About
A full voice call with an AI agent: an orb that listens, thinks and speaks, live captions for both sides, interrupting the agent mid reply, mute and device menus, and a switch to text at any point.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Voice Session to your project and fits it to your theme.
Code
How Voice Session is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { ChatcnLevel, TranscriptSegment, VoiceSession, createChatcnSpeechLevel, readLevel } from "@/components/ui/voice-session"
/** One step of a scripted conversation. Give `user`, `assistant`, or both. */
interface ChatcnVoiceScriptTurn {
/** What the user says. Spoken word by word with interim text, then settled. */
user?: string
/** What the agent says in reply. */
assistant?: string
/** The user cuts in after this many words of `assistant`. The next turn's `user` is the barge-in. */
interruptAfter?: number
/** Silence before this turn, in ms. Default 600. */
pause?: number
}
interface UseChatcnVoiceMockOptions {
script: ChatcnVoiceScriptTurn[]
/** Reply to typed messages once the script runs out of assistant turns. */
fallbackReply?: string | ((text: string) => string)
/** Start connecting on mount. Default false. */
autoStart?: boolean
/** Connection time in ms. Default 1400. */
connectMs?: number
/** Time to first word after the user stops, in ms. Default 900. */
thinkingMs?: number
/** Words per second while the user speaks. Default 2.8. */
userRate?: number
/** Words per second while the agent speaks. Default 3.1. */
assistantRate?: number
/** A real input level, for example a microphone meter. Replaces the simulated user voice. */
inputLevel?: ChatcnLevel
}
/* -------------------------------------------------------------------------------------------------
* Types
* -----------------------------------------------------------------------------------------------*/
/** Every state a voice session moves through. The orb's states plus the connection's own. */
type ChatcnVoiceSessionState =
| "idle"
| "connecting"
| "listening"
| "thinking"
| "speaking"
| "interrupted"
| "muted"
| "reconnecting"
| "error"
| "ended"
interface UseChatcnVoiceMockResult {
state: ChatcnVoiceSessionState
segments: TranscriptSegment[]
/** Epoch ms when the call connected. Undefined before. */
startedAt: number | undefined
/** Epoch ms when the call ended. */
endedAt: number | undefined
/** The user's level: simulated while they speak, or the real `inputLevel`. */
getInputLevel: () => number
/** The agent's level while it speaks. */
getOutputLevel: () => number
muted: boolean
setMuted: (muted: boolean) => void
/** Connect and play the script from the top. */
start: () => void
/** Hang up. The transcript stays. */
end: () => void
/** Send a typed message. Interrupts the agent if it is speaking, then replies. */
sendText: (text: string) => void
/** Barge in by voice: cut the agent off where it is. */
interrupt: () => void
/** Drop the connection, to show the error state. */
fail: () => void
/** Reconnect after an error, keeping the transcript. */
reconnect: () => void
}
/* -------------------------------------------------------------------------------------------------
* Engine
* -----------------------------------------------------------------------------------------------*/
const CANCELLED = Symbol("cancelled")
function words(text: string) {
return text.split(/(?<=\s)/)
}
/**
* A scripted voice engine for demos, tests and design work. It drives the same props a realtime
* provider would: session state, transcript segments with interim text, input and output levels,
* barge-in, typed messages mid-call, mute, drop and reconnect. Spread it into `VoiceSession`.
*
* Swap it for a provider adapter later; the components never know the difference.
*/
function useChatcnVoiceMock(options: UseChatcnVoiceMockOptions): UseChatcnVoiceMockResult {
const opts = React.useRef(options)
opts.current = options
const [state, setState] = React.useState<ChatcnVoiceSessionState>("idle")
const [segments, setSegments] = React.useState<TranscriptSegment[]>([])
const [startedAt, setStartedAt] = React.useState<number>()
const [endedAt, setEndedAt] = React.useState<number>()
const [muted, setMutedState] = React.useState(false)
const [user] = React.useState(() => createChatcnSpeechLevel({ seed: 3 }))
const [agent] = React.useState(() => createChatcnSpeechLevel({ seed: 7, loudness: 0.85 }))
const e = React.useRef({
token: { cancelled: false },
segments: [] as TranscriptSegment[],
cursor: 0,
startedAt: 0,
muted: false,
speakingId: undefined as string | undefined,
speakingRest: "",
nextId: 0,
})
const publish = React.useCallback(() => setSegments([...e.current.segments]), [])
const patch = React.useCallback(
(id: string, change: Partial<TranscriptSegment>) => {
e.current.segments = e.current.segments.map((s) => (s.id === id ? { ...s, ...change } : s))
publish()
},
[publish],
)
const add = React.useCallback(
(segment: Omit<TranscriptSegment, "id" | "at">) => {
const id = `seg-${e.current.nextId++}`
const at = e.current.startedAt ? Date.now() - e.current.startedAt : 0
e.current.segments = [...e.current.segments, { id, at, ...segment }]
publish()
return id
},
[publish],
)
const cancel = React.useCallback(() => {
e.current.token.cancelled = true
e.current.token = { cancelled: false }
user.setSpeaking(false)
agent.setSpeaking(false)
return e.current.token
}, [user, agent])
/** Waits, then throws if this run was cancelled in the meantime. */
const stepper = (token: { cancelled: boolean }) => async (ms: number) => {
await new Promise((resolve) => window.setTimeout(resolve, ms))
if (token.cancelled) throw CANCELLED
}
const userSays = React.useCallback(
async (text: string, step: (ms: number) => Promise<void>) => {
// Muted: wait until the user unmutes.
while (e.current.muted) await step(200)
setState("listening")
await step(350)
user.setSpeaking(true)
const id = add({ role: "user", text: "", interim: "" })
const parts = words(text)
const rate = opts.current.userRate ?? 2.8
let settled = ""
let pending = ""
for (let i = 0; i < parts.length; i++) {
await step(1000 / rate)
pending += parts[i]
// The recognizer settles a few words at a time, the way real interim results behave.
if ((i + 1) % 4 === 0) {
settled += pending
pending = ""
}
patch(id, { text: settled, interim: pending })
}
user.setSpeaking(false)
await step(250)
patch(id, { text: text.trim(), interim: undefined })
},
[add, patch, user],
)
const agentSays = React.useCallback(
async (text: string, step: (ms: number) => Promise<void>, interruptAfter?: number) => {
setState("thinking")
await step(opts.current.thinkingMs ?? 900)
setState("speaking")
agent.setSpeaking(true)
const id = add({ role: "assistant", text: "", live: true })
const parts = words(text)
const rate = opts.current.assistantRate ?? 3.1
let spoken = ""
e.current.speakingId = id
for (let i = 0; i < parts.length; i++) {
spoken += parts[i]
e.current.speakingRest = parts.slice(i + 1).join("")
patch(id, { text: spoken })
if (interruptAfter !== undefined && i + 1 >= interruptAfter) {
// Barge-in: the user starts talking over the reply.
agent.setSpeaking(false)
e.current.speakingId = undefined
patch(id, {
text: spoken.trimEnd(),
live: false,
interrupted: true,
unspoken: e.current.speakingRest.trim() || undefined,
})
setState("interrupted")
await step(650)
return
}
await step(1000 / rate)
}
agent.setSpeaking(false)
e.current.speakingId = undefined
patch(id, { text: text.trim(), live: false })
},
[add, patch, agent],
)
/** Plays the script from the cursor, then waits in listening. */
const play = React.useCallback(
async (token: { cancelled: boolean }) => {
const step = stepper(token)
try {
const script = opts.current.script
while (e.current.cursor < script.length) {
const turn = script[e.current.cursor] as ChatcnVoiceScriptTurn
e.current.cursor++
await step(turn.pause ?? 600)
if (turn.user) await userSays(turn.user, step)
if (turn.assistant) await agentSays(turn.assistant, step, turn.interruptAfter)
}
setState(e.current.muted ? "muted" : "listening")
} catch (err) {
if (err !== CANCELLED) throw err
}
},
[userSays, agentSays],
)
const connect = React.useCallback(
async (token: { cancelled: boolean }, as: "connecting" | "reconnecting") => {
const step = stepper(token)
setState(as)
try {
await step(opts.current.connectMs ?? 1400)
} catch {
return false
}
if (as === "connecting") {
const now = Date.now()
e.current.startedAt = now
setStartedAt(now)
}
return true
},
[],
)
const start = React.useCallback(() => {
const token = cancel()
e.current.segments = []
e.current.cursor = 0
publish()
setEndedAt(undefined)
setStartedAt(undefined)
void connect(token, "connecting").then((ok) => {
if (ok) void play(token)
})
}, [cancel, connect, play, publish])
/** Cuts the agent off where it is, keeping what it said. */
const cutOff = React.useCallback(() => {
const id = e.current.speakingId
if (!id) return false
const seg = e.current.segments.find((s) => s.id === id)
patch(id, {
text: seg?.text.trimEnd() ?? "",
live: false,
interrupted: true,
unspoken: e.current.speakingRest.trim() || undefined,
})
e.current.speakingId = undefined
return true
}, [patch])
const interrupt = React.useCallback(() => {
const token = cancel()
if (!cutOff()) return
setState("interrupted")
window.setTimeout(() => {
if (!token.cancelled) setState(e.current.muted ? "muted" : "listening")
}, 650)
}, [cancel, cutOff])
const sendText = React.useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed) return
const token = cancel()
cutOff()
add({ role: "user", text: trimmed, channel: "text" })
const step = stepper(token)
// Reply with the next scripted assistant turn, or the fallback once the script runs out.
const script = opts.current.script
let reply: string | undefined
while (e.current.cursor < script.length && reply === undefined) {
reply = script[e.current.cursor]?.assistant
e.current.cursor++
}
if (reply === undefined) {
const fb = opts.current.fallbackReply
reply =
typeof fb === "function" ? fb(trimmed) : (fb ?? "Got it. I'll take that into account.")
}
const answer = reply
void (async () => {
try {
await agentSays(answer, step)
setState(e.current.muted ? "muted" : "listening")
} catch (err) {
if (err !== CANCELLED) throw err
}
})()
},
[add, agentSays, cancel, cutOff],
)
const end = React.useCallback(() => {
cancel()
cutOff()
setState("ended")
setEndedAt(Date.now())
}, [cancel, cutOff])
const fail = React.useCallback(() => {
cancel()
cutOff()
setState("error")
}, [cancel, cutOff])
const reconnect = React.useCallback(() => {
const token = cancel()
void connect(token, "reconnecting").then((ok) => {
if (ok) void play(token)
})
}, [cancel, connect, play])
const setMuted = React.useCallback((next: boolean) => {
e.current.muted = next
setMutedState(next)
setState((s) =>
next && s === "listening" ? "muted" : !next && s === "muted" ? "listening" : s,
)
}, [])
const getInputLevel = React.useCallback(() => {
if (e.current.muted) return 0
const real = opts.current.inputLevel
return real !== undefined ? readLevel(real) : user.getLevel()
}, [user])
const getOutputLevel = agent.getLevel
React.useEffect(() => {
if (opts.current.autoStart) start()
return () => {
e.current.token.cancelled = true
}
}, [start])
return {
state,
segments,
startedAt,
endedAt,
getInputLevel,
getOutputLevel,
muted,
setMuted,
start,
end,
sendText,
interrupt,
fail,
reconnect,
}
}
const SCRIPT: ChatcnVoiceScriptTurn[] = [
{
user: "Why did the preview deploy fail?",
assistant:
"It failed at the build step. Next could not resolve lib/stripe/client, because your last commit renamed that file to stripe-client. Two imports still point at the old path, one in the checkout page and one in the webhook route. I can update both, run the type check, and then",
interruptAfter: 30,
},
{
user: "Just fix the imports and redeploy.",
assistant:
"On it. I'm updating both imports now, then I'll push and start a new preview build. It usually takes about two minutes.",
pause: 200,
},
]
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
const DEVICES = {
microphones: [
{ id: "default", label: "Built-in microphone", isDefault: true },
{ id: "earbuds", label: "Wireless earbuds" },
],
speakers: [
{ id: "default", label: "Built-in speakers", isDefault: true },
{ id: "earbuds", label: "Wireless earbuds" },
],
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/**
* A coding agent on a voice call: the user asks why a preview deploy failed, the orb listens and
* answers with live captions, the user cuts it off mid reply and it adapts. The call starts again
* when it ends. Runs on the mock engine, so no microphone is needed. Switch to text at any point.
*/
export default function VoiceSessionPreview() {
const call = useChatcnVoiceMock({
script: SCRIPT,
autoStart: true,
// Quicker than a real call, so a short preview reaches the agent's answer.
connectMs: 500,
thinkingMs: 500,
userRate: 4.5,
assistantRate: 4,
fallbackReply:
"Noted. I'll add that to the deploy notes and keep the preview running while the build finishes.",
})
const { state, start } = call
React.useEffect(() => {
if (state !== "ended" || reducedMotion()) return
const id = window.setTimeout(start, 1800)
return () => window.clearTimeout(id)
}, [state, start])
return (
<FitFrame minWidth="46rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<VoiceSession
title="Coding agent"
state={call.state}
segments={call.segments}
inputLevel={call.getInputLevel}
outputLevel={call.getOutputLevel}
startedAt={call.startedAt}
endedAt={call.endedAt}
muted={call.muted}
onMutedChange={call.setMuted}
onStart={call.start}
onEnd={call.end}
onReconnect={call.reconnect}
onInterrupt={call.interrupt}
onSendText={call.sendText}
controls={{ ...DEVICES, microphone: "default", speaker: "default" }}
className="rounded-(--chatcn-radius-2xl) shadow-(--chatcn-shadow-sm) h-[27rem] w-full max-w-2xl"
/>
<MoreOn slug="voice-session">A full voice call with captions, interruptions and text mode. On</MoreOn>
</div>
</FitFrame>
)
}








