Aadi

Voice Session

Component 1

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>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Run Inbox

    Run Inbox

    1

Explore more

Part of chatcn — browse the full library on 21st.dev.