1fdeda904b
* feat: add voice mode for chat input Adds a voice input button to the chat composer with three transcription engines: in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY. User-configurable from a new /settings/voice-mode page: - Master on/off toggle (defaults to on; opt-out, not opt-in) - Engine preference (auto / Web Speech / Whisper Web / Whisper Server) - Language hint (auto-detect or BCP-47 pinned) - Activation mode (click-to-toggle or hold-to-talk) - Auto-send after transcription - Whisper Web model size (tiny / base / small) - Editable Cmd+Shift+M keyboard shortcut Voice settings live under user_settings.voice_mode as a nested JSON object — no schema migration. Setting changes broadcast via the existing user.settings.updated WS event so any open chat tab updates live. The mic button is rendered on the right of the composer alongside the send button (round primary fill; destructive fill with pulse ring while recording). On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button stays visible as a muted greyed icon — tapping it surfaces a toast with the reason (typically a missing secure context). Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled from the onnx-community/* mirror and cached by transformers.js in the Cache Storage on first use. * fix: address voice mode PR review feedback and E2E selector clash Bot-review fixes from #1159: - Map http.MaxBytesError from c.FormFile to 413 instead of bare 400. - Strip BCP-47 region suffix before passing language hint to Whisper Web. - Recover from worker crash: terminate + null worker + reset ready flag so next init/transcribe creates a fresh worker instead of hanging. - Read live store state via storeApi.getState() in voice-mode settings savers to avoid stale-closure clobbering concurrent settings updates. - Detach Web Speech callbacks before abort() so trailing onend/onerror events don't mutate hook state after cancel(). - Spread caller init before method/body in voice-api fetch so a caller- supplied init can't override the multipart POST. - Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine (FailNow from non-test goroutine is unsafe). - Add Enabled/AutoSend zeroing assertions in partial-update test so the bool-overwrite-on-PATCH behavior is locked in explicitly. E2E fix: - toolbar-overflow spec used button.rounded-full which now matches both the send and voice buttons (strict-mode violation). Switched to the submit-message-button testid for an unambiguous locator. * fix: scope voice settings rollback + whisper worker error to changed keys Round 2 of PR review feedback on #1159: - voice-mode settings rollback now restores only the keys patched by the failing request (not the whole snapshot), preserving concurrent edits to unrelated voiceMode fields. - Shortcut rollback diffs previous vs next and reverts only the changed keys (restore prior value or delete if absent), preserving other concurrent shortcut edits. - Whisper worker error handler captures the worker reference at attach time and only clears refs when this.worker still matches it — a stale error from a previously replaced worker no longer nukes the active one. - Update stale whisper-web-models JSDoc that still pointed at Xenova mirrors. * fix: voice mode progress scale, hold-mode race, race-aware rollback Round 3 of PR review feedback on #1159: - Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js emits 0–100, the button's display + the `ready: 1` convention expect 0–1 — fixes the 5000% mid-download display). - Claim driverRef synchronously at the top of finishCapture so concurrent pointerup + pointerleave invocations early-return instead of racing and clobbering a freshly-started recording's ref. - Race-aware rollback: voice-mode and shortcut catch branches now skip reverting a key when latest[key] !== next[key]. A failed earlier request no longer overwrites a later successful save to the same key. - Update the Worker wire-protocol comment to show onnx-community/whisper-base instead of the stale Xenova/* example. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { whisperModelConfig } from "./whisper-web-models";
|
|
import type { WhisperWebModelSize } from "@/lib/types/http-voice";
|
|
|
|
/**
|
|
* Sample rate Whisper expects. We resample the captured audio to this rate
|
|
* (mono Float32Array) before sending to the worker — Whisper's own decoder
|
|
* would do this too, but doing it here keeps the worker focused on inference.
|
|
*/
|
|
const WHISPER_SAMPLE_RATE = 16000;
|
|
|
|
export type WhisperWebProgress = {
|
|
stage: string;
|
|
progress: number;
|
|
};
|
|
|
|
export type WhisperWebHandlers = {
|
|
onProgress?: (p: WhisperWebProgress) => void;
|
|
};
|
|
|
|
type WorkerMessage =
|
|
| { type: "progress"; stage: string; progress: number }
|
|
| { type: "ready" }
|
|
| { type: "result"; text: string }
|
|
| { type: "error"; message: string };
|
|
|
|
type Pending = {
|
|
kind: "init" | "transcribe";
|
|
resolve: (value: string | undefined) => void;
|
|
reject: (err: Error) => void;
|
|
};
|
|
|
|
/**
|
|
* Client wrapper around the whisper-web worker. Hides the postMessage
|
|
* protocol behind a clean promise-based API and handles the audio decode +
|
|
* resample step so callers only see "Blob in, transcript out".
|
|
*/
|
|
export class WhisperWebClient {
|
|
private worker: Worker | null = null;
|
|
private pending: Pending | null = null;
|
|
private ready = false;
|
|
private loadingModelId: string | null = null;
|
|
|
|
constructor(private handlers: WhisperWebHandlers = {}) {}
|
|
|
|
/**
|
|
* Lazy-creates the worker on first use. Returns a promise that resolves
|
|
* when the requested model is loaded and ready to transcribe.
|
|
*/
|
|
async init(size: WhisperWebModelSize): Promise<void> {
|
|
const config = whisperModelConfig(size);
|
|
if (this.ready && this.loadingModelId === config.modelId) return;
|
|
this.ensureWorker();
|
|
this.loadingModelId = config.modelId;
|
|
this.ready = false;
|
|
await this.send({ kind: "init", payload: { type: "init", model: config.modelId } });
|
|
this.ready = true;
|
|
}
|
|
|
|
/**
|
|
* Transcribe a recorded blob. The blob may be in any container the browser
|
|
* can decode (audio/webm, audio/wav, audio/mp4, …) — we resample everything
|
|
* to 16 kHz mono Float32 before handing to the worker.
|
|
*/
|
|
async transcribe(blob: Blob, language?: string): Promise<string> {
|
|
if (!this.ready || !this.worker) {
|
|
throw new Error("WhisperWebClient: not initialized");
|
|
}
|
|
const audio = await blobToWhisperFloat32(blob);
|
|
const text = await this.send({
|
|
kind: "transcribe",
|
|
payload: { type: "transcribe", audio, language },
|
|
transfer: [audio.buffer],
|
|
});
|
|
return text ?? "";
|
|
}
|
|
|
|
/** Tear down the worker and release the loaded model. */
|
|
dispose(): void {
|
|
if (this.worker) {
|
|
try {
|
|
this.worker.postMessage({ type: "dispose" });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.worker.terminate();
|
|
this.worker = null;
|
|
}
|
|
this.ready = false;
|
|
this.loadingModelId = null;
|
|
if (this.pending) {
|
|
this.pending.reject(new Error("WhisperWebClient disposed"));
|
|
this.pending = null;
|
|
}
|
|
}
|
|
|
|
private ensureWorker() {
|
|
if (this.worker) return;
|
|
// The `new Worker(new URL(..., import.meta.url))` form is Next.js / webpack's
|
|
// recommended pattern — webpack handles the bundling and asset path.
|
|
this.worker = new Worker(new URL("../../workers/whisper-web.worker.ts", import.meta.url), {
|
|
type: "module",
|
|
});
|
|
this.worker.addEventListener("message", (e: MessageEvent<WorkerMessage>) =>
|
|
this.handleMessage(e.data),
|
|
);
|
|
// Capture the worker reference at listener-attach time. A late error from
|
|
// a previously-disposed worker can still bubble up after we've already
|
|
// created its replacement; without the identity check below, that stale
|
|
// event would terminate the brand-new worker too.
|
|
const ownWorker = this.worker;
|
|
this.worker.addEventListener("error", (e) => {
|
|
const err = new Error(e.message || "Whisper worker crashed");
|
|
ownWorker?.terminate();
|
|
// Only clear our refs if this is still the active worker — a stale
|
|
// error from a worker we already replaced must not nuke the new one.
|
|
if (this.worker === ownWorker) {
|
|
this.worker = null;
|
|
this.ready = false;
|
|
this.loadingModelId = null;
|
|
}
|
|
if (this.pending) {
|
|
this.pending.reject(err);
|
|
this.pending = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
private send(args: {
|
|
kind: "init" | "transcribe";
|
|
payload: object;
|
|
transfer?: Transferable[];
|
|
}): Promise<string | undefined> {
|
|
if (!this.worker) throw new Error("WhisperWebClient: worker not initialized");
|
|
if (this.pending) {
|
|
return Promise.reject(new Error("WhisperWebClient: another request is in flight"));
|
|
}
|
|
return new Promise<string | undefined>((resolve, reject) => {
|
|
this.pending = { kind: args.kind, resolve, reject };
|
|
this.worker?.postMessage(args.payload, args.transfer ?? []);
|
|
});
|
|
}
|
|
|
|
private handleMessage(msg: WorkerMessage) {
|
|
if (msg.type === "progress") {
|
|
this.handlers.onProgress?.({ stage: msg.stage, progress: msg.progress });
|
|
return;
|
|
}
|
|
const pending = this.pending;
|
|
if (!pending) return;
|
|
this.pending = null;
|
|
if (msg.type === "error") {
|
|
pending.reject(new Error(msg.message));
|
|
return;
|
|
}
|
|
if (msg.type === "ready") {
|
|
pending.resolve(undefined);
|
|
return;
|
|
}
|
|
if (msg.type === "result") {
|
|
pending.resolve(msg.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode an arbitrary audio Blob and return a Float32Array sampled at 16 kHz
|
|
* mono — the format Whisper expects.
|
|
*/
|
|
export async function blobToWhisperFloat32(blob: Blob): Promise<Float32Array> {
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
// Decode using an AudioContext at the source rate, then bounce through an
|
|
// OfflineAudioContext for the resample. AudioContext.decodeAudioData
|
|
// tolerates webm/opus, mp4/aac, wav, ogg — anything the browser can play.
|
|
const AudioCtor =
|
|
window.AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AudioCtor) throw new Error("AudioContext is not available in this browser");
|
|
const decodeCtx = new AudioCtor();
|
|
let decoded: AudioBuffer;
|
|
try {
|
|
decoded = await decodeCtx.decodeAudioData(arrayBuffer);
|
|
} finally {
|
|
await decodeCtx.close();
|
|
}
|
|
return resampleToMono16k(decoded);
|
|
}
|
|
|
|
async function resampleToMono16k(buf: AudioBuffer): Promise<Float32Array> {
|
|
const length = Math.ceil((buf.duration * WHISPER_SAMPLE_RATE) / 1);
|
|
const offline = new OfflineAudioContext(1, length, WHISPER_SAMPLE_RATE);
|
|
const source = offline.createBufferSource();
|
|
source.buffer = buf;
|
|
source.connect(offline.destination);
|
|
source.start(0);
|
|
const rendered = await offline.startRendering();
|
|
return rendered.getChannelData(0).slice();
|
|
}
|