439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
/*
|
|
* Veskslop daemon — audio devices, playback, ducking, and capture.
|
|
*
|
|
* The daemon plays audio into a virtual sink. A remap source of that sink's
|
|
* monitor becomes the client microphone. Client output can be routed to a
|
|
* second sink that the daemon records. Every system command is optional: when
|
|
* the host has no PulseAudio/PipeWire, the manager reports unavailable.
|
|
*/
|
|
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
|
|
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 {}
|
|
|
|
export interface AudioManagerStatus {
|
|
available: boolean;
|
|
backend: "pulse" | null;
|
|
error?: string;
|
|
hint?: string;
|
|
sink: string;
|
|
source: string;
|
|
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 {
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
errorCode?: string;
|
|
}
|
|
|
|
function run(command: string, args: string[]): Promise<CommandResult> {
|
|
return new Promise(resolve => {
|
|
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
child.stdout?.on("data", chunk => (stdout += chunk));
|
|
child.stderr?.on("data", chunk => (stderr += chunk));
|
|
child.on("error", error =>
|
|
resolve({ code: null, stdout, stderr: String(error), errorCode: (error as NodeJS.ErrnoException).code })
|
|
);
|
|
child.on("close", code => resolve({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export class AudioManager {
|
|
private modules: number[] = [];
|
|
private player?: ChildProcess;
|
|
private recorder?: ChildProcess;
|
|
private ducked = false;
|
|
private available = false;
|
|
private error?: string;
|
|
private hint?: string;
|
|
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"]);
|
|
const ffmpeg = await run(process.env.VESKSLOP_FFMPEG ?? "ffmpeg", ["-version"]);
|
|
this.playerAvailable = ffplay.code === 0;
|
|
this.ffmpegAvailable = ffmpeg.code === 0;
|
|
|
|
let info = await run("pactl", ["info"]);
|
|
if (info.code !== 0 && info.errorCode !== "ENOENT" && process.env.VESKSLOP_AUDIO_MANAGE_SERVER !== "0") {
|
|
await this.tryStartServer();
|
|
info = await run("pactl", ["info"]);
|
|
}
|
|
|
|
this.available = info.code === 0;
|
|
this.backend = this.available ? "pulse" : null;
|
|
|
|
if (this.available) {
|
|
this.error = undefined;
|
|
this.hint = this.playerAvailable ? undefined : "ffplay is missing; install ffmpeg to play audio.";
|
|
return;
|
|
}
|
|
|
|
if (info.errorCode === "ENOENT") {
|
|
this.error = "pactl is not installed";
|
|
this.hint =
|
|
"Install a sound server and its tools, for example: apt install pulseaudio pulseaudio-utils ffmpeg";
|
|
} else {
|
|
this.error = info.stderr.trim() || "the audio server is not reachable";
|
|
this.hint =
|
|
"The audio server is not running. Start PulseAudio or PipeWire-Pulse. The daemon starts pulseaudio automatically when it is installed.";
|
|
}
|
|
}
|
|
|
|
/** Start a private PulseAudio daemon when the server is missing. */
|
|
private async tryStartServer(): Promise<void> {
|
|
const start = await run("pulseaudio", ["--start", "--exit-idle-time=-1"]);
|
|
if (start.errorCode === "ENOENT") {
|
|
this.hint = "pulseaudio is not installed; install it or start PipeWire-Pulse.";
|
|
return;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
await sleep(500);
|
|
const info = await run("pactl", ["info"]);
|
|
if (info.code === 0) return;
|
|
}
|
|
}
|
|
|
|
status(): AudioManagerStatus {
|
|
return {
|
|
available: this.available,
|
|
backend: this.backend,
|
|
error: this.error,
|
|
hint: this.hint,
|
|
sink: SINK,
|
|
source: SOURCE,
|
|
captureSink: CAPTURE_SINK,
|
|
ducked: this.ducked,
|
|
duckPercent: DUCK_PERCENT,
|
|
ttsLeadMs: LEAD_MS,
|
|
playing: !!this.player,
|
|
recording: !!this.recorder,
|
|
player: this.playerAvailable,
|
|
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();
|
|
if (!this.available) throw new AudioUnavailableError(this.error ?? "audio is not available");
|
|
|
|
const sinks = (await this.pactl(["list", "short", "sinks"])).split("\n");
|
|
const sources = (await this.pactl(["list", "short", "sources"])).split("\n");
|
|
|
|
if (!sinks.some(line => line.includes(SINK))) {
|
|
const id = await this.pactl([
|
|
"load-module",
|
|
"module-null-sink",
|
|
`sink_name=${SINK}`,
|
|
"sink_properties=device.description=VeskslopTTS"
|
|
]);
|
|
this.modules.push(Number(id));
|
|
}
|
|
|
|
if (!sources.some(line => line.includes(SOURCE))) {
|
|
const id = await this.pactl([
|
|
"load-module",
|
|
"module-remap-source",
|
|
`master=${SINK}.monitor`,
|
|
`source_name=${SOURCE}`,
|
|
"source_properties=device.description=VeskslopMic"
|
|
]);
|
|
this.modules.push(Number(id));
|
|
}
|
|
|
|
if (!sinks.some(line => line.includes(CAPTURE_SINK))) {
|
|
const id = await this.pactl([
|
|
"load-module",
|
|
"module-null-sink",
|
|
`sink_name=${CAPTURE_SINK}`,
|
|
"sink_properties=device.description=VeskslopCapture"
|
|
]);
|
|
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. */
|
|
async dispose(): Promise<void> {
|
|
this.stopPlayback();
|
|
this.stopCapture();
|
|
if (this.ducked) await this.setDuck(false).catch(() => undefined);
|
|
|
|
for (const id of [...this.modules].reverse()) {
|
|
await run("pactl", ["unload-module", String(id)]);
|
|
}
|
|
this.modules = [];
|
|
}
|
|
|
|
async play(file: string, volume?: number): Promise<void> {
|
|
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"];
|
|
const filters: string[] = [];
|
|
if (LEAD_MS > 0) filters.push(`adelay=${LEAD_MS}:all=1`);
|
|
|
|
// Volume percent, 0-200. 100 keeps the level as synthesized. ffplay's own
|
|
// -volume stops at 100, so use the volume filter and its dB scale.
|
|
if (typeof volume === "number" && Number.isFinite(volume) && volume !== 100) {
|
|
const gain = volume > 0 ? `${(20 * Math.log10(volume / 100)).toFixed(2)}dB` : "0";
|
|
filters.push(`volume=${gain}`);
|
|
}
|
|
|
|
if (filters.length) args.push("-af", filters.join(","));
|
|
args.push(file);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn(binary, args, {
|
|
env: { ...process.env, PULSE_SINK: SINK },
|
|
stdio: ["ignore", "ignore", "pipe"]
|
|
});
|
|
|
|
this.player = child;
|
|
let stderr = "";
|
|
|
|
child.stderr?.on("data", chunk => (stderr += chunk));
|
|
child.on("error", error => {
|
|
this.player = undefined;
|
|
reject(new AudioUnavailableError(String(error)));
|
|
});
|
|
child.on("close", code => {
|
|
this.player = undefined;
|
|
if (code === 0 || code === null) resolve();
|
|
else reject(new AudioUnavailableError(stderr.trim() || `the player exited with ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
stopPlayback(): void {
|
|
try {
|
|
this.player?.kill("SIGTERM");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
this.player = undefined;
|
|
}
|
|
|
|
/** Lower the virtual sink volume while other people speak. */
|
|
async setDuck(on: boolean): Promise<void> {
|
|
if (this.ducked === on) return;
|
|
await this.pactl(["set-sink-volume", SINK, on ? `${DUCK_PERCENT}%` : "100%"]);
|
|
this.ducked = on;
|
|
}
|
|
|
|
async startCapture(file: string): Promise<void> {
|
|
if (this.recorder) throw new Error("capture is already running");
|
|
if (!this.available) throw new AudioUnavailableError(this.error ?? "audio is not available");
|
|
|
|
const binary = process.env.VESKSLOP_FFMPEG ?? "ffmpeg";
|
|
const child = spawn(
|
|
binary,
|
|
[
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"pulse",
|
|
"-i",
|
|
`${CAPTURE_SINK}.monitor`,
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
"16000",
|
|
"-y",
|
|
file
|
|
],
|
|
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
);
|
|
|
|
this.recorder = child;
|
|
child.on("close", () => {
|
|
if (this.recorder === child) this.recorder = undefined;
|
|
});
|
|
}
|
|
|
|
stopCapture(): void {
|
|
const child = this.recorder;
|
|
this.recorder = undefined;
|
|
try {
|
|
child?.kill("SIGINT");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
}
|
|
|
|
/** Stream the capture sink as raw 48 kHz mono PCM. Returns a stop function. */
|
|
startMonitor(onData: (chunk: Buffer) => void, onError?: (error: Error) => void): () => void {
|
|
if (!this.available) throw new AudioUnavailableError(this.error ?? "audio is not available");
|
|
if (!this.ffmpegAvailable) {
|
|
throw new AudioUnavailableError("ffmpeg is missing; install it to hear the call");
|
|
}
|
|
|
|
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,
|
|
[
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"pulse",
|
|
"-i",
|
|
`${CAPTURE_SINK}.monitor`,
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
"48000",
|
|
"-f",
|
|
"s16le",
|
|
"-"
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
);
|
|
|
|
child.stdout?.on("data", onData);
|
|
let stderr = "";
|
|
child.stderr?.on("data", chunk => (stderr += chunk));
|
|
child.on("error", error => onError?.(new AudioUnavailableError(String(error))));
|
|
child.on("close", code => {
|
|
if (code) onError?.(new AudioUnavailableError(stderr.trim() || `ffmpeg exited with ${code}`));
|
|
});
|
|
|
|
return () => {
|
|
clearInterval(adoptTimer);
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
};
|
|
}
|
|
|
|
private async pactl(args: string[]): Promise<string> {
|
|
const result = await run("pactl", args);
|
|
if (result.code !== 0) {
|
|
throw new AudioUnavailableError(result.stderr.trim() || `pactl ${args[0]} failed`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
}
|