diff --git a/src/islands/media/VoiceToText.tsx b/src/islands/media/VoiceToText.tsx index d77c749..0e9ab34 100644 --- a/src/islands/media/VoiceToText.tsx +++ b/src/islands/media/VoiceToText.tsx @@ -8,7 +8,8 @@ import { CopyButton } from '@/components/ui/CopyButton'; import { downloadService } from '@/services/download'; import { useAudioRecorder } from '@/hooks/useAudioRecorder'; import { decodeToMono16k } from '@/tools/media/stt-audio.lib'; -import { createTranscriber, type SttModelId } from '@/tools/media/stt.engine'; +import { transcribeInWorker } from '@/tools/media/stt.client'; +import { type SttModelId } from '@/tools/media/stt.engine'; import { segmentsToText, segmentsToSrt, @@ -61,6 +62,7 @@ export default function VoiceToText() { const [language, setLanguage] = useState(''); const [modelProgress, setModelProgress] = useState(null); const [transcribing, setTranscribing] = useState(false); + const [elapsed, setElapsed] = useState(0); const [segments, setSegments] = useState(null); const [editedText, setEditedText] = useState(''); const [tab, setTab] = useState('text'); @@ -77,6 +79,21 @@ export default function VoiceToText() { // Revoke the preview URL on unmount. useEffect(() => () => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); }, []); + // Ask the browser to keep this origin's storage persistent, so the (potentially + // large) cached Whisper model isn't evicted between sessions and re-downloaded. + useEffect(() => { + navigator.storage?.persist?.().catch(() => {}); + }, []); + + // Tick an elapsed counter while transcribing (the worker keeps the UI responsive). + useEffect(() => { + if (!transcribing) return; + setElapsed(0); + const started = Date.now(); + const id = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); + return () => clearInterval(id); + }, [transcribing]); + const setAudio = (blob: Blob) => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); const url = URL.createObjectURL(blob); @@ -123,10 +140,14 @@ export default function VoiceToText() { setModelProgress(null); // only shows once real download progress fires (first load) try { const audio = await decodeToMono16k(audioBlob); - const engine = await createTranscriber(model, r => setModelProgress(r)); - setModelProgress(null); // model ready (or cached) — now inference (indeterminate) const isMultilingual = MODELS.find(m => m.value === model)?.multilingual; - const segs = await engine.transcribe(audio, { language: isMultilingual ? language || undefined : undefined }); + const segs = await transcribeInWorker( + audio, + model, + isMultilingual ? language || undefined : undefined, + r => setModelProgress(r), + ); + setModelProgress(null); // model ready (or cached) — now inference (indeterminate) setSegments(segs); setEditedText(segmentsToText(segs)); setTab('text'); @@ -231,7 +252,12 @@ export default function VoiceToText() { )} {busy && modelProgress === null && ( -

Transcribing on your device… this can take a moment.

+

+ Transcribing on your device… ({formatClock(elapsed)}) + {MODELS.find(m => m.value === model)?.value === 'onnx-community/whisper-small' + ? ' — the “Better” model is much slower, especially on phones; a short clip can take a few minutes.' + : ' this can take a moment.'} +

)} {error && {error}} diff --git a/src/tools/media/stt.client.ts b/src/tools/media/stt.client.ts new file mode 100644 index 0000000..111778f --- /dev/null +++ b/src/tools/media/stt.client.ts @@ -0,0 +1,48 @@ +import type { SttModelId } from './stt.engine'; +import type { TranscriptSegment } from './stt.lib'; + +// A single long-lived worker so the model cache inside it persists across runs. +let worker: Worker | null = null; + +function getWorker(): Worker { + if (!worker) { + worker = new Worker(new URL('./stt.worker.ts', import.meta.url), { type: 'module' }); + } + return worker; +} + +interface ProgressMsg { type: 'progress'; ratio: number } +interface ReadyMsg { type: 'ready' } +interface ResultMsg { type: 'result'; segments: TranscriptSegment[] } +interface ErrorMsg { type: 'error'; message: string } +type WorkerMsg = ProgressMsg | ReadyMsg | ResultMsg | ErrorMsg; + +/** + * Transcribe on a background worker so the main thread (UI) stays responsive. + * `onProgress` reports model-download progress (0..1). Resolves with the segments. + */ +export function transcribeInWorker( + audio: Float32Array, + model: SttModelId, + language: string | undefined, + onProgress?: (ratio: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const w = getWorker(); + const onMessage = (e: MessageEvent) => { + const m = e.data; + if (m.type === 'progress') onProgress?.(m.ratio); + else if (m.type === 'result') { cleanup(); resolve(m.segments); } + else if (m.type === 'error') { cleanup(); reject(new Error(m.message)); } + }; + const onError = () => { cleanup(); reject(new Error('The transcription worker crashed.')); }; + const cleanup = () => { + w.removeEventListener('message', onMessage as EventListener); + w.removeEventListener('error', onError); + }; + w.addEventListener('message', onMessage as EventListener); + w.addEventListener('error', onError); + // Transfer the audio buffer to avoid a copy (we don't reuse it on this side). + w.postMessage({ audio, model, language }, [audio.buffer]); + }); +} diff --git a/src/tools/media/stt.worker.ts b/src/tools/media/stt.worker.ts new file mode 100644 index 0000000..63560a7 --- /dev/null +++ b/src/tools/media/stt.worker.ts @@ -0,0 +1,23 @@ +// Runs Whisper transcription off the main thread so the UI never freezes during +// inference (which can take a while for larger models on WASM/CPU). +import { createTranscriber, type SttModelId } from './stt.engine'; + +interface WorkerCtx { + postMessage(msg: unknown): void; + onmessage: ((e: MessageEvent) => void) | null; +} +const ctx = self as unknown as WorkerCtx; + +interface Req { audio: Float32Array; model: SttModelId; language?: string } + +ctx.onmessage = async (e: MessageEvent) => { + const { audio, model, language } = e.data; + try { + const engine = await createTranscriber(model, r => ctx.postMessage({ type: 'progress', ratio: r })); + ctx.postMessage({ type: 'ready' }); + const segments = await engine.transcribe(audio, { language }); + ctx.postMessage({ type: 'result', segments }); + } catch (err) { + ctx.postMessage({ type: 'error', message: err instanceof Error ? err.message : 'Transcription failed' }); + } +};