vocalcode
v0.1.1
Published
OpenCode plugin that speaks assistant responses aloud using Piper TTS
Maintainers
Readme
vocalcode
OpenCode plugin that speaks assistant responses aloud using Piper TTS.
What It Does
- Narrates assistant replies sentence-by-sentence as they stream (markdown and code filtered out) — on by default, toggleable at runtime and persisted across sessions
- Subagent threads are never narrated; their synthetic prompts also don't interrupt main-thread narration
- User input takes over audio: sending a message, running
/stop, or turning narration off interrupts current playback and drops the queued sentences - Runs one shared Piper HTTP server for all opencode sessions: the first session spawns it, the last session to exit kills it (ownership lock + heartbeat refcount, kills verified against the spawned child PID only), and a session that resumes after suspend respawns it automatically if it died
- Caches synthesized audio (sha256 of voice + text + settings) so repeated lines replay instantly without re-synthesis; oldest clips are evicted over the size cap, and the shared cache + replay index are wiped only when the first active opencode session starts (
TTS_CLEAR_CACHE) /speakcommand and aspeaktool let the model talk on demand (interrupts current narration)/voicecommand plusvoice_list/voice_settools: pick a voice; missing voices are only downloaded after the user explicitly confirms/ttscommand plustts_toggle/tts_status/tts_downloadtools: toggle narration, check status, install voicesPIPER_MANAGED=falsedisables spawning — connect to an already-running server only
Commands & Tools
| Command / Tool | Purpose |
|---|---|
| /tts on\|off\|status\|setup | Toggle auto-narration, report status, or run the voice setup flow |
| tts_toggle | Enable/disable/toggle narration (persists in state.json); disabling also stops current playback |
| tts_status | Narration state, voice, server health, installed voices |
| tts_download | Download a voice (~60 MB) — requests OpenCode permission before running the download command; a stalled download is killed after 10 minutes |
| /speak, speak | Speak text on demand; still works when auto-narration is off |
| /stop, stop_speaking | Stop audio playback and drop queued sentences; the rest of the current reply is not narrated |
| /replay, replay_speech | Replay recent narrated sentences from cache (default: last sentence; interrupts current playback) |
| /voice, voice_list, voice_set | Inspect and switch voices |
Voice Setup
If the configured voice is missing at startup, Vocalcode does not download anything on its own: it shows a toast telling you to run /tts setup. That flow asks you to confirm the download (one-time, ~60 MB) before installing the voice and starting the server.
Prerequisites
Voices are downloaded into the shared voices dir only after OpenCode permission is granted; the default (en_US-ljspeech-medium) is not fetched automatically.
Voice Licensing
Piper voice licenses vary per model — some are trained on non-commercial or research-only datasets. Vocalcode never redistributes voice models; each model's terms live in its MODEL_CARD on rhasspy/piper-voices. See docs/VOICES.md for the list of English voices with clean licensing (public domain / CC BY / CC0 / Apache-2.0 datasets and derivation chains), which includes the default.
Installation
Add to your opencode.json:
{
"plugin": ["vocalcode"]
}Configuration
All settings are environment variables. Values are validated at startup — malformed settings (bad URL protocol, non-positive numbers, unsupported voice names, non-http URL with PIPER_MANAGED=true) fail with a ConfigError instead of being silently ignored:
| Variable | Default | Description |
|---|---|---|
| OPENCODE_TTS_ENABLED | true | Enable/disable TTS |
| PIPER_URL | http://127.0.0.1:5000 | Piper HTTP server URL |
| PIPER_VOICE | en_US-ljspeech-medium | Voice model to use |
| PIPER_MANAGED | true | Spawn a managed server when none is reachable |
| PIPER_COMMAND | python -m piper.http_server | Server spawn command |
| PIPER_DOWNLOAD_COMMAND | python -m piper.download_voices | Voice download command |
| PIPER_DATA_DIR | <data>/voices | Voice model directory (--data-dir) |
| PIPER_LENGTH_SCALE | 1 | Speech rate (higher = slower) |
| PIPER_NOISE_SCALE | 0.667 | Phoneme noise |
| PIPER_NOISE_W_SCALE | 0.8 | Phoneme width noise |
| PIPER_TIMEOUT_MS | 30000 | Request timeout in ms |
| PIPER_READY_TIMEOUT_MS | 30000 | Server startup wait in ms |
| TTS_MAX_CHUNK_LENGTH | 500 | Max characters per synthesis |
| TTS_MAX_QUEUE_SIZE | 20 | Max queued sentences |
| TTS_CACHE_MAX_BYTES | 209715200 | Audio cache size cap (200 MB) |
| TTS_CLEAR_CACHE | true | Clear shared cached audio + replay index when the first active opencode session starts |
| VOCALCODE_DATA | %LOCALAPPDATA%\vocalcode | Shared data dir (lock, heartbeats, cache, voices, state) |
| TTS_LOG_LEVEL | info | Log level: debug, info, warn, error. Set debug to see per-clip playback/cache diagnostics and streaming dedup decisions (e.g. why audio is silent or re-narrated) |
The chosen voice and the auto-narration toggle persist across sessions in <data>/state.json.
How It Works
Assistant streaming response
-> message.part.delta / message.part.updated events
-> Extract only newly generated text (no duplicates)
-> Filter Markdown / code blocks (stateful across chunk boundaries)
-> Buffer into sentences
-> Queue (Effect fiber + bounded dropping queue; interrupt = shutdown)
-> Cache lookup (sha256 key) -> Piper HTTP /synthesize on miss (one delayed retry)
-> Play WAV (ffplay / afplay / aplay / PowerShell SoundPlayer)Architecture
Built on Effect TS: tagged errors (Data.TaggedError), a bounded dropping Queue with a single worker fiber, Schedule-driven readiness polling and synthesis retries, semaphores for operation serialization, and daemon fibers for the server heartbeat.
| File | Responsibility |
|---|---|
| src/index.ts | OpenCode plugin entry: events, tools, /speak + /stop + /replay + /voice + /tts commands |
| src/piper.ts | Piper HTTP client (health, voices, synthesize) |
| src/server.ts | Singleton server supervisor: ownership lock, child PID, heartbeats, health-gated last-out kill |
| src/cache.ts | WAV cache, newest-wins eviction, replay index |
| src/speech.ts | Sentence pipeline: queue, synthesis, playback, interrupt, replay |
| src/audio.ts | Cross-platform WAV playback |
| src/text.ts | Markdown filtering, sentence segmentation |
| src/config.ts | Environment config + persisted voice state |
| src/command.ts | Quote-aware command-line parser for spawn/download commands |
| src/errors.ts | Tagged error types |
Observability
The narration pipeline and server supervisor emit Effect spans and incremental counters, ready for any OpenTelemetry backend:
- Spans:
server.ensure,speech.synthesize,speech.play - Counters:
vocalcode_server_spawns,vocalcode_clips_synthesized,vocalcode_clips_played
Development
# Typecheck
bunx tsc --noEmit
# Run tests
bun testThe test suite covers command parsing, markdown/stream filtering, sentence segmentation, config validation, the Piper HTTP protocol, cache behavior (eviction, corruption, atomic writes), the speech pipeline (ordering, interrupts, per-session cancellation, retries), and server supervision (lock ownership, child-PID teardown, readiness-timeout recovery with verified child kill).
Platform Support
- Windows:
ffplayif installed, elseSystem.Media.SoundPlayervia PowerShell - macOS:
afplay - Linux:
ffplay,aplay, orpaplay(auto-detected)
License
Vocalcode is MIT-licensed. It does not vendor or redistribute Piper (GPL-3.0): Piper is installed separately (pip install piper-tts[http]) and runs as its own process, which the plugin spawns and talks to over HTTP. Voice models are likewise downloaded at runtime, never redistributed — each carries its own terms (see Voice Licensing).
