Chrome extension that transcribes voice messages on WhatsApp Web, Telegram Web and Discord — entirely in the browser, with no audio sent anywhere.
Transcription runs locally using Whisper via transformers.js and ONNX Runtime — on the GPU through WebGPU where available, falling back to WebAssembly otherwise. No API key or account required.
| Platform | URL |
|---|---|
| WhatsApp Web | web.whatsapp.com |
| Telegram Web | web.telegram.org |
| Discord | discord.com |
- 100% local — no API key, no server, no audio sent anywhere
- On-demand — only active while the floating panel is open; dormant otherwise
- Cached — the same audio is never transcribed twice (SHA-256 hash stored in
chrome.storage.local) - Four Whisper models — Tiny (~41 MB), Base (~77 MB), Small (~325 MB, recommended), Large v3 Turbo (~759 MB)
- Live transcript — text appears as the model decodes it, not once the whole clip is done
- Silence trimming — non-speech is cut before inference: faster, and fewer hallucinations
- 14 languages — select your language once in settings
- Model management — download and delete models directly from the settings panel
- Visual Queue — placeholder bubbles indicate which queued voice messages are waiting to be processed
- Pause — keep the panel open but temporarily stop transcribing new messages
- Silent Mode — automatically mute the voice message while transcribing
- Export — save all transcriptions to a text file (in settings)
- Timestamps — optional [MM:SS] markers in the text
- Floating panel — draggable, persistent across navigation, close and reopen any time
- Copy — click any entry to copy it, or use "Copy all"
- Stop — cancel an in-progress transcription without unloading the model from memory
- Chrome 112 or later (Manifest V3 + Offscreen Documents API)
- Node.js 18 or later (build only)
git clone https://github.com/RobertoReale/Voice-Message-Transcriber.git
cd whatsapp-web-transcriber
npm install
npm run buildThen load the extension in Chrome:
- Go to
chrome://extensions/ - Enable Developer mode (top-right toggle)
- Click Load unpacked → select the
dist/folder
- Open any supported platform
- Click the extension icon (top-right of Chrome) to open the transcription panel
- First run: open ⚙ Settings, select your language, then choose a model — it downloads automatically (~325 MB for the recommended Whisper Small, ~77 MB for Base if you are on a metered connection)
- Keep the panel open and play any voice message — the transcription appears in the panel
- Click any entry to copy it to the clipboard
Note: The extension is dormant while the panel is closed — no audio is intercepted or transcribed.
| Button | Action |
|---|---|
| ⚙️ | Settings — language, model, download, export, timestamps and more |
| ⏸️/ |
Pause/Resume transcription (keeps panel open but stops processing new messages) |
| 📋 | Copy all transcriptions to clipboard |
| 🔊/🔇 | Toggle Silent Mode (mutes the audio of the voice message when transcribing) |
| 🗑 | Clear the visible list (cache preserved — same audio won't re-transcribe) |
| ⏹ | Stop the current in-progress transcription |
| ✕ | Hide panel (reopen from extension icon) |
Export and cache controls are in ⚙ Settings.
src/
├── injected/interceptor.ts — HTMLMediaElement.src patch (world: MAIN, document_start)
├── content/index.ts — fetch, OGG/Opus decode, cache check, port to SW
├── background/service-worker.ts — port router, offscreen lifecycle, model cache reconciliation
├── offscreen/whisper.ts — Whisper pipeline (transformers.js + ONNX WebGPU/WASM)
└── ui/ — floating panel, settings, styles, drag
injected/interceptor.ts runs in the page context (world: "MAIN") at document_start, before any site JS. It patches HTMLMediaElement.prototype.src. Two patterns are captured:
- Blob URLs (
blob:https://...): WhatsApp Web and Telegram Web download audio to memory before playing it, producing a blob URL. - Discord CDN URLs (
cdn.discordapp.com/.../voice-message*): Discord voice messages are served directly from the CDN as signed HTTPS URLs.
The manifest restricts which origins the script is injected on, so the patch is a no-op on every other site.
Site assigns audio URL (blob or CDN)
→ injected script (.src setter fires → postMessage to content script)
→ content script
fetch(url) → ArrayBuffer
AudioContext.decodeAudioData() → 16 kHz Float32 PCM
SHA-256 hash → check chrome.storage.local
[cache hit] → display immediately
[cache miss] → open port to service worker
→ service worker
chrome.runtime.sendMessage(WHISPER_TRANSCRIBE)
→ offscreen document
load / reuse Whisper model (ONNX WebGPU, WASM fallback)
run inference
← text
store in chrome.storage.local
← TRANSCRIBE_RESULT via port
display in panel
OGG/Opus decoding happens in the content script (not the offscreen document) because Chrome MV3 offscreen documents lack the codec in AudioContext.
The content-script ↔ service-worker channel is a long-lived port (chrome.runtime.connect), which keeps the MV3 service worker alive for the entire duration of the transcription.
Models are fetched from Hugging Face via transformers.js and stored in the browser's Cache API (caches.open('transformers-cache')). After the first download no network request is made.
| Model | Encoder | Decoder | Size | Device | Notes |
|---|---|---|---|---|---|
onnx-community/whisper-tiny |
q8 | q8 | ~41 MB | WASM | fastest · lowest accuracy |
onnx-community/whisper-base |
q8 | q8 | ~77 MB | WASM | fast · light |
onnx-community/whisper-small |
q8 | q4 | ~325 MB | WebGPU | default · recommended |
onnx-community/whisper-large-v3-turbo |
q4 | q4 | ~759 MB | WebGPU | most accurate · slowest |
Sizes are encoder_model + decoder_model_merged at the dtypes above — the only two ONNX files transformers.js loads for Whisper — and are derived in code from sizeMb so the panel can never quote a figure the build does not actually download.
Quantization is per module, not per model. The transformers.js documentation is explicit that Whisper is "extremely sensitive to quantization settings, especially of the encoder", so the encoder never goes below int8 (q8). The exception is Large v3 Turbo, whose int8 encoder is 645 MB against 425 MB for q4. Decoders are q4 where the model runs on WebGPU, which serves MatMulNBits well, and q8 on WASM, where the int8 kernels are the mature path.
Note that on the small models 4-bit is larger than 8-bit: MatMulNBits carries scales and zero-points and leaves the embedding tables in fp32, which dominate at that scale. Versions ≤1.2.1 used a flat q4 and shipped Whisper Tiny as 96 MB behind a "~40 MB" label; the same model at q8 really is 41 MB.
Small and Large v3 Turbo run on the GPU through WebGPU, with automatic fallback to WASM if the adapter is unavailable or the GPU pipeline throws. Tiny and Base stay on WASM: they are genuinely faster there, because at that model size the GPU dispatch overhead costs more than the compute it saves. WASM inference uses up to 4 threads when SharedArrayBuffer is available.
WebGPU used to be disabled for all q4 models, because ONNX Runtime's WebGPU backend returned [Music] instead of speech on them. Re-measured on 2026-08-04 against the pinned dependency stack, which has not changed since the first commit: output is character-identical to WASM on all three models, on real voice messages, and WebGPU is 1.5x faster on Small and 2.8x on Large v3 Turbo. The failure does not reproduce on any ONNX Runtime version from 1.21.0-dev (Oct 2024) to 1.26.0-dev, nor on Chrome 135 through 150 — see docs/webgpu-onnxruntime-fix.md for the method, the numbers and what was ruled out. A q8 decoder stays off the GPU: that is where the old untested "Can't perform where op" crash came from, Where being part of the decoder's attention masking rather than anything in the encoder.
Changing a dtype changes the ONNX filename, so the weights fetched by a previous version would otherwise sit in the Cache API forever. On update the service worker evicts every cached .onnx a model no longer loads, and marks that model as needing a re-download.
Key inference settings that reduce hallucinations and repetition loops:
num_beams: 1,temperature: 0— fast deterministic decodingrepetition_penalty: 1.1— discourages repeating tokenscondition_on_prev_tokens: false— prevents chunk-boundary repetitioncompression_ratio_threshold: 2.4,no_speech_threshold: 0.5— discard non-speech chunks- Post-processing: consecutive identical segments are stripped before display
Silence is what Whisper hallucinates on — the [Music] and "Subtitles by…" that isNonSpeech has to filter out after the fact. src/shared/vad.ts cuts the non-speech regions before inference instead, which also shortens the clip the model has to process.
It is an energy detector with hysteresis, not a neural VAD: it borrows Silero's framing (32 ms frames at 16 kHz) and its two-threshold structure, but decides on frame RMS against a noise floor estimated from the clip itself. detectSpeechRegions returns null — meaning "use the audio unchanged" — whenever the clip cannot be segmented confidently: too short, too little contrast between speech and background, or no speech found. A trim is only accepted if it saves at least a second and leaves at least half a second behind, because a false negative here deletes someone's words. Trimming is skipped entirely when timestamps are enabled, since removing audio would shift every timestamp away from the recording.
The first inference after a model load pays for shader compilation on WebGPU and the first pass through the graph on WASM. A throwaway one-token inference is queued right after the load so the user's first voice message does not pay it.
While decoding, a WhisperTextStreamer forwards the text as it is produced (TRANSCRIPTION_PARTIAL, coalesced to one message per 150 ms) and the panel fills the pending bubble in progressively. The displayed result still comes from the pipeline's own output — the stream only drives the preview, and on chunked audio it can repeat a word across a window seam.
- No audio leaves the browser
- No telemetry or analytics
- No external network requests after the one-time model download from Hugging Face
- Transcription cache stored in
chrome.storage.local, clearable from the panel at any time
- Summarization: Use a local SLM (Small Language Model) via WebGPU to summarize very long voice messages into bullet points.
- Built-in Translation: Use Whisper's native
task: "translate"to automatically translate foreign language voice messages to English (or your native language) on the fly. - Speaker Diarization: Separate and label text by different speakers (e.g., "Speaker 1", "Speaker 2") for group audio or calls.
npm run build # production build → dist/
npm run dev # same as build (reload extension manually in chrome://extensions after each build)
npm run lint # TypeScript type check (tsc --noEmit)
npm test # unit tests (node:test, TypeScript run directly)MIT
- transformers.js — in-browser Whisper via ONNX Runtime
- onnx-community — quantized Whisper ONNX models