Self-Hosted Voice Dictation with Whisper and Parakeet

I run speech recognition on my home server (a GMKtec NucBox with Intel Iris Xe graphics) for voice dictation. My phone runs Mouthful, my Android dictation app, and my weaker devices run Vocalinux. Both send their audio to mouthful-server, a small server in Mouthful’s repo that puts a speech engine behind whisper-server’s API: OpenAI’s Whisper or NVIDIA’s Parakeet. It applies my custom dictionary of jargon, can have an LLM clean up each transcript, and logs stats about each dictation. It all runs in Docker Compose on my Arch Linux server.

Engines

The server’s config chooses the engine and whether an LLM cleans up the transcript, so that I can switch without changing my clients. To compare the choices, I read two of my blog posts into both my laptop and my phone. Then I split each recording at pauses into chunks of up to 12 seconds, like Vocalinux does, and scored each engine:

Setup (laptop / phone)Jargon spelled right (of 21)Word error rate on plain proseTime per chunk
Whisper9 / 85.6% / 2.8%1.2 to 1.3 s
Whisper with cleanup10 / 125.1% / 2.2%3.1 to 3.3 s
Parakeet4 / 52.2% / 2.8%0.3 s
Parakeet with cleanup10 / 132.2% / 1.1%2.4 to 2.7 s

Jargon only counts if the capitalization is exact. Over Wi-Fi, my phone’s round trip adds about 0.2 seconds to these times, about 40% of which is the upload.

Whisper (small.en-q8_0) spells the most jargon right on its own, because it can take my dictionary as a prompt. On my NucBox, it transcribes a 10-second utterance in about 1 second. medium.en-q8_0 took 2.6 seconds and large-v3-turbo-q8_0 took 3.1 seconds. Published benchmarks show that the larger models are more accurate on realistic speech (e.g. about 30% fewer errors on Common Voice for large-v2 vs small.en). But on my recordings, large-v3-turbo made only slightly fewer errors than small.en on plain prose and no fewer on jargon. Shorter audio doesn’t help, since Whisper always processes a full 30-second window. (The --audio-ctx option shrinks that window, but it destroyed accuracy in my tests.) The q8_0 models were also faster than their f16 versions on this GPU.

Parakeet (TDT 0.6B v3) is about four times as fast and makes fewer errors on plain prose, especially from my laptop’s noisy mic. But it can’t take a prompt, so it misspells most of my jargon. It runs on the GPU through whisper.cpp’s libparakeet, which only comes with a command-line tool, so I wrap it in a small server. Earlier, I ran Parakeet TDT 0.6B v2 on the CPU through onnx-asr, which was slower and less accurate.

Cleanup has Qwen3.5 4B fix misheard words with my dictionary as a glossary, and fix punctuation, capitalization, and filler words. It takes about 1 to 1.5 seconds on my real dictations, which are shorter than the chunks above. Parakeet with cleanup made the fewest errors and spelled the most jargon right. If the LLM changes too much of a transcript, e.g. by answering a dictated question, the server keeps the original.

Whisper uses Silero VAD to skip audio without speech. Without it, Whisper often transcribes silence as “Thank you.” But VAD’s default padding of 30 ms also clips short words at the edges of speech. When my phone kept losing the first word of a dictation, I first suspected the app’s mic, but it starts recording within 60 ms of the tap. In 24 short clips that started just before a phrase, VAD dropped a first or last word (e.g. “and” or “or”) in 4. Padding of 200 ms fixed all of them, while 400 ms let Whisper hallucinate a lone “and” again.

Dictionary

Speech recognition often misspells jargon, e.g. “Vocalinox” for Vocalinux and “ripcrep” for ripgrep. mouthful-server uses my dictionary to fix this in three ways:

  1. It sends the dictionary’s terms to Whisper as a prompt, which makes Whisper more likely to spell them right. On the passage about Unix shells above, my dictionary raised the jargon that Whisper spelled right from 3 to 9 terms on my laptop and from 5 to 8 on my phone, with almost no added latency. Ignoring capitalization, Whisper already got most of them (12 and 16 of 21), so the prompt mostly fixes capitalization, e.g. “zsh” to “Zsh”. But Whisper only reads the last 224 tokens (roughly 700 characters) of a prompt.
  2. It gives the terms to the cleanup LLM as a glossary.
  3. It replaces known mishearings in the transcript, e.g. “ripcrep” with “ripgrep”. This fixes words that the prompt can’t fix, works for both engines, and has no size limit.

(Vocalinux has its own custom dictionary, but its Remote API engine doesn’t send the terms to the server.)

Create ~/.config/mouthful/dictionary.txt. Each line is a term, optionally followed by a colon and comma-separated mishearings. Mishearings match case-insensitively and only as whole words, and a space also matches a hyphen. To fix a term’s capitalization, list the term itself as a mishearing. Put the most important terms first, since only they fit in Whisper’s prompt. The server rereads the file on every request, so edits apply immediately.

# Only list terms that speech recognition gets wrong, and only list mishearings that I never mean literally
Vocalinux: vocalinox, vocal linux
KeePassXC: keypass xc, keepass xc, key pass xc
Syncthing: syncthing, sync thing, singthing
tmux: teamux, teamox, team mox
ripgrep: ripcrep, rip crep
yq: ik

Setup

mouthful-server lives in the server folder of Mouthful’s repo. It needs Linux with Docker and an Intel or AMD GPU, although I’ve only tested Intel. For NVIDIA GPUs, edit compose.yaml and the Dockerfile there.

git clone https://github.com/artnc/mouthful.git
cd mouthful
make server

This downloads the four models (about 3.5 GB) at pinned versions, checks their hashes, and starts up to three containers:

Parakeet with cleanup is the default. To change it, I put e.g. ENGINE=whisper and CLEANUP=0 in server/.env and rerun make server, which takes a few seconds.

Stats

For each dictation, mouthful-server adds a line to ~/.local/share/mouthful/dictations.jsonl with the time, the audio length, the engine and its timing, the cleanup result, the word count before and after cleanup, the number of dictionary fixes, and the client’s user agent. It never logs audio or text. If a client loses a response and sends the same audio again, the server logs the dictation only once. A line looks like this:

{"asr_model":"parakeet-tdt-0.6b-v3-q8_0","asr_ms":342,"audio_ms":11000,"cleanup":"applied","cleanup_ms":1051,"fixes":0,"llm_model":"Qwen3.5-4B-Q4_0","raw_words":22,"time_ms":1793226433313,"total_ms":1404,"user_agent":"curl/8.23.0","words":22}

Run make stats in the repo for a summary per engine and cleanup setting. Words per minute include pauses. Here’s mine so far:

engine              cleanup  dictations  words  wpm  median_s  rejected
ggml-small.en-q8_0  false    121         876    117  0.788     0

Clients

To set up Vocalinux on a client:

  1. Run yay -S python-onnxruntime vocalinux and then vocalinux
  2. In Vocalinux’s Settings > Speech Model > Advanced > Engine, select Remote API
  3. Set the server URL to http://<server hostname or IP>:8080, and keep the endpoint at /inference

Clients of OpenAI’s API can also POST to /v1/audio/transcriptions.

Why not VocaGateway?

The makers of Vocalinux also publish VocaGateway, a self-hosted transcription server for their apps. It’s far more polished than my setup, with a web UI, phone pairing, and dozens of models. So I ran its Docker image (commit f7180fb, CPU build, with its recommended models) on my NucBox. Then I transcribed the same two passages as above, read into both my laptop and my phone:

Setup (laptop / phone)Jargon spelled right (of 21)Word error rate on plain proseTime per chunk of up to 12 s
VocaGateway with Parakeet TDT 0.6B v22 / 23.4% / 4.5%0.6 s
Same, with its transcript cleanup2 / 23.4% / 4.5%3.5 to 4.0 s
VocaGateway with small.en5 / 33.9% / 3.4%2.5 s
My Whisper, with greedy decoding at the time6 / 116.7% / 5.1%1.0 s
My Parakeet with cleanup10 / 132.2% / 1.1%2.5 to 2.75 s

Jargon only counts if the capitalization is exact.

What didn’t help

Ideas that I haven’t tried