← snapshot
9115 bytes
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { cedarConfigured } from "@/lib/cedar";
import {
record,
toWhisperWav,
transcribeLocally,
whisperAvailable,
type Recording,
} from "@/lib/record";
import { useSpeech } from "@/lib/speech";
/**
* A bench for the voice path, because voice is the one part of this app that
* cannot be verified by reading it. Each engine is exercised on its own, so a
* bad transcript can be blamed on the right thing: the microphone, the
* recogniser, or the model.
*
* The live capture in the app itself still runs on the browser's own
* `SpeechRecognition` — it streams partial results as you speak, which whisper
* cannot. Whisper is here for the cases where accuracy beats immediacy.
*/
export function VoiceLab() {
const {
canListen,
listening,
listen,
stop,
interim,
level,
speak,
speaking,
engine,
muted,
setMuted,
} = useSpeech();
const [whisper, setWhisper] = useState<boolean | null>(null);
const [cedar, setCedar] = useState<boolean | null>(null);
const [recording, setRecording] = useState(false);
const [micLevel, setMicLevel] = useState(0);
const [result, setResult] = useState<string>("");
const [heard, setHeard] = useState<string>("");
const [note, setNote] = useState<string>("");
const recRef = useRef<Recording | null>(null);
const [selfTest, setSelfTest] = useState<string>("");
/**
* The microphone cannot be driven headlessly, but the part that decides
* whether whisper can read our bytes — decode, downsample, WAV header,
* upload — can. This runs that whole path on a known clip.
*/
const runSelfTest = async () => {
setSelfTest("running…");
try {
const bytes = await (await fetch("/probe.wav")).arrayBuffer();
const Ctx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
const ctx = new Ctx();
const decoded = await ctx.decodeAudioData(bytes);
const wav = toWhisperWav(decoded.getChannelData(0), decoded.sampleRate);
void ctx.close();
const out = await transcribeLocally(wav);
setSelfTest(`${out.text} — ${out.engine}, ${out.elapsed_ms} ms`);
} catch (e) {
setSelfTest(`failed: ${e instanceof Error ? e.message : "unknown"}`);
}
};
useEffect(() => {
void whisperAvailable().then(setWhisper);
void cedarConfigured().then(setCedar);
}, []);
const startWhisper = async () => {
setNote("");
setResult("");
try {
recRef.current = await record(setMicLevel);
setRecording(true);
} catch (e) {
setNote(`microphone refused: ${e instanceof Error ? e.message : "unknown"}`);
}
};
const stopWhisper = async () => {
const rec = recRef.current;
if (!rec) return;
setRecording(false);
const wav = await rec.stop();
recRef.current = null;
setResult(`transcribing ${(wav.size / 1024).toFixed(0)} KB…`);
try {
const out = await transcribeLocally(wav);
setResult(out.text || "(silence)");
setNote(`${out.engine} · ${out.elapsed_ms} ms · ${(wav.size / 1024).toFixed(0)} KB`);
} catch (e) {
setResult("");
setNote(e instanceof Error ? e.message : "transcription failed");
}
};
return (
<div className="jac-page">
<p className="jac-eyebrow">Voice lab</p>
<h1 className="jac-h1">Can it hear you?</h1>
<p className="jac-lede">
Three independent pieces. Test them separately so a bad transcript can
be blamed on the right one.
</p>
<ul className="jac-remark-list" style={{ marginTop: 18 }}>
<li className="jac-remark">
<p className="jac-panel-label">1 · Browser recognition — the live path</p>
<p className="jac-small">
Streams words as you speak. Keeps listening through pauses and stops
after about two seconds of real silence.
</p>
<div className="jac-remark-say">
<button
type="button"
className="jac-mic"
data-live={listening}
style={{ "--mic-level": String(level) } as React.CSSProperties}
disabled={!canListen}
onClick={async () => {
if (listening) {
stop();
return;
}
setHeard("");
const { text, error } = await listen();
setHeard(error ? `— ${error} —` : text || "(silence)");
}}
>
{listening ? "Stop" : canListen ? "Listen" : "Unavailable"}
</button>
<span className="jac-small">
{listening ? interim || "listening…" : "level ring follows your voice"}
</span>
</div>
{heard ? <p className="jac-remark-body">{heard}</p> : null}
</li>
<li className="jac-remark" data-kind="suggestion">
<p className="jac-panel-label">2 · whisper.cpp — local, on this machine</p>
<p className="jac-small">
{whisper === null
? "checking…"
: whisper
? "Configured. Audio is transcribed here and deleted; nothing is uploaded."
: "Not configured — set WHISPER_MODEL on jac-serve to enable it."}
</p>
<div className="jac-remark-say">
<button
type="button"
className="jac-mic"
data-live={recording}
style={{ "--mic-level": String(micLevel) } as React.CSSProperties}
disabled={!whisper}
onClick={() => void (recording ? stopWhisper() : startWhisper())}
>
{recording ? "Stop & transcribe" : "Record"}
</button>
<span className="jac-small">
{recording ? "recording — 16 kHz mono" : "records, then transcribes at the end"}
</span>
</div>
{result ? <p className="jac-remark-body">{result}</p> : null}
{note ? <p className="jac-small">{note}</p> : null}
<div className="jac-remark-actions">
<button
type="button"
className="jac-btn"
disabled={!whisper}
onClick={() => void runSelfTest()}
>
Self-test without the mic
</button>
<span className="jac-small">
Runs a known clip through the same encode-and-upload path.
</span>
</div>
{selfTest ? (
<p className="jac-remark-body" data-selftest="1">
{selfTest}
</p>
) : null}
</li>
<li className="jac-remark" data-kind="question">
<p className="jac-panel-label">3 · Jackie's voice</p>
<p className="jac-small">
{cedar === null
? "checking…"
: cedar
? "cedar, streamed from gpt-4o-mini-tts."
: "No OPENAI_API_KEY."}
</p>
{cedar === false ? (
<p className="jac-small jac-warn">
Falling back to the browser's built-in synthesis. This machine
exposes no premium, neural, or Siri voices, so the best available
is a dated formant synth — it will sound robotic no matter what.
Put <code className="jac-mono">OPENAI_API_KEY=sk-…</code> in{" "}
<code className="jac-mono">web/.env.local</code> and restart{" "}
<code className="jac-mono">npm run dev</code> for cedar.
</p>
) : null}
{engine !== "unknown" ? (
<p className="jac-small">
last spoken by: <code className="jac-mono">{engine}</code>
</p>
) : null}
<p className="jac-small">
{muted
? "Off. Jackie says nothing until you ask her to — speech that starts on its own is startling, and this machine has no good voices."
: "On. Remembered for next time."}
</p>
<button
type="button"
className="jac-btn"
onClick={() => setMuted(!muted)}
style={{ marginRight: 8 }}
>
{muted ? "Turn the voice on" : "Turn it off"}
</button>
<button
type="button"
className="jac-btn"
disabled={speaking || muted}
onClick={() =>
void speak(
"Exponential backoff is for transient network errors only. " +
"A request that was unauthorized is never replayed.",
)
}
>
{speaking ? "Speaking…" : "Say something"}
</button>
</li>
</ul>
<p className="jac-small" style={{ marginTop: 22 }}>
<Link href="/lab">← the figure lab</Link> · <Link href="/">the floor</Link>
</p>
</div>
);
}