Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions src/islands/media/VoiceToText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +62,7 @@ export default function VoiceToText() {
const [language, setLanguage] = useState('');
const [modelProgress, setModelProgress] = useState<number | null>(null);
const [transcribing, setTranscribing] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [segments, setSegments] = useState<TranscriptSegment[] | null>(null);
const [editedText, setEditedText] = useState('');
const [tab, setTab] = useState<Tab>('text');
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -231,7 +252,12 @@ export default function VoiceToText() {
<ProgressBar percent={modelProgress * 100} label="Downloading model (first time only)" />
)}
{busy && modelProgress === null && (
<p className="text-sm text-muted-foreground">Transcribing on your device… this can take a moment.</p>
<p className="text-sm text-muted-foreground">
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.'}
</p>
)}

{error && <Alert variant="error">{error}</Alert>}
Expand Down
48 changes: 48 additions & 0 deletions src/tools/media/stt.client.ts
Original file line number Diff line number Diff line change
@@ -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<TranscriptSegment[]> {
return new Promise((resolve, reject) => {
const w = getWorker();
const onMessage = (e: MessageEvent<WorkerMsg>) => {
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]);
});
}
23 changes: 23 additions & 0 deletions src/tools/media/stt.worker.ts
Original file line number Diff line number Diff line change
@@ -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<Req>) => {
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' });
}
};
Loading