Voice Conversation New
Realtime voice mode with an orb, live captions for both sides, interrupt, mute, end call and a latency badge.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-voice-conversation.json02
Or copy the source below (3 files). Dependencies: lucide-react.
Source
components/niceui/ai/io/VoiceConversation.tsx
import { useEffect, useMemo, useState } from 'react';
import { Hand, Mic, MicOff, PhoneOff, Phone } from 'lucide-react';
import { cn } from '@/lib/cn';
import { VoiceOrb, type OrbState } from '../VoiceOrb';
import { prefersReducedMotion, useTicker } from './shared';
type Turn = { who: 'user' | 'assistant'; text: string };
const SCRIPT: Turn[] = [
{ who: 'user', text: 'Can you move my 3pm with Priya to tomorrow morning?' },
{ who: 'assistant', text: 'Sure. Tomorrow at 9:30 is free for both of you. Want me to send the update with a short note?' },
{ who: 'user', text: 'Yes, and mention the new pricing deck.' },
{ who: 'assistant', text: 'Done. I moved it to 9:30 and attached the pricing deck to the invite.' },
];
// Timeline: each turn speaks for its word count at ~3.4 words/s, with short gaps.
function useTimeline() {
return useMemo(() => {
let at = 0.6;
return SCRIPT.map((turn) => {
const dur = turn.text.split(' ').length / 3.4;
const t = { ...turn, start: at, end: at + dur };
at += dur + (turn.who === 'user' ? 0.9 : 0.6);
return t;
});
}, []);
}
export type VoiceConversationProps = { className?: string };
/** Realtime voice mode: orb, live captions for both sides, interrupt, mute and end call. */
export function VoiceConversation({ className }: VoiceConversationProps) {
const timeline = useTimeline();
const total = timeline[timeline.length - 1].end + 1.2;
const [live, setLive] = useState(true);
const [muted, setMuted] = useState(false);
const [cut, setCut] = useState<number | null>(null);
const [t, reset] = useTicker(live && !prefersReducedMotion(), 20);
const now = prefersReducedMotion() ? total : t;
useEffect(() => {
if (live && t > total + 2) reset();
}, [t, total, live, reset]);
const idx = timeline.findIndex((x) => now >= x.start && now < x.end);
const current = idx >= 0 ? timeline[idx] : null;
const thinking = !current && timeline.some((x, i) => x.who === 'assistant' && now >= (timeline[i - 1]?.end ?? 0) && now < x.start);
const state: OrbState = !live ? 'idle' : current?.who === 'assistant' ? 'speaking' : current?.who === 'user' ? (muted ? 'idle' : 'listening') : thinking ? 'thinking' : 'idle';
const shown = timeline
.filter((x) => now >= x.start)
.map((x, i) => {
const words = x.text.split(' ');
const interrupted = cut !== null && x.who === 'assistant' && cut >= x.start && cut < x.end;
const limit = interrupted ? (cut! - x.start) * 3.4 : (now - x.start) * 3.4;
return { ...x, i, text: words.slice(0, Math.max(1, Math.ceil(limit))).join(' '), done: now >= x.end || interrupted, interrupted };
})
.slice(-3);
const interrupt = () => {
if (current?.who === 'assistant') setCut(now);
};
return (
<div className={cn('relative w-full max-w-md overflow-hidden rounded-3xl bg-ink-900 shadow-card', className)}>
<div className="flex items-center justify-between px-4 pt-4">
<span className="flex items-center gap-1.5 rounded-full bg-white/5 px-2.5 py-1 text-[11px] text-ink-300">
<span className={cn('h-1.5 w-1.5 rounded-full', live ? 'bg-emerald-400' : 'bg-ink-500')} />
{live ? 'Live' : 'Call ended'}
</span>
<span className="font-mono text-[11px] tabular-nums text-ink-500">{live ? '182 ms' : '—'} latency</span>
</div>
<div className="flex justify-center py-6">
<VoiceOrb state={state} size={150} />
</div>
<p className="-mt-2 text-center text-xs text-ink-400">
{!live ? 'Tap call to start again' : state === 'speaking' ? 'Speaking…' : state === 'listening' ? 'Listening…' : state === 'thinking' ? 'Thinking…' : muted ? 'You are muted' : 'Go ahead'}
</p>
<div className="mx-4 mt-4 min-h-[132px] space-y-2.5" aria-live="polite">
{shown.map((x) => (
<div key={x.i} className={cn('flex animate-fade-up', x.who === 'user' ? 'justify-end' : 'justify-start')}>
<p
className={cn(
'max-w-[85%] rounded-2xl px-3.5 py-2 text-[13.5px] leading-snug',
x.who === 'user' ? 'rounded-br-md bg-brand-500/15 text-brand-100' : 'rounded-bl-md bg-white/[0.04] text-ink-200',
!x.done && 'ring-1 ring-white/10'
)}
>
{x.text}
{x.interrupted && <span className="ml-1 text-[11px] text-ink-500">· interrupted</span>}
</p>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-center gap-3 border-t border-white/5 p-4">
<button onClick={() => setMuted((m) => !m)} aria-pressed={muted} aria-label={muted ? 'Unmute' : 'Mute'} className={cn('focus-ring flex h-11 w-11 items-center justify-center rounded-full transition-colors', muted ? 'bg-rose-500/15 text-rose-300' : 'bg-white/5 text-ink-200 hover:bg-white/10')}>
{muted ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
</button>
<button onClick={interrupt} disabled={current?.who !== 'assistant'} className="focus-ring flex h-11 items-center gap-2 rounded-full bg-white/5 px-4 text-sm text-ink-200 transition-colors hover:bg-white/10 disabled:opacity-40">
<Hand className="h-4 w-4" /> Interrupt
</button>
<button
onClick={() => {
if (live) setLive(false);
else {
setCut(null);
reset();
setLive(true);
}
}}
aria-label={live ? 'End call' : 'Start call'}
className={cn('focus-ring flex h-11 w-11 items-center justify-center rounded-full transition-colors', live ? 'bg-rose-500 text-white hover:bg-rose-400' : 'bg-emerald-500 text-ink-950 hover:bg-emerald-400')}
>
{live ? <PhoneOff className="h-4 w-4" /> : <Phone className="h-4 w-4" />}
</button>
</div>
</div>
);
}
export function VoiceConversationDemo() {
return <VoiceConversation />;
}components/niceui/ai/VoiceOrb.tsx
import { useEffect, useRef, useState } from 'react';
import { Brain, Circle, Mic, Volume2 } from 'lucide-react';
import { cn } from '@/lib/cn';
export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking';
const CONFIG: Record<OrbState, { base: number; amp: number; speed: number }> = {
idle: { base: 0.02, amp: 0.03, speed: 0.4 },
listening: { base: 0.06, amp: 0.16, speed: 1.2 },
thinking: { base: 0.03, amp: 0.04, speed: 3.6 },
speaking: { base: 0.1, amp: 0.26, speed: 1.8 },
};
const LAYERS = [
{ bg: 'radial-gradient(circle at 35% 35%, #bae6fd 0%, #38bdf8 35%, transparent 70%)', k: 1.0, dir: 1, dur: '9s' },
{ bg: 'radial-gradient(circle at 65% 60%, #2dd4bf 0%, #0ea5e9 40%, transparent 72%)', k: 1.4, dir: -1, dur: '12s' },
{ bg: 'radial-gradient(circle at 50% 75%, #1461e1 0%, #174eb6 45%, transparent 75%)', k: 0.8, dir: 1, dur: '15s' },
{ bg: 'radial-gradient(circle at 40% 30%, #f7f7f8 0%, #bce2ff 18%, transparent 45%)', k: 1.8, dir: -1, dur: '7s' },
];
export type VoiceOrbProps = { state?: OrbState; size?: number; className?: string };
export function VoiceOrb({ state = 'idle', size = 200, className }: VoiceOrbProps) {
const layerRefs = useRef<Array<HTMLDivElement | null>>([]);
const glowRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
let raf = 0;
let amp = 0;
let speed = 0.4;
let rot = 0;
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min(48, now - last) / 1000;
last = now;
const t = now / 1000;
const cfg = CONFIG[stateRef.current];
// layered sines approximate organic voice noise
const noise = (Math.sin(t * 7.3) + Math.sin(t * 13.1 + 1.7) * 0.6 + Math.sin(t * 23.7 + 0.4) * 0.35) / 1.95;
const target = cfg.base + cfg.amp * (0.5 + noise * 0.5);
amp += (target - amp) * Math.min(1, dt * 9);
speed += (cfg.speed - speed) * Math.min(1, dt * 3);
rot += speed * dt * 60;
layerRefs.current.forEach((el, i) => {
if (!el) return;
const l = LAYERS[i];
const wobble = Math.sin(t * (1.3 + i * 0.4)) * 4;
el.style.transform = `translate(${wobble}%, ${-wobble * 0.6}%) rotate(${rot * l.dir * (1 + i * 0.25)}deg) scale(${1 + amp * l.k})`;
});
if (glowRef.current) {
glowRef.current.style.transform = `scale(${1 + amp * 1.2})`;
glowRef.current.style.opacity = String(0.35 + amp * 2);
}
if (ringRef.current) ringRef.current.style.transform = `scale(${1.04 + amp * 0.5})`;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={cn('relative shrink-0', className)} style={{ width: size, height: size }}>
<div ref={glowRef} className="absolute inset-0 rounded-full bg-brand-500/60 blur-3xl" />
<div ref={ringRef} className="absolute inset-0 rounded-full border border-brand-300/20" />
<div className="absolute inset-0 overflow-hidden rounded-full bg-ink-950 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]">
{LAYERS.map((l, i) => (
<div key={i} ref={(el) => (layerRefs.current[i] = el)} className="absolute inset-[-15%] will-change-transform">
<div className="h-full w-full animate-blob blur-2xl" style={{ background: l.bg, animationDuration: l.dur }} />
</div>
))}
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.35),transparent_40%)]" />
<div className="absolute inset-0 rounded-full shadow-[inset_0_-20px_40px_rgba(10,10,12,0.6)]" />
</div>
</div>
);
}
const STATES: Array<{ id: OrbState; label: string; icon: typeof Mic; caption: string }> = [
{ id: 'idle', label: 'Idle', icon: Circle, caption: 'Tap to talk' },
{ id: 'listening', label: 'Listening', icon: Mic, caption: 'Listening…' },
{ id: 'thinking', label: 'Thinking', icon: Brain, caption: 'Thinking…' },
{ id: 'speaking', label: 'Speaking', icon: Volume2, caption: '“Your flight to Lisbon departs at 9:40.”' },
];
export function VoiceOrbDemo() {
const [state, setState] = useState<OrbState>('listening');
const index = STATES.findIndex((s) => s.id === state);
const current = STATES[index];
return (
<div className="flex flex-col items-center gap-8">
<button
onClick={() => setState(state === 'idle' ? 'listening' : 'idle')}
className="focus-ring rounded-full transition-transform duration-300 ease-spring active:scale-95"
aria-label="Toggle voice"
>
<VoiceOrb state={state} size={180} />
</button>
<p key={state} className={cn('h-5 animate-fade-up text-sm', state === 'thinking' ? 'shimmer-text font-medium' : 'text-ink-300')}>
{current.caption}
</p>
<div className="relative grid grid-cols-4 rounded-xl bg-ink-900 p-1 shadow-card">
<span
className="absolute inset-y-1 left-1 w-[calc((100%-8px)/4)] rounded-lg bg-ink-800 shadow-hairline transition-transform duration-500 ease-spring"
style={{ transform: `translateX(${index * 100}%)` }}
/>
{STATES.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setState(id)}
className={cn(
'focus-ring relative z-10 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium transition-colors sm:px-4',
state === id ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200',
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
</div>
);
}components/niceui/ai/io/shared.ts
import { useEffect, useRef, useState } from 'react';
export const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds elapsed while `active`, ticking at `fps`. */
export function useTicker(active: boolean, fps = 20) {
const [t, setT] = useState(0);
const base = useRef(0);
useEffect(() => {
if (!active) return;
const start = performance.now() - base.current * 1000;
const id = window.setInterval(() => {
base.current = (performance.now() - start) / 1000;
setT(base.current);
}, 1000 / fps);
return () => clearInterval(id);
}, [active, fps]);
const reset = () => {
base.current = 0;
setT(0);
};
return [t, reset] as const;
}
/** Deterministic pseudo-random 0..1. */
export const rnd = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;