feat(dictation): add persistent language selection in General settings

This commit is contained in:
Bartosz Chronowki
2026-10-25 14:33:49 +01:00
parent b277ee3e0f
commit 3bb28e5f29
18 changed files with 1158 additions and 72 deletions
+49 -64
View File
@@ -4,6 +4,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { Hint } from "@/components/ui/hint";
import { Button } from "@/components/ui/button";
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
import { useDictationLanguage } from "@/components/dictation-preference";
import { setDictationActive } from "@/components/dictation-state";
export { isDictationActive } from "@/components/dictation-state";
/**
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
@@ -86,15 +91,7 @@ import { Hint } from "@/components/ui/hint";
* endpointing=300, no interims. Silence streams too (the worklet never
* pauses), so the socket never idles — no KeepAlive needed.
*/
const DG_URL =
"wss://api.deepgram.com/v1/listen" +
"?model=nova-3" +
"&encoding=linear16" +
"&sample_rate=16000" +
"&channels=1" +
"&smart_format=true" +
"&punctuate=true" +
"&endpointing=300";
// URL construction lives in the browser-safe core; only language varies.
/** Meter tuning — the owner's tested values, see the file header. */
const METER_FLOOR_DB = -50;
@@ -204,16 +201,6 @@ export function rmsToMeter(rms: number): number {
*/
let questionTarget: ((text: string) => void) | null = null;
/* 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;
}
/* The finisher registry: submit paths FAR from the hook's owner (the
* pending question's dock) need finish() without a prop bridge. Same
* module-level pattern as questionTarget — one window, one instance,
@@ -285,6 +272,7 @@ export type Dictation = {
* question waits.
*/
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
const { getLanguage } = useDictationLanguage();
const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
@@ -327,9 +315,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
}, []);
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => {
const cleanup = useCallback((warmNext = true) => {
genRef.current++;
dictationActiveFlag = false;
setDictationActive(false);
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) {
clearTimeout(safetyRef.current);
@@ -357,16 +345,16 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
for (const w of waitersRef.current.splice(0)) w();
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
// page just doesn't warm.
warmTokens.refresh();
if (warmNext) warmTokens.refresh();
}, []);
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
const openSocket = useCallback(
(gen: number, token: string) => {
(gen: number, token: string, language: DictationLanguage) => {
// 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]);
const ws = new WebSocket(deepgramListenUrl(language), ["bearer", token]);
wsRef.current = ws;
ws.addEventListener("open", () => {
@@ -408,7 +396,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
reconnectsRef.current++;
mintFreshToken() // always FRESH — a stale warm token is the likely culprit
.then((t) => {
if (gen === genRef.current) openSocket(gen, t);
if (gen === genRef.current) openSocket(gen, t, language);
})
.catch(() => {
if (gen === genRef.current) {
@@ -426,13 +414,23 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
);
const start = useCallback(async () => {
dictationActiveFlag = true;
setDictationActive(true);
setError(null);
setPhase("connecting");
reconnectsRef.current = 0;
bufferRef.current = []; // fresh run, fresh buffer (round 2)
pendingRef.current = []; // fresh run, fresh queue (mitigation A)
const gen = ++genRef.current;
let language: DictationLanguage;
try {
language = await getLanguage();
} catch (e) {
if (gen !== genRef.current) return;
setError(e instanceof Error ? e.message : "Dictation language unavailable");
cleanup();
return;
}
if (gen !== genRef.current) return;
// Mitigation D: the warm token (pre-minted at mount / after the last
// run) resolves ~instantly; a cold/keyless mint surfaces below. The
@@ -509,8 +507,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
if (gen !== genRef.current) return;
// Mitigation B: the handshake fires the moment the graph is live,
// overlapping any remaining token wait — mic-gated, never before it.
openSocket(gen, token);
}, [openSocket, cleanup]);
openSocket(gen, token, language);
}, [openSocket, cleanup, getLanguage]);
const stop = useCallback(() => {
const gen = genRef.current;
@@ -586,7 +584,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// attached.
useEffect(() => {
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless
}, []);
return () => {
// Navigation cancels capture: invalidate every pending await before
// releasing resources, and never append into a different page's input.
bufferRef.current = [];
cleanup(false);
};
}, [cleanup]);
useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle);
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
@@ -630,7 +634,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
* error keeps the old behavior (click = retry, which clears). */
const keyless = error === DICTATION_KEYLESS;
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
const recording = phase === "recording" || phase === "stopping";
/** Adaptive cell count (owner round 2): derived from the meter's
* measured width — narrow rows show fewer bars instead of
* overflowing under neighbors. */
@@ -673,17 +676,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) {
return (
<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
<Hint label={error ?? "Dictate"} action={error ? undefined : "toggle-dictation"} side="top">
<Button
type="button"
variant="outline"
size="xs"
onClick={() => {
if (keyless) {
clearError();
@@ -694,45 +691,33 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
}}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
data-testid="dictation-toggle"
// 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"
>
className="h-7 w-full min-w-0 gap-1.5 overflow-hidden border-border/50 bg-card/50 font-normal text-muted-foreground/70 shadow-lg backdrop-blur hover:border-border hover:bg-card/50 hover:text-muted-foreground dark:bg-card/50 dark:hover:bg-card/50"
>
<MicIcon className="size-3.5" />
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
</button>
</Button>
</Hint>
);
}
return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<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
<Hint label="Stop dictation (Esc)" side="top">
<Button
type="button"
variant="ghost"
size="xs"
onClick={toggle}
disabled={phase === "stopping"}
aria-label="Stop dictation"
data-testid="dictation-toggle"
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"
>
{phase === "stopping" ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<MicIcon className="size-3.5" />
)}
data-testid="dictation-toggle"
className="h-7 gap-1.5 bg-destructive/10 text-destructive hover:bg-destructive/20"
>
{phase === "stopping" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
Stop
</button>
</Button>
</Hint>
<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
(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) => (
<span