You can run Whisper inside a web page today. The audio file never leaves the visitor's computer: the browser downloads the speech model once, caches it, and does the listening itself. No server, no API key, no per-minute bill.
This post shows how to build it with Transformers.js: a Web Worker, WebGPU with a WASM fallback, long files in blocks, and SRT/VTT output. It also covers the three things that tripped us up. A working version is at the end if you want to try it before reading the code.
1. Decode the file to 16 kHz mono
Whisper wants 16,000 samples per second, one channel. The Web Audio API resamples for you if you create the context at that rate:
async function decode(file) { const ctx = new AudioContext({ sampleRate: 16000 }); try { const audio = await ctx.decodeAudioData(await file.arrayBuffer()); const n = audio.length, mono = new Float32Array(n), ch = audio.numberOfChannels; for (let c = 0; c < ch; c++) { const d = audio.getChannelData(c); for (let i = 0; i < n; i++) mono[i] += d[i] / ch; } return mono; } finally { ctx.close(); } } This handles MP3, M4A, WAV, OGG, FLAC, and also the audio track of MP4 and WebM video, because the browser's own decoders do the work.
2. Load the model in a Web Worker
Recognition blocks the thread for seconds at a time, so it goes in a module worker. Try WebGPU first and fall back to WASM:
// stt-worker.js import { pipeline } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1"; async function load(id) { if (navigator.gpu && await navigator.gpu.requestAdapter().catch(() => null)) { try { return await pipeline("automatic-speech-recognition", id, { device: "webgpu", dtype: { encoder_model: "fp32", decoder_model_merged: "q4" }, }); } catch { /* fall through to the CPU */ } } return pipeline("automatic-speech-recognition", id, { device: "wasm", dtype: "q8" }); } onnx-community/whisper-tiny is about 40 MB, whisper-base about 80 MB and whisper-small about 250 MB. Base is a good default for clear speech. Small is noticeably better with accents, noise and languages other than English.
Pass progress_callback to pipeline() if you want a download bar. It reports loaded and total per file.
3. Work through long files in blocks
Handing an hour of audio to the pipeline in one call works, but the page shows nothing until the very end. Feed it 90-second blocks instead, shift each block's timestamps by its offset, and post partial results as you go:
const RATE = 16000, BLOCK = 90 * RATE; const opts = { chunk_length_s: 30, stride_length_s: 5, return_timestamps: true, task: "transcribe", language: "swedish" }; const segments = []; for (let start = 0; start < pcm.length; start += BLOCK) { const block = pcm.subarray(start, Math.min(pcm.length, start + BLOCK)); const offset = start / RATE; const out = await asr(block, opts); for (const c of out.chunks || []) { const s = c.timestamp?.[0] ?? 0; let e = c.timestamp?.[1]; if (e == null || e < s) e = Math.min(block.length / RATE, s + 5); // last chunk can lack an end if (c.text.trim()) segments.push({ start: offset + s, end: offset + e, text: c.text.trim() }); } postMessage({ type: "progress", done: offset + block.length / RATE, segments }); } 4. Write SRT and VTT
The two formats differ only in the header, the numbering and the millisecond separator:
function stamp(t, sep) { const ms = Math.max(0, Math.round(t * 1000)), p = (n, w) => String(n).padStart(w, "0"); return `${p(Math.floor(ms / 3600000), 2)}:${p(Math.floor(ms / 60000) % 60, 2)}:` + `${p(Math.floor(ms / 1000) % 60, 2)}${sep}${p(ms % 1000, 3)}`; } const srt = (segs) => segs.map((s, i) => `${i + 1}\n${stamp(s.start, ",")} --> ${stamp(s.end, ",")}\n${s.text}\n`).join("\n"); const vtt = (segs) => "WEBVTT\n\n" + segs.map((s) => `${stamp(s.start, ".")} --> ${stamp(s.end, ".")}\n${s.text}\n`).join("\n"); Both load straight into YouTube Studio and most video editors.
Three things that tripped us up
1. Always pass a language. With Transformers.js 3.5.1 and no language option, Whisper assumed English and translated a Swedish recording into English instead of transcribing it. It did not detect the language. Preselect a language from navigator.language and let the user change it.
2. The model host can refuse your page's origin. In our tests on 14 Nov, Hugging Face's model files answered 404 to browser requests whose Referer was a *.workers.dev page. The same requests from *.pages.dev and from real domains got 200. If the model "is not found" only in production, check where the page is hosted before you check the model ID.
3. The first run is the slow one. Downloading the model takes most of the first run's time. Say so in the UI ("fetching the listening model, first time only"), because people assume it has hung. Later runs load from the browser cache in a second or two.
When the browser is the wrong place
In-browser transcription is great for privacy and costs nothing to run, but it depends on the visitor's hardware, and it does not fit batch jobs or a backend pipeline. For those you want a server-side call to a bigger model.
Try it
Disclosure: we (SpellzCode) make both of these.
- Free, in the browser: Glyphcall Voice: transcribe is the code above as a finished page, with text, SRT and VTT downloads. There is also a text to speech page that runs the open Kokoro model the same way.
- As an API: /transcribe runs Whisper large-v3-turbo on our servers for $0.01 per file (files up to 5 MB at a public URL). There is no account: card credits start at $1, or an agent can pay per call in USDC over x402.
curl -H "Authorization: Bearer adt_…" \ "https://agent-data-tools.revmesh2074.workers.dev/transcribe?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FSpecial%3AFilePath%2FEn-us-hello.ogg" Automatic transcripts contain mistakes, wherever they run, so check names and numbers before you publish them.



