Merge pull request 'feat(dankvoice): /say options menu with output-device routing' (#25) from feat/23-dankvoice-menu into main

This commit was merged in pull request #25.
This commit is contained in:
2026-10-19 00:40:33 -07:00
4 changed files with 328 additions and 68 deletions
+27 -9
View File
@@ -16,10 +16,10 @@ timestamp: 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](/core/index.md)).
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](/core/index.md)).
## Why it exists
@@ -39,12 +39,19 @@ local speech and the cloned voices carry the real narration.
| 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, cap |
| `/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. Markdown is stripped before synthesis — fenced code is dropped, links
@@ -63,16 +70,21 @@ resolves, so no host address and no credential appears in this file. From
|---|---|---|
| `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 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.
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)
@@ -101,6 +113,11 @@ removed when the player process exits.
(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>`.
## Limits
@@ -125,6 +142,7 @@ removed when the player process exits.
`@earendil-works/pi-coding-agent` `dist/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).
* Gitea issue #23 on `dank/command-center` (the options-menu work item).
## See Also
+15
View File
@@ -11,6 +11,21 @@ timestamp: 2026-08-28
# Bundle Change Log
## 2026-10-18 — dankvoice: `/say` options menu with output-device routing (Refs #23)
Added `/say menu` (bare `/say` now opens it; `help` keeps the text listing), the
pi-peon-style settings surface: auto-speak toggle, voice picked from the frank-tts
roster, **output device** (PipeWire sinks via `wpctl` → `pw-play --target` /
`paplay --device=`), player binary, spoken-char cap, and a "speak a test phrase"
utterance that proves the audio path audibly. Choices persist to
`~/.omp/cache/dankvoice.json` (`DANKVOICE_CONFIG`); env now wins over that file,
which wins over defaults — answering the silent-session incident where
PipeWire's remembered Bluetooth route played synthesized answers into nowhere.
`detectPlayer` becomes `availablePlayers` + `playerFor`. Tests gain a fixture
`wpctl`, a scripted `select` in the driver, and three menu scenarios: 15 green
in `tests/dankvoice.bats`, 51 green suite-wide.
## 2026-10-18 — dankvoice: the cloned voices finally speak in the TUI (Refs #21)
Nothing in the harness could pronounce them: the TUI vocalizer and `omp say` gate `--voice` against the bundled Kokoro enum (`omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, …`), so frank-tts was reachable only as a file. Added `agent/extensions/dankvoice.ts` — `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`), `/say voice|voices|stop|status` — synthesizing over the OpenAI speech route and playing through `pw-play`/`paplay`/`ffplay`, with the endpoint read from the `api: openai-speech` row of `agent/models.yml` (no host, no secret in the file). Verified live in a real TUI session on the workstation: roster `GET /v1/audio/voices` → `narrator1, narrator2, narrator3`; `/say` produced 99,884 B / 2.08 s and 69,164 B / 1.44 s WAVs handed to the player, temp dirs cleaned afterwards; `/say on` armed the footer (`🔊 narrator1`) and each following finished turn spoke its answer (1.20 s WAV), an unknown voice name answered with the roster instead of speaking, and an `agent_end` carrying `willContinue` (the harness scheduled an auto-retry) is deliberately never narrated. Extension discovery itself proved with a probe file in `~/.omp/agent/extensions/` loaded by a fresh `omp -p` run. Reference page [dankvoice](core/dankvoice.md), registered in the core index; black-box seam `tests/dankvoice.bats` (12 tests).