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;