"use client";
import * as React from "react";
import {
MicrophoneIcon,
PhoneXMarkIcon,
SparklesIcon,
SpeakerWaveIcon,
SpeakerXMarkIcon,
UserIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { Thread, ThreadContent } from "@/components/pandacoderz-ui/thread";
import { Waveform } from "@/components/pandacoderz-ui/waveform";
import { sleep, streamText, uid } from "@/lib/mock-stream";
type VoiceState = "idle" | "listening" | "thinking" | "speaking";
type Turn = { id: string; role: "user" | "assistant"; text: string; partial?: boolean };
const script: { user: string; assistant: string }[] = [
{ user: "Hey, what's on my calendar this afternoon?", assistant: "You have two things. A design review at two, and a thirty minute call with the platform team at four. There is a gap between them if you want time to prep." },
{ user: "Move the platform call to tomorrow morning.", assistant: "Done. The platform call is now tomorrow at nine thirty. I sent the update to the three attendees and kept the original notes attached." },
{ user: "Thanks, that's all.", assistant: "You're welcome. I'll stay quiet until you need me." },
];
const stateLabel: Record<VoiceState, string> = {
idle: "Tap to talk",
listening: "Listening…",
thinking: "Thinking…",
speaking: "Speaking · tap to interrupt",
};
export type VoiceChatProps = { className?: string; title?: string };
export default function VoiceChat({ className, title = "Voice assistant" }: VoiceChatProps) {
const [state, setState] = React.useState<VoiceState>("idle");
const [turns, setTurns] = React.useState<Turn[]>([]);
const [muted, setMuted] = React.useState(false);
const [turnIndex, setTurnIndex] = React.useState(0);
const abortRef = React.useRef<AbortController | null>(null);
const upsert = (turn: Turn) =>
setTurns((prev) => (prev.some((t) => t.id === turn.id) ? prev.map((t) => (t.id === turn.id ? turn : t)) : [...prev, turn]));
const cancel = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setTurns((prev) => prev.map((t) => ({ ...t, partial: false })));
}, []);
const runTurn = React.useCallback(async () => {
cancel();
const controller = new AbortController();
abortRef.current = controller;
const line = script[turnIndex % script.length];
setTurnIndex((i) => i + 1);
const userId = uid("u");
const assistantId = uid("a");
try {
setState("listening");
await sleep(500, controller.signal);
let heard = "";
for await (const piece of streamText(line.user, controller.signal, 55)) {
heard += piece;
upsert({ id: userId, role: "user", text: heard, partial: true });
}
upsert({ id: userId, role: "user", text: line.user });
setState("thinking");
await sleep(800, controller.signal);
setState("speaking");
let said = "";
for await (const piece of streamText(line.assistant, controller.signal, 45)) {
said += piece;
upsert({ id: assistantId, role: "assistant", text: said, partial: true });
}
upsert({ id: assistantId, role: "assistant", text: line.assistant });
setState("idle");
} catch {
/* interrupted */
} finally {
if (abortRef.current === controller) abortRef.current = null;
}
}, [cancel, turnIndex]);
const onMainButton = () => {
if (state === "idle") void runTurn();
else if (state === "speaking") void runTurn(); // barge-in: cut speech, start listening
else {
cancel();
setState("idle");
}
};
const endCall = () => {
cancel();
setState("idle");
setTurns([]);
setTurnIndex(0);
};
React.useEffect(() => () => abortRef.current?.abort(), []);
const speaking = state === "speaking";
const listening = state === "listening";
return (
<div data-slot="voice-chat" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><SparklesIcon size={14} className="flex" /></span>
{title}
</div>
<span className={cn("flex items-center gap-1.5 text-xs text-muted-foreground")}>
<span className={cn("size-1.5 rounded-full", state === "idle" ? "bg-muted-foreground/40" : "bg-emerald-500")} />
{state === "idle" ? "Ready" : "Live"}
</span>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-[1fr_minmax(0,22rem)]">
<div className="flex flex-col items-center justify-center gap-8 px-6 py-10">
<div className="relative flex items-center justify-center">
<span className={cn("absolute size-44 rounded-full bg-brand/10 transition-all duration-700", (listening || speaking) && "scale-125 bg-brand/15", state === "thinking" && "animate-pulse")} />
<span className={cn("absolute size-32 rounded-full bg-brand/15 transition-all duration-500", (listening || speaking) && "scale-110")} />
<button
type="button"
onClick={onMainButton}
aria-label={stateLabel[state]}
className={cn(
"relative z-10 flex size-24 items-center justify-center rounded-full text-primary-foreground shadow-modal transition-transform active:scale-95",
speaking ? "bg-foreground text-background" : "bg-brand",
)}
>
{speaking ? <SpeakerWaveIcon size={30} className="flex" /> : <MicrophoneIcon size={30} className="flex" />}
</button>
</div>
<Waveform active={listening || speaking} bars={36} height={44} className={cn("w-full max-w-xs", speaking ? "text-foreground" : "text-brand")} />
<div className="h-5 text-sm text-muted-foreground">
{state === "thinking" ? <TextShimmer invertLight>{stateLabel[state]}</TextShimmer> : stateLabel[state]}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="rounded-full" onClick={() => setMuted((m) => !m)} aria-pressed={muted}>
{muted ? <SpeakerXMarkIcon size={14} className="flex" /> : <SpeakerWaveIcon size={14} className="flex" />}
{muted ? "Unmute" : "Mute"}
</Button>
<Button variant="destructive" size="sm" className="rounded-full" onClick={endCall} disabled={turns.length === 0 && state === "idle"}>
<PhoneXMarkIcon size={14} className="flex" /> End
</Button>
</div>
</div>
<aside className="flex min-h-0 flex-col border-t md:border-t-0 md:border-l">
<div className="flex h-10 shrink-0 items-center px-4 text-xs font-medium text-muted-foreground">Transcript</div>
{turns.length === 0 ? (
<p className="px-4 text-sm text-muted-foreground">Your conversation will appear here as it happens.</p>
) : (
<Thread className="min-h-0 flex-1">
<ThreadContent className="gap-4 px-4 pt-0">
{turns.map((t) => (
<div key={t.id} className="flex gap-2.5">
<span className={cn("mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full", t.role === "user" ? "bg-muted text-foreground" : "bg-brand-soft text-brand")}>
{t.role === "user" ? <UserIcon size={12} className="flex" /> : <SparklesIcon size={12} className="flex" />}
</span>
<p className={cn("text-sm leading-6", t.partial && "text-muted-foreground")}>
{t.text}
{t.partial ? <span className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-0.5 animate-pulse bg-current" /> : null}
</p>
</div>
))}
</ThreadContent>
</Thread>
)}
</aside>
</div>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;What’s inside
- Waveform animates while listening or speaking; pass real
levelsfrom an AnalyserNode to follow the microphone. - Thread keeps the transcript pinned to the newest turn.
- Barge-in lets a tap during speech cut the reply and start listening again.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/voice-chat.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/voice-chat.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/voice-chat.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/voice-chat.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import {
MicrophoneIcon,
PhoneXMarkIcon,
SparklesIcon,
SpeakerWaveIcon,
SpeakerXMarkIcon,
UserIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { Thread, ThreadContent } from "@/components/pandacoderz-ui/thread";
import { Waveform } from "@/components/pandacoderz-ui/waveform";
import { sleep, streamText, uid } from "@/lib/mock-stream";
type VoiceState = "idle" | "listening" | "thinking" | "speaking";
type Turn = { id: string; role: "user" | "assistant"; text: string; partial?: boolean };
const script: { user: string; assistant: string }[] = [
{ user: "Hey, what's on my calendar this afternoon?", assistant: "You have two things. A design review at two, and a thirty minute call with the platform team at four. There is a gap between them if you want time to prep." },
{ user: "Move the platform call to tomorrow morning.", assistant: "Done. The platform call is now tomorrow at nine thirty. I sent the update to the three attendees and kept the original notes attached." },
{ user: "Thanks, that's all.", assistant: "You're welcome. I'll stay quiet until you need me." },
];
const stateLabel: Record<VoiceState, string> = {
idle: "Tap to talk",
listening: "Listening…",
thinking: "Thinking…",
speaking: "Speaking · tap to interrupt",
};
export type VoiceChatProps = { className?: string; title?: string };
export default function VoiceChat({ className, title = "Voice assistant" }: VoiceChatProps) {
const [state, setState] = React.useState<VoiceState>("idle");
const [turns, setTurns] = React.useState<Turn[]>([]);
const [muted, setMuted] = React.useState(false);
const [turnIndex, setTurnIndex] = React.useState(0);
const abortRef = React.useRef<AbortController | null>(null);
const upsert = (turn: Turn) =>
setTurns((prev) => (prev.some((t) => t.id === turn.id) ? prev.map((t) => (t.id === turn.id ? turn : t)) : [...prev, turn]));
const cancel = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setTurns((prev) => prev.map((t) => ({ ...t, partial: false })));
}, []);
const runTurn = React.useCallback(async () => {
cancel();
const controller = new AbortController();
abortRef.current = controller;
const line = script[turnIndex % script.length];
setTurnIndex((i) => i + 1);
const userId = uid("u");
const assistantId = uid("a");
try {
setState("listening");
await sleep(500, controller.signal);
let heard = "";
for await (const piece of streamText(line.user, controller.signal, 55)) {
heard += piece;
upsert({ id: userId, role: "user", text: heard, partial: true });
}
upsert({ id: userId, role: "user", text: line.user });
setState("thinking");
await sleep(800, controller.signal);
setState("speaking");
let said = "";
for await (const piece of streamText(line.assistant, controller.signal, 45)) {
said += piece;
upsert({ id: assistantId, role: "assistant", text: said, partial: true });
}
upsert({ id: assistantId, role: "assistant", text: line.assistant });
setState("idle");
} catch {
/* interrupted */
} finally {
if (abortRef.current === controller) abortRef.current = null;
}
}, [cancel, turnIndex]);
const onMainButton = () => {
if (state === "idle") void runTurn();
else if (state === "speaking") void runTurn(); // barge-in: cut speech, start listening
else {
cancel();
setState("idle");
}
};
const endCall = () => {
cancel();
setState("idle");
setTurns([]);
setTurnIndex(0);
};
React.useEffect(() => () => abortRef.current?.abort(), []);
const speaking = state === "speaking";
const listening = state === "listening";
return (
<div data-slot="voice-chat" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><SparklesIcon size={14} className="flex" /></span>
{title}
</div>
<span className={cn("flex items-center gap-1.5 text-xs text-muted-foreground")}>
<span className={cn("size-1.5 rounded-full", state === "idle" ? "bg-muted-foreground/40" : "bg-emerald-500")} />
{state === "idle" ? "Ready" : "Live"}
</span>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-[1fr_minmax(0,22rem)]">
<div className="flex flex-col items-center justify-center gap-8 px-6 py-10">
<div className="relative flex items-center justify-center">
<span className={cn("absolute size-44 rounded-full bg-brand/10 transition-all duration-700", (listening || speaking) && "scale-125 bg-brand/15", state === "thinking" && "animate-pulse")} />
<span className={cn("absolute size-32 rounded-full bg-brand/15 transition-all duration-500", (listening || speaking) && "scale-110")} />
<button
type="button"
onClick={onMainButton}
aria-label={stateLabel[state]}
className={cn(
"relative z-10 flex size-24 items-center justify-center rounded-full text-primary-foreground shadow-modal transition-transform active:scale-95",
speaking ? "bg-foreground text-background" : "bg-brand",
)}
>
{speaking ? <SpeakerWaveIcon size={30} className="flex" /> : <MicrophoneIcon size={30} className="flex" />}
</button>
</div>
<Waveform active={listening || speaking} bars={36} height={44} className={cn("w-full max-w-xs", speaking ? "text-foreground" : "text-brand")} />
<div className="h-5 text-sm text-muted-foreground">
{state === "thinking" ? <TextShimmer invertLight>{stateLabel[state]}</TextShimmer> : stateLabel[state]}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="rounded-full" onClick={() => setMuted((m) => !m)} aria-pressed={muted}>
{muted ? <SpeakerXMarkIcon size={14} className="flex" /> : <SpeakerWaveIcon size={14} className="flex" />}
{muted ? "Unmute" : "Mute"}
</Button>
<Button variant="destructive" size="sm" className="rounded-full" onClick={endCall} disabled={turns.length === 0 && state === "idle"}>
<PhoneXMarkIcon size={14} className="flex" /> End
</Button>
</div>
</div>
<aside className="flex min-h-0 flex-col border-t md:border-t-0 md:border-l">
<div className="flex h-10 shrink-0 items-center px-4 text-xs font-medium text-muted-foreground">Transcript</div>
{turns.length === 0 ? (
<p className="px-4 text-sm text-muted-foreground">Your conversation will appear here as it happens.</p>
) : (
<Thread className="min-h-0 flex-1">
<ThreadContent className="gap-4 px-4 pt-0">
{turns.map((t) => (
<div key={t.id} className="flex gap-2.5">
<span className={cn("mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full", t.role === "user" ? "bg-muted text-foreground" : "bg-brand-soft text-brand")}>
{t.role === "user" ? <UserIcon size={12} className="flex" /> : <SparklesIcon size={12} className="flex" />}
</span>
<p className={cn("text-sm leading-6", t.partial && "text-muted-foreground")}>
{t.text}
{t.partial ? <span className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-0.5 animate-pulse bg-current" /> : null}
</p>
</div>
))}
</ThreadContent>
</Thread>
)}
</aside>
</div>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;The registry item pulls in every component it depends on.
Usage
import VoiceChat from "@/components/blocks/voice-chat/voice-chat";
export default function Page() {
return (
<div className="h-dvh p-4">
<VoiceChat />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Replace runTurn with your realtime pipeline: capture audio, stream a transcript into the user turn, then stream the reply text while playing audio. The state machine (idle, listening, thinking, speaking) stays the same.