Recipes
Voice call
A realtime call screen: connection status and timer, the orb, live captions, and mute, interrupt and end controls.
Assistant
Connecting
Install
pnpm dlx shadcn@latest add @jds/voice-call
Installs the recipe and every component it uses into your project, ready to run.
Source
components/voice-call/voice-call.tsx
"use client"
import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button"
import {
CallControls,
CallEnd,
CallInterrupt,
CallMute,
CallStatus,
type CallState,
} from "@/components/voice/call-controls"
import { VoiceOrb, type VoiceState } from "@/components/voice/voice-orb"
import { useSimulatedSpectrum } from "@/hooks/use-simulated-spectrum"
import { duration, ease } from "@/lib/motion"
/** A scripted call. In a real app these come from your realtime voice session. */
const script: { speaker: "user" | "agent"; text: string }[] = [
{ speaker: "agent", text: "Hi Sam, you've got two meetings this afternoon. Want a quick rundown?" },
{ speaker: "user", text: "Yes, and push anything after four to tomorrow." },
{ speaker: "agent", text: "Done. The design review stays at two. I moved the call with Luca to tomorrow at 9:30." },
{ speaker: "user", text: "Perfect, thanks." },
{ speaker: "agent", text: "Anytime. I'll send a summary to your inbox." },
]
export default function VoiceCall() {
const [call, setCall] = React.useState<CallState>("connecting")
const [startedAt, setStartedAt] = React.useState<number>()
const [line, setLine] = React.useState(-1)
const [words, setWords] = React.useState(0)
const [muted, setMuted] = React.useState(false)
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
const current = script[line]
const speaking = current && words < current.text.split(" ").length
const orbState: VoiceState =
call !== "connected"
? "idle"
: !current
? "listening"
: current.speaker === "agent"
? speaking
? "speaking"
: "listening"
: speaking
? "listening"
: "thinking"
const { level } = useSimulatedSpectrum(orbState === "speaking" || (orbState === "listening" && !!speaking && !muted))
const clear = () => {
timers.current.forEach(clearTimeout)
timers.current = []
}
React.useEffect(() => clear, [])
// Connect, then play the script one word at a time.
React.useEffect(() => {
if (call !== "connecting") return
const id = setTimeout(() => {
setCall("connected")
setStartedAt(Date.now())
setLine(0)
setWords(0)
}, 1400)
return () => clearTimeout(id)
}, [call])
React.useEffect(() => {
if (call !== "connected" || !current) return
const total = current.text.split(" ").length
const id = setTimeout(
() => {
if (words < total) setWords((w) => w + 1)
else if (line < script.length - 1) {
setLine((l) => l + 1)
setWords(0)
}
},
words < total ? 190 : 900,
)
timers.current.push(id)
return () => clearTimeout(id)
}, [call, current, words, line])
const interrupt = () => {
// Cut the agent off: show what was said, then hand the turn to the user.
if (current?.speaker !== "agent") return
setWords(current.text.split(" ").length)
}
const restart = () => {
clear()
setCall("connecting")
setLine(-1)
setWords(0)
}
const caption = current ? current.text.split(" ").slice(0, words).join(" ") : ""
const previous = line > 0 ? script[line - 1] : undefined
return (
<div className="relative flex h-160 w-full flex-col items-center justify-between overflow-hidden rounded-2xl border bg-background px-6 py-8">
<div className="flex flex-col items-center gap-1 text-center">
<span className="text-sm font-medium">Assistant</span>
<CallStatus state={call} startedAt={startedAt} />
</div>
<VoiceOrb state={orbState} level={level} size={220} />
<div className="flex h-24 w-full max-w-md flex-col items-center justify-end gap-2 text-center" aria-live="polite">
{call === "ended" ? (
<p className="text-sm text-muted-foreground">Call ended. A summary is on its way.</p>
) : (
<>
{previous && <p className="line-clamp-1 text-sm text-muted-foreground/60">{previous.text}</p>}
<AnimatePresence mode="popLayout">
{current && (
<motion.p
key={line}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: duration.base, ease: ease.out }}
className={current.speaker === "agent" ? "text-base" : "text-base text-muted-foreground"}
>
{caption}
</motion.p>
)}
</AnimatePresence>
</>
)}
</div>
{call === "ended" ? (
<Button variant="outline" onClick={restart}>
Call again
</Button>
) : (
<CallControls>
<CallMute pressed={muted} onPressedChange={setMuted} />
<CallInterrupt disabled={current?.speaker !== "agent" || !speaking} onClick={interrupt} />
<CallEnd
onClick={() => {
clear()
setCall("ended")
}}
/>
</CallControls>
)}
</div>
)
}hooks/use-simulated-spectrum.ts
"use client"
import * as React from "react"
/** Speech-like spectrum for agent playback, demos, and users who decline mic access. */
export function useSimulatedSpectrum(active: boolean, bands = 24) {
const [spectrum, setSpectrum] = React.useState<number[]>(() => Array(bands).fill(0))
React.useEffect(() => {
if (!active) return
let raf = 0
const tick = (t: number) => {
setSpectrum(
Array.from({ length: bands }, (_, i) => {
const center = 1 - Math.abs(i - bands / 2) / (bands / 2)
const wave = (Math.sin(t / 140 + i * 0.7) + Math.sin(t / 90 + i * 1.3)) / 4 + 0.5
return Math.max(0, center * wave * (0.6 + Math.random() * 0.4))
})
)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
setSpectrum(Array(bands).fill(0))
}
}, [active, bands])
const level = Math.min(1, (spectrum.reduce((a, b) => a + b, 0) / bands) * 2.2)
return { spectrum, level }
}