309 lines
13 KiB
JavaScript
309 lines
13 KiB
JavaScript
const { exec } = require('child_process');
|
|
const util = require('util');
|
|
const execAsync = util.promisify(exec);
|
|
|
|
const VIRT_MIC_NAME = 'simplescreenshare-audio';
|
|
|
|
// Pipewire helper class to isolate application audio
|
|
class PipewireHelper {
|
|
|
|
// Create a virtual microphone (a null-audio-sink) that Electron can listen to
|
|
static async createVirtualMic() {
|
|
try {
|
|
await execAsync(`pw-cli destroy ${VIRT_MIC_NAME}`).catch(() => {}); // Cleanup old
|
|
|
|
const cmd = `pw-cli create-node adapter \'{ factory.name=support.null-audio-sink node.name=${VIRT_MIC_NAME} node.description=\"SimpleScreenshare Audio\" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] audio.rate=48000 audio.channels=2 }\'`;
|
|
const { stdout } = await execAsync(cmd);
|
|
console.log("Created virtual mic:", stdout.trim());
|
|
|
|
// Wait a moment for Pipewire to register it
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Failed to create virtual mic:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Destroy the virtual microphone
|
|
static async destroyVirtualMic() {
|
|
try {
|
|
// Find node ID
|
|
const { stdout } = await execAsync(`pw-cli dump short Node`);
|
|
const lines = stdout.split('\\n');
|
|
for (const line of lines) {
|
|
if (line.includes(VIRT_MIC_NAME)) {
|
|
const id = line.split(',')[0].trim();
|
|
await execAsync(`pw-cli destroy ${id}`);
|
|
console.log(`Destroyed virtual mic (ID: ${id})`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to destroy virtual mic:", error);
|
|
}
|
|
}
|
|
|
|
// Get all running applications currently playing audio
|
|
static async getAudioApplications() {
|
|
try {
|
|
// pw-dump outputs the entire graph as JSON, increase buffer!
|
|
const { stdout } = await execAsync('pw-dump', { maxBuffer: 1024 * 1024 * 50 });
|
|
const dump = JSON.parse(stdout);
|
|
|
|
// Filter out audio output streams (applications playing sound)
|
|
const apps = dump.filter(node =>
|
|
node.info &&
|
|
node.info.props &&
|
|
node.info.props['media.class'] === 'Stream/Output/Audio' &&
|
|
node.info.props['application.name']
|
|
).map(node => ({
|
|
id: node.id,
|
|
name: node.info.props['application.name'],
|
|
mediaName: node.info.props['media.name'] || 'Unknown Stream'
|
|
}));
|
|
|
|
// Deduplicate by application name and return
|
|
const uniqueApps = [];
|
|
const seen = new Set();
|
|
for (const app of apps) {
|
|
if (!seen.has(app.name)) {
|
|
seen.add(app.name);
|
|
uniqueApps.push(app);
|
|
}
|
|
}
|
|
return uniqueApps;
|
|
|
|
} catch (error) {
|
|
console.error("Failed to get audio applications:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Remove all existing links TO the virtual mic's input ports
|
|
// This prevents echo from stale connections when switching audio sources
|
|
static async unlinkAllFromMic() {
|
|
try {
|
|
// IMPORTANT: Use `pw-link -l` NOT `pw-link -l -I` — the -I flag hangs when piped
|
|
const { stdout } = await execAsync(`pw-link -l`, { maxBuffer: 1024 * 1024, timeout: 3000 }).catch(() => ({ stdout: '' }));
|
|
if (!stdout) return;
|
|
|
|
const lines = stdout.split('\n');
|
|
|
|
// pw-link -l format:
|
|
// alsa_output...:monitor_FL (source port - NOT indented)
|
|
// |-> simplescreenshare-audio:input_FL (outgoing link - indented with |->)
|
|
let currentSourcePort = null;
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
|
|
// Non-indented line = port declaration
|
|
if (!line.startsWith(' ')) {
|
|
currentSourcePort = line.trim();
|
|
continue;
|
|
}
|
|
|
|
// Indented line with |-> targeting our virtual mic
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('|->') && (trimmed.includes(`${VIRT_MIC_NAME}:input_`) || trimmed.includes('SimpleScreenshare Audio:input_'))) {
|
|
const targetPort = trimmed.replace('|->', '').trim();
|
|
if (currentSourcePort && targetPort) {
|
|
console.log(`Unlinking: "${currentSourcePort}" -> "${targetPort}"`);
|
|
await execAsync(`pw-link -d "${currentSourcePort}" "${targetPort}"`).catch(e =>
|
|
console.log("pw-link unlink:", e.message)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to unlink from mic:", error);
|
|
}
|
|
}
|
|
|
|
// Link a target application's output to our Virtual Microphone
|
|
static async linkApplicationToMic(targetAppName) {
|
|
try {
|
|
// 1. Get the dump with massive buffer
|
|
const { stdout: dumpOut } = await execAsync('pw-dump', { maxBuffer: 1024 * 1024 * 50 });
|
|
const dump = JSON.parse(dumpOut);
|
|
|
|
// 2. Find the target application node
|
|
const targetNode = dump.find(node =>
|
|
node.info &&
|
|
node.info.props &&
|
|
node.info.props['media.class'] === 'Stream/Output/Audio' &&
|
|
node.info.props['application.name'] === targetAppName
|
|
);
|
|
|
|
// 3. Find our virtual mic node
|
|
const micNode = dump.find(node =>
|
|
node.info &&
|
|
node.info.props &&
|
|
node.info.props['node.name'] === VIRT_MIC_NAME
|
|
);
|
|
|
|
if (!targetNode || !micNode) {
|
|
console.error("Could not find target node or virtual mic node");
|
|
return false;
|
|
}
|
|
|
|
console.log(`Linking ${targetAppName} (ID: ${targetNode.id}) to ${VIRT_MIC_NAME} (ID: ${micNode.id})`);
|
|
|
|
// Clean up any existing links to prevent echo from stale connections
|
|
await this.unlinkAllFromMic();
|
|
|
|
// 4. Find the Ports for both nodes
|
|
const ports = dump.filter(n => n.type === 'PipeWire:Interface:Port');
|
|
|
|
// Target App Output Ports
|
|
const targetPorts = ports.filter(p => p.info.props['node.id'] === targetNode.id && p.info.direction === 'output');
|
|
const targetFL = targetPorts.find(p => p.info.props['audio.channel'] === 'FL');
|
|
const targetFR = targetPorts.find(p => p.info.props['audio.channel'] === 'FR');
|
|
|
|
// Virtual Mic Input Ports
|
|
const micPorts = ports.filter(p => p.info.props['node.id'] === micNode.id && p.info.direction === 'input');
|
|
const micFL = micPorts.find(p => p.info.props['audio.channel'] === 'FL');
|
|
const micFR = micPorts.find(p => p.info.props['audio.channel'] === 'FR');
|
|
|
|
if (!targetFL || !targetFR || !micFL || !micFR) {
|
|
console.error("Could not find stereo ports for linking");
|
|
return false;
|
|
}
|
|
|
|
// 5. Link them using port aliases!
|
|
const targetFlAlias = targetFL.info.props['port.alias'] || targetFL.info.props['object.path'] || targetFL.id;
|
|
const targetFrAlias = targetFR.info.props['port.alias'] || targetFR.info.props['object.path'] || targetFR.id;
|
|
const micFlAlias = micFL.info.props['port.alias'] || micFL.info.props['object.path'] || micFL.id;
|
|
const micFrAlias = micFR.info.props['port.alias'] || micFR.info.props['object.path'] || micFR.id;
|
|
|
|
await execAsync(`pw-link "${targetFlAlias}" "${micFlAlias}"`).catch(e => console.log("pw-link output:", e.message));
|
|
await execAsync(`pw-link "${targetFrAlias}" "${micFrAlias}"`).catch(e => console.log("pw-link output:", e.message));
|
|
|
|
console.log("Successfully linked audio.");
|
|
return true;
|
|
|
|
} catch (error) {
|
|
console.error("Failed to link application:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Link the system's default audio output monitor to our Virtual Microphone
|
|
// This captures ALL desktop audio cleanly via Pipewire without Chromium's broken desktop audio capture
|
|
static async linkMonitorToMic() {
|
|
try {
|
|
const { stdout: dumpOut } = await execAsync('pw-dump', { maxBuffer: 1024 * 1024 * 50 });
|
|
const dump = JSON.parse(dumpOut);
|
|
|
|
// Find the default audio sink (the system's main output)
|
|
const sinkNode = dump.find(node =>
|
|
node.info &&
|
|
node.info.props &&
|
|
node.info.props['media.class'] === 'Audio/Sink' &&
|
|
(node.info.props['node.name'] || '').includes('output')
|
|
);
|
|
|
|
// Find our virtual mic node
|
|
const micNode = dump.find(node =>
|
|
node.info &&
|
|
node.info.props &&
|
|
node.info.props['node.name'] === VIRT_MIC_NAME
|
|
);
|
|
|
|
if (!sinkNode || !micNode) {
|
|
console.error("Could not find default sink or virtual mic node");
|
|
return false;
|
|
}
|
|
|
|
console.log(`Linking system monitor (ID: ${sinkNode.id}) to ${VIRT_MIC_NAME} (ID: ${micNode.id})`);
|
|
|
|
// Clean up any existing links to prevent echo from stale connections
|
|
await this.unlinkAllFromMic();
|
|
|
|
const ports = dump.filter(n => n.type === 'PipeWire:Interface:Port');
|
|
|
|
// The monitor ports on a sink are "output" direction (they output what the sink is playing)
|
|
const sinkMonitorPorts = ports.filter(p =>
|
|
p.info.props['node.id'] === sinkNode.id && p.info.direction === 'output'
|
|
);
|
|
const sinkFL = sinkMonitorPorts.find(p => p.info.props['audio.channel'] === 'FL');
|
|
const sinkFR = sinkMonitorPorts.find(p => p.info.props['audio.channel'] === 'FR');
|
|
|
|
const micPorts = ports.filter(p => p.info.props['node.id'] === micNode.id && p.info.direction === 'input');
|
|
const micFL = micPorts.find(p => p.info.props['audio.channel'] === 'FL');
|
|
const micFR = micPorts.find(p => p.info.props['audio.channel'] === 'FR');
|
|
|
|
if (!sinkFL || !sinkFR || !micFL || !micFR) {
|
|
console.error("Could not find stereo monitor/mic ports for linking");
|
|
return false;
|
|
}
|
|
|
|
const sinkFlAlias = sinkFL.info.props['port.alias'] || sinkFL.info.props['object.path'] || sinkFL.id;
|
|
const sinkFrAlias = sinkFR.info.props['port.alias'] || sinkFR.info.props['object.path'] || sinkFR.id;
|
|
const micFlAlias = micFL.info.props['port.alias'] || micFL.info.props['object.path'] || micFL.id;
|
|
const micFrAlias = micFR.info.props['port.alias'] || micFR.info.props['object.path'] || micFR.id;
|
|
|
|
await execAsync(`pw-link "${sinkFlAlias}" "${micFlAlias}"`).catch(e => console.log("pw-link output:", e.message));
|
|
await execAsync(`pw-link "${sinkFrAlias}" "${micFrAlias}"`).catch(e => console.log("pw-link output:", e.message));
|
|
|
|
console.log("Successfully linked system monitor audio.");
|
|
return true;
|
|
|
|
} catch (error) {
|
|
console.error("Failed to link monitor:", error);
|
|
return false;
|
|
}
|
|
}
|
|
// Monitor PipeWire graph for changes (new/removed audio streams)
|
|
// Calls `onChange` whenever a relevant change is detected (debounced)
|
|
static _monitorProcess = null;
|
|
static _debounceTimer = null;
|
|
|
|
static startMonitoring(onChange) {
|
|
if (this._monitorProcess) return; // already monitoring
|
|
|
|
const { spawn } = require('child_process');
|
|
// pw-mon outputs a line for every PipeWire graph event (node added, removed, etc.)
|
|
const proc = spawn('pw-mon', ['--color=never'], {
|
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
});
|
|
|
|
this._monitorProcess = proc;
|
|
|
|
proc.stdout.on('data', (data) => {
|
|
const text = data.toString();
|
|
// Only react to node add/remove events that are likely audio-related
|
|
if (text.includes('added') || text.includes('removed')) {
|
|
// Debounce: multiple events fire in quick succession when an app starts/stops
|
|
clearTimeout(this._debounceTimer);
|
|
this._debounceTimer = setTimeout(() => {
|
|
onChange();
|
|
}, 500);
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
console.error('pw-mon failed to start:', err.message);
|
|
this._monitorProcess = null;
|
|
});
|
|
|
|
proc.on('exit', () => {
|
|
this._monitorProcess = null;
|
|
});
|
|
|
|
console.log('Started PipeWire graph monitoring (pw-mon)');
|
|
}
|
|
|
|
static stopMonitoring() {
|
|
if (this._monitorProcess) {
|
|
this._monitorProcess.kill();
|
|
this._monitorProcess = null;
|
|
}
|
|
clearTimeout(this._debounceTimer);
|
|
this._debounceTimer = null;
|
|
console.log('Stopped PipeWire graph monitoring');
|
|
}
|
|
}
|
|
|
|
module.exports = PipewireHelper;
|