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