mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
fix(voice): reuse the Linux audio capture across routing changes (#2440)
This commit is contained in:
@@ -556,6 +556,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>;
|
||||
onFrame: (callback: (message: NativeAudioFrameMessage) => void) => () => void;
|
||||
onEnd: (callback: (message: NativeAudioEndMessage) => void) => () => void;
|
||||
|
||||
+9
@@ -75,6 +75,7 @@ import {
|
||||
captureNativeAudioTrackForLinuxRouting,
|
||||
commitNativeAudioBridgeReplacement,
|
||||
disarmNativeAudio,
|
||||
reconfigureLinuxNativeAudioRouting,
|
||||
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {ScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
@@ -752,6 +753,14 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
const participant = room?.localParticipant;
|
||||
if (!participant || !participant.isScreenShareEnabled) return false;
|
||||
if (!linuxRule) return false;
|
||||
if (options.replaceExisting !== true) {
|
||||
const reconfigured = await reconfigureLinuxNativeAudioRouting(linuxRule, options);
|
||||
if (reconfigured !== 'unsupported') {
|
||||
this.syncLocalScreenShareAudioStateInternal(participant, true);
|
||||
this.syncPersistedScreenShareAudioPreferenceInternal(participant);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const capturedTrack = await captureNativeAudioTrackForLinuxRouting(linuxRule, options);
|
||||
if (!capturedTrack) return false;
|
||||
let adopted = false;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isValidAudioFrameMessage,
|
||||
type NativeAudioBridgeHandle,
|
||||
type NativeAudioBridgeStats,
|
||||
nativeAudioRoutingRulesEqual,
|
||||
patchTrackStopForCleanup,
|
||||
replaceStreamAudioTrack,
|
||||
shouldMixSelfWindowAudioIntoSystemCapture,
|
||||
@@ -1363,6 +1364,35 @@ export function captureNativeAudioTrackForSelfWindow(): MediaStreamTrack | null
|
||||
return track;
|
||||
}
|
||||
|
||||
export type LinuxNativeAudioReconfigureResult = 'unchanged' | 'updated' | 'unsupported';
|
||||
|
||||
export async function reconfigureLinuxNativeAudioRouting(
|
||||
linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>,
|
||||
options: {includeSelfWindowAudio?: boolean} = {},
|
||||
): Promise<LinuxNativeAudioReconfigureResult> {
|
||||
const bridge = activeBridge;
|
||||
if (!bridge) return 'unsupported';
|
||||
if (bridge.linuxRule === undefined) return 'unsupported';
|
||||
if (Boolean(bridge.includeSelfWindowAudio) !== (options.includeSelfWindowAudio === true)) return 'unsupported';
|
||||
if (nativeAudioRoutingRulesEqual(bridge.linuxRule, linuxRule)) return 'unchanged';
|
||||
const nativeAudioApi = getNativeAudioApi();
|
||||
if (typeof nativeAudioApi?.setRule !== 'function') return 'unsupported';
|
||||
let applied = false;
|
||||
try {
|
||||
applied = await nativeAudioApi.setRule(bridge.captureId, linuxRule);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to reconfigure Linux native audio routing in place', {
|
||||
captureId: bridge.captureId,
|
||||
error,
|
||||
});
|
||||
return 'unsupported';
|
||||
}
|
||||
if (!applied) return 'unsupported';
|
||||
if (activeBridge !== bridge) return 'unsupported';
|
||||
activeBridge = {...bridge, linuxRule};
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export async function captureNativeAudioTrackForLinuxRouting(
|
||||
linuxRule: NonNullable<NativeAudioStartOptions['linuxRule']>,
|
||||
options: {includeSelfWindowAudio?: boolean} = {},
|
||||
@@ -1376,7 +1406,12 @@ export async function captureNativeAudioTrackForLinuxRouting(
|
||||
? await createNativeAudioBridgeWithSelfWindowAudio(result.captureId)
|
||||
: await createNativeAudioBridge(result.captureId);
|
||||
const cleanup = attachNativeAudioCleanup(new MediaStream([handle.track]), result.captureId, handle);
|
||||
activeBridge = {captureId: result.captureId, cleanup};
|
||||
activeBridge = {
|
||||
captureId: result.captureId,
|
||||
cleanup,
|
||||
linuxRule,
|
||||
includeSelfWindowAudio: options.includeSelfWindowAudio === true,
|
||||
};
|
||||
if (previousBridge && previousBridge.captureId !== result.captureId) {
|
||||
supersededBridge = previousBridge;
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type NativeAudioRoutingRule,
|
||||
nativeAudioRoutingRulesEqual,
|
||||
} from '@app/features/voice/utils/native_audio_capture_bridge/shared';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const base: NativeAudioRoutingRule = {
|
||||
include: [{'application.name': 'Firefox'}],
|
||||
exclude: [{'node.name': 'fluxer'}],
|
||||
ignoreDevices: true,
|
||||
};
|
||||
|
||||
describe('nativeAudioRoutingRulesEqual', () => {
|
||||
it('treats a rule as equal to itself and to a structural copy', () => {
|
||||
expect(nativeAudioRoutingRulesEqual(base, base)).toBe(true);
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, include: [{'application.name': 'Firefox'}]})).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the order of include and exclude patterns', () => {
|
||||
const a: NativeAudioRoutingRule = {include: [{'application.name': 'A'}, {'application.name': 'B'}]};
|
||||
const b: NativeAudioRoutingRule = {include: [{'application.name': 'B'}, {'application.name': 'A'}]};
|
||||
expect(nativeAudioRoutingRulesEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('separates a missing list from an empty one only when the contents differ', () => {
|
||||
expect(nativeAudioRoutingRulesEqual({include: []}, {})).toBe(true);
|
||||
expect(nativeAudioRoutingRulesEqual({include: [{'application.name': 'A'}]}, {})).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a changed selection, which is the case that must restart routing', () => {
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, include: [{'application.name': 'Chromium'}]})).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, include: []})).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, exclude: []})).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a changed tuning flag', () => {
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, ignoreDevices: false})).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, onlySpeakers: true})).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, onlyDefaultSpeakers: true})).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(base, {...base, ignoreInputMedia: true})).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an undefined flag as false rather than as a difference', () => {
|
||||
expect(nativeAudioRoutingRulesEqual({include: []}, {include: [], onlySpeakers: false})).toBe(true);
|
||||
});
|
||||
|
||||
it('never reports equality when a rule is missing', () => {
|
||||
expect(nativeAudioRoutingRulesEqual(undefined, base)).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(base, undefined)).toBe(false);
|
||||
expect(nativeAudioRoutingRulesEqual(undefined, undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,43 @@ export interface NativeAudioBridgeHandle {
|
||||
export interface ActiveNativeAudioBridge {
|
||||
captureId: string;
|
||||
cleanup: (stopRemote?: boolean) => Promise<void>;
|
||||
linuxRule?: NativeAudioRoutingRule;
|
||||
includeSelfWindowAudio?: boolean;
|
||||
}
|
||||
|
||||
export type NativeAudioRoutingRule = NonNullable<NativeAudioStartOptions['linuxRule']>;
|
||||
|
||||
function routingPatternsEqual(a: NativeAudioRoutingRule['include'], b: NativeAudioRoutingRule['include']): boolean {
|
||||
const left = a ?? [];
|
||||
const right = b ?? [];
|
||||
if (left.length !== right.length) return false;
|
||||
const serialize = (entry: Record<string, unknown>): string =>
|
||||
JSON.stringify(
|
||||
Object.entries(entry)
|
||||
.filter(([, value]) => typeof value === 'string')
|
||||
.sort(([x], [y]) => x.localeCompare(y)),
|
||||
);
|
||||
const serializedLeft = left.map(serialize).sort();
|
||||
const serializedRight = right.map(serialize).sort();
|
||||
return serializedLeft.every((entry, index) => entry === serializedRight[index]);
|
||||
}
|
||||
|
||||
export function nativeAudioRoutingRulesEqual(
|
||||
a: NativeAudioRoutingRule | undefined,
|
||||
b: NativeAudioRoutingRule | undefined,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (!routingPatternsEqual(a.include, b.include)) return false;
|
||||
if (!routingPatternsEqual(a.exclude, b.exclude)) return false;
|
||||
return (
|
||||
Boolean(a.ignoreDevices) === Boolean(b.ignoreDevices) &&
|
||||
Boolean(a.ignoreInputMedia) === Boolean(b.ignoreInputMedia) &&
|
||||
Boolean(a.ignoreVirtual) === Boolean(b.ignoreVirtual) &&
|
||||
Boolean(a.workaround) === Boolean(b.workaround) &&
|
||||
Boolean(a.onlySpeakers) === Boolean(b.onlySpeakers) &&
|
||||
Boolean(a.onlyDefaultSpeakers) === Boolean(b.onlyDefaultSpeakers)
|
||||
);
|
||||
}
|
||||
|
||||
export interface GeneratorAudioTrack extends MediaStreamTrack {
|
||||
|
||||
Vendored
+1
@@ -653,6 +653,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user