"use client";
import * as React from "react";
import { VoiceOrb, type VoiceOrbState } from "@/components/ui/voice-orb";
// A scripted call: the orb listens, thinks and speaks while the transcript writes itself.
// In a real app, drive `state` from your voice pipeline and `level` from useAudioLevel().
const SCRIPT: [string, string][] = [
["Can you move my 3 pm with Anna to tomorrow?", "Sure. Tomorrow at 3 pm she's free, and so are you."],
["Great, and remind her to bring the contract.", "Done. I moved the meeting and sent Anna a note about the contract."],
["What else is on my calendar tomorrow?", "Just a 10 am design review. The rest of the day is open."],
];
type Msg = { id: number; who: "you" | "ai"; words: string[]; shown: number };
const words = (s: string) => s.split(" ");
export default function VoiceOrbInContext() {
const [state, setState] = React.useState<VoiceOrbState>("idle");
const [msgs, setMsgs] = React.useState<Msg[]>([]);
const [turn, setTurn] = React.useState(0);
const [running, setRunning] = React.useState(true);
const [muted, setMuted] = React.useState(false);
const [level, setLevel] = React.useState(0);
const [seconds, setSeconds] = React.useState(0);
const idRef = React.useRef(0);
const last = msgs[msgs.length - 1];
const push = (who: Msg["who"], text: string) =>
setMsgs((m) => [...m, { id: ++idRef.current, who, words: words(text), shown: 0 }].slice(-4));
const reveal = () => setMsgs((m) => m.map((x, i) => (i === m.length - 1 ? { ...x, shown: x.shown + 1 } : x)));
// The conversation loop.
React.useEffect(() => {
if (!running) return;
const [ask, answer] = SCRIPT[turn % SCRIPT.length];
let t: ReturnType<typeof setTimeout>;
if (state === "idle") {
t = setTimeout(() => {
push("you", ask);
setState("listening");
}, 1400);
} else if (state === "listening") {
if (muted) return; // a muted mic hears nothing, so the transcript waits
t = last && last.shown < last.words.length ? setTimeout(reveal, 230) : setTimeout(() => setState("thinking"), 450);
} else if (state === "thinking") {
t = setTimeout(() => {
push("ai", answer);
setState("speaking");
}, 1300);
} else {
t = last && last.shown < last.words.length
? setTimeout(reveal, 190)
: setTimeout(() => {
setState("idle");
setTurn((n) => n + 1);
}, 700);
}
return () => clearTimeout(t);
}, [running, state, turn, muted, last?.shown]); // eslint-disable-line react-hooks/exhaustive-deps
// Fake voice envelope. Replace with useAudioLevel(micStream | ttsAudio).
const audible = (state === "listening" && !muted) || state === "speaking";
React.useEffect(() => {
if (!audible) return setLevel(0);
let raf = 0;
const start = performance.now();
const tick = (now: number) => {
const s = (now - start) / 1000;
const phrase = (Math.sin(s * 0.7) + 1) / 2;
const syl = Math.max(0, Math.sin(s * 6.2)) * 0.7 + Math.max(0, Math.sin(s * 9.1 + 1)) * 0.3;
setLevel(Math.round((0.3 + phrase * syl * 0.7) * 100) / 100);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [audible]);
// Call timer.
React.useEffect(() => {
if (!running) return;
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, [running]);
const end = () => {
setRunning(false);
setState("idle");
setMsgs([]);
setSeconds(0);
setMuted(false);
};
// Tap the orb: start a new call, or interrupt the assistant.
const press = () => {
if (!running) {
setRunning(true);
return;
}
if (state === "speaking" || state === "thinking") {
setMsgs((m) => m.map((x) => ({ ...x, shown: x.words.length })));
setState("idle");
setTurn((n) => n + 1);
}
};
const clock = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
return (
<div className="flex h-full min-h-[600px] w-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex items-center justify-between px-6 pt-5 font-mono text-[11px] uppercase tracking-[.14em] text-muted-foreground sm:px-10">
<span>Assistant</span>
<span className="tabular-nums">{running ? clock : "Call ended"}</span>
</header>
<main className="flex min-h-0 flex-1 flex-col justify-center">
{/* Transcript: newest at the bottom, older lines fade out at the top. */}
<div
aria-live="polite"
className="mx-auto flex h-[clamp(150px,36vh,300px)] w-full max-w-xl shrink-0 flex-col justify-end gap-4 overflow-hidden px-6 pb-2 pt-8"
style={{ maskImage: "linear-gradient(to bottom, transparent, #000 48px)", WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 48px)" }}
>
{!running && <p className="text-center text-sm text-muted-foreground">Tap the orb to start a new call.</p>}
{msgs.map((m) => (
<p
key={m.id}
className={
m.who === "you"
? "max-w-[85%] self-end text-right text-sm text-muted-foreground sm:text-base"
: "max-w-[90%] self-start text-lg font-medium leading-snug sm:text-2xl"
}
>
{m.words.slice(0, m.shown).join(" ")}
</p>
))}
</div>
{/* Padding leaves room for the rings, which grow to 1.5× the orb while it speaks. */}
<div className="flex shrink-0 items-center justify-center" style={{ padding: "calc(min(170px, 40vw, 24vh) * 0.3) 0" }}>
<VoiceOrb
state={state}
level={level}
size="min(170px, 40vw, 24vh)"
onPress={press}
aria-label={!running ? "Start a call" : state === "speaking" || state === "thinking" ? "Interrupt" : "Voice assistant"}
/>
</div>
</main>
<footer className="flex shrink-0 justify-center gap-3 pb-6">
<button
type="button"
aria-pressed={muted}
disabled={!running}
onClick={() => setMuted((v) => !v)}
className="rounded-full bg-muted px-5 py-2.5 text-sm font-medium transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
{muted ? "Unmute" : "Mute"}
</button>
<button
type="button"
disabled={!running}
onClick={end}
className="rounded-full bg-red-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
End
</button>
</footer>
</div>
);
}