jacquardSnapshot

← snapshot

8528 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 } =
    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&apos;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&apos;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}
          <button
            type="button"
            className="jac-btn"
            disabled={speaking}
            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>
  );
}