Merge pull request 'chore: retire dankvoice drop-in for the dank/dankvoice package' (#35) from feat/34-dankvoice-cutover into main

This commit was merged in pull request #35.
This commit is contained in:
2026-10-19 12:43:48 -07:00
5 changed files with 64 additions and 1857 deletions
File diff suppressed because it is too large Load Diff
+55 -223
View File
@@ -1,7 +1,7 @@
--- ---
type: reference type: reference
title: dankvoice title: dankvoice
description: omp extension that speaks assistant output with the fleet's cloned voices through frank-tts. description: Voice output ships as the dank/dankvoice package; this page keeps only the fleet integration facts and the cutover procedure.
tags: tags:
- omp - omp
- extension - extension
@@ -15,244 +15,76 @@ timestamp: 2026-10-19
# dankvoice # dankvoice
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive Voice output lives in the **`dank/dankvoice` package**:
harness: `/say` opens the options menu, `/say <text>` speaks through the `pi install git:http://192.168.0.143:3000/dank/dankvoice.git`. This page owns
speakers and `/say on` narrates the assistant's thinking and final answer only fleet integration and the cutover. The package README
after every turn. The words are first rewritten into natural spoken prose by (<http://192.168.0.143:3000/dank/dankvoice>) owns the `/say` command
the **@tiny** role model (exactly what the built-in vocalizer's reference, the settings/env knob list, the segmentation state machine, the
`speech.enhanced` does), and everything is served by **frank-tts** enhancer port, and mood control — never duplicate those facts here.
(Dankclaw :7772, the `speech` model role in [agent/models.yml](/core/index.md)).
## Why it exists ## Fleet wiring
Nothing built into the harness can pronounce Frank's cloned voices. * **TTS endpoint** — resolved from the provider block whose model row carries
`api: openai-speech` in [agent/models.yml](/core/index.md):
**frank-tts** on Dankclaw `:7772`. LAN-only — no Caddy vhost fronts it.
* **Narration humanizer** — the harness `@tiny` role
(`modelRoles.tiny` in `agent/config.yml`), on this fleet **frank-zealot**
(Dankclaw `:7771`).
* **Hybrid-endpoint trap (load-bearing)** — a raw `fetch` to a
thinking-by-default model spends the token budget on `reasoning_content`
and answers `content: ""`. The package pins
`chat_template_kwargs: { enable_thinking: false }` on the rewrite call; do
not remove it. On a hybrid endpoint, empty content beside non-empty
`reasoning_content` means the flag was ignored — that is a failed rewrite
(fall back to the mechanical text), never the legitimate "nothing to say".
* **Voice contract** — omitting `voice` is legal: frank-tts fills its
`DEFAULT_VOICE` (2026-10-18 voice-pack work). `GET {base}/v1/audio/voices`
answers a bare JSON array; an unknown voice answers 400 with
`available_voices`, which the package uses as roster fallback. On this shim
`GET /v1/voices` and `GET /v1/models` are 404.
| Surface | Engine | Verdict | ## Cutover (Refs #34)
|---|---|---|
| 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 The `agent/extensions/dankvoice.ts` drop-in and its 26-test bats suite are
second transport that happens to speak, so Kokoro stays available for quick retired here (the package suite is a strict superset; test names ported 1:1).
local speech and the cloned voices carry the real narration. Per box:
## Commands 1. `pi install git:http://192.168.0.143:3000/dank/dankvoice.git`
2. `/say status`, then "Speak a test phrase" from the `/say` menu — the
audible proof a silent session needs.
3. Pull `main` (the drop-in disappears with it) and restart the session.
| Command | Effect | Menu choices survive the cutover: both transports persist to the same
|---|---| gitignored settings file, `~/.omp/cache/dankvoice.json`
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, thinking-narration toggle, humanize toggle, test phrase | (`DANKVOICE_CONFIG` relocates it).
| `/say <text>` | synthesize once and play; interrupts whatever is playing |
| `/say on` \| `/say off` | arm/disarm auto-speak of the thinking blocks and 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, thinking, humanize |
Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never ## Why not the built-in vocalizer
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 The harness speak-aloud path (`speech.enabled` / `speech.voice`, `omp say`)
silent. Each spoken unit — thinking first when narrated, then the answer — synthesizes with bundled Kokoro ONNX against a **compile-time voice enum** —
is first rewritten into spoken prose by the @tiny model; when the enhancer cloned voices can never reach it, and no setting routes it to frank-tts.
(below) declines, the text goes to segmentation as-is, where fenced code and `dankvoice` is a second transport beside it, not a patch: Kokoro stays for
tables vanish, links collapse to their label and bullets and table pipes quick local speech, the package carries cloned-voice narration. The `tts`
disappear. A long answer is never one monolithic synthesis request; see tool / `xd://tts` device reach frank-tts too, but produce files, not audio
[Segmentation](#segmentation) for how utterances are cut and played. playback.
## 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 — the thinking unit
draws on it before the answer does.
## 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, shared by the thinking and answer units) |
| `DANKVOICE_PLAYER` | detect | player binary, skipping detection |
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
| `DANKVOICE_REWRITE_URL` | the @tiny role | chat base URL (`…/v1`) for the enhancer, bypassing role resolution |
| `DANKVOICE_REWRITE_MODEL` | the @tiny role's model | chat model named in the rewrite body |
| `DANKVOICE_REWRITE=0` | enhancer on when a route resolves | speak mechanical text |
| `DANKVOICE_THINKING` | settings file, default on | `0`/`1` force thinking narration off/on |
| `DANKVOICE_OMPCONFIG` | `~/.omp/agent/config.yml` | YAML to read the `modelRoles.tiny` role 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.
## Humanized narration (the @tiny role)
The built-in vocalizer never reads answers aloud verbatim when
`speech.enhanced` is on: a small model rewrites each utterance into words a
person would say. `dankvoice` ports that enhancer — same system prompt
(verbatim from the bundle), same budgets (1536 max tokens, 6 s timeout,
4000-char input window), same contract (a failed rewrite falls back to the
mechanically cleaned text; an empty rewrite skips the unit as "nothing to
say"). The target model is the harness's `@tiny` role, resolved the way the
harness resolves it: `modelRoles.tiny` in `agent/config.yml` reads
`provider (host)/model:effort`; the provider block in `agent/models.yml`
supplies the `baseUrl`; the request is `POST {base}/chat/completions`. On
this fleet that is **frank-zealot** (`llamacpp (dankclaw-3090)`,
`:7771`) — the 6 s abort keeps a slow tiny model off the critical path.
The request body pins `chat_template_kwargs: { enable_thinking: false }`,
and that field is load-bearing: the harness injects it for managed roles,
but a raw `fetch` gets the model's default — thinking on — so the Qwen35
hybrid spends the budget on `reasoning_content` and answers `content: ""`
(measured: 6.9 s / 6893 reasoning chars / empty content without the flag;
0.55 s / 369 clean chars with it). For a hybrid endpoint, empty content
beside non-empty `reasoning_content` means the flag was ignored — a failed
rewrite (mechanical fallback), never the legitimate "nothing to say".
Thinking blocks ride the same path: at `agent_end` the last assistant
message's true `thinking` parts are narrated (rewritten first, as their own
unit, before the answer), while blocks flagged `narration: true` — which
providers use to address the user through the thinking channel — count as
answer text. The menu, `/say status`, and `DANKVOICE_THINKING` toggle it;
the choice persists in the settings file.
## Verified facts (2026-10-18, extended 2026-10-19)
* **Extension discovery** — any `.ts` file in `~/.omp/agent/extensions/` loads
at startup: a probe file there printed `loaded 2026-10-19T02:36:55Z` from a
fresh `omp -p` run. `omp --help` documents `--no-extensions` (discovery off,
explicit `-e/--extension` and `--hook` still load) and `plugin install|link`.
* **Endpoint** — resolving the live `agent/models.yml` yields
`speechUrl http://192.168.0.143:7772/v1/audio/speech`,
`voicesUrl …/v1/audio/voices`, `healthUrl …/health`, `model frank-tts`.
* **Roster** — `GET /v1/audio/voices` answers the bare JSON array
`["narrator1","narrator2","narrator3"]`; `/health` answers
`{"status":"ok","backend":"omni-http","voices":[…]}`. `GET /v1/voices` and
`GET /v1/models` are **404** on this shim, and an unknown voice answers
`400 unknown voice 'zzz'; available voices: …` with `available_voices` in the
body — which is the fallback the extension uses if the roster route ever
disappears.
* **Omitting `voice` is legal** — frank-tts fills its `DEFAULT_VOICE` since 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 from `ffprobe`), each handed to the player; temp
directories were gone afterwards.
* **Auto-speak** — verified in a real TUI session: `/say on` armed 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. An `agent_end` carrying `willContinue` (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=60` only the
leading segment was spoken.
* **Humanize + thinking (2026-10-19, Refs #27)** — driver scenarios cover the
rewrite round-trip (fixture chat route), the 404 → mechanical fallback,
thinking narrated as its own unit before the answer, `DANKVOICE_THINKING=0`
silencing it, and `narration: true` spoken once as the answer; the endpoint
status line shows the resolved humanize model from the @tiny role. Live
smoke against the real services: a markdown `/say` became 3 sequential
prose segments (mechanical cleanup of that text yields at most 2), and a
thinking+answer `agent_end` played thinking first. 22 green in
`tests/dankvoice.bats`.
* **Thinking-off fix (2026-10-19, Refs #32)** — replaying the exact rewrite
body against live frank-zealot: without `chat_template_kwargs.
enable_thinking: false` the reply arrives as 6893 `reasoning_content`
characters with `content: ""` in 6.9 s (twice the 6 s abort); with it,
369 clean characters in 0.55 s. Driver fixtures now pin the flag
(`thinkoff=false` on the recorded chat call), turn reasoning-only replies
into mechanical speech instead of silence, and time the menu's test
phrase (~0 ms handler). 26 green in `tests/dankvoice.bats`.
## Limits ## Limits
* The TUI's own speak-aloud feature still narrates in Kokoro: its voice list is * Auto-speak narrates thinking and the final message only — mid-turn tool
a compile-time enum in the harness bundle, and no setting routes it to chatter is never spoken; skipped in `print`/`json` modes so benches stay
frank-tts. Cloned voices live only on this file-producing/speaking path and silent.
on the `tts` tool. See [Handy](/tools/handy.md) for the STT half. * frank-tts deployment, voice packs and the registry are owned by the
* `agent_end` narrates thinking blocks and the final message only — mid-turn `serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`).
tool chatter is never spoken. Thinking covers the last assistant message;
tool-loop gaps in earlier messages are skipped by design.
* Humanized narration is serial: audio starts only after the @tiny rewrite
returns (≤ 6 s per unit, then frank-tts synthesis). Say `/say off`-grade
latency is the price of prose; `DANKVOICE_REWRITE=0` trades it back for
instant mechanical speech.
* 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
`serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`), not
by this one.
## Citations ## Citations
* Live probes on the workstation, 2026-10-18: `omp say --voice narrator1 …`, * Package repo `dank/dankvoice` (README, `docs/decisions/`) — behaviour and
`curl http://192.168.0.143:7772/health`, `/v1/audio/voices`, knob truth.
`/v1/audio/…/speech` (invalid voice), `ffprobe` on the produced WAVs. * Gitea issues #21, #23, #27, #32 (drop-in history) and #34 (this cutover)
* `omp --help` (extension discovery flags, `say`, `plugin`) and on `dank/command-center`.
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts` * Live endpoint probes 2026-10-18/19 documented in those issues.
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
* The stock speak-aloud implementation — vocalizer pipeline `I6o`, utterance
segmenter `fHe` and speech enhancer `Qyr` with its prompt `T6o` — in
`@oh-my-pi/pi-coding-agent`'s `dist/cli.js`, read off the minified bundle
2026-10-19: dankvoice ports the mechanical and the enhanced halves, so an
LLM rewrite does happen 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).
* Gitea issue #27 on `dank/command-center` (the enhanced-narration work item).
* Gitea issue #32 on `dank/command-center` (the latency/silence fix work item).
## See Also ## See Also
* [Core Documentation](/core/index.md) — the live harness configuration. * [Core Documentation](/core/index.md) — the live harness configuration.
* [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice. * [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice.
* `serverAppConfigs-docs/Dankclaw/Frank TTS.md` — the frank-tts service runbook.
+1 -1
View File
@@ -12,7 +12,7 @@ These files define the structural conventions that govern the agent system itsel
* [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior. * [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior.
* [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides. * [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides.
* [dankvoice](dankvoice.md) — Cloned-voice vocalization extension: `/say` and auto-speak over frank-tts, endpoint read from `agent/models.yml`. * [dankvoice](dankvoice.md) — Voice output is the `dank/dankvoice` package (`pi install git:`); this page keeps fleet wiring and the cutover procedure.
* [OMP Status Line Customization](statusline.md) — How presets, composer shapes, and segment arrays actually interact; the fleet's tuned bar. (moved from /pi/, 2026-10-19) * [OMP Status Line Customization](statusline.md) — How presets, composer shapes, and segment arrays actually interact; the fleet's tuned bar. (moved from /pi/, 2026-10-19)
* [OMP Subagent Limits & Fan-out Policy](subagent-limits.md) — Live caps: concurrency 2, no nested spawns (the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy. (moved from /pi/, 2026-10-19) * [OMP Subagent Limits & Fan-out Policy](subagent-limits.md) — Live caps: concurrency 2, no nested spawns (the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy. (moved from /pi/, 2026-10-19)
+8
View File
@@ -16,6 +16,14 @@ lesson, a few lines. Full incident narratives are recoverable from git
(`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives (`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives
on its concept page, linked here. Never re-expand an entry in place. on its concept page, linked here. Never re-expand an entry in place.
## 2026-10-19 — dankvoice cut over to the `dank/dankvoice` package (Refs #34)
Drop-in `agent/extensions/dankvoice.ts` and its 26-test suite retired; the
package carries the behaviour (41/41 black-box suite) and is installed with
`pi install git:`. Law: install and prove `/say status` speaks on a box
*before* pulling the cutover commit — the shared settings file keeps menu
state across both transports, so the swap is stateless once installed.
## 2026-10-19 — dankvoice: silence and timeouts, root-caused (Refs #32) ## 2026-10-19 — dankvoice: silence and timeouts, root-caused (Refs #32)
The humanizer's raw `fetch` to frank-zealot omitted `chat_template_kwargs. The humanizer's raw `fetch` to frank-zealot omitted `chat_template_kwargs.
-472
View File
@@ -1,472 +0,0 @@
#!/usr/bin/env bats
# tests/dankvoice.bats — black-box tests for agent/extensions/dankvoice.ts
#
# The extension's contract, from outside: it registers a `say` command and an
# `agent_end` listener; what is observable is (a) the JSON it POSTs to the
# speech route, (b) the file it hands to the player, and (c) the notifications
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
# (it answers the speech route with a fixed WAV, the roster route with a JSON
# array, and everything else with 404), a stub player stands in for pw-play
# (it records its full argv and copies the .wav it was given), and a fixture
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
#
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
# name the fixture wpctl reports), DV_MODELS (models.yml fixture),
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
# DV_MODE (the harness mode reported to the extension), DV_REWRITE (fixture
# behavior for the humanizer route: prose | echo | reasoning | stall, with
# DV_STALL_MS).
setup() {
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
WORK="$(mktemp -d)"
export DV_EXT="$REPO/agent/extensions/dankvoice.ts"
export DV_STUB="$WORK/player.sh"
export DV_STUB_LOG="$WORK/player.log"
export DV_COPY="$WORK/played.wav"
export DV_EMPTY_BIN="$WORK/emptybin"
mkdir -p "$DV_EMPTY_BIN"
cat > "$DV_STUB" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DV_STUB_LOG"
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'START %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
sleep "${DV_HOLD:-0.1}"
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'END %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
SH
chmod +x "$DV_STUB"
# the same stub reachable under the canonical player names, plus a fixture
# wpctl so output-device enumeration is deterministic on any machine
mkdir -p "$WORK/bin"
ln -s "$DV_STUB" "$WORK/bin/pw-play"
export DV_PWPLAY="$WORK/bin/pw-play"
export DV_WPCTL="alsa_output.usb-KT.0"
cat > "$WORK/bin/wpctl" <<'WPC'
#!/usr/bin/env bash
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
WPC
chmod +x "$WORK/bin/wpctl"
# settings live in an isolated file so menu choices never leak between tests
export DANKVOICE_CONFIG="$WORK/settings.json"
# models.yml fixture: a chat provider first, the speech provider second
export DV_MODELS="$WORK/models.yml"
cat > "$DV_MODELS" <<'YML'
providers:
"chat (box)":
baseUrl: http://10.0.0.1:9999/v1
models:
- id: chatmodel
api: openai-completions
"speech (box)":
baseUrl: http://127.0.0.1:1/v1
auth: none
models:
- id: speechmodel
api: openai-speech
YML
# omp config.yml fixture: the @tiny role points at the chat provider above
export DV_OMPCONFIG="$WORK/ompconfig.yml"
cat > "$DV_OMPCONFIG" <<'CFY'
modelRoles:
tiny: chat (box)/chatmodel:off
CFY
cat > "$WORK/driver.ts" <<'TS'
// Black-box driver: stand up the frank-tts fixture, load the extension against
// it, invoke one scenario, print what the endpoint received and what got played.
import { createServer } from "node:http";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname } from "node:path";
const scenario = process.argv[2] ?? "speak";
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
const started = Date.now();
interface Call {
url: string | undefined;
body: string;
at: number;
}
const received: Call[] = [];
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const at = Date.now() - started;
const body = Buffer.concat(chunks).toString("utf8");
received.push({ url: req.url, body, at });
if (req.url?.endsWith("/audio/voices")) {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(["narrator1", "narrator2"]));
return;
}
if (req.url?.endsWith("/audio/speech")) {
res.setHeader("content-type", "audio/wav");
res.end(AUDIO);
return;
}
if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) {
const respond = () => {
res.setHeader("content-type", "application/json");
const spoken = process.env.DV_REWRITE === "echo"
? String(JSON.parse(body).messages?.[1]?.content ?? "")
: "Spoken, humanized words.";
const verdict = process.env.DV_REWRITE === "reasoning"
? { choices: [{ message: { content: "", reasoning_content: "Long internal deliberation that never reaches the reply field." } }] }
: { choices: [{ message: { content: spoken } }] };
res.end(JSON.stringify(verdict));
};
if (process.env.DV_REWRITE === "stall") setTimeout(respond, Number(process.env.DV_STALL_MS ?? 200));
else respond();
return;
}
res.statusCode = 404;
res.end("{}");
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
if (scenario === "endpoint") {
process.env.DANKVOICE_MODELS = process.env.DV_MODELS;
} else {
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
}
process.env.DANKVOICE_OMPCONFIG = process.env.DV_OMPCONFIG;
if (scenario !== "endpoint") process.env.DANKVOICE_REWRITE_URL = `http://127.0.0.1:${port}/v1`;
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`;
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB;
const extension = await import(process.env.DV_EXT as string);
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
const events = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
extension.default({
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler),
});
const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
let answerIndex = 0;
const ctx = {
mode: process.env.DV_MODE ?? "tui",
ui: {
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
select: (title: string, options: string[]) => {
console.log(`SELECT ${title} ${options.join("|")}`);
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
answerIndex += 1;
return Promise.resolve(answer ?? options[0]);
},
},
};
const say = commands.get("say");
const agentEnd = events.get("agent_end");
const answer = (text: string) => ({ messages: [{ role: "assistant", content: [{ type: "text", text }] }] });
const settle = () => new Promise((resolve) => setTimeout(resolve, 800));
if (!say) {
console.log("COMMANDS none");
} else {
console.log(`COMMANDS ${[...commands.keys()].join(",")} EVENTS ${[...events.keys()].join(",")}`);
switch (scenario) {
case "speak":
await say.handler("hello world", ctx);
break;
case "voice":
await say.handler("voice narrator2", ctx);
await say.handler("good morning", ctx);
break;
case "menu":
await say.handler("menu", ctx);
break;
case "empty":
await say.handler("", ctx);
break;
case "devicemenu":
await say.handler("menu", ctx);
await say.handler("hello world", ctx);
break;
case "auto":
await say.handler("on", ctx);
await agentEnd?.(answer("the answer"), ctx);
await settle();
await say.handler("off", ctx);
await agentEnd?.(answer("the second answer"), ctx);
break;
case "continuation":
await say.handler("on", ctx);
await agentEnd?.({ ...answer("not yet"), willContinue: true }, ctx);
await settle();
await agentEnd?.(answer("the settled answer"), ctx);
break;
case "printmode":
await say.handler("on", ctx);
await agentEnd?.(answer("quiet"), ctx);
break;
case "voices":
await say.handler("voices", ctx);
break;
case "endpoint":
await say.handler("status", ctx);
break;
case "markdown":
await say.handler("## Title\n\nUse `pw-play` here.\n\n```bash\nnpm i x\n```\n\nsee [docs](http://example.test) and **bold**\n- a\n- b", ctx);
break;
case "truncate":
await say.handler("Sentence one is here. ".repeat(40), ctx);
break;
case "sentences":
await say.handler("First stop here. Second one goes!!! Really? Third ... done", ctx);
break;
case "decimals":
await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx);
break;
case "rewrite":
await say.handler("The deploy failed because a secret was missing.", ctx);
break;
case "thinking":
await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx);
break;
case "testphrase": {
const startedAt = Date.now();
await say.handler("menu", ctx);
console.log(`HANDLER_MS ${Date.now() - startedAt}`);
break;
}
case "narration":
await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx);
break;
case "noplayer":
await say.handler("speak anyway", ctx);
break;
}
await settle();
}
for (const call of received) {
const parsed: unknown = JSON.parse(call.body === "" ? "{}" : call.body);
const field = (key: string): string => {
if (parsed && typeof parsed === "object" && key in parsed) {
const value = parsed[key];
if (typeof value === "string") return value;
}
return "-";
};
const kwargs = parsed && typeof parsed === "object" && "chat_template_kwargs" in parsed
? (parsed as { chat_template_kwargs?: { enable_thinking?: unknown } }).chat_template_kwargs
: undefined;
const think = kwargs && typeof kwargs === "object" && "enable_thinking" in kwargs ? String(kwargs.enable_thinking) : "absent";
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}${call.url?.endsWith("/chat/completions") ? ` thinkoff=${think}` : ""}`);
}
console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`);
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
server.close();
TS
export DV_DRIVER="$WORK/driver.ts"
}
teardown() {
rm -rf "$WORK"
}
say_run() {
run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1"
}
@test "the extension registers the say command and an agent_end listener" {
say_run speak
[ "$status" -eq 0 ]
[[ "$output" == *"COMMANDS say EVENTS agent_end"* ]]
}
@test "speaking posts the text to the speech route" {
say_run speak
[ "$status" -eq 0 ]
[[ "$output" == *"CALL /v1/audio/speech model=frank-tts format=wav voice=- input=hello world"* ]]
}
@test "one utterance is played once, as exactly the bytes the endpoint sent" {
say_run speak
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"COPIED 24"* ]]
}
@test "the voice is named in the request only when the user chose one" {
say_run speak
[[ "$output" == *"voice=- input=hello world"* ]]
say_run voice
[[ "$output" == *"voice=narrator2 input=good morning"* ]]
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
}
@test "auto-speak narrates the final answer once and stays silent after it is disarmed" {
say_run auto
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"input=the answer"* ]]
[[ "$output" != *"the second answer"* ]]
[[ "$output" == *"STATUS dankvoice 🔊"* ]]
}
@test "auto-speak stays silent outside the interactive modes" {
DV_MODE=print say_run printmode
[[ "$output" == *"PLAYS 0"* ]]
[[ "$output" != *"input=quiet"* ]]
}
@test "the voice list comes from the endpoint roster route" {
say_run voices
[[ "$output" == *"CALL /v1/audio/voices"* ]]
[[ "$output" == *"dankvoice voices: narrator1, narrator2"* ]]
}
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
say_run endpoint
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
[[ "$output" == *"humanize chatmodel"* ]]
[[ "$output" != *"10.0.0.1"* ]]
}
@test "markdown never reaches the speech engine" {
say_run markdown
[[ "$output" == *"input=Title"* ]]
[[ "$output" == *"input=Use pw-play here."* ]]
[[ "$output" == *"input=see docs and bold"* ]]
[[ "$output" != *"npm i x"* ]]
[[ "$output" != *"example.test"* ]]
}
@test "the spoken budget keeps only the leading whole segments" {
DANKVOICE_MAX=60 say_run truncate
[[ "$output" == *"CALLS 1"* ]]
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"input=Sentence one is here."* ]]
}
@test "a long answer is spoken as sequential sentence-sized requests" {
say_run sentences
[[ "$output" == *"CALLS 3"* ]]
[[ "$output" == *"PLAYS 3"* ]]
[[ "$output" == *"input=First stop here."* ]]
[[ "$output" == *"input=Second one goes!!! Really?"* ]]
[[ "$output" == *"input=Third ... done"* ]]
}
@test "decimals and comma-decimals never split a sentence" {
say_run decimals
[[ "$output" == *"CALLS 2"* ]]
[[ "$output" == *"input=Model 3.5 ships today."* ]]
[[ "$output" == *"input=Version 1,5 too."* ]]
}
@test "an answer is rewritten into spoken prose before synthesis" {
DV_REWRITE=prose say_run rewrite
[[ "$output" == *"CALL /v1/chat/completions"* ]]
[[ "$output" == *"input=Spoken, humanized words."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "a failed rewrite speaks the mechanical text unchanged" {
say_run speak
[[ "$output" == *"input=hello world"* ]]
}
@test "thinking is narrated before the answer as its own segment" {
DV_REWRITE=echo say_run thinking
[[ "$output" == *"input=Considering the wiring."* ]]
[[ "$output" == *"input=The answer is nine."* ]]
[[ "$output" == *"CALLS 2"* ]]
local first second
first=$(grep -n "input=Considering the wiring\." <<<"$output" | head -1 | cut -d: -f1)
second=$(grep -n "input=The answer is nine\." <<<"$output" | head -1 | cut -d: -f1)
[ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ]
}
@test "thinking narration can be switched off" {
DANKVOICE_THINKING=0 DV_REWRITE=echo say_run thinking
[[ "$output" == *"input=The answer is nine."* ]]
[[ "$output" != *"input=Considering the wiring."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "narration-flagged thinking is spoken once as the answer" {
DV_REWRITE=echo say_run narration
[[ "$output" == *"input=Here is the result."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "a missing player is reported, not swallowed" {
say_run noplayer
[[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]]
[[ "$output" == *"PLAYS 0"* ]]
}
@test "a scheduled continuation is never narrated as the final answer" {
say_run continuation
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"input=the settled answer"* ]]
[[ "$output" != *"input=not yet"* ]]
}
@test "the menu lists every knob and a plain /say opens it" {
say_run menu
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Thinking narration: on (toggle)|Humanize answers: on (tiny) (toggle)|Speak a test phrase"* ]]
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
say_run empty
[[ "$output" == *"SELECT dankvoice — options"* ]]
}
@test "a voice picked from the menu is spoken by every later request" {
say_run menu "Voice: server default (change)|narrator2"
[[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]]
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
say_run speak
[[ "$output" == *"voice=narrator2 input=hello world"* ]]
}
@test "a device chosen from the menu becomes the player's output target" {
DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL"
[[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]]
[[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]]
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
}
@test "the rewrite asks the humanizer with thinking explicitly off" {
DV_REWRITE=prose say_run rewrite
[[ "$output" == *"input=- thinkoff=false"* ]]
}
@test "an answer hiding in reasoning_content speaks the mechanical text, not silence" {
DV_REWRITE=reasoning say_run rewrite
[[ "$output" == *"input=The deploy failed because a secret was missing."* ]]
[[ "$output" == *"CALLS 1"* ]]
[[ "$output" == *"PLAYS 1"* ]]
}
@test "a rewrite slower than a breath but under the timeout still speaks" {
DV_REWRITE=stall DV_STALL_MS=300 say_run rewrite
[[ "$output" == *"input=Spoken, humanized words."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "the menu test phrase speaks in the background instead of blocking the menu" {
DV_REWRITE=stall DV_STALL_MS=1500 say_run testphrase "Speak a test phrase"
[[ "$output" == *"NOTIFY info dankvoice: speaking the test phrase"* ]]
local ms
ms=$(grep -o 'HANDLER_MS [0-9]*' <<<"$output" | cut -d' ' -f2)
[ -n "$ms" ] && [ "$ms" -lt 1200 ]
}