Owner scope: services/, /, the voice settings
section, docs/. The shell (frontend/) is the
frontend owner's; what it needs from this layer is in
docs/ #33.
Why it exists: the browser Web Speech API is missing or refuses service in pywebview (WebView2 / WebKit), and in Chrome/Edge it streams audio to the vendor's cloud. This layer runs both directions on the machine.
What works (measured on this machine, 2026-11-01/02)#
Windows 11, 16 logical CPUs, Python 3.12 venv, CPU only. Round trip = Piper
speaks a fixed sentence, Whisper transcribes the WAV, compared to the source
text (python -m services.voice roundtrip). These are synthetic-voice
numbers; real microphone speech has not been measured (no human
recordings were available to the agent).
| Language | STT (whisper small, int8, 2 threads, beam 5) |
TTS (Piper default voice) | Round trip |
|---|---|---|---|
English en |
detected en (p 0.99) |
en_US-lessac-medium |
WER 0.08 / CER 0.11 - the only difference is "twelve" → "12" |
German de |
detected de (p 0.999) |
de_DE-thorsten-medium |
WER 0.09 / CER 0.09 - "zwölf" → "12" |
Hindi hi |
detected hi (p 0.99) |
hi_IN-priyamvada-medium |
CER 0.08-0.12 over 6 runs (spelling: ताप्मान, दिग्री) |
Bengali bn |
detected bn (constrained, see below) |
bn_BD-google-medium (16 speakers, speaker 0) |
CER 0.31-0.42 in 5 of 6 runs, 1.0 (garbage) in 1 of 6. Works, not reliably |
Latency (warm worker):
| Step | Time |
|---|---|
| TTS, one sentence (3-5 s of audio) | 0.15-0.28 s (Piper, all four languages) |
| TTS, first use of a voice | +2-5 s (onnxruntime session); 4 voices stay loaded |
STT, one 3-5 s utterance, small, 2 threads |
4.4-6.2 s |
same, 4 threads (voice.threads: 4) |
3.2-4.1 s |
worker start + whisper small load |
~1-2 s, once per idle period |
Whisper always encodes a 30 s window, so STT time is roughly constant for short utterances: the 4-6 s is the price of one spoken turn on 2 threads.
Other model sizes, same clips:
| Model | Download | en / de | hi / bn | Time per utterance (2 threads) |
|---|---|---|---|---|
base |
148 MB | CER 0.11 / 0.00 | unusable: writes Urdu script for Hindi, garbage for Bengali | 1.5-1.8 s |
small (default) |
486 MB | CER 0.11 / 0.09 | 0.08 / ~0.35 | 4.4-6.2 s |
large-v3-turbo |
1.62 GB | CER 0.11 / 0.09 | 0.19 / 0.27 | 23-42 s |
So: small is the default; base is a fair choice for English/German-only
users who want speed; turbo is not worth it on CPU.
Also measured:
- WebM/Opus (what
MediaRecorderproduces) decodes via PyAV without an ffmpeg executable: all four languages went throughPOST /withapi/ voice/ transcribe audio/over real HTTP.webm;codecs=opus - Windows SAPI (system voice): this machine has only
Microsoft David DesktopandMicrosoft Zira Desktop(en-US). English round trip CER 0.11, 2.0 s per sentence (a PowerShell process per call). Hindi, Bengali, German: no system voice here, not tested. (The OneCore voices underSpeech_OneCoreare not visible toSystem.Speech.) - The worker runs at
BELOW_NORMALpriority on Windows (checked:/).api/ voice/ status.worker.priority == "below_normal"
Not tested: macOS say, Linux espeak-ng (code paths written, no machine),
the online edge-tts engine against the real service (only with a fake),
real microphone audio, noisy rooms, code-switched speech (Hinglish).
Capability matrix#
| Language | STT | TTS offline (Piper) | TTS system voice | TTS online (opt-in) | Tested |
|---|---|---|---|---|---|
| en | faster-whisper, offline, model on demand | 30+ en_US/en_GB voices, on demand (63 MB each) | Windows: David/Zira (installed by default) · macOS say · Linux espeak-ng |
edge-tts en-US-AriaNeural |
STT+Piper+SAPI round trip: yes |
| hi | same | 3 voices (priyamvada, pratham, rohan), on demand | Windows only with the Hindi language pack's speech voice · espeak-ng (robotic) | hi-IN-SwaraNeural |
STT+Piper: yes; system/online: no |
| bn | same (weak, see above) | 1 voice, 16 speakers (bn_BD-google-medium), on demand | espeak-ng (robotic); Windows has no Bengali SAPI voice in standard packs | bn-IN-TanishaaNeural |
STT+Piper: yes (unreliable); system/online: no |
| de | same | 10 voices (thorsten, kerstin, eva_k, ...), on demand | Windows German language pack · macOS · espeak-ng | de-DE-KatjaNeural |
STT+Piper: yes; system/online: no |
| 50+ others | Whisper's 99 languages | Piper catalogue: ar bg ca cs cy da el es et eu fa fi fr he hu hy id is it ja ka kk ko ku lb lt lv ml mr ne nl no pl pt ro ru sk sl sq sr sv sw te th tr uk ur vi zh | whatever the OS has | 24 configured | not tested |
The full, live matrix is GET / (per language: engine,
voice id, installed, quality, offline, download size).
MMS-TTS (facebook/, torch) was not built: the Piper catalogue
has Hindi and Bengali voices (checked 2026-11-01), so the torch stack (and
its separate venv) is not needed for the four core languages. It remains the
option for a language Piper lacks (e.g. Tamil, Gujarati, Punjabi); see
REQUESTS.md.
Architecture#
shell ──HTTP──> api.py ──(path startswith /api/voice/)──> services/voice/routes.py
│
VoiceService (service.py)
┌──────────────────────┬───────────────────────┼──────────────────────┐
downloads (download.py) system voices (system.py) online (online.py) WorkerClient (worker.py)
urllib -> ~/.imurun/voice SAPI / say / espeak-ng edge-tts, OFF │ stdin/stdout frames
voice.model.download SSE child process, low prio by default ▼
voice worker process
(BELOW_NORMAL / nice 10)
engines.py: WhisperSTT, PiperTTS- The server process never imports an engine.
has_moduleusesimportlib.util.find_spec; faster-whisper, CTranslate2, onnxruntime and Piper load only in the worker (python -m services.voice.worker, same interpreter). Reasons: process priority covers the engines' own thread pools; killing the process really frees the RAM; a native crash cannot take down the server. - Resources.
voice.threads(default 2) bounds CTranslate2cpu_threads, onnxruntimeintra_op_num_threadsandOMP_NUM_THREADS. One request at a time (a lock). The worker exits aftervoice.idleUnloadMinutes(default 10) without requests and is respawned on the next one (POST /does it now). It never downloads (api/ voice/ unload HF_HUB_OFFLINE=1,local_files_only); its working directory is~/..imurun/ voice/ tmp - Downloads run in the server (threads), stream to
<file>.partand are renamed when complete (resumable with HTTP Range). Progress is published asvoice.model.downloadevery 0.5 s. Sources:Systran/andfaster-whisper-* rhasspy/on Hugging Face.piper-voices - Language handling.
- STT: with
autoDetectLanguageon, Whisper detects; if its pick is not invoice.languages(default en, hi, bn, de) the best of those from the same detection pass is used and the audio decoded again. This fixes Hindi-heard-as-Urdu-script and Bengali-heard-as-Gujarati. Turn it off (or setlang) to force one language. - Bengali re-scripting: whisper
smallwrites Bengali speech in Devanagari letters even withlanguage="bn"(8 of 8 samples). The Devanagari and Bengali Unicode blocks are parallel (+0x80), so the worker shifts them (engines.devanagari_to_bengali); CER went from 1.0 to ~0.3. The reply saysscript_fixed: truewhen it did this. - The detected code comes back as
languageandanswer_language; the shell sends it with the turn so the model answers in it (needs the one-line loop change indocs/#13;agentic/ REQUESTS.md languages.answer_directive()is the line). - TTS picks the voice from
lang, but the text's script wins when they disagree (lang=enwith Devanagari text speaks Hindi).
- STT: with
- Sentence chunking. The shell calls
POST /once per sentence and prefetches the next while the current one plays (TTS is ~20x faster than real time, so speech starts ~0.2 s after the first sentence of the reply is complete). The server does not stream one response per reply;api/ voice/ speak services/(voice/ text.py speakable,sentences) is the reference splitter (. ! ? । ॥ 。, max 220 chars), matching the browser one. - Formats. In: WAV natively (stdlib + numpy), WebM/Opus, OGG, MP3,
FLAC, M4A through PyAV (bundled FFmpeg libraries), or an
ffmpegon PATH if PyAV is missing. Out: WAV (16-bit mono, Piper's rate 16/22.05 kHz), MP3 only from the online engine. - Engine order for TTS (
voice.ttsEngine: auto): the user's chosen voice for that language, then Piper (installed voice, else it starts the download and falls through), then a system voice, then online only ifvoice.onlineFallbackis true. The reply'sX-Voice-Fallbackheader says why an earlier tier was skipped.
Settings (GET/PUT /api/settings/voice )#
| Key | Default | Meaning |
|---|---|---|
lang, voiceURI, rate, sounds |
"", "", 1, true |
the shell's existing browser-voice settings (unchanged meaning; lang is also the STT hint when auto-detect is off) |
engine |
auto |
for the shell: auto (server when / says available), server, browser |
sttEngine |
faster-whisper |
or off |
sttModel |
small |
tiny base small medium large-v3 large-v3-turbo |
sttBeamSize |
5 | 1 is greedy; it looped for 69 s on one Bengali clip and was not faster for short clips |
ttsEngine |
auto |
auto piper system online |
voices |
{} |
per language: {"hi": "piper:hi_IN-rohan-medium", "bn": "piper:bn_BD-google-medium#3", "en": "system:Microsoft Zira Desktop"} (#n = speaker) |
autoDetectLanguage |
true | detect the spoken language |
languages |
["en","hi","bn","de"] |
the candidates detection is restricted to |
replyInSameLanguage |
true | sets answer_language in the transcription |
speakReplies |
false | for the shell: read every reply aloud |
inputDevice |
"" |
for the shell: MediaDeviceInfo.deviceId (the server never opens a microphone) |
onlineFallback |
false | edge-tts: sends the reply text to Microsoft. Never the default |
autoInstall |
true | first use of a missing model/voice starts its download |
threads |
2 | engine threads (1-16) |
idleUnloadMinutes |
10 | 0 = never unload |
Install, per OS#
Common (in the app's venv; the voice packages are optional):
python -m pip install -r requirements-voice.txt
python -m services.voice doctor
python -m services.voice install whisper small # 486 MB, or let first use do it
python -m services.voice install piper hi_IN-priyamvada-medium- Windows: nothing else. SAPI voices for other languages: Settings >
Time & language > Language > add the language > include "Speech". (Only
System.Speech"Desktop" voices are used; verify withdoctor.) - macOS: nothing else;
sayvoices come from System Settings > Accessibility > Spoken Content > System voice > Manage voices. Not tested. - Linux:
sudo apt install espeak-ng(or dnf/pacman) for the system fallback; Piper bundles its own espeak-ng data and does not need it. Not tested. - Online fallback (optional):
pip install edge-tts, thenPUT /.api/ settings/ voice {"onlineFallback": true}
Data: ~/ (models/, voices/,
cache/, tmp/). Override with IMURUN_VOICE_HOME.
Nothing is written to the working directory.
Tests#
tests/- languages, Markdown→speech text, sentence splitting, WAV/multipart, catalogue, Devanagari→Bengali (no engines).test_voice_core.py tests/- every route on a realtest_voice_api.py ApiServerwith a fake worker; 503 reasons without engines; download job events; settings; the real worker subprocess withIMURUN_VOICE_FAKE=1.tests/- opt-intest_voice_engines_real.py IMURUN_VOICE_TESTS=1: Piper → Whisper round trip for en/de/hi (CER bounds) and bn (language only).