Files
command-center/tests/dankvoice.bats
T
Frank 765b686859 feat(dankvoice): speak the cloned voices in the TUI (Refs #21)
Every speak-aloud path in the harness gates `--voice` against the bundled
Kokoro enum, so the frank-tts cloned voices could only ever produce files.
`agent/extensions/` auto-loads, so one tracked extension adds the missing
transport: `/say <text>`, `/say on|off` (narrate the final answer at
`agent_end`, silent when the harness schedules a continuation),
`/say voice|voices|stop|status` — OpenAI speech POST plus playback through
pw-play/paplay/ffplay. The endpoint and served id come from the
`api: openai-speech` row of `agent/models.yml`, so the file carries no host
and no secret; markdown is stripped, over-long utterances are cut at a
sentence bound, and the temp WAV dir is always removed.

Verified live in a real TUI session on the workstation (roster
narrator1/narrator2/narrator3; 2.08 s and 1.44 s WAVs played; auto-speak
spoke each finished answer, 1.20 s WAV; unknown voice answers with the
roster). Black-box seam `tests/dankvoice.bats` (12 tests) drives the
extension against a fixture speech endpoint and a stub player.

cwd: /home/blank/.omp/wt/feat-21-dankvoice
session: wt-20261017-181502-6d1fc1f
2026-10-18 20:00:52 -07:00

264 lines
9.1 KiB
Bash
Executable File

#!/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 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.
#
# 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).
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' "$1" >> "$DV_STUB_LOG"
cp "$1" "$DV_COPY"
sleep 0.1
SH
chmod +x "$DV_STUB"
# 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
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";
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 received: Array<{ url: string | undefined; body: string }> = [];
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
received.push({ url: req.url, body });
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;
}
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`;
}
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : 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 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 ?? ""}`),
},
};
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 "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 "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 "-";
};
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`);
}
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 (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 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" != *"10.0.0.1"* ]]
}
@test "markdown never reaches the speech engine" {
say_run markdown
[[ "$output" == *"input=Title Use pw-play here. see docs and bold a b"* ]]
}
@test "an over-long utterance is bounded and cut at a sentence boundary" {
DANKVOICE_MAX=60 say_run truncate
[[ "$output" == *"here. …"* ]]
[ "$(grep -o 'Sentence one is here\.' <<<"$output" | wc -l)" -le 4 ]
}
@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"* ]]
}