Files
fluxer-desktop-patched/fluxer_desktop/native/linux-audio-capture/index.js
T
brenden 682afacd30 Add native self-hosted instance connection to fluxer_desktop
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.
2026-07-01 18:22:43 -04:00

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;
},
};