Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls
This commit is contained in:
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user