mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
953 lines
31 KiB
TypeScript
953 lines
31 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {randomUUID} from 'node:crypto';
|
|
import {createRequire} from 'node:module';
|
|
import {createChildLogger} from '@electron/common/Logger';
|
|
import type {
|
|
NativeAudioApplication,
|
|
NativeAudioAvailability,
|
|
NativeAudioEndReason,
|
|
NativeAudioRoutingGraphResult,
|
|
NativeAudioStartOptions,
|
|
NativeAudioStartResult,
|
|
VirtmicLinkOptions,
|
|
VirtmicNode,
|
|
VirtmicRoutingGraph,
|
|
} from '@electron/common/Types';
|
|
import {getNativeAudioMode} from '@electron/main/LaunchOptions';
|
|
import {ipcMain} from 'electron';
|
|
import {buildFluxerAudioExcludePatterns, isKnownFluxerAudioProcessPid} from './FluxerAudioIdentity';
|
|
import {resolveVirtmicWindowPid} from './LinuxAudioCapture';
|
|
import {parseWindowSourceToken as parseDesktopWindowSourceToken} from './LinuxAudioCaptureHelpers';
|
|
import {getTccStatus} from './MacTcc';
|
|
import {
|
|
audioFrameDebugDetails,
|
|
isValidAudioFrame,
|
|
isValidLinuxRule,
|
|
isValidTargetPid,
|
|
normalizeTimestampUs,
|
|
} from './NativeAudioValidation';
|
|
import {createScreenAudioSinkHandleForSender, hasActiveNativeEngineForSender} from './NativeVoiceEngine';
|
|
|
|
const logger = createChildLogger('NativeAudio');
|
|
const requireModule = createRequire(import.meta.url);
|
|
|
|
interface NativeCaptureInstance {
|
|
on(event: 'frame', listener: (frame: unknown) => void): this;
|
|
on(event: 'error', listener: (error: Error) => void): this;
|
|
on(event: 'closed', listener: () => void): this;
|
|
removeListener(event: 'frame', listener: (frame: unknown) => void): this;
|
|
removeListener(event: 'error', listener: (error: Error) => void): this;
|
|
removeListener(event: 'closed', listener: () => void): this;
|
|
start(): Promise<void> | void;
|
|
stop(): Promise<void> | void;
|
|
setScreenAudioSink?: (handle: unknown) => boolean;
|
|
clearScreenAudioSink?: () => void;
|
|
routingGraph?: () => VirtmicRoutingGraph | null;
|
|
}
|
|
|
|
interface WindowsNativeAudioModule {
|
|
isSupported: () => boolean;
|
|
getBackendInfo?: () => {
|
|
supported: boolean;
|
|
reason?: string;
|
|
processSupported?: boolean;
|
|
systemSupported?: boolean;
|
|
systemExcludesSelf?: boolean;
|
|
processIncludeSupported?: boolean;
|
|
processExcludeSupported?: boolean;
|
|
sessionMixerSupported?: boolean;
|
|
systemLoopbackMode?: string;
|
|
minWindowsBuild?: number;
|
|
minWindowsVersionLabel?: string;
|
|
detectedWindowsBuild?: number;
|
|
};
|
|
pidFromHwnd: (hwnd: bigint) => number;
|
|
resolveAudioRootPid: (pid: number) => number;
|
|
ProcessLoopback: new (
|
|
pid: number,
|
|
options?: {
|
|
includeProcessTree?: boolean;
|
|
captureScope?: 'process' | 'system' | 'session-mixer';
|
|
winCaptureScope?: 'process' | 'system' | 'session-mixer';
|
|
},
|
|
) => NativeCaptureInstance;
|
|
loadError?: Error | null;
|
|
}
|
|
|
|
interface MacApplicationDescriptor {
|
|
pid: number;
|
|
bundleId?: string;
|
|
name: string;
|
|
}
|
|
|
|
interface MacBackendAvailability {
|
|
sck?: {
|
|
supported: boolean;
|
|
macosVersion?: string;
|
|
};
|
|
coreaudio?: {
|
|
supported: boolean;
|
|
};
|
|
screenPermission?: string;
|
|
audioPermission?: string;
|
|
}
|
|
|
|
interface MacNativeAudioModule {
|
|
listAudibleApplications: () => Promise<Array<MacApplicationDescriptor>>;
|
|
getBackendAvailability: () => Promise<MacBackendAvailability>;
|
|
pidFromWindowId: (windowId: number) => number;
|
|
ProcessLoopback: new (
|
|
pid: number,
|
|
options?: {
|
|
excludeSelf?: boolean;
|
|
includeProcessTree?: boolean;
|
|
backend?: 'sck' | 'coreaudio' | 'auto';
|
|
captureScope?: 'process' | 'system';
|
|
macCaptureScope?: 'process' | 'system';
|
|
},
|
|
) => NativeCaptureInstance;
|
|
loadError?: Error | null;
|
|
}
|
|
|
|
interface LinuxRoutingRule extends VirtmicLinkOptions {
|
|
include?: Array<VirtmicNode>;
|
|
exclude?: Array<VirtmicNode>;
|
|
onlySpeakers?: boolean;
|
|
onlyDefaultSpeakers?: boolean;
|
|
}
|
|
|
|
interface LinuxNativeAudioModule {
|
|
pipeWireAvailable: () => boolean;
|
|
audioBackend?: () => 'pipewire' | 'none';
|
|
ProcessLoopback: new (
|
|
target:
|
|
| number
|
|
| {
|
|
linuxRule: LinuxRoutingRule;
|
|
},
|
|
options?: {
|
|
includeProcessTree?: boolean;
|
|
ignoreDevices?: boolean;
|
|
},
|
|
) => NativeCaptureInstance;
|
|
}
|
|
|
|
interface NativeAddonLoadResult {
|
|
addon?: LinuxNativeAudioModule | MacNativeAudioModule | WindowsNativeAudioModule;
|
|
availability: NativeAudioAvailability;
|
|
platform: NodeJS.Platform;
|
|
}
|
|
|
|
interface ActiveNativeAudioSession {
|
|
captureId: string;
|
|
capture: NativeCaptureInstance;
|
|
sender: Electron.WebContents;
|
|
onFrame: (frame: unknown) => void;
|
|
onError: (error: Error) => void;
|
|
onClosed: () => void;
|
|
onSenderDestroyed: () => void;
|
|
finalized: boolean;
|
|
stopping?: Promise<void>;
|
|
stoppingReason?: NativeAudioEndReason;
|
|
stoppingDetail?: string;
|
|
}
|
|
|
|
const MAX_NATIVE_AUDIO_SESSIONS_PER_SENDER = 2;
|
|
|
|
let cachedLoadResult: NativeAddonLoadResult | undefined;
|
|
let handlersRegistered = false;
|
|
|
|
const activeSessions = new Map<string, ActiveNativeAudioSession>();
|
|
const activeSessionIdsBySenderId = new Map<number, Set<string>>();
|
|
|
|
function isModuleNotFoundError(error: unknown): boolean {
|
|
const message = String(
|
|
(
|
|
error as {
|
|
message?: string;
|
|
}
|
|
)?.message ??
|
|
error ??
|
|
'',
|
|
).toLowerCase();
|
|
return message.includes('cannot find module') || message.includes('module_not_found');
|
|
}
|
|
|
|
type LinuxRoutingPattern = Record<string, string>;
|
|
|
|
function appendUniquePattern(out: Array<LinuxRoutingPattern>, pattern: LinuxRoutingPattern): void {
|
|
if (Object.keys(pattern).length === 0) return;
|
|
const key = JSON.stringify(pattern);
|
|
if (!out.some((existing) => JSON.stringify(existing) === key)) {
|
|
out.push(pattern);
|
|
}
|
|
}
|
|
|
|
function normalizeLinuxRoutingRule(rule: LinuxRoutingRule): LinuxRoutingRule {
|
|
const include = rule.include?.map((pattern) => ({...pattern})) ?? [];
|
|
if (include.length === 0 && rule.onlySpeakers === false) {
|
|
appendUniquePattern(include, {'media.class': 'Stream/Output/Audio'});
|
|
}
|
|
const exclude: Array<LinuxRoutingPattern> = [];
|
|
for (const pattern of rule.exclude ?? []) {
|
|
appendUniquePattern(exclude, {...pattern});
|
|
}
|
|
for (const pattern of buildFluxerAudioExcludePatterns()) {
|
|
appendUniquePattern(exclude, pattern);
|
|
}
|
|
if (rule.ignoreInputMedia ?? true) {
|
|
appendUniquePattern(exclude, {'media.class': 'Stream/Input/Audio'});
|
|
}
|
|
if (rule.ignoreVirtual) {
|
|
appendUniquePattern(exclude, {'node.virtual': 'true'});
|
|
}
|
|
return {
|
|
...rule,
|
|
include,
|
|
exclude,
|
|
};
|
|
}
|
|
|
|
function describeAddonLoadError(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
const code = (error as Error & {code?: string}).code;
|
|
const message = error.message ?? '';
|
|
return code ? `${code}: ${message}` : message;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function loadNativeAudioAddon(): NativeAddonLoadResult {
|
|
if (cachedLoadResult) return cachedLoadResult;
|
|
if (getNativeAudioMode(process.argv) === 'off') {
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
reason: 'disabled-by-launch',
|
|
detail: 'Native audio disabled by launch diagnostics',
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
const addon = requireModule('@fluxer/win-process-loopback') as WindowsNativeAudioModule;
|
|
if (addon.loadError) {
|
|
const detail = describeAddonLoadError(addon.loadError);
|
|
logger.warn('Windows native audio addon reported load error', addon.loadError);
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'windows-wasapi-loopback',
|
|
reason: 'load-failed',
|
|
detail,
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
const backendInfo = addon.getBackendInfo?.();
|
|
const processInclude =
|
|
backendInfo?.processIncludeSupported ?? backendInfo?.processSupported ?? addon.isSupported();
|
|
const processExclude =
|
|
backendInfo?.processExcludeSupported ?? backendInfo?.systemLoopbackMode === 'process-exclude';
|
|
const sessionMixer = backendInfo?.sessionMixerSupported ?? processInclude;
|
|
const processSupported = backendInfo?.processSupported ?? processInclude;
|
|
const systemSupported = backendInfo?.systemSupported ?? (processExclude || sessionMixer);
|
|
const systemExcludesSelf = backendInfo?.systemExcludesSelf ?? (processExclude || sessionMixer);
|
|
const systemLoopbackMode =
|
|
backendInfo?.systemLoopbackMode ??
|
|
(processExclude ? 'process-exclude' : sessionMixer ? 'session-mixer' : 'unavailable');
|
|
if (!processSupported && !systemSupported) {
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'windows-wasapi-loopback',
|
|
reason: 'os-version-too-old',
|
|
detail: backendInfo?.reason,
|
|
capabilities: {
|
|
process: false,
|
|
system: false,
|
|
systemExcludesSelf: false,
|
|
processInclude: false,
|
|
processExclude: false,
|
|
sessionMixer: false,
|
|
systemLoopbackMode: 'unavailable',
|
|
},
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
addon,
|
|
availability: {
|
|
available: true,
|
|
backend: 'windows-wasapi-loopback',
|
|
detail: backendInfo?.reason,
|
|
capabilities: {
|
|
process: processSupported,
|
|
system: systemSupported,
|
|
systemExcludesSelf,
|
|
processInclude,
|
|
processExclude,
|
|
sessionMixer,
|
|
systemLoopbackMode:
|
|
systemLoopbackMode === 'process-exclude' || systemLoopbackMode === 'session-mixer'
|
|
? systemLoopbackMode
|
|
: 'unavailable',
|
|
},
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
} catch (error) {
|
|
const detail = describeAddonLoadError(error);
|
|
const reason = isModuleNotFoundError(error) ? 'addon-not-installed' : 'load-failed';
|
|
logger.warn('Failed to load Windows native audio addon', error);
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'windows-wasapi-loopback',
|
|
reason,
|
|
detail,
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
}
|
|
if (process.platform === 'darwin') {
|
|
try {
|
|
const addon = requireModule('@fluxer/mac-app-audio') as MacNativeAudioModule;
|
|
if (addon.loadError) {
|
|
const detail = describeAddonLoadError(addon.loadError);
|
|
logger.warn('macOS native audio addon reported load error', addon.loadError);
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'macos-sck',
|
|
reason: 'load-failed',
|
|
detail,
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
addon,
|
|
availability: {
|
|
available: true,
|
|
backend: 'macos-sck',
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
} catch (error) {
|
|
const detail = describeAddonLoadError(error);
|
|
const reason = isModuleNotFoundError(error) ? 'addon-not-installed' : 'load-failed';
|
|
logger.warn('Failed to load macOS native audio addon', error);
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'macos-sck',
|
|
reason,
|
|
detail,
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
}
|
|
if (process.platform === 'linux') {
|
|
try {
|
|
const addon = requireModule('@fluxer/linux-audio-capture') as LinuxNativeAudioModule;
|
|
if (typeof addon.pipeWireAvailable !== 'function' || typeof addon.ProcessLoopback !== 'function') {
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'linux-pipewire',
|
|
reason: 'load-failed',
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
const pipewireAvailable = addon.pipeWireAvailable();
|
|
const backend = addon.audioBackend?.() ?? (pipewireAvailable ? 'pipewire' : 'none');
|
|
if (!pipewireAvailable || backend !== 'pipewire') {
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'linux-pipewire',
|
|
reason: 'no-pipewire',
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
addon,
|
|
availability: {
|
|
available: true,
|
|
backend: 'linux-pipewire',
|
|
capabilities: {
|
|
process: true,
|
|
system: true,
|
|
systemExcludesSelf: true,
|
|
},
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
} catch (error) {
|
|
const detail = describeAddonLoadError(error);
|
|
const reason = isModuleNotFoundError(error) ? 'addon-not-installed' : 'load-failed';
|
|
logger.warn('Failed to load Linux native audio addon', error);
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
backend: 'linux-pipewire',
|
|
reason,
|
|
detail,
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
}
|
|
cachedLoadResult = {
|
|
platform: process.platform,
|
|
availability: {
|
|
available: false,
|
|
reason: 'unsupported-platform',
|
|
},
|
|
};
|
|
return cachedLoadResult;
|
|
}
|
|
|
|
function getMacScreenPermissionStatus(): string | null {
|
|
try {
|
|
return getTccStatus('screen-recording');
|
|
} catch (error) {
|
|
logger.debug('Failed to read macOS screen capture status', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getNativeAudioAvailability(): Promise<NativeAudioAvailability> {
|
|
const loadResult = loadNativeAudioAddon();
|
|
if (!loadResult.availability.available || !loadResult.addon) {
|
|
return loadResult.availability;
|
|
}
|
|
if (loadResult.platform !== 'darwin') {
|
|
return loadResult.availability;
|
|
}
|
|
try {
|
|
const addon = loadResult.addon as MacNativeAudioModule;
|
|
const backendAvailability = await addon.getBackendAvailability();
|
|
const coreAudioSupported = backendAvailability.coreaudio?.supported === true;
|
|
const sckSupported = backendAvailability.sck?.supported === true;
|
|
const backend = sckSupported ? 'macos-sck' : 'macos-coreaudio';
|
|
const capabilities = {
|
|
process: sckSupported || coreAudioSupported,
|
|
system: sckSupported || coreAudioSupported,
|
|
systemExcludesSelf: sckSupported || coreAudioSupported,
|
|
};
|
|
if (!coreAudioSupported && !sckSupported) {
|
|
return {
|
|
available: false,
|
|
backend,
|
|
reason: 'os-version-too-old',
|
|
detail: backendAvailability.sck?.macosVersion,
|
|
capabilities,
|
|
};
|
|
}
|
|
const screenPermissionStatus = getMacScreenPermissionStatus();
|
|
if (screenPermissionStatus === 'denied' || screenPermissionStatus === 'restricted') {
|
|
return {
|
|
available: false,
|
|
backend,
|
|
reason: 'permission-denied',
|
|
detail: `screen:${screenPermissionStatus}`,
|
|
capabilities,
|
|
};
|
|
}
|
|
return {
|
|
available: true,
|
|
backend,
|
|
detail: backendAvailability.sck?.macosVersion,
|
|
capabilities,
|
|
};
|
|
} catch (error) {
|
|
logger.warn('Failed to query macOS native audio availability', error);
|
|
return {
|
|
available: false,
|
|
backend: 'macos-sck',
|
|
reason: 'load-failed',
|
|
};
|
|
}
|
|
}
|
|
|
|
async function listNativeAudioApplications(): Promise<Array<NativeAudioApplication>> {
|
|
const loadResult = loadNativeAudioAddon();
|
|
if (!loadResult.availability.available || !loadResult.addon) {
|
|
return [];
|
|
}
|
|
if (loadResult.platform !== 'darwin') {
|
|
return [];
|
|
}
|
|
try {
|
|
const addon = loadResult.addon as MacNativeAudioModule;
|
|
const applications = await addon.listAudibleApplications();
|
|
return applications
|
|
.filter((application) => Number.isFinite(application.pid) && application.pid > 0)
|
|
.map((application) => ({
|
|
pid: application.pid,
|
|
identifier: application.bundleId || `pid:${application.pid}`,
|
|
name: application.name || application.bundleId || `PID ${application.pid}`,
|
|
}));
|
|
} catch (error) {
|
|
logger.warn('Failed to list native audio applications', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function resolveAudioRootPidForSource(sourceId: unknown): Promise<number | null> {
|
|
const windowToken = parseDesktopWindowSourceToken(sourceId);
|
|
if (!windowToken) {
|
|
return null;
|
|
}
|
|
const loadResult = loadNativeAudioAddon();
|
|
if (!loadResult.availability.available || !loadResult.addon) {
|
|
return null;
|
|
}
|
|
try {
|
|
if (loadResult.platform === 'win32') {
|
|
const addon = loadResult.addon as WindowsNativeAudioModule;
|
|
const pid = addon.pidFromHwnd(BigInt(windowToken));
|
|
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
if (isKnownFluxerAudioProcessPid(pid)) return null;
|
|
const rootPid = addon.resolveAudioRootPid(pid);
|
|
const resolved = Number.isFinite(rootPid) && rootPid > 0 ? rootPid : pid;
|
|
return isKnownFluxerAudioProcessPid(resolved) ? null : resolved;
|
|
}
|
|
if (loadResult.platform === 'darwin') {
|
|
const addon = loadResult.addon as MacNativeAudioModule;
|
|
const pid = addon.pidFromWindowId(Number(windowToken));
|
|
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
return isKnownFluxerAudioProcessPid(pid) ? null : pid;
|
|
}
|
|
if (loadResult.platform === 'linux') {
|
|
return resolveVirtmicWindowPid(sourceId);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to resolve native-audio source PID', {sourceId, error});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function copySamplesBuffer(samples: Float32Array): ArrayBuffer {
|
|
const copy = new Float32Array(samples.length);
|
|
copy.set(samples);
|
|
return copy.buffer;
|
|
}
|
|
|
|
function removeSessionListeners(session: ActiveNativeAudioSession): void {
|
|
session.capture.removeListener('frame', session.onFrame);
|
|
session.capture.removeListener('error', session.onError);
|
|
session.capture.removeListener('closed', session.onClosed);
|
|
session.sender.removeListener('destroyed', session.onSenderDestroyed);
|
|
}
|
|
|
|
function rememberSenderSession(senderId: number, captureId: string): void {
|
|
let senderSessions = activeSessionIdsBySenderId.get(senderId);
|
|
if (!senderSessions) {
|
|
senderSessions = new Set();
|
|
activeSessionIdsBySenderId.set(senderId, senderSessions);
|
|
}
|
|
senderSessions.add(captureId);
|
|
}
|
|
|
|
function forgetSenderSession(senderId: number, captureId: string): void {
|
|
const senderSessions = activeSessionIdsBySenderId.get(senderId);
|
|
if (!senderSessions) return;
|
|
senderSessions.delete(captureId);
|
|
if (senderSessions.size === 0) {
|
|
activeSessionIdsBySenderId.delete(senderId);
|
|
}
|
|
}
|
|
|
|
function finalizeSession(session: ActiveNativeAudioSession, reason: NativeAudioEndReason, detail?: string): void {
|
|
if (session.finalized) return;
|
|
session.finalized = true;
|
|
removeSessionListeners(session);
|
|
activeSessions.delete(session.captureId);
|
|
forgetSenderSession(session.sender.id, session.captureId);
|
|
if (!session.sender.isDestroyed()) {
|
|
try {
|
|
session.sender.send('native-audio:end', {
|
|
captureId: session.captureId,
|
|
reason,
|
|
detail,
|
|
});
|
|
} catch (error) {
|
|
logger.warn('Failed to send native audio end event to renderer', {
|
|
captureId: session.captureId,
|
|
reason,
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stopActiveSession(
|
|
session: ActiveNativeAudioSession,
|
|
reason: NativeAudioEndReason = 'stopped',
|
|
detail?: string,
|
|
): Promise<void> {
|
|
if (session.finalized) return;
|
|
if (session.stopping) return session.stopping;
|
|
session.stoppingReason = reason;
|
|
session.stoppingDetail = detail;
|
|
session.stopping = (async () => {
|
|
try {
|
|
await Promise.resolve(session.capture.stop());
|
|
} catch (error) {
|
|
logger.warn('Failed to stop native audio capture', {captureId: session.captureId, error});
|
|
} finally {
|
|
finalizeSession(session, reason, detail);
|
|
}
|
|
})();
|
|
return session.stopping;
|
|
}
|
|
|
|
async function stopCaptureById(
|
|
captureId: string,
|
|
reason: NativeAudioEndReason = 'stopped',
|
|
detail?: string,
|
|
): Promise<void> {
|
|
const session = activeSessions.get(captureId);
|
|
if (!session) return;
|
|
await stopActiveSession(session, reason, detail);
|
|
}
|
|
|
|
async function makeRoomForSenderSession(senderId: number): Promise<void> {
|
|
const senderSessions = activeSessionIdsBySenderId.get(senderId);
|
|
if (!senderSessions) return;
|
|
while (senderSessions.size >= MAX_NATIVE_AUDIO_SESSIONS_PER_SENDER) {
|
|
const oldestCaptureId = senderSessions.values().next().value as string | undefined;
|
|
if (!oldestCaptureId) return;
|
|
await stopCaptureById(oldestCaptureId, 'stopped', 'sender-session-limit');
|
|
}
|
|
}
|
|
|
|
async function startNativeAudioCapture(
|
|
sender: Electron.WebContents,
|
|
options: NativeAudioStartOptions,
|
|
): Promise<NativeAudioStartResult> {
|
|
if (sender.isDestroyed()) {
|
|
throw new Error('Cannot start native audio capture for destroyed renderer');
|
|
}
|
|
const loadResult = loadNativeAudioAddon();
|
|
const linuxRule =
|
|
loadResult.platform === 'linux' && isValidLinuxRule(options?.linuxRule)
|
|
? normalizeLinuxRoutingRule(options.linuxRule)
|
|
: null;
|
|
const macCaptureScope =
|
|
loadResult.platform === 'darwin' && options?.macCaptureScope === 'system' ? 'system' : 'process';
|
|
const macSystemCapture = loadResult.platform === 'darwin' && macCaptureScope === 'system';
|
|
const winCaptureScope =
|
|
loadResult.platform === 'win32' && options?.winCaptureScope === 'session-mixer'
|
|
? 'session-mixer'
|
|
: loadResult.platform === 'win32' && options?.winCaptureScope === 'system'
|
|
? 'system'
|
|
: 'process';
|
|
const winSystemCapture =
|
|
loadResult.platform === 'win32' && (winCaptureScope === 'system' || winCaptureScope === 'session-mixer');
|
|
const targetPid = options?.targetPid;
|
|
if (!options || (!linuxRule && !macSystemCapture && !winSystemCapture && !isValidTargetPid(targetPid))) {
|
|
throw new Error('Invalid native audio target PID');
|
|
}
|
|
if (!macSystemCapture && !winSystemCapture && targetPid === process.pid) {
|
|
throw new Error('Refusing to capture native audio from own process');
|
|
}
|
|
if (
|
|
!macSystemCapture &&
|
|
!winSystemCapture &&
|
|
isValidTargetPid(targetPid) &&
|
|
isKnownFluxerAudioProcessPid(targetPid)
|
|
) {
|
|
throw new Error('Refusing to capture native audio from Fluxer process');
|
|
}
|
|
const availability = await getNativeAudioAvailability();
|
|
if (!availability.available) {
|
|
throw new Error(`Native audio capture unavailable (${availability.reason ?? 'unknown'})`);
|
|
}
|
|
const requestedScope: 'process' | 'system' =
|
|
winSystemCapture || macSystemCapture
|
|
? 'system'
|
|
: linuxRule
|
|
? linuxRule.include && linuxRule.include.length > 0
|
|
? 'process'
|
|
: 'system'
|
|
: 'process';
|
|
const capability = availability.capabilities?.[requestedScope];
|
|
if (capability === false) {
|
|
throw new Error(
|
|
`Native ${loadResult.platform} ${requestedScope} audio capture unavailable (${availability.reason ?? 'unsupported-scope'})`,
|
|
);
|
|
}
|
|
if (requestedScope === 'system' && availability.capabilities?.systemExcludesSelf !== true) {
|
|
throw new Error(`Native ${loadResult.platform} system audio capture does not guarantee Fluxer self-exclusion`);
|
|
}
|
|
const effectiveWinCaptureScope =
|
|
loadResult.platform === 'win32' &&
|
|
winCaptureScope === 'system' &&
|
|
availability.capabilities?.processExclude === false &&
|
|
availability.capabilities?.sessionMixer === true
|
|
? 'session-mixer'
|
|
: winCaptureScope;
|
|
if (sender.isDestroyed()) {
|
|
throw new Error('Cannot start native audio capture for destroyed renderer');
|
|
}
|
|
if (!loadResult.addon) {
|
|
throw new Error('Native audio addon unavailable');
|
|
}
|
|
await makeRoomForSenderSession(sender.id);
|
|
if (sender.isDestroyed()) {
|
|
throw new Error('Cannot start native audio capture for destroyed renderer');
|
|
}
|
|
const captureId = randomUUID();
|
|
const targetPidForCapture = macSystemCapture || winSystemCapture ? process.pid : targetPid;
|
|
let capture: NativeCaptureInstance;
|
|
if (loadResult.platform === 'win32') {
|
|
if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID');
|
|
capture = new (loadResult.addon as WindowsNativeAudioModule).ProcessLoopback(targetPidForCapture, {
|
|
includeProcessTree: winSystemCapture ? false : (options.includeProcessTree ?? true),
|
|
captureScope: effectiveWinCaptureScope,
|
|
});
|
|
} else if (loadResult.platform === 'darwin') {
|
|
if (!isValidTargetPid(targetPidForCapture)) throw new Error('Invalid native audio target PID');
|
|
capture = new (loadResult.addon as MacNativeAudioModule).ProcessLoopback(targetPidForCapture, {
|
|
excludeSelf: true,
|
|
includeProcessTree: macSystemCapture ? true : (options.includeProcessTree ?? true),
|
|
backend: options.macBackend ?? (macSystemCapture ? 'sck' : 'auto'),
|
|
captureScope: macCaptureScope,
|
|
});
|
|
} else if (loadResult.platform === 'linux') {
|
|
const addon = loadResult.addon as LinuxNativeAudioModule;
|
|
capture = linuxRule
|
|
? new addon.ProcessLoopback({linuxRule})
|
|
: new addon.ProcessLoopback(targetPid as number, {
|
|
includeProcessTree: options.includeProcessTree ?? true,
|
|
ignoreDevices: true,
|
|
});
|
|
} else {
|
|
throw new Error('Native audio addon unavailable for this platform');
|
|
}
|
|
let firstFrameLogged = false;
|
|
let nativeSinkAttached = false;
|
|
let slowPathReported = false;
|
|
const session: ActiveNativeAudioSession = {
|
|
captureId,
|
|
capture,
|
|
sender,
|
|
finalized: false,
|
|
onFrame: (frame) => {
|
|
if (session.finalized) return;
|
|
if (!isValidAudioFrame(frame)) {
|
|
logger.warn('Native audio capture emitted invalid frame', {
|
|
captureId,
|
|
...audioFrameDebugDetails(frame),
|
|
});
|
|
stopActiveSession(session, 'addon-error', 'Native audio addon emitted an invalid PCM frame').catch((error) =>
|
|
logger.warn('stopActiveSession failed after invalid native audio frame', {captureId, error}),
|
|
);
|
|
return;
|
|
}
|
|
if (sender.isDestroyed()) {
|
|
stopActiveSession(session, 'stopped').catch((error) =>
|
|
logger.warn('stopActiveSession failed after sender destroyed', {captureId, error}),
|
|
);
|
|
return;
|
|
}
|
|
if (nativeSinkAttached) {
|
|
if (!slowPathReported) {
|
|
slowPathReported = true;
|
|
logger.error(
|
|
'Native screen-audio fast path stopped engaging: a captured frame reached the JS bridge even though the native engine sink is attached. Failing the capture so this critical regression surfaces immediately instead of silently downgrading to the crackle-prone path.',
|
|
{captureId, senderId: sender.id, sampleRate: frame.sampleRate, channels: frame.channels},
|
|
);
|
|
stopActiveSession(session, 'addon-error', 'native screen-audio fast path stopped engaging').catch((error) =>
|
|
logger.warn('stopActiveSession failed after native fast-path regression', {captureId, error}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
sender.send('native-audio:frame', {
|
|
captureId,
|
|
sampleRate: frame.sampleRate,
|
|
channels: frame.channels,
|
|
timestampUs: normalizeTimestampUs(frame.timestampUs),
|
|
samples: copySamplesBuffer(frame.samples),
|
|
});
|
|
} catch (error) {
|
|
logger.warn('Failed to send native audio frame to renderer', {captureId, error});
|
|
stopActiveSession(session, 'stopped').catch((stopError) =>
|
|
logger.warn('stopActiveSession failed after native audio send failure', {captureId, error: stopError}),
|
|
);
|
|
return;
|
|
}
|
|
if (!firstFrameLogged) {
|
|
firstFrameLogged = true;
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
logger.warn('Native audio capture emitted error', {captureId, error});
|
|
stopActiveSession(session, 'addon-error', detail).catch((stopError) =>
|
|
logger.warn('stopActiveSession failed after addon error', {captureId, error: stopError}),
|
|
);
|
|
},
|
|
onClosed: () => {
|
|
const reason = session.stoppingReason ?? 'target-exited';
|
|
finalizeSession(session, reason, session.stoppingDetail);
|
|
},
|
|
onSenderDestroyed: () => {
|
|
stopActiveSession(session, 'stopped').catch((error) =>
|
|
logger.warn('stopActiveSession failed on sender-destroyed', {captureId, error}),
|
|
);
|
|
},
|
|
};
|
|
capture.on('frame', session.onFrame);
|
|
capture.on('error', session.onError);
|
|
capture.on('closed', session.onClosed);
|
|
sender.once('destroyed', session.onSenderDestroyed);
|
|
activeSessions.set(captureId, session);
|
|
rememberSenderSession(sender.id, captureId);
|
|
try {
|
|
if (hasActiveNativeEngineForSender(sender.id)) {
|
|
if (typeof capture.setScreenAudioSink !== 'function') {
|
|
throw new Error(
|
|
`Native screen-audio fast path unavailable for capture ${captureId}: capture binding lacks setScreenAudioSink (stale native build)`,
|
|
);
|
|
}
|
|
const screenAudioSink = createScreenAudioSinkHandleForSender(sender.id);
|
|
if (!screenAudioSink) {
|
|
throw new Error(
|
|
`Native screen-audio fast path unavailable for capture ${captureId}: native voice engine produced no sink handle for the active session`,
|
|
);
|
|
}
|
|
if (capture.setScreenAudioSink(screenAudioSink) !== true) {
|
|
throw new Error(
|
|
`Native screen-audio fast path unavailable for capture ${captureId}: native capture binding does not implement setScreenAudioSink (stale native build)`,
|
|
);
|
|
}
|
|
nativeSinkAttached = true;
|
|
}
|
|
await Promise.resolve(capture.start());
|
|
} catch (error) {
|
|
logger.error('Native audio capture failed to start', {captureId, error});
|
|
removeSessionListeners(session);
|
|
try {
|
|
await Promise.resolve(capture.stop());
|
|
} catch (stopError) {
|
|
logger.warn('Failed to stop native audio capture after start failure', {captureId, error: stopError});
|
|
}
|
|
activeSessions.delete(captureId);
|
|
forgetSenderSession(sender.id, captureId);
|
|
throw error;
|
|
}
|
|
if (sender.isDestroyed()) {
|
|
await stopActiveSession(session, 'stopped', 'sender-destroyed-during-start');
|
|
throw new Error('Native audio capture sender was destroyed during startup');
|
|
}
|
|
if (session.finalized || activeSessions.get(captureId) !== session) {
|
|
if (!session.finalized) {
|
|
await stopActiveSession(session, 'stopped', 'session-replaced-during-start');
|
|
}
|
|
throw new Error('Native audio capture ended before startup completed');
|
|
}
|
|
return {
|
|
captureId,
|
|
sampleRate: 48000,
|
|
channels: 2,
|
|
};
|
|
}
|
|
|
|
function getNativeAudioRoutingGraph(sender: Electron.WebContents, captureId?: string): NativeAudioRoutingGraphResult {
|
|
const loadResult = loadNativeAudioAddon();
|
|
const availability = loadResult.availability;
|
|
if (loadResult.platform !== 'linux' || !availability.available) {
|
|
return {ok: false, graphs: [], availability};
|
|
}
|
|
const ownedIds = activeSessionIdsBySenderId.get(sender.id);
|
|
const candidateIds =
|
|
typeof captureId === 'string' && captureId
|
|
? ownedIds?.has(captureId)
|
|
? [captureId]
|
|
: []
|
|
: Array.from(ownedIds ?? []);
|
|
const graphs = candidateIds
|
|
.map((id) => {
|
|
const session = activeSessions.get(id);
|
|
if (!session || session.sender.id !== sender.id) return null;
|
|
return {
|
|
captureId: id,
|
|
graph: session.capture.routingGraph?.() ?? null,
|
|
};
|
|
})
|
|
.filter((entry): entry is {captureId: string; graph: VirtmicRoutingGraph | null} => entry !== null);
|
|
return {ok: true, graphs, availability};
|
|
}
|
|
|
|
export function registerNativeAudioHandlers(): void {
|
|
if (handlersRegistered) return;
|
|
handlersRegistered = true;
|
|
ipcMain.handle('native-audio:get-availability', (): Promise<NativeAudioAvailability> => getNativeAudioAvailability());
|
|
ipcMain.handle(
|
|
'native-audio:list-applications',
|
|
(): Promise<Array<NativeAudioApplication>> => listNativeAudioApplications(),
|
|
);
|
|
ipcMain.handle(
|
|
'native-audio:resolve-root-pid',
|
|
(_event, sourceId: unknown): Promise<number | null> => resolveAudioRootPidForSource(sourceId),
|
|
);
|
|
ipcMain.handle(
|
|
'native-audio:start',
|
|
(event, options: NativeAudioStartOptions): Promise<NativeAudioStartResult> =>
|
|
startNativeAudioCapture(event.sender, options),
|
|
);
|
|
ipcMain.handle('native-audio:stop', async (event, captureId: string): Promise<void> => {
|
|
const session = activeSessions.get(captureId);
|
|
if (!session || session.sender.id !== event.sender.id) {
|
|
return;
|
|
}
|
|
await stopActiveSession(session, 'stopped');
|
|
});
|
|
ipcMain.handle(
|
|
'native-audio:get-routing-graph',
|
|
(event, captureId?: string): NativeAudioRoutingGraphResult => getNativeAudioRoutingGraph(event.sender, captureId),
|
|
);
|
|
}
|
|
|
|
export function cleanupNativeAudio(): void {
|
|
if (!handlersRegistered) return;
|
|
ipcMain.removeHandler('native-audio:get-availability');
|
|
ipcMain.removeHandler('native-audio:list-applications');
|
|
ipcMain.removeHandler('native-audio:resolve-root-pid');
|
|
ipcMain.removeHandler('native-audio:start');
|
|
ipcMain.removeHandler('native-audio:stop');
|
|
ipcMain.removeHandler('native-audio:get-routing-graph');
|
|
handlersRegistered = false;
|
|
for (const session of [...activeSessions.values()]) {
|
|
stopActiveSession(session, 'stopped').catch((error) =>
|
|
logger.warn('stopActiveSession failed during cleanup', {captureId: session.captureId, error}),
|
|
);
|
|
}
|
|
}
|