Files
Toju/electron/audio/linux-screen-share-routing.ts
Myx 7a4c4ede8c Screensharing rework
Split Linux screensharing audio tracks, Rework screensharing functionality and layout
This will need some refactoring soon
2026-03-08 06:33:27 +01:00

754 lines
19 KiB
TypeScript

import {
ChildProcess,
execFile,
spawn
} from 'child_process';
import { randomUUID } from 'crypto';
import { WebContents } from 'electron';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const SCREEN_SHARE_SINK_NAME = 'metoyou_screenshare_sink';
const SCREEN_SHARE_MONITOR_SOURCE_NAME = `${SCREEN_SHARE_SINK_NAME}.monitor`;
const VOICE_SINK_NAME = 'metoyou_voice_sink';
const REROUTE_INTERVAL_MS = 750;
const MONITOR_CAPTURE_SAMPLE_RATE = 48_000;
const MONITOR_CAPTURE_CHANNEL_COUNT = 2;
const MONITOR_CAPTURE_BITS_PER_SAMPLE = 16;
const MONITOR_CAPTURE_STOP_TIMEOUT_MS = 1_000;
const MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
const MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
interface ShortSinkEntry {
index: string;
name: string;
}
interface ShortSinkInputEntry {
index: string;
sinkIndex: string;
}
interface SinkInputDetails extends ShortSinkInputEntry {
properties: Record<string, string>;
}
interface PactlJsonSinkInputEntry {
index?: number | string;
properties?: Record<string, unknown>;
sink?: number | string;
}
interface LinuxScreenShareAudioRoutingState {
active: boolean;
restoreSinkName: string | null;
screenShareLoopbackModuleId: string | null;
voiceLoopbackModuleId: string | null;
rerouteIntervalId: ReturnType<typeof setInterval> | null;
}
interface LinuxScreenShareMonitorCaptureState {
captureId: string | null;
process: ChildProcess | null;
stderr: string;
stopRequested: boolean;
targetWebContents: WebContents | null;
}
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
export interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
const routingState: LinuxScreenShareAudioRoutingState = {
active: false,
restoreSinkName: null,
screenShareLoopbackModuleId: null,
voiceLoopbackModuleId: null,
rerouteIntervalId: null
};
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
captureId: null,
process: null,
stderr: '',
stopRequested: false,
targetWebContents: null
};
let pactlAvailableCache: boolean | null = null;
export async function prepareLinuxScreenShareAudioRouting(): Promise<LinuxScreenShareAudioRoutingInfo> {
if (process.platform !== 'linux') {
return buildRoutingInfo(false, false, 'Linux-only audio routing is unavailable on this platform.');
}
if (!await isPactlAvailable()) {
return buildRoutingInfo(false, false, 'pactl is unavailable; falling back to standard desktop audio capture.');
}
await ensureNullSink(SCREEN_SHARE_SINK_NAME);
await ensureNullSink(VOICE_SINK_NAME);
return buildRoutingInfo(true, routingState.active);
}
export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScreenShareAudioRoutingInfo> {
const prepared = await prepareLinuxScreenShareAudioRouting();
if (!prepared.available) {
return prepared;
}
if (routingState.active) {
return buildRoutingInfo(true, true);
}
const restoreSinkName = await getPreferredRestoreSinkName();
if (!restoreSinkName) {
return buildRoutingInfo(false, false, 'Unable to determine a playback sink for Linux screen-share audio routing.');
}
try {
routingState.restoreSinkName = restoreSinkName;
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
routingState.active = true;
await rerouteAppSinkInputsToVoiceSink();
startSinkInputRerouteLoop();
return buildRoutingInfo(true, true);
} catch (error) {
await deactivateLinuxScreenShareAudioRouting();
return buildRoutingInfo(
false,
false,
error instanceof Error ? error.message : 'Failed to activate Linux screen-share audio routing.'
);
}
}
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
const restoreSinkName = routingState.restoreSinkName;
stopSinkInputRerouteLoop();
await stopLinuxScreenShareMonitorCapture();
try {
if (restoreSinkName) {
await setDefaultSink(restoreSinkName);
await moveSinkInputs(restoreSinkName, (sinkName) => sinkName === SCREEN_SHARE_SINK_NAME || sinkName === VOICE_SINK_NAME);
}
} catch {
// Best-effort cleanup only.
}
await Promise.all([unloadModuleIfLoaded(routingState.screenShareLoopbackModuleId), unloadModuleIfLoaded(routingState.voiceLoopbackModuleId)]);
routingState.active = false;
routingState.restoreSinkName = null;
routingState.screenShareLoopbackModuleId = null;
routingState.voiceLoopbackModuleId = null;
return true;
}
export async function cleanupLinuxScreenShareAudioRouting(): Promise<void> {
await deactivateLinuxScreenShareAudioRouting();
}
export async function startLinuxScreenShareMonitorCapture(
targetWebContents: WebContents
): Promise<LinuxScreenShareMonitorCaptureInfo> {
if (process.platform !== 'linux') {
throw new Error('Linux screen-share monitor capture is unavailable on this platform.');
}
if (!routingState.active) {
throw new Error('Linux screen-share audio routing must be active before monitor capture starts.');
}
await stopLinuxScreenShareMonitorCapture();
const captureId = randomUUID();
const captureProcess = spawn('parec', [
'--device',
SCREEN_SHARE_MONITOR_SOURCE_NAME,
'--raw',
'--format=s16le',
'--rate',
`${MONITOR_CAPTURE_SAMPLE_RATE}`,
'--channels',
`${MONITOR_CAPTURE_CHANNEL_COUNT}`
], {
env: process.env,
stdio: [
'ignore',
'pipe',
'pipe'
]
});
monitorCaptureState.captureId = captureId;
monitorCaptureState.process = captureProcess;
monitorCaptureState.stderr = '';
monitorCaptureState.stopRequested = false;
monitorCaptureState.targetWebContents = targetWebContents;
let started = false;
const startPromise = new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
if (!started) {
cleanupMonitorCaptureState(captureId, error.message);
reject(error);
return;
}
cleanupMonitorCaptureState(captureId, error.message);
};
captureProcess.on('error', onError);
captureProcess.once('spawn', () => {
started = true;
resolve();
});
});
captureProcess.stdout.on('data', (chunk: Buffer) => {
if (monitorCaptureState.captureId !== captureId) {
return;
}
const target = monitorCaptureState.targetWebContents;
if (!target || target.isDestroyed()) {
return;
}
target.send(MONITOR_AUDIO_CHUNK_CHANNEL, {
captureId,
chunk: Uint8Array.from(chunk)
});
});
captureProcess.stderr.on('data', (chunk: Buffer) => {
if (monitorCaptureState.captureId !== captureId) {
return;
}
const nextStderr = `${monitorCaptureState.stderr}${chunk.toString()}`;
monitorCaptureState.stderr = nextStderr.slice(-4_096);
});
captureProcess.once('close', (code, signal) => {
const reason = buildMonitorCaptureCloseReason(captureId, code, signal);
cleanupMonitorCaptureState(captureId, reason);
});
await startPromise;
return {
bitsPerSample: MONITOR_CAPTURE_BITS_PER_SAMPLE,
captureId,
channelCount: MONITOR_CAPTURE_CHANNEL_COUNT,
sampleRate: MONITOR_CAPTURE_SAMPLE_RATE,
sourceName: SCREEN_SHARE_MONITOR_SOURCE_NAME
};
}
export async function stopLinuxScreenShareMonitorCapture(captureId?: string): Promise<boolean> {
if (!monitorCaptureState.captureId || !monitorCaptureState.process) {
return true;
}
if (captureId && captureId !== monitorCaptureState.captureId) {
return false;
}
const currentCaptureId = monitorCaptureState.captureId;
const captureProcess = monitorCaptureState.process;
monitorCaptureState.stopRequested = true;
await new Promise<void>((resolve) => {
const forceKillTimeout = setTimeout(() => {
if (!captureProcess.killed) {
captureProcess.kill('SIGKILL');
}
}, MONITOR_CAPTURE_STOP_TIMEOUT_MS);
captureProcess.once('close', () => {
clearTimeout(forceKillTimeout);
resolve();
});
if (!captureProcess.killed) {
captureProcess.kill('SIGTERM');
return;
}
clearTimeout(forceKillTimeout);
resolve();
});
return monitorCaptureState.captureId !== currentCaptureId;
}
function buildRoutingInfo(
available: boolean,
active: boolean,
reason?: string
): LinuxScreenShareAudioRoutingInfo {
return {
available,
active,
monitorCaptureSupported: true,
screenShareSinkName: SCREEN_SHARE_SINK_NAME,
screenShareMonitorSourceName: SCREEN_SHARE_MONITOR_SOURCE_NAME,
voiceSinkName: VOICE_SINK_NAME,
...(reason ? { reason } : {})
};
}
async function isPactlAvailable(): Promise<boolean> {
if (pactlAvailableCache !== null) {
return pactlAvailableCache;
}
try {
await runPactl('info');
pactlAvailableCache = true;
} catch {
pactlAvailableCache = false;
}
return pactlAvailableCache;
}
async function runPactl(...args: string[]): Promise<string> {
const { stdout } = await execFileAsync('pactl', args, {
env: process.env
});
return stdout.trim();
}
async function ensureNullSink(sinkName: string): Promise<void> {
const sinks = await listSinks();
if (sinks.some((sink) => sink.name === sinkName)) {
return;
}
await runPactl(
'load-module',
'module-null-sink',
`sink_name=${sinkName}`,
`sink_properties=device.description=${sinkName}`
);
}
async function loadLoopbackModule(sourceName: string, sinkName: string): Promise<string> {
const moduleId = await runPactl(
'load-module',
'module-loopback',
`source=${sourceName}`,
`sink=${sinkName}`,
'latency_msec=10',
'source_dont_move=true',
'sink_dont_move=true'
);
return moduleId.split(/\s+/)[0] || moduleId;
}
async function unloadModuleIfLoaded(moduleId: string | null): Promise<void> {
if (!moduleId) {
return;
}
try {
await runPactl('unload-module', moduleId);
} catch {
// Module may have already been unloaded externally.
}
}
async function getPreferredRestoreSinkName(): Promise<string | null> {
const defaultSinkName = await getDefaultSinkName();
if (defaultSinkName && defaultSinkName !== SCREEN_SHARE_SINK_NAME && defaultSinkName !== VOICE_SINK_NAME) {
return defaultSinkName;
}
const sinks = await listSinks();
return sinks.find((sink) => sink.name !== SCREEN_SHARE_SINK_NAME && sink.name !== VOICE_SINK_NAME)?.name ?? null;
}
async function getDefaultSinkName(): Promise<string | null> {
const info = await runPactl('info');
const defaultSinkLine = info
.split(/\r?\n/)
.find((line) => line.startsWith('Default Sink:'));
if (!defaultSinkLine) {
return null;
}
const sinkName = defaultSinkLine.replace('Default Sink:', '').trim();
return sinkName || null;
}
async function setDefaultSink(sinkName: string): Promise<void> {
await runPactl('set-default-sink', sinkName);
}
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
const [
sinks,
sinkInputs,
descendantProcessIds
] = await Promise.all([
listSinks(),
listSinkInputDetails(),
collectDescendantProcessIds(process.pid)
]);
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
await Promise.all(
sinkInputs.map(async (sinkInput) => {
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
return;
}
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
if (sinkName === VOICE_SINK_NAME) {
return;
}
try {
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
} catch {
// Streams can disappear or be recreated while rerouting.
}
})
);
}
function cleanupMonitorCaptureState(captureId: string, reason?: string): void {
if (monitorCaptureState.captureId !== captureId) {
return;
}
const target = monitorCaptureState.targetWebContents;
monitorCaptureState.captureId = null;
monitorCaptureState.process = null;
monitorCaptureState.stderr = '';
monitorCaptureState.stopRequested = false;
monitorCaptureState.targetWebContents = null;
if (!target || target.isDestroyed()) {
return;
}
target.send(MONITOR_AUDIO_ENDED_CHANNEL, {
captureId,
...(reason ? { reason } : {})
});
}
function buildMonitorCaptureCloseReason(
captureId: string,
code: number | null,
signal: NodeJS.Signals | null
): string | undefined {
if (monitorCaptureState.captureId !== captureId) {
return undefined;
}
if (monitorCaptureState.stopRequested) {
return undefined;
}
if (monitorCaptureState.stderr.trim()) {
return monitorCaptureState.stderr.trim();
}
if (signal) {
return `Linux screen-share monitor capture stopped with signal ${signal}.`;
}
if (typeof code === 'number' && code !== 0) {
return `Linux screen-share monitor capture exited with code ${code}.`;
}
return undefined;
}
function startSinkInputRerouteLoop(): void {
if (routingState.rerouteIntervalId) {
return;
}
routingState.rerouteIntervalId = setInterval(() => {
void rerouteAppSinkInputsToVoiceSink();
}, REROUTE_INTERVAL_MS);
}
function stopSinkInputRerouteLoop(): void {
if (!routingState.rerouteIntervalId) {
return;
}
clearInterval(routingState.rerouteIntervalId);
routingState.rerouteIntervalId = null;
}
function isAppOwnedSinkInput(
sinkInput: SinkInputDetails,
descendantProcessIds: ReadonlySet<string>
): boolean {
const processId = sinkInput.properties['application.process.id'];
return typeof processId === 'string' && descendantProcessIds.has(processId);
}
async function moveSinkInputs(
targetSinkName: string,
shouldMove: (sinkName: string | null) => boolean
): Promise<void> {
const [sinks, sinkInputs] = await Promise.all([listSinks(), listSinkInputs()]);
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
await Promise.all(
sinkInputs.map(async (sinkInput) => {
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
if (!shouldMove(sinkName)) {
return;
}
try {
await runPactl('move-sink-input', sinkInput.index, targetSinkName);
} catch {
// Streams can disappear while iterating.
}
})
);
}
async function listSinks(): Promise<ShortSinkEntry[]> {
const output = await runPactl('list', 'short', 'sinks');
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split(/\s+/))
.filter((columns) => columns.length >= 2)
.map((columns) => ({
index: columns[0],
name: columns[1]
}));
}
async function listSinkInputs(): Promise<ShortSinkInputEntry[]> {
const output = await runPactl('list', 'short', 'sink-inputs');
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split(/\s+/))
.filter((columns) => columns.length >= 2)
.map((columns) => ({
index: columns[0],
sinkIndex: columns[1]
}));
}
async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
try {
const output = await runPactl('--format=json', 'list', 'sink-inputs');
const entries = JSON.parse(output) as PactlJsonSinkInputEntry[];
if (!Array.isArray(entries)) {
return [];
}
return entries
.map((entry) => {
const index = typeof entry.index === 'number' || typeof entry.index === 'string'
? `${entry.index}`
: '';
const sinkIndex = typeof entry.sink === 'number' || typeof entry.sink === 'string'
? `${entry.sink}`
: '';
const properties = Object.fromEntries(
Object.entries(entry.properties || {}).map(([key, value]) => [key, typeof value === 'string' ? value : `${value ?? ''}`])
);
return {
index,
sinkIndex,
properties
} satisfies SinkInputDetails;
})
.filter((entry) => !!entry.index && !!entry.sinkIndex);
} catch {
// Fall back to the legacy text format parser below.
}
const output = await runPactl('list', 'sink-inputs');
const entries: SinkInputDetails[] = [];
let currentEntry: SinkInputDetails | null = null;
let parsingProperties = false;
const pushCurrentEntry = (): void => {
if (currentEntry) {
entries.push(currentEntry);
}
};
for (const rawLine of output.split(/\r?\n/)) {
const sinkInputMatch = rawLine.match(/^Sink Input #(\d+)/);
if (sinkInputMatch) {
pushCurrentEntry();
currentEntry = {
index: sinkInputMatch[1],
sinkIndex: '',
properties: {}
};
parsingProperties = false;
continue;
}
if (!currentEntry) {
continue;
}
const sinkMatch = rawLine.match(/^\s*Sink:\s*(\d+)/);
if (sinkMatch) {
currentEntry.sinkIndex = sinkMatch[1];
continue;
}
if (/^\s*Properties:\s*$/.test(rawLine)) {
parsingProperties = true;
continue;
}
if (!parsingProperties) {
continue;
}
if (rawLine.trim().length === 0) {
parsingProperties = false;
continue;
}
const propertyLine = rawLine.trim();
const separatorIndex = propertyLine.indexOf(' = ');
if (separatorIndex === -1) {
if (/^\S/.test(rawLine) || /^\s+\S[^=]*:\s*$/.test(rawLine)) {
parsingProperties = false;
}
continue;
}
const key = propertyLine.slice(0, separatorIndex).trim();
const rawValue = propertyLine.slice(separatorIndex + 3).trim();
currentEntry.properties[key] = stripSurroundingQuotes(rawValue);
}
pushCurrentEntry();
return entries.filter((entry) => !!entry.sinkIndex);
}
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
env: process.env
});
const childrenByParentId = new Map<string, string[]>();
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const [pid, ppid] = line.split(/\s+/);
if (!pid || !ppid) {
return;
}
const siblings = childrenByParentId.get(ppid) ?? [];
siblings.push(pid);
childrenByParentId.set(ppid, siblings);
});
const rootId = `${rootProcessId}`;
const descendantIds = new Set<string>([rootId]);
const queue = [rootId];
while (queue.length > 0) {
const currentId = queue.shift();
if (!currentId) {
continue;
}
for (const childId of childrenByParentId.get(currentId) ?? []) {
if (descendantIds.has(childId)) {
continue;
}
descendantIds.add(childId);
queue.push(childId);
}
}
return descendantIds;
}
function stripSurroundingQuotes(value: string): string {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
return value.slice(1, -1);
}
return value;
}