Skip to content

Read Aloud

thefourCraft edited this page Jun 21, 2026 · 1 revision

Read Aloud (Text-to-Speech)

Read Aloud is the mirror image of dictation: select text anywhere, press a hotkey, and Zerm speaks it in a natural voice — local (Kokoro) or cloud.

Code lives in Zerm/TextToSpeech/.

Flow

flowchart TB
    HK[Read Aloud hotkey] --> TC[TTSController.toggle]
    TC -->|already speaking| STOP[stop]
    TC -->|start| SESS[startSession<br/>reserve widget, show Preparing…]
    SESS --> FETCH[SelectedTextService.fetchSelectedText]
    FETCH --> PREP[prepareSpokenText]
    PREP --> SMART{Smart Reading}
    SMART --> CHUNK[split into sentence chunks]
    CHUNK --> SYN[provider.synthesize per chunk]
    SYN --> Q[TTSPlayer streaming queue]
    Q --> PLAY[AVAudioEngine playback<br/>live audio bars]
    PLAY --> DONE[endSpeaking → idle]
Loading

Key types

Type Role
TTSController Orchestrates fetch → prepare → synthesize → play; owns mutual exclusion with dictation
TTSProvider (+ TTSProviderRegistry) Provider protocol; mirrors the STT CloudProvider pattern
TTSPlayer Single AVAudioEngine/AVAudioPlayerNode pipeline with a streaming queue
KokoroModelManager / KokoroEngine On-device Kokoro download + sherpa-onnx synthesis
TTSTextNormalizer / TTSNaturalizer Smart Reading — clean + optionally rewrite

Providers

Local: Kokoro-82M (default for offline). Cloud: Deepgram Aura-2, ElevenLabs, OpenAI, Gemini, Inworld, Cartesia. All providers return 16-bit PCM that TTSPlayer plays through one pipeline.

Streaming for instant feel

The first sentence is synthesized and played while the rest is still being generated:

  • TTSController splits text into sentence chunks (first chunk = 1 sentence for a fast start, rest ≈ 220 chars).
  • TTSPlayer.startStreaming / enqueue / finishEnqueueing schedule buffers back-to-back.
  • Kokoro is pre-warmed on launch so the first read isn't a cold load.

Shared recorder widget

Read Aloud reuses the dictation notch/mini widget and its live audio-bar visualizer, driven by the real TTS output level (TTSPlayer taps the mixer → RMS → recorder.audioMeter). The widget shows the true phase via RecordingState:

  • Thinking… — on-device AI rewrite running (generatingSpeech)
  • Preparing… — synthesizing (preparingSpeech)
  • live bars — playing (speaking)

Dictation and Read Aloud are mutually exclusive (single RecordingState); double-Escape cancels whichever is active.

Known fix worth remembering

The metering tap is installed once and never removed in hot paths. Calling removeTap from the playback-completion handler (audio thread) while stop() also removed it (main thread) deadlocked AVAudioEngine and froze the app. General rule: never installTap/removeTap from completion/audio-thread contexts.

See: Smart Reading · On-Device LLM

Clone this wiki locally