fix(voice): reuse the Linux audio capture across routing changes (#2440)

This commit is contained in:
Hampus
2026-09-04 01:00:32 +02:00
committed by GitHub
parent cc3a9c8613
commit 587324fa38
11 changed files with 206 additions and 1 deletions
+2
View File
@@ -120,6 +120,8 @@ export declare class ProcessLoopback extends EventEmitter implements ProcessLoop
start(): void;
setRoutingRule(target: {linuxRule: JsRoutingRule} | number, options?: {includeProcessTree?: boolean}): boolean;
routingGraph(): RoutingGraph | null;
stop(): Promise<void>;
@@ -235,6 +235,19 @@ function patternsEqual(a, b) {
return true;
}
function routingRulesEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
if (!patternsEqual(a.include, b.include)) return false;
if (!patternsEqual(a.exclude, b.exclude)) return false;
if (!patternsEqual(a.workaround, b.workaround)) return false;
return (
Boolean(a.ignoreDevices) === Boolean(b.ignoreDevices) &&
Boolean(a.onlySpeakers) === Boolean(b.onlySpeakers) &&
Boolean(a.onlyDefaultSpeakers) === Boolean(b.onlyDefaultSpeakers)
);
}
class ProcessLoopback extends EventEmitter {
constructor(target, options = {}) {
super();
@@ -291,6 +304,28 @@ class ProcessLoopback extends EventEmitter {
}
}
setRoutingRule(target, options = {}) {
if (this.closed || !this.started) return false;
if (typeof this.capture?.setRule !== 'function') return false;
let nextRule;
try {
nextRule = routingRuleFromTarget(target, options);
} catch {
return false;
}
if (routingRulesEqual(nextRule, this.rule)) return true;
let applied = false;
try {
applied = this.capture.setRule(nextRule) !== false;
} catch {
return false;
}
if (!applied) return false;
this.rule = nextRule;
this.options = options;
return true;
}
refreshRuleForLateChildren() {
if (this.closed || !this.started || this.targetPid === null) return;
let nextRule;
+1
View File
@@ -430,6 +430,7 @@ export interface NativeAudioApi {
listAudibleApplications: () => Promise<Array<NativeAudioApplication>>;
resolveAudioRootPidForSource: (sourceId: string) => Promise<number | null>;
start: (options: NativeAudioStartOptions) => Promise<NativeAudioStartResult>;
setRule: (captureId: string, linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>) => Promise<boolean>;
stop: (captureId: string) => Promise<void>;
getRoutingGraph: (captureId?: string) => Promise<NativeAudioRoutingGraphResult>;
onFrame: (callback: (message: NativeAudioFrameMessage) => void) => () => void;
+28
View File
@@ -40,6 +40,7 @@ interface NativeCaptureInstance {
removeListener(event: 'closed', listener: () => void): this;
start(): Promise<void> | void;
stop(): Promise<void> | void;
setRoutingRule?: (target: {linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>}) => boolean;
routingGraph?: () => VirtmicRoutingGraph | null;
}
@@ -631,6 +632,23 @@ async function stopCaptureById(
await stopActiveSession(session, reason, detail);
}
function reconfigureCaptureById(
senderId: number,
captureId: string,
linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>,
): boolean {
const session = activeSessions.get(captureId);
if (!session || session.sender.id !== senderId) return false;
if (session.finalized || session.stopping) return false;
if (typeof session.capture.setRoutingRule !== 'function') return false;
try {
return session.capture.setRoutingRule({linuxRule});
} catch (error) {
logger.warn('Failed to reconfigure native audio routing in place', {captureId, error});
return false;
}
}
async function makeRoomForSenderSession(senderId: number): Promise<void> {
const senderSessions = activeSessionIdsBySenderId.get(senderId);
if (!senderSessions) return;
@@ -885,6 +903,15 @@ export function registerNativeAudioHandlers(): void {
(event, options: NativeAudioStartOptions): Promise<NativeAudioStartResult> =>
startNativeAudioCapture(event.sender, options),
);
ipcMain.handle(
'native-audio:set-rule',
(event, captureId: unknown, linuxRule: NativeAudioStartOptions['linuxRule']): boolean => {
if (typeof captureId !== 'string' || !isValidLinuxRule(linuxRule)) {
return false;
}
return reconfigureCaptureById(event.sender.id, captureId, linuxRule);
},
);
ipcMain.handle('native-audio:stop', async (event, captureId: string): Promise<void> => {
const session = activeSessions.get(captureId);
if (!session || session.sender.id !== event.sender.id) {
@@ -904,6 +931,7 @@ export function cleanupNativeAudio(): void {
ipcMain.removeHandler('native-audio:list-applications');
ipcMain.removeHandler('native-audio:resolve-root-pid');
ipcMain.removeHandler('native-audio:start');
ipcMain.removeHandler('native-audio:set-rule');
ipcMain.removeHandler('native-audio:stop');
ipcMain.removeHandler('native-audio:get-routing-graph');
handlersRegistered = false;
+2
View File
@@ -684,6 +684,8 @@ const api: ElectronAPI = {
ipcRenderer.invoke('native-audio:resolve-root-pid', sourceId),
start: (options: NativeAudioStartOptions): Promise<NativeAudioStartResult> =>
ipcRenderer.invoke('native-audio:start', options),
setRule: (captureId: string, linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>): Promise<boolean> =>
ipcRenderer.invoke('native-audio:set-rule', captureId, linuxRule),
stop: (captureId: string): Promise<void> => ipcRenderer.invoke('native-audio:stop', captureId),
getRoutingGraph: (captureId?: string): Promise<NativeAudioRoutingGraphResult> =>
ipcRenderer.invoke('native-audio:get-routing-graph', captureId),