Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
396 lines
12 KiB
JavaScript
396 lines
12 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
const {EventEmitter} = require('node:events');
|
|
const {existsSync, readdirSync, readFileSync} = require('node:fs');
|
|
const {join, sep} = require('node:path');
|
|
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
|
const MODULE_NAME = '@fluxer/linux-audio-capture';
|
|
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_AUDIO_CAPTURE_SKIP_NATIVE_PROBE';
|
|
|
|
function resolveNativeRoot() {
|
|
const asarSegment = `${sep}app.asar${sep}`;
|
|
if (!__dirname.includes(asarSegment)) return __dirname;
|
|
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
|
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
|
}
|
|
|
|
function nativeFileName() {
|
|
if (process.platform !== 'linux') {
|
|
throw new Error(`@fluxer/linux-audio-capture is only supported on Linux, got ${process.platform}`);
|
|
}
|
|
switch (process.arch) {
|
|
case 'x64':
|
|
return 'linux-audio-capture.linux-x64-gnu.node';
|
|
case 'arm64':
|
|
return 'linux-audio-capture.linux-arm64-gnu.node';
|
|
default:
|
|
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
|
|
}
|
|
}
|
|
|
|
let binding;
|
|
|
|
try {
|
|
const nativeRoot = resolveNativeRoot();
|
|
const nativePath = join(nativeRoot, nativeFileName());
|
|
const loadedNative = loadNativeBinding({
|
|
moduleName: MODULE_NAME,
|
|
nativePath,
|
|
nativeRoot,
|
|
packageDir: __dirname,
|
|
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
|
});
|
|
if (loadedNative.loadError) {
|
|
throw loadedNative.loadError;
|
|
}
|
|
binding = loadedNative.binding;
|
|
} catch (error) {
|
|
throw createNativeLoadError({
|
|
moduleName: MODULE_NAME,
|
|
nativeRoot: resolveNativeRoot(),
|
|
packageDir: __dirname,
|
|
reason: 'native loader threw before binding load completed',
|
|
cause: error,
|
|
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
|
|
});
|
|
}
|
|
|
|
function normalizeRoutingRule(rule) {
|
|
if (!rule || typeof rule !== 'object') return {};
|
|
const normalizeList = (value) =>
|
|
Array.isArray(value)
|
|
? value
|
|
.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry))
|
|
.map((entry) =>
|
|
Object.fromEntries(
|
|
Object.entries(entry)
|
|
.filter(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
|
|
.map(([k, v]) => [k, String(v)]),
|
|
),
|
|
)
|
|
: undefined;
|
|
return {
|
|
include: normalizeList(rule.include),
|
|
exclude: normalizeList(rule.exclude),
|
|
workaround: normalizeList(rule.workaround),
|
|
ignoreDevices: rule.ignoreDevices ?? rule.ignore_devices,
|
|
onlySpeakers: rule.onlySpeakers ?? rule.only_speakers,
|
|
onlyDefaultSpeakers: rule.onlyDefaultSpeakers ?? rule.only_default_speakers,
|
|
};
|
|
}
|
|
|
|
function readProcParentMap() {
|
|
const parents = new Map();
|
|
let entries = [];
|
|
try {
|
|
entries = readdirSync('/proc', {withFileTypes: true});
|
|
} catch {
|
|
return parents;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
try {
|
|
const stat = readFileSync(`/proc/${entry.name}/stat`, 'utf8');
|
|
const end = stat.lastIndexOf(')');
|
|
if (end < 0) continue;
|
|
const fields = stat
|
|
.slice(end + 1)
|
|
.trim()
|
|
.split(/\s+/);
|
|
const parentPid = Number(fields[1]);
|
|
if (Number.isSafeInteger(parentPid) && parentPid > 0) {
|
|
parents.set(Number(entry.name), parentPid);
|
|
}
|
|
} catch {}
|
|
}
|
|
return parents;
|
|
}
|
|
|
|
function isDescendantPid(pid, rootPid, parents) {
|
|
let current = pid;
|
|
const seen = new Set();
|
|
while (parents.has(current) && !seen.has(current)) {
|
|
seen.add(current);
|
|
const parent = parents.get(current);
|
|
if (parent === rootPid) return true;
|
|
current = parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function targetPidList(pid, includeProcessTree) {
|
|
if (!includeProcessTree) return [pid];
|
|
const parents = readProcParentMap();
|
|
const pids = [pid];
|
|
for (const candidate of parents.keys()) {
|
|
if (candidate !== pid && isDescendantPid(candidate, pid, parents)) {
|
|
pids.push(candidate);
|
|
}
|
|
}
|
|
return pids;
|
|
}
|
|
|
|
function appendUniquePattern(patterns, pattern) {
|
|
if (!pattern || typeof pattern !== 'object') return;
|
|
const entries = Object.entries(pattern).filter(([, value]) => typeof value === 'string' && value.length > 0);
|
|
if (entries.length === 0) return;
|
|
const normalized = Object.fromEntries(entries);
|
|
const key = JSON.stringify(Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b)));
|
|
if (
|
|
patterns.some((existing) => JSON.stringify(Object.entries(existing).sort(([a], [b]) => a.localeCompare(b))) === key)
|
|
) {
|
|
return;
|
|
}
|
|
patterns.push(normalized);
|
|
}
|
|
|
|
function inventoryPatternsForTargetPids(pids) {
|
|
if (!(binding && typeof binding.AudioBridge === 'function')) return [];
|
|
const wanted = new Set(pids.map((pid) => String(pid)));
|
|
const patterns = [];
|
|
let bridge = null;
|
|
try {
|
|
bridge = new binding.AudioBridge();
|
|
const inventory = bridge.inventory();
|
|
if (!Array.isArray(inventory)) return patterns;
|
|
for (const props of inventory) {
|
|
if (!props || typeof props !== 'object') continue;
|
|
if (props['media.class'] !== 'Stream/Output/Audio') continue;
|
|
const processId = props['application.process.id'] || props['pipewire.sec.pid'];
|
|
if (!wanted.has(String(processId || ''))) continue;
|
|
appendUniquePattern(patterns, {'object.serial': String(props['object.serial'] || '')});
|
|
appendUniquePattern(patterns, {'node.name': String(props['node.name'] || '')});
|
|
appendUniquePattern(patterns, {'client.id': String(props['client.id'] || '')});
|
|
}
|
|
} catch {
|
|
} finally {
|
|
try {
|
|
bridge?.release?.();
|
|
} catch {}
|
|
}
|
|
return patterns;
|
|
}
|
|
|
|
function routingRuleFromTarget(target, options) {
|
|
if (target && typeof target === 'object') {
|
|
return normalizeRoutingRule(target.linuxRule || target);
|
|
}
|
|
const pid = Number(target);
|
|
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
throw new TypeError('ProcessLoopback target pid must be a positive integer');
|
|
}
|
|
const targetPids = targetPidList(pid, Boolean(options?.includeProcessTree));
|
|
const include = inventoryPatternsForTargetPids(targetPids);
|
|
for (const candidate of targetPids) {
|
|
appendUniquePattern(include, {'application.process.id': String(candidate)});
|
|
appendUniquePattern(include, {'pipewire.sec.pid': String(candidate)});
|
|
}
|
|
return normalizeRoutingRule({
|
|
include,
|
|
ignoreDevices: options?.ignoreDevices ?? true,
|
|
});
|
|
}
|
|
|
|
const LATE_SPAWN_REFRESH_INTERVAL_MS = 2_000;
|
|
const MAX_DRAIN_FRAMES_PER_TICK = 16;
|
|
const MAX_IDLE_DIRECT_CAPTURES = 2;
|
|
const MIX_TICK_PERIOD_MS = 20;
|
|
|
|
let idleDirectCaptures = [];
|
|
|
|
function acquireDirectAudioCapture() {
|
|
if (typeof binding.DirectAudioCapture !== 'function') {
|
|
throw new Error('DirectAudioCapture native export missing');
|
|
}
|
|
const pooled = idleDirectCaptures.pop();
|
|
return pooled ?? new binding.DirectAudioCapture();
|
|
}
|
|
|
|
function releaseDirectAudioCapture(capture) {
|
|
if (!capture || idleDirectCaptures.includes(capture)) return;
|
|
if (idleDirectCaptures.length < MAX_IDLE_DIRECT_CAPTURES) {
|
|
idleDirectCaptures.push(capture);
|
|
}
|
|
}
|
|
|
|
function clearIdleDirectCapturePool() {
|
|
idleDirectCaptures = [];
|
|
}
|
|
|
|
function patternsEqual(a, b) {
|
|
if (a === b) return true;
|
|
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
if (a.length !== b.length) return false;
|
|
const serialize = (entry) =>
|
|
JSON.stringify(
|
|
Object.entries(entry)
|
|
.filter(([, v]) => typeof v === 'string')
|
|
.sort(([x], [y]) => x.localeCompare(y)),
|
|
);
|
|
const aSet = a.map(serialize).sort();
|
|
const bSet = b.map(serialize).sort();
|
|
for (let i = 0; i < aSet.length; i++) {
|
|
if (aSet[i] !== bSet[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
class ProcessLoopback extends EventEmitter {
|
|
constructor(target, options = {}) {
|
|
super();
|
|
this.capture = acquireDirectAudioCapture();
|
|
if (typeof this.capture.setLifecycleCallback === 'function') {
|
|
this.capture.setLifecycleCallback((type, message) => this.handleNativeLifecycle(type, message));
|
|
}
|
|
this.targetPid = null;
|
|
this.includeProcessTree = false;
|
|
if (!(target && typeof target === 'object')) {
|
|
const pid = Number(target);
|
|
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
this.targetPid = pid;
|
|
this.includeProcessTree = Boolean(options?.includeProcessTree);
|
|
}
|
|
}
|
|
this.rule = routingRuleFromTarget(target, options);
|
|
this.options = options;
|
|
this.timer = null;
|
|
this.refreshTimer = null;
|
|
this.closed = false;
|
|
this.started = false;
|
|
}
|
|
|
|
handleNativeLifecycle(type, message) {
|
|
if (type === 'error') {
|
|
this.emit('error', new Error(message || 'Linux direct audio capture stopped'));
|
|
if (!this.closed) void this.stop();
|
|
return;
|
|
}
|
|
if (type === 'closed' || type === 'closed-clean') {
|
|
if (!this.closed) void this.stop();
|
|
return;
|
|
}
|
|
if (type === 'diagnostic') {
|
|
this.emit('diagnostic', message || '');
|
|
}
|
|
}
|
|
|
|
start() {
|
|
if (this.closed) {
|
|
throw new Error('ProcessLoopback already closed');
|
|
}
|
|
if (this.started) return;
|
|
if (!this.capture.start(this.rule)) {
|
|
throw new Error('failed to start Linux direct audio capture');
|
|
}
|
|
this.started = true;
|
|
this.timer = setInterval(() => this.tick(), MIX_TICK_PERIOD_MS);
|
|
this.timer.unref?.();
|
|
if (this.targetPid !== null && this.includeProcessTree && typeof this.capture.setRule === 'function') {
|
|
this.refreshTimer = setInterval(() => this.refreshRuleForLateChildren(), LATE_SPAWN_REFRESH_INTERVAL_MS);
|
|
this.refreshTimer.unref?.();
|
|
}
|
|
}
|
|
|
|
refreshRuleForLateChildren() {
|
|
if (this.closed || !this.started || this.targetPid === null) return;
|
|
let nextRule;
|
|
try {
|
|
nextRule = routingRuleFromTarget(this.targetPid, {
|
|
...(this.options ?? {}),
|
|
includeProcessTree: this.includeProcessTree,
|
|
});
|
|
} catch {
|
|
return;
|
|
}
|
|
if (patternsEqual(nextRule.include, this.rule.include) && patternsEqual(nextRule.exclude, this.rule.exclude)) {
|
|
return;
|
|
}
|
|
this.rule = nextRule;
|
|
try {
|
|
this.capture.setRule(this.rule);
|
|
} catch {}
|
|
}
|
|
|
|
tick() {
|
|
if (this.closed || !this.started) return;
|
|
try {
|
|
this.drainCaptureFrames();
|
|
} catch (error) {
|
|
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
void this.stop();
|
|
}
|
|
}
|
|
|
|
drainCaptureFrames() {
|
|
for (let i = 0; i < MAX_DRAIN_FRAMES_PER_TICK; i++) {
|
|
const frame = this.capture.read();
|
|
if (!frame || !(frame.samples instanceof ArrayBuffer) || frame.samples.byteLength === 0) return;
|
|
this.emit('frame', {
|
|
samples: new Float32Array(frame.samples),
|
|
sampleRate: frame.sampleRate,
|
|
channels: frame.channels,
|
|
timestampUs: frame.timestampUs,
|
|
});
|
|
}
|
|
}
|
|
|
|
routingGraph() {
|
|
return this.capture && typeof this.capture.routingGraph === 'function' ? this.capture.routingGraph() : null;
|
|
}
|
|
|
|
setScreenAudioSink(handle) {
|
|
if (!this.capture || typeof this.capture.setScreenAudioSink !== 'function') return false;
|
|
try {
|
|
return this.capture.setScreenAudioSink(handle) !== false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
clearScreenAudioSink() {
|
|
if (this.capture && typeof this.capture.clearScreenAudioSink === 'function') {
|
|
this.capture.clearScreenAudioSink();
|
|
}
|
|
}
|
|
|
|
async stop() {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
this.clearScreenAudioSink();
|
|
if (this.timer) clearInterval(this.timer);
|
|
this.timer = null;
|
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
this.refreshTimer = null;
|
|
const wasStarted = this.started;
|
|
this.started = false;
|
|
const capture = this.capture;
|
|
this.capture = null;
|
|
let stopped = false;
|
|
try {
|
|
capture?.stop();
|
|
stopped = true;
|
|
} finally {
|
|
if (stopped && wasStarted) {
|
|
releaseDirectAudioCapture(capture);
|
|
}
|
|
this.emit('closed');
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
AudioBridge: binding.AudioBridge,
|
|
DirectAudioCapture: binding.DirectAudioCapture,
|
|
AudioMixRuntimeHandle: binding.AudioMixRuntimeHandle,
|
|
ProcessLoopback,
|
|
pipeWireAvailable: binding.pipeWireAvailable,
|
|
audioBackend: binding.audioBackend ?? (() => (binding.pipeWireAvailable?.() ? 'pipewire' : 'none')),
|
|
__setBindingForTests(nextBinding) {
|
|
binding = nextBinding;
|
|
clearIdleDirectCapturePool();
|
|
},
|
|
__getIdleDirectCaptureCountForTests() {
|
|
return idleDirectCaptures.length;
|
|
},
|
|
};
|