Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls

This commit is contained in:
Fisto
2026-09-15 15:09:23 +02:00
parent eab94132bb
commit 302436f95f
42 changed files with 4675 additions and 180 deletions
+39 -9
View File
@@ -10,7 +10,7 @@ import { AudioManager } from "./audio.js";
import type { CaptureSegment } from "./capture.js";
import { CapturePipeline } from "./capture.js";
import type { Stt } from "./stt.js";
import { Tts, type TtsOptions, type TtsVoice } from "./tts.js";
import { Tts, type TtsOptions, type TtsProvider, type TtsVoice } from "./tts.js";
import type { TranscriptSegment, TranscriptSession, TranscriptStore } from "./transcript.js";
import { DEFAULT_VAD, type VadConfig } from "./vad.js";
@@ -50,10 +50,12 @@ export class AudioService {
private readonly speakerIntervals: SpeakerInterval[] = [];
private readonly speakerNames = new Map<string, string>();
private playing = false;
private duckEnabled = true;
private nextId = 1;
private ducked = false;
private selfId?: string;
private capture?: CapturePipeline;
private captureAdoptTimer?: ReturnType<typeof setInterval>;
private session?: TranscriptSession;
private segmentCount = 0;
private initialized = false;
@@ -94,6 +96,7 @@ export class AudioService {
stt: this.stt.status(),
queue: this.queue.length,
speaking: this.playing,
duckEnabled: this.duckEnabled,
capturing: !!this.capture,
sessionId: this.session?.id ?? null,
segments: this.segmentCount,
@@ -101,6 +104,16 @@ export class AudioService {
};
}
/** Enable or disable ducking the TTS while others speak. */
setDuckEnabled(on: boolean): void {
this.duckEnabled = on;
void this.applyDuck(this.shouldDuck());
}
private shouldDuck(): boolean {
return this.duckEnabled && this.playing && this.speakers.size > 0;
}
async speak(text: string, options: TtsOptions = {}): Promise<SpeakResult> {
await this.init();
@@ -109,12 +122,18 @@ export class AudioService {
const tts = this.tts.status();
const provider = options.provider ?? tts.defaultProvider;
if (provider === "local" && !tts.local) {
throw new Error("local TTS is not configured (VESKSLOP_TTS_CMD)");
}
if (provider === "api" && !tts.api) {
throw new Error("API TTS is not configured (VESKSLOP_TTS_API_URL)");
}
if (provider === "command" && !tts.command) {
throw new Error("the custom TTS engine is not configured (VESKSLOP_TTS_CMD)");
}
if (provider === "espeak" && !tts.espeak) {
throw new Error("espeak-ng is not installed");
}
if (provider === "local" && !tts.local) {
throw new Error("local TTS is not configured (VESKSLOP_TTS_CMD)");
}
const trimmed = text.trim();
if (!trimmed) throw new Error("text is required");
@@ -156,7 +175,7 @@ export class AudioService {
}
this.pruneIntervals();
await this.applyDuck(this.playing && this.speakers.size > 0);
await this.applyDuck(this.shouldDuck());
}
async startCapture(): Promise<{ sessionId: string }> {
@@ -185,6 +204,8 @@ export class AudioService {
});
this.capture.start();
void this.manager.adoptClientStreams();
this.captureAdoptTimer = setInterval(() => void this.manager.adoptClientStreams(), 5000);
this.session = session;
this.options.onTranscript?.({ type: "TRANSCRIPT_SESSION", session });
@@ -192,6 +213,8 @@ export class AudioService {
}
stopCapture(): void {
clearInterval(this.captureAdoptTimer);
this.captureAdoptTimer = undefined;
this.capture?.stop();
this.capture = undefined;
@@ -209,8 +232,12 @@ export class AudioService {
return this.manager.startMonitor(onData, onError);
}
ttsVoices(): Promise<TtsVoice[]> {
return this.tts.voices();
ttsVoices(engine?: TtsProvider): Promise<TtsVoice[]> {
return this.tts.voices(engine);
}
warmTts(engine?: TtsProvider) {
return this.tts.warm(engine);
}
async dispose(): Promise<void> {
@@ -259,7 +286,10 @@ export class AudioService {
try {
const text = await this.stt.transcribe(record.wav);
if (!text) return;
if (!text) {
console.warn(`[capture] transcription empty for ${record.wav}`);
return;
}
this.transcripts.setSegmentText(record.id, text);
this.options.onTranscript?.({ type: "TRANSCRIPT_TEXT", id: record.id, text });
@@ -321,7 +351,7 @@ export class AudioService {
this.playing = true;
try {
const file = await this.tts.synthesize(item.text, item.options);
await this.applyDuck(this.speakers.size > 0);
await this.applyDuck(this.shouldDuck());
await this.manager.play(file);
} catch (error) {
console.error("[audio] speak failed:", (error as Error).message);
+104 -2
View File
@@ -13,6 +13,7 @@ const SINK = process.env.VESKSLOP_AUDIO_SINK ?? "veskslop_tts";
const SOURCE = process.env.VESKSLOP_AUDIO_SOURCE ?? "veskslop_mic";
const CAPTURE_SINK = process.env.VESKSLOP_AUDIO_CAPTURE_SINK ?? "veskslop_capture";
const DUCK_PERCENT = Number(process.env.VESKSLOP_AUDIO_DUCK ?? 30);
const LEAD_MS = Math.min(Math.max(Number(process.env.VESKSLOP_TTS_LEAD_MS ?? 150) || 0, 0), 2000);
export class AudioUnavailableError extends Error {}
@@ -26,10 +27,15 @@ export interface AudioManagerStatus {
captureSink: string;
ducked: boolean;
duckPercent: number;
/** Lead-in silence in milliseconds before each TTS line. */
ttsLeadMs: number;
playing: boolean;
recording: boolean;
player: boolean;
ffmpeg: boolean;
clientMatch: string[];
adopted: number;
lastAdoptError?: string;
}
interface CommandResult {
@@ -69,6 +75,8 @@ export class AudioManager {
private backend: "pulse" | null = null;
private playerAvailable = false;
private ffmpegAvailable = false;
private adoptedTotal = 0;
private lastAdoptError?: string;
async detect(): Promise<void> {
const ffplay = await run(process.env.VESKSLOP_FFPLAY ?? "ffplay", ["-version"]);
@@ -128,13 +136,24 @@ export class AudioManager {
captureSink: CAPTURE_SINK,
ducked: this.ducked,
duckPercent: DUCK_PERCENT,
ttsLeadMs: LEAD_MS,
playing: !!this.player,
recording: !!this.recorder,
player: this.playerAvailable,
ffmpeg: this.ffmpegAvailable
ffmpeg: this.ffmpegAvailable,
clientMatch: this.clientPatterns(),
adopted: this.adoptedTotal,
lastAdoptError: this.lastAdoptError
};
}
private clientPatterns(): string[] {
return (process.env.VESKSLOP_AUDIO_CLIENT_MATCH ?? "vesktop,electron,chromium,discord")
.split(",")
.map(part => part.trim().toLowerCase())
.filter(Boolean);
}
/** Create the virtual devices when they are missing. Safe to call twice. */
async ensureDevices(): Promise<void> {
if (!this.available) await this.detect();
@@ -173,6 +192,76 @@ export class AudioManager {
]);
this.modules.push(Number(id));
}
await this.adoptClientStreams();
}
/**
* Move the client's playback streams into the capture sink. Without this the
* capture sink stays silent and the browser monitor streams zero samples.
*/
async adoptClientStreams(): Promise<number> {
if (!this.available) return 0;
const patterns = this.clientPatterns();
if (!patterns.length) return 0;
const sinks = await run("pactl", ["list", "short", "sinks"]);
if (sinks.code !== 0) {
this.lastAdoptError = sinks.stderr.trim() || "pactl list sinks failed";
return 0;
}
const captureIndex = sinks.stdout
.split("\n")
.map(line => line.split(/\s+/))
.find(fields => fields[1] === CAPTURE_SINK)?.[0];
if (captureIndex === undefined) return 0;
const list = await run("pactl", ["list", "sink-inputs"]);
if (list.code !== 0) {
this.lastAdoptError = list.stderr.trim() || "pactl list sink-inputs failed";
return 0;
}
let moved = 0;
let index: string | undefined;
let sinkIndex: string | undefined;
let matchText = "";
const tryMove = async () => {
if (!index || sinkIndex === captureIndex) return;
if (!patterns.some(pattern => matchText.includes(pattern))) return;
const result = await run("pactl", ["move-sink-input", index, CAPTURE_SINK]);
if (result.code === 0) {
moved++;
this.adoptedTotal++;
console.log(`[audio] moved stream ${index} to ${CAPTURE_SINK}`);
} else {
this.lastAdoptError = result.stderr.trim() || `move-sink-input ${index} failed`;
}
};
for (const line of list.stdout.split("\n")) {
const header = line.match(/^Sink Input #(\d+)/);
if (header) {
await tryMove();
index = header[1];
sinkIndex = undefined;
matchText = "";
continue;
}
const sink = line.match(/^\s*Sink:\s*(\d+)/);
if (sink) sinkIndex = sink[1];
const property = line.match(/^\s*(application\.name|application\.process\.binary)\s*=\s*"?([^"]+)"?/);
if (property) matchText += ` ${property[2].toLowerCase()}`;
}
await tryMove();
return moved;
}
/** Stop children and unload the modules this daemon created. */
@@ -191,8 +280,15 @@ export class AudioManager {
if (!this.available) throw new AudioUnavailableError(this.error ?? "audio is not available");
const binary = process.env.VESKSLOP_FFPLAY ?? "ffplay";
// Lead-in silence: give Discord's voice pipeline a moment to settle
// before the first syllable, so the attack is not swallowed.
const args = ["-nodisp", "-autoexit", "-loglevel", "error"];
if (LEAD_MS > 0) args.push("-af", `adelay=${LEAD_MS}:all=1`);
args.push(file);
await new Promise<void>((resolve, reject) => {
const child = spawn(binary, ["-nodisp", "-autoexit", "-loglevel", "error", file], {
const child = spawn(binary, args, {
env: { ...process.env, PULSE_SINK: SINK },
stdio: ["ignore", "ignore", "pipe"]
});
@@ -278,6 +374,11 @@ export class AudioManager {
}
const binary = process.env.VESKSLOP_FFMPEG ?? "ffmpeg";
// The client may start or restart its stream while we listen.
void this.adoptClientStreams();
const adoptTimer = setInterval(() => void this.adoptClientStreams(), 5000);
const child = spawn(
binary,
[
@@ -308,6 +409,7 @@ export class AudioManager {
});
return () => {
clearInterval(adoptTimer);
try {
child.kill("SIGTERM");
} catch {
+187 -9
View File
@@ -22,7 +22,7 @@ import { Stt } from "./stt.js";
import { TranscriptStore } from "./transcript.js";
import { DEFAULT_VAD } from "./vad.js";
import { CoreClient, coreSocketPath } from "./core-client.js";
import { CoreProcess } from "./core-process.js";
import { CoreProcess, staleBundleWarning } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js";
import { PreferencesStore } from "./preferences.js";
import { Puppet } from "./puppet.js";
@@ -49,6 +49,13 @@ async function connectLoop(): Promise<void> {
puppet = new Puppet(client, audio);
client.onEvent(event => {
// Speaking hints add to the reconciled set so the poller can
// clear users the store no longer reports as speaking.
if (event?.type === "VOICE_SPEAKING" && event.userId) {
if (event.speaking) speakingUsers.add(String(event.userId));
else speakingUsers.delete(String(event.userId));
}
void puppet!.enqueueEvent(event);
void audio.onEvent(event);
webui.broadcast(event);
@@ -56,6 +63,7 @@ async function connectLoop(): Promise<void> {
client.onClose(() => {
console.warn("[daemon] core bridge disconnected");
stopSpeakingPoll();
coreConnected = false;
core = undefined;
puppet = undefined;
@@ -66,7 +74,8 @@ async function connectLoop(): Promise<void> {
coreConnected = true;
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
webui.broadcast({ type: "CORE_STATUS", coreConnected: true });
void autoConnectAudio(client);
startSpeakingPoll();
void autoConnectAudioWithRetry(client);
return;
} catch (error) {
@@ -89,6 +98,69 @@ function requirePuppet(): Puppet {
return puppet;
}
/*
* Discord's dispatcher only sends speaking-start events, so the UI and the
* transcript attribution need the store's authoritative set. Poll it once a
* second and synthesize the missing stop/start events. Dispatcher events feed
* the same set so a user the store no longer reports is cleared.
*/
let speakingTimer: ReturnType<typeof setInterval> | undefined;
const speakingUsers = new Set<string>();
function emitCoreEvent(event: any): void {
void puppet?.enqueueEvent(event);
void audio.onEvent(event);
webui.broadcast(event);
}
async function pollSpeaking(): Promise<void> {
if (!core) return;
try {
const result = (await core.voiceSpeaking()) as { channelId?: string | null; userIds?: string[] };
const next = new Set<string>(result?.channelId ? (result.userIds ?? []) : []);
for (const userId of next) {
if (!speakingUsers.has(userId)) {
emitCoreEvent({
type: "VOICE_SPEAKING",
userId,
speaking: true,
channelId: result.channelId ?? null
});
}
}
for (const userId of speakingUsers) {
if (!next.has(userId)) {
emitCoreEvent({
type: "VOICE_SPEAKING",
userId,
speaking: false,
channelId: result?.channelId ?? null
});
}
}
speakingUsers.clear();
for (const userId of next) speakingUsers.add(userId);
} catch {
// The close handler stops the poller.
}
}
function startSpeakingPoll(): void {
stopSpeakingPoll();
speakingTimer = setInterval(() => void pollSpeaking(), 1000);
}
function stopSpeakingPoll(): void {
if (speakingTimer) clearInterval(speakingTimer);
speakingTimer = undefined;
speakingUsers.clear();
}
const coreProcess = new CoreProcess({ tokenFile });
const transcripts = new TranscriptStore(
@@ -129,6 +201,7 @@ const audio = new AudioService({
onTranscript: event => webui.broadcast(event)
});
audio.setDuckEnabled(preferences.get().audio?.duck !== false);
void audio.init();
/** Point the client at the virtual microphone and capture sink when present. */
@@ -161,11 +234,25 @@ async function autoConnectAudio(client: CoreClient): Promise<{ connected: boolea
console.log("[audio] client devices set:", JSON.stringify(result));
return { connected: true, message: "the client now uses the virtual devices" };
} catch (error) {
console.warn("[audio] device auto-connect failed:", (error as Error).message);
return { connected: false, message: (error as Error).message };
}
}
/**
* The bridge socket is up before the renderer finishes loading, so the first
* device calls can fail with "core is not ready" style errors. Retry.
*/
async function autoConnectAudioWithRetry(client: CoreClient): Promise<void> {
for (let attempt = 1; attempt <= 10; attempt++) {
if (attempt > 1) await sleep(2500);
const result = await autoConnectAudio(client);
if (result.connected) return;
console.warn(`[audio] device auto-connect attempt ${attempt}: ${result.message}`);
}
}
const auth = new AuthService(tokenFile, update => {
webui.broadcast({ type: "AUTH_QR", update });
@@ -385,12 +472,36 @@ function buildServer(): McpServer {
{
description: "Move the client to another voice channel.",
inputSchema: {
channelId: z.string().describe("Voice channel ID")
channelId: z.string().describe("Channel ID")
}
},
async ({ channelId }) => jsonText(await pacedVoice(() => requireCore().voiceMove(channelId)))
);
server.registerTool(
"get_user_volume",
{
description:
"Per-user playback volume (0 to 200, 100 is default) and the client's own output volume. A volume of 0 silences that user.",
inputSchema: {
userIds: z.array(z.string()).min(1).max(50).describe("User IDs to look up")
}
},
async ({ userIds }) => jsonText(await requireCore().userVolume(userIds))
);
server.registerTool(
"set_user_volume",
{
description: "Set a user's playback volume. 0 silences the user; 100 is default; 200 is the maximum.",
inputSchema: {
userId: z.string().describe("User ID"),
volume: z.number().int().min(0).max(200).describe("Volume from 0 to 200")
}
},
async ({ userId, volume }) => jsonText(await pacedVoice(() => requireCore().userSetVolume(userId, volume)))
);
server.registerTool(
"get_audio_status",
{
@@ -405,11 +516,58 @@ function buildServer(): McpServer {
description: "Queue text to speech into the bot's virtual microphone.",
inputSchema: {
text: z.string().min(1).max(1000).describe("Text to speak"),
provider: z.enum(["local", "api"]).optional().describe("TTS provider"),
voice: z.string().optional().describe("Voice name for the provider")
provider: z
.enum(["local", "espeak", "command", "api"])
.optional()
.describe("TTS engine: command is the configured VESKSLOP_TTS_CMD engine"),
voice: z.string().optional().describe("Voice name for the provider"),
speed: z.number().min(0.5).max(2).optional().describe("Rate multiplier, 1.0 is natural"),
pitch: z.number().min(-12).max(12).optional().describe("Pitch offset in semitones"),
expression: z
.number()
.min(0)
.max(100)
.optional()
.describe("Flextalk Expression profile value (Keith Bell, Kerry Watson)"),
profile: z
.record(z.string(), z.number())
.optional()
.describe("Flextalk profile overrides (pitch, rate, prominences, head_size, lowering)"),
highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"),
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
words: z.boolean().optional().describe("Synthesize each word and merge with gaps"),
wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms")
}
},
async ({ text, provider, voice }) => jsonText(await audio.speak(text, { provider, voice }))
async ({ text, provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) =>
jsonText(
await audio.speak(text, {
provider,
voice,
speed,
pitch,
expression,
profile,
highpass,
echo,
words,
wordGapMs
})
)
);
server.registerTool(
"start_tts_engine",
{
description: "Start (warm up) the TTS engine so the first spoken line is fast.",
inputSchema: {
engine: z
.enum(["local", "espeak", "command", "api"])
.optional()
.describe("Engine to start. Defaults to the configured one.")
}
},
async ({ engine }) => jsonText(await audio.warmTts(engine))
);
server.registerTool(
@@ -620,8 +778,24 @@ app.delete("/mcp", (_req, res) => {
);
});
app.get("/health", (_req, res) => {
res.json({ ok: true, coreConnected, socketPath: coreSocketPath, ownerId: puppet?.owner, queries: getQueryCount() });
app.get("/health", async (_req, res) => {
let selfId = puppet?.self;
if (!selfId && core) {
try {
selfId = await core.getSelfId();
} catch {
// The core may not be ready yet.
}
}
res.json({
ok: true,
coreConnected,
socketPath: coreSocketPath,
ownerId: puppet?.owner,
selfId,
queries: getQueryCount()
});
});
app.listen(PORT, HOST, (error?: Error) => {
@@ -630,9 +804,13 @@ app.listen(PORT, HOST, (error?: Error) => {
process.exit(1);
}
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
const stale = staleBundleWarning();
if (stale) console.warn(`[daemon] ${stale}`);
});
function shutdown() {
stopSpeakingPoll();
core?.close();
void Promise.allSettled([coreProcess.dispose(4000), audio.dispose()]).finally(() => process.exit(0));
}
+12
View File
@@ -196,10 +196,22 @@ export class CoreClient {
return this.request("voice.users", { userIds });
}
async voiceSpeaking() {
return this.request("voice.speaking", {});
}
async userStatus(userIds: string[]) {
return this.request("user.status", { userIds });
}
async userVolume(userIds: string[]) {
return this.request("user.volume", { userIds });
}
async userSetVolume(userId: string, volume: number) {
return this.request("user.setVolume", { userId, volume });
}
async voiceDevices() {
return this.request("voice.devices");
}
+32 -2
View File
@@ -9,7 +9,7 @@
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -17,7 +17,37 @@ const ROOT = fileURLToPath(new URL("../..", import.meta.url));
const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
const MAX_OUTPUT_LINES = 20;
const MAX_OUTPUT_LINES = 200;
/**
* Warn when the built client bundle predates the core sources. Without this, a
* new daemon paired with a stale client reports UNKNOWN_ACTION for new actions.
*/
export function staleBundleWarning(): string | null {
const sources = [
"src/renderer/puppet/actions.ts",
"src/renderer/puppet/events.ts",
"src/shared/puppetProtocol.ts",
"src/main/puppetBridge.ts"
];
const bundles = ["dist/js/renderer.js", "dist/js/main.js"];
try {
let newest = 0;
for (const source of sources) newest = Math.max(newest, statSync(join(ROOT, source)).mtimeMs);
let oldest = Number.POSITIVE_INFINITY;
for (const bundle of bundles) oldest = Math.min(oldest, statSync(join(ROOT, bundle)).mtimeMs);
if (oldest < newest) {
return "the built client bundle is older than the core sources; run `pnpm build` and restart the client";
}
} catch {
// A missing bundle is reported by the client start itself.
}
return null;
}
export interface CoreExit {
code: number | null;
+18
View File
@@ -100,6 +100,8 @@ const FAKE_HISTORY = Array.from({ length: 8 }, (_, index) => ({
const MOCK_VOICE_CHANNEL = "200000000000000002";
let botVoiceChannel: string | null = MOCK_VOICE_CHANNEL;
let ownerVoiceChannel: string | null = MOCK_VOICE_CHANNEL;
let outputVolume: number | null = 100;
const userVolumes: Record<string, number> = {};
const STATE_FAIL_MS = Number(process.env.VESKSLOP_MOCK_STATE_FAIL_MS ?? 0);
const startedAt = Date.now();
@@ -165,6 +167,8 @@ function handle(action: string | undefined, params: any) {
members: id === "999" ? mockVoiceMembers() : []
}))
};
case "voice.speaking":
return { channelId: botVoiceChannel, userIds: [] };
case "user.status":
return {
users: (params?.userIds ?? []).map((id: string) => ({
@@ -172,6 +176,20 @@ function handle(action: string | undefined, params: any) {
status: id === "999" ? "online" : id === "177117296140091393" ? "idle" : "offline"
}))
};
case "user.volume":
return {
outputVolume: outputVolume,
users: (params?.userIds ?? []).map((id: string) => ({
id,
volume: userVolumes[id] ?? 100
}))
};
case "user.setVolume": {
const userId = String(params?.userId);
const volume = Number(params?.volume);
userVolumes[userId] = volume;
return { userId, volume, outputVolume };
}
case "voice.setDevices":
return { inputDeviceId: params?.inputDeviceId ?? null, outputDeviceId: params?.outputDeviceId ?? null };
case "user.search": {
+145 -6
View File
@@ -9,9 +9,61 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
export interface TtsPreferences {
provider: "local" | "api" | null;
provider: "local" | "espeak" | "command" | "api" | null;
language: string | null;
voice: string | null;
/** Speech-rate multiplier, 1.0 is the engine's natural rate. Null uses the engine default. */
speed: number | null;
/** Pitch offset in semitones, 0 is the engine's natural pitch. Null uses the engine default. */
pitch: number | null;
/** Flextalk Expression profile value, 0 to 100. Null keeps the engine's registry default. */
expression: number | null;
/** Flextalk profile overrides (only the keys the user changed). */
profile: Record<string, number>;
/** Five saved profile slots; null means empty. `name` labels the slot. */
profilePresets: Array<Record<string, number | string> | null>;
/** High-pass filter Hz, 40-400. Null uses the engine default (40). */
highpass: number | null;
/** Mono echo amount, 0-100. Null or 0 disables. */
echo: number | null;
/** Synthesize each word and merge with silence gaps (Flextalk only). */
words: boolean;
/** Silence between words in milliseconds, 0-500. */
wordGapMs: number | null;
}
const PROFILE_RANGES: Record<string, [number, number]> = {
pitch: [0, 255],
rate: [0, 100],
volume: [0, 65535],
expression: [0, 100],
prominences: [0, 100],
head_size: [0, 100],
lowering: [0, 100]
};
function normalizeProfile(value: unknown): Record<string, number> {
const profile: Record<string, number> = {};
if (!value || typeof value !== "object") return profile;
for (const [key, range] of Object.entries(PROFILE_RANGES)) {
const raw = (value as Record<string, unknown>)[key];
if (isUnset(raw)) continue;
const number = Number(raw);
if (!Number.isFinite(number) || number < range[0] || number > range[1]) continue;
profile[key] = Math.round(number);
}
return profile;
}
function normalizePreset(value: unknown): Record<string, number | string> | null {
if (!value || typeof value !== "object") return null;
const preset: Record<string, number | string> = normalizeProfile(value);
const name = (value as Record<string, unknown>).name;
if (typeof name === "string" && name.trim()) preset.name = name.trim().slice(0, 40);
return preset;
}
export interface FavouritesPreferences {
@@ -19,16 +71,36 @@ export interface FavouritesPreferences {
channels: string[];
}
export interface AudioPreferences {
/** Duck the TTS while someone else speaks. */
duck: boolean;
}
export interface WebPreferences {
/** null means every guild is visible. An empty array hides all. */
visibleGuilds: string[] | null;
tts: TtsPreferences;
audio: AudioPreferences;
favourites: FavouritesPreferences;
}
const DEFAULTS: WebPreferences = {
visibleGuilds: null,
tts: { provider: null, language: null, voice: null },
tts: {
provider: null,
language: null,
voice: null,
speed: null,
pitch: null,
expression: null,
profile: {},
profilePresets: [null, null, null, null, null],
highpass: null,
echo: null,
words: false,
wordGapMs: null
},
audio: { duck: true },
favourites: { users: [], channels: [] }
};
@@ -37,6 +109,42 @@ export interface PreferencesOptions {
legacyFile?: string;
}
function normalizeProvider(value: unknown): TtsPreferences["provider"] {
return value === "api" || value === "local" || value === "espeak" || value === "command" ? value : null;
}
function isUnset(value: unknown): boolean {
return value === null || value === undefined || value === "";
}
function normalizeSpeed(value: unknown): number | null {
if (isUnset(value)) return null;
const speed = Number(value);
if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) return null;
return Math.round(speed * 100) / 100;
}
function normalizePitch(value: unknown): number | null {
if (isUnset(value)) return null;
const pitch = Number(value);
if (!Number.isFinite(pitch) || pitch < -12 || pitch > 12) return null;
return Math.round(pitch * 10) / 10;
}
function normalizeExpression(value: unknown): number | null {
if (isUnset(value)) return null;
const expression = Number(value);
if (!Number.isFinite(expression) || expression < 0 || expression > 100) return null;
return Math.round(expression);
}
function normalizeRange(value: unknown, min: number, max: number): number | null {
if (isUnset(value)) return null;
const number = Number(value);
if (!Number.isFinite(number) || number < min || number > max) return null;
return Math.round(number);
}
export class PreferencesStore {
private preferences: WebPreferences = structuredClone(DEFAULTS);
@@ -68,9 +176,26 @@ export class PreferencesStore {
if (patch.tts !== undefined) {
this.preferences.tts = {
provider: patch.tts?.provider === "api" ? "api" : patch.tts?.provider === "local" ? "local" : null,
provider: normalizeProvider(patch.tts?.provider),
language: patch.tts?.language ? String(patch.tts.language).slice(0, 16) : null,
voice: patch.tts?.voice ? String(patch.tts.voice).slice(0, 64) : null
voice: patch.tts?.voice ? String(patch.tts.voice).slice(0, 64) : null,
speed: normalizeSpeed(patch.tts?.speed),
pitch: normalizePitch(patch.tts?.pitch),
expression: normalizeExpression(patch.tts?.expression),
profile: normalizeProfile(patch.tts?.profile),
profilePresets: Array.isArray(patch.tts?.profilePresets)
? Array.from({ length: 5 }, (_, index) => normalizePreset(patch.tts?.profilePresets?.[index]))
: this.preferences.tts.profilePresets,
highpass: normalizeRange(patch.tts?.highpass, 40, 400),
echo: normalizeRange(patch.tts?.echo, 0, 100),
words: patch.tts?.words === true,
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500)
};
}
if (patch.audio !== undefined) {
this.preferences.audio = {
duck: patch.audio?.duck !== false
};
}
@@ -112,9 +237,23 @@ export class PreferencesStore {
? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))]
: null,
tts: {
provider: provider === "api" || provider === "local" ? provider : null,
provider: normalizeProvider(provider),
language: raw?.tts?.language ? String(raw.tts.language).slice(0, 16) : null,
voice: raw?.tts?.voice ? String(raw.tts.voice).slice(0, 64) : null
voice: raw?.tts?.voice ? String(raw.tts.voice).slice(0, 64) : null,
speed: normalizeSpeed(raw?.tts?.speed),
pitch: normalizePitch(raw?.tts?.pitch),
expression: normalizeExpression(raw?.tts?.expression),
profile: normalizeProfile(raw?.tts?.profile),
profilePresets: Array.isArray(raw?.tts?.profilePresets)
? Array.from({ length: 5 }, (_, index) => normalizePreset(raw.tts.profilePresets[index]))
: [null, null, null, null, null],
highpass: normalizeRange(raw?.tts?.highpass, 40, 400),
echo: normalizeRange(raw?.tts?.echo, 0, 100),
words: raw?.tts?.words === true,
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500)
},
audio: {
duck: raw?.audio?.duck !== false
},
favourites: {
users: Array.isArray(raw?.favourites?.users)
+5
View File
@@ -157,6 +157,11 @@ export class Puppet {
private selfId?: string;
private queue: Promise<void> = Promise.resolve();
/** The logged-in account's user ID, once the core has been asked for it. */
get self(): string | undefined {
return this.selfId;
}
constructor(
private readonly core: PuppetTransport,
private readonly audio?: PuppetAudio
+229 -15
View File
@@ -7,24 +7,51 @@
*/
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export class TtsError extends Error {}
export type TtsProvider = "local" | "api";
export type TtsProvider = "local" | "espeak" | "command" | "api";
export interface TtsOptions {
provider?: TtsProvider;
voice?: string;
/** Speech-rate multiplier; the engine maps it to its own units. */
speed?: number;
/** Pitch offset in semitones; the engine maps it to its own units. */
pitch?: number;
/** Flextalk Expression profile value, 0 to 100. */
expression?: number;
/** Flextalk profile overrides: pitch, rate, volume, expression, prominences, head_size, lowering. */
profile?: Record<string, number>;
/** High-pass filter Hz, 40 to 400. */
highpass?: number;
/** Mono echo amount, 0 to 100. */
echo?: number;
/** Synthesize each word and merge with silence gaps. */
words?: boolean;
/** Silence between words in milliseconds, 0 to 500. */
wordGapMs?: number;
}
export interface TtsEngine {
id: "espeak" | "command" | "api";
name: string;
available: boolean;
/** false when the engine has no language dimension (single-language command engines). */
language: boolean;
}
export interface TtsStatus {
local: boolean;
api: boolean;
espeak: boolean;
command: boolean;
defaultProvider: TtsProvider;
engine: string | null;
engines: TtsEngine[];
}
export interface TtsVoice {
@@ -32,8 +59,19 @@ export interface TtsVoice {
name: string;
language: string;
gender: "male" | "female" | null;
/** false when the engine ignores pitch for this voice (for example Flextalk). */
supportsPitch?: boolean;
/** true when the voice has a Flextalk-style Expression profile parameter. */
supportsExpression?: boolean;
/** Registry default for the expression parameter, when known. */
expressionDefault?: number;
/** Per-voice Flextalk profile defaults (pitch, rate, prominences, head_size, lowering, expression). */
profileDefaults?: Record<string, number>;
}
/** espeak-ng voice variants, appended to every base voice. */
const ESPEAK_VARIANTS = ["m1", "m2", "m3", "m4", "m5", "m6", "m7", "f1", "f2", "f3", "f4", "f5", "croak", "whisper"];
interface CommandResult {
code: number | null;
stdout: string;
@@ -81,11 +119,62 @@ function parseVoices(output: string): TtsVoice[] {
return voices;
}
/**
* Command-mode voice list. Entries are separated by commas or newlines. Each entry is
* `id`, `id|Name`, `id|Name|gender`, `id|Name|gender|language`, or
* `id|Name|gender|language|flags`, where flags are `+`-separated. Flags: `no-pitch`
* (the engine ignores pitch) and `expression` / `expression=NN` (Flextalk profile).
*/
function parseEngineVoices(raw: string): TtsVoice[] {
return raw
.split(/[,\n]+/)
.map(part => part.trim())
.filter(Boolean)
.map((entry): TtsVoice => {
const [id, name, gender, language, flags] = entry.split("|").map(part => part?.trim());
const flagList = flags?.split("+").map(flag => flag.trim()) ?? [];
const expression = flagList.find(flag => flag === "expression" || flag.startsWith("expression="));
const defaults = flagList.find(flag => flag.startsWith("defaults="));
let profileDefaults: Record<string, number> | undefined;
if (defaults) {
const parts = defaults.slice("defaults=".length).split(":");
profileDefaults = {};
for (let index = 0; index + 1 < parts.length; index += 2) {
const value = Number(parts[index + 1]);
if (Number.isFinite(value)) profileDefaults[parts[index]] = value;
}
}
const expressionDefault = profileDefaults?.expression ??
(expression?.includes("=") ? Number(expression.split("=")[1]) : undefined);
return {
id,
name: name || id,
language: language || "en",
gender: gender === "male" || gender === "female" ? gender : null,
supportsPitch: !flagList.includes("no-pitch"),
supportsExpression: !!expression || !!profileDefaults,
...(Number.isFinite(expressionDefault) ? { expressionDefault } : {}),
...(profileDefaults ? { profileDefaults } : {})
};
});
}
/** Serialize a profile object to the wrapper's `key=value,key=value` form. */
function serializeProfile(profile?: Record<string, number>): string {
if (!profile) return "";
return Object.entries(profile)
.map(([key, value]) => `${key}=${value}`)
.join(",");
}
export class Tts {
private readonly dir: string;
private counter = 0;
private espeak = false;
private voiceCache?: TtsVoice[];
private readonly voiceCache = new Map<string, TtsVoice[]>();
constructor(cacheDir?: string) {
const base = cacheDir ?? process.env.VESKSLOP_CACHE_DIR ?? join(tmpdir(), "veskslop-cache");
@@ -109,39 +198,128 @@ export class Tts {
return this.dir;
}
/** Display name for the configured command engine. */
get commandName(): string {
return process.env.VESKSLOP_TTS_NAME ?? "custom engine";
}
async detect(): Promise<void> {
const result = await run("espeak-ng", ["--version"]);
this.espeak = result.code === 0;
}
engines(): TtsEngine[] {
return [
{ id: "espeak", name: "espeak-ng", available: this.espeak, language: true },
{ id: "command", name: this.commandName, available: !!this.localCommand, language: false },
{ id: "api", name: "HTTP API", available: !!this.apiUrl, language: true }
];
}
private autoProvider(): TtsProvider {
if (this.localCommand) return "command";
if (this.espeak) return "espeak";
return this.apiUrl ? "api" : "local";
}
status(): TtsStatus {
const local = !!this.localCommand || this.espeak;
const override = process.env.VESKSLOP_TTS_PROVIDER;
const allowed = new Set<TtsProvider>(["local", "espeak", "command", "api"]);
return {
local,
api: !!this.apiUrl,
defaultProvider: override === "local" || override === "api" ? override : local ? "local" : "api",
engine: this.localCommand ? "command" : this.espeak ? "espeak-ng" : null
espeak: this.espeak,
command: !!this.localCommand,
defaultProvider: allowed.has(override as TtsProvider) ? (override as TtsProvider) : this.autoProvider(),
engine: this.localCommand ? "command" : this.espeak ? "espeak-ng" : null,
engines: this.engines()
};
}
async voices(): Promise<TtsVoice[]> {
if (this.voiceCache) return this.voiceCache;
if (!this.espeak) return (this.voiceCache = []);
async voices(engine?: TtsProvider): Promise<TtsVoice[]> {
const key = engine ?? this.autoProvider();
const cached = this.voiceCache.get(key);
if (cached) return cached;
let voices: TtsVoice[] = [];
if (key === "command" || (key === "local" && this.localCommand)) {
const declared = process.env.VESKSLOP_TTS_VOICES;
voices = declared ? parseEngineVoices(declared) : [];
} else if (key === "espeak" || key === "local") {
voices = await this.espeakVoices();
}
this.voiceCache.set(key, voices);
return voices;
}
private async espeakVoices(): Promise<TtsVoice[]> {
if (!this.espeak) return [];
const result = await run("espeak-ng", ["--voices"]);
this.voiceCache = result.code === 0 ? parseVoices(result.stdout) : [];
return this.voiceCache;
const base = result.code === 0 ? parseVoices(result.stdout) : [];
return base.flatMap((voice): TtsVoice[] => [
voice,
...ESPEAK_VARIANTS.map(
(variant): TtsVoice => ({
id: `${voice.id}+${variant}`,
name: `${voice.name} (${variant})`,
language: voice.language,
gender: variant.startsWith("m") ? "male" : variant.startsWith("f") ? "female" : null
})
)
]);
}
/**
* Start the engine so the first real line is fast: run one throwaway synthesis
* (the command wrapper starts Xvfb, Wine, and the host), then delete the file.
*/
async warm(engine?: TtsProvider): Promise<{ engine: TtsProvider; ms: number; warmed: boolean }> {
const key = engine ?? this.autoProvider();
const start = Date.now();
if (key === "command" && !this.localCommand) {
throw new TtsError("the custom TTS engine is not configured (VESKSLOP_TTS_CMD)");
}
const usesCommand = key === "command" || (key === "local" && !!this.localCommand);
if (!usesCommand) {
// espeak starts instantly and the HTTP provider is expected to stay warm.
return { engine: key, ms: 0, warmed: false };
}
const voice = process.env.VESKSLOP_TTS_VOICE || undefined;
const file = await this.viaLocalCommand("The quick brown fox jumps over the lazy dog.", {
provider: key,
voice
});
try {
unlinkSync(file);
} catch {
// best effort
}
return { engine: key, ms: Date.now() - start, warmed: true };
}
async synthesize(text: string, options: TtsOptions = {}): Promise<string> {
const provider = options.provider ?? this.status().defaultProvider;
return provider === "api" ? this.viaApi(text, options) : this.viaLocalCommand(text, options);
if (provider === "api") return this.viaApi(text, options);
if (provider === "espeak") return this.viaLocalCommand(text, options, true);
if (provider === "command") return this.viaLocalCommand(text, options, false);
return this.viaLocalCommand(text, options);
}
private async viaLocalCommand(text: string, options: TtsOptions): Promise<string> {
const command = this.localCommand;
private async viaLocalCommand(text: string, options: TtsOptions, forceEspeak = false): Promise<string> {
const command = forceEspeak ? undefined : this.localCommand;
if (!command && !this.espeak) {
throw new TtsError("no local TTS engine is available (install espeak-ng or set VESKSLOP_TTS_CMD)");
}
@@ -162,6 +340,14 @@ export class Tts {
.replace("{out}", out)
.replace("{text_file}", textFile)
.replace("{voice}", options.voice ?? "")
.replace("{speed}", options.speed !== undefined ? String(options.speed) : "")
.replace("{pitch}", options.pitch !== undefined ? String(options.pitch) : "")
.replace("{expression}", options.expression !== undefined ? String(options.expression) : "")
.replace("{profile}", serializeProfile(options.profile))
.replace("{highpass}", options.highpass !== undefined ? String(options.highpass) : "")
.replace("{echo}", options.echo !== undefined ? String(options.echo) : "")
.replace("{words}", options.words ? "1" : "")
.replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "")
)
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
@@ -169,7 +355,24 @@ export class Tts {
await new Promise<void>((resolve, reject) => {
const child = spawn(args[0], args.slice(1), {
env: { ...process.env, VESKSLOP_TTS_VOICE: options.voice ?? "" },
env: {
...process.env,
VESKSLOP_TTS_VOICE: options.voice ?? "",
...(options.speed !== undefined ? { VESKSLOP_TTS_SPEED: String(options.speed) } : {}),
...(options.pitch !== undefined ? { VESKSLOP_TTS_PITCH: String(options.pitch) } : {}),
...(options.expression !== undefined
? { VESKSLOP_TTS_EXPRESSION: String(options.expression) }
: {}),
...(options.profile && Object.keys(options.profile).length
? { VESKSLOP_TTS_PROFILE: serializeProfile(options.profile) }
: {}),
...(options.highpass !== undefined ? { VESKSLOP_TTS_HIGHPASS: String(options.highpass) } : {}),
...(options.echo !== undefined ? { VESKSLOP_TTS_ECHO: String(options.echo) } : {}),
...(options.words ? { VESKSLOP_TTS_WORDS: "1" } : {}),
...(options.wordGapMs !== undefined
? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) }
: {})
},
stdio: ["ignore", "ignore", "pipe"]
});
let stderr = "";
@@ -197,7 +400,18 @@ export class Tts {
? { Authorization: `Bearer ${process.env.VESKSLOP_TTS_API_KEY}` }
: {})
},
body: JSON.stringify({ text, ...(options.voice ? { voice: options.voice } : {}) })
body: JSON.stringify({
text,
...(options.voice ? { voice: options.voice } : {}),
...(options.speed !== undefined ? { speed: options.speed } : {}),
...(options.pitch !== undefined ? { pitch: options.pitch } : {}),
...(options.expression !== undefined ? { expression: options.expression } : {}),
...(options.profile && Object.keys(options.profile).length ? { profile: options.profile } : {}),
...(options.highpass !== undefined ? { highpass: options.highpass } : {}),
...(options.echo !== undefined ? { echo: options.echo } : {}),
...(options.words ? { words: true } : {}),
...(options.wordGapMs !== undefined ? { wordGapMs: options.wordGapMs } : {})
})
});
if (!response.ok) throw new TtsError(`the TTS API returned ${response.status}`);
+103 -8
View File
@@ -25,7 +25,7 @@ import type { PreferencesStore, WebPreferences } from "./preferences.js";
import type { Puppet } from "./puppet.js";
import { ensureTls } from "./tls.js";
import type { TranscriptStore } from "./transcript.js";
import type { TtsVoice } from "./tts.js";
import type { TtsProvider, TtsVoice } from "./tts.js";
const PINNED_LANGUAGES = ["en", "sv", "de", "da", "nb", "no", "ru", "chr"];
const LANGUAGE_NAMES: Record<string, string> = {
@@ -401,7 +401,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
app.post(
"/api/preferences",
wrap(async (req, res) => {
const { visibleGuilds, tts, favourites } = req.body ?? {};
const { visibleGuilds, tts, audio, favourites } = req.body ?? {};
if (visibleGuilds !== undefined && visibleGuilds !== null && !Array.isArray(visibleGuilds)) {
throw new HttpError(400, "visibleGuilds must be an array of guild IDs or null");
@@ -414,7 +414,18 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
patch.tts = {
provider: tts?.provider ?? null,
language: tts?.language ?? null,
voice: tts?.voice ?? null
voice: tts?.voice ?? null,
speed: tts?.speed ?? null,
pitch: tts?.pitch ?? null,
expression: tts?.expression ?? null,
profile: tts?.profile ?? {},
profilePresets: Array.isArray(tts?.profilePresets)
? tts.profilePresets.slice(0, 5)
: deps.preferences.get().tts.profilePresets,
highpass: tts?.highpass ?? null,
echo: tts?.echo ?? null,
words: tts?.words === true,
wordGapMs: tts?.wordGapMs ?? null
};
}
@@ -425,6 +436,11 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
};
}
if (audio !== undefined) {
patch.audio = { duck: audio?.duck !== false };
deps.audio.setDuckEnabled(patch.audio.duck);
}
res.json({ ok: true, result: deps.preferences.update(patch) });
})
);
@@ -486,6 +502,41 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
})
);
app.get(
"/api/voice/volume",
wrap(async (req, res) => {
let userIds = req.query.userIds
? String(req.query.userIds)
.split(",")
.map(id => id.trim())
.filter(Boolean)
: [];
if (!userIds.length) {
const voice = (await requireCore().voiceGet()) as { members?: Array<{ id: string }> } | undefined;
userIds = (voice?.members ?? []).map(member => member.id);
}
if (!userIds.length) {
res.json({ ok: true, result: { outputVolume: null, users: [] } });
return;
}
res.json({ ok: true, result: await requireCore().userVolume(userIds) });
})
);
app.post(
"/api/voice/volume",
wrap(async (req, res) => {
const userId = String(req.body?.userId ?? "");
const volume = Number(req.body?.volume);
const result = await pacedVoice(() => requireCore().userSetVolume(userId, volume));
deps.getPuppet()?.appendAction({ type: "voice", action: "set-volume", userId, volume });
res.json({ ok: true, result });
})
);
app.post(
"/api/voice/join",
wrap(async (req, res) => {
@@ -526,8 +577,28 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
app.get(
"/api/audio/tts/voices",
wrap(async (_req, res) => {
res.json({ ok: true, result: groupVoices(await deps.audio.ttsVoices()) });
wrap(async (req, res) => {
const engine = req.query.engine ? String(req.query.engine) : undefined;
res.json({ ok: true, result: groupVoices(await deps.audio.ttsVoices(engine as TtsProvider | undefined)) });
})
);
app.post(
"/api/audio/tts/warm",
wrap(async (req, res) => {
const requested = req.body?.engine;
const engine = ["local", "espeak", "command", "api"].includes(requested)
? (requested as TtsProvider)
: undefined;
const result = await deps.audio.warmTts(engine);
deps.getPuppet()?.appendAction({
type: "audio",
action: "warm-engine",
engine: result.engine,
ms: result.ms
});
res.json({ ok: true, result });
})
);
@@ -568,11 +639,35 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
const text = String(req.body?.text ?? "");
const prefs = deps.preferences.get();
const provider =
req.body?.provider === "api" || req.body?.provider === "local" ? req.body.provider : prefs.tts.provider;
const requested = req.body?.provider;
const provider = ["api", "local", "espeak", "command"].includes(requested)
? requested
: prefs.tts.provider;
const voice = req.body?.voice ? String(req.body.voice) : prefs.tts.voice ?? undefined;
const speed = req.body?.speed !== undefined ? Number(req.body.speed) : prefs.tts.speed ?? undefined;
const pitch = req.body?.pitch !== undefined ? Number(req.body.pitch) : prefs.tts.pitch ?? undefined;
const expression =
req.body?.expression !== undefined ? Number(req.body.expression) : prefs.tts.expression ?? undefined;
const profile = req.body?.profile && typeof req.body.profile === "object" ? req.body.profile : prefs.tts.profile;
const highpass =
req.body?.highpass !== undefined ? Number(req.body.highpass) : prefs.tts.highpass ?? undefined;
const echo = req.body?.echo !== undefined ? Number(req.body.echo) : prefs.tts.echo ?? undefined;
const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
const wordGapMs =
req.body?.wordGapMs !== undefined ? Number(req.body.wordGapMs) : prefs.tts.wordGapMs ?? undefined;
const result = await deps.audio.speak(text, { provider: provider ?? undefined, voice });
const result = await deps.audio.speak(text, {
provider: provider ?? undefined,
voice,
...(Number.isFinite(speed) ? { speed } : {}),
...(Number.isFinite(pitch) ? { pitch } : {}),
...(Number.isFinite(expression) ? { expression } : {}),
...(profile && Object.keys(profile).length ? { profile } : {}),
...(Number.isFinite(highpass) ? { highpass } : {}),
...(Number.isFinite(echo) ? { echo } : {}),
...(words ? { words: true } : {}),
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {})
});
deps.getPuppet()?.appendAction({
type: "audio",
action: "speak",