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
9 changes: 6 additions & 3 deletions src/islands/media/VoiceToText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default function VoiceToText() {
const [subFormat, setSubFormat] = useState<'srt' | 'vtt'>('srt');
const [error, setError] = useState('');
const urlRef = useRef('');
const audioRef = useRef<HTMLAudioElement>(null);

// Pick up a finished recording as the working audio.
useEffect(() => {
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function VoiceToText() {
if (recorder.recording) {
recorder.stop();
} else {
audioRef.current?.pause();
setSegments(null);
setError('');
recorder.start();
Expand All @@ -74,14 +76,15 @@ export default function VoiceToText() {

const transcribe = async () => {
if (!audioBlob) return;
audioRef.current?.pause(); // don't leave the preview playing while inference blocks the thread
setError('');
setSegments(null);
setTranscribing(true);
setModelProgress(0);
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 — now inference (indeterminate)
setModelProgress(null); // model ready (or cached) — now inference (indeterminate)
const segs = await engine.transcribe(audio);
setSegments(segs);
setEditedText(segmentsToText(segs));
Expand Down Expand Up @@ -135,7 +138,7 @@ export default function VoiceToText() {
{recorder.error && <Alert variant="error">{recorder.error.message}</Alert>}

{audioUrl && (
<audio controls src={audioUrl} className="w-full" />
<audio ref={audioRef} controls src={audioUrl} className="w-full" />
)}

{/* Model + run */}
Expand Down
20 changes: 18 additions & 2 deletions src/tools/media/stt.engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,31 @@ async function webgpuAvailable(): Promise<boolean> {
}
}

// Cache the built pipeline so repeated transcriptions with the same model reuse
// it — otherwise every run re-initializes the ONNX session (re-reading weights,
// re-running the load progress, blocking the main thread).
let cached: { model: SttModelId; transcriber: Transcriber } | null = null;

/** Drop the cached transcriber (e.g. for tests). */
export function resetTranscriber(): void {
cached = null;
}

/**
* Create the on-device speech-to-text engine. This is the ONLY file that touches
* transformers.js — keep it thin so the SDK stays swappable. WebGPU is used when
* available, otherwise a quantized WASM model keeps the download smaller.
*
* Audio never leaves the browser; only the model weights are fetched (from the HF
* CDN) the first time a model is used, then cached by the browser.
* CDN) the first time a model is used, then cached by the browser. The built
* pipeline is cached in-memory so subsequent runs skip re-initialization.
*/
export async function createTranscriber(
model: SttModelId,
onProgress?: (ratio: number) => void,
): Promise<Transcriber> {
if (cached && cached.model === model) return cached.transcriber;

const { pipeline } = await import('@huggingface/transformers');
const backend: SttBackend = (await webgpuAvailable()) ? 'webgpu' : 'wasm';

Expand All @@ -57,7 +70,7 @@ export async function createTranscriber(
},
});

return {
const transcriber: Transcriber = {
backend,
async transcribe(audio: Float32Array): Promise<TranscriptSegment[]> {
const out = (await pipe(audio, {
Expand All @@ -77,4 +90,7 @@ export async function createTranscriber(
return [{ start: 0, end: 0, text: out.text ?? '' }];
},
};

cached = { model, transcriber };
return transcriber;
}
Loading