Files
mumh5/electron/pipewire.ts
kibiandClaude Opus 5.5 3b23f69a5f Screen sharing: start dialog, sound, video tiles and a meeting view
- Start dialog with source list, live preview, sound and quality choices
- Sound on Linux through PipeWire: one program or everything except this
  app, fed into a virtual microphone and kept linked as programs come and go;
  system loopback on Windows; stereo Opus
- A sharer's tile shows the picture; clicking it opens a large view with the
  other tiles as a strip. Own stream is previewed in the own tile
- Stream volume for viewers, watch and stop from the profile panel
- Unit tests for the PipeWire link planner; the proxy test no longer writes to
  the root channel other tests listen in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:19:29 +02:00

159 lines
7.6 KiB
TypeScript

// Sound for screen sharing on Linux. Chromium cannot capture what other programs play, so a
// virtual microphone is created in PipeWire and the chosen programs' output is linked into it;
// the renderer then records that microphone like any other. Works through PipeWire's command
// line tools (pw-cli, pw-dump, pw-link, pw-mon), no native module.
import { execFile, spawn, type ChildProcess } from 'node:child_process';
export const VIRTUAL_MIC = 'mumh5-share-audio';
export const VIRTUAL_MIC_LABEL = 'mumh5 screen share audio';
// What to capture: everything except this app's own sound, or one program
export type AudioTarget = { kind: 'all' } | { kind: 'app'; name: string };
interface PwObject { id: number; type: string; info?: { direction?: string; props?: Record<string, any> } }
function run(cmd: string, args: string[], maxBuffer = 64 * 1024 * 1024): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { maxBuffer, timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout));
});
}
const dump = async (): Promise<PwObject[]> => JSON.parse(await run('pw-dump', []));
// A playing program: its name and process come from the stream itself or from the client that owns it
function describe(node: PwObject, byId: Map<number, PwObject>): { name: string; pid: number | null } {
const p = node.info?.props ?? {};
const client = byId.get(p['client.id'])?.info?.props ?? {};
return {
name: String(p['application.name'] ?? client['application.name'] ?? p['node.name'] ?? ''),
pid: Number(p['application.process.id'] ?? client['application.process.id']) || null
};
}
const outputs = (objects: PwObject[]) =>
objects.filter(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['media.class'] === 'Stream/Output/Audio');
// Programs currently playing sound, without this app
export function audioApps(objects: PwObject[], ownPids: number[]): string[] {
const byId = new Map(objects.map(o => [o.id, o]));
const names = new Set<string>();
for (const node of outputs(objects)) {
const d = describe(node, byId);
if (d.name && !(d.pid && ownPids.includes(d.pid))) names.add(d.name);
}
return [...names].sort((a, b) => a.localeCompare(b));
}
// Which output ports to connect to which inputs of the virtual microphone, and which existing
// links to remove. This app's own playback is never linked: the people watching are in the same
// voice channel and would hear themselves back.
export function planLinks(objects: PwObject[], target: AudioTarget, ownPids: number[]): { add: [number, number][]; remove: number[] } {
const byId = new Map(objects.map(o => [o.id, o]));
const mic = objects.find(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC);
if (!mic) return { add: [], remove: [] };
const ports = objects.filter(o => o.type === 'PipeWire:Interface:Port');
const micIn = ports.filter(p => p.info?.props?.['node.id'] === mic.id && p.info?.direction === 'input');
const input = (channel: string) => micIn.find(p => p.info?.props?.['audio.channel'] === channel)?.id;
const left = input('FL'), right = input('FR');
if (left == null || right == null) return { add: [], remove: [] };
const wanted = new Set<string>();
for (const node of outputs(objects)) {
const d = describe(node, byId);
if (d.pid && ownPids.includes(d.pid)) continue;
if (target.kind === 'app' && d.name !== target.name) continue;
for (const port of ports) {
if (port.info?.props?.['node.id'] !== node.id || port.info?.direction !== 'output') continue;
const channel = port.info.props['audio.channel'];
// Mono programs go to both sides; surround beyond the front pair is left out
if (channel === 'FL' || channel === 'MONO') wanted.add(`${port.id}:${left}`);
if (channel === 'FR' || channel === 'MONO') wanted.add(`${port.id}:${right}`);
}
}
const remove: number[] = [];
for (const link of objects.filter(o => o.type === 'PipeWire:Interface:Link')) {
const info = link.info as any;
if (info?.['input-node-id'] !== mic.id) continue;
const key = `${info['output-port-id']}:${info['input-port-id']}`;
if (wanted.has(key)) wanted.delete(key);
else remove.push(link.id);
}
return { add: [...wanted].map(k => k.split(':').map(Number) as [number, number]), remove };
}
// Whether PipeWire and its tools are there
export async function pipewireAvailable(): Promise<boolean> {
if (process.platform !== 'linux') return false;
try { await dump(); await run('pw-link', ['--version']); await run('pw-cli', ['--version']); return true; } catch { return false; }
}
export class ShareAudio {
private target: AudioTarget | null = null;
private monitor: ChildProcess | null = null;
private debounce: ReturnType<typeof setTimeout> | undefined;
private busy: Promise<void> = Promise.resolve();
private ownPids: () => number[];
constructor(ownPids: () => number[]) { this.ownPids = ownPids; }
async apps(): Promise<string[]> {
return audioApps(await dump(), this.ownPids());
}
// Creates the microphone, links the target and keeps the links right while programs come and go
async start(target: AudioTarget): Promise<void> {
this.target = target;
if (!(await dump()).some(o => o.info?.props?.['node.name'] === VIRTUAL_MIC)) {
await run('pw-cli', ['create-node', 'adapter',
`{ factory.name=support.null-audio-sink node.name=${VIRTUAL_MIC} node.description="${VIRTUAL_MIC_LABEL}" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] audio.rate=48000 audio.channels=2 }`]);
// Give PipeWire a moment to publish the node and its ports
for (let i = 0; i < 20; i++) {
const objects = await dump();
const mic = objects.find(o => o.info?.props?.['node.name'] === VIRTUAL_MIC);
if (mic && objects.some(o => o.type === 'PipeWire:Interface:Port' && o.info?.props?.['node.id'] === mic.id)) break;
await new Promise(r => setTimeout(r, 100));
}
}
await this.relink();
if (!this.monitor) {
const proc = spawn('pw-mon', ['--color=never'], { stdio: ['ignore', 'pipe', 'ignore'] });
this.monitor = proc;
proc.stdout!.on('data', (data: Buffer) => {
const text = data.toString();
if (!text.includes('added') && !text.includes('removed')) return;
// Starting a program fires many events at once
clearTimeout(this.debounce);
this.debounce = setTimeout(() => this.relink().catch(() => {}), 400);
});
proc.on('error', () => { if (this.monitor === proc) this.monitor = null; });
proc.on('exit', () => { if (this.monitor === proc) this.monitor = null; });
}
}
// One at a time, so two graph changes cannot interleave their link commands
private relink(): Promise<void> {
const next = this.busy.then(async () => {
if (!this.target) return;
const { add, remove } = planLinks(await dump(), this.target, this.ownPids());
for (const id of remove) await run('pw-link', ['-d', String(id)]).catch(() => {});
// "File exists" when a link is already there is fine
for (const [out, input] of add) await run('pw-link', [String(out), String(input)]).catch(() => {});
});
this.busy = next.catch(() => {});
return next;
}
async stop(): Promise<void> {
this.target = null;
clearTimeout(this.debounce);
this.monitor?.kill();
this.monitor = null;
await this.busy;
try {
for (const o of await dump()) {
if (o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC) await run('pw-cli', ['destroy', String(o.id)]).catch(() => {});
}
} catch { /* PipeWire gone */ }
}
}