mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
refactor(desktop): replace the native voice engine with a standalone hardware encoder (#1677)
This commit is contained in:
1 parent
98c40c6979
commit
ccdd85b099
893 files changed
+620
-371790
No files matched your search
@@ -27,7 +27,6 @@ import {
|
||||
isValidTargetPid,
|
||||
normalizeTimestampUs,
|
||||
} from './NativeAudioValidation';
|
||||
import {createScreenAudioSinkHandleForSender, hasActiveNativeEngineForSender} from './NativeVoiceEngine';
|
||||
|
||||
const logger = createChildLogger('NativeAudio');
|
||||
const requireModule = createRequire(import.meta.url);
|
||||
@@ -41,8 +40,6 @@ interface NativeCaptureInstance {
|
||||
removeListener(event: 'closed', listener: () => void): this;
|
||||
start(): Promise<void> | void;
|
||||
stop(): Promise<void> | void;
|
||||
setScreenAudioSink?: (handle: unknown) => boolean;
|
||||
clearScreenAudioSink?: () => void;
|
||||
routingGraph?: () => VirtmicRoutingGraph | null;
|
||||
}
|
||||
|
||||
@@ -749,8 +746,6 @@ async function startNativeAudioCapture(
|
||||
throw new Error('Native audio addon unavailable for this platform');
|
||||
}
|
||||
let firstFrameLogged = false;
|
||||
let nativeSinkAttached = false;
|
||||
let slowPathReported = false;
|
||||
const session: ActiveNativeAudioSession = {
|
||||
captureId,
|
||||
capture,
|
||||
@@ -774,19 +769,6 @@ async function startNativeAudioCapture(
|
||||
);
|
||||
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,
|
||||
@@ -830,25 +812,6 @@ async function startNativeAudioCapture(
|
||||
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});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createRequire} from 'node:module';
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import {
|
||||
normalizeVoiceEngineV2BridgeHardwareEncoderCapabilities,
|
||||
unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities,
|
||||
VOICE_ENGINE_V2_IPC_CHANNELS,
|
||||
type VoiceEngineV2BridgeHardwareEncoderCapabilities,
|
||||
} from '@fluxer/voice_engine_v2/bridge';
|
||||
import {ipcMain} from 'electron';
|
||||
|
||||
const logger = createChildLogger('NativeHardwareEncoder');
|
||||
const requireModule = createRequire(import.meta.url);
|
||||
|
||||
interface HardwareEncoderModule {
|
||||
getHardwareEncoderCapabilities?: () => unknown;
|
||||
}
|
||||
|
||||
let cachedModule: HardwareEncoderModule | null | undefined;
|
||||
let cachedModuleLoadErrorDetail: string | undefined;
|
||||
|
||||
function loadHardwareEncoderModule(): HardwareEncoderModule | null {
|
||||
if (cachedModule !== undefined) return cachedModule;
|
||||
cachedModuleLoadErrorDetail = undefined;
|
||||
try {
|
||||
cachedModule = requireModule('@fluxer/hardware-encoder') as HardwareEncoderModule;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load @fluxer/hardware-encoder', error);
|
||||
cachedModuleLoadErrorDetail = error instanceof Error ? error.message : String(error);
|
||||
cachedModule = null;
|
||||
}
|
||||
return cachedModule;
|
||||
}
|
||||
|
||||
function getHardwareEncoderCapabilities(): VoiceEngineV2BridgeHardwareEncoderCapabilities {
|
||||
const mod = loadHardwareEncoderModule();
|
||||
if (!mod) {
|
||||
return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities('load-failed', cachedModuleLoadErrorDetail);
|
||||
}
|
||||
if (typeof mod.getHardwareEncoderCapabilities !== 'function') {
|
||||
return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities('unsupported-addon-version');
|
||||
}
|
||||
try {
|
||||
return normalizeVoiceEngineV2BridgeHardwareEncoderCapabilities(mod.getHardwareEncoderCapabilities());
|
||||
} catch (error) {
|
||||
logger.warn('Native hardware encoder getHardwareEncoderCapabilities() threw', error);
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities('query-failed', detail);
|
||||
}
|
||||
}
|
||||
|
||||
let handlersRegistered = false;
|
||||
|
||||
export function registerNativeHardwareEncoderHandlers(): void {
|
||||
if (handlersRegistered) return;
|
||||
handlersRegistered = true;
|
||||
ipcMain.handle(
|
||||
VOICE_ENGINE_V2_IPC_CHANNELS.getHardwareEncoderCapabilities,
|
||||
(): VoiceEngineV2BridgeHardwareEncoderCapabilities => getHardwareEncoderCapabilities(),
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanupNativeHardwareEncoderHandlers(): void {
|
||||
if (!handlersRegistered) return;
|
||||
ipcMain.removeHandler(VOICE_ENGINE_V2_IPC_CHANNELS.getHardwareEncoderCapabilities);
|
||||
handlersRegistered = false;
|
||||
}
|
||||
@@ -86,12 +86,7 @@ function makeNativeAddon({
|
||||
};
|
||||
}
|
||||
|
||||
function loadNativeScreenCapture({
|
||||
platform = 'linux',
|
||||
addon,
|
||||
tccStatus = 'not-determined',
|
||||
frameSinkHandle = null,
|
||||
} = {}) {
|
||||
function loadNativeScreenCapture({platform = 'linux', addon, tccStatus = 'not-determined'} = {}) {
|
||||
const handlers = new Map();
|
||||
const calls = {
|
||||
logs: {debug: [], warn: []},
|
||||
@@ -149,12 +144,6 @@ function loadNativeScreenCapture({
|
||||
if (specifier === './MacTcc') {
|
||||
return {getTccStatus: () => tccStatus};
|
||||
}
|
||||
if (specifier === './NativeVoiceEngine') {
|
||||
return {
|
||||
createNativeVoiceEngineScreenFrameSinkHandle: (captureId) =>
|
||||
typeof frameSinkHandle === 'function' ? frameSinkHandle(captureId) : frameSinkHandle,
|
||||
};
|
||||
}
|
||||
if (specifier === './NativeScreenCaptureValidation') {
|
||||
return {
|
||||
isValidStartOptions: () => true,
|
||||
@@ -242,15 +231,10 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
|
||||
});
|
||||
|
||||
test('starts display and window captures with exact source id and kind and reports diagnostics', async () => {
|
||||
const frameSinkHandles = {
|
||||
'capture-1': {native: true, captureId: 'capture-1'},
|
||||
'capture-2': {native: true, captureId: 'capture-2'},
|
||||
};
|
||||
const {addon, captures} = makeNativeAddon();
|
||||
const harness = loadNativeScreenCapture({
|
||||
platform: 'linux',
|
||||
addon,
|
||||
frameSinkHandle: (captureId) => frameSinkHandles[captureId] ?? null,
|
||||
});
|
||||
const {sender, sent} = makeSender();
|
||||
harness.module.registerNativeScreenCaptureHandlers();
|
||||
@@ -296,7 +280,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
|
||||
showCursorClicks: true,
|
||||
captureRect: {x: 10, y: 20, width: 300, height: 200},
|
||||
nativeFrameSinkRequired: true,
|
||||
frameSinkHandle: frameSinkHandles['capture-1'],
|
||||
},
|
||||
{
|
||||
sourceId: 'window:4242',
|
||||
@@ -311,7 +294,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
|
||||
showCursorClicks: false,
|
||||
captureRect: undefined,
|
||||
nativeFrameSinkRequired: true,
|
||||
frameSinkHandle: frameSinkHandles['capture-2'],
|
||||
},
|
||||
]);
|
||||
assert.equal(displayResult.captureId, 'capture-1');
|
||||
@@ -342,37 +324,11 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
|
||||
assert.equal(captures[1].stopCount, 1);
|
||||
});
|
||||
|
||||
test('fails fast when native frame sink is required but unavailable', async () => {
|
||||
const {addon, captures} = makeNativeAddon();
|
||||
const harness = loadNativeScreenCapture({platform: 'linux', addon});
|
||||
const {sender} = makeSender();
|
||||
harness.module.registerNativeScreenCaptureHandlers();
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
harness.handlers.get('native-screen-capture:start')(
|
||||
{sender},
|
||||
{
|
||||
sourceId: 'window:4242',
|
||||
sourceKind: 'window',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
captureId: 'preselected-capture-id',
|
||||
nativeFrameSinkRequired: true,
|
||||
},
|
||||
),
|
||||
/requires a native frame sink handle/,
|
||||
);
|
||||
assert.equal(captures.length, 0);
|
||||
});
|
||||
|
||||
test('passes caller-provided capture id and native sink handle to the platform wrapper', async () => {
|
||||
const frameSinkHandle = {native: true};
|
||||
test('passes caller-provided capture id to the platform wrapper', async () => {
|
||||
const {addon, captures} = makeNativeAddon();
|
||||
const harness = loadNativeScreenCapture({
|
||||
platform: 'linux',
|
||||
addon,
|
||||
frameSinkHandle: (captureId) => (captureId === 'preselected-capture-id' ? frameSinkHandle : null),
|
||||
});
|
||||
const {sender, sent} = makeSender();
|
||||
harness.module.registerNativeScreenCaptureHandlers();
|
||||
@@ -403,7 +359,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
|
||||
showCursorClicks: false,
|
||||
captureRect: undefined,
|
||||
nativeFrameSinkRequired: true,
|
||||
frameSinkHandle,
|
||||
});
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
@@ -518,7 +473,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
|
||||
const harness = loadNativeScreenCapture({
|
||||
platform: 'win32',
|
||||
addon,
|
||||
frameSinkHandle: (captureId) => ({native: true, captureId}),
|
||||
});
|
||||
harness.module.registerNativeScreenCaptureHandlers();
|
||||
const {sender} = makeSender();
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
import {ipcMain} from 'electron';
|
||||
import {getTccStatus} from './MacTcc';
|
||||
import {isValidStartOptions, normalizeScreenCaptureDimension} from './NativeScreenCaptureValidation';
|
||||
import {createNativeVoiceEngineScreenFrameSinkHandle} from './NativeVoiceEngine';
|
||||
|
||||
const logger = createChildLogger('NativeScreenCapture');
|
||||
const requireModule = createRequire(import.meta.url);
|
||||
@@ -822,10 +821,6 @@ async function startNativeScreenCapture(
|
||||
if (activeSessions.has(captureId)) {
|
||||
throw new Error('Native screen capture id is already active');
|
||||
}
|
||||
const frameSinkHandle = createNativeVoiceEngineScreenFrameSinkHandle(captureId);
|
||||
if (!frameSinkHandle) {
|
||||
throw new Error('Native screen capture requires a native frame sink handle, but none is active');
|
||||
}
|
||||
const capture = new loadResult.addon.ScreenCapture({
|
||||
sourceId: options.sourceId,
|
||||
sourceKind: options.sourceKind,
|
||||
@@ -839,7 +834,6 @@ async function startNativeScreenCapture(
|
||||
showCursorClicks: options.showCursorClicks === true,
|
||||
captureRect: options.captureRect,
|
||||
nativeFrameSinkRequired: true,
|
||||
frameSinkHandle,
|
||||
});
|
||||
const session: ActiveNativeScreenSession = {
|
||||
captureId,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,241 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
isVoiceEngineV2BridgePublishMicrophoneOptions,
|
||||
type VoiceEngineV2BridgeAudioInputDevice,
|
||||
type VoiceEngineV2BridgeAudioOutputDevice,
|
||||
type VoiceEngineV2BridgeCameraDevice,
|
||||
} from '@fluxer/voice_engine_v2/bridge';
|
||||
import {
|
||||
clampNativeMicrophoneMaxBitrateBps,
|
||||
handleNativeVoiceEngineListAudioInputDevices,
|
||||
handleNativeVoiceEngineListAudioOutputDevices,
|
||||
handleNativeVoiceEngineListCameraDevices,
|
||||
handleNativeVoiceEnginePublishMicrophone,
|
||||
MICROPHONE_MAX_BITRATE_BPS_CAP,
|
||||
MICROPHONE_MAX_BITRATE_BPS_FLOOR,
|
||||
type NativeMicrophonePublishOptions,
|
||||
type NativeVoiceEngineAudioIpcEngine,
|
||||
type NativeVoiceEngineAudioQueryEngine,
|
||||
NativeVoiceEngineUnavailableError,
|
||||
} from './NativeVoiceEngineIpcCore';
|
||||
|
||||
function createEngine(overrides: Partial<NativeVoiceEngineAudioIpcEngine> = {}): NativeVoiceEngineAudioIpcEngine {
|
||||
return {
|
||||
publishMicrophone: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAudioQueryEngine(
|
||||
overrides: Partial<NativeVoiceEngineAudioQueryEngine> = {},
|
||||
): NativeVoiceEngineAudioQueryEngine {
|
||||
return {
|
||||
listAudioInputDevices: () => [],
|
||||
listAudioOutputDevices: () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('production microphone publish calls publishDeviceMicrophone with device and DSP options', async () => {
|
||||
const deviceCalls: Array<NativeMicrophonePublishOptions> = [];
|
||||
const pcmCalls: Array<[number, number]> = [];
|
||||
const engine = createEngine({
|
||||
publishMicrophone: async (sampleRate, channels) => {
|
||||
pcmCalls.push([sampleRate, channels]);
|
||||
},
|
||||
publishDeviceMicrophone: async (options) => {
|
||||
deviceCalls.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await handleNativeVoiceEnginePublishMicrophone(
|
||||
{engine},
|
||||
{
|
||||
deviceId: 'recording-device-guid',
|
||||
echoCancellation: false,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deviceCalls, [
|
||||
{
|
||||
deviceId: 'recording-device-guid',
|
||||
echoCancellation: false,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(pcmCalls, []);
|
||||
});
|
||||
|
||||
test('production microphone publish forwards a clamped maxBitrateBps to the native addon', async () => {
|
||||
const deviceCalls: Array<NativeMicrophonePublishOptions> = [];
|
||||
const engine = createEngine({
|
||||
publishDeviceMicrophone: async (options) => {
|
||||
deviceCalls.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await handleNativeVoiceEnginePublishMicrophone({engine}, {deviceId: 'recording-device-guid', maxBitrateBps: 96_000});
|
||||
await handleNativeVoiceEnginePublishMicrophone({engine}, {deviceId: 'recording-device-guid', maxBitrateBps: 1});
|
||||
await handleNativeVoiceEnginePublishMicrophone(
|
||||
{engine},
|
||||
{deviceId: 'recording-device-guid', maxBitrateBps: 1_000_000},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
deviceCalls.map((options) => options.maxBitrateBps),
|
||||
[96_000, MICROPHONE_MAX_BITRATE_BPS_FLOOR, MICROPHONE_MAX_BITRATE_BPS_CAP],
|
||||
);
|
||||
});
|
||||
|
||||
test('microphone publish omits maxBitrateBps from the native call when absent', async () => {
|
||||
const deviceCalls: Array<NativeMicrophonePublishOptions> = [];
|
||||
const engine = createEngine({
|
||||
publishDeviceMicrophone: async (options) => {
|
||||
deviceCalls.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await handleNativeVoiceEnginePublishMicrophone({engine}, {deviceId: 'recording-device-guid'});
|
||||
|
||||
assert.equal(deviceCalls.length, 1);
|
||||
assert.equal('maxBitrateBps' in (deviceCalls[0] ?? {}), false);
|
||||
});
|
||||
|
||||
test('microphone publish rejects invalid maxBitrateBps values', async () => {
|
||||
const engine = createEngine({
|
||||
publishDeviceMicrophone: async () => {},
|
||||
});
|
||||
|
||||
for (const maxBitrateBps of [0, -64_000, 64_000.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
await assert.rejects(
|
||||
() => handleNativeVoiceEnginePublishMicrophone({engine}, {deviceId: 'recording-device-guid', maxBitrateBps}),
|
||||
/Invalid voice-engine microphone maxBitrateBps/,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
() =>
|
||||
handleNativeVoiceEnginePublishMicrophone(
|
||||
{engine},
|
||||
{deviceId: 'recording-device-guid', maxBitrateBps: '96000' as unknown as number},
|
||||
),
|
||||
/Invalid voice-engine microphone maxBitrateBps/,
|
||||
);
|
||||
});
|
||||
|
||||
test('clampNativeMicrophoneMaxBitrateBps pair-asserts the app-side floor and cap', () => {
|
||||
assert.equal(clampNativeMicrophoneMaxBitrateBps(undefined), undefined);
|
||||
assert.equal(clampNativeMicrophoneMaxBitrateBps(MICROPHONE_MAX_BITRATE_BPS_FLOOR), MICROPHONE_MAX_BITRATE_BPS_FLOOR);
|
||||
assert.equal(clampNativeMicrophoneMaxBitrateBps(MICROPHONE_MAX_BITRATE_BPS_CAP), MICROPHONE_MAX_BITRATE_BPS_CAP);
|
||||
assert.equal(clampNativeMicrophoneMaxBitrateBps(7_999), MICROPHONE_MAX_BITRATE_BPS_FLOOR);
|
||||
assert.equal(clampNativeMicrophoneMaxBitrateBps(510_001), MICROPHONE_MAX_BITRATE_BPS_CAP);
|
||||
assert.equal(clampNativeMicrophoneMaxBitrateBps(64_000), 64_000);
|
||||
});
|
||||
|
||||
test('pcm-test microphone publish calls the legacy synthetic PCM method', async () => {
|
||||
const deviceCalls: Array<NativeMicrophonePublishOptions> = [];
|
||||
const pcmCalls: Array<[number, number]> = [];
|
||||
const engine = createEngine({
|
||||
publishMicrophone: async (sampleRate, channels) => {
|
||||
pcmCalls.push([sampleRate, channels]);
|
||||
},
|
||||
publishDeviceMicrophone: async (options) => {
|
||||
deviceCalls.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await handleNativeVoiceEnginePublishMicrophone({engine}, {mode: 'pcm-test', sampleRate: 48_000, numChannels: 2});
|
||||
|
||||
assert.deepEqual(pcmCalls, [[48_000, 2]]);
|
||||
assert.deepEqual(deviceCalls, []);
|
||||
});
|
||||
|
||||
test('audio device list handlers parse JSON strings returned by the native addon', async () => {
|
||||
const inputDevices: Array<VoiceEngineV2BridgeAudioInputDevice> = [
|
||||
{deviceId: 'input-default', label: 'Default microphone', isDefault: true},
|
||||
{deviceId: 'input-usb', label: 'USB microphone', isDefault: false},
|
||||
];
|
||||
const outputDevices: Array<VoiceEngineV2BridgeAudioOutputDevice> = [
|
||||
{deviceId: 'output-default', label: 'Default speaker', isDefault: true},
|
||||
{deviceId: 'output-headset', label: 'Headset', isDefault: false},
|
||||
];
|
||||
const engine = createAudioQueryEngine({
|
||||
listAudioInputDevices: () => JSON.stringify(inputDevices),
|
||||
listAudioOutputDevices: () => JSON.stringify(outputDevices),
|
||||
});
|
||||
|
||||
assert.deepEqual(await handleNativeVoiceEngineListAudioInputDevices(engine), inputDevices);
|
||||
assert.deepEqual(await handleNativeVoiceEngineListAudioOutputDevices(engine), outputDevices);
|
||||
});
|
||||
|
||||
test('audio device list handlers pass through arrays returned by the native addon', async () => {
|
||||
const inputDevices: Array<VoiceEngineV2BridgeAudioInputDevice> = [
|
||||
{deviceId: 'input-default', label: 'Default microphone', isDefault: true},
|
||||
];
|
||||
const outputDevices: Array<VoiceEngineV2BridgeAudioOutputDevice> = [
|
||||
{deviceId: 'output-default', label: 'Default speaker', isDefault: true},
|
||||
];
|
||||
const engine = createAudioQueryEngine({
|
||||
listAudioInputDevices: () => inputDevices,
|
||||
listAudioOutputDevices: () => outputDevices,
|
||||
});
|
||||
|
||||
assert.equal(await handleNativeVoiceEngineListAudioInputDevices(engine), inputDevices);
|
||||
assert.equal(await handleNativeVoiceEngineListAudioOutputDevices(engine), outputDevices);
|
||||
});
|
||||
|
||||
test('audio device listing throws when the native voice engine is unavailable', async () => {
|
||||
await assert.rejects(() => handleNativeVoiceEngineListAudioInputDevices(null), NativeVoiceEngineUnavailableError);
|
||||
await assert.rejects(() => handleNativeVoiceEngineListAudioOutputDevices(null), NativeVoiceEngineUnavailableError);
|
||||
});
|
||||
|
||||
test('audio device listing rejects malformed native addon JSON', async () => {
|
||||
const engine = createAudioQueryEngine({
|
||||
listAudioInputDevices: () => '{',
|
||||
listAudioOutputDevices: () => '{"devices":[]}',
|
||||
});
|
||||
|
||||
await assert.rejects(() => handleNativeVoiceEngineListAudioInputDevices(engine), /Failed to parse/);
|
||||
await assert.rejects(() => handleNativeVoiceEngineListAudioOutputDevices(engine), /must be a JSON array/);
|
||||
});
|
||||
|
||||
test('microphone publish validation rejects invalid bridge arguments', async () => {
|
||||
assert.equal(isVoiceEngineV2BridgePublishMicrophoneOptions(null), false);
|
||||
assert.equal(isVoiceEngineV2BridgePublishMicrophoneOptions({mode: 'invalid'}), false);
|
||||
assert.equal(
|
||||
isVoiceEngineV2BridgePublishMicrophoneOptions({mode: 'pcm-test', sampleRate: '48000', numChannels: 1}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isVoiceEngineV2BridgePublishMicrophoneOptions({deviceId: 7}), false);
|
||||
assert.equal(isVoiceEngineV2BridgePublishMicrophoneOptions({mode: 'device', deviceId: 'input-default'}), true);
|
||||
|
||||
await assert.rejects(
|
||||
() => handleNativeVoiceEnginePublishMicrophone(null, {mode: 'pcm-test', sampleRate: 48_000, numChannels: 1}),
|
||||
/Native voice engine is not connected/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => handleNativeVoiceEnginePublishMicrophone({engine: createEngine()}, {mode: 'pcm-test', sampleRate: 48_000}),
|
||||
/Invalid voice-engine pcm-test microphone args/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => handleNativeVoiceEnginePublishMicrophone({engine: createEngine()}, {deviceId: 'input-default'}),
|
||||
/Native voice engine addon does not support device microphone capture/,
|
||||
);
|
||||
});
|
||||
|
||||
test('camera device listing throws when the native voice engine is unavailable', async () => {
|
||||
await assert.rejects(() => handleNativeVoiceEngineListCameraDevices(null), NativeVoiceEngineUnavailableError);
|
||||
});
|
||||
|
||||
test('camera device listing returns the native device list without a connected session', async () => {
|
||||
const devices: Array<VoiceEngineV2BridgeCameraDevice> = [
|
||||
{deviceId: 'native-studio', label: 'Studio Display Camera', description: 'Apple Studio Display Camera', index: 0},
|
||||
];
|
||||
|
||||
assert.deepEqual(await handleNativeVoiceEngineListCameraDevices({listCameraDevices: () => devices}), devices);
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {
|
||||
VoiceEngineV2BridgeAudioInputDevice,
|
||||
VoiceEngineV2BridgeAudioOutputDevice,
|
||||
VoiceEngineV2BridgeCameraDevice,
|
||||
VoiceEngineV2BridgePublishMicrophoneOptions,
|
||||
} from '@fluxer/voice_engine_v2/bridge';
|
||||
|
||||
export const MICROPHONE_MAX_BITRATE_BPS_FLOOR = 8_000;
|
||||
export const MICROPHONE_MAX_BITRATE_BPS_CAP = 510_000;
|
||||
|
||||
export interface NativeMicrophonePublishOptions {
|
||||
deviceId?: string;
|
||||
echoCancellation?: boolean;
|
||||
noiseSuppression?: boolean;
|
||||
autoGainControl?: boolean;
|
||||
deepFilter?: boolean;
|
||||
deepFilterNoiseReductionLevel?: number;
|
||||
maxBitrateBps?: number;
|
||||
}
|
||||
|
||||
interface NativeVoiceEnginePublishMicrophoneArgs extends VoiceEngineV2BridgePublishMicrophoneOptions {
|
||||
maxBitrateBps?: number;
|
||||
}
|
||||
|
||||
export interface NativeVoiceEngineAudioIpcEngine {
|
||||
publishMicrophone(sampleRate: number, channels: number): Promise<void>;
|
||||
publishDeviceMicrophone?(opts: NativeMicrophonePublishOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NativeVoiceEngineAudioQueryEngine {
|
||||
listAudioInputDevices():
|
||||
| Array<VoiceEngineV2BridgeAudioInputDevice>
|
||||
| string
|
||||
| Promise<Array<VoiceEngineV2BridgeAudioInputDevice> | string>;
|
||||
listAudioOutputDevices():
|
||||
| Array<VoiceEngineV2BridgeAudioOutputDevice>
|
||||
| string
|
||||
| Promise<Array<VoiceEngineV2BridgeAudioOutputDevice> | string>;
|
||||
}
|
||||
|
||||
interface NativeVoiceEngineAudioIpcSession {
|
||||
engine: NativeVoiceEngineAudioIpcEngine;
|
||||
}
|
||||
|
||||
export class NativeVoiceEngineCapabilityError extends Error {
|
||||
readonly capability: string;
|
||||
|
||||
constructor(capability: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'NativeVoiceEngineCapabilityError';
|
||||
this.capability = capability;
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeVoiceEngineNotConnectedError extends Error {
|
||||
constructor() {
|
||||
super('Native voice engine is not connected');
|
||||
this.name = 'NativeVoiceEngineNotConnectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeVoiceEngineUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Native voice engine is unavailable');
|
||||
this.name = 'NativeVoiceEngineUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeVoiceEngineInvalidArgsError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NativeVoiceEngineInvalidArgsError';
|
||||
}
|
||||
}
|
||||
|
||||
function parseNativeJsonArray<T>(value: unknown, label: string): Array<T> {
|
||||
if (Array.isArray(value)) return value as Array<T>;
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Native voice engine ${label} must be a JSON array`);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse native voice engine ${label}: ${String(error)}`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`Native voice engine ${label} must be a JSON array`);
|
||||
}
|
||||
return parsed as Array<T>;
|
||||
}
|
||||
|
||||
function clampNativeMicrophoneDeepFilterNoiseReductionLevel(level: number | undefined): number | undefined {
|
||||
if (level === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isFinite(level)) {
|
||||
throw new NativeVoiceEngineInvalidArgsError('Invalid voice-engine microphone deepFilterNoiseReductionLevel');
|
||||
}
|
||||
const clamped = Math.min(Math.max(level, 0), 100);
|
||||
assert.ok(clamped >= 0, 'microphone deepFilterNoiseReductionLevel must be at least 0');
|
||||
assert.ok(clamped <= 100, 'microphone deepFilterNoiseReductionLevel must be at most 100');
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export function clampNativeMicrophoneMaxBitrateBps(maxBitrateBps: number | undefined): number | undefined {
|
||||
if (maxBitrateBps === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isInteger(maxBitrateBps)) {
|
||||
throw new NativeVoiceEngineInvalidArgsError('Invalid voice-engine microphone maxBitrateBps');
|
||||
}
|
||||
if (maxBitrateBps <= 0) {
|
||||
throw new NativeVoiceEngineInvalidArgsError('Invalid voice-engine microphone maxBitrateBps');
|
||||
}
|
||||
const clamped = Math.min(Math.max(maxBitrateBps, MICROPHONE_MAX_BITRATE_BPS_FLOOR), MICROPHONE_MAX_BITRATE_BPS_CAP);
|
||||
assert.ok(clamped >= MICROPHONE_MAX_BITRATE_BPS_FLOOR, 'microphone maxBitrateBps must respect the floor');
|
||||
assert.ok(clamped <= MICROPHONE_MAX_BITRATE_BPS_CAP, 'microphone maxBitrateBps must respect the cap');
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export async function handleNativeVoiceEnginePublishMicrophone(
|
||||
session: NativeVoiceEngineAudioIpcSession | null,
|
||||
args: NativeVoiceEnginePublishMicrophoneArgs,
|
||||
): Promise<void> {
|
||||
if (!session) {
|
||||
throw new NativeVoiceEngineNotConnectedError();
|
||||
}
|
||||
if (args.mode === 'pcm-test') {
|
||||
if (typeof args.sampleRate !== 'number' || typeof args.numChannels !== 'number') {
|
||||
throw new NativeVoiceEngineInvalidArgsError('Invalid voice-engine pcm-test microphone args');
|
||||
}
|
||||
await session.engine.publishMicrophone(args.sampleRate, args.numChannels);
|
||||
return;
|
||||
}
|
||||
if (typeof session.engine.publishDeviceMicrophone !== 'function') {
|
||||
throw new NativeVoiceEngineCapabilityError(
|
||||
'microphoneCapture',
|
||||
'Native voice engine addon does not support device microphone capture',
|
||||
);
|
||||
}
|
||||
const maxBitrateBps = clampNativeMicrophoneMaxBitrateBps(args.maxBitrateBps);
|
||||
const deepFilterNoiseReductionLevel = clampNativeMicrophoneDeepFilterNoiseReductionLevel(
|
||||
args.deepFilterNoiseReductionLevel,
|
||||
);
|
||||
await session.engine.publishDeviceMicrophone({
|
||||
deviceId: args.deviceId,
|
||||
echoCancellation: args.echoCancellation,
|
||||
noiseSuppression: args.noiseSuppression,
|
||||
autoGainControl: args.autoGainControl,
|
||||
...(args.deepFilter !== undefined ? {deepFilter: args.deepFilter} : {}),
|
||||
...(deepFilterNoiseReductionLevel !== undefined ? {deepFilterNoiseReductionLevel} : {}),
|
||||
...(maxBitrateBps !== undefined ? {maxBitrateBps} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
interface NativeVoiceEngineCameraQueryEngine {
|
||||
listCameraDevices(): Array<VoiceEngineV2BridgeCameraDevice> | Promise<Array<VoiceEngineV2BridgeCameraDevice>>;
|
||||
}
|
||||
|
||||
export async function handleNativeVoiceEngineListCameraDevices(
|
||||
engine: NativeVoiceEngineCameraQueryEngine | null,
|
||||
): Promise<Array<VoiceEngineV2BridgeCameraDevice>> {
|
||||
if (!engine) {
|
||||
throw new NativeVoiceEngineUnavailableError();
|
||||
}
|
||||
const devices = await engine.listCameraDevices();
|
||||
assert.ok(Array.isArray(devices), 'native voice engine listCameraDevices must return an array');
|
||||
return devices;
|
||||
}
|
||||
|
||||
export async function handleNativeVoiceEngineListAudioOutputDevices(
|
||||
engine: NativeVoiceEngineAudioQueryEngine | null,
|
||||
): Promise<Array<VoiceEngineV2BridgeAudioOutputDevice>> {
|
||||
if (!engine) {
|
||||
throw new NativeVoiceEngineUnavailableError();
|
||||
}
|
||||
return parseNativeJsonArray<VoiceEngineV2BridgeAudioOutputDevice>(
|
||||
await engine.listAudioOutputDevices(),
|
||||
'audio output devices',
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleNativeVoiceEngineListAudioInputDevices(
|
||||
engine: NativeVoiceEngineAudioQueryEngine | null,
|
||||
): Promise<Array<VoiceEngineV2BridgeAudioInputDevice>> {
|
||||
if (!engine) {
|
||||
throw new NativeVoiceEngineUnavailableError();
|
||||
}
|
||||
return parseNativeJsonArray<VoiceEngineV2BridgeAudioInputDevice>(
|
||||
await engine.listAudioInputDevices(),
|
||||
'audio input devices',
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"name": "native_voice_engine_ipc_requests",
|
||||
"cases": [
|
||||
{
|
||||
"name": "valid connect reaches the native engine",
|
||||
"channel": "connect",
|
||||
"event": "sender",
|
||||
"args": [{"url": "wss://voice.example.test", "token": "token-connect"}],
|
||||
"expected": {"result": "void", "engineCalls": {"connect": 1}}
|
||||
},
|
||||
{
|
||||
"name": "invalid connect args throw",
|
||||
"channel": "connect",
|
||||
"event": "sender",
|
||||
"args": [{"url": "wss://voice.example.test", "token": ""}],
|
||||
"expected": {"throws": "Invalid voice-engine connect args"}
|
||||
},
|
||||
{
|
||||
"name": "invalid mic enabled args return typed operation failure",
|
||||
"channel": "setMicEnabled",
|
||||
"args": ["yes"],
|
||||
"expected": {
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "invalid-args",
|
||||
"message": "Invalid voice-engine set-mic-enabled args"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "valid microphone publish returns typed success",
|
||||
"setup": "connected",
|
||||
"channel": "publishMicrophone",
|
||||
"args": [{"mode": "pcm-test", "sampleRate": 48000, "numChannels": 2}],
|
||||
"expected": {"result": {"ok": true}}
|
||||
},
|
||||
{
|
||||
"name": "invalid microphone publish returns typed operation failure",
|
||||
"channel": "publishMicrophone",
|
||||
"args": [{"mode": "pcm-test", "sampleRate": "48000", "numChannels": 2}],
|
||||
"expected": {
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "invalid-args",
|
||||
"message": "Invalid voice-engine publish-microphone args",
|
||||
"capability": "microphoneCapture"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "valid zero-copy screen publish reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "publishScreen",
|
||||
"args": [
|
||||
{
|
||||
"captureId": "capture-zero-copy",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264",
|
||||
"maxBitrateBps": 6000000,
|
||||
"maxFramerate": 60,
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
],
|
||||
"expected": {"result": "void", "engineCalls": {"publishScreenShare": 1}}
|
||||
},
|
||||
{
|
||||
"name": "explicit non-zero-copy screen publish is rejected",
|
||||
"setup": "connected",
|
||||
"channel": "publishScreen",
|
||||
"args": [
|
||||
{
|
||||
"captureId": "capture-copying",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"zeroCopyRequired": false
|
||||
}
|
||||
],
|
||||
"expected": {"throws": "requires zero-copy transport"}
|
||||
},
|
||||
{
|
||||
"name": "hardware screen encoding without zero-copy is rejected",
|
||||
"setup": "screen",
|
||||
"channel": "updateScreenShareEncoding",
|
||||
"args": [
|
||||
{
|
||||
"captureId": "fixture-screen",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": false
|
||||
}
|
||||
],
|
||||
"expected": {"throws": "requires zero-copy transport"}
|
||||
},
|
||||
{
|
||||
"name": "valid native screen audio publish reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "publishScreenAudio",
|
||||
"args": [{"sampleRate": 48000, "numChannels": 2, "route": "native", "captureId": "capture-zero-copy"}],
|
||||
"expected": {"result": "void", "engineCalls": {"publishScreenShareAudio": 1}}
|
||||
},
|
||||
{
|
||||
"name": "invalid screen audio route throws",
|
||||
"setup": "connected",
|
||||
"channel": "publishScreenAudio",
|
||||
"args": [{"sampleRate": 48000, "numChannels": 2, "route": "loopback"}],
|
||||
"expected": {"throws": "Invalid voice-engine publish-screen-audio args"}
|
||||
},
|
||||
{
|
||||
"name": "valid camera publish reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "publishCamera",
|
||||
"args": [{"deviceId": "camera-1", "width": 1280, "height": 720, "frameRate": 30}],
|
||||
"expected": {"result": "void", "engineCalls": {"publishCamera": 1}}
|
||||
},
|
||||
{
|
||||
"name": "valid native camera sink publish reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "publishNativeCameraSink",
|
||||
"args": [{"deviceId": "camera-1", "width": 1280, "height": 720, "frameRate": 30, "backgroundMode": "blur"}],
|
||||
"expected": {"result": {"trackSid": "TR_native_camera"}, "engineCalls": {"publishNativeCameraSink": 1}}
|
||||
},
|
||||
{
|
||||
"name": "valid processed camera publish reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "publishProcessedCamera",
|
||||
"args": [{"width": 1280, "height": 720, "frameRate": 30}],
|
||||
"expected": {"result": {"trackSid": "TR_processed_camera"}, "engineCalls": {"publishProcessedCamera": 1}}
|
||||
},
|
||||
{
|
||||
"name": "valid processed camera frame reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "pushProcessedCameraFrame",
|
||||
"args": [
|
||||
{"format": "i420", "width": 2, "height": 2, "timestampUs": 1, "data": {"$arrayBuffer": [1, 1, 1, 1, 2, 3]}}
|
||||
],
|
||||
"expected": {"result": true, "engineCalls": {"processedCameraFrames": 1}}
|
||||
},
|
||||
{
|
||||
"name": "invalid processed camera frame throws",
|
||||
"setup": "connected",
|
||||
"channel": "pushProcessedCameraFrame",
|
||||
"args": [{"format": "i420", "width": 2, "height": 2, "timestampUs": 1, "data": {"$arrayBuffer": [1, 2, 3]}}],
|
||||
"expected": {"throws": "Invalid voice-engine push-processed-camera-frame args"}
|
||||
},
|
||||
{
|
||||
"name": "invalid camera publish throws",
|
||||
"setup": "connected",
|
||||
"channel": "publishCamera",
|
||||
"args": [{"deviceId": "camera-1", "frameRate": 0}],
|
||||
"expected": {"throws": "Invalid voice-engine publish-camera args"}
|
||||
},
|
||||
{
|
||||
"name": "connected stats return normalized empty stats",
|
||||
"setup": "connected",
|
||||
"channel": "getConnectionStats",
|
||||
"args": [],
|
||||
"expected": {"result": {"rttMs": null, "outbound": [], "inbound": []}}
|
||||
},
|
||||
{
|
||||
"name": "valid participant volume reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "setParticipantVolume",
|
||||
"args": [{"participantSid": "participant-1", "volume": 0.75}],
|
||||
"expected": {"result": "void", "engineCalls": {"setParticipantVolume": 1}}
|
||||
},
|
||||
{
|
||||
"name": "invalid participant volume throws",
|
||||
"setup": "connected",
|
||||
"channel": "setParticipantVolume",
|
||||
"args": [{"participantSid": "", "volume": 0.75}],
|
||||
"expected": {"throws": "Invalid voice-engine set-participant-volume args"}
|
||||
},
|
||||
{
|
||||
"name": "valid remote subscription reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "setRemoteTrackSubscription",
|
||||
"args": [{"participantIdentity": "alice", "source": "screen", "subscribed": true, "quality": "high"}],
|
||||
"expected": {"result": "void", "engineCalls": {"remoteTrackSubscription": 1}}
|
||||
},
|
||||
{
|
||||
"name": "invalid remote subscription quality throws",
|
||||
"setup": "connected",
|
||||
"channel": "setRemoteTrackSubscription",
|
||||
"args": [{"participantIdentity": "alice", "source": "screen", "subscribed": true, "quality": "ultra"}],
|
||||
"expected": {"throws": "Invalid voice-engine set-remote-track-subscription args"}
|
||||
},
|
||||
{
|
||||
"name": "valid data publish reaches native engine",
|
||||
"setup": "connected",
|
||||
"channel": "publishData",
|
||||
"args": [{"payload": {"$arrayBuffer": [1, 2, 3, 4]}, "reliable": true, "topic": "fixture"}],
|
||||
"expected": {"result": "void", "engineCalls": {"publishData": 1}}
|
||||
},
|
||||
{
|
||||
"name": "invalid data payload throws",
|
||||
"setup": "connected",
|
||||
"channel": "publishData",
|
||||
"args": [{"payload": [1, 2, 3, 4], "reliable": true}],
|
||||
"expected": {"throws": "Invalid voice-engine publish-data args"}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,9 +61,12 @@ import {cleanupVirtmic, registerVirtmicHandlers} from '@electron/main/LinuxAudio
|
||||
import {initializeMainI18n} from '@electron/main/MainI18n';
|
||||
import {createApplicationMenu} from '@electron/main/Menu';
|
||||
import {cleanupNativeAudio, registerNativeAudioHandlers} from '@electron/main/NativeAudio';
|
||||
import {
|
||||
cleanupNativeHardwareEncoderHandlers,
|
||||
registerNativeHardwareEncoderHandlers,
|
||||
} from '@electron/main/NativeHardwareEncoder';
|
||||
import {runNativeModulePreflight} from '@electron/main/NativeModulePreflight';
|
||||
import {cleanupNativeScreenCapture, registerNativeScreenCaptureHandlers} from '@electron/main/NativeScreenCapture';
|
||||
import {cleanupNativeVoiceEngine, registerNativeVoiceEngineHandlers} from '@electron/main/NativeVoiceEngine';
|
||||
import {appendOpenH264Switches} from '@electron/main/OpenH264Manager';
|
||||
import {startRpcServer, stopRpcServer} from '@electron/main/RpcServer';
|
||||
import {cleanupLinuxChromiumSpellcheckDictionaries} from '@electron/main/Spellcheck';
|
||||
@@ -386,9 +389,9 @@ if (launchConfigurationError) {
|
||||
log.error('[Init] Failed to register native screen capture handlers:', error);
|
||||
}
|
||||
try {
|
||||
runStartupPhase('native-voice-engine-handlers', registerNativeVoiceEngineHandlers);
|
||||
runStartupPhase('native-hardware-encoder-handlers', registerNativeHardwareEncoderHandlers);
|
||||
} catch (error: unknown) {
|
||||
log.error('[Init] Failed to register native voice engine handlers:', error);
|
||||
log.error('[Init] Failed to register native hardware encoder handlers:', error);
|
||||
}
|
||||
try {
|
||||
runStartupPhase('application-menu', createApplicationMenu);
|
||||
@@ -472,9 +475,10 @@ if (launchConfigurationError) {
|
||||
cleanupGlobalKeyHook();
|
||||
cleanupNativeAudio();
|
||||
cleanupNativeScreenCapture();
|
||||
cleanupNativeHardwareEncoderHandlers();
|
||||
cleanupVirtmic();
|
||||
destroyDesktopTray();
|
||||
const asyncCleanups: Array<Promise<unknown>> = [cleanupNativeVoiceEngine(), stopRpcServer()];
|
||||
const asyncCleanups: Array<Promise<unknown>> = [stopRpcServer()];
|
||||
if (netLog.currentlyLogging) {
|
||||
asyncCleanups.push(
|
||||
netLog.stopLogging().catch((error) => {
|
||||
|
||||
Reference in new issue
Block a user