feat(dankvoice): /say options menu with output-device routing (Refs #23)

Bare /say and /say menu open a ctx.ui.select settings surface: auto-speak
toggle, voice from the frank-tts roster, PipeWire output device
(pw-play --target / paplay --device=), player binary, spoken cap, and a
test phrase. Choices persist to ~/.omp/cache/dankvoice.json (env wins
over file); detectPlayer becomes availablePlayers + playerFor. Fixes
the silent-route failure mode where synthesized audio played into an
offline Bluetooth sink. 15/15 dankvoice bats, 51/51 suite, live pty
TUI smoke armed the footer and wrote the settings file.
This commit is contained in:
Frank committed 2026-10-19 00:31:40 -07:00
1 parent 54f15e2a58
commit dd8f3974c1
4 files changed
+328 -68

No files matched your search

+75 -11
View File
@@ -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<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
@@ -97,11 +118,19 @@ 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");
@@ -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"* ]]
}