Refs #32 The humanizer's raw fetch to frank-zealot omitted chat_template_kwargs.enable_thinking: false, so the Qwen35 hybrid thought instead of answering (6.9 s reasoning_content, empty content - timing out the 6 s abort; or answering "" and muting the unit). Pin the kwarg, treat empty-content-with-reasoning as failure over silence, and stop the menu's test phrase from blocking its handler. Three red-captured regressions; 26/26 green, verified red against HEAD.
1162 lines
45 KiB
TypeScript
1162 lines
45 KiB
TypeScript
/**
|
|
* 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 <text> 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 <name> 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 <sink>` or `paplay --device=<sink>` (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 <args>` 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<string, string> {
|
|
const body: Record<string, string> = { 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<string | null> {
|
|
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<string, unknown>;
|
|
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`: `116<TAB>alsa_output…<TAB>audio/sink<TAB>*`. */
|
|
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<string, string>, dir: string, timeoutMs: number): Promise<string> {
|
|
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<string[]> {
|
|
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<void>((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<void> {
|
|
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) | <text> | on | off | menu | voice <name> | 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 <text> · /say on|off (auto-speak answers) · /say voice <name> · /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();
|
|
});
|
|
}
|