diff --git a/agent/extensions/dankvoice.ts b/agent/extensions/dankvoice.ts deleted file mode 100644 index 269d2c5..0000000 --- a/agent/extensions/dankvoice.ts +++ /dev/null @@ -1,1161 +0,0 @@ -/** - * dankvoice — cloned-voice vocalization for omp, served by frank-tts. - * - * The built-in speak-aloud feature (`speech.enabled` / `speech.voice`) and the - * `omp say` CLI are gated to the bundled Kokoro voice enum, so the cloned - * voices on frank-tts (Dankclaw :7772, the `speech` model role in - * agent/models.yml) could only ever produce files. This extension adds the - * missing path — the TUI speaks with the cloned voices: - * - * /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. 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_CONFIG settings file (default ~/.omp/cache/dankvoice.json) - * DANKVOICE_PLAYER player binary, skipping detection - * DANKVOICE_VOICE voice name ("" = server default) - * DANKVOICE_MAX spoken-character budget per answer (default 700) - * DANKVOICE_AUTO=1 start with auto-speak armed - * DANKVOICE_REWRITE_URL @tiny chat base URL (…/v1) for spoken-prose rewrite - * DANKVOICE_REWRITE_MODEL chat model id (default: the @tiny role's model) - * DANKVOICE_REWRITE=0 disable the rewrite step (speak mechanical text) - * DANKVOICE_THINKING=0|1 narration of thinking blocks (default: on) - * DANKVOICE_OMPCONFIG omp config.yml to resolve the @tiny role from - * - * Long answers are cut into speakable segments by a state machine ported - * from omp's built-in vocalizer: strong boundaries on [.!?…] runs followed - * by whitespace (so "!!!!!" and "..." stay one boundary), guards for - * abbreviations and decimals, code fences and tables swallowed, markdown - * cleaned. Each segment is synthesized to a temp WAV in $TMPDIR and played - * to completion before the next request goes out — requests are strictly - * sequential, so audio never stacks. The chain runs detached from the event - * handler, so an utterance may outlive the harness handler timeout. A new - * utterance interrupts the previous one. 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"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; - -const DEFAULT_MAX_CHARS = 700; -const SYNTH_TIMEOUT_MS = 60_000; -const VOICES_TIMEOUT_MS = 15_000; -/** A voice name the shim must reject; its 400 body carries the real list. */ -const VOICE_PROBE = "_dankvoice_probe_"; - -// ---------------------------------------------------------------- pure logic - -export interface SpeechTarget { - /** POST target for OpenAI speech synthesis. */ - speechUrl: string; - /** GET target for the voice roster (frank-tts answers a bare JSON array). */ - voicesUrl: string; - /** GET target for the liveness route of the same host:port. */ - healthUrl: string; - /** Served model id to name in the request body. */ - model: string; -} - -export interface Player { - command: string; - args: string[]; -} - -export type SayAction = - | { kind: "menu" } - | { kind: "help" } - | { kind: "speak"; text: string } - | { kind: "on" } - | { kind: "off" } - | { kind: "stop" } - | { kind: "voices" } - | { kind: "status" } - | { kind: "voice"; name: string }; - -/** `/say ` grammar: subcommands win, anything else is spoken text. */ -export function parseSayArgs(raw: string): SayAction { - const args = raw.trim(); - const lower = args.toLowerCase(); - 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" }; - if (lower === "voices") return { kind: "voices" }; - if (lower === "status") return { kind: "status" }; - if (lower === "voice" || lower.startsWith("voice ")) return { kind: "voice", name: args.slice(5).trim() }; - return { kind: "speak", text: args }; -} - -/** - * Pull the speech endpoint out of agent/models.yml. The runner API lives on - * the MODEL row (`api: openai-speech`), the base URL on its provider, so this - * scan remembers the last provider base URL and model id seen and returns when - * a model row claims the speech runner API. A new provider key (indent two) - * resets both. - */ -export function resolveSpeechEndpoint(modelsYml: string): SpeechTarget | undefined { - let base: string | undefined; - let id: string | undefined; - for (const line of modelsYml.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - if (/^ {2}\S.*:\s*$/.test(line)) { - base = undefined; - id = undefined; - continue; - } - const providerUrl = trimmed.match(/^baseUrl:\s*(\S+)/); - if (providerUrl) { - base = providerUrl[1].replace(/\/+$/, ""); - continue; - } - const modelId = trimmed.match(/^-\s*id:\s*(\S+)/); - if (modelId) { - id = modelId[1]; - continue; - } - if (/^api:\s*openai-speech\s*$/.test(trimmed) && base) { - return { - speechUrl: `${base}/audio/speech`, - voicesUrl: `${base}/audio/voices`, - healthUrl: `${base.replace(/\/v1$/, "")}/health`, - model: id ?? "frank-tts", - }; - } - } - return undefined; -} - -/** OpenAI speech body; `voice` is sent only when named — the shim carries its own default. */ -export function speechBody(model: string, text: string, voice: string): Record { - const body: Record = { model, input: text, response_format: "wav" }; - if (voice) body.voice = voice; - return body; -} - -/** Voice names as frank-tts reports them: the invalid-voice 400 body carries `available_voices`. */ -export function parseVoices(payload: unknown): string[] { - // frank-tts answers GET /v1/audio/voices with a bare array and an unknown voice with - // {error:{available_voices:[…]}}; a flat body is accepted too. - if (typeof payload !== "object" || payload === null) return []; - let list: unknown; - if (Array.isArray(payload)) { - list = payload; - } else if ("error" in payload && typeof payload.error === "object" && payload.error !== null && "available_voices" in payload.error) { - list = payload.error.available_voices; - } else if ("available_voices" in payload) { - list = payload.available_voices; - } else if ("voices" in payload) { - list = payload.voices; - } else if ("data" in payload) { - list = payload.data; - } - if (!Array.isArray(list)) return []; - const names: string[] = []; - for (const entry of list) { - if (typeof entry === "string") { - names.push(entry); - } else if (typeof entry === "object" && entry !== null) { - const name = "id" in entry ? entry.id : "name" in entry ? entry.name : undefined; - if (typeof name === "string") names.push(name); - } - } - return names.filter((name) => name.length > 0); -} - -/** 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] }; -} - -// ------------------------------------------------------------ segmenting -// -// Ported from omp's built-in speak-aloud vocalizer (the `fHe` segmenter and -// its helpers in dist/cli.js). The same boundary rules as the stock path, -// kept deliberately: push() takes text and returns the segments that are -// complete, flush() ends the message. - -const SENTENCE_END = /[.!?\u2026]+[)\]"'\u00BB\u201D\u2019]*\s/g; -const WEAK_BREAK = /[,;:\u2014\u2013]\s/g; -const ABBREVIATION = /(?:^|\s)(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No)\.$/i; -const LINE_HEAD = /^(?:#{1,6}|[-*+]|-{2,}|\*{2,}|_{2,}|\d{1,3}|\d{1,3}[.)]|>+|`{1,2}|~{1,2})$/; -const HORIZONTAL_RULE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/; - -const IMAGE_LINK = /!\[([^\]]*)\]\(([^()]*)\)/g; -const LINK = /\[([^\]]+)\]\(([^()]*)\)/g; -const BRACKETED_URL = /<(https?:\/\/[^\s>]+)>/g; -const BARE_URL = /\bhttps?:\/\/[^\s<>()"'\]]+|\bwww\.[\w-]+(?:\.[\w-]+)+[^\s<>()"'\]]*/g; -const INLINE_CODE = /`{1,2}([^`]+)`{1,2}/g; -const BOLD_MARKUP = /\*\*|__|~~/g; -const STAR = /\*(?=\S)|(?<=\S)\*/g; -const UNDERSCORE = /(^|\s)_+|_+(?=\s|$)/g; -const HTML_TAG = /<\/?[a-zA-Z][^<>]*>/g; -const EMPHASIS_RUN = /(^|\s)[-*_]{3,}(?=\s|$)/g; -const DEEP_PATH = /(^|[\s("'`])((?:~|\.{1,2})?\/?[\w.@+-]+(?:\/[\w.@+-]+){2,}\/?)/g; -const HAS_WORD = /[\p{L}\p{N}]/u; - -/** Speak a URL by host and a file path by its last segment. */ -export function spokenDomain(url: string): string { - return url.replace(/^[a-z][\w+.-]*:\/\//i, "").replace(/^www\./i, "").replace(/[/?#].*$/, ""); -} - -/** Final per-segment cleanup; empty when nothing speakable survived. */ -export function cleanForSpeech(text: string): string { - const out = text - .replace(IMAGE_LINK, "$1") - .replace(LINK, "$1") - .replace(BRACKETED_URL, (_match, url: string) => spokenDomain(url)) - .replace(BARE_URL, (url: string) => spokenDomain(url)) - .replace(INLINE_CODE, "$1") - .replace(BOLD_MARKUP, "") - .replace(STAR, "") - .replace(UNDERSCORE, "$1") - .replace(HTML_TAG, " ") - .replace(EMPHASIS_RUN, "$1") - .replace(DEEP_PATH, (_match, pre: string, path: string) => { - const parts = path.split("/").filter((part) => part.length > 0); - return pre + (parts[parts.length - 1] ?? path); - }) - .replace(/\s+/g, " ") - .trim(); - return HAS_WORD.test(out) ? out : ""; -} - -function sentenceBoundary(text: string, minChars: number): number { - SENTENCE_END.lastIndex = 0; - for (let match = SENTENCE_END.exec(text); match; match = SENTENCE_END.exec(text)) { - const end = match.index + match[0].length; - if (end < minChars) continue; - if (ABBREVIATION.test(text.slice(0, end).trimEnd())) continue; - if ((text.slice(0, end).match(/`/g)?.length ?? 0) % 2 !== 0) continue; - return end; - } - return -1; -} - -function firstWeakBreak(text: string, minChars: number): number { - WEAK_BREAK.lastIndex = 0; - for (let match = WEAK_BREAK.exec(text); match; match = WEAK_BREAK.exec(text)) { - const end = match.index + match[0].length; - if (end >= minChars) return end; - } - return -1; -} - -function lastWeakBreakWithin(text: string, minChars: number, maxChars: number): number { - WEAK_BREAK.lastIndex = 0; - let found = -1; - for (let match = WEAK_BREAK.exec(text); match; match = WEAK_BREAK.exec(text)) { - const end = match.index + match[0].length; - if (end > maxChars) break; - if (end >= minChars) found = end; - } - return found; -} - -function wordWrapCut(text: string, maxChars: number): number { - const space = text.lastIndexOf(" ", maxChars); - return space > 0 ? space + 1 : Math.min(maxChars, text.length); -} - -type LineHead = - | { kind: "swallow" } - | { kind: "fence"; fence: string } - | { kind: "marker"; spoken: string } - | { kind: "prose"; text: string } - | { kind: "undecided" }; - -/** Classify a line start: tables and fences swallow, list markers get spoken. */ -export function classifyLineHead(head: string): LineHead { - if (head === "|") return { kind: "swallow" }; - if (/^(?:`{3}|~{3})/.test(head)) return { kind: "fence", fence: head.slice(0, 3) }; - if (/^#{1,6}[ \t]/.test(head)) return { kind: "marker", spoken: "" }; - if (/^[-*+][ \t]/.test(head)) return { kind: "marker", spoken: "" }; - const ordered = /^(\d{1,3})[.)][ \t]/.exec(head); - if (ordered) return { kind: "marker", spoken: `${ordered[1]}, ` }; - if (/^>+/.test(head) && !/^>+$/.test(head)) return { kind: "prose", text: head.replace(/^>+[ \t]?/, "") }; - if (LINE_HEAD.test(head)) return { kind: "undecided" }; - return { kind: "prose", text: head }; -} - -type SegmenterState = "linestart" | "prose" | "swallow" | "code"; - -/** The built-in vocalizer's segmenter: streamed text in, speakable segments out. */ -export class SpeechSegmenter { - #state: SegmenterState = "linestart"; - #head = ""; - #fence = ""; - #fenceLine = ""; - #resume: SegmenterState = "linestart"; - #buffer = ""; - #started = false; - - push(chunk: string): string[] { - const out: string[] = []; - for (const char of chunk) this.#feed(char, out); - this.#split(out); - return out; - } - - flush(): string[] { - const out: string[] = []; - if (this.#state === "linestart" && this.#head.length > 0 && !HORIZONTAL_RULE.test(this.#head)) this.#buffer += this.#head; - this.#head = ""; - this.#state = "linestart"; - this.#emit(out); - return out; - } - - /** Streaming idle flush: only cut a tail that already looks finished. */ - flushIdle(): string[] { - const out: string[] = []; - const tail = this.#buffer.trimEnd(); - if (!/[.!?\u2026][)\]"'\u00BB\u201D\u2019]*$/.test(tail) && tail.length < 24) return out; - this.#emit(out); - return out; - } - - #feed(char: string, out: string[]): void { - switch (this.#state) { - case "linestart": - this.#feedLineStart(char, out); - return; - case "prose": - if (char === "\n") this.#endLine(out); - else this.#buffer += char; - return; - case "swallow": - if (char === "\n") this.#state = this.#resume; - return; - case "code": - this.#feedCode(char); - return; - } - } - - #feedLineStart(char: string, out: string[]): void { - if (char === "\n") { - const head = this.#head; - this.#head = ""; - if (head.length > 0 && !HORIZONTAL_RULE.test(head)) this.#buffer += head; - this.#endLine(out); - return; - } - this.#head += char; - const classified = classifyLineHead(this.#head); - if (classified.kind === "undecided") { - if (this.#head.length > 8) { - this.#buffer += this.#head; - this.#head = ""; - this.#state = "prose"; - } - return; - } - this.#head = ""; - switch (classified.kind) { - case "prose": - this.#buffer += classified.text; - this.#state = "prose"; - return; - case "marker": - this.#buffer += classified.spoken; - this.#state = "prose"; - return; - case "swallow": - this.#state = "swallow"; - this.#resume = "linestart"; - return; - case "fence": - this.#fence = classified.fence; - this.#fenceLine = ""; - this.#state = "swallow"; - this.#resume = "code"; - return; - } - } - - #feedCode(char: string): void { - if (char === "\n") { - this.#fenceLine = ""; - return; - } - if (this.#fenceLine.length < 3) { - this.#fenceLine += char; - if (this.#fenceLine === this.#fence) { - this.#state = "swallow"; - this.#resume = "linestart"; - } - } - } - - #endLine(out: string[]): void { - this.#state = "linestart"; - this.#emit(out); - } - - #emit(out: string[]): void { - this.#split(out); - const segment = this.#buffer; - this.#buffer = ""; - this.#pushSegment(segment, out); - } - - #split(out: string[]): void { - for (;;) { - const buffer = this.#buffer; - const boundary = sentenceBoundary(buffer, this.#started ? 24 : 12); - if (boundary !== -1 && boundary <= 280) { - this.#cut(boundary, out); - continue; - } - if (!this.#started && buffer.length >= 40) { - const weak = firstWeakBreak(buffer, 12); - if (weak !== -1 && weak <= 140) { - this.#cut(weak, out); - continue; - } - if (buffer.length >= 140) { - this.#cut(wordWrapCut(buffer, 140), out); - continue; - } - } - if (this.#started && buffer.length >= 160) { - const weak = lastWeakBreakWithin(buffer, 24, 160); - if (weak !== -1) { - this.#cut(weak, out); - continue; - } - } - if (buffer.length > 280) { - const weak = lastWeakBreakWithin(buffer, 24, 280); - this.#cut(weak !== -1 ? weak : wordWrapCut(buffer, 280), out); - continue; - } - return; - } - } - - #cut(end: number, out: string[]): void { - const head = this.#buffer.slice(0, end); - this.#buffer = this.#buffer.slice(end); - this.#pushSegment(head, out); - } - - #pushSegment(raw: string, out: string[]): void { - const cleaned = cleanForSpeech(raw); - if (!cleaned) return; - out.push(cleaned); - this.#started = true; - } -} - -/** A whole message to speakable segments: the segmenter run start to end. */ -export function segmentsForSpeech(text: string): string[] { - const segmenter = new SpeechSegmenter(); - const segments = segmenter.push(text); - for (const tail of segmenter.flush()) segments.push(tail); - return segments; -} - -/** The leading whole segments that fit the user's spoken-character budget. */ -export function budgetSegments(segments: string[], maxChars: number): string[] { - const chosen: string[] = []; - let used = 0; - for (const segment of segments) { - if (used + segment.length > maxChars) break; - used += segment.length; - chosen.push(segment); - } - return chosen; -} - -// ------------------------------------------------------------ humanization -// -// Port of the built-in vocalizer's speech enhancer (the `Qyr` class and its -// system prompt `T6o` in dist/cli.js): rewrite each unit into natural spoken -// prose with the @tiny model before synthesis. Same prompt, same budgets -// (1536 max tokens, 6 s, 4000-char input window), and the same contract: -// null means the enhancer failed — fall back to the mechanical text — while -// an empty string means the enhancer found nothing worth saying. - -const REWRITE_MAX_TOKENS = 1536; -const REWRITE_TIMEOUT_MS = 6_000; -const REWRITE_MAX_INPUT = 4_000; - -const REWRITE_SYSTEM_PROMPT = `Rewrite coding-assistant replies as words a person would say aloud. Audience: developer listening while working; written reply remains onscreen. Narrate; NEVER dictate syntax. - -Output ONLY spoken words—no Markdown, quotes, preamble, or stage directions. - -- Natural colleague-over-the-shoulder summary. Preserve original meaning, order, tone; add no opinions, greetings, or content absent from text. -- NEVER read URLs, Markdown/table syntax, or separators. Link: label or site name ("the Bun issue on GitHub"). File path: file name only (say "vocalizer.ts," not "vocalizer dot t s"). -- Code blocks: NEVER read; replace each with one short clause describing it or its function ("a small helper that retries the request"). Skip it if surrounding prose already explains it. -- Short inline identifiers, flags, commands: speak as-is ("run bun check"); paraphrase if awkward. -- Speak numbers, versions, symbols naturally: "v1.2" → "version one point two"; "→" → "to"; "&" → "and"; "~5s" → "about five seconds". -- Lists: flowing sentences ("first, then, and finally"); NEVER recite bullets or numbers. -- Concise: same length or shorter; compress boilerplate, NEVER pad. -- Partial mid-thought fragments: render only what exists; NEVER invent an ending. -- Pure code, tables, or markup: empty reply. -`; - -export interface RewriteTarget { - /** OpenAI-compatible chat completions route of the @tiny model. */ - chatUrl: string; - /** Served model id to name in the request body. */ - model: string; -} - -/** - * Resolve the @tiny role the way the harness does: `modelRoles.tiny` in - * config.yml reads `provider (host)/model:effort`; the provider block in - * models.yml supplies the base URL. Unknown role or provider → undefined. - */ -export function resolveTinyRewriter(modelsYml: string, configYml: string): RewriteTarget | undefined { - const role = configYml.match(/^[ \t]*tiny:[ \t]*(.+?)[ \t]*$/m)?.[1]; - if (!role) return undefined; - const ref = role.includes(":") ? role.slice(0, role.lastIndexOf(":")) : role; - const slash = ref.lastIndexOf("/"); - if (slash <= 0) return undefined; - const wantProvider = ref.slice(0, slash).trim(); - const wantModel = ref.slice(slash + 1).trim(); - if (!wantProvider || !wantModel) return undefined; - let inProvider = false; - let base: string | undefined; - for (const line of modelsYml.split(/\r?\n/)) { - const key = line.match(/^ {2}"?([^"\s][^":]*?)"?[ \t]*:[ \t]*$/); - if (key) { - inProvider = key[1].trim() === wantProvider; - base = undefined; - continue; - } - if (!inProvider) continue; - const url = line.trim().match(/^baseUrl:\s*(\S+)/); - if (url) { - base = url[1].replace(/\/+$/, ""); - continue; - } - if (line.trim().match(/^-\s*id:\s*(\S+)/)?.[1] === wantModel && base) { - return { chatUrl: `${base}/chat/completions`, model: wantModel }; - } - } - return undefined; -} - -/** Env override first, then the @tiny role; both miss → no humanization. */ -export function resolveRewriter(env: NodeJS.ProcessEnv, modelsYml: string, configYml: string): RewriteTarget | undefined { - const override = env.DANKVOICE_REWRITE_URL?.trim().replace(/\/+$/, ""); - if (override) return { chatUrl: `${override}/chat/completions`, model: env.DANKVOICE_REWRITE_MODEL?.trim() || "tiny" }; - return resolveTinyRewriter(modelsYml, configYml); -} - -/** - * The enhancer's rewrite step; never throws — null is its "couldn't". - * - * The `chat_template_kwargs.enable_thinking: false` field is load-bearing. - * The @tiny fleet model (frank-zealot, a Qwen35 hybrid) defaults to a - * reasoning pass that fills `reasoning_content` and leaves `content` empty. - * The harness injects this flag for the built-in vocalizer; a raw POST does - * not get it, so without it the rewrite spends its whole 6 s budget thinking - * (blowing the timeout → mechanical fallback) and, when it answers in time, - * returns an empty `content` that would mute the utterance. Measured: 6.9 s / - * empty content without the flag, 0.55 s / clean prose with it. - */ -export async function rewriteForSpeech(target: RewriteTarget, text: string): Promise { - try { - const res = await fetch(target.chatUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - model: target.model, - messages: [ - { role: "system", content: REWRITE_SYSTEM_PROMPT }, - { role: "user", content: text.slice(0, REWRITE_MAX_INPUT) }, - ], - max_tokens: REWRITE_MAX_TOKENS, - chat_template_kwargs: { enable_thinking: false }, - }), - signal: AbortSignal.timeout(REWRITE_TIMEOUT_MS), - }); - if (!res.ok) return null; - const payload: unknown = await res.json(); - if (!payload || typeof payload !== "object" || !("choices" in payload) || !Array.isArray(payload.choices) || payload.choices.length === 0) return null; - const first: unknown = payload.choices[0]; - if (!first || typeof first !== "object" || !("message" in first)) return null; - const message: unknown = first.message; - if (!message || typeof message !== "object" || !("content" in message)) return null; - const content: unknown = message.content; - const spoken = typeof content === "string" ? content.trim() : Array.isArray(content) ? textParts(content) : null; - if (spoken === null) return null; - if (spoken === "") { - // A reasoning-only reply means the endpoint ignored the thinking-off - // flag: the prose landed in reasoning_content, not content. That is a - // failed rewrite (fall back to mechanical text), NOT the enhancer's - // legitimate "" ("nothing worth saying") — which has no reasoning. - const reasoning = "reasoning_content" in message ? message.reasoning_content : undefined; - if (typeof reasoning === "string" && reasoning.trim().length > 0) return null; - } - return spoken; - } catch { - return null; - } -} - -/** Concatenated text of a content-part array; undefined shape handled by caller. */ -function textParts(content: readonly unknown[]): string { - const parts: string[] = []; - for (const part of content) { - if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") { - parts.push(part.text); - } - } - return parts.join(" ").trim(); -} - -/** The text the assistant last actually said; tool-only messages do not count. */ -export function lastAssistantText(messages: readonly unknown[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") continue; - if (!("content" in message)) continue; - const content = message.content; - if (typeof content === "string") return content; - if (!Array.isArray(content)) continue; - const parts: string[] = []; - for (const part of content) { - if (!part || typeof part !== "object" || !("type" in part)) continue; - if (part.type === "text" && "text" in part && typeof part.text === "string") { - parts.push(part.text); - } else if (part.type === "thinking" && "narration" in part && part.narration === true && "thinking" in part && typeof part.thinking === "string") { - // provider-returned thinking addressed to the user: it is answer text - parts.push(part.thinking); - } - } - const joined = parts.join("\n").trim(); - if (joined) return joined; - } - return ""; -} - -/** The last assistant message's true thinking blocks; narration-flagged ones are answer text elsewhere. */ -export function lastAssistantThinking(messages: readonly unknown[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") continue; - if (!("content" in message) || !Array.isArray(message.content)) return ""; - const parts: string[] = []; - for (const part of message.content) { - if (part && typeof part === "object" && "type" in part && part.type === "thinking" && !(("narration" in part) && part.narration === true) && "thinking" in part && typeof part.thinking === "string") { - parts.push(part.thinking); - } - } - return parts.join("\n").trim(); - } - return ""; -} - -/** Menu choices, persisted between sessions; every field optional. */ -export interface Settings { - voice?: string; - player?: string; - sink?: string; - maxChars?: number; - auto?: boolean; - thinking?: 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; - if (typeof raw.thinking === "boolean") settings.thinking = raw.thinking; - 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; - rewriter?: RewriteTarget; - player?: Player; - sink: string; - maxChars: number; - auto: boolean; - voice: string; - thinking: boolean; -} - -function lookupOnPath(command: string): string | null { - const found = spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" }); - return found.status === 0 && found.stdout.trim() ? found.stdout.trim() : null; -} - -function readModelsYml(): string { - const path = process.env.DANKVOICE_MODELS ?? join(process.env.HOME ?? "", ".omp", "agent", "models.yml"); - try { - return readFileSync(path, "utf8"); - } catch { - return ""; - } -} - -function readOmpConfigYml(): string { - const path = process.env.DANKVOICE_OMPCONFIG ?? join(process.env.HOME ?? "", ".omp", "agent", "config.yml"); - try { - return readFileSync(path, "utf8"); - } catch { - return ""; - } -} - -function loadConfig(): Config { - const env = process.env; - const settings = readSettings(settingsPath(env)); - const modelsYml = readModelsYml(); - const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, ""); - const modelOverride = env.DANKVOICE_MODEL?.trim(); - const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(modelsYml); - const target: SpeechTarget | undefined = overrideUrl - ? { - speechUrl: `${overrideUrl}/audio/speech`, - voicesUrl: `${overrideUrl}/audio/voices`, - healthUrl: `${overrideUrl.replace(/\/v1$/, "")}/health`, - model: modelOverride || "frank-tts", - } - : fromYml - ? { ...fromYml, model: modelOverride || fromYml.model } - : undefined; - const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0]; - const sink = settings.sink ?? ""; - const maxChars = Number(env.DANKVOICE_MAX); - const rewriter = env.DANKVOICE_REWRITE === "0" ? undefined : resolveRewriter(env, modelsYml, readOmpConfigYml()); - const thinking = env.DANKVOICE_THINKING === "1" ? true : env.DANKVOICE_THINKING === "0" ? false : (settings.thinking ?? true); - return { - target, - rewriter, - 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 ?? "", - thinking, - }; -} - -/** 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, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs), - }); - const contentType = res.headers.get("content-type") ?? ""; - if (!res.ok || contentType.includes("json")) { - let message = `frank-tts HTTP ${res.status}`; - try { - const payload: unknown = await res.json(); - if ( - typeof payload === "object" && - payload !== null && - "error" in payload && - typeof payload.error === "object" && - payload.error !== null && - "message" in payload.error && - typeof payload.error.message === "string" - ) { - message = payload.error.message; - } - } catch { - // keep the status-line message - } - throw new Error(message); - } - const bytes = new Uint8Array(await res.arrayBuffer()); - if (bytes.byteLength === 0) throw new Error("frank-tts returned no audio"); - const file = join(dir, `say-${Date.now()}.wav`); - writeFileSync(file, bytes); - return file; -} - -/** frank-tts's roster route first; if it ever answers nothing, ask with a voice it must reject. */ -async function fetchVoices(target: SpeechTarget): Promise { - const roster = await fetch(target.voicesUrl, { signal: AbortSignal.timeout(VOICES_TIMEOUT_MS) }) - .then((res) => res.json()) - .catch(() => undefined); - const listed = parseVoices(roster); - if (listed.length > 0) return listed; - const probe = await fetch(target.speechUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(speechBody(target.model, "voice list probe", VOICE_PROBE)), - signal: AbortSignal.timeout(VOICES_TIMEOUT_MS), - }).catch(() => undefined); - if (!probe) return []; - return parseVoices(await probe.json().catch(() => undefined)); -} - -/** Play a WAV and clean its temp directory up when the player exits. */ -function startPlayback(player: Player, file: string, dir: string): ChildProcess { - const child = spawn(player.command, [...player.args, file], { stdio: "ignore" }); - const clean = () => { - try { - rmSync(file, { force: true }); - rmSync(dir, { recursive: true, force: true }); - } catch { - // temp files are not worth a toast - } - }; - child.once("exit", clean); - child.once("error", clean); - return child; -} - -export default function dankvoice(pi: ExtensionAPI) { - const cfg = loadConfig(); - const state: { auto: boolean; voice: string; child?: ChildProcess; run: number; lastCtx?: ExtensionContext } = { - auto: cfg.auto, - voice: cfg.voice, - run: 0, - }; - const voiceLabel = () => state.voice || "server default"; - const reflectStatus = () => state.lastCtx?.ui.setStatus("dankvoice", state.auto ? `🔊 ${voiceLabel()}` : undefined); - - const stop = () => { - state.run += 1; // retire the live chain; its loop stops at the next gate - if (state.child && state.child.exitCode === null) state.child.kill("SIGTERM"); - state.child = undefined; - }; - - 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, - thinking: cfg.thinking, - }; - 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); - }; - - /** - * Speak one answer as strictly sequential requests: synthesize a segment, - * play it to the end, only then ask for the next — audio never stacks. - * Like the built-in enhancer path, each unit (thinking first, then the - * answer) is rewritten into spoken prose by the @tiny model before - * segmentation; a failed rewrite falls back to the mechanical text and an - * empty rewrite skips its unit. Superseded chains exit at the gates. - */ - async function speak(ctx: ExtensionContext, text: string, thinking = "") { - if (!cfg.target) { - ctx.ui.notify(missingTarget(), "error"); - return; - } - if (!cfg.player) { - ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error"); - return; - } - const target = cfg.target; - const player = cfg.player; - const units: string[] = []; - if (cfg.thinking && thinking.trim()) units.push(thinking.trim()); - if (text.trim()) units.push(text.trim()); - const segments: string[] = []; - let used = 0; - for (const unit of units) { - const rewritten = cfg.rewriter ? await rewriteForSpeech(cfg.rewriter, unit) : null; - if (rewritten === "") continue; - const chosen = budgetSegments(segmentsForSpeech(rewritten ?? unit), Math.max(0, cfg.maxChars - used)); - used += chosen.reduce((chars, segment) => chars + segment.length, 0); - segments.push(...chosen); - } - if (segments.length === 0) return; - stop(); - const run = state.run; - for (const segment of segments) { - if (state.run !== run) return; - const dir = mkdtempSync(join(tmpdir(), "dankvoice-")); - try { - const file = await synthesize(target, speechBody(target.model, segment, state.voice), dir, SYNTH_TIMEOUT_MS); - if (state.run !== run) { - rmSync(dir, { recursive: true, force: true }); - return; - } - await new Promise((resolve) => { - const child = startPlayback(player, file, dir); - state.child = child; - child.once("exit", () => resolve()); - child.once("error", () => resolve()); - }); - } catch (error) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // temp files are not worth a toast - } - ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"); - return; - } - } - } - - 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)`, - `Thinking narration: ${cfg.thinking ? "on" : "off"} (toggle)`, - `Humanize answers: ${cfg.rewriter ? `on (${cfg.rewriter.model})` : "off"} (toggle)`, - "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; - } - if (choice === options[5]) { - cfg.thinking = !cfg.thinking; - ctx.ui.notify(`dankvoice: thinking narration ${cfg.thinking ? "on" : "off"}`, "info"); - persistSettings(ctx); - return; - } - if (choice === options[6]) { - cfg.rewriter = cfg.rewriter ? undefined : resolveRewriter(process.env, readModelsYml(), readOmpConfigYml()); - ctx.ui.notify(cfg.rewriter ? `dankvoice: humanizing with ${cfg.rewriter.model}` : "dankvoice: humanization off", "info"); - persistSettings(ctx); - return; - } - // Fire-and-forget on purpose: awaiting the playback chain here blocks - // the menu handler for the whole phrase and the harness reports the - // command as timed out. The point of the test is to hear it, not to - // block the menu on it. - ctx.ui.notify("dankvoice: speaking the test phrase", "info"); - void speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`).catch((error) => - ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"), - ); - } - - pi.registerCommand("say", { - description: - "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 (menu) · /say · /say on|off (auto-speak answers) · /say voice · /say voices · /say stop · /say status", - "info", - ); - return; - case "on": - setAuto(ctx, true); - return; - case "off": - setAuto(ctx, false); - return; - case "stop": - stop(); - return; - case "voice": - setVoice(ctx, action.name); - return; - case "voices": { - if (!cfg.target) { - ctx.ui.notify(missingTarget(), "error"); - return; - } - const voices: string[] = []; - try { - voices.push(...(await fetchVoices(cfg.target))); - } catch (error) { - ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"); - return; - } - ctx.ui.notify( - voices.length - ? `dankvoice voices: ${voices.join(", ")} — using ${voiceLabel()}` - : "dankvoice: frank-tts reported no voice list", - voices.length ? "info" : "warning", - ); - return; - } - case "status": - ctx.ui.notify( - `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 · thinking ${cfg.thinking ? "on" : "off"} · humanize ${cfg.rewriter ? cfg.rewriter.model : "off"}`, - "info", - ); - return; - case "speak": - void speak(ctx, action.text).catch((error) => ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error")); - return; - } - }, - }); - - pi.on("agent_end", (event, ctx) => { - state.lastCtx = ctx; - if (!state.auto) return; - // `willContinue` means the harness already scheduled a continuation - // (auto-retry, empty-stop retry): this is not the user-visible terminal - // settle, so narrating it would speak an answer the model is about to redo. - if (event.willContinue) return; - if (ctx.mode === "print" || ctx.mode === "json") return; - // Fire-and-forget: a multi-segment utterance legitimately outlives the - // harness's handler timeout, and blocking here would only produce a - // spurious "handler timed out" toast while playback continues anyway. - void speak(ctx, lastAssistantText(event.messages), lastAssistantThinking(event.messages)).catch((error) => - ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"), - ); - }); - - pi.on("session_shutdown", () => { - stop(); - }); -} diff --git a/omp-docs/core/dankvoice.md b/omp-docs/core/dankvoice.md index 8f2c635..19c9496 100644 --- a/omp-docs/core/dankvoice.md +++ b/omp-docs/core/dankvoice.md @@ -1,7 +1,7 @@ --- type: reference 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: - omp - extension @@ -15,244 +15,76 @@ timestamp: 2026-10-19 # dankvoice -`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive -harness: `/say` opens the options menu, `/say ` speaks through the -speakers and `/say on` narrates the assistant's thinking and final answer -after every turn. The words are first rewritten into natural spoken prose by -the **@tiny** role model (exactly what the built-in vocalizer's -`speech.enhanced` does), and everything is served by **frank-tts** -(Dankclaw :7772, the `speech` model role in [agent/models.yml](/core/index.md)). +Voice output lives in the **`dank/dankvoice` package**: +`pi install git:http://192.168.0.143:3000/dank/dankvoice.git`. This page owns +only fleet integration and the cutover. The package README +() owns the `/say` command +reference, the settings/env knob list, the segmentation state machine, the +enhancer port, and mood control — never duplicate those facts here. -## 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 | -|---|---|---| -| 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 | +## Cutover (Refs #34) -`dankvoice` is not a patch to that vocalizer (harness code, not ours); it is a -second transport that happens to speak, so Kokoro stays available for quick -local speech and the cloned voices carry the real narration. +The `agent/extensions/dankvoice.ts` drop-in and its 26-test bats suite are +retired here (the package suite is a strict superset; test names ported 1:1). +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 | -|---|---| -| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, thinking-narration toggle, humanize toggle, test phrase | -| `/say ` | 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 `🔊 ` 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, device, cap, thinking, humanize | +Menu choices survive the cutover: both transports persist to the same +gitignored settings file, `~/.omp/cache/dankvoice.json` +(`DANKVOICE_CONFIG` relocates it). -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. +## Why not the built-in vocalizer -Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay -silent. Each spoken unit — thinking first when narrated, then the answer — -is first rewritten into spoken prose by the @tiny model; when the enhancer -(below) declines, the text goes to segmentation as-is, where fenced code and -tables vanish, links collapse to their label and bullets and table pipes -disappear. A long answer is never one monolithic synthesis request; see -[Segmentation](#segmentation) for how utterances are cut and played. - -## 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 ` 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. - -## 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 `. -* **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`. +The harness speak-aloud path (`speech.enabled` / `speech.voice`, `omp say`) +synthesizes with bundled Kokoro ONNX against a **compile-time voice enum** — +cloned voices can never reach it, and no setting routes it to frank-tts. +`dankvoice` is a second transport beside it, not a patch: Kokoro stays for +quick local speech, the package carries cloned-voice narration. The `tts` +tool / `xd://tts` device reach frank-tts too, but produce files, not audio +playback. ## Limits -* The TUI's own speak-aloud feature still narrates in Kokoro: its voice list is - a compile-time enum in the harness bundle, and no setting routes it to - frank-tts. Cloned voices live only on this file-producing/speaking path and - on the `tts` tool. See [Handy](/tools/handy.md) for the STT half. -* `agent_end` narrates thinking blocks and the final message only — mid-turn - 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. +* Auto-speak narrates thinking and the final message only — mid-turn tool + chatter is never spoken; skipped in `print`/`json` modes so benches stay + silent. +* frank-tts deployment, voice packs and the registry are owned by the + `serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`). ## Citations -* Live probes on the workstation, 2026-10-18: `omp say --voice narrator1 …`, - `curl http://192.168.0.143:7772/health`, `/v1/audio/voices`, - `/v1/audio/…/speech` (invalid voice), `ffprobe` on the produced WAVs. -* `omp --help` (extension discovery flags, `say`, `plugin`) and - `@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts` - (`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). +* Package repo `dank/dankvoice` (README, `docs/decisions/`) — behaviour and + knob truth. +* Gitea issues #21, #23, #27, #32 (drop-in history) and #34 (this cutover) + on `dank/command-center`. +* Live endpoint probes 2026-10-18/19 documented in those issues. ## See Also * [Core Documentation](/core/index.md) — the live harness configuration. * [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice. -* `serverAppConfigs-docs/Dankclaw/Frank TTS.md` — the frank-tts service runbook. diff --git a/omp-docs/core/index.md b/omp-docs/core/index.md index a7d80f5..56f4eb1 100644 --- a/omp-docs/core/index.md +++ b/omp-docs/core/index.md @@ -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. * [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 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) diff --git a/omp-docs/log.md b/omp-docs/log.md index 9345718..b13cfeb 100644 --- a/omp-docs/log.md +++ b/omp-docs/log.md @@ -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 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) The humanizer's raw `fetch` to frank-zealot omitted `chat_template_kwargs. diff --git a/tests/dankvoice.bats b/tests/dankvoice.bats deleted file mode 100755 index f79749b..0000000 --- a/tests/dankvoice.bats +++ /dev/null @@ -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((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 Promise }>(); -const events = new Map Promise | void>(); -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"); -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 ] -}