2026-09-17 19:31:25 +03:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import { Loader2Icon, MicIcon } from "lucide-react";
|
2026-09-24 23:20:59 +03:00
|
|
|
|
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
2026-09-25 01:52:20 +03:00
|
|
|
|
import { Hint } from "@/components/ui/hint";
|
2026-09-17 19:31:25 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Architecture (docs/research/stt.md): the renderer captures mic audio in
|
|
|
|
|
|
* an AudioWorklet (public/worklets/pcm-capture.js → linear16 16kHz mono),
|
|
|
|
|
|
* opens the Deepgram streaming WebSocket DIRECTLY (audio never loops
|
|
|
|
|
|
* through our server) with a short-lived token minted by /api/stt/token
|
|
|
|
|
|
* (the real key stays server-side), and streams frames while it listens.
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* Finals append into the PRIMARY INPUT (see below).
|
2026-09-17 19:31:25 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Ported patterns from the owner's pi `dictate` extension (his words:
|
|
|
|
|
|
* "very well tuned") — see ~/.pi/agent/extensions/dictate:
|
|
|
|
|
|
* - generation counter: every start/stop increments it; all async
|
|
|
|
|
|
* handlers no-op when stale, so a dying session's socket can never
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* tear down the current one.
|
2026-09-17 19:31:25 +03:00
|
|
|
|
* - meter: latest-RMS-never-resets — the level ref keeps the last
|
|
|
|
|
|
* observed value, so a tick with no fresh audio re-renders the last
|
|
|
|
|
|
* level instead of dropping to silence (60ms tick samples the ref).
|
|
|
|
|
|
* - dB mapping floor −50 / ceiling −10: the sweet spot for showing
|
|
|
|
|
|
* conversational speech as mid-height bars without pegging.
|
|
|
|
|
|
* - stop: mic dies FIRST (no more audio enqueues), then CloseStream,
|
|
|
|
|
|
* then a 3s safety net in case Deepgram never closes the socket.
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* - interim_results=false, endpointing=300: his live-tuned params — no
|
|
|
|
|
|
* partial hypotheses, finals commit ~300ms after a pause (owner
|
|
|
|
|
|
* round 1: live words are distracting and shift the meter; the
|
|
|
|
|
|
* meter's width is FIXED while recording).
|
|
|
|
|
|
*
|
2026-09-17 20:06:07 +03:00
|
|
|
|
* Owner round 2 (2026-09-17, second live test):
|
|
|
|
|
|
* - DEFERRED transcript: finals accumulate in a buffer during the run
|
|
|
|
|
|
* and flush into the primary input ONCE, when the dictation ends
|
|
|
|
|
|
* (click-again/Esc → Deepgram flushes its last finals → socket close
|
|
|
|
|
|
* → one joined append). Nothing textual renders while recording —
|
|
|
|
|
|
* only the meter. Live output may return as a toggleable setting at
|
|
|
|
|
|
* the settings milestone; deferred is the only behavior for now.
|
|
|
|
|
|
* - ADAPTIVE meter: the strip's cell count derives from its measured
|
2026-09-23 15:57:59 +03:00
|
|
|
|
* width (4px pitch, 8–48 cells, ResizeObserver) so narrow rows (the
|
|
|
|
|
|
* draft view's tighter column) show fewer bars instead of
|
|
|
|
|
|
* overflowing under their neighbors.
|
2026-09-17 20:06:07 +03:00
|
|
|
|
*
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* Owner round 1 (2026-09-17, first live mic test):
|
|
|
|
|
|
* - ONE primary transcription input at all times: the pending
|
|
|
|
|
|
* quiz/ask question's field while one waits (text mode → the answer
|
|
|
|
|
|
* field, select modes → "Your thinking"), else the page's composer.
|
|
|
|
|
|
* Implemented as a module-level registry the question form claims on
|
|
|
|
|
|
* mount — see useDictationTarget below.
|
|
|
|
|
|
* - Click → meter IMMEDIATELY (optimistic): the meter strip renders
|
|
|
|
|
|
* from the click, connection + TCC happen behind it. The token mint
|
|
|
|
|
|
* and the mic acquisition run CONCURRENTLY (Promise.all) to shrink
|
|
|
|
|
|
* the real gap.
|
2026-09-17 19:31:25 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Meter rendering lives in <DictationBar> (own 60ms timer over the level
|
|
|
|
|
|
* ref) so the 16-renders/sec never touch the page component — only the
|
|
|
|
|
|
* tiny bar strip repaints.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* Deepgram streaming params (the owner's live-tuned dictate config): nova-3,
|
|
|
|
|
|
* endpointing=300, no interims. Silence streams too (the worklet never
|
|
|
|
|
|
* pauses), so the socket never idles — no KeepAlive needed.
|
2026-09-17 19:31:25 +03:00
|
|
|
|
*/
|
|
|
|
|
|
const DG_URL =
|
|
|
|
|
|
"wss://api.deepgram.com/v1/listen" +
|
|
|
|
|
|
"?model=nova-3" +
|
|
|
|
|
|
"&encoding=linear16" +
|
|
|
|
|
|
"&sample_rate=16000" +
|
|
|
|
|
|
"&channels=1" +
|
|
|
|
|
|
"&smart_format=true" +
|
|
|
|
|
|
"&punctuate=true" +
|
2026-09-17 19:59:14 +03:00
|
|
|
|
"&endpointing=300";
|
2026-09-17 19:31:25 +03:00
|
|
|
|
|
|
|
|
|
|
/** Meter tuning — the owner's tested values, see the file header. */
|
|
|
|
|
|
const METER_FLOOR_DB = -50;
|
|
|
|
|
|
const METER_CEILING_DB = -10;
|
|
|
|
|
|
export const METER_TICK_MS = 60;
|
|
|
|
|
|
/** Rolling history: 48 cells × 60ms ≈ 2.9s of recent loudness. */
|
|
|
|
|
|
export const METER_CELLS = 48;
|
|
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
/** The Deepgram key's id in the keys store (the env-var name — the Keys
|
|
|
|
|
|
* section's openRow state and the PUT /api/keys/:id seam). */
|
|
|
|
|
|
export const DEEPGRAM_KEY_ID = "DEEPGRAM_API_KEY";
|
|
|
|
|
|
|
|
|
|
|
|
/** The client-authored KEYLESS line (zero-keys wave, owner 2026-09-25):
|
|
|
|
|
|
* the token route's raw 503 message is dev-speak ("add it to auth.json
|
|
|
|
|
|
* (desktop) or .env.local (dev)") — the surface says the short truth
|
|
|
|
|
|
* instead. Exported for pins; the errored idle button's click recognizes
|
|
|
|
|
|
* it and deep-links Settings → Keys → the Deepgram row. */
|
|
|
|
|
|
export const DICTATION_KEYLESS = "Dictation needs a Deepgram key";
|
|
|
|
|
|
|
2026-09-17 19:31:25 +03:00
|
|
|
|
/** RMS (0..1) → normalized 0..1 across the audible dB window. */
|
|
|
|
|
|
export function rmsToMeter(rms: number): number {
|
|
|
|
|
|
if (rms <= 0) return 0;
|
|
|
|
|
|
const db = 20 * Math.log10(rms);
|
|
|
|
|
|
const t = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB);
|
|
|
|
|
|
return Math.max(0, Math.min(1, t));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 19:59:14 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* The primary-input registry: at most ONE non-composer input can claim the
|
|
|
|
|
|
* dictation target while mounted — the pending question's answer/thinking
|
|
|
|
|
|
* field (question-dock.tsx). The page's `append` (the composer) is the
|
|
|
|
|
|
* fallback when nobody claims. Module-level by design: the claimant is a
|
|
|
|
|
|
* sibling subtree far from the hook's owner, and there is exactly one
|
|
|
|
|
|
* window, one pending question, one target at a time.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let questionTarget: ((text: string) => void) | null = null;
|
|
|
|
|
|
|
2026-09-17 20:47:23 +03:00
|
|
|
|
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
|
|
|
|
|
|
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
|
|
|
|
|
|
* stop handler can't report back through a CustomEvent. Set at start(),
|
|
|
|
|
|
* cleared in cleanup() (every terminal path runs through cleanup). */
|
|
|
|
|
|
let dictationActiveFlag = false;
|
|
|
|
|
|
|
|
|
|
|
|
export function isDictationActive(): boolean {
|
|
|
|
|
|
return dictationActiveFlag;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 19:59:14 +03:00
|
|
|
|
/** Join a dictated final onto the current text (shared separator rule). */
|
|
|
|
|
|
export function joinDictation(current: string, final: string): string {
|
|
|
|
|
|
return current ? current.replace(/\s+$/, "") + " " + final : final;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Claim the dictation target while mounted (and `claimed` is true). The
|
|
|
|
|
|
* unmount guard is identity-based so dev-mode double-invoked effects
|
|
|
|
|
|
* can't clear another claimant.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function useDictationTarget(append: (text: string) => void, claimed: boolean) {
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!claimed) return;
|
|
|
|
|
|
questionTarget = append;
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (questionTarget === append) questionTarget = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [append, claimed]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 19:31:25 +03:00
|
|
|
|
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping";
|
|
|
|
|
|
|
|
|
|
|
|
export type Dictation = {
|
|
|
|
|
|
phase: DictationPhase;
|
2026-09-17 19:59:14 +03:00
|
|
|
|
/** Terminal-state explanation, shown inside the wide button (cleared on the next start). */
|
2026-09-17 19:31:25 +03:00
|
|
|
|
error: string | null;
|
|
|
|
|
|
/** Latest observed RMS, 0..1 — sampled by the meter's own tick, never reset. */
|
|
|
|
|
|
levelRef: { current: number };
|
2026-09-17 19:59:14 +03:00
|
|
|
|
/** Not idle — from the click until the stop completes (the optimistic window). */
|
2026-09-17 19:31:25 +03:00
|
|
|
|
active: boolean;
|
2026-09-17 21:18:56 +03:00
|
|
|
|
/** End + KEEP the transcript: flush the deferred buffer into the primary
|
|
|
|
|
|
* input (the Stop pill and ⌘D). */
|
2026-09-17 19:31:25 +03:00
|
|
|
|
toggle: () => void;
|
|
|
|
|
|
stop: () => void;
|
2026-09-17 21:18:56 +03:00
|
|
|
|
/** End + DISCARD the transcript: no flush, immediate teardown. The Esc
|
|
|
|
|
|
* semantics (owner, 2026-09-17 round 3): Esc is a cancel, not a stop —
|
|
|
|
|
|
* before, it flushed exactly like the toggle did. */
|
|
|
|
|
|
cancel: () => void;
|
2026-09-24 23:20:59 +03:00
|
|
|
|
/** Clear the terminal error without starting a run (zero-keys wave):
|
|
|
|
|
|
* the keyless click leaves for Settings and the surface must come
|
|
|
|
|
|
* back clean. */
|
|
|
|
|
|
clearError: () => void;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* One dictation instance per composer-bearing page. `append` is the
|
|
|
|
|
|
* composer fallback; the mounted question form's claim wins while a
|
|
|
|
|
|
* question waits.
|
2026-09-17 19:31:25 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
|
|
|
|
|
const [phase, setPhase] = useState<DictationPhase>("idle");
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
|
|
|
|
|
const levelRef = useRef(0);
|
2026-09-17 19:59:14 +03:00
|
|
|
|
/** The page's fallback append — kept fresh per render so a mid-recording
|
|
|
|
|
|
* retarget (composer↔question) is honored on the very next final. */
|
|
|
|
|
|
const appendRef = useRef(append);
|
|
|
|
|
|
appendRef.current = append;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
|
|
|
|
|
|
// Live machinery (non-render values). AudioContext/MediaStream are
|
|
|
|
|
|
// closed on stop; the socket is recreated per run.
|
|
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
|
|
const ctxRef = useRef<AudioContext | null>(null);
|
|
|
|
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
|
|
|
|
const genRef = useRef(0); // stale-handler guard (dictate pattern)
|
|
|
|
|
|
const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
const reconnectsRef = useRef(0);
|
2026-09-17 20:06:07 +03:00
|
|
|
|
/** The deferred transcript (owner round 2): finals land here during the
|
|
|
|
|
|
* run and flush into the primary input once, at teardown. */
|
|
|
|
|
|
const bufferRef = useRef<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
/** Flush the buffered transcript into the primary input — question claim
|
|
|
|
|
|
* first, composer fallback. Called exactly once per run, at cleanup; the
|
|
|
|
|
|
* buffer clears itself so double-cleanup is a no-op. */
|
|
|
|
|
|
const flush = useCallback(() => {
|
|
|
|
|
|
if (bufferRef.current.length === 0) return;
|
|
|
|
|
|
const text = bufferRef.current.join(" ");
|
|
|
|
|
|
bufferRef.current = [];
|
|
|
|
|
|
(questionTarget ?? appendRef.current)(text);
|
|
|
|
|
|
}, []);
|
2026-09-17 19:31:25 +03:00
|
|
|
|
|
|
|
|
|
|
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
|
|
|
|
|
const cleanup = useCallback(() => {
|
|
|
|
|
|
genRef.current++;
|
2026-09-17 20:47:23 +03:00
|
|
|
|
dictationActiveFlag = false;
|
2026-09-17 20:06:07 +03:00
|
|
|
|
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
2026-09-17 19:31:25 +03:00
|
|
|
|
if (safetyRef.current) {
|
|
|
|
|
|
clearTimeout(safetyRef.current);
|
|
|
|
|
|
safetyRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const track of streamRef.current?.getTracks() ?? []) track.stop();
|
|
|
|
|
|
streamRef.current = null;
|
|
|
|
|
|
void ctxRef.current?.close().catch(() => {});
|
|
|
|
|
|
ctxRef.current = null;
|
|
|
|
|
|
const ws = wsRef.current;
|
|
|
|
|
|
wsRef.current = null;
|
|
|
|
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
ws.close();
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
setPhase("idle");
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
/** Mint a short-lived token (the route holds the real key server-side).
|
|
|
|
|
|
* The keyless 503 (no DEEPGRAM_API_KEY) is the route's ONLY 503 — the
|
|
|
|
|
|
* raw message is dev-speak, so the error becomes the short
|
|
|
|
|
|
* client-authored line (zero-keys wave, owner 2026-09-25). */
|
2026-09-17 19:31:25 +03:00
|
|
|
|
const mintToken = useCallback(async () => {
|
|
|
|
|
|
const res = await fetch("/api/stt/token");
|
|
|
|
|
|
const data = (await res.json()) as { token?: string; message?: string };
|
2026-09-24 23:20:59 +03:00
|
|
|
|
if (!res.ok || !data.token) {
|
|
|
|
|
|
if (res.status === 503) throw new Error(DICTATION_KEYLESS);
|
|
|
|
|
|
throw new Error(data.message ?? `token route ${res.status}`);
|
|
|
|
|
|
}
|
2026-09-17 19:31:25 +03:00
|
|
|
|
return data.token;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
|
|
|
|
|
const openSocket = useCallback(
|
|
|
|
|
|
(gen: number, token: string) => {
|
2026-09-17 19:44:35 +03:00
|
|
|
|
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
|
|
|
|
|
// jwt] is rejected at the handshake (verified live; "token" is for
|
|
|
|
|
|
// raw API keys only, as in the owner's pi dictate extension).
|
|
|
|
|
|
const ws = new WebSocket(DG_URL, ["bearer", token]);
|
2026-09-17 19:31:25 +03:00
|
|
|
|
wsRef.current = ws;
|
|
|
|
|
|
|
|
|
|
|
|
ws.addEventListener("open", () => {
|
|
|
|
|
|
if (gen !== genRef.current) return;
|
|
|
|
|
|
setPhase("recording");
|
|
|
|
|
|
});
|
|
|
|
|
|
ws.addEventListener("message", (ev) => {
|
|
|
|
|
|
if (gen !== genRef.current) return;
|
|
|
|
|
|
let msg: any;
|
|
|
|
|
|
try {
|
|
|
|
|
|
msg = JSON.parse(ev.data as string);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (msg.type === "Results") {
|
|
|
|
|
|
const text: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
|
2026-09-17 20:06:07 +03:00
|
|
|
|
// Only finals stream (interim_results=false). DEFERRED (owner
|
|
|
|
|
|
// round 2): they buffer here and reach the input once, at
|
|
|
|
|
|
// teardown — the meter is the only live feedback.
|
|
|
|
|
|
if (msg.is_final && text) bufferRef.current.push(text);
|
2026-09-17 19:31:25 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ws.addEventListener("close", (ev) => {
|
|
|
|
|
|
if (gen !== genRef.current) return;
|
|
|
|
|
|
if (ev.code === 1000) {
|
|
|
|
|
|
// Our own CloseStream landing: finalize now — the stop path's
|
|
|
|
|
|
// safety net stays as the no-close backstop only.
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Abnormal close mid-run: ONE reconnect with a fresh token (the
|
|
|
|
|
|
// research pass: resume is our job, no SDK to lean on).
|
|
|
|
|
|
if (reconnectsRef.current < 1) {
|
|
|
|
|
|
reconnectsRef.current++;
|
|
|
|
|
|
mintToken()
|
|
|
|
|
|
.then((t) => {
|
|
|
|
|
|
if (gen === genRef.current) openSocket(gen, t);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (gen === genRef.current) {
|
|
|
|
|
|
setError("Deepgram reconnect failed");
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError(`Deepgram connection lost (${ev.code})`);
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-09-17 19:59:14 +03:00
|
|
|
|
[cleanup, mintToken],
|
2026-09-17 19:31:25 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const start = useCallback(async () => {
|
2026-09-17 20:47:23 +03:00
|
|
|
|
dictationActiveFlag = true;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
setError(null);
|
|
|
|
|
|
setPhase("connecting");
|
|
|
|
|
|
reconnectsRef.current = 0;
|
2026-09-17 20:06:07 +03:00
|
|
|
|
bufferRef.current = []; // fresh run, fresh buffer (round 2)
|
2026-09-17 19:31:25 +03:00
|
|
|
|
const gen = ++genRef.current;
|
|
|
|
|
|
|
2026-09-17 19:59:14 +03:00
|
|
|
|
// Token + mic CONCURRENTLY (owner round 1: the sequential wait was the
|
|
|
|
|
|
// felt delay) — the optimistic meter already covers the gap. 127.0.0.1
|
|
|
|
|
|
// is a secure context, so getUserMedia works without TLS; macOS TCC
|
|
|
|
|
|
// prompts on first use.
|
|
|
|
|
|
const mic = navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
let token: string;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
try {
|
2026-09-17 19:59:14 +03:00
|
|
|
|
const [t, stream] = await Promise.all([mintToken(), mic]);
|
|
|
|
|
|
token = t;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
if (gen !== genRef.current) {
|
|
|
|
|
|
for (const track of stream.getTracks()) track.stop();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
streamRef.current = stream;
|
|
|
|
|
|
|
|
|
|
|
|
const ctx = new AudioContext({ sampleRate: 16000 });
|
|
|
|
|
|
ctxRef.current = ctx;
|
|
|
|
|
|
await ctx.audioWorklet.addModule("/worklets/pcm-capture.js");
|
|
|
|
|
|
|
|
|
|
|
|
const worklet = new AudioWorkletNode(ctx, "pcm-capture");
|
|
|
|
|
|
worklet.port.onmessage = (e) => {
|
|
|
|
|
|
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer };
|
|
|
|
|
|
if (msg.type === "level") {
|
|
|
|
|
|
levelRef.current = msg.rms ?? 0; // never reset to zero (dictate pattern)
|
|
|
|
|
|
} else if (msg.type === "audio") {
|
|
|
|
|
|
const ws = wsRef.current;
|
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg.buffer!);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
ctx.createMediaStreamSource(stream).connect(worklet);
|
|
|
|
|
|
// Pull the graph with silence out (the worklet writes no output) —
|
|
|
|
|
|
// an unconnected worklet node is never clocked.
|
|
|
|
|
|
worklet.connect(ctx.destination);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (gen !== genRef.current) return;
|
|
|
|
|
|
const msg =
|
|
|
|
|
|
e instanceof DOMException && (e.name === "NotAllowedError" || e.name === "SecurityError")
|
|
|
|
|
|
? "Microphone permission denied"
|
|
|
|
|
|
: e instanceof Error
|
|
|
|
|
|
? e.message
|
|
|
|
|
|
: "mic unavailable";
|
|
|
|
|
|
setError(msg);
|
|
|
|
|
|
cleanup();
|
2026-09-17 19:59:14 +03:00
|
|
|
|
return;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
}
|
2026-09-17 19:59:14 +03:00
|
|
|
|
if (gen !== genRef.current) return;
|
|
|
|
|
|
openSocket(gen, token);
|
2026-09-17 19:31:25 +03:00
|
|
|
|
}, [openSocket, cleanup, mintToken]);
|
|
|
|
|
|
|
|
|
|
|
|
const stop = useCallback(() => {
|
|
|
|
|
|
const gen = genRef.current;
|
|
|
|
|
|
if (phase !== "recording" && phase !== "connecting") return;
|
|
|
|
|
|
setPhase("stopping");
|
|
|
|
|
|
|
|
|
|
|
|
// Mic dies FIRST — no more audio enqueues (dictate pattern).
|
|
|
|
|
|
for (const track of streamRef.current?.getTracks() ?? []) track.stop();
|
|
|
|
|
|
streamRef.current = null;
|
|
|
|
|
|
void ctxRef.current?.close().catch(() => {});
|
|
|
|
|
|
ctxRef.current = null;
|
|
|
|
|
|
|
|
|
|
|
|
const ws = wsRef.current;
|
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
ws.send(JSON.stringify({ type: "CloseStream" }));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Deepgram flushes remaining finals then closes; if it never
|
|
|
|
|
|
// does, the safety net ends the run anyway (dictate pattern).
|
|
|
|
|
|
safetyRef.current = setTimeout(() => {
|
|
|
|
|
|
if (gen === genRef.current) cleanup();
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [phase, cleanup]);
|
|
|
|
|
|
|
2026-09-17 21:18:56 +03:00
|
|
|
|
const cancel = useCallback(() => {
|
|
|
|
|
|
if (phase === "idle") return;
|
|
|
|
|
|
// Discard first, teardown second — cleanup() flushes, and the now-empty
|
|
|
|
|
|
// buffer makes that flush a no-op. Also kills a "stopping" run: the
|
|
|
|
|
|
// flush already underway is abandoned (its timer is cleared inside).
|
|
|
|
|
|
bufferRef.current = [];
|
|
|
|
|
|
cleanup();
|
|
|
|
|
|
}, [phase, cleanup]);
|
|
|
|
|
|
|
2026-09-17 19:31:25 +03:00
|
|
|
|
const toggle = useCallback(() => {
|
|
|
|
|
|
if (phase === "idle") void start();
|
|
|
|
|
|
else if (phase === "recording" || phase === "connecting") stop();
|
|
|
|
|
|
// "stopping" presses are ignored — the flush is in flight.
|
|
|
|
|
|
}, [phase, start, stop]);
|
|
|
|
|
|
|
2026-09-17 20:47:23 +03:00
|
|
|
|
// Shortcut seams (keyboard wave A): ⌘D toggles from anywhere on the
|
2026-09-17 21:18:56 +03:00
|
|
|
|
// page; the Esc chain lands here as a CANCEL (owner, round 3). Each
|
|
|
|
|
|
// page owns ONE hook instance, and only the mounted page's listener is
|
|
|
|
|
|
// attached.
|
2026-09-17 20:47:23 +03:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
|
|
|
|
|
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
|
|
|
|
|
|
}, [toggle]);
|
|
|
|
|
|
useEffect(() => {
|
2026-09-17 21:18:56 +03:00
|
|
|
|
window.addEventListener("lh:kbd-cancel-dictation", cancel);
|
|
|
|
|
|
return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel);
|
|
|
|
|
|
}, [cancel]);
|
2026-09-17 20:47:23 +03:00
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
return { phase, error, levelRef, active: phase !== "idle", toggle, stop, cancel, clearError: () => setError(null) };
|
2026-09-17 19:31:25 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-17 19:59:14 +03:00
|
|
|
|
* The stats-bar dictation slot (owner placement, round 1): the row's middle
|
|
|
|
|
|
* is one strip that is always the full free width —
|
|
|
|
|
|
*
|
|
|
|
|
|
* idle a wide, quiet "Dictate" button (mic + label, grayed out — the
|
|
|
|
|
|
* whole middle is the affordance, per the owner).
|
|
|
|
|
|
* active optimistic from the CLICK: red stop pill + the fixed-width
|
|
|
|
|
|
* meter (no interim text ever shifts it). Spinner while
|
|
|
|
|
|
* Deepgram flushes the last final.
|
|
|
|
|
|
* error the wide button again, label swapped for the failure reason
|
|
|
|
|
|
* (full text in the tooltip), cleared on the next click.
|
2026-09-17 19:31:25 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export function DictationBar({ dictation }: { dictation: Dictation }) {
|
2026-09-24 23:20:59 +03:00
|
|
|
|
const { phase, error, levelRef, toggle, active, clearError } = dictation;
|
|
|
|
|
|
const { open: openSettings } = useSettingsDialog();
|
|
|
|
|
|
/** The keyless click (zero-keys wave, owner dictation trio): the errored
|
|
|
|
|
|
* idle button deep-links Settings → Keys → the Deepgram row and clears
|
|
|
|
|
|
* the error — the surface is clean when the user returns. Any other
|
|
|
|
|
|
* error keeps the old behavior (click = retry, which clears). */
|
|
|
|
|
|
const keyless = error === DICTATION_KEYLESS;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
|
|
|
|
|
const recording = phase === "recording" || phase === "stopping";
|
2026-09-17 20:06:07 +03:00
|
|
|
|
/** Adaptive cell count (owner round 2): derived from the meter's
|
2026-09-23 15:57:59 +03:00
|
|
|
|
* measured width — narrow rows show fewer bars instead of
|
|
|
|
|
|
* overflowing under neighbors. */
|
2026-09-17 20:06:07 +03:00
|
|
|
|
const [cellCount, setCellCount] = useState(METER_CELLS);
|
|
|
|
|
|
const meterRef = useRef<HTMLDivElement | null>(null);
|
2026-09-17 19:31:25 +03:00
|
|
|
|
|
|
|
|
|
|
// The meter's own heartbeat — 60ms samples of the level ref into a
|
|
|
|
|
|
// rolling ring. Only THIS strip re-renders; the page never does.
|
|
|
|
|
|
useEffect(() => {
|
2026-09-17 19:59:14 +03:00
|
|
|
|
if (!active) return;
|
2026-09-17 19:31:25 +03:00
|
|
|
|
setLevels(new Array(METER_CELLS).fill(0));
|
|
|
|
|
|
const id = setInterval(() => {
|
|
|
|
|
|
setLevels((ring) => {
|
|
|
|
|
|
const next = ring.slice(1);
|
|
|
|
|
|
next.push(rmsToMeter(levelRef.current));
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, METER_TICK_MS);
|
|
|
|
|
|
return () => clearInterval(id);
|
2026-09-17 19:59:14 +03:00
|
|
|
|
}, [active, levelRef]);
|
|
|
|
|
|
|
2026-09-17 20:06:07 +03:00
|
|
|
|
// Fit the cell count to the available width (4px pitch per cell:
|
|
|
|
|
|
// 2px bar + 2px gap, clamped 8..48). The meter div's width is set by
|
|
|
|
|
|
// the row's free space — cells carry no minimum width — so measuring
|
|
|
|
|
|
// it once per activation + on resize keeps the strip honest in the
|
|
|
|
|
|
// draft/home rows where the slider leaves little middle.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!active) return;
|
|
|
|
|
|
const el = meterRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const fit = () => {
|
|
|
|
|
|
const w = el.clientWidth;
|
|
|
|
|
|
setCellCount(Math.max(8, Math.min(METER_CELLS, Math.floor(w / 4))));
|
|
|
|
|
|
};
|
|
|
|
|
|
fit();
|
|
|
|
|
|
const ro = new ResizeObserver(fit);
|
|
|
|
|
|
ro.observe(el);
|
|
|
|
|
|
return () => ro.disconnect();
|
|
|
|
|
|
}, [active]);
|
|
|
|
|
|
|
2026-09-17 19:59:14 +03:00
|
|
|
|
if (!active) {
|
|
|
|
|
|
return (
|
2026-09-25 01:52:20 +03:00
|
|
|
|
<Hint
|
|
|
|
|
|
// Errored: the button span truncates the line — the tooltip is its
|
|
|
|
|
|
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
|
|
|
|
|
|
// it's the Keys row label, the truth the user needs). Otherwise the
|
|
|
|
|
|
// quiet label + the live ⌘D binding.
|
|
|
|
|
|
label={error ?? "Dictate"}
|
|
|
|
|
|
action={error ? undefined : "toggle-dictation"}
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (keyless) {
|
|
|
|
|
|
clearError();
|
|
|
|
|
|
openSettings("keys", DEEPGRAM_KEY_ID);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toggle();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
|
|
|
|
|
data-testid="dictation-toggle"
|
2026-09-23 01:46:15 +03:00
|
|
|
|
// Same blurring as the floating chrome (owner, fix wave round 3),
|
|
|
|
|
|
// but lower opacity — the mic stays quieter than the model pills.
|
|
|
|
|
|
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
2026-09-17 19:59:14 +03:00
|
|
|
|
>
|
2026-09-25 01:52:20 +03:00
|
|
|
|
<MicIcon className="size-3.5" />
|
|
|
|
|
|
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Hint>
|
2026-09-17 19:59:14 +03:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-17 19:31:25 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-17 19:59:14 +03:00
|
|
|
|
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
2026-09-25 01:52:20 +03:00
|
|
|
|
<Hint
|
|
|
|
|
|
// The owner flagged this copy good ("Stop dictation (Esc)") —
|
|
|
|
|
|
// kept verbatim; only the machinery moved to the styled tooltip.
|
|
|
|
|
|
label="Stop dictation (Esc)"
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={toggle}
|
|
|
|
|
|
disabled={phase === "stopping"}
|
|
|
|
|
|
aria-label="Stop dictation"
|
2026-09-17 19:31:25 +03:00
|
|
|
|
data-testid="dictation-toggle"
|
2026-09-17 19:59:14 +03:00
|
|
|
|
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
2026-09-17 19:31:25 +03:00
|
|
|
|
>
|
|
|
|
|
|
{phase === "stopping" ? (
|
2026-09-17 19:59:14 +03:00
|
|
|
|
<Loader2Icon className="size-3.5 animate-spin" />
|
2026-09-17 19:31:25 +03:00
|
|
|
|
) : (
|
2026-09-17 19:59:14 +03:00
|
|
|
|
<MicIcon className="size-3.5" />
|
2026-09-17 19:31:25 +03:00
|
|
|
|
)}
|
2026-09-25 01:52:20 +03:00
|
|
|
|
Stop
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</Hint>
|
2026-09-17 19:59:14 +03:00
|
|
|
|
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
|
|
|
|
|
{/* Fixed width while recording — nothing ever appends text beside it
|
2026-09-17 20:06:07 +03:00
|
|
|
|
(no interims, owner round 1). Cells carry no minimum width; the
|
|
|
|
|
|
COUNT adapts to the measured row space (owner round 2). */}
|
|
|
|
|
|
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
|
|
|
|
|
{levels.slice(-cellCount).map((v, i) => (
|
2026-09-17 19:59:14 +03:00
|
|
|
|
<span
|
|
|
|
|
|
key={i}
|
2026-09-17 20:06:07 +03:00
|
|
|
|
className="min-w-0 flex-1 rounded-full bg-foreground/40"
|
2026-09-17 19:59:14 +03:00
|
|
|
|
style={{ height: `${2 + Math.round(v * 14)}px` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-09-17 19:31:25 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|