diff --git a/agent/extensions/dankvoice.ts b/agent/extensions/dankvoice.ts index 36ac89b..0def455 100644 --- a/agent/extensions/dankvoice.ts +++ b/agent/extensions/dankvoice.ts @@ -7,28 +7,35 @@ * agent/models.yml) could only ever produce files. This extension adds the * missing path — the TUI speaks with the cloned voices: * - * /say speak once through the speakers - * /say on | off auto-speak the assistant's final answer each turn - * /say voice choose a voice (empty = whatever the server defaults to) - * /say voices list the voices frank-tts reports - * /say stop cut off the current utterance - * /say status voice, endpoint and player in use + * /say open the options menu + * /say speak once through the speakers + * /say on | off arm/disarm auto-speak of the assistant's final answer + * /say menu the options menu (voice, output device, player, cap) + * /say voice choose a voice (empty = whatever the server defaults to) + * /say voices list the voices frank-tts reports + * /say stop cut off the current utterance + * /say status voice, endpoint, player and device in use + * /say help the command list * * The endpoint comes from agent/models.yml — the provider block carrying * `api: openai-speech`, i.e. the same row the speech role resolves — so this - * file carries no host address and no secret. Environment overrides: + * file carries no host address and no secret. Menu choices persist to + * ~/.omp/cache/dankvoice.json (runtime state, never tracked). Environment + * overrides win over the file, which wins over defaults: * * DANKVOICE_URL speech base URL (…/v1) instead of models.yml * DANKVOICE_MODEL served model id (default: the id in that block) - * DANKVOICE_VOICE default voice name - * DANKVOICE_AUTO=1 start with auto-speak armed - * DANKVOICE_MAX spoken-character cap per utterance (default 700) + * DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json) * DANKVOICE_PLAYER player binary, skipping detection - * DANKVOICE_MODELS path to models.yml (default ~/.omp/agent/models.yml) + * DANKVOICE_VOICE voice name ("" = server default) + * DANKVOICE_MAX spoken-character cap per utterance (default 700) + * DANKVOICE_AUTO=1 start with auto-speak armed * * Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed * when its player process exits. A new utterance interrupts the previous one - * instead of queueing behind it. + * instead of queueing behind it. Output routing: a saved sink becomes + * `pw-play --target ` or `paplay --device=` (ffplay has no device + * concept and ignores it); with no saved sink the player's own default applies. */ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; @@ -62,6 +69,7 @@ export interface Player { } export type SayAction = + | { kind: "menu" } | { kind: "help" } | { kind: "speak"; text: string } | { kind: "on" } @@ -75,7 +83,8 @@ export type SayAction = export function parseSayArgs(raw: string): SayAction { const args = raw.trim(); const lower = args.toLowerCase(); - if (lower === "" || lower === "help") return { kind: "help" }; + if (lower === "" || lower === "menu") return { kind: "menu" }; + if (lower === "help") return { kind: "help" }; if (lower === "on") return { kind: "on" }; if (lower === "off") return { kind: "off" }; if (lower === "stop") return { kind: "stop" }; @@ -162,17 +171,18 @@ export function parseVoices(payload: unknown): string[] { return names.filter((name) => name.length > 0); } -/** Player preference: PipeWire first (this fleet runs WirePlumber), then Pulse, then ffmpeg. */ -export function detectPlayer(lookup: (command: string) => string | null): Player | null { - const candidates: Player[] = [ - { command: "pw-play", args: [] }, - { command: "paplay", args: [] }, - { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] }, - ]; - for (const candidate of candidates) { - if (lookup(candidate.command)) return candidate; - } - return null; +/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */ +export function availablePlayers(lookup: (command: string) => string | null): string[] { + return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null); +} + +/** One player invocation: the saved sink becomes each player's routing dialect. */ +export function playerFor(command: string, sink: string): Player { + const base = command.split("/").pop() ?? command; + if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] }; + if (!sink) return { command, args: [] }; + if (base === "paplay") return { command, args: [`--device=${sink}`] }; + return { command, args: ["--target", sink] }; } /** What a speech engine should say: prose only, no fences, no markup, bounded. */ @@ -217,11 +227,62 @@ export function lastAssistantText(messages: readonly unknown[]): string { return ""; } +/** Menu choices, persisted between sessions; every field optional. */ +export interface Settings { + voice?: string; + player?: string; + sink?: string; + maxChars?: number; + auto?: boolean; +} + +/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */ +export function settingsPath(env: NodeJS.ProcessEnv): string { + return env.DANKVOICE_CONFIG?.trim() || join(env.HOME ?? "", ".omp", "cache", "dankvoice.json"); +} + +export function readSettings(path: string): Settings { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const raw = parsed as Record; + const settings: Settings = {}; + if (typeof raw.voice === "string") settings.voice = raw.voice; + if (typeof raw.player === "string") settings.player = raw.player; + if (typeof raw.sink === "string") settings.sink = raw.sink; + if (typeof raw.maxChars === "number" && Number.isFinite(raw.maxChars) && raw.maxChars > 0) settings.maxChars = Math.floor(raw.maxChars); + if (typeof raw.auto === "boolean") settings.auto = raw.auto; + return settings; + } catch { + return {}; + } +} + +/** A sink row from `wpctl list audio sinks`: `116alsa_output…audio/sink*`. */ +export interface SinkRow { + id: string; + name: string; +} + +export function parseSinkList(output: string): SinkRow[] { + const rows: SinkRow[] = []; + for (const line of output.split("\n")) { + const cols = line.split("\t").map((col) => col.trim()); + if (cols.length < 3 || !cols[2]?.includes("audio/sink")) continue; + const id = cols[0] ?? ""; + const name = cols[1] ?? ""; + if (!id || !name) continue; + rows.push({ id, name }); + } + return rows; +} + // ------------------------------------------------------------- live wiring interface Config { target?: SpeechTarget; player?: Player; + sink: string; maxChars: number; auto: boolean; voice: string; @@ -242,8 +303,10 @@ function readModelsYml(): string { } function loadConfig(): Config { - const overrideUrl = process.env.DANKVOICE_URL?.trim().replace(/\/+$/, ""); - const modelOverride = process.env.DANKVOICE_MODEL?.trim(); + const env = process.env; + const settings = readSettings(settingsPath(env)); + const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, ""); + const modelOverride = env.DANKVOICE_MODEL?.trim(); const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml()); const target: SpeechTarget | undefined = overrideUrl ? { @@ -255,17 +318,25 @@ function loadConfig(): Config { : fromYml ? { ...fromYml, model: modelOverride || fromYml.model } : undefined; - const playerOverride = process.env.DANKVOICE_PLAYER?.trim(); - const maxChars = Number(process.env.DANKVOICE_MAX); + const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0]; + const sink = settings.sink ?? ""; + const maxChars = Number(env.DANKVOICE_MAX); return { target, - player: playerOverride ? { command: playerOverride, args: [] } : (detectPlayer(lookupOnPath) ?? undefined), - maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_MAX_CHARS, - auto: process.env.DANKVOICE_AUTO === "1", - voice: process.env.DANKVOICE_VOICE?.trim() ?? "", + player: playerCommand ? playerFor(playerCommand, sink) : undefined, + sink, + maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS), + auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false), + voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "", }; } +/** PipeWire sinks via wpctl; empty when wireplumber is unreachable. */ +function listSinks(): SinkRow[] { + const probe = spawnSync("wpctl", ["list", "audio", "sinks"], { encoding: "utf8" }); + return probe.status === 0 ? parseSinkList(probe.stdout) : []; +} + /** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */ async function synthesize(target: SpeechTarget, body: Record, dir: string, timeoutMs: number): Promise { const res = await fetch(target.speechUrl, { @@ -351,6 +422,39 @@ export default function dankvoice(pi: ExtensionAPI) { const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)"; + const persistSettings = (ctx: ExtensionContext) => { + const settings: Settings = { + voice: state.voice, + player: cfg.player?.command ?? "", + sink: cfg.sink, + maxChars: cfg.maxChars, + auto: state.auto, + }; + try { + writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`); + } catch (error) { + ctx.ui.notify(`dankvoice: settings not saved: ${error instanceof Error ? error.message : String(error)}`, "warning"); + } + }; + + const setVoice = (ctx: ExtensionContext, name: string) => { + state.voice = name; + reflectStatus(); + ctx.ui.notify(name ? `dankvoice: voice = ${name}` : "dankvoice: voice = whatever frank-tts defaults to", "info"); + persistSettings(ctx); + }; + + const setAuto = (ctx: ExtensionContext, on: boolean) => { + state.auto = on; + if (!on) stop(); + reflectStatus(); + ctx.ui.notify( + on ? `dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})` : "dankvoice: auto-speak off", + "info", + ); + persistSettings(ctx); + }; + async function speak(ctx: ExtensionContext, text: string) { const spoken = stripForSpeech(text, cfg.maxChars); if (!spoken) return; @@ -378,40 +482,99 @@ export default function dankvoice(pi: ExtensionAPI) { } } + async function openMenu(ctx: ExtensionContext): Promise { + const options = [ + `Auto-speak: ${state.auto ? "on" : "off"} (toggle)`, + `Voice: ${voiceLabel()} (change)`, + `Output device: ${cfg.sink || "system default"} (change)`, + `Player: ${cfg.player?.command ?? "none"} (change)`, + `Spoken cap: ${cfg.maxChars} chars (change)`, + "Speak a test phrase", + ]; + const choice = await ctx.ui.select("dankvoice — options", options); + if (!choice) return; + if (choice === options[0]) { + setAuto(ctx, !state.auto); + return; + } + if (choice === options[1]) { + if (!cfg.target) { + ctx.ui.notify(missingTarget(), "error"); + return; + } + let voices: string[]; + try { + voices = await fetchVoices(cfg.target); + } catch (error) { + ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"); + return; + } + const picked = await ctx.ui.select("dankvoice — voice", ["server default", ...voices]); + if (picked === undefined) return; + setVoice(ctx, picked === "server default" ? "" : picked); + return; + } + if (choice === options[2]) { + const picked = await ctx.ui.select("dankvoice — output device", ["system default", ...listSinks().map((sink) => sink.name)]); + if (picked === undefined) return; + cfg.sink = picked === "system default" ? "" : picked; + if (cfg.player) cfg.player = playerFor(cfg.player.command, cfg.sink); + ctx.ui.notify(`dankvoice: output device = ${cfg.sink || "system default"}`, "info"); + persistSettings(ctx); + return; + } + if (choice === options[3]) { + const choices = [...new Set([cfg.player?.command, ...availablePlayers(lookupOnPath)].filter((c): c is string => Boolean(c)))]; + if (choices.length === 0) { + ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error"); + return; + } + const picked = await ctx.ui.select("dankvoice — player", choices); + if (picked === undefined) return; + cfg.player = playerFor(picked, cfg.sink); + ctx.ui.notify(`dankvoice: player = ${picked}`, "info"); + persistSettings(ctx); + return; + } + if (choice === options[4]) { + const picked = await ctx.ui.select("dankvoice — spoken cap", ["300", "700", "1500", "3000"]); + const next = Number(picked); + if (!Number.isFinite(next) || next <= 0) return; + cfg.maxChars = next; + ctx.ui.notify(`dankvoice: cap = ${next} chars`, "info"); + persistSettings(ctx); + return; + } + await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`); + } + pi.registerCommand("say", { description: - "Speak with the fleet's cloned voices (frank-tts): /say | on | off | voice | voices | stop | status", + "Speak with the fleet's cloned voices (frank-tts): /say (menu) | | on | off | menu | voice | voices | stop | status", handler: async (args: string, ctx) => { state.lastCtx = ctx; const action = parseSayArgs(args); switch (action.kind) { + case "menu": + await openMenu(ctx); + return; case "help": ctx.ui.notify( - "dankvoice: /say · /say on|off (auto-speak answers) · /say voice · /say voices · /say stop · /say status", + "dankvoice: /say (menu) · /say · /say on|off (auto-speak answers) · /say voice · /say voices · /say stop · /say status", "info", ); return; case "on": - state.auto = true; - reflectStatus(); - ctx.ui.notify(`dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})`, "info"); + setAuto(ctx, true); return; case "off": - state.auto = false; - stop(); - reflectStatus(); - ctx.ui.notify("dankvoice: auto-speak off", "info"); + setAuto(ctx, false); return; case "stop": stop(); return; case "voice": - state.voice = action.name; - reflectStatus(); - ctx.ui.notify( - action.name ? `dankvoice: voice = ${action.name}` : "dankvoice: voice = whatever frank-tts defaults to", - "info", - ); + setVoice(ctx, action.name); return; case "voices": { if (!cfg.target) { @@ -435,7 +598,7 @@ export default function dankvoice(pi: ExtensionAPI) { } case "status": ctx.ui.notify( - `dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`, + `dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`, "info", ); return; diff --git a/omp-docs/core/dankvoice.md b/omp-docs/core/dankvoice.md index 4d46952..27ecce6 100644 --- a/omp-docs/core/dankvoice.md +++ b/omp-docs/core/dankvoice.md @@ -16,10 +16,10 @@ timestamp: 2026-10-18 # dankvoice `agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive -harness: `/say ` 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 ` 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 ` | 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 `🔊 ` while armed | | `/say voice ` | 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-.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 ` or `paplay --device=`; 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-.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 `. ## 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 diff --git a/omp-docs/log.md b/omp-docs/log.md index 2e9d7da..771c0ee 100644 --- a/omp-docs/log.md +++ b/omp-docs/log.md @@ -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 `, `/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). diff --git a/tests/dankvoice.bats b/tests/dankvoice.bats index 5fac75c..bad1490 100755 --- a/tests/dankvoice.bats +++ b/tests/dankvoice.bats @@ -3,15 +3,19 @@ # # 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 notification -# text it prints. 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) and a stub player stands in for pw-play: it records -# the path it was given and copies the bytes it was told to play. +# 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_MODELS (models.yml fixture), DV_EMPTY_BIN (a PATH with no -# players), DV_MODE (the harness mode reported to the extension). +# (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_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE +# (the harness mode reported to the extension). setup() { REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" @@ -26,12 +30,27 @@ setup() { cat > "$DV_STUB" <<'SH' #!/usr/bin/env bash -printf '%s\n' "$1" >> "$DV_STUB_LOG" -cp "$1" "$DV_COPY" +printf '%s\n' "$*" >> "$DV_STUB_LOG" +for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done sleep 0.1 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' @@ -54,6 +73,7 @@ YML // 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]); @@ -88,7 +108,8 @@ if (scenario === "endpoint") { process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`; } if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin"; -process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_STUB; +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 Promise }>(); @@ -97,11 +118,19 @@ extension.default({ registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise }) => commands.set(name, options), on: (event: string, handler: (event: unknown, ctx: unknown) => Promise | 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"); @@ -121,6 +150,16 @@ if (!say) { 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); @@ -171,6 +210,7 @@ for (const call of received) { console.log(`CALLS ${received.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 @@ -183,7 +223,7 @@ teardown() { } say_run() { - run bun "$DV_DRIVER" "$1" + run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1" } @test "the extension registers the say command and an agent_end listener" { @@ -261,3 +301,27 @@ say_run() { [[ "$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)|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"* ]] +}