Files
Veskslop-t/poc/src/audio.ts
T

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();
}
}