agent_end awaited one whole-answer synthesis; long answers hit the harness 30s extension-handler timeout (handler timed out after 30000ms) and the single frank-tts request was the stall. Port the built-in speak-aloud vocalizer's utterance segmenter (fHe in dist/cli.js) so segmentation matches the known-working stock path: [.!?…]-run boundaries, abbreviation/decimal/inline-code guards, fenced code and tables swallowed, markup cleaned. Play segment N to player exit before synthesizing N+1 (audio never stacks); fire-and-forget the chain so utterances outlive the handler timeout; a run token retires superseded chains. DANKVOICE_MAX becomes a per-answer budget of whole segments; stripForSpeech is gone. Refs #23 follow-up.
10 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-19 | 2026-10-19 |
dankvoice
agent/extensions/dankvoice.ts adds cloned-voice speech to the interactive
harness: /say opens the options menu, /say <text> speaks through the
speakers and /say on narrates the assistant's final answer after every
turn, all 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 | /say menu |
options menu (ctx.ui.select): auto-speak toggle, voice, output device, player, spoken cap, test phrase |
/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, device, cap |
Menu choices persist to ~/.omp/cache/dankvoice.json (runtime state, never
tracked); the environment overrides the file, the file overrides defaults.
"Speak a test phrase" synthesizes a short line through the current voice,
player and device — the audible proof whenever a session "has say on" but
stays silent.
Auto-speak is skipped in print/json mode so benches and pipelines stay
silent. A long answer is never one monolithic synthesis request; see
Segmentation for how utterances are cut and played.
Segmentation
Utterances are cut by a state machine ported from the harness's own
speak-aloud vocalizer (the minified fHe class in the agent bundle's
dist/cli.js), kept deliberately in lockstep with the stock path:
- A strong boundary is a run of
[.!?…]followed by whitespace — so!!!!!and...are one boundary, never a pile of cuts. - Boundaries after
e.g.,etc.,Mr.and friends, or inside inline code, don't count; decimals (3.5,1,5) survive because a boundary requires trailing whitespace. - Line starts are classified: table rows and fenced code are swallowed whole (their content is never read aloud), heading and bullet markers vanish, ordered-list markers speak their number ("1, …"), quote markers strip.
- Per segment, markdown is cleaned: links collapse to their label, URLs to their host, deep paths to their last segment; emphasis markup dies.
- Segments aim for one sentence; run-ons break at commas or word wraps before 280 characters.
Playback is strictly sequential: segment N+1 is synthesized only after
segment N's player process exited, so audio never stacks. The chain runs
detached from agent_end (fire-and-forget) and may legitimately outlive
the harness's 30 s handler timeout — the old awaiting design produced a
"handler timed out after 30000ms" toast on long answers. A new utterance,
/say stop, or disarming auto-speak retires the live chain through a run
token: the current player is killed and pending segments are skipped.
DANKVOICE_MAX is a per-answer character budget: the leading whole
segments that fit are spoken, the rest are dropped.
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_CONFIG |
~/.omp/cache/dankvoice.json |
persisted menu choices (voice, player, sink, cap, auto) |
DANKVOICE_VOICE |
empty (server default) | default voice name |
DANKVOICE_AUTO=1 |
off | start with auto-speak armed |
DANKVOICE_MAX |
700 |
spoken character budget per answer (whole leading segments) |
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 (PipeWire first:
the workstations run WirePlumber). A saved output device routes the player
explicitly — pw-play --target <sink> or paplay --device=<sink>; ffplay
has no device concept and ignores it — while an unset device leaves the
player's own default. Sinks are enumerated with wpctl list audio sinks;
if wireplumber is unreachable the menu offers only "system default". 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, 2026-10-19)
- 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. - Menu — verified via driver scenarios 2026-10-18 (Refs #23): the menu
lists auto-speak/voice/device/player/cap/test-phrase; a voice chosen in the
menu is used by later utterances and survives a fresh load via the settings
file; a device chosen in the menu appears in the player invocation as
--target <sink>. - Segmented playback — driver scenarios 2026-10-19: "First stop here.
Second one goes!!! Really? Third ... done" produced exactly 3 sequential
requests and 3 plays; "Model 3.5 ships today. Version 1,5 too." produced
exactly 2 (decimals never split); the markdown fixture's fenced code and
URL never reached the fixture endpoint; with
DANKVOICE_MAX=60only the leading segment was spoken.bats tests/dankvoice.bats17/17.
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").- The stock speak-aloud implementation — vocalizer pipeline
I6oand utterance segmenterfHe— in@oh-my-pi/pi-coding-agent'sdist/cli.js, read off the minified bundle 2026-10-19; the enhanced rewrite ("with the tiny/smol model before synthesis") is that bundle's ownspeech.enhanceddescription. dankvoice ports the mechanical half only: no LLM rewrite happens before frank-tts. - Gitea issue #21 on
dank/command-center(the work item this page ships with). - Gitea issue #23 on
dank/command-center(the options-menu work item).
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.