Every speak-aloud path in the harness gates `--voice` against the bundled Kokoro enum, so the frank-tts cloned voices could only ever produce files. `agent/extensions/` auto-loads, so one tracked extension adds the missing transport: `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`, silent when the harness schedules a continuation), `/say voice|voices|stop|status` — OpenAI speech POST plus playback through pw-play/paplay/ffplay. The endpoint and served id come from the `api: openai-speech` row of `agent/models.yml`, so the file carries no host and no secret; markdown is stripped, over-long utterances are cut at a sentence bound, and the temp WAV dir is always removed. Verified live in a real TUI session on the workstation (roster narrator1/narrator2/narrator3; 2.08 s and 1.44 s WAVs played; auto-speak spoke each finished answer, 1.20 s WAV; unknown voice answers with the roster). Black-box seam `tests/dankvoice.bats` (12 tests) drives the extension against a fixture speech endpoint and a stub player. cwd: /home/blank/.omp/wt/feat-21-dankvoice session: wt-20261017-181502-6d1fc1f
6.7 KiB
type, title, description, tags, verified, timestamp
| type | title | description | tags | verified | timestamp | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| reference | dankvoice | omp extension that speaks assistant output with the fleet's cloned voices through frank-tts. |
|
2026-10-18 | 2026-10-18 |
dankvoice
agent/extensions/dankvoice.ts adds cloned-voice speech to the interactive
harness: /say <text> speaks through the speakers and /say on narrates the
assistant's final answer after every turn, both served by
frank-tts (Dankclaw :7772, the speech model role in
agent/models.yml).
Why it exists
Nothing built into the harness can pronounce Frank's cloned voices.
| Surface | Engine | Verdict |
|---|---|---|
TUI speak-aloud (speech.enabled, speech.voice) |
bundled Kokoro ONNX worker, synthesizeStream(voice, speed, signal) |
Kokoro voice enum only; never consults models.yml or the speech role |
omp say [TEXT] |
same local backend (--model = local model key) |
omp say --voice narrator1 "test" → error: Expected --voice to be one of: af_heart, af_bella, af_nicole, af_kore, af_sarah, am_michael, am_fenrir, am_puck, bf_emma, bm_george, bm_fable (verified 2026-10-18) |
tts tool / xd://tts device (modelRoles.speech) |
frank-tts over OpenAI /v1/audio/speech |
cloned voices work, but only as a file — nothing plays them |
dankvoice is not a patch to that vocalizer (harness code, not ours); it is a
second transport that happens to speak, so Kokoro stays available for quick
local speech and the cloned voices carry the real narration.
Commands
| Command | Effect |
|---|---|
/say <text> |
synthesize once and play; interrupts whatever is playing |
/say on | /say off |
arm/disarm auto-speak of the assistant's final text at agent_end; the footer shows 🔊 <voice> while armed |
/say voice <name> |
pick a voice; an empty name defers to the server's own default |
/say voices |
list what frank-tts reports |
/say stop |
cut the current utterance |
/say status | /say help |
endpoint, voice, player, cap |
Auto-speak is skipped in print/json mode so benches and pipelines stay
silent. Markdown is stripped before synthesis — fenced code is dropped, links
collapse to their label, bullets and table pipes disappear — and the utterance
is truncated at a sentence boundary inside the cap.
Endpoint resolution
The base URL is read from agent/models.yml, from the provider block whose
model row carries api: openai-speech — the same row the speech role
resolves, so no host address and no credential appears in this file. From
baseUrl: http://192.168.0.143:7772/v1 it derives
POST …/v1/audio/speech, GET …/v1/audio/voices and GET …/health.
| Environment | Default | Meaning |
|---|---|---|
DANKVOICE_URL |
models.yml | speech base URL (…/v1), bypasses the YAML read |
DANKVOICE_MODEL |
the id in that block (frank-tts) |
model named in the request body |
DANKVOICE_VOICE |
empty (server default) | default voice name |
DANKVOICE_AUTO=1 |
off | start with auto-speak armed |
DANKVOICE_MAX |
700 |
spoken characters per utterance |
DANKVOICE_PLAYER |
detect | player binary, skipping detection |
DANKVOICE_MODELS |
~/.omp/agent/models.yml |
YAML to read the endpoint from |
Player detection order is pw-play → paplay → ffplay -nodisp -autoexit
(PipeWire first: the workstations run WirePlumber). Each utterance is written
to $TMPDIR/dankvoice-*/say-<ms>.wav and the file plus its directory are
removed when the player process exits.
Verified facts (2026-10-18)
- Extension discovery — any
.tsfile in~/.omp/agent/extensions/loads at startup: a probe file there printedloaded 2026-10-19T02:36:55Zfrom a freshomp -prun.omp --helpdocuments--no-extensions(discovery off, explicit-e/--extensionand--hookstill load) andplugin install|link. - Endpoint — resolving the live
agent/models.ymlyieldsspeechUrl http://192.168.0.143:7772/v1/audio/speech,voicesUrl …/v1/audio/voices,healthUrl …/health,model frank-tts. - Roster —
GET /v1/audio/voicesanswers the bare JSON array["narrator1","narrator2","narrator3"];/healthanswers{"status":"ok","backend":"omni-http","voices":[…]}.GET /v1/voicesandGET /v1/modelsare 404 on this shim, and an unknown voice answers400 unknown voice 'zzz'; available voices: …withavailable_voicesin the body — which is the fallback the extension uses if the roster route ever disappears. - Omitting
voiceis legal — frank-tts fills itsDEFAULT_VOICEsince the 2026-10-18 voice-pack work, so an unset voice synthesizes instead of failing. - Speech —
/say "dankvoice live smoke test"produced a 99,884 B / 2.08 s WAV and/say voice narrator2+/say "cloned voice check"a 69,164 B / 1.44 s WAV (durations fromffprobe), each handed to the player; temp directories were gone afterwards. - Auto-speak — verified in a real TUI session:
/say onarmed the footer (🔊 narrator1), the next finished turn synthesized and played the answer (1.20 s WAV), and an unknown voice name answers with the roster instead of speaking. Anagent_endcarryingwillContinue(the harness scheduled an auto-retry) is never narrated — only the terminal settle speaks.
Limits
- The TUI's own speak-aloud feature still narrates in Kokoro: its voice list is
a compile-time enum in the harness bundle, and no setting routes it to
frank-tts. Cloned voices live only on this file-producing/speaking path and
on the
ttstool. See Handy for the STT half. agent_endnarrates the final message text only — mid-turn tool chatter is never spoken.- frank-tts is LAN-only: no Caddy vhost fronts :7772, so this extension is usable on the home network.
- Deployment, packs and the voice registry are owned by the
serverAppConfigsrepo (serverAppConfigs-docs/Dankclaw/Frank TTS.md), not by this one.
Citations
- Live probes on the workstation, 2026-10-18:
omp say --voice narrator1 …,curl http://192.168.0.143:7772/health,/v1/audio/voices,/v1/audio/…/speech(invalid voice),ffprobeon the produced WAVs. omp --help(extension discovery flags,say,plugin) and@earendil-works/pi-coding-agentdist/core/extensions/types.d.ts(ExtensionAPI.on/registerCommand,ExtensionMode = "tui" | "rpc" | "json" | "print").- Gitea issue #21 on
dank/command-center(the work item this page ships with).
See Also
- Core Documentation — the live harness configuration.
- Handy — offline speech-to-text, the input half of voice.
serverAppConfigs-docs/Dankclaw/Frank TTS.md— the frank-tts service runbook.