feat(dankvoice): /say options menu with output-device routing (Refs #23)
Bare /say and /say menu open a ctx.ui.select settings surface: auto-speak toggle, voice from the frank-tts roster, PipeWire output device (pw-play --target / paplay --device=), player binary, spoken cap, and a test phrase. Choices persist to ~/.omp/cache/dankvoice.json (env wins over file); detectPlayer becomes availablePlayers + playerFor. Fixes the silent-route failure mode where synthesized audio played into an offline Bluetooth sink. 15/15 dankvoice bats, 51/51 suite, live pty TUI smoke armed the footer and wrote the settings file.
This commit is contained in:
+211
-48
@@ -7,28 +7,35 @@
|
||||
* agent/models.yml) could only ever produce files. This extension adds the
|
||||
* missing path — the TUI speaks with the cloned voices:
|
||||
*
|
||||
* /say <text> speak once through the speakers
|
||||
* /say on | off auto-speak the assistant's final answer each turn
|
||||
* /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 and player in use
|
||||
* /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. Environment overrides:
|
||||
* file carries no host address and no secret. Menu choices persist to
|
||||
* ~/.omp/cache/dankvoice.json (runtime state, never tracked). Environment
|
||||
* overrides win over the file, which wins over defaults:
|
||||
*
|
||||
* DANKVOICE_URL speech base URL (…/v1) instead of models.yml
|
||||
* DANKVOICE_MODEL served model id (default: the id in that block)
|
||||
* DANKVOICE_VOICE default voice name
|
||||
* DANKVOICE_AUTO=1 start with auto-speak armed
|
||||
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
|
||||
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json)
|
||||
* DANKVOICE_PLAYER player binary, skipping detection
|
||||
* DANKVOICE_MODELS path to models.yml (default ~/.omp/agent/models.yml)
|
||||
* DANKVOICE_VOICE voice name ("" = server default)
|
||||
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
|
||||
* DANKVOICE_AUTO=1 start with auto-speak armed
|
||||
*
|
||||
* Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed
|
||||
* when its player process exits. A new utterance interrupts the previous one
|
||||
* instead of queueing behind it.
|
||||
* instead of queueing behind it. Output routing: a saved sink becomes
|
||||
* `pw-play --target <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";
|
||||
@@ -62,6 +69,7 @@ export interface Player {
|
||||
}
|
||||
|
||||
export type SayAction =
|
||||
| { kind: "menu" }
|
||||
| { kind: "help" }
|
||||
| { kind: "speak"; text: string }
|
||||
| { kind: "on" }
|
||||
@@ -75,7 +83,8 @@ export type SayAction =
|
||||
export function parseSayArgs(raw: string): SayAction {
|
||||
const args = raw.trim();
|
||||
const lower = args.toLowerCase();
|
||||
if (lower === "" || lower === "help") return { kind: "help" };
|
||||
if (lower === "" || lower === "menu") return { kind: "menu" };
|
||||
if (lower === "help") return { kind: "help" };
|
||||
if (lower === "on") return { kind: "on" };
|
||||
if (lower === "off") return { kind: "off" };
|
||||
if (lower === "stop") return { kind: "stop" };
|
||||
@@ -162,17 +171,18 @@ export function parseVoices(payload: unknown): string[] {
|
||||
return names.filter((name) => name.length > 0);
|
||||
}
|
||||
|
||||
/** Player preference: PipeWire first (this fleet runs WirePlumber), then Pulse, then ffmpeg. */
|
||||
export function detectPlayer(lookup: (command: string) => string | null): Player | null {
|
||||
const candidates: Player[] = [
|
||||
{ command: "pw-play", args: [] },
|
||||
{ command: "paplay", args: [] },
|
||||
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (lookup(candidate.command)) return candidate;
|
||||
}
|
||||
return null;
|
||||
/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */
|
||||
export function availablePlayers(lookup: (command: string) => string | null): string[] {
|
||||
return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null);
|
||||
}
|
||||
|
||||
/** One player invocation: the saved sink becomes each player's routing dialect. */
|
||||
export function playerFor(command: string, sink: string): Player {
|
||||
const base = command.split("/").pop() ?? command;
|
||||
if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] };
|
||||
if (!sink) return { command, args: [] };
|
||||
if (base === "paplay") return { command, args: [`--device=${sink}`] };
|
||||
return { command, args: ["--target", sink] };
|
||||
}
|
||||
|
||||
/** What a speech engine should say: prose only, no fences, no markup, bounded. */
|
||||
@@ -217,11 +227,62 @@ export function lastAssistantText(messages: readonly unknown[]): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Menu choices, persisted between sessions; every field optional. */
|
||||
export interface Settings {
|
||||
voice?: string;
|
||||
player?: string;
|
||||
sink?: string;
|
||||
maxChars?: number;
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */
|
||||
export function settingsPath(env: NodeJS.ProcessEnv): string {
|
||||
return env.DANKVOICE_CONFIG?.trim() || join(env.HOME ?? "", ".omp", "cache", "dankvoice.json");
|
||||
}
|
||||
|
||||
export function readSettings(path: string): Settings {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const raw = parsed as Record<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;
|
||||
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;
|
||||
player?: Player;
|
||||
sink: string;
|
||||
maxChars: number;
|
||||
auto: boolean;
|
||||
voice: string;
|
||||
@@ -242,8 +303,10 @@ function readModelsYml(): string {
|
||||
}
|
||||
|
||||
function loadConfig(): Config {
|
||||
const overrideUrl = process.env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||
const modelOverride = process.env.DANKVOICE_MODEL?.trim();
|
||||
const env = process.env;
|
||||
const settings = readSettings(settingsPath(env));
|
||||
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||
const modelOverride = env.DANKVOICE_MODEL?.trim();
|
||||
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
|
||||
const target: SpeechTarget | undefined = overrideUrl
|
||||
? {
|
||||
@@ -255,17 +318,25 @@ function loadConfig(): Config {
|
||||
: fromYml
|
||||
? { ...fromYml, model: modelOverride || fromYml.model }
|
||||
: undefined;
|
||||
const playerOverride = process.env.DANKVOICE_PLAYER?.trim();
|
||||
const maxChars = Number(process.env.DANKVOICE_MAX);
|
||||
const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0];
|
||||
const sink = settings.sink ?? "";
|
||||
const maxChars = Number(env.DANKVOICE_MAX);
|
||||
return {
|
||||
target,
|
||||
player: playerOverride ? { command: playerOverride, args: [] } : (detectPlayer(lookupOnPath) ?? undefined),
|
||||
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_MAX_CHARS,
|
||||
auto: process.env.DANKVOICE_AUTO === "1",
|
||||
voice: process.env.DANKVOICE_VOICE?.trim() ?? "",
|
||||
player: playerCommand ? playerFor(playerCommand, sink) : undefined,
|
||||
sink,
|
||||
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS),
|
||||
auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false),
|
||||
voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** PipeWire sinks via wpctl; empty when wireplumber is unreachable. */
|
||||
function listSinks(): SinkRow[] {
|
||||
const probe = spawnSync("wpctl", ["list", "audio", "sinks"], { encoding: "utf8" });
|
||||
return probe.status === 0 ? parseSinkList(probe.stdout) : [];
|
||||
}
|
||||
|
||||
/** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */
|
||||
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
|
||||
const res = await fetch(target.speechUrl, {
|
||||
@@ -351,6 +422,39 @@ export default function dankvoice(pi: ExtensionAPI) {
|
||||
|
||||
const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)";
|
||||
|
||||
const persistSettings = (ctx: ExtensionContext) => {
|
||||
const settings: Settings = {
|
||||
voice: state.voice,
|
||||
player: cfg.player?.command ?? "",
|
||||
sink: cfg.sink,
|
||||
maxChars: cfg.maxChars,
|
||||
auto: state.auto,
|
||||
};
|
||||
try {
|
||||
writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`dankvoice: settings not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||
}
|
||||
};
|
||||
|
||||
const setVoice = (ctx: ExtensionContext, name: string) => {
|
||||
state.voice = name;
|
||||
reflectStatus();
|
||||
ctx.ui.notify(name ? `dankvoice: voice = ${name}` : "dankvoice: voice = whatever frank-tts defaults to", "info");
|
||||
persistSettings(ctx);
|
||||
};
|
||||
|
||||
const setAuto = (ctx: ExtensionContext, on: boolean) => {
|
||||
state.auto = on;
|
||||
if (!on) stop();
|
||||
reflectStatus();
|
||||
ctx.ui.notify(
|
||||
on ? `dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})` : "dankvoice: auto-speak off",
|
||||
"info",
|
||||
);
|
||||
persistSettings(ctx);
|
||||
};
|
||||
|
||||
async function speak(ctx: ExtensionContext, text: string) {
|
||||
const spoken = stripForSpeech(text, cfg.maxChars);
|
||||
if (!spoken) return;
|
||||
@@ -378,40 +482,99 @@ export default function dankvoice(pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openMenu(ctx: ExtensionContext): Promise<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)`,
|
||||
"Speak a test phrase",
|
||||
];
|
||||
const choice = await ctx.ui.select("dankvoice — options", options);
|
||||
if (!choice) return;
|
||||
if (choice === options[0]) {
|
||||
setAuto(ctx, !state.auto);
|
||||
return;
|
||||
}
|
||||
if (choice === options[1]) {
|
||||
if (!cfg.target) {
|
||||
ctx.ui.notify(missingTarget(), "error");
|
||||
return;
|
||||
}
|
||||
let voices: string[];
|
||||
try {
|
||||
voices = await fetchVoices(cfg.target);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
return;
|
||||
}
|
||||
const picked = await ctx.ui.select("dankvoice — voice", ["server default", ...voices]);
|
||||
if (picked === undefined) return;
|
||||
setVoice(ctx, picked === "server default" ? "" : picked);
|
||||
return;
|
||||
}
|
||||
if (choice === options[2]) {
|
||||
const picked = await ctx.ui.select("dankvoice — output device", ["system default", ...listSinks().map((sink) => sink.name)]);
|
||||
if (picked === undefined) return;
|
||||
cfg.sink = picked === "system default" ? "" : picked;
|
||||
if (cfg.player) cfg.player = playerFor(cfg.player.command, cfg.sink);
|
||||
ctx.ui.notify(`dankvoice: output device = ${cfg.sink || "system default"}`, "info");
|
||||
persistSettings(ctx);
|
||||
return;
|
||||
}
|
||||
if (choice === options[3]) {
|
||||
const choices = [...new Set([cfg.player?.command, ...availablePlayers(lookupOnPath)].filter((c): c is string => Boolean(c)))];
|
||||
if (choices.length === 0) {
|
||||
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
||||
return;
|
||||
}
|
||||
const picked = await ctx.ui.select("dankvoice — player", choices);
|
||||
if (picked === undefined) return;
|
||||
cfg.player = playerFor(picked, cfg.sink);
|
||||
ctx.ui.notify(`dankvoice: player = ${picked}`, "info");
|
||||
persistSettings(ctx);
|
||||
return;
|
||||
}
|
||||
if (choice === options[4]) {
|
||||
const picked = await ctx.ui.select("dankvoice — spoken cap", ["300", "700", "1500", "3000"]);
|
||||
const next = Number(picked);
|
||||
if (!Number.isFinite(next) || next <= 0) return;
|
||||
cfg.maxChars = next;
|
||||
ctx.ui.notify(`dankvoice: cap = ${next} chars`, "info");
|
||||
persistSettings(ctx);
|
||||
return;
|
||||
}
|
||||
await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
|
||||
}
|
||||
|
||||
pi.registerCommand("say", {
|
||||
description:
|
||||
"Speak with the fleet's cloned voices (frank-tts): /say <text> | on | off | voice <name> | voices | stop | status",
|
||||
"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 <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
|
||||
"dankvoice: /say (menu) · /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
case "on":
|
||||
state.auto = true;
|
||||
reflectStatus();
|
||||
ctx.ui.notify(`dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})`, "info");
|
||||
setAuto(ctx, true);
|
||||
return;
|
||||
case "off":
|
||||
state.auto = false;
|
||||
stop();
|
||||
reflectStatus();
|
||||
ctx.ui.notify("dankvoice: auto-speak off", "info");
|
||||
setAuto(ctx, false);
|
||||
return;
|
||||
case "stop":
|
||||
stop();
|
||||
return;
|
||||
case "voice":
|
||||
state.voice = action.name;
|
||||
reflectStatus();
|
||||
ctx.ui.notify(
|
||||
action.name ? `dankvoice: voice = ${action.name}` : "dankvoice: voice = whatever frank-tts defaults to",
|
||||
"info",
|
||||
);
|
||||
setVoice(ctx, action.name);
|
||||
return;
|
||||
case "voices": {
|
||||
if (!cfg.target) {
|
||||
@@ -435,7 +598,7 @@ export default function dankvoice(pi: ExtensionAPI) {
|
||||
}
|
||||
case "status":
|
||||
ctx.ui.notify(
|
||||
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`,
|
||||
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user