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.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
[package]
name = "fluxer_linux_audio_capture"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
napi-derive = "3.5.6"
fluxer_rt_thread = {path = "../rt-thread"}
fluxer_audio_mix = {path = "../audio-mix"}
fluxer_audio_apm = {path = "../audio-apm"}
fluxer_audio_timing = {path = "../audio-timing"}
fluxer_screen_frame_bus = {path = "../screen-frame-bus"}
[target.'cfg(target_os = "linux")'.dependencies]
pipewire = "0.10.0"
[build-dependencies]
napi-build = "2.3.2"
[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "end_to_end"
harness = false
@@ -0,0 +1,18 @@
{
"measured_at": "85e057a273fd",
"host": "darwin-arm64-apple-silicon",
"regression_budget_percent": 5.0,
"criterion_args": {
"warm_up_time_sec": 2,
"measurement_time_sec": 5
},
"benches": {
"linux_audio_end_to_end/8_sources_capture_ring_mix_policy": {
"median_ns": 2453.7,
"low_ns": 2428.6,
"high_ns": 2479.5,
"budget_percent_override": 8.0,
"note": "Sub-microsecond mix-runtime tick; same noise floor logic as audio-mix/mix_tick. Pure-Rust bench helpers; no PipeWire process required."
}
}
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_audio_mix::{AUDIO_OUTPUT_FRAMES, SourceRing, SourceRingProducer};
use fluxer_linux_audio_capture::audio_mix_runtime_bench_helpers::{
AudioMixRuntimeBuilder, CaptureSource, MIX_CHANNELS, MIX_SAMPLE_RATE_HZ, MIX_TICK_PERIOD_NS,
NullMixOutputSink,
};
const BENCH_SOURCE_COUNT: usize = 8;
fn build_sources_and_runtime() -> (
Vec<CaptureSource>,
fluxer_linux_audio_capture::audio_mix_runtime_bench_helpers::AudioMixRuntime,
) {
let mut sources = Vec::with_capacity(BENCH_SOURCE_COUNT);
let mut builder = AudioMixRuntimeBuilder::new();
for n in 0..BENCH_SOURCE_COUNT {
let (source, consumer) =
CaptureSource::create(n as u64 + 1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS).expect("source");
sources.push(source);
builder = builder.add_source(n as u64 + 1, consumer);
}
let runtime = builder.build(NullMixOutputSink).expect("build");
(sources, runtime)
}
fn fill_sources(sources: &mut [CaptureSource], frames: usize) {
assert!(!sources.is_empty());
assert!(frames > 0);
let payload: Vec<i16> = (0..frames).map(|n| ((n as i16) % 4096) - 2048).collect();
for source in sources.iter_mut() {
let _pushed = source.ingest_skip_apm(&payload);
}
}
fn bench_end_to_end_tick(c: &mut Criterion) {
let mut group = c.benchmark_group("linux_audio_end_to_end");
group.sample_size(50);
group.bench_function("8_sources_capture_ring_mix_policy", |b| {
let (mut sources, mut runtime) = build_sources_and_runtime();
let mut tick_index: u64 = 0;
b.iter(|| {
fill_sources(&mut sources, AUDIO_OUTPUT_FRAMES);
let frame = runtime
.run_one_tick_blocking(tick_index * MIX_TICK_PERIOD_NS)
.expect("frame");
tick_index = tick_index.wrapping_add(1);
black_box(frame);
});
});
group.finish();
}
#[allow(dead_code)]
fn _producer_helper(producer: SourceRingProducer) -> SourceRingProducer {
producer
}
#[allow(dead_code)]
fn _ring_helper() -> usize {
let _: usize = SourceRing::create(8192, 48_000).map(|_| 0).unwrap_or(0);
0
}
criterion_group!(benches, bench_end_to_end_tick);
criterion_main!(benches);
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+126
View File
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EventEmitter} from 'node:events';
export interface JsRoutingRule {
include?: Array<Record<string, string>>;
exclude?: Array<Record<string, string>>;
workaround?: Array<Record<string, string>>;
ignoreDevices?: boolean;
onlySpeakers?: boolean;
onlyDefaultSpeakers?: boolean;
}
export interface AudioFrame {
samples: Float32Array;
sampleRate: number;
channels: number;
timestampUs: number;
}
export interface NativeAudioFrame {
samples: ArrayBuffer;
sampleRate: number;
channels: number;
timestampUs: number;
}
export interface RoutingGraphNode {
id: number;
props: Record<string, string>;
}
export interface RoutingGraphPort {
id: number;
nodeId: number;
direction: string;
channel: string;
props: Record<string, string>;
}
export interface RoutingGraphLink {
outputNodeId: number;
outputPortId: number;
inputNodeId: number;
inputPortId: number;
owned: boolean;
passive: boolean;
}
export interface RoutingGraph {
backend: 'pipewire' | 'none' | string;
nodes: Array<RoutingGraphNode>;
ports: Array<RoutingGraphPort>;
ownedLinks: Array<RoutingGraphLink>;
}
export declare function pipeWireAvailable(): boolean;
export declare function audioBackend(): 'pipewire' | 'none';
export declare class AudioBridge {
constructor();
inventory(fields?: Array<string> | undefined | null): Array<Record<string, string>>;
routingGraph(): RoutingGraph;
apply(rule: JsRoutingRule): boolean;
release(): void;
backend(): 'pipewire' | 'none';
}
export declare class DirectAudioCapture {
constructor();
start(rule: JsRoutingRule): boolean;
setRule(rule: JsRoutingRule): boolean;
setLifecycleCallback(callback: (type: string, message: string) => void): void;
read(): NativeAudioFrame | null;
routingGraph(): RoutingGraph;
stop(): void;
}
export declare class AudioMixRuntimeHandle {
constructor(sourceCount: number);
static boundToDirectCapture(capture: DirectAudioCapture): AudioMixRuntimeHandle;
sourceCount(): number;
tick(tickAtNs?: number | null): number;
markPushedTotal(): number;
dispose(): void;
}
interface ProcessLoopbackEvents {
on(event: 'frame', listener: (frame: AudioFrame) => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'closed', listener: () => void): this;
on(event: 'diagnostic', listener: (message: string) => void): this;
removeListener(event: 'frame', listener: (frame: AudioFrame) => void): this;
removeListener(event: 'error', listener: (error: Error) => void): this;
removeListener(event: 'closed', listener: () => void): this;
removeListener(event: 'diagnostic', listener: (message: string) => void): this;
}
export declare class ProcessLoopback extends EventEmitter implements ProcessLoopbackEvents {
constructor(targetPid: number, options?: {includeProcessTree?: boolean; ignoreDevices?: boolean});
constructor(options: {linuxRule: JsRoutingRule});
start(): void;
routingGraph(): RoutingGraph | null;
stop(): Promise<void>;
}
@@ -0,0 +1,395 @@
// 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;
},
};
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {test} from 'node:test';
const requireSrc = createRequire(import.meta.url);
const modulePath = requireSrc.resolve('./index.js');
function freshModule() {
delete requireSrc.cache[modulePath];
return requireSrc('./index.js');
}
function makeFakeBinding() {
const captures = [];
let startResult = true;
class FakeDirectAudioCapture {
constructor() {
this.started = false;
this.stopCount = 0;
this.startRules = [];
this.lifecycleCallback = undefined;
captures.push(this);
}
setLifecycleCallback(callback) {
this.lifecycleCallback = callback;
}
start(rule) {
if (!startResult) return false;
this.started = true;
this.startRules.push(rule);
return true;
}
setRule(rule) {
this.startRules.push(rule);
return true;
}
read() {
return null;
}
stop() {
this.started = false;
this.stopCount += 1;
}
}
function FakeAudioMixRuntimeHandle() {}
FakeAudioMixRuntimeHandle.boundToDirectCapture = () => {
throw new Error('ProcessLoopback must not tick a discard-only mix runtime');
};
return {
binding: {
AudioBridge: class {},
AudioMixRuntimeHandle: FakeAudioMixRuntimeHandle,
DirectAudioCapture: FakeDirectAudioCapture,
pipeWireAvailable: () => true,
audioBackend: () => 'pipewire',
},
captures,
setStartResult(value) {
startResult = value;
},
};
}
let loadError = null;
try {
freshModule();
} catch (error) {
loadError = error;
}
test('ProcessLoopback reuses idle direct captures after stop', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding, captures} = makeFakeBinding();
mod.__setBindingForTests(binding);
const rule = {linuxRule: {include: [{'application.name': 'Firefox'}]}};
const first = new mod.ProcessLoopback(rule);
first.start();
await first.stop();
assert.equal(captures.length, 1);
assert.equal(captures[0].stopCount, 1);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 1);
const second = new mod.ProcessLoopback(rule);
second.start();
await second.stop();
assert.equal(captures.length, 1);
assert.equal(captures[0].stopCount, 2);
assert.equal(captures[0].startRules.length, 2);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 1);
});
test('ProcessLoopback does not pool failed direct captures', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding, captures, setStartResult} = makeFakeBinding();
mod.__setBindingForTests(binding);
const rule = {linuxRule: {include: [{'application.name': 'Firefox'}]}};
setStartResult(false);
const first = new mod.ProcessLoopback(rule);
assert.throws(() => first.start(), /failed to start Linux direct audio capture/);
await first.stop();
assert.equal(captures.length, 1);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 0);
setStartResult(true);
const second = new mod.ProcessLoopback(rule);
second.start();
await second.stop();
assert.equal(captures.length, 2);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 1);
});
test('ProcessLoopback installs native lifecycle callback on direct capture', {skip: loadError?.message}, () => {
const mod = freshModule();
const {binding, captures} = makeFakeBinding();
mod.__setBindingForTests(binding);
const loopback = new mod.ProcessLoopback({linuxRule: {include: [{'application.name': 'Firefox'}]}});
assert.equal(captures.length, 1);
assert.equal(typeof captures[0].lifecycleCallback, 'function');
assert.equal(loopback.listenerCount('closed'), 0);
});
test('ProcessLoopback does not create a discard-only audio mix runtime', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding} = makeFakeBinding();
mod.__setBindingForTests(binding);
const loopback = new mod.ProcessLoopback({linuxRule: {include: [{'application.name': 'Firefox'}]}});
loopback.start();
await new Promise((resolve) => setImmediate(resolve));
await loopback.stop();
});
test('ProcessLoopback closes once when native lifecycle closes while idle', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding, captures} = makeFakeBinding();
mod.__setBindingForTests(binding);
const loopback = new mod.ProcessLoopback({linuxRule: {include: [{'application.name': 'Firefox'}]}});
let closed = 0;
loopback.on('closed', () => {
closed += 1;
});
captures[0].lifecycleCallback('closed-clean', 'daemon disconnected');
await new Promise((resolve) => setImmediate(resolve));
await loopback.stop();
assert.equal(closed, 1);
assert.equal(captures[0].stopCount, 1);
});
@@ -0,0 +1,524 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
const os = require('node:os');
const {basename} = require('node:path');
const {spawnSync} = require('node:child_process');
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
const MAX_TEXT_LENGTH = 6000;
const MAX_DIRECTORY_ENTRIES = 80;
function trimText(value, limit = MAX_TEXT_LENGTH) {
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
const trimmed = text.trim();
if (!trimmed) return null;
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
}
function errorDiagnostic(error) {
if (!error) return null;
if (error instanceof Error) {
return {
name: error.name || 'Error',
message: error.message,
code: error.code || null,
stack: trimText(error.stack || error.message),
};
}
return {
name: typeof error,
message: trimText(String(error)),
code: null,
stack: null,
};
}
function formatErrorDiagnostic(diagnostic) {
if (!diagnostic) return null;
const lines = [];
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
if (diagnostic.stack) lines.push(diagnostic.stack);
else if (diagnostic.message) lines.push(diagnostic.message);
return trimText(lines.join('\n'));
}
function fileDiagnostic(filePath) {
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
try {
const stat = statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mode: `0${(stat.mode & 0o777).toString(8)}`,
mtime: stat.mtime.toISOString(),
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return {path: filePath, exists: false, error: reason};
}
}
function formatFileDiagnostic(diagnostic) {
if (!diagnostic) return 'not resolved';
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
return [
`exists=true`,
`size=${diagnostic.size}`,
`mode=${diagnostic.mode}`,
`mtime=${diagnostic.mtime}`,
`isFile=${diagnostic.isFile}`,
].join(', ');
}
function directoryDiagnostic(dirPath) {
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
try {
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
}));
entries.sort((a, b) => a.name.localeCompare(b.name));
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
return {
path: dirPath,
ok: true,
entries: visible,
total: entries.length,
omitted: Math.max(0, entries.length - visible.length),
};
} catch (error) {
return {
path: dirPath,
ok: false,
error: error instanceof Error ? error.message : String(error),
entries: [],
total: 0,
omitted: 0,
};
}
}
function formatDirectoryDiagnostic(diagnostic) {
if (!diagnostic) return '<unavailable>';
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
return [...entries, ...suffix].join('\n') || '<empty>';
}
function selectedEnvironmentNames(skipNativeProbeEnv) {
const names = [
'ELECTRON_RUN_AS_NODE',
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
'LD_LIBRARY_PATH',
'DYLD_LIBRARY_PATH',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_TYPE',
'DBUS_SESSION_BUS_ADDRESS',
'PULSE_SERVER',
'PIPEWIRE_REMOTE',
'PATH',
];
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
return names;
}
function environmentDiagnostics(skipNativeProbeEnv) {
return Object.fromEntries(
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
);
}
function formatEnvironment(diagnostic) {
return Object.entries(diagnostic)
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
.join('\n');
}
function runtimeDiagnostics() {
const versions = process.versions || {};
let reportHeader = null;
if (process.report && typeof process.report.getReport === 'function') {
try {
reportHeader = process.report.getReport().header || null;
} catch {
reportHeader = null;
}
}
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
return {
node: versions.node || null,
electron: versions.electron || null,
modules: versions.modules || null,
napi: versions.napi || null,
v8: versions.v8 || null,
uv: versions.uv || null,
openssl: versions.openssl || null,
glibcRuntime,
glibcCompiler,
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: typeof os.version === 'function' ? os.version() : null,
execPath: process.execPath,
resourcesPath: process.resourcesPath || null,
cwd: process.cwd(),
};
}
function formatRuntimeDiagnostics(diagnostic) {
return [
`node=${diagnostic.node || '<unknown>'}`,
`electron=${diagnostic.electron || '<none>'}`,
`modules=${diagnostic.modules || '<unknown>'}`,
`napi=${diagnostic.napi || '<unknown>'}`,
`v8=${diagnostic.v8 || '<unknown>'}`,
`uv=${diagnostic.uv || '<unknown>'}`,
`openssl=${diagnostic.openssl || '<unknown>'}`,
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
`process=${diagnostic.platform}/${diagnostic.arch}`,
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
`execPath=${diagnostic.execPath}`,
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
`cwd=${diagnostic.cwd}`,
].join('\n');
}
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
/^vcruntime\d+(?:_\d+)?\.dll$/i,
/^msvcp\d+(?:_\d+)?\.dll$/i,
/^msvcr\d+(?:_\d+)?\.dll$/i,
/^concrt\d+\.dll$/i,
/^vcamp\d+\.dll$/i,
/^vcomp\d+\.dll$/i,
];
function readPeImports(filePath) {
let buffer;
try {
buffer = readFileSync(filePath);
} catch {
return null;
}
if (buffer.length < 0x40) return null;
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
const coffOffset = peOffset + 4;
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
const optionalHeaderOffset = coffOffset + 20;
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
const magic = buffer.readUInt16LE(optionalHeaderOffset);
if (magic !== 0x10b && magic !== 0x20b) return null;
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
const importEntryOffset = dataDirectoriesOffset + 8;
if (importEntryOffset + 8 > buffer.length) return null;
const importRva = buffer.readUInt32LE(importEntryOffset);
if (importRva === 0) return [];
const sections = [];
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
for (let i = 0; i < numberOfSections; i++) {
const base = sectionTableOffset + i * 40;
if (base + 40 > buffer.length) return null;
sections.push({
virtualSize: buffer.readUInt32LE(base + 8),
virtualAddress: buffer.readUInt32LE(base + 12),
rawSize: buffer.readUInt32LE(base + 16),
rawPointer: buffer.readUInt32LE(base + 20),
});
}
const rvaToOffset = (rva) => {
for (const s of sections) {
const span = Math.max(s.virtualSize, s.rawSize);
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
return rva - s.virtualAddress + s.rawPointer;
}
}
return -1;
};
const readCString = (offset) => {
let end = offset;
while (end < buffer.length && buffer[end] !== 0) end++;
return buffer.toString('ascii', offset, end);
};
const importTableOffset = rvaToOffset(importRva);
if (importTableOffset < 0) return [];
const imports = new Set();
for (let i = 0; i < 1024; i++) {
const base = importTableOffset + i * 20;
if (base + 20 > buffer.length) break;
const lookupRva = buffer.readUInt32LE(base);
const nameRva = buffer.readUInt32LE(base + 12);
const iatRva = buffer.readUInt32LE(base + 16);
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
const nameOffset = rvaToOffset(nameRva);
if (nameOffset < 0) continue;
const name = readCString(nameOffset);
if (name) imports.add(name);
}
return Array.from(imports);
}
function windowsImportProbe(nativePath) {
const imports = readPeImports(nativePath);
if (imports === null) return null;
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
const redistributable = sortedImports.filter((dll) =>
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
);
return {
command: ['pe-imports', nativePath],
status: 0,
signal: null,
error: null,
stdout: sortedImports.join('\n') || null,
stderr: null,
missing: [],
redistributable,
};
}
function dependencyProbe(nativePath) {
if (!nativePath || !existsSync(nativePath)) return null;
if (process.platform === 'win32') return windowsImportProbe(nativePath);
const command =
process.platform === 'linux'
? ['ldd', nativePath]
: process.platform === 'darwin'
? ['otool', '-L', nativePath]
: null;
if (!command) return null;
const [bin, ...args] = command;
const result = spawnSync(bin, args, {
encoding: 'utf8',
timeout: 4000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = trimText(result.stdout);
const stderr = trimText(result.stderr);
const missing =
process.platform === 'linux' && stdout
? stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('not found'))
: [];
return {
command,
status: result.status,
signal: result.signal || null,
error: result.error ? result.error.message : null,
stdout,
stderr,
missing,
redistributable: [],
};
}
function formatDependencyProbe(diagnostic) {
if (!diagnostic) return null;
const status = diagnostic.error
? `error=${diagnostic.error}`
: diagnostic.signal
? `signal=${diagnostic.signal}`
: `status=${diagnostic.status}`;
return [
`$ ${diagnostic.command.join(' ')}`,
status,
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
diagnostic.redistributable?.length
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
: null,
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatExtraDiagnostic(diagnostic) {
if (!diagnostic) return null;
if (typeof diagnostic === 'string') return diagnostic;
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
return `${diagnostic.name}:\n${diagnostic.text}`;
}
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
}
function collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
return {
schemaVersion: 1,
moduleName,
reason,
target: {
platform: process.platform,
arch: process.arch,
},
packageDir: packageDir || null,
nativeRoot: nativeRoot || null,
nativePath: nativePath || null,
nativeFile: nativePath ? basename(nativePath) : null,
nativeFileStat: fileDiagnostic(nativePath),
runtime: runtimeDiagnostics(),
environment: environmentDiagnostics(skipNativeProbeEnv),
nativeRootEntries: directoryDiagnostic(nativeRoot),
dependencyProbe: dependencyProbe(nativePath),
extraDiagnostics: extraDiagnostics.filter(Boolean),
cause: errorDiagnostic(cause),
};
}
function formatNativeDiagnostics(diagnostics) {
const sections = [
`module=${diagnostics.moduleName}`,
`reason=${diagnostics.reason}`,
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
`environment:\n${formatEnvironment(diagnostics.environment)}`,
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
];
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
const causeText = formatErrorDiagnostic(diagnostics.cause);
if (causeText) sections.push(`cause:\n${causeText}`);
return sections.join('\n');
}
function isNativeLoadError(error) {
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
}
function createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
if (isNativeLoadError(cause)) return cause;
const diagnostics = collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics,
});
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
error.name = 'NativeModuleLoadError';
error[NATIVE_LOAD_ERROR_MARKER] = true;
error.nativeDiagnostics = diagnostics;
error.toJSON = () => ({
name: error.name,
message: error.message,
nativeDiagnostics: diagnostics,
});
if (cause) error.cause = cause;
return error;
}
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
return null;
}
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: timeoutMs,
});
if (result.status === 0) return null;
const reason = result.error
? result.error.message
: result.signal
? `safety probe terminated by signal ${result.signal}`
: `safety probe exited with code ${result.status}`;
return createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
skipNativeProbeEnv,
extraDiagnostics: [
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
],
});
}
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
if (!existsSync(nativePath)) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'native binary not found',
skipNativeProbeEnv,
}),
};
}
const nativeProbeError = probe
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
: null;
if (nativeProbeError) {
return {binding: null, loadError: nativeProbeError};
}
try {
return {binding: require(nativePath), loadError: null};
} catch (error) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'require(nativePath) threw',
cause: error,
skipNativeProbeEnv,
}),
};
}
}
module.exports = {
collectNativeDiagnostics,
createNativeLoadError,
formatNativeDiagnostics,
isNativeLoadError,
loadNativeBinding,
probeNativeBinary,
};
@@ -0,0 +1,27 @@
{
"name": "@fluxer/linux-audio-capture",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"linux"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"linux-audio-capture.linux-x64-gnu.node",
"linux-audio-capture.linux-arm64-gnu.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "cargo test --manifest-path Cargo.toml"
}
}
@@ -0,0 +1,147 @@
#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const DIRECT_CAPTURE_SAMPLE_RATE: u32 = 48_000;
pub const DIRECT_CAPTURE_CHANNELS: u32 = 2;
pub const DIRECT_CAPTURE_LATENCY_FRAMES: u32 = 4_096;
pub const DIRECT_CAPTURE_READ_CHUNK_US: u32 = 20_000;
pub const DIRECT_CAPTURE_MAX_SAMPLES: usize =
DIRECT_CAPTURE_SAMPLE_RATE as usize * DIRECT_CAPTURE_CHANNELS as usize * 2;
pub const DIRECT_CAPTURE_MAX_READ_SAMPLES: usize =
DIRECT_CAPTURE_SAMPLE_RATE as usize * DIRECT_CAPTURE_CHANNELS as usize / 50;
pub const MAX_ROUTING_RULE_PATTERNS: u32 = 64;
pub const MAX_ROUTING_RULE_KEYS_PER_PATTERN: u32 = 32;
pub const MAX_ROUTING_RULE_KEY_LENGTH: usize = 128;
pub const MAX_ROUTING_RULE_VALUE_LENGTH: usize = 512;
pub const MAX_INVENTORY_FIELDS: u32 = 32;
pub const MAX_INVENTORY_FIELD_LENGTH: usize = 128;
pub fn whole_frame_sample_count(sample_count: usize, channels: u32) -> usize {
if channels == 0 {
return 0;
}
let channel_count = channels as usize;
sample_count - (sample_count % channel_count)
}
pub fn direct_whole_frame_sample_count(sample_count: usize) -> usize {
whole_frame_sample_count(sample_count, DIRECT_CAPTURE_CHANNELS)
}
pub fn bounded_direct_read_sample_count(available: usize) -> usize {
bounded_direct_read_sample_count_for_format(
available,
DIRECT_CAPTURE_SAMPLE_RATE,
DIRECT_CAPTURE_CHANNELS,
)
}
pub fn sample_count_for_duration_us(sample_rate: u32, channels: u32, duration_us: u32) -> usize {
if sample_rate == 0 || channels == 0 || duration_us == 0 {
return 0;
}
let frames = ((u128::from(sample_rate) * u128::from(duration_us)) / 1_000_000).max(1) as usize;
frames.saturating_mul(channels as usize)
}
pub fn duration_us_for_sample_count(sample_count: usize, sample_rate: u32, channels: u32) -> i64 {
if sample_rate == 0 || channels == 0 {
return 0;
}
let frames = sample_count / channels as usize;
((frames as u128 * 1_000_000) / u128::from(sample_rate)).min(i64::MAX as u128) as i64
}
pub fn bounded_direct_read_sample_count_for_format(
available: usize,
sample_rate: u32,
channels: u32,
) -> usize {
let max = sample_count_for_duration_us(sample_rate, channels, DIRECT_CAPTURE_READ_CHUNK_US);
whole_frame_sample_count(available.min(max), channels)
}
pub fn bounded_direct_append_slice(input: &[f32]) -> &[f32] {
let whole = direct_whole_frame_sample_count(input.len());
let framed = &input[..whole];
if framed.len() > DIRECT_CAPTURE_MAX_SAMPLES {
&framed[framed.len() - DIRECT_CAPTURE_MAX_SAMPLES..]
} else {
framed
}
}
pub fn direct_capture_latency_fraction() -> String {
format!("{DIRECT_CAPTURE_LATENCY_FRAMES}/{DIRECT_CAPTURE_SAMPLE_RATE}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whole_frame_sample_count_trims_incomplete_channel_frames() {
assert_eq!(0, whole_frame_sample_count(1, 2));
assert_eq!(2, whole_frame_sample_count(2, 2));
assert_eq!(4, whole_frame_sample_count(5, 2));
assert_eq!(6, whole_frame_sample_count(7, 3));
assert_eq!(0, whole_frame_sample_count(7, 0));
}
#[test]
fn direct_read_count_is_bounded_and_stereo_aligned() {
assert_eq!(0, bounded_direct_read_sample_count(1));
assert_eq!(2, bounded_direct_read_sample_count(3));
assert_eq!(
DIRECT_CAPTURE_MAX_READ_SAMPLES,
bounded_direct_read_sample_count(DIRECT_CAPTURE_MAX_READ_SAMPLES + 1),
);
}
#[test]
fn direct_read_count_uses_stable_twenty_ms_chunks() {
assert_eq!(1_920, DIRECT_CAPTURE_MAX_READ_SAMPLES);
assert_eq!(
1_920,
bounded_direct_read_sample_count_for_format(9_600, 48_000, 2)
);
assert_eq!(
1_764,
bounded_direct_read_sample_count_for_format(9_600, 44_100, 2)
);
}
#[test]
fn sample_duration_conversion_uses_whole_audio_frames() {
assert_eq!(1_920, sample_count_for_duration_us(48_000, 2, 20_000));
assert_eq!(20_000, duration_us_for_sample_count(1_920, 48_000, 2));
assert_eq!(0, sample_count_for_duration_us(0, 2, 20_000));
assert_eq!(0, duration_us_for_sample_count(1_920, 0, 2));
}
#[test]
fn direct_append_slice_keeps_only_complete_stereo_samples_within_queue_cap() {
let samples: [f32; 5] = [1.0, 2.0, 3.0, 4.0, 5.0];
let trimmed = bounded_direct_append_slice(&samples);
assert_eq!(4, trimmed.len());
assert_eq!(&samples[..4], trimmed);
}
#[test]
fn direct_capture_latency_matches_stable_screen_share_buffer() {
assert_eq!("4096/48000", direct_capture_latency_fraction());
}
#[test]
fn routing_parser_caps_are_intentionally_finite() {
const {
assert!(MAX_ROUTING_RULE_PATTERNS > 0);
assert!(MAX_ROUTING_RULE_KEYS_PER_PATTERN > 0);
assert!(MAX_ROUTING_RULE_KEY_LENGTH > 0);
assert!(MAX_ROUTING_RULE_VALUE_LENGTH >= MAX_ROUTING_RULE_KEY_LENGTH);
assert!(MAX_INVENTORY_FIELDS <= MAX_ROUTING_RULE_KEYS_PER_PATTERN);
assert!(MAX_INVENTORY_FIELD_LENGTH <= MAX_ROUTING_RULE_KEY_LENGTH);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::routing::{PropMap, RoutingRule, SelfIdentity};
#[derive(Default, Clone)]
pub struct RoutingGraphSnapshot {
pub backend: String,
pub nodes: Vec<RoutingGraphNode>,
pub ports: Vec<RoutingGraphPort>,
pub owned_links: Vec<RoutingGraphLink>,
}
#[derive(Default, Clone)]
pub struct RoutingGraphNode {
pub id: u32,
pub props: PropMap,
}
#[derive(Default, Clone)]
pub struct RoutingGraphPort {
pub id: u32,
pub node_id: u32,
pub direction: String,
pub channel: String,
pub props: PropMap,
}
#[derive(Default, Clone, Copy)]
pub struct RoutingGraphLink {
pub output_node_id: u32,
pub output_port_id: u32,
pub input_node_id: u32,
pub input_port_id: u32,
}
pub trait CaptureBridge: Send + Sync {
fn inventory(&self) -> Vec<PropMap>;
fn apply(&self, rule: RoutingRule) -> bool;
fn release(&self);
fn populate_self_identity(&self, identity: SelfIdentity);
fn backend_name(&self) -> &'static str;
fn routing_graph(&self) -> RoutingGraphSnapshot {
RoutingGraphSnapshot {
backend: self.backend_name().to_string(),
..Default::default()
}
}
}
pub trait DirectCapture: Send + Sync {
fn start(&self, rule: RoutingRule) -> bool;
fn set_rule(&self, rule: RoutingRule) -> bool;
fn read(&self) -> Option<CapturedFrame>;
fn stop(&self);
fn populate_self_identity(&self, identity: SelfIdentity);
fn set_screen_audio_sink(
&self,
_sink: std::sync::Arc<fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef>,
) {
}
fn clear_screen_audio_sink(&self) {}
fn routing_graph(&self) -> RoutingGraphSnapshot {
RoutingGraphSnapshot::default()
}
fn last_push_ns_arc(&self) -> Option<std::sync::Arc<std::sync::atomic::AtomicU64>> {
None
}
}
pub struct CapturedFrame {
pub samples: Vec<f32>,
pub sample_rate: u32,
pub channels: u32,
pub timestamp_us: i64,
}
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::VecDeque;
use crate::audio_contract::{
self, DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_MAX_SAMPLES, DIRECT_CAPTURE_SAMPLE_RATE,
};
use crate::backend::CapturedFrame;
pub struct DirectReadMeta {
pub sample_rate: u32,
pub channels: u32,
pub timestamp_us: i64,
}
pub struct DirectAudioBuffer {
samples: VecDeque<f32>,
queue_start_us: i64,
sample_rate: u32,
channels: u32,
}
impl DirectAudioBuffer {
pub fn new(sample_rate: u32, channels: u32) -> Self {
Self {
samples: VecDeque::with_capacity(DIRECT_CAPTURE_MAX_SAMPLES),
queue_start_us: 0,
sample_rate: sample_rate.max(1),
channels: channels.max(1),
}
}
pub fn default_format() -> Self {
Self::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.samples.len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
#[cfg(test)]
pub fn queue_start_us(&self) -> i64 {
self.queue_start_us
}
pub fn set_format(&mut self, sample_rate: u32, channels: u32) {
let sample_rate = sample_rate.max(1);
let channels = channels.max(1);
if self.sample_rate != sample_rate || self.channels != channels {
self.clear();
}
self.sample_rate = sample_rate;
self.channels = channels;
}
pub fn clear(&mut self) {
self.samples.clear();
self.queue_start_us = 0;
}
pub fn push(&mut self, input: &[f32], end_timestamp_us: i64) {
let whole = audio_contract::whole_frame_sample_count(input.len(), self.channels);
if whole == 0 {
return;
}
let mut frame = &input[..whole];
if frame.len() > DIRECT_CAPTURE_MAX_SAMPLES {
let keep =
audio_contract::whole_frame_sample_count(DIRECT_CAPTURE_MAX_SAMPLES, self.channels);
frame = &frame[frame.len() - keep..];
self.clear();
}
if self.samples.is_empty() {
let duration_us = audio_contract::duration_us_for_sample_count(
frame.len(),
self.sample_rate,
self.channels,
);
self.queue_start_us = end_timestamp_us.saturating_sub(duration_us);
}
self.drop_for_incoming(frame.len());
self.samples.extend(frame.iter().copied());
}
pub fn read(&mut self) -> Option<CapturedFrame> {
let mut out = Vec::with_capacity(audio_contract::DIRECT_CAPTURE_MAX_READ_SAMPLES);
let meta = self.read_into(&mut out)?;
Some(CapturedFrame {
samples: out,
sample_rate: meta.sample_rate,
channels: meta.channels,
timestamp_us: meta.timestamp_us,
})
}
pub fn read_into(&mut self, out: &mut Vec<f32>) -> Option<DirectReadMeta> {
assert!(self.sample_rate >= 1);
assert!(self.channels >= 1);
out.clear();
if self.samples.is_empty() {
return None;
}
let take = audio_contract::bounded_direct_read_sample_count_for_format(
self.samples.len(),
self.sample_rate,
self.channels,
);
if take == 0 {
return None;
}
assert!(take <= self.samples.len());
assert!(take.is_multiple_of(self.channels as usize));
let timestamp_us = self.queue_start_us.max(0);
let (front, back) = self.samples.as_slices();
let front_take = take.min(front.len());
out.extend_from_slice(&front[..front_take]);
out.extend_from_slice(&back[..take - front_take]);
self.samples.drain(..take);
self.queue_start_us =
self.queue_start_us
.saturating_add(audio_contract::duration_us_for_sample_count(
take,
self.sample_rate,
self.channels,
));
if self.samples.is_empty() {
self.queue_start_us = 0;
}
Some(DirectReadMeta {
sample_rate: self.sample_rate,
channels: self.channels,
timestamp_us,
})
}
fn drop_for_incoming(&mut self, incoming: usize) {
assert!(incoming >= 1);
assert!(incoming <= DIRECT_CAPTURE_MAX_SAMPLES);
assert!(self.channels >= 1);
let total = self.samples.len() + incoming;
if total <= DIRECT_CAPTURE_MAX_SAMPLES {
return;
}
let overflow = total - DIRECT_CAPTURE_MAX_SAMPLES;
let channels = self.channels as usize;
let remainder = overflow % channels;
let drop = if remainder == 0 {
overflow
} else {
overflow + (channels - remainder)
}
.min(self.samples.len());
self.samples.drain(..drop);
self.queue_start_us =
self.queue_start_us
.saturating_add(audio_contract::duration_us_for_sample_count(
drop,
self.sample_rate,
self.channels,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_emits_stable_twenty_ms_chunks_with_continuous_timestamps() {
let mut buffer = DirectAudioBuffer::default_format();
buffer.push(&vec![0.5; 4_800], 1_000_000);
let first = buffer.read().expect("first chunk");
assert_eq!(1_920, first.samples.len());
assert_eq!(950_000, first.timestamp_us);
let second = buffer.read().expect("second chunk");
assert_eq!(1_920, second.samples.len());
assert_eq!(970_000, second.timestamp_us);
}
#[test]
fn format_changes_clear_queued_samples() {
let mut buffer = DirectAudioBuffer::default_format();
buffer.push(&vec![0.5; 1_920], 100_000);
assert!(!buffer.is_empty());
buffer.set_format(44_100, 2);
assert!(buffer.is_empty());
assert_eq!(0, buffer.queue_start_us());
buffer.push(&vec![0.25; 1_764], 200_000);
let frame = buffer.read().expect("chunk");
assert_eq!(1_764, frame.samples.len());
assert_eq!(180_000, frame.timestamp_us);
assert_eq!(44_100, frame.sample_rate);
}
#[test]
fn overflow_drops_from_front_and_advances_timestamp() {
let mut buffer = DirectAudioBuffer::default_format();
buffer.push(&vec![0.5; DIRECT_CAPTURE_MAX_SAMPLES + 1_920], 3_000_000);
assert_eq!(DIRECT_CAPTURE_MAX_SAMPLES, buffer.len());
let frame = buffer.read().expect("chunk");
assert_eq!(1_000_000, frame.timestamp_us);
}
}
@@ -0,0 +1,425 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::BTreeMap;
pub const AUDIO_BUFFERING_MAX_TICKS: u32 = 64;
pub const SOURCE_RESET_AFTER_BUFFERED_TICKS: u32 = 128;
pub const SOURCE_STALE_AFTER_NS: u64 = 5_000_000_000;
pub const NEVER_PUSHED_SENTINEL: u64 = u64::MAX;
pub fn compute_source_age_ns(last_push_ns: u64, registered_at_ns: u64, now_ns: u64) -> u64 {
let baseline_ns = if last_push_ns == NEVER_PUSHED_SENTINEL {
registered_at_ns
} else {
last_push_ns
};
assert!(baseline_ns != NEVER_PUSHED_SENTINEL);
if now_ns <= baseline_ns {
return 0;
}
now_ns - baseline_ns
}
pub const SAMPLE_RATE_HZ_MIN: u32 = 8_000;
pub const SAMPLE_RATE_HZ_MAX: u32 = 384_000;
pub const TICK_PERIOD_NS_MIN: u64 = 1_000_000;
pub const TICK_PERIOD_NS_MAX: u64 = 100_000_000;
pub const BUFFERED_FRAMES_MAX: u64 = 1 << 28;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IgnoreAudioDecision {
Mix,
IgnoreThisTick,
ResetSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IgnoreAudioResetReason {
BufferOverflow,
StaleSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IgnoreAudioSourceState {
pub id: u64,
pub buffered_frames: u64,
pub last_frame_age_ns: u64,
pub is_muted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioTick {
pub at_ns: u64,
pub period_ns: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioMetrics {
pub ignored_tick_count: u64,
pub reset_count: u64,
}
impl IgnoreAudioMetrics {
pub const ZERO: IgnoreAudioMetrics = IgnoreAudioMetrics {
ignored_tick_count: 0,
reset_count: 0,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioSourceResetEvent {
pub source_id: u64,
pub at_ns: u64,
pub buffered_frames_at_reset: u64,
pub last_frame_age_ns: u64,
pub reason: IgnoreAudioResetReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioEvaluation {
pub decision: IgnoreAudioDecision,
pub event: Option<IgnoreAudioSourceResetEvent>,
}
#[derive(Debug, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
pub enum IgnoreAudioError {
SampleRateOutOfRange { sample_rate_hz: u32 },
TickPeriodOutOfRange { period_ns: u64 },
BufferedFramesOutOfRange { buffered_frames: u64 },
}
pub struct IgnoreAudioPolicy {
sample_rate_hz: u32,
metrics_by_source: BTreeMap<u64, IgnoreAudioMetrics>,
}
impl IgnoreAudioPolicy {
pub fn new(sample_rate_hz: u32) -> Result<Self, IgnoreAudioError> {
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
return Err(IgnoreAudioError::SampleRateOutOfRange { sample_rate_hz });
}
assert!(sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
assert!(sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
Ok(Self {
sample_rate_hz,
metrics_by_source: BTreeMap::new(),
})
}
pub fn sample_rate_hz(&self) -> u32 {
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
assert!(self.sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
self.sample_rate_hz
}
pub fn metrics_for(&self, source_id: u64) -> IgnoreAudioMetrics {
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
self.metrics_by_source
.get(&source_id)
.copied()
.unwrap_or(IgnoreAudioMetrics::ZERO)
}
pub fn evaluate(
&mut self,
source_state: &IgnoreAudioSourceState,
tick: IgnoreAudioTick,
) -> Result<IgnoreAudioEvaluation, IgnoreAudioError> {
validate_source_state(source_state)?;
validate_tick(tick)?;
let tick_frames = compute_tick_frames(tick.period_ns, self.sample_rate_hz)?;
assert!(tick_frames >= 1);
let decision = decide(source_state, tick_frames);
let event = self.apply_decision(source_state, tick, decision);
let evaluation = IgnoreAudioEvaluation { decision, event };
assert_evaluation_invariant(&evaluation);
Ok(evaluation)
}
fn apply_decision(
&mut self,
source_state: &IgnoreAudioSourceState,
tick: IgnoreAudioTick,
decision: IgnoreAudioDecision,
) -> Option<IgnoreAudioSourceResetEvent> {
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
match decision {
IgnoreAudioDecision::Mix => None,
IgnoreAudioDecision::IgnoreThisTick => {
let entry = self
.metrics_by_source
.entry(source_state.id)
.or_insert(IgnoreAudioMetrics::ZERO);
entry.ignored_tick_count = entry.ignored_tick_count.saturating_add(1);
None
}
IgnoreAudioDecision::ResetSource => {
let entry = self
.metrics_by_source
.entry(source_state.id)
.or_insert(IgnoreAudioMetrics::ZERO);
entry.reset_count = entry.reset_count.saturating_add(1);
let reason = if source_state.last_frame_age_ns > SOURCE_STALE_AFTER_NS {
IgnoreAudioResetReason::StaleSource
} else {
IgnoreAudioResetReason::BufferOverflow
};
Some(IgnoreAudioSourceResetEvent {
source_id: source_state.id,
at_ns: tick.at_ns,
buffered_frames_at_reset: source_state.buffered_frames,
last_frame_age_ns: source_state.last_frame_age_ns,
reason,
})
}
}
}
}
pub fn compute_tick_frames(period_ns: u64, sample_rate_hz: u32) -> Result<u64, IgnoreAudioError> {
if !(TICK_PERIOD_NS_MIN..=TICK_PERIOD_NS_MAX).contains(&period_ns) {
return Err(IgnoreAudioError::TickPeriodOutOfRange { period_ns });
}
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
return Err(IgnoreAudioError::SampleRateOutOfRange { sample_rate_hz });
}
let product: u128 = (period_ns as u128) * (sample_rate_hz as u128);
let frames = (product / 1_000_000_000u128) as u64;
let frames = frames.max(1);
assert!(frames >= 1);
assert!(frames <= BUFFERED_FRAMES_MAX);
Ok(frames)
}
fn decide(source_state: &IgnoreAudioSourceState, tick_frames: u64) -> IgnoreAudioDecision {
assert!(tick_frames >= 1);
if source_state.is_muted {
return IgnoreAudioDecision::Mix;
}
let reset_by_stale = source_state.last_frame_age_ns > SOURCE_STALE_AFTER_NS;
let reset_by_buffer =
source_state.buffered_frames > (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * tick_frames;
if reset_by_stale {
return IgnoreAudioDecision::ResetSource;
}
if reset_by_buffer {
return IgnoreAudioDecision::ResetSource;
}
if source_state.buffered_frames <= tick_frames {
return IgnoreAudioDecision::Mix;
}
let over_buffering =
source_state.buffered_frames > (AUDIO_BUFFERING_MAX_TICKS as u64) * tick_frames;
if over_buffering {
return IgnoreAudioDecision::IgnoreThisTick;
}
IgnoreAudioDecision::Mix
}
fn validate_source_state(state: &IgnoreAudioSourceState) -> Result<(), IgnoreAudioError> {
if state.buffered_frames > BUFFERED_FRAMES_MAX {
return Err(IgnoreAudioError::BufferedFramesOutOfRange {
buffered_frames: state.buffered_frames,
});
}
Ok(())
}
fn validate_tick(tick: IgnoreAudioTick) -> Result<(), IgnoreAudioError> {
if !(TICK_PERIOD_NS_MIN..=TICK_PERIOD_NS_MAX).contains(&tick.period_ns) {
return Err(IgnoreAudioError::TickPeriodOutOfRange {
period_ns: tick.period_ns,
});
}
Ok(())
}
fn assert_evaluation_invariant(evaluation: &IgnoreAudioEvaluation) {
match evaluation.decision {
IgnoreAudioDecision::ResetSource => {
assert!(evaluation.event.is_some());
}
_ => {
assert!(evaluation.event.is_none());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canonical_tick(at_ns: u64) -> IgnoreAudioTick {
IgnoreAudioTick {
at_ns,
period_ns: 21_333_333,
}
}
#[test]
fn rejects_sample_rate_below_min() {
let err = IgnoreAudioPolicy::new(4_000).err();
assert!(matches!(
err,
Some(IgnoreAudioError::SampleRateOutOfRange { .. })
));
}
#[test]
fn rejects_sample_rate_above_max() {
let err = IgnoreAudioPolicy::new(500_000).err();
assert!(matches!(
err,
Some(IgnoreAudioError::SampleRateOutOfRange { .. })
));
}
#[test]
fn compute_tick_frames_at_48k_21_3ms_yields_1023_ish() {
let frames = compute_tick_frames(21_333_333, 48_000).expect("ok");
assert!(frames >= 1023);
assert!(frames <= 1024);
}
#[test]
fn empty_buffer_yields_mix_decision() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 1,
buffered_frames: 0,
last_frame_age_ns: 0,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::Mix);
assert!(evaluation.event.is_none());
}
#[test]
fn muted_source_always_yields_mix_decision() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 5,
buffered_frames: 10_000_000,
last_frame_age_ns: 0,
is_muted: true,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::Mix);
}
#[test]
fn over_buffered_yields_ignore_this_tick() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 9,
buffered_frames: (AUDIO_BUFFERING_MAX_TICKS as u64) * 1024 + 1,
last_frame_age_ns: 0,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::IgnoreThisTick);
assert_eq!(policy.metrics_for(9).ignored_tick_count, 1);
}
#[test]
fn over_threshold_buffer_triggers_reset() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 12,
buffered_frames: (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * 1024 + 1,
last_frame_age_ns: 0,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(7)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::ResetSource);
let event = evaluation.event.expect("reset emits event");
assert_eq!(event.source_id, 12);
assert_eq!(event.at_ns, 7);
assert_eq!(event.reason, IgnoreAudioResetReason::BufferOverflow);
assert_eq!(policy.metrics_for(12).reset_count, 1);
}
#[test]
fn stale_source_triggers_reset_with_stale_reason() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 1,
buffered_frames: 0,
last_frame_age_ns: SOURCE_STALE_AFTER_NS + 1,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::ResetSource);
let event = evaluation.event.expect("reset emits event");
assert_eq!(event.reason, IgnoreAudioResetReason::StaleSource);
}
#[test]
fn period_too_small_rejected() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 1,
buffered_frames: 0,
last_frame_age_ns: 0,
is_muted: false,
};
let err = policy
.evaluate(
&state,
IgnoreAudioTick {
at_ns: 0,
period_ns: 0,
},
)
.err();
assert!(matches!(
err,
Some(IgnoreAudioError::TickPeriodOutOfRange { .. })
));
}
#[test]
fn compute_source_age_uses_registration_for_never_pushed() {
let registered_at_ns = 1_000_000;
let now_ns = 1_000_000 + 6_000_000_000;
let age = compute_source_age_ns(NEVER_PUSHED_SENTINEL, registered_at_ns, now_ns);
assert_eq!(age, 6_000_000_000);
}
#[test]
fn compute_source_age_uses_last_push_after_first_push() {
let registered_at_ns = 1_000;
let last_push_ns = 5_000;
let age = compute_source_age_ns(last_push_ns, registered_at_ns, 9_000);
assert_eq!(age, 4_000);
}
#[test]
fn compute_source_age_zero_when_now_before_baseline() {
let age = compute_source_age_ns(NEVER_PUSHED_SENTINEL, 5_000, 1_000);
assert_eq!(age, 0);
}
#[test]
fn metrics_accumulate_across_calls() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 2,
buffered_frames: (AUDIO_BUFFERING_MAX_TICKS as u64) * 1024 + 1,
last_frame_age_ns: 0,
is_muted: false,
};
for _ in 0..5 {
let _ = policy.evaluate(&state, canonical_tick(0));
}
assert_eq!(policy.metrics_for(2).ignored_tick_count, 5);
}
}
@@ -0,0 +1,863 @@
#![allow(clippy::too_many_lines)]
// SPDX-License-Identifier: AGPL-3.0-or-later
mod audio_contract;
mod audio_mix_runtime;
mod backend;
mod direct_buffer;
mod ignore_audio_runtime;
#[doc(hidden)]
pub mod audio_mix_runtime_bench_helpers {
pub use crate::audio_mix_runtime::{
AudioMixRuntime, AudioMixRuntimeBuilder, CaptureSource, CapturedMixOutputSink,
MIX_CHANNELS, MIX_SAMPLE_RATE_HZ, MIX_TICK_PERIOD_NS, MixOutputFrame, MixOutputSink,
MixRuntimeError, NullMixOutputSink, SOURCE_RING_CAP_FRAMES,
};
}
#[doc(hidden)]
pub mod ignore_audio_bench_helpers {
pub use crate::ignore_audio_runtime::{
AUDIO_BUFFERING_MAX_TICKS, IgnoreAudioDecision, IgnoreAudioEvaluation, IgnoreAudioMetrics,
IgnoreAudioPolicy, IgnoreAudioResetReason, IgnoreAudioSourceResetEvent,
IgnoreAudioSourceState, IgnoreAudioTick, SOURCE_RESET_AFTER_BUFFERED_TICKS,
SOURCE_STALE_AFTER_NS,
};
}
#[cfg(target_os = "linux")]
mod pipewire;
#[cfg(target_os = "linux")]
mod pipewire_bridge;
mod routing;
#[cfg(target_os = "linux")]
mod self_identity;
use std::ptr;
use std::sync::Arc;
use std::sync::Mutex;
use fluxer_screen_frame_bus::{NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef};
use napi::Env;
use napi::JsValue;
use napi::Status;
use napi::bindgen_prelude::{
Array, ArrayBuffer, Error, Function, Object, Result, Unknown, ValueType,
};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use napi_derive::napi;
use crate::audio_contract::{
MAX_INVENTORY_FIELD_LENGTH, MAX_INVENTORY_FIELDS, MAX_ROUTING_RULE_KEY_LENGTH,
MAX_ROUTING_RULE_KEYS_PER_PATTERN, MAX_ROUTING_RULE_PATTERNS, MAX_ROUTING_RULE_VALUE_LENGTH,
};
use crate::backend::{
CaptureBridge as CaptureBridgeTrait, DirectCapture as DirectCaptureTrait, RoutingGraphSnapshot,
};
use crate::routing::{PropMap, PropPattern, RoutingRule, SelfIdentity};
type LifecycleTsfn =
Arc<ThreadsafeFunction<(String, String), (), (String, String), Status, false, false, 8>>;
#[cfg(target_os = "linux")]
fn make_self_identity() -> SelfIdentity {
let mut id = SelfIdentity::default();
self_identity::populate_self_identity(&mut id);
id
}
#[cfg(not(target_os = "linux"))]
#[allow(dead_code)]
fn make_self_identity() -> SelfIdentity {
SelfIdentity::default()
}
#[cfg(target_os = "linux")]
fn open_capture_backend() -> Option<(Box<dyn CaptureBridgeTrait>, &'static str)> {
if let Some(bridge) = pipewire_bridge::PipeWireBridge::open() {
bridge.populate_self_identity(make_self_identity());
return Some((Box::new(bridge), "pipewire"));
}
None
}
#[cfg(not(target_os = "linux"))]
fn open_capture_backend() -> Option<(Box<dyn CaptureBridgeTrait>, &'static str)> {
None
}
#[cfg(target_os = "linux")]
fn open_direct_backend() -> Option<Box<dyn DirectCaptureTrait>> {
if let Some(direct) = pipewire_bridge::PipeWireDirectCapture::open() {
direct.populate_self_identity(make_self_identity());
return Some(Box::new(direct));
}
None
}
#[cfg(not(target_os = "linux"))]
fn open_direct_backend() -> Option<Box<dyn DirectCaptureTrait>> {
None
}
#[cfg(target_os = "linux")]
fn pipewire_reachable() -> bool {
pipewire_bridge::daemon_reachable()
}
#[cfg(not(target_os = "linux"))]
fn pipewire_reachable() -> bool {
false
}
#[napi(js_name = "pipeWireAvailable")]
pub fn pipe_wire_available() -> bool {
pipewire_reachable()
}
#[napi(js_name = "audioBackend")]
pub fn audio_backend() -> &'static str {
if pipewire_reachable() {
"pipewire"
} else {
"none"
}
}
#[napi]
pub struct AudioBridge {
backend: Mutex<Option<Box<dyn CaptureBridgeTrait>>>,
name: &'static str,
}
#[napi]
impl AudioBridge {
#[napi(constructor)]
pub fn new() -> Self {
match open_capture_backend() {
Some((backend, name)) => Self {
backend: Mutex::new(Some(backend)),
name,
},
None => Self {
backend: Mutex::new(None),
name: "none",
},
}
}
#[napi]
pub fn inventory(&self, fields: Option<Vec<String>>) -> Result<Vec<PropMapWire>> {
let fields = match fields {
Some(values) => validate_inventory_fields(values)?,
None => Vec::new(),
};
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
let snapshot = guard.as_ref().map(|b| b.inventory()).unwrap_or_default();
Ok(snapshot
.into_iter()
.map(|entry| project_inventory_entry(entry, &fields))
.collect())
}
#[napi]
pub fn apply(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.apply(parsed)))
}
#[napi]
pub fn release(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.release();
}
Ok(())
}
#[napi(js_name = "routingGraph")]
pub fn routing_graph(&self) -> Result<RoutingGraphWire> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
let graph = guard
.as_ref()
.map(|b| b.routing_graph())
.unwrap_or_default();
Ok(RoutingGraphWire(graph))
}
#[napi]
pub fn backend(&self) -> &'static str {
self.name
}
}
impl Default for AudioBridge {
fn default() -> Self {
Self::new()
}
}
fn retain_screen_audio_sink_handle(
value: Unknown<'_>,
) -> Result<Arc<NativeScreenFrameSinkHandleRef>> {
if value.get_type()? != ValueType::External {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink expects a native external sink handle",
));
}
let raw_value = value.value();
let mut data: *mut std::ffi::c_void = ptr::null_mut();
let status =
unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) };
if status != napi::sys::Status::napi_ok || data.is_null() {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink received an empty native external sink handle",
));
}
let handle = unsafe {
NativeScreenFrameSinkHandle::retain_from_raw(data.cast::<NativeScreenFrameSinkHandle>())
}
.ok_or_else(|| {
generic_error("DirectAudioCapture.setScreenAudioSink received an invalid handle")
})?;
Ok(Arc::new(handle))
}
#[napi]
pub struct DirectAudioCapture {
backend: Mutex<Option<Box<dyn DirectCaptureTrait>>>,
lifecycle_tsfn: Mutex<Option<LifecycleTsfn>>,
}
#[napi]
impl DirectAudioCapture {
#[napi(constructor)]
pub fn new() -> Self {
Self {
backend: Mutex::new(open_direct_backend()),
lifecycle_tsfn: Mutex::new(None),
}
}
#[napi(js_name = "setLifecycleCallback")]
pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> {
let tsfn: LifecycleTsfn = Arc::new(
callback
.build_threadsafe_function::<(String, String)>()
.max_queue_size::<8>()
.build_callback(|ctx| Ok(ctx.value))?,
);
let mut guard = self
.lifecycle_tsfn
.lock()
.map_err(|_| generic_error("DirectAudioCapture lifecycle poisoned"))?;
*guard = Some(tsfn);
Ok(())
}
#[napi]
pub fn start(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.start(parsed)))
}
#[napi]
pub fn set_rule(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.set_rule(parsed)))
}
#[napi]
pub fn read<'env>(&self, env: &'env Env) -> Result<Option<NativeAudioFrame<'env>>> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let Some(backend) = guard.as_ref() else {
return Ok(None);
};
let Some(frame) = backend.read() else {
return Ok(None);
};
let arraybuffer = audio_samples_to_arraybuffer(env, &frame.samples)?;
Ok(Some(NativeAudioFrame {
samples: arraybuffer,
sample_rate: frame.sample_rate,
channels: frame.channels,
timestamp_us: frame.timestamp_us.max(0) as f64,
}))
}
#[napi(js_name = "setScreenAudioSink")]
pub fn set_screen_audio_sink(&self, sink_handle: Unknown<'_>) -> Result<()> {
let sink = retain_screen_audio_sink_handle(sink_handle)?;
if !sink.supports_screen_audio() {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink handle does not support screen audio",
));
}
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.set_screen_audio_sink(sink);
}
Ok(())
}
#[napi(js_name = "clearScreenAudioSink")]
pub fn clear_screen_audio_sink(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.clear_screen_audio_sink();
}
Ok(())
}
#[napi]
pub fn stop(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.stop();
}
drop(guard);
self.emit_lifecycle("closed-clean", "direct audio capture stopped");
Ok(())
}
#[napi(js_name = "routingGraph")]
pub fn routing_graph(&self) -> Result<RoutingGraphWire> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let graph = guard
.as_ref()
.map(|b| b.routing_graph())
.unwrap_or_default();
Ok(RoutingGraphWire(graph))
}
}
impl Default for DirectAudioCapture {
fn default() -> Self {
Self::new()
}
}
impl DirectAudioCapture {
fn emit_lifecycle(&self, kind: &str, message: &str) {
let tsfn = self
.lifecycle_tsfn
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned());
let Some(tsfn) = tsfn else {
return;
};
let _: Status = tsfn.call(
(kind.to_string(), message.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
}
#[napi]
pub struct AudioMixRuntimeHandle {
inner: Mutex<Option<crate::audio_mix_runtime::AudioMixRuntime>>,
source_count: u32,
mark_pushed_total: Arc<std::sync::atomic::AtomicU64>,
}
#[napi]
impl AudioMixRuntimeHandle {
#[napi(constructor)]
pub fn new(source_count: u32) -> Result<Self> {
Self::build(source_count, None)
}
#[napi(factory, js_name = "boundToDirectCapture")]
pub fn bound_to_direct_capture(direct: &DirectAudioCapture) -> Result<Self> {
let arc = direct_capture_freshness(direct)?;
Self::build(1, Some(arc))
}
fn build(
source_count: u32,
bound_freshness: Option<Arc<std::sync::atomic::AtomicU64>>,
) -> Result<Self> {
if source_count == 0 {
return Err(invalid_arg(
"AudioMixRuntimeHandle requires at least 1 source",
));
}
if source_count as usize > fluxer_audio_mix::MAX_MIX_SOURCES {
return Err(invalid_arg("AudioMixRuntimeHandle exceeds MAX_MIX_SOURCES"));
}
let clock: Arc<dyn fluxer_rt_thread::MonotonicClock> =
Arc::new(fluxer_rt_thread::SystemMonotonicClock::new());
let mut builder =
crate::audio_mix_runtime::AudioMixRuntimeBuilder::new().with_clock(Arc::clone(&clock));
for index in 0..source_count {
let source_id = (index as u64) + 1;
let (_source, consumer) = crate::audio_mix_runtime::CaptureSource::create(
source_id,
crate::audio_mix_runtime::MIX_SAMPLE_RATE_HZ,
crate::audio_mix_runtime::MIX_CHANNELS,
)
.map_err(|_| generic_error("CaptureSource::create failed"))?;
let freshness = if index == 0 {
match &bound_freshness {
Some(arc) => Arc::clone(arc),
None => Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX)),
}
} else {
Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX))
};
builder = builder.add_source_with_freshness(source_id, consumer, freshness);
}
let runtime = builder
.build(crate::audio_mix_runtime::NullMixOutputSink)
.map_err(|_| generic_error("AudioMixRuntimeBuilder::build failed"))?;
let mark_pushed_total = runtime.mark_pushed_total_arc();
Ok(Self {
inner: Mutex::new(Some(runtime)),
source_count,
mark_pushed_total,
})
}
#[napi(js_name = "sourceCount")]
pub fn source_count_js(&self) -> u32 {
assert!(self.source_count > 0);
assert!(self.source_count as usize <= fluxer_audio_mix::MAX_MIX_SOURCES);
self.source_count
}
#[napi]
pub fn tick(&self, tick_at_ns: Option<i64>) -> Result<u32> {
use fluxer_rt_thread::MonotonicClock as _;
assert!(self.source_count > 0);
let mut guard = self
.inner
.lock()
.map_err(|_| generic_error("AudioMixRuntimeHandle poisoned"))?;
let runtime = guard
.as_mut()
.ok_or_else(|| generic_error("AudioMixRuntimeHandle disposed"))?;
let at_ns: u64 = match tick_at_ns {
Some(v) if v > 0 => v as u64,
_ => fluxer_rt_thread::SystemMonotonicClock::new().now_ns(),
};
assert!(at_ns > 0);
let marked = runtime
.observe_source_pushes_without_mix(at_ns)
.map_err(|_| generic_error("AudioMixRuntime tick failed"))?;
Ok(marked.min(u32::MAX as u64) as u32)
}
#[napi(js_name = "markPushedTotal")]
pub fn mark_pushed_total_js(&self) -> u32 {
assert!(self.source_count > 0);
let value = self
.mark_pushed_total
.load(std::sync::atomic::Ordering::Acquire);
let clamped = value.min(u32::MAX as u64);
assert!(clamped <= u32::MAX as u64);
clamped as u32
}
#[napi]
pub fn dispose(&self) -> Result<()> {
assert!(self.source_count > 0);
assert!(self.source_count as usize <= fluxer_audio_mix::MAX_MIX_SOURCES);
let mut guard = self
.inner
.lock()
.map_err(|_| generic_error("AudioMixRuntimeHandle poisoned"))?;
guard.take();
Ok(())
}
}
fn direct_capture_freshness(
direct: &DirectAudioCapture,
) -> Result<Arc<std::sync::atomic::AtomicU64>> {
let guard = direct
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let backend = guard
.as_ref()
.ok_or_else(|| generic_error("DirectAudioCapture backend unavailable"))?;
backend
.last_push_ns_arc()
.ok_or_else(|| generic_error("DirectAudioCapture backend lacks freshness atomic"))
}
#[napi(object)]
pub struct NativeAudioFrame<'env> {
pub samples: ArrayBuffer<'env>,
#[napi(js_name = "sampleRate")]
pub sample_rate: u32,
pub channels: u32,
#[napi(js_name = "timestampUs")]
pub timestamp_us: f64,
}
pub struct PropMapWire(pub PropMap);
impl napi::bindgen_prelude::ToNapiValue for PropMapWire {
unsafe fn to_napi_value(
raw_env: napi::sys::napi_env,
value: Self,
) -> Result<napi::sys::napi_value> {
let env = napi::Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
for (key, val) in value.0 {
object.set(&key, val)?;
}
unsafe {
<Object<'_> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(raw_env, object)
}
}
}
pub struct RoutingGraphWire(pub RoutingGraphSnapshot);
impl napi::bindgen_prelude::ToNapiValue for RoutingGraphWire {
unsafe fn to_napi_value(
raw_env: napi::sys::napi_env,
value: Self,
) -> Result<napi::sys::napi_value> {
let env = napi::Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
object.set("backend", value.0.backend)?;
object.set("nodes", routing_graph_nodes_to_array(&env, value.0.nodes)?)?;
object.set("ports", routing_graph_ports_to_array(&env, value.0.ports)?)?;
object.set(
"ownedLinks",
routing_graph_links_to_array(&env, value.0.owned_links)?,
)?;
unsafe {
<Object<'_> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(raw_env, object)
}
}
}
fn prop_map_to_object<'env>(env: &'env Env, props: PropMap) -> Result<Object<'env>> {
let mut object = Object::new(env)?;
for (key, value) in props {
object.set(&key, value)?;
}
Ok(object)
}
fn routing_graph_nodes_to_array<'env>(
env: &'env Env,
nodes: Vec<crate::backend::RoutingGraphNode>,
) -> Result<Array<'env>> {
let mut array = env.create_array(nodes.len() as u32)?;
for (index, node) in nodes.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("id", node.id)?;
object.set("props", prop_map_to_object(env, node.props)?)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn routing_graph_ports_to_array<'env>(
env: &'env Env,
ports: Vec<crate::backend::RoutingGraphPort>,
) -> Result<Array<'env>> {
let mut array = env.create_array(ports.len() as u32)?;
for (index, port) in ports.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("id", port.id)?;
object.set("nodeId", port.node_id)?;
object.set("direction", port.direction)?;
object.set("channel", port.channel)?;
object.set("props", prop_map_to_object(env, port.props)?)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn routing_graph_links_to_array<'env>(
env: &'env Env,
links: Vec<crate::backend::RoutingGraphLink>,
) -> Result<Array<'env>> {
let mut array = env.create_array(links.len() as u32)?;
for (index, link) in links.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("outputNodeId", link.output_node_id)?;
object.set("outputPortId", link.output_port_id)?;
object.set("inputNodeId", link.input_node_id)?;
object.set("inputPortId", link.input_port_id)?;
object.set("owned", true)?;
object.set("passive", true)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn project_inventory_entry(mut entry: PropMap, fields: &[String]) -> PropMapWire {
if fields.is_empty() {
return PropMapWire(entry);
}
let mut filtered = PropMap::with_capacity(fields.len());
for field in fields {
if let Some(value) = entry.remove(field) {
filtered.insert(field.clone(), value);
}
}
PropMapWire(filtered)
}
fn audio_samples_to_arraybuffer<'env>(
env: &'env Env,
samples: &[f32],
) -> Result<ArrayBuffer<'env>> {
let bytes: Vec<u8> = samples
.iter()
.flat_map(|sample| sample.to_le_bytes())
.collect();
ArrayBuffer::from_data(env, bytes)
}
fn validate_inventory_fields(values: Vec<String>) -> Result<Vec<String>> {
if values.len() as u32 > MAX_INVENTORY_FIELDS {
return Err(invalid_arg("too many inventory fields"));
}
for value in &values {
if value.len() > MAX_INVENTORY_FIELD_LENGTH {
return Err(invalid_arg("inventory field exceeds length cap"));
}
}
Ok(values)
}
fn parse_routing_rule(value: &Object) -> Result<RoutingRule> {
Ok(RoutingRule {
include_when: parse_pattern_list(value, "include")?,
never_when: parse_pattern_list(value, "exclude")?,
pin_target_for: parse_pattern_list(value, "workaround")?,
skip_hardware_devices: read_optional_bool(value, "ignoreDevices")?
.or(read_optional_bool(value, "ignore_devices")?)
.unwrap_or(false),
only_audio_sinks: read_optional_bool(value, "onlySpeakers")?
.or(read_optional_bool(value, "only_speakers")?)
.unwrap_or(false),
only_default_audio_sink: read_optional_bool(value, "onlyDefaultSpeakers")?
.or(read_optional_bool(value, "only_default_speakers")?)
.unwrap_or(false),
})
}
fn parse_pattern_list(value: &Object, name: &str) -> Result<Vec<PropPattern>> {
let Some(raw) = read_optional_unknown(value, name)? else {
return Ok(Vec::new());
};
if matches!(
raw.get_type()?,
napi::ValueType::Null | napi::ValueType::Undefined
) {
return Ok(Vec::new());
}
let array = unsafe { raw.cast::<napi::bindgen_prelude::Array>() }
.map_err(|_| invalid_arg(format!("{name} must be an array of objects")))?;
let len = array.len();
if len > MAX_ROUTING_RULE_PATTERNS {
return Err(invalid_arg(format!("{name} exceeds pattern cap")));
}
let mut out = Vec::with_capacity(len as usize);
for index in 0..len {
let entry = array
.get::<Object>(index)
.map_err(|_| invalid_arg(format!("{name}[{index}] must be an object")))?
.ok_or_else(|| invalid_arg(format!("{name}[{index}] must be an object")))?;
out.push(object_to_prop_map(&entry)?);
}
Ok(out)
}
fn object_to_prop_map(object: &Object) -> Result<PropMap> {
let keys = Object::keys(object)?;
if keys.len() as u32 > MAX_ROUTING_RULE_KEYS_PER_PATTERN {
return Err(invalid_arg("routing pattern has too many keys"));
}
let mut out = PropMap::with_capacity(keys.len());
for key in keys {
if key.is_empty() || key.len() > MAX_ROUTING_RULE_KEY_LENGTH {
return Err(invalid_arg("routing pattern key is empty or too long"));
}
let raw = read_optional_unknown(object, &key)?
.ok_or_else(|| invalid_arg("routing pattern value missing"))?;
if raw.get_type()? != napi::ValueType::String {
return Err(invalid_arg("routing pattern value must be a string"));
}
let value: String = unsafe { raw.cast() }?;
if value.len() > MAX_ROUTING_RULE_VALUE_LENGTH {
return Err(invalid_arg("routing pattern value too long"));
}
out.insert(key, value);
}
Ok(out)
}
fn read_optional_unknown<'a>(object: &Object<'a>, name: &str) -> Result<Option<Unknown<'a>>> {
object.get::<Unknown>(name)
}
fn read_optional_bool(object: &Object, name: &str) -> Result<Option<bool>> {
let Some(raw) = read_optional_unknown(object, name)? else {
return Ok(None);
};
match raw.get_type()? {
napi::ValueType::Null | napi::ValueType::Undefined => Ok(None),
napi::ValueType::Boolean => Ok(Some(unsafe { raw.cast() }?)),
_ => Err(invalid_arg(format!("{name} must be a boolean"))),
}
}
fn generic_error(reason: impl Into<String>) -> Error {
Error::new(Status::GenericFailure, reason.into())
}
fn invalid_arg(reason: impl Into<String>) -> Error {
Error::new(Status::InvalidArg, reason.into())
}
#[allow(dead_code)]
fn _keep_arc_in_scope(_: Arc<()>) {}
#[cfg(all(test, target_os = "linux"))]
mod js_path_tests {
use super::AudioMixRuntimeHandle;
use crate::pipewire::stream_ops::{
DIRECT_CAPTURE_APM_FRAME_SAMPLES, build_test_user_data, process_audio_chunk,
};
use fluxer_rt_thread::MonotonicClock;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug)]
struct FakeClock {
value_ns: AtomicU64,
}
impl FakeClock {
fn new(initial_ns: u64) -> Self {
assert!(initial_ns > 0);
Self {
value_ns: AtomicU64::new(initial_ns),
}
}
}
impl MonotonicClock for FakeClock {
fn now_ns(&self) -> u64 {
self.value_ns.load(Ordering::Acquire)
}
}
fn make_f32_payload(samples: &[f32]) -> Vec<u8> {
assert!(!samples.is_empty());
let mut out = Vec::with_capacity(samples.len() * 4);
for sample in samples {
out.extend_from_slice(&sample.to_ne_bytes());
}
assert_eq!(out.len(), samples.len() * 4);
out
}
fn build_handle_with_shared_freshness(last_push_ns: Arc<AtomicU64>) -> AudioMixRuntimeHandle {
assert!(Arc::strong_count(&last_push_ns) >= 1);
let handle = AudioMixRuntimeHandle::build(1, Some(last_push_ns))
.expect("AudioMixRuntimeHandle build via JS path");
assert_eq!(handle.source_count_js(), 1);
handle
}
#[test]
fn js_runtime_tick_consumes_freshness_pushed_by_production_callback() {
let clock: Arc<dyn MonotonicClock> = Arc::new(FakeClock::new(11_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut user_data = build_test_user_data(Arc::clone(&last_push_ns), Arc::clone(&clock));
let handle = build_handle_with_shared_freshness(Arc::clone(&last_push_ns));
assert_eq!(handle.mark_pushed_total_js(), 0);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0001)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut user_data, &payload);
let pushed_after_callback = last_push_ns.load(Ordering::Acquire);
assert_ne!(pushed_after_callback, u64::MAX);
assert_eq!(pushed_after_callback, 11_000_000);
let marked = handle
.tick(Some(pushed_after_callback as i64))
.expect("AudioMixRuntimeHandle::tick observes freshness");
assert_eq!(marked, 1);
let total = handle.mark_pushed_total_js();
assert!(
total >= 1,
"AudioMixRuntimeHandle::tick did not advance mark_pushed_total ({total})",
);
handle.dispose().expect("dispose");
}
#[test]
fn js_runtime_tick_idempotent_for_unchanged_freshness_atomic() {
let clock: Arc<dyn MonotonicClock> = Arc::new(FakeClock::new(22_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut user_data = build_test_user_data(Arc::clone(&last_push_ns), Arc::clone(&clock));
let handle = build_handle_with_shared_freshness(Arc::clone(&last_push_ns));
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0002)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut user_data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
let _ = handle.tick(Some(observed as i64)).expect("first tick");
let after_first = handle.mark_pushed_total_js();
assert!(after_first >= 1);
let _ = handle.tick(Some(observed as i64 + 1)).expect("second tick");
let after_second = handle.mark_pushed_total_js();
assert_eq!(
after_first, after_second,
"second tick must not advance mark_pushed_total when freshness atomic is unchanged",
);
handle.dispose().expect("dispose");
}
}
@@ -0,0 +1,433 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use pipewire as pw;
use pw::keys;
use pw::metadata::{Metadata, MetadataListener};
use pw::properties::{PropertiesBox, properties};
use fluxer_rt_thread::{PriorityProfile, RealtimePriorityGuard, RtError, RtOutcome};
use crate::audio_contract::{self, DIRECT_CAPTURE_SAMPLE_RATE};
use crate::backend::{RoutingGraphLink, RoutingGraphNode, RoutingGraphPort, RoutingGraphSnapshot};
use crate::routing::PropMap;
pub(crate) const READY_TIMEOUT_MS: u64 = 2_000;
pub(crate) const SINK_NODE_NAME: &str = "fluxer-screen-share";
pub(crate) const SINK_NODE_DESCRIPTION: &str = "Fluxer Screen Share Audio";
pub(crate) const DIRECT_SINK_PREFIX: &str = "fluxer-direct-capture";
pub(crate) const DIRECT_SINK_DESCRIPTION: &str = "Fluxer Direct Capture Audio";
pub(crate) const MEDIA_CLASS_CAPTURE_STREAM: &str = "Stream/Input/Audio";
pub(crate) const PIN_TARGET_METADATA_KEY: &str = "target.object";
pub(crate) const PIN_TARGET_METADATA_TYPE: &str = "Spa:String";
pub(crate) const CH_FRONT_LEFT: &str = "FL";
pub(crate) const CH_FRONT_RIGHT: &str = "FR";
pub(crate) const CH_MONO: &str = "MONO";
pub const MAX_FRAME_SAMPLES: usize = 1_920;
const _: () = assert!(MAX_FRAME_SAMPLES > 0);
const _: () = assert!(MAX_FRAME_SAMPLES <= 8_192);
pub(crate) static DIRECT_SINK_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Copy, Clone)]
pub(crate) enum VirtualSinkKind {
LegacyVirtualSource,
PrivateAudioSink,
}
pub(crate) fn next_direct_sink_name() -> String {
let seq = DIRECT_SINK_COUNTER.fetch_add(1, Ordering::Relaxed);
assert!(seq > 0);
format!("{DIRECT_SINK_PREFIX}-{}-{seq}", std::process::id())
}
pub(crate) fn acquire_audio_rt_guard() -> Option<RealtimePriorityGuard> {
match RealtimePriorityGuard::acquire(PriorityProfile::Audio) {
Ok(guard) => {
log_rt_outcome(guard.outcome());
Some(guard)
}
Err(RtError::PlatformDenied(errno)) => {
eprintln!(
"[fluxer-linux-audio] RT priority denied (errno={errno}); continuing without elevation",
);
None
}
Err(other) => {
eprintln!("[fluxer-linux-audio] RT priority error: {other}");
None
}
}
}
fn log_rt_outcome(outcome: &RtOutcome) {
match outcome {
RtOutcome::Acquired => {}
RtOutcome::PartialFallback => {
eprintln!(
"[fluxer-linux-audio] RT priority partial fallback engaged (Linux EPERM path)",
);
}
}
}
#[derive(Default, Clone)]
pub(crate) struct PortRecord {
pub(crate) node_id: u32,
pub(crate) direction: String,
pub(crate) channel: String,
pub(crate) props: PropMap,
}
#[derive(Default)]
pub(crate) struct InventorySnapshot {
pub(crate) nodes: HashMap<u32, PropMap>,
pub(crate) clients: HashMap<u32, PropMap>,
pub(crate) ports: HashMap<u32, PortRecord>,
}
impl InventorySnapshot {
pub(crate) fn enriched_node_props(&self, props: &PropMap) -> PropMap {
let mut enriched = props
.get("client.id")
.and_then(|client_id| client_id.parse::<u32>().ok())
.and_then(|client_id| self.clients.get(&client_id))
.map(client_identity_props)
.unwrap_or_default();
for (key, value) in props {
enriched.insert(key.clone(), value.clone());
}
if !enriched.contains_key("application.process.id")
&& let Some(pid) = enriched.get("pipewire.sec.pid").cloned()
{
enriched.insert("application.process.id".to_string(), pid);
}
enriched
}
pub(crate) fn enriched_nodes(&self) -> HashMap<u32, PropMap> {
self.nodes
.iter()
.map(|(id, props)| (*id, self.enriched_node_props(props)))
.collect()
}
pub(crate) fn enriched_node_values(&self) -> Vec<PropMap> {
self.nodes
.values()
.map(|props| self.enriched_node_props(props))
.collect()
}
pub(crate) fn routing_graph_nodes(&self) -> Vec<RoutingGraphNode> {
let mut nodes: Vec<RoutingGraphNode> = self
.enriched_nodes()
.into_iter()
.map(|(id, props)| RoutingGraphNode { id, props })
.collect();
nodes.sort_by_key(|node| node.id);
nodes
}
pub(crate) fn routing_graph_ports(&self) -> Vec<RoutingGraphPort> {
let mut ports: Vec<RoutingGraphPort> = self
.ports
.iter()
.map(|(id, port)| RoutingGraphPort {
id: *id,
node_id: port.node_id,
direction: port.direction.clone(),
channel: port.channel.clone(),
props: port.props.clone(),
})
.collect();
ports.sort_by_key(|port| port.id);
ports
}
}
fn client_identity_props(client: &PropMap) -> PropMap {
client
.iter()
.filter(|(key, _)| is_client_identity_key(key))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn is_client_identity_key(key: &str) -> bool {
key.starts_with("application.") || key.starts_with("pipewire.sec.")
}
pub(crate) struct MetadataWatch {
pub(crate) metadata: Metadata,
pub(crate) is_default: bool,
pub(crate) _listener: MetadataListener,
}
pub(crate) fn collect_props(dict: Option<&pw::spa::utils::dict::DictRef>) -> PropMap {
let mut props = PropMap::new();
if let Some(d) = dict {
for (k, v) in d.iter() {
props.insert(k.to_string(), v.to_string());
}
}
props
}
pub(crate) fn is_routable_media_class(class: &str) -> bool {
matches!(
class,
crate::routing::MEDIA_CLASS_PLAYBACK_STREAM
| MEDIA_CLASS_CAPTURE_STREAM
| "Audio/Source"
| "Audio/Sink"
)
}
#[cfg(test)]
pub(crate) fn build_virtual_sink_props() -> PropertiesBox {
build_virtual_sink_props_for(
SINK_NODE_NAME,
SINK_NODE_DESCRIPTION,
VirtualSinkKind::LegacyVirtualSource,
)
}
pub(crate) fn build_virtual_sink_props_for(
node_name: &str,
description: &str,
kind: VirtualSinkKind,
) -> PropertiesBox {
let media_class = match kind {
VirtualSinkKind::LegacyVirtualSource => "Audio/Source/Virtual",
VirtualSinkKind::PrivateAudioSink => "Audio/Sink",
};
let mut props = properties! {
"factory.name" => "support.null-audio-sink",
"node.name" => node_name,
"node.nick" => node_name,
"node.description" => description,
"media.class" => media_class,
"node.virtual" => "true",
"node.passive" => "true",
"node.dont-move" => "true",
"node.dont-reconnect" => "true",
"node.latency" => audio_contract::direct_capture_latency_fraction(),
"audio.rate" => DIRECT_CAPTURE_SAMPLE_RATE.to_string(),
"audio.position" => "[FL,FR]",
"monitor.channel-volumes" => "true",
};
if matches!(kind, VirtualSinkKind::PrivateAudioSink) {
props.insert("node.hidden", "true");
}
props.insert("audio.channels", "2");
props
}
pub(crate) fn build_link_props(
src_node: u32,
src_port: u32,
sink_node: u32,
sink_port: u32,
) -> PropertiesBox {
properties! {
"object.linger" => "false",
"link.passive" => "true",
*keys::LINK_OUTPUT_NODE => src_node.to_string(),
*keys::LINK_OUTPUT_PORT => src_port.to_string(),
*keys::LINK_INPUT_NODE => sink_node.to_string(),
*keys::LINK_INPUT_PORT => sink_port.to_string(),
}
}
pub(crate) fn pick_node_ports(
node_id: u32,
direction: &str,
ports: &HashMap<u32, PortRecord>,
) -> Option<(u32, u32)> {
let mut fl = None;
let mut fr = None;
let mut mono = None;
let mut candidates = Vec::new();
for (port_id, rec) in ports.iter() {
if rec.node_id != node_id || rec.direction != direction {
continue;
}
candidates.push(*port_id);
match rec.channel.to_ascii_uppercase().as_str() {
CH_FRONT_LEFT => fl = Some(*port_id),
CH_FRONT_RIGHT => fr = Some(*port_id),
"" | CH_MONO => mono = Some(*port_id),
_ => {}
}
}
if let (Some(l), Some(r)) = (fl, fr) {
return Some((l, r));
}
candidates.sort_unstable();
if candidates.len() >= 2 {
return Some((candidates[0], candidates[1]));
}
mono.map(|m| (m, m))
}
pub(crate) fn pick_source_output_ports(
node_id: u32,
ports: &HashMap<u32, PortRecord>,
) -> Option<(u32, u32)> {
pick_node_ports(node_id, "out", ports)
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct LinkKey {
pub(crate) src_node: u32,
pub(crate) src_port: u32,
pub(crate) sink_node: u32,
pub(crate) sink_port: u32,
}
impl LinkKey {
pub(crate) fn new(src_node: u32, src_port: u32, sink_node: u32, sink_port: u32) -> Self {
Self {
src_node,
src_port,
sink_node,
sink_port,
}
}
pub(crate) fn graph_link(self) -> RoutingGraphLink {
RoutingGraphLink {
output_node_id: self.src_node,
output_port_id: self.src_port,
input_node_id: self.sink_node,
input_port_id: self.sink_port,
}
}
}
pub(crate) struct OwnedLink {
pub(crate) key: LinkKey,
pub(crate) link: pw::link::Link,
}
pub(crate) fn create_link(core: &pw::core::CoreRc, key: LinkKey) -> Option<OwnedLink> {
let props = build_link_props(key.src_node, key.src_port, key.sink_node, key.sink_port);
let link = core
.create_object::<pw::link::Link>("link-factory", &props)
.ok()?;
Some(OwnedLink { key, link })
}
pub(crate) fn destroy_owned_links(
core: &pw::core::CoreRc,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
) {
let links = std::mem::take(&mut *owned_links.borrow_mut());
for owned in links {
let link = owned.link;
let _ = core.destroy_object(link);
}
replace_owned_link_snapshot(owned_link_snapshot, Vec::new());
}
pub(crate) fn sync_owned_links(
core: &pw::core::CoreRc,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
desired_links: Vec<LinkKey>,
) {
let desired: HashSet<LinkKey> = desired_links.into_iter().collect();
let mut links = owned_links.borrow_mut();
let mut index = 0;
while index < links.len() {
if desired.contains(&links[index].key) {
index += 1;
continue;
}
let removed = links.swap_remove(index);
let _ = core.destroy_object(removed.link);
}
let existing: HashSet<LinkKey> = links.iter().map(|owned| owned.key).collect();
for key in desired {
if existing.contains(&key) {
continue;
}
if let Some(link) = create_link(core, key) {
links.push(link);
}
}
let keys = links.iter().map(|owned| owned.key).collect();
replace_owned_link_snapshot(owned_link_snapshot, keys);
}
pub(crate) fn replace_owned_link_snapshot(
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
mut keys: Vec<LinkKey>,
) {
keys.sort_by_key(|key| (key.src_node, key.src_port, key.sink_node, key.sink_port));
if let Ok(mut guard) = owned_link_snapshot.lock() {
*guard = keys;
}
}
pub(crate) fn ensure_virtual_sink(
core: &pw::core::CoreRc,
sink_proxy: &std::rc::Rc<std::cell::RefCell<Option<pw::node::Node>>>,
node_name: &str,
description: &str,
kind: VirtualSinkKind,
) {
if sink_proxy.borrow().is_some() {
return;
}
let props = build_virtual_sink_props_for(node_name, description, kind);
if let Ok(node) = core.create_object::<pw::node::Node>("adapter", &props) {
*sink_proxy.borrow_mut() = Some(node);
}
}
pub(crate) fn build_routing_graph_snapshot(
backend: &str,
inventory: &Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
) -> RoutingGraphSnapshot {
let (nodes, ports) = match inventory.lock() {
Ok(guard) => (guard.routing_graph_nodes(), guard.routing_graph_ports()),
Err(_) => (Vec::new(), Vec::new()),
};
let owned_links = match owned_link_snapshot.lock() {
Ok(guard) => guard.iter().copied().map(LinkKey::graph_link).collect(),
Err(_) => Vec::new(),
};
RoutingGraphSnapshot {
backend: backend.to_string(),
nodes,
ports,
owned_links,
}
}
pub(crate) fn daemon_reachable() -> bool {
pw::init();
let Ok(mainloop) = pw::main_loop::MainLoopRc::new(None) else {
return false;
};
let Ok(context) = pw::context::ContextRc::new(&mainloop, None) else {
return false;
};
context.connect_rc(None).is_ok()
}
@@ -0,0 +1,402 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use pipewire as pw;
use pw::metadata::Metadata;
use pw::types::ObjectType;
use super::common::{
InventorySnapshot, LinkKey, MetadataWatch, OwnedLink, PortRecord, collect_props,
is_routable_media_class, replace_owned_link_snapshot,
};
use super::routing::{
DirectRoutingState, RoutingState, recompute_routing, refresh_direct_sink_input_ports,
refresh_sink_input_ports,
};
use super::stream_ops::DirectStreamRuntime;
pub(crate) struct GlobalAddedContext<'a> {
pub(crate) registry: &'a pw::registry::RegistryRc,
pub(crate) inventory: &'a Arc<Mutex<InventorySnapshot>>,
pub(crate) state: &'a std::rc::Rc<std::cell::RefCell<RoutingState>>,
pub(crate) core: &'a pw::core::CoreRc,
pub(crate) owned_links: &'a std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
pub(crate) owned_link_snapshot: &'a Arc<Mutex<Vec<LinkKey>>>,
pub(crate) metadata_watchers: &'a std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
pub(crate) sink_node_name: &'a str,
}
fn handle_global_added_client(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let props = collect_props(obj.props);
let Ok(mut snap) = ctx.inventory.lock() else {
return;
};
snap.clients.insert(obj.id, props);
drop(snap);
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
fn handle_global_added_node(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let props = collect_props(obj.props);
let class = props.get("media.class").cloned().unwrap_or_default();
let node_name = props.get("node.name").cloned().unwrap_or_default();
let is_our_sink = node_name == ctx.sink_node_name;
if !is_our_sink && !is_routable_media_class(&class) {
return;
}
let Ok(mut snap) = ctx.inventory.lock() else {
return;
};
snap.nodes.insert(obj.id, props);
drop(snap);
if is_our_sink {
ctx.state.borrow_mut().sink_global_id = obj.id;
refresh_sink_input_ports(ctx.inventory, ctx.state);
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
return;
}
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
fn build_port_record(props: &crate::routing::PropMap) -> Option<PortRecord> {
let node_id = props.get("node.id").and_then(|s| s.parse::<u32>().ok())?;
if node_id == 0 {
return None;
}
let direction = props
.get("port.direction")
.map(String::as_str)
.unwrap_or("")
.to_ascii_lowercase();
let channel = props.get("audio.channel").cloned().unwrap_or_default();
Some(PortRecord {
node_id,
direction,
channel,
props: props.clone(),
})
}
fn handle_global_added_port(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let props = collect_props(obj.props);
let Some(record) = build_port_record(&props) else {
return;
};
let node_id = record.node_id;
let Ok(mut snap) = ctx.inventory.lock() else {
return;
};
snap.ports.insert(obj.id, record);
drop(snap);
let sink_id = ctx.state.borrow().sink_global_id;
if sink_id != 0 && node_id == sink_id {
refresh_sink_input_ports(ctx.inventory, ctx.state);
}
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
fn handle_global_added_metadata(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let metadata_name = obj
.props
.and_then(|dict| dict.get("metadata.name"))
.unwrap_or("")
.to_string();
let is_default = metadata_name == "default";
let Ok(metadata) = ctx.registry.bind::<Metadata, _>(obj) else {
return;
};
let inv = ctx.inventory.clone();
let st = ctx.state.clone();
let core_for_listener = ctx.core.clone();
let owned_for_listener = ctx.owned_links.clone();
let link_snapshot_for_listener = ctx.owned_link_snapshot.clone();
let metadata_watchers_for_listener = ctx.metadata_watchers.clone();
let sink_node_name_owned = ctx.sink_node_name.to_string();
let listener = metadata
.add_listener_local()
.property(move |_subject, key, _type_, value| {
if key == Some("default.audio.sink") {
let name = value
.map(crate::routing::parse_default_sink_name)
.unwrap_or_default();
st.borrow_mut().default_sink_name = name;
recompute_routing(
&inv,
&st,
&core_for_listener,
&owned_for_listener,
&link_snapshot_for_listener,
&metadata_watchers_for_listener,
&sink_node_name_owned,
);
}
0
})
.register();
ctx.metadata_watchers.borrow_mut().push(MetadataWatch {
metadata,
is_default,
_listener: listener,
});
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
pub(crate) fn handle_global_added(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: GlobalAddedContext<'_>,
) {
match obj.type_ {
ObjectType::Client => handle_global_added_client(obj, &ctx),
ObjectType::Node => handle_global_added_node(obj, &ctx),
ObjectType::Port => handle_global_added_port(obj, &ctx),
ObjectType::Metadata => handle_global_added_metadata(obj, &ctx),
_ => {}
}
}
pub(crate) fn handle_global_removed(
id: u32,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
) -> bool {
let mut changed = false;
if let Ok(mut snap) = inventory.lock() {
changed |= snap.nodes.remove(&id).is_some();
changed |= snap.clients.remove(&id).is_some();
changed |= snap.ports.remove(&id).is_some();
}
let mut st = state.borrow_mut();
let removed_sink = st.sink_global_id == id;
if st.sink_global_id == id {
st.sink_global_id = 0;
st.sink_input_fl = None;
st.sink_input_fr = None;
}
if st.sink_input_fl == Some(id) {
st.sink_input_fl = None;
}
if st.sink_input_fr == Some(id) {
st.sink_input_fr = None;
}
changed |= st.pinned_capture_nodes.remove(&id);
changed || removed_sink
}
pub(crate) struct DirectGlobalAddedArgs<'a> {
pub(crate) registry: &'a pw::registry::RegistryRc,
pub(crate) inventory: &'a Arc<Mutex<InventorySnapshot>>,
pub(crate) state: &'a std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
pub(crate) core: &'a pw::core::CoreRc,
pub(crate) runtime: &'a DirectStreamRuntime,
pub(crate) metadata_watchers: &'a std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
}
fn direct_added_client(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
) -> bool {
let props = collect_props(obj.props);
let Ok(mut snap) = args.inventory.lock() else {
return false;
};
snap.clients.insert(obj.id, props);
true
}
fn direct_added_node(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
) -> bool {
let props = collect_props(obj.props);
let class = props.get("media.class").cloned().unwrap_or_default();
let node_name = props.get("node.name").cloned().unwrap_or_default();
let is_our_sink = node_name == args.runtime.sink_node_name;
if !is_our_sink && !is_routable_media_class(&class) {
return false;
}
{
let Ok(mut snap) = args.inventory.lock() else {
return false;
};
snap.nodes.insert(obj.id, props);
}
if is_our_sink {
args.state.borrow_mut().sink_global_id = obj.id;
refresh_direct_sink_input_ports(args.inventory, args.state);
}
true
}
fn direct_added_port(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
) -> bool {
let props = collect_props(obj.props);
let Some(record) = build_port_record(&props) else {
return false;
};
let node_id = record.node_id;
{
let Ok(mut snap) = args.inventory.lock() else {
return false;
};
snap.ports.insert(obj.id, record);
}
let sink_id = args.state.borrow().sink_global_id;
if sink_id != 0 && node_id == sink_id {
refresh_direct_sink_input_ports(args.inventory, args.state);
}
true
}
fn direct_added_metadata(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
on_default_change: impl Fn() + 'static,
) -> bool {
let metadata_name = obj
.props
.and_then(|dict| dict.get("metadata.name"))
.unwrap_or("")
.to_string();
let is_default = metadata_name == "default";
let Ok(metadata) = args.registry.bind::<Metadata, _>(obj) else {
return false;
};
let st = args.state.clone();
let listener = metadata
.add_listener_local()
.property(move |_subject, key, _type_, value| {
if key == Some("default.audio.sink") {
let name = value
.map(crate::routing::parse_default_sink_name)
.unwrap_or_default();
st.borrow_mut().default_sink_name = name;
on_default_change();
}
0
})
.register();
args.metadata_watchers.borrow_mut().push(MetadataWatch {
metadata,
is_default,
_listener: listener,
});
false
}
pub(crate) fn handle_direct_global_added(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: DirectGlobalAddedArgs<'_>,
on_default_change: impl Fn() + 'static,
) -> bool {
match obj.type_ {
ObjectType::Client => direct_added_client(obj, &args),
ObjectType::Node => direct_added_node(obj, &args),
ObjectType::Port => direct_added_port(obj, &args),
ObjectType::Metadata => direct_added_metadata(obj, &args, on_default_change),
_ => false,
}
}
pub(crate) fn handle_direct_global_removed(
id: u32,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &DirectStreamRuntime,
) -> bool {
let mut changed = false;
if let Ok(mut snap) = inventory.lock() {
changed |= snap.nodes.remove(&id).is_some();
changed |= snap.clients.remove(&id).is_some();
changed |= snap.ports.remove(&id).is_some();
}
{
let mut st = state.borrow_mut();
if st.sink_global_id == id {
st.sink_global_id = 0;
st.sink_input_fl = None;
st.sink_input_fr = None;
runtime.sink_proxy.borrow_mut().take();
runtime.owned_links.borrow_mut().clear();
replace_owned_link_snapshot(&runtime.owned_link_snapshot, Vec::new());
*runtime.active_stream.borrow_mut() = None;
*runtime.active_listener.borrow_mut() = None;
runtime.running.store(false, Ordering::Relaxed);
changed = true;
}
if st.sink_input_fl == Some(id) {
st.sink_input_fl = None;
changed = true;
}
if st.sink_input_fr == Some(id) {
st.sink_input_fr = None;
changed = true;
}
}
changed
}
@@ -0,0 +1,485 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use pipewire as pw;
use pw::channel::Receiver as PwReceiver;
use pw::context::ContextRc;
use pw::main_loop::MainLoopRc;
use fluxer_rt_thread::MonotonicClock;
use crate::direct_buffer::DirectAudioBuffer;
use crate::routing::{RoutingRule, SelfIdentity};
use super::common::{
DIRECT_SINK_DESCRIPTION, InventorySnapshot, LinkKey, MetadataWatch, OwnedLink,
SINK_NODE_DESCRIPTION, SINK_NODE_NAME, VirtualSinkKind, acquire_audio_rt_guard,
destroy_owned_links, ensure_virtual_sink,
};
use super::device_enum::{
DirectGlobalAddedArgs, GlobalAddedContext, handle_direct_global_added,
handle_direct_global_removed, handle_global_added, handle_global_removed,
};
use super::routing::{
DirectRoutingState, RoutingState, clear_pinned_capture_targets, recompute_direct_links,
recompute_routing, refresh_direct_sink_input_ports,
};
use super::stream_ops::{
BuildDirectStreamArgs, DirectStreamRuntime, ScreenAudioSinkSlot, build_direct_stream,
};
pub(crate) enum BridgeCommand {
Apply(RoutingRule),
Release,
SetIdentity(SelfIdentity),
Shutdown,
}
struct BridgeWorkerState {
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
state: std::rc::Rc<std::cell::RefCell<RoutingState>>,
sink_proxy: std::rc::Rc<std::cell::RefCell<Option<pw::node::Node>>>,
owned_links: std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
metadata_watchers: std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
}
fn init_bridge_state(
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
) -> BridgeWorkerState {
BridgeWorkerState {
snapshot,
owned_link_snapshot,
state: std::rc::Rc::new(std::cell::RefCell::new(RoutingState::default())),
sink_proxy: std::rc::Rc::new(std::cell::RefCell::new(None)),
owned_links: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
metadata_watchers: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
}
}
fn install_bridge_registry(
registry: &pw::registry::RegistryRc,
core: &pw::core::CoreRc,
worker: &BridgeWorkerState,
) -> pw::registry::Listener {
let inv_for_global = worker.snapshot.clone();
let link_snapshot_for_global = worker.owned_link_snapshot.clone();
let state_for_global = worker.state.clone();
let core_for_global = core.clone();
let registry_for_global = registry.clone();
let owned_for_global = worker.owned_links.clone();
let metadata_for_global = worker.metadata_watchers.clone();
let inv_rm = worker.snapshot.clone();
let state_rm = worker.state.clone();
let core_rm = core.clone();
let owned_rm = worker.owned_links.clone();
let link_snapshot_rm = worker.owned_link_snapshot.clone();
let metadata_rm = worker.metadata_watchers.clone();
registry
.add_listener_local()
.global(move |obj| {
handle_global_added(
obj,
GlobalAddedContext {
registry: &registry_for_global,
inventory: &inv_for_global,
state: &state_for_global,
core: &core_for_global,
owned_links: &owned_for_global,
owned_link_snapshot: &link_snapshot_for_global,
metadata_watchers: &metadata_for_global,
sink_node_name: SINK_NODE_NAME,
},
);
})
.global_remove(move |id| {
let changed = handle_global_removed(id, &inv_rm, &state_rm);
if changed && state_rm.borrow().active_rule.is_some() {
recompute_routing(
&inv_rm,
&state_rm,
&core_rm,
&owned_rm,
&link_snapshot_rm,
&metadata_rm,
SINK_NODE_NAME,
);
}
})
.register()
}
fn install_bridge_command_handler<'a>(
mainloop: &'a MainLoopRc,
rx: PwReceiver<BridgeCommand>,
core: &pw::core::CoreRc,
worker: &BridgeWorkerState,
) -> pw::channel::AttachedReceiver<'a, BridgeCommand> {
let inv_for_cmd = worker.snapshot.clone();
let link_snapshot_for_cmd = worker.owned_link_snapshot.clone();
let state_for_cmd = worker.state.clone();
let core_for_cmd = core.clone();
let owned_for_cmd = worker.owned_links.clone();
let metadata_for_cmd = worker.metadata_watchers.clone();
let sink_for_cmd = worker.sink_proxy.clone();
let mainloop_weak = mainloop.downgrade();
rx.attach(mainloop.loop_(), move |cmd| match cmd {
BridgeCommand::Apply(rule) => {
state_for_cmd.borrow_mut().active_rule = Some(rule);
ensure_virtual_sink(
&core_for_cmd,
&sink_for_cmd,
SINK_NODE_NAME,
SINK_NODE_DESCRIPTION,
VirtualSinkKind::LegacyVirtualSource,
);
recompute_routing(
&inv_for_cmd,
&state_for_cmd,
&core_for_cmd,
&owned_for_cmd,
&link_snapshot_for_cmd,
&metadata_for_cmd,
SINK_NODE_NAME,
);
}
BridgeCommand::Release => {
clear_pinned_capture_targets(&state_for_cmd, &metadata_for_cmd);
state_for_cmd.borrow_mut().active_rule = None;
destroy_owned_links(&core_for_cmd, &owned_for_cmd, &link_snapshot_for_cmd);
}
BridgeCommand::SetIdentity(id) => {
state_for_cmd.borrow_mut().identity = id;
}
BridgeCommand::Shutdown => {
clear_pinned_capture_targets(&state_for_cmd, &metadata_for_cmd);
destroy_owned_links(&core_for_cmd, &owned_for_cmd, &link_snapshot_for_cmd);
sink_for_cmd.borrow_mut().take();
if let Some(ml) = mainloop_weak.upgrade() {
ml.quit();
}
}
})
}
pub(crate) fn run_bridge_worker(
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
rx: PwReceiver<BridgeCommand>,
ready_tx: std::sync::mpsc::SyncSender<bool>,
) {
let _rt_guard = acquire_audio_rt_guard();
pw::init();
let Ok(mainloop) = MainLoopRc::new(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(context) = ContextRc::new(&mainloop, None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(core) = context.connect_rc(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(registry) = core.get_registry_rc() else {
let _ = ready_tx.send(false);
return;
};
let worker = init_bridge_state(snapshot, owned_link_snapshot);
let _registry_listener = install_bridge_registry(&registry, &core, &worker);
let _attached_rx = install_bridge_command_handler(&mainloop, rx, &core, &worker);
let _ = ready_tx.send(true);
mainloop.run();
}
pub(crate) enum DirectCommand {
Start {
rule: RoutingRule,
identity: Box<SelfIdentity>,
},
UpdateRule {
rule: RoutingRule,
},
Stop,
Shutdown,
}
#[derive(Clone, Copy)]
pub(crate) enum DirectSinkRetention {
Preserve,
Drop,
}
pub(crate) fn stop_direct_streams(
core: &pw::core::CoreRc,
runtime: &DirectStreamRuntime,
sink_retention: DirectSinkRetention,
) {
*runtime.active_listener.borrow_mut() = None;
*runtime.active_stream.borrow_mut() = None;
destroy_owned_links(core, &runtime.owned_links, &runtime.owned_link_snapshot);
if matches!(sink_retention, DirectSinkRetention::Drop) {
runtime.sink_proxy.borrow_mut().take();
}
runtime.running.store(false, Ordering::Relaxed);
}
pub(crate) fn clear_direct_samples(samples: &Arc<Mutex<DirectAudioBuffer>>) {
if let Ok(mut guard) = samples.lock() {
guard.clear();
}
}
pub(crate) fn recompute_direct_streams(
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &DirectStreamRuntime,
) {
if state.borrow().active_rule.is_none() {
stop_direct_streams(core, runtime, DirectSinkRetention::Preserve);
return;
}
let updated = recompute_direct_links(
core,
inventory,
state,
&runtime.owned_links,
&runtime.owned_link_snapshot,
);
if !updated {
return;
}
ensure_or_promote_direct_stream(core, runtime);
}
fn ensure_or_promote_direct_stream(core: &pw::core::CoreRc, runtime: &DirectStreamRuntime) {
if runtime.active_stream.borrow().is_some() {
runtime.running.store(true, Ordering::Relaxed);
return;
}
let args = BuildDirectStreamArgs {
core,
samples: runtime.samples.clone(),
target_sink_name: &runtime.sink_node_name,
stream_node_name: &runtime.stream_node_name,
last_push_ns: runtime.last_push_ns.clone(),
clock: runtime.clock.clone(),
screen_audio_sink: runtime.screen_audio_sink.clone(),
};
match build_direct_stream(args) {
Ok((stream, listener)) => {
*runtime.active_stream.borrow_mut() = Some(stream);
*runtime.active_listener.borrow_mut() = Some(listener);
runtime.running.store(true, Ordering::Relaxed);
}
Err(_) => {
runtime.running.store(false, Ordering::Relaxed);
}
}
}
pub(crate) struct DirectWorkerInputs {
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) inventory: Arc<Mutex<InventorySnapshot>>,
pub(crate) owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
pub(crate) running: Arc<AtomicBool>,
pub(crate) sink_node_name: String,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
fn build_direct_runtime(inputs: &DirectWorkerInputs) -> std::rc::Rc<DirectStreamRuntime> {
let stream_node_name = format!("{}-stream", inputs.sink_node_name);
std::rc::Rc::new(DirectStreamRuntime {
active_stream: std::rc::Rc::new(std::cell::RefCell::new(None)),
active_listener: std::rc::Rc::new(std::cell::RefCell::new(None)),
owned_links: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
owned_link_snapshot: inputs.owned_link_snapshot.clone(),
sink_proxy: std::rc::Rc::new(std::cell::RefCell::new(None)),
samples: inputs.samples.clone(),
running: inputs.running.clone(),
sink_node_name: inputs.sink_node_name.clone(),
stream_node_name,
last_push_ns: inputs.last_push_ns.clone(),
clock: inputs.clock.clone(),
screen_audio_sink: inputs.screen_audio_sink.clone(),
})
}
fn install_direct_registry(
registry: &pw::registry::RegistryRc,
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &std::rc::Rc<DirectStreamRuntime>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
) -> pw::registry::Listener {
let inv_added = inventory.clone();
let state_added = state.clone();
let core_added = core.clone();
let runtime_added = runtime.clone();
let registry_added = registry.clone();
let metadata_added = metadata_watchers.clone();
let inv_rm = inventory.clone();
let state_rm = state.clone();
let core_rm = core.clone();
let runtime_rm = runtime.clone();
registry
.add_listener_local()
.global(move |obj| {
let inv_for_cb = inv_added.clone();
let state_for_cb = state_added.clone();
let core_for_cb = core_added.clone();
let runtime_for_cb = runtime_added.clone();
let on_default_change = move || {
recompute_direct_streams(&core_for_cb, &inv_for_cb, &state_for_cb, &runtime_for_cb);
};
let changed = handle_direct_global_added(
obj,
DirectGlobalAddedArgs {
registry: &registry_added,
inventory: &inv_added,
state: &state_added,
core: &core_added,
runtime: &runtime_added,
metadata_watchers: &metadata_added,
},
on_default_change,
);
if changed {
recompute_direct_streams(&core_added, &inv_added, &state_added, &runtime_added);
}
})
.global_remove(move |id| {
let changed = handle_direct_global_removed(id, &inv_rm, &state_rm, &runtime_rm);
if changed {
recompute_direct_streams(&core_rm, &inv_rm, &state_rm, &runtime_rm);
}
})
.register()
}
fn install_direct_command_handler<'a>(
mainloop: &'a MainLoopRc,
rx: PwReceiver<DirectCommand>,
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &std::rc::Rc<DirectStreamRuntime>,
) -> pw::channel::AttachedReceiver<'a, DirectCommand> {
let core_for_cmd = core.clone();
let inv_for_cmd = inventory.clone();
let state_for_cmd = state.clone();
let runtime_for_cmd = runtime.clone();
let mainloop_weak = mainloop.downgrade();
rx.attach(mainloop.loop_(), move |cmd| match cmd {
DirectCommand::Start { rule, identity } => {
clear_direct_samples(&runtime_for_cmd.samples);
{
let mut st = state_for_cmd.borrow_mut();
st.identity = *identity;
st.active_rule = Some(rule);
}
ensure_virtual_sink(
&core_for_cmd,
&runtime_for_cmd.sink_proxy,
&runtime_for_cmd.sink_node_name,
DIRECT_SINK_DESCRIPTION,
VirtualSinkKind::PrivateAudioSink,
);
refresh_direct_sink_input_ports(&inv_for_cmd, &state_for_cmd);
recompute_direct_streams(
&core_for_cmd,
&inv_for_cmd,
&state_for_cmd,
&runtime_for_cmd,
);
}
DirectCommand::UpdateRule { rule } => {
let active = state_for_cmd.borrow().active_rule.is_some();
if !active {
return;
}
state_for_cmd.borrow_mut().active_rule = Some(rule);
recompute_direct_streams(
&core_for_cmd,
&inv_for_cmd,
&state_for_cmd,
&runtime_for_cmd,
);
}
DirectCommand::Stop => {
stop_direct_streams(
&core_for_cmd,
&runtime_for_cmd,
DirectSinkRetention::Preserve,
);
{
let mut st = state_for_cmd.borrow_mut();
st.active_rule = None;
}
clear_direct_samples(&runtime_for_cmd.samples);
}
DirectCommand::Shutdown => {
stop_direct_streams(&core_for_cmd, &runtime_for_cmd, DirectSinkRetention::Drop);
clear_direct_samples(&runtime_for_cmd.samples);
if let Some(ml) = mainloop_weak.upgrade() {
ml.quit();
}
}
})
}
pub(crate) fn run_direct_worker(
inputs: DirectWorkerInputs,
rx: PwReceiver<DirectCommand>,
ready_tx: std::sync::mpsc::SyncSender<bool>,
) {
let _rt_guard = acquire_audio_rt_guard();
pw::init();
let Ok(mainloop) = MainLoopRc::new(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(context) = ContextRc::new(&mainloop, None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(core) = context.connect_rc(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(registry) = core.get_registry_rc() else {
let _ = ready_tx.send(false);
return;
};
let state: std::rc::Rc<std::cell::RefCell<DirectRoutingState>> =
std::rc::Rc::new(std::cell::RefCell::new(DirectRoutingState::default()));
let metadata_watchers: std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let runtime = build_direct_runtime(&inputs);
let _registry_listener = install_direct_registry(
&registry,
&core,
&inputs.inventory,
&state,
&runtime,
&metadata_watchers,
);
let _attached_rx =
install_direct_command_handler(&mainloop, rx, &core, &inputs.inventory, &state, &runtime);
let _ = ready_tx.send(true);
mainloop.run();
}
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub(crate) mod common;
pub(crate) mod device_enum;
pub(crate) mod event_loop;
pub(crate) mod routing;
pub(crate) mod stream_ops;
@@ -0,0 +1,373 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
use std::mem;
use std::sync::{Arc, Mutex};
use pipewire as pw;
use crate::routing::{
MEDIA_CLASS_PLAYBACK_STREAM, PropMap, RoutingRule, SelfIdentity, matches_any, should_route_node,
};
use super::common::{
InventorySnapshot, LinkKey, MEDIA_CLASS_CAPTURE_STREAM, MetadataWatch, OwnedLink,
PIN_TARGET_METADATA_KEY, PIN_TARGET_METADATA_TYPE, destroy_owned_links, pick_node_ports,
pick_source_output_ports, sync_owned_links,
};
#[derive(Default)]
pub(crate) struct RoutingState {
pub(crate) identity: SelfIdentity,
pub(crate) active_rule: Option<RoutingRule>,
pub(crate) default_sink_name: String,
pub(crate) sink_global_id: u32,
pub(crate) pinned_capture_nodes: HashSet<u32>,
pub(crate) sink_input_fl: Option<u32>,
pub(crate) sink_input_fr: Option<u32>,
}
pub(crate) fn refresh_sink_input_ports(
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
) {
let sink_id = state.borrow().sink_global_id;
if sink_id == 0 {
return;
}
assert!(sink_id != 0);
let Ok(snap) = inventory.lock() else {
return;
};
let ports = pick_node_ports(sink_id, "in", &snap.ports);
drop(snap);
if let Some((fl, fr)) = ports {
assert!(fl != 0);
assert!(fr != 0);
let mut st = state.borrow_mut();
st.sink_input_fl = Some(fl);
st.sink_input_fr = Some(fr);
}
}
pub(crate) fn default_sink_target_id(
nodes: &HashMap<u32, PropMap>,
default_sink_name: &str,
) -> String {
if default_sink_name.is_empty() {
return String::new();
}
nodes
.values()
.find(|props| {
props
.get("node.name")
.is_some_and(|name| name == default_sink_name)
})
.and_then(|props| props.get("object.serial").cloned())
.unwrap_or_default()
}
pub(crate) fn matching_pinned_capture_nodes(
nodes: &HashMap<u32, PropMap>,
rule: &RoutingRule,
sink_global_id: u32,
) -> HashSet<u32> {
if rule.pin_target_for.is_empty() {
return HashSet::new();
}
nodes
.iter()
.filter_map(|(node_id, props)| {
if *node_id == sink_global_id {
return None;
}
let is_capture_stream = props
.get("media.class")
.is_some_and(|class| class == MEDIA_CLASS_CAPTURE_STREAM);
if is_capture_stream && matches_any(props, &rule.pin_target_for) {
Some(*node_id)
} else {
None
}
})
.collect()
}
pub(crate) fn sync_pinned_capture_targets(
nodes: &HashMap<u32, PropMap>,
rule: &RoutingRule,
sink_global_id: u32,
sink_node_name: &str,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
) {
let desired = matching_pinned_capture_nodes(nodes, rule, sink_global_id);
let previous = state.borrow().pinned_capture_nodes.clone();
for node_id in previous.difference(&desired) {
set_pinned_capture_target(metadata_watchers, *node_id, None);
}
for node_id in &desired {
set_pinned_capture_target(metadata_watchers, *node_id, Some(sink_node_name));
}
state.borrow_mut().pinned_capture_nodes = desired;
}
pub(crate) fn clear_pinned_capture_targets(
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
) {
let pinned = mem::take(&mut state.borrow_mut().pinned_capture_nodes);
for node_id in pinned {
set_pinned_capture_target(metadata_watchers, node_id, None);
}
}
pub(crate) fn set_pinned_capture_target(
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
node_id: u32,
target: Option<&str>,
) {
assert!(node_id != 0);
let watchers = metadata_watchers.borrow();
for watcher in watchers.iter().filter(|watcher| watcher.is_default) {
watcher.metadata.set_property(
node_id,
PIN_TARGET_METADATA_KEY,
target.map(|_| PIN_TARGET_METADATA_TYPE),
target,
);
}
}
struct RoutingResolved {
rule: RoutingRule,
sink_id: u32,
sink_in_fl: u32,
sink_in_fr: u32,
identity: SelfIdentity,
default_sink_name: String,
}
fn resolve_routing_inputs(
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
) -> Option<RoutingResolved> {
let st = state.borrow();
let rule = st.active_rule.clone()?;
let sink_id = st.sink_global_id;
if sink_id == 0 {
return None;
}
let (sink_in_fl, sink_in_fr) = (st.sink_input_fl?, st.sink_input_fr?);
Some(RoutingResolved {
rule,
sink_id,
sink_in_fl,
sink_in_fr,
identity: st.identity.clone(),
default_sink_name: st.default_sink_name.clone(),
})
}
fn build_desired_links(
nodes: &HashMap<u32, PropMap>,
ports: &HashMap<u32, super::common::PortRecord>,
resolved: &RoutingResolved,
default_sink_target_id: &str,
) -> Vec<LinkKey> {
let mut desired_links = Vec::new();
for (node_id, props) in nodes {
if *node_id == resolved.sink_id {
continue;
}
if !should_route_node(
*node_id,
props,
&resolved.rule,
&resolved.default_sink_name,
default_sink_target_id,
resolved.sink_id,
&resolved.identity,
) {
continue;
}
let Some((src_l, src_r)) = pick_source_output_ports(*node_id, ports) else {
continue;
};
desired_links.push(LinkKey::new(
*node_id,
src_l,
resolved.sink_id,
resolved.sink_in_fl,
));
desired_links.push(LinkKey::new(
*node_id,
src_r,
resolved.sink_id,
resolved.sink_in_fr,
));
}
desired_links
}
pub(crate) fn recompute_routing(
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
core: &pw::core::CoreRc,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
sink_node_name: &str,
) {
let Some(resolved) = resolve_routing_inputs(state) else {
clear_pinned_capture_targets(state, metadata_watchers);
destroy_owned_links(core, owned_links, owned_link_snapshot);
return;
};
let Ok(snap) = inventory.lock() else {
return;
};
let nodes = snap.enriched_nodes();
let ports = snap.ports.clone();
drop(snap);
let default_sink_target_id = default_sink_target_id(&nodes, &resolved.default_sink_name);
sync_pinned_capture_targets(
&nodes,
&resolved.rule,
resolved.sink_id,
sink_node_name,
state,
metadata_watchers,
);
let desired_links = build_desired_links(&nodes, &ports, &resolved, &default_sink_target_id);
sync_owned_links(core, owned_links, owned_link_snapshot, desired_links);
}
#[derive(Default)]
pub(crate) struct DirectRoutingState {
pub(crate) identity: SelfIdentity,
pub(crate) active_rule: Option<RoutingRule>,
pub(crate) default_sink_name: String,
pub(crate) sink_global_id: u32,
pub(crate) sink_input_fl: Option<u32>,
pub(crate) sink_input_fr: Option<u32>,
}
pub(crate) fn refresh_direct_sink_input_ports(
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
) {
let sink_id = state.borrow().sink_global_id;
if sink_id == 0 {
return;
}
let Ok(snap) = inventory.lock() else {
return;
};
let ports = pick_node_ports(sink_id, "in", &snap.ports);
drop(snap);
if let Some((fl, fr)) = ports {
let mut st = state.borrow_mut();
st.sink_input_fl = Some(fl);
st.sink_input_fr = Some(fr);
}
}
struct DirectResolved {
rule: RoutingRule,
sink_id: u32,
sink_in_fl: u32,
sink_in_fr: u32,
identity: SelfIdentity,
default_sink_name: String,
}
fn resolve_direct_inputs(
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
) -> Option<DirectResolved> {
let st = state.borrow();
let rule = st.active_rule.clone()?;
let sink_id = st.sink_global_id;
let (sink_in_fl, sink_in_fr) = (st.sink_input_fl?, st.sink_input_fr?);
Some(DirectResolved {
rule,
sink_id,
sink_in_fl,
sink_in_fr,
identity: st.identity.clone(),
default_sink_name: st.default_sink_name.clone(),
})
}
fn build_direct_desired_links(
nodes: &HashMap<u32, PropMap>,
ports: &HashMap<u32, super::common::PortRecord>,
resolved: &DirectResolved,
default_sink_target_id: &str,
) -> Vec<LinkKey> {
let mut desired_links = Vec::new();
for (node_id, props) in nodes {
if *node_id == resolved.sink_id {
continue;
}
if !should_route_node(
*node_id,
props,
&resolved.rule,
&resolved.default_sink_name,
default_sink_target_id,
resolved.sink_id,
&resolved.identity,
) {
continue;
}
let Some((src_l, src_r)) = pick_source_output_ports(*node_id, ports) else {
continue;
};
desired_links.push(LinkKey::new(
*node_id,
src_l,
resolved.sink_id,
resolved.sink_in_fl,
));
desired_links.push(LinkKey::new(
*node_id,
src_r,
resolved.sink_id,
resolved.sink_in_fr,
));
}
desired_links
}
pub(crate) fn recompute_direct_links(
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
) -> bool {
let Some(resolved) = resolve_direct_inputs(state) else {
return false;
};
let Ok(snap) = inventory.lock() else {
return false;
};
let nodes = snap.enriched_nodes();
let ports = snap.ports.clone();
drop(snap);
let default_sink_target_id = default_sink_target_id(&nodes, &resolved.default_sink_name);
let desired_links =
build_direct_desired_links(&nodes, &ports, &resolved, &default_sink_target_id);
sync_owned_links(core, owned_links, owned_link_snapshot, desired_links);
true
}
const _: () = {
let _ = MEDIA_CLASS_PLAYBACK_STREAM;
};
@@ -0,0 +1,439 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::mem;
use std::ops::Range;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef;
use pipewire as pw;
use pw::keys;
use pw::properties::{PropertiesBox, properties};
use pw::spa;
use spa::param::format::{MediaSubtype, MediaType};
use spa::param::format_utils;
use spa::pod::Pod;
use spa::sys as spa_sys;
use fluxer_audio_apm::{
APM_MAX_FRAME_SAMPLES, ApmConfig, ApmError, AudioProcessor, StubAudioProcessor,
expected_frame_samples,
};
use fluxer_rt_thread::MonotonicClock;
use crate::audio_contract::{self, DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_SAMPLE_RATE};
use crate::direct_buffer::DirectAudioBuffer;
use super::common::{LinkKey, MAX_FRAME_SAMPLES, OwnedLink};
pub(crate) type ScreenAudioSinkSlot = Arc<RwLock<Option<Arc<NativeScreenFrameSinkHandleRef>>>>;
pub const DIRECT_CAPTURE_APM_FRAME_SAMPLES: usize =
(DIRECT_CAPTURE_SAMPLE_RATE as usize) / 100 * (DIRECT_CAPTURE_CHANNELS as usize);
const _: () = assert!(DIRECT_CAPTURE_APM_FRAME_SAMPLES <= APM_MAX_FRAME_SAMPLES * 2);
const _: () = assert!(MAX_FRAME_SAMPLES >= DIRECT_CAPTURE_APM_FRAME_SAMPLES);
pub struct DirectCaptureApm {
processor: Box<dyn AudioProcessor + Send>,
accum_f32: Box<[f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES]>,
accum_len: usize,
scratch_i16: Box<[i16; DIRECT_CAPTURE_APM_FRAME_SAMPLES]>,
expected_sample_rate_hz: u32,
expected_channels: u16,
processed_samples: u64,
apm_frames_processed: u64,
}
impl DirectCaptureApm {
pub fn new(sample_rate_hz: u32, channels: u16) -> Result<Self, ApmError> {
assert!(sample_rate_hz >= 8_000);
assert!(channels >= 1);
let processor = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?;
let expected = expected_frame_samples(sample_rate_hz, channels);
assert!(expected > 0);
assert!(expected <= DIRECT_CAPTURE_APM_FRAME_SAMPLES);
Ok(Self {
processor: Box::new(processor),
accum_f32: Box::new([0.0; DIRECT_CAPTURE_APM_FRAME_SAMPLES]),
accum_len: 0,
scratch_i16: Box::new([0i16; DIRECT_CAPTURE_APM_FRAME_SAMPLES]),
expected_sample_rate_hz: sample_rate_hz,
expected_channels: channels,
processed_samples: 0,
apm_frames_processed: 0,
})
}
pub fn reconfigure(&mut self, sample_rate_hz: u32, channels: u16) -> Result<(), ApmError> {
assert!(sample_rate_hz >= 8_000);
assert!(channels >= 1);
if self.expected_sample_rate_hz == sample_rate_hz && self.expected_channels == channels {
return Ok(());
}
let processor = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?;
let expected = expected_frame_samples(sample_rate_hz, channels);
if expected == 0 || expected > DIRECT_CAPTURE_APM_FRAME_SAMPLES {
return Err(ApmError::ChannelsOutOfRange { channels });
}
self.processor = Box::new(processor);
self.expected_sample_rate_hz = sample_rate_hz;
self.expected_channels = channels;
self.accum_len = 0;
Ok(())
}
pub fn process_in_place(&mut self, samples: &mut [f32]) -> Result<usize, ApmError> {
assert!(!samples.is_empty());
assert!(self.expected_channels >= 1);
let apm_frame_len =
expected_frame_samples(self.expected_sample_rate_hz, self.expected_channels);
assert!(apm_frame_len > 0);
assert!(apm_frame_len <= self.scratch_i16.len());
let mut processed_complete: usize = 0;
let mut idx: usize = 0;
let total = samples.len();
while idx < total {
let want = apm_frame_len - self.accum_len;
let take = want.min(total - idx);
for offset in 0..take {
self.accum_f32[self.accum_len + offset] = samples[idx + offset];
}
self.accum_len += take;
idx += take;
if self.accum_len == apm_frame_len {
self.run_apm_one_frame(apm_frame_len)?;
if idx >= apm_frame_len {
let dst_lo = idx - apm_frame_len;
for offset in 0..apm_frame_len {
samples[dst_lo + offset] = self.accum_f32[offset];
}
processed_complete += apm_frame_len;
} else {
processed_complete += take;
}
self.accum_len = 0;
self.apm_frames_processed = self.apm_frames_processed.saturating_add(1);
}
}
self.processed_samples = self.processed_samples.saturating_add(total as u64);
Ok(processed_complete)
}
fn run_apm_one_frame(&mut self, apm_frame_len: usize) -> Result<(), ApmError> {
assert!(apm_frame_len <= self.scratch_i16.len());
assert!(apm_frame_len <= self.accum_f32.len());
for offset in 0..apm_frame_len {
self.scratch_i16[offset] = f32_sample_to_i16(self.accum_f32[offset]);
}
let result = self.processor.process_capture_frame(
&mut self.scratch_i16[..apm_frame_len],
self.expected_sample_rate_hz,
self.expected_channels,
);
result?;
for offset in 0..apm_frame_len {
self.accum_f32[offset] = i16_sample_to_f32(self.scratch_i16[offset]);
}
Ok(())
}
pub fn apm_frames_processed(&self) -> u64 {
self.apm_frames_processed
}
pub fn processed_samples(&self) -> u64 {
self.processed_samples
}
pub fn pending_accumulator_len(&self) -> usize {
self.accum_len
}
}
pub(crate) fn f32_sample_to_i16(value: f32) -> i16 {
let scaled = value * (i16::MAX as f32);
if scaled >= (i16::MAX as f32) {
return i16::MAX;
}
if scaled <= (i16::MIN as f32) {
return i16::MIN;
}
scaled as i16
}
pub(crate) fn i16_sample_to_f32(value: i16) -> f32 {
(value as f32) / (i16::MAX as f32)
}
pub(crate) struct DirectUserData {
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) format: spa::param::audio::AudioInfoRaw,
pub(crate) apm: Mutex<DirectCaptureApm>,
pub(crate) scratch: Mutex<Box<[f32; MAX_FRAME_SAMPLES]>>,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
pub(crate) struct DirectStreamRuntime {
pub(crate) active_stream: std::rc::Rc<std::cell::RefCell<Option<pw::stream::StreamRc>>>,
pub(crate) active_listener:
std::rc::Rc<std::cell::RefCell<Option<pw::stream::StreamListener<DirectUserData>>>>,
pub(crate) owned_links: std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
pub(crate) owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
pub(crate) sink_proxy: std::rc::Rc<std::cell::RefCell<Option<pw::node::Node>>>,
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) running: Arc<AtomicBool>,
pub(crate) sink_node_name: String,
pub(crate) stream_node_name: String,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
pub(crate) fn direct_chunk_payload_range(
raw_len: usize,
offset: usize,
size: usize,
) -> Option<Range<usize>> {
if size == 0 || offset >= raw_len {
return None;
}
let end = offset.checked_add(size)?.min(raw_len);
let available = end.checked_sub(offset)?;
let aligned = available - (available % mem::size_of::<f32>());
if aligned == 0 {
return None;
}
Some(offset..offset + aligned)
}
pub(crate) fn build_direct_audio_info() -> spa::param::audio::AudioInfoRaw {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(DIRECT_CAPTURE_SAMPLE_RATE);
audio_info.set_channels(DIRECT_CAPTURE_CHANNELS);
let mut position = [0; spa::param::audio::MAX_CHANNELS];
position[0] = spa_sys::SPA_AUDIO_CHANNEL_FL;
position[1] = spa_sys::SPA_AUDIO_CHANNEL_FR;
audio_info.set_position(position);
audio_info
}
pub(crate) fn build_direct_stream_props(
target_sink_name: &str,
stream_node_name: &str,
) -> PropertiesBox {
properties! {
*keys::NODE_NAME => stream_node_name,
*keys::MEDIA_TYPE => "Audio",
*keys::MEDIA_CATEGORY => "Capture",
*keys::MEDIA_ROLE => "Music",
"media.class" => "Stream/Input/Audio",
*keys::STREAM_CAPTURE_SINK => "true",
"node.latency" => audio_contract::direct_capture_latency_fraction(),
"node.passive" => "true",
"node.virtual" => "true",
"node.hidden" => "true",
"node.dont-fallback" => "true",
"node.dont-move" => "true",
"node.dont-reconnect" => "true",
"stream.dont-remix" => "true",
"audio.rate" => DIRECT_CAPTURE_SAMPLE_RATE.to_string(),
"audio.channels" => DIRECT_CAPTURE_CHANNELS.to_string(),
"audio.position" => "[FL,FR]",
"target.object" => target_sink_name,
}
}
fn handle_param_changed(user_data: &mut DirectUserData, id: u32, param: Option<&Pod>) {
let Some(param) = param else { return };
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Ok((media_type, media_subtype)) = format_utils::parse_format(param) else {
return;
};
if media_type != MediaType::Audio || media_subtype != MediaSubtype::Raw {
return;
}
if user_data.format.parse(param).is_err() {
return;
}
let rate = user_data.format.rate();
let channels = user_data.format.channels();
if let Ok(mut guard) = user_data.samples.lock() {
guard.set_format(rate, channels);
}
if let Ok(mut apm_guard) = user_data.apm.lock() {
let channels_u16 = channels.min(u16::MAX as u32) as u16;
let _ = apm_guard.reconfigure(rate, channels_u16.max(1));
}
}
fn decode_f32_into_scratch(raw_payload: &[u8], scratch: &mut [f32; MAX_FRAME_SAMPLES]) -> usize {
let sample_count = raw_payload.len() / mem::size_of::<f32>();
let take = sample_count.min(MAX_FRAME_SAMPLES);
let mut written = 0usize;
let mut iter = raw_payload.chunks_exact(mem::size_of::<f32>());
for slot in scratch.iter_mut().take(take) {
let Some(chunk) = iter.next() else {
break;
};
*slot = f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
written += 1;
}
written
}
pub(crate) fn process_audio_chunk(user_data: &mut DirectUserData, payload: &[u8]) {
let Ok(mut scratch_guard) = user_data.scratch.lock() else {
return;
};
let written = decode_f32_into_scratch(payload, &mut scratch_guard);
if written == 0 {
return;
}
let channels = user_data.format.channels().max(1);
let aligned = audio_contract::whole_frame_sample_count(written, channels);
if aligned == 0 {
return;
}
if let Ok(mut apm_guard) = user_data.apm.lock() {
let _ = apm_guard.process_in_place(&mut scratch_guard[..aligned]);
}
let now_ns = user_data.clock.now_ns();
if now_ns > 0 {
user_data.last_push_ns.store(now_ns, Ordering::Release);
}
if let Ok(guard) = user_data.screen_audio_sink.read()
&& let Some(sink) = guard.as_ref()
{
let frames = aligned as u32 / channels;
if frames > 0 {
sink.enqueue_screen_audio_f32(
&scratch_guard[..aligned],
frames,
channels,
user_data.format.rate(),
(now_ns / 1_000) as i64,
);
}
return;
}
if let Ok(mut samples_guard) = user_data.samples.lock() {
let now_us = (now_ns / 1_000) as i64;
samples_guard.push(&scratch_guard[..aligned], now_us);
}
}
fn process_stream_buffer(stream: &pw::stream::Stream, user_data: &mut DirectUserData) {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let chunk = data.chunk();
let n_bytes = chunk.size() as usize;
let offset = chunk.offset() as usize;
let Some(raw) = data.data() else { return };
let Some(payload) = direct_chunk_payload_range(raw.len(), offset, n_bytes) else {
return;
};
process_audio_chunk(user_data, &raw[payload]);
}
pub(crate) struct BuildDirectStreamArgs<'a> {
pub(crate) core: &'a pw::core::CoreRc,
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) target_sink_name: &'a str,
pub(crate) stream_node_name: &'a str,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
pub(crate) fn build_direct_stream(
args: BuildDirectStreamArgs<'_>,
) -> Result<
(
pw::stream::StreamRc,
pw::stream::StreamListener<DirectUserData>,
),
pw::Error,
> {
let props = build_direct_stream_props(args.target_sink_name, args.stream_node_name);
let apm = DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.map_err(|_| pw::Error::CreationFailed)?;
let data = DirectUserData {
samples: args.samples,
format: spa::param::audio::AudioInfoRaw::new(),
apm: Mutex::new(apm),
scratch: Mutex::new(Box::new([0.0_f32; MAX_FRAME_SAMPLES])),
last_push_ns: args.last_push_ns,
clock: args.clock,
screen_audio_sink: args.screen_audio_sink,
};
let stream = pw::stream::StreamRc::new(args.core.clone(), "fluxer-direct-capture", props)?;
let listener = stream
.add_local_listener_with_user_data(data)
.param_changed(|_, user_data, id, param| {
handle_param_changed(user_data, id, param);
})
.process(|stream, user_data| {
process_stream_buffer(stream, user_data);
})
.register()?;
let audio_info = build_direct_audio_info();
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|_| pw::Error::CreationFailed)?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).ok_or(pw::Error::CreationFailed)?];
stream.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)?;
Ok((stream, listener))
}
#[cfg(test)]
pub(crate) fn build_test_user_data(
last_push_ns: Arc<AtomicU64>,
clock: Arc<dyn MonotonicClock>,
) -> DirectUserData {
let apm = DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let mut format = spa::param::audio::AudioInfoRaw::new();
format.set_rate(DIRECT_CAPTURE_SAMPLE_RATE);
format.set_channels(DIRECT_CAPTURE_CHANNELS);
DirectUserData {
samples: Arc::new(Mutex::new(DirectAudioBuffer::default_format())),
format,
apm: Mutex::new(apm),
scratch: Mutex::new(Box::new([0.0_f32; MAX_FRAME_SAMPLES])),
last_push_ns,
clock,
screen_audio_sink: Arc::new(RwLock::new(None)),
}
}
@@ -0,0 +1,970 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef;
use pipewire as pw;
use pw::channel::{Sender as PwSender, channel as pw_channel};
use fluxer_rt_thread::{MonotonicClock, SystemMonotonicClock};
use crate::audio_contract::DIRECT_CAPTURE_MAX_READ_SAMPLES;
use crate::backend::{CaptureBridge, CapturedFrame, DirectCapture, RoutingGraphSnapshot};
use crate::direct_buffer::DirectAudioBuffer;
use crate::pipewire::common::{
InventorySnapshot, LinkKey, READY_TIMEOUT_MS, build_routing_graph_snapshot,
daemon_reachable as common_daemon_reachable, next_direct_sink_name,
};
use crate::pipewire::event_loop::{
BridgeCommand, DirectCommand, DirectWorkerInputs, run_bridge_worker, run_direct_worker,
};
use crate::pipewire::stream_ops::ScreenAudioSinkSlot;
use crate::routing::{PropMap, RoutingRule, SelfIdentity};
pub fn daemon_reachable() -> bool {
common_daemon_reachable()
}
pub struct PipeWireBridge {
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
tx: PwSender<BridgeCommand>,
thread: Mutex<Option<JoinHandle<()>>>,
}
impl PipeWireBridge {
pub fn open() -> Option<Self> {
if !daemon_reachable() {
return None;
}
let snapshot = Arc::new(Mutex::new(InventorySnapshot::default()));
let owned_link_snapshot = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = pw_channel::<BridgeCommand>();
let snap_for_thread = snapshot.clone();
let links_for_thread = owned_link_snapshot.clone();
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<bool>(1);
let handle = thread::Builder::new()
.name("fluxer-pipewire-bridge".into())
.spawn(move || {
run_bridge_worker(snap_for_thread, links_for_thread, rx, ready_tx);
})
.ok()?;
match ready_rx.recv_timeout(Duration::from_millis(READY_TIMEOUT_MS)) {
Ok(true) => Some(Self {
snapshot,
owned_link_snapshot,
tx,
thread: Mutex::new(Some(handle)),
}),
_ => {
let _ = tx.send(BridgeCommand::Shutdown);
let _ = handle.join();
None
}
}
}
}
impl Drop for PipeWireBridge {
fn drop(&mut self) {
let _ = self.tx.send(BridgeCommand::Shutdown);
if let Ok(mut thread) = self.thread.lock()
&& let Some(handle) = thread.take()
{
let _ = handle.join();
}
}
}
impl CaptureBridge for PipeWireBridge {
fn inventory(&self) -> Vec<PropMap> {
match self.snapshot.lock() {
Ok(guard) => guard.enriched_node_values(),
Err(_) => Vec::new(),
}
}
fn apply(&self, rule: RoutingRule) -> bool {
self.tx.send(BridgeCommand::Apply(rule)).is_ok()
}
fn release(&self) {
let _ = self.tx.send(BridgeCommand::Release);
}
fn populate_self_identity(&self, identity: SelfIdentity) {
let _ = self.tx.send(BridgeCommand::SetIdentity(identity));
}
fn backend_name(&self) -> &'static str {
"pipewire"
}
fn routing_graph(&self) -> RoutingGraphSnapshot {
build_routing_graph_snapshot("pipewire", &self.snapshot, &self.owned_link_snapshot)
}
}
pub struct PipeWireDirectCapture {
samples: Arc<Mutex<DirectAudioBuffer>>,
inventory: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
tx: PwSender<DirectCommand>,
#[allow(dead_code)]
running: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
identity: Mutex<SelfIdentity>,
#[allow(dead_code)]
last_push_ns: Arc<AtomicU64>,
screen_audio_sink: ScreenAudioSinkSlot,
}
impl PipeWireDirectCapture {
pub fn open() -> Option<Self> {
Self::open_with_clock(Arc::new(SystemMonotonicClock::new()))
}
pub fn open_with_clock(clock: Arc<dyn MonotonicClock>) -> Option<Self> {
if !daemon_reachable() {
return None;
}
let samples = Arc::new(Mutex::new(DirectAudioBuffer::default_format()));
let inventory = Arc::new(Mutex::new(InventorySnapshot::default()));
let owned_link_snapshot = Arc::new(Mutex::new(Vec::new()));
let running = Arc::new(AtomicBool::new(false));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let screen_audio_sink: ScreenAudioSinkSlot = Arc::new(RwLock::new(None));
let (tx, rx) = pw_channel::<DirectCommand>();
let sink_node_name = next_direct_sink_name();
let inputs = DirectWorkerInputs {
samples: samples.clone(),
inventory: inventory.clone(),
owned_link_snapshot: owned_link_snapshot.clone(),
running: running.clone(),
sink_node_name,
last_push_ns: last_push_ns.clone(),
clock,
screen_audio_sink: screen_audio_sink.clone(),
};
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<bool>(1);
let handle = thread::Builder::new()
.name("fluxer-pipewire-direct".into())
.spawn(move || {
run_direct_worker(inputs, rx, ready_tx);
})
.ok()?;
match ready_rx.recv_timeout(Duration::from_millis(READY_TIMEOUT_MS)) {
Ok(true) => Some(Self {
samples,
inventory,
owned_link_snapshot,
tx,
running,
thread: Mutex::new(Some(handle)),
identity: Mutex::new(SelfIdentity::default()),
last_push_ns,
screen_audio_sink,
}),
_ => {
let _ = tx.send(DirectCommand::Shutdown);
let _ = handle.join();
None
}
}
}
#[allow(dead_code)]
pub fn last_push_ns(&self) -> u64 {
self.last_push_ns.load(std::sync::atomic::Ordering::Acquire)
}
}
impl Drop for PipeWireDirectCapture {
fn drop(&mut self) {
let _ = self.tx.send(DirectCommand::Shutdown);
if let Ok(mut thread) = self.thread.lock()
&& let Some(handle) = thread.take()
{
let _ = handle.join();
}
}
}
impl DirectCapture for PipeWireDirectCapture {
fn start(&self, rule: RoutingRule) -> bool {
let Ok(identity) = self.identity.lock().map(|guard| guard.clone()) else {
return false;
};
self.tx
.send(DirectCommand::Start {
rule,
identity: Box::new(identity),
})
.is_ok()
}
fn set_rule(&self, rule: RoutingRule) -> bool {
self.tx.send(DirectCommand::UpdateRule { rule }).is_ok()
}
fn read(&self) -> Option<CapturedFrame> {
let mut out = Vec::with_capacity(DIRECT_CAPTURE_MAX_READ_SAMPLES);
let meta = {
let Ok(mut guard) = self.samples.lock() else {
return None;
};
guard.read_into(&mut out)?
};
Some(CapturedFrame {
samples: out,
sample_rate: meta.sample_rate,
channels: meta.channels,
timestamp_us: meta.timestamp_us,
})
}
fn stop(&self) {
let _ = self.tx.send(DirectCommand::Stop);
}
fn set_screen_audio_sink(&self, sink: Arc<NativeScreenFrameSinkHandleRef>) {
if let Ok(mut guard) = self.screen_audio_sink.write() {
*guard = Some(sink);
}
}
fn clear_screen_audio_sink(&self) {
if let Ok(mut guard) = self.screen_audio_sink.write() {
*guard = None;
}
}
fn populate_self_identity(&self, identity: SelfIdentity) {
if let Ok(mut guard) = self.identity.lock() {
*guard = identity;
}
}
fn routing_graph(&self) -> RoutingGraphSnapshot {
build_routing_graph_snapshot("pipewire", &self.inventory, &self.owned_link_snapshot)
}
fn last_push_ns_arc(&self) -> Option<Arc<AtomicU64>> {
Some(Arc::clone(&self.last_push_ns))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipewire::common::{
DIRECT_SINK_PREFIX, MEDIA_CLASS_CAPTURE_STREAM, PortRecord, SINK_NODE_DESCRIPTION,
SINK_NODE_NAME, VirtualSinkKind, build_link_props, build_virtual_sink_props,
build_virtual_sink_props_for, is_routable_media_class, pick_node_ports,
pick_source_output_ports,
};
use crate::pipewire::routing::{default_sink_target_id, matching_pinned_capture_nodes};
use crate::pipewire::stream_ops::{
DIRECT_CAPTURE_APM_FRAME_SAMPLES, DirectCaptureApm, build_direct_audio_info,
build_direct_stream_props, direct_chunk_payload_range, f32_sample_to_i16,
i16_sample_to_f32,
};
use crate::routing::MEDIA_CLASS_PLAYBACK_STREAM;
use std::collections::{HashMap, HashSet};
use crate::audio_contract::{DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_SAMPLE_RATE};
use crate::routing::should_route_node;
use pipewire::spa::sys as spa_sys;
#[test]
fn is_routable_media_class_matches_audio_node_classes() {
assert!(is_routable_media_class(MEDIA_CLASS_PLAYBACK_STREAM));
assert!(is_routable_media_class(MEDIA_CLASS_CAPTURE_STREAM));
assert!(is_routable_media_class("Audio/Source"));
assert!(is_routable_media_class("Audio/Sink"));
assert!(!is_routable_media_class("Video/Source"));
assert!(!is_routable_media_class(""));
}
#[test]
fn daemon_unreachable_returns_none_from_open() {
let bridge = PipeWireBridge::open();
if let Some(b) = bridge {
let _ = b.inventory();
b.release();
}
}
#[test]
fn direct_open_returns_none_or_cleans_up() {
let direct = PipeWireDirectCapture::open();
if let Some(d) = direct {
d.stop();
}
}
#[test]
fn integration_smoke_apply_release_cycle_is_safe() {
let Some(bridge) = PipeWireBridge::open() else {
return;
};
bridge.apply(RoutingRule::default());
std::thread::sleep(Duration::from_millis(20));
bridge.release();
let _ = bridge.inventory();
}
#[test]
fn virtual_sink_props_match_legacy_contract() {
let props = build_virtual_sink_props();
let dict = props.dict();
assert_eq!(dict.get("factory.name"), Some("support.null-audio-sink"));
assert_eq!(dict.get("node.name"), Some(SINK_NODE_NAME));
assert_eq!(dict.get("node.nick"), Some(SINK_NODE_NAME));
assert_eq!(dict.get("node.description"), Some(SINK_NODE_DESCRIPTION));
assert_eq!(dict.get("media.class"), Some("Audio/Source/Virtual"));
assert_eq!(dict.get("node.virtual"), Some("true"));
assert_eq!(dict.get("node.passive"), Some("true"));
assert_eq!(dict.get("node.dont-move"), Some("true"));
assert_eq!(dict.get("node.dont-reconnect"), Some("true"));
assert_eq!(dict.get("node.latency"), Some("4096/48000"));
assert_eq!(dict.get("audio.rate"), Some("48000"));
assert_eq!(dict.get("audio.channels"), Some("2"));
assert_eq!(dict.get("audio.position"), Some("[FL,FR]"));
assert_eq!(dict.get("monitor.channel-volumes"), Some("true"));
}
#[test]
fn link_props_carry_per_port_routing() {
let props = build_link_props(101, 7, 202, 13);
let dict = props.dict();
assert_eq!(dict.get("link.output.node"), Some("101"));
assert_eq!(dict.get("link.output.port"), Some("7"));
assert_eq!(dict.get("link.input.node"), Some("202"));
assert_eq!(dict.get("link.input.port"), Some("13"));
assert_eq!(dict.get("object.linger"), Some("false"));
assert_eq!(dict.get("link.passive"), Some("true"));
}
#[test]
fn direct_stream_props_capture_private_sink_monitor() {
let props = build_direct_stream_props(
"fluxer-direct-capture-7-1",
"fluxer-direct-capture-7-1-stream",
);
let dict = props.dict();
assert_eq!(
dict.get("node.name"),
Some("fluxer-direct-capture-7-1-stream")
);
assert_eq!(dict.get("media.type"), Some("Audio"));
assert_eq!(dict.get("media.category"), Some("Capture"));
assert_eq!(dict.get("media.class"), Some("Stream/Input/Audio"));
assert_eq!(
dict.get("stream.capture.sink"),
Some("true"),
"must tap the sink monitor; tapping a Stream/Output/Audio target.object does not produce frames",
);
assert_eq!(dict.get("node.passive"), Some("true"));
assert_eq!(dict.get("node.virtual"), Some("true"));
assert_eq!(dict.get("node.hidden"), Some("true"));
assert_eq!(dict.get("node.dont-fallback"), Some("true"));
assert_eq!(dict.get("node.dont-move"), Some("true"));
assert_eq!(dict.get("node.dont-reconnect"), Some("true"));
assert_eq!(dict.get("stream.dont-remix"), Some("true"));
assert_eq!(dict.get("node.latency"), Some("4096/48000"));
assert_eq!(dict.get("audio.rate"), Some("48000"));
assert_eq!(dict.get("audio.channels"), Some("2"));
assert_eq!(dict.get("audio.position"), Some("[FL,FR]"));
assert_eq!(dict.get("target.object"), Some("fluxer-direct-capture-7-1"));
}
#[test]
fn direct_chunk_payload_range_respects_pipewire_chunk_offset() {
assert_eq!(direct_chunk_payload_range(64, 8, 16), Some(8..24));
assert_eq!(direct_chunk_payload_range(18, 4, 16), Some(4..16));
assert_eq!(direct_chunk_payload_range(64, 64, 16), None);
assert_eq!(direct_chunk_payload_range(64, 8, 0), None);
assert_eq!(direct_chunk_payload_range(10, 8, 2), None);
}
#[test]
fn direct_audio_info_advertises_stereo_fl_fr() {
let info = build_direct_audio_info();
assert_eq!(
info.format(),
pipewire::spa::param::audio::AudioFormat::F32LE
);
assert_eq!(info.rate(), DIRECT_CAPTURE_SAMPLE_RATE);
assert_eq!(info.channels(), DIRECT_CAPTURE_CHANNELS);
assert_eq!(info.position()[0], spa_sys::SPA_AUDIO_CHANNEL_FL);
assert_eq!(info.position()[1], spa_sys::SPA_AUDIO_CHANNEL_FR);
}
#[test]
fn next_direct_sink_name_is_unique_per_call() {
let a = next_direct_sink_name();
let b = next_direct_sink_name();
assert_ne!(a, b, "concurrent captures must not collide on sink names");
assert!(a.starts_with(DIRECT_SINK_PREFIX));
assert!(b.starts_with(DIRECT_SINK_PREFIX));
}
#[test]
fn default_sink_target_id_uses_object_serial_for_matching_node_name() {
let nodes = HashMap::from([
(
1,
PropMap::from([
("node.name".to_string(), "alsa_output.foo".to_string()),
("object.serial".to_string(), "1234".to_string()),
]),
),
(
2,
PropMap::from([
("node.name".to_string(), "alsa_output.bar".to_string()),
("object.serial".to_string(), "5678".to_string()),
]),
),
]);
assert_eq!("1234", default_sink_target_id(&nodes, "alsa_output.foo"));
assert_eq!("", default_sink_target_id(&nodes, "missing"));
}
#[test]
fn matching_pinned_capture_nodes_only_matches_record_stream_inputs() {
let nodes = HashMap::from([
(
10,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_CAPTURE_STREAM.to_string(),
),
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
]),
),
(
11,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_PLAYBACK_STREAM.to_string(),
),
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
]),
),
(
12,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_CAPTURE_STREAM.to_string(),
),
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "OtherCapture".to_string()),
]),
),
(
13,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_CAPTURE_STREAM.to_string(),
),
("application.process.id".to_string(), "9999".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
]),
),
]);
let rule = RoutingRule {
pin_target_for: vec![PropMap::from([
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
])],
..Default::default()
};
assert_eq!(
matching_pinned_capture_nodes(&nodes, &rule, 99),
HashSet::from([10])
);
assert!(
matching_pinned_capture_nodes(&nodes, &rule, 10).is_empty(),
"the bridge's own sink id must never be pinned"
);
}
#[test]
fn private_sink_props_advertise_hidden_audio_sink() {
let private = build_virtual_sink_props_for(
"fluxer-direct-capture-7-1",
crate::pipewire::common::DIRECT_SINK_DESCRIPTION,
VirtualSinkKind::PrivateAudioSink,
);
let legacy = build_virtual_sink_props_for(
SINK_NODE_NAME,
SINK_NODE_DESCRIPTION,
VirtualSinkKind::LegacyVirtualSource,
);
assert_eq!(private.dict().get("media.class"), Some("Audio/Sink"));
assert_eq!(private.dict().get("node.hidden"), Some("true"));
assert_eq!(
legacy.dict().get("media.class"),
Some("Audio/Source/Virtual")
);
assert_eq!(legacy.dict().get("node.hidden"), None);
}
#[test]
fn inventory_enriches_nodes_with_owning_client_identity() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([
("application.name".to_string(), "Firefox".to_string()),
("application.process.id".to_string(), "4242".to_string()),
(
"application.process.binary".to_string(),
"firefox".to_string(),
),
]),
);
inventory.nodes.insert(
88,
PropMap::from([
("client.id".to_string(), "77".to_string()),
(
"media.class".to_string(),
MEDIA_CLASS_PLAYBACK_STREAM.to_string(),
),
("node.name".to_string(), "Firefox output".to_string()),
]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.process.id"),
Some(&"4242".to_string())
);
assert_eq!(
node.get("application.process.binary"),
Some(&"firefox".to_string())
);
assert_eq!(node.get("node.name"), Some(&"Firefox output".to_string()));
let rule = RoutingRule {
include_when: vec![PropMap::from([(
"application.process.id".to_string(),
"4242".to_string(),
)])],
..Default::default()
};
assert!(should_route_node(
88,
node,
&rule,
"",
"",
0,
&SelfIdentity::default(),
));
}
#[test]
fn inventory_keeps_node_properties_authoritative_over_client_props() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([("application.name".to_string(), "Client Name".to_string())]),
);
inventory.nodes.insert(
88,
PropMap::from([
("client.id".to_string(), "77".to_string()),
("application.name".to_string(), "Stream Name".to_string()),
]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.name"),
Some(&"Stream Name".to_string())
);
}
#[test]
fn inventory_falls_back_to_pipewire_security_pid() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([("pipewire.sec.pid".to_string(), "5150".to_string())]),
);
inventory.nodes.insert(
88,
PropMap::from([("client.id".to_string(), "77".to_string())]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.process.id"),
Some(&"5150".to_string())
);
}
#[test]
fn inventory_does_not_inherit_client_object_serial_as_node_target() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([
("application.process.id".to_string(), "4242".to_string()),
("object.serial".to_string(), "client-serial".to_string()),
]),
);
inventory.nodes.insert(
88,
PropMap::from([
("client.id".to_string(), "77".to_string()),
("node.name".to_string(), "Playback Stream".to_string()),
]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.process.id"),
Some(&"4242".to_string())
);
assert_eq!(node.get("object.serial"), None);
assert_eq!(node.get("node.name"), Some(&"Playback Stream".to_string()));
}
fn port(node_id: u32, dir: &str, ch: &str) -> PortRecord {
PortRecord {
node_id,
direction: dir.into(),
channel: ch.into(),
props: PropMap::new(),
}
}
#[test]
fn pick_source_output_ports_prefers_stereo_pair() {
let mut ports = HashMap::new();
ports.insert(1, port(42, "out", "fl"));
ports.insert(2, port(42, "out", "fr"));
ports.insert(3, port(42, "in", "FL"));
ports.insert(4, port(99, "out", "FL"));
let (l, r) = pick_source_output_ports(42, &ports).expect("stereo pair");
assert_eq!(l, 1);
assert_eq!(r, 2);
}
#[test]
fn pick_source_output_ports_falls_back_to_first_two_jack_style_ports() {
let mut ports = HashMap::new();
ports.insert(30, port(42, "out", "AUX1"));
ports.insert(20, port(42, "out", "AUX0"));
ports.insert(10, port(42, "in", "AUX0"));
let (l, r) = pick_source_output_ports(42, &ports).expect("jack-style stereo fallback");
assert_eq!(l, 20);
assert_eq!(r, 30);
}
#[test]
fn pick_node_ports_applies_same_fallback_to_private_capture_inputs() {
let mut ports = HashMap::new();
ports.insert(8, port(7, "in", "1"));
ports.insert(9, port(7, "in", "2"));
let (l, r) = pick_node_ports(7, "in", &ports).expect("input stereo fallback");
assert_eq!(l, 8);
assert_eq!(r, 9);
}
#[test]
fn pick_source_output_ports_fans_mono_to_both_inputs() {
let mut ports = HashMap::new();
ports.insert(7, port(42, "out", "MONO"));
let (l, r) = pick_source_output_ports(42, &ports).expect("mono fan-out");
assert_eq!(l, 7);
assert_eq!(r, 7);
}
#[test]
fn pick_source_output_ports_treats_blank_channel_as_mono() {
let mut ports = HashMap::new();
ports.insert(11, port(42, "out", ""));
let (l, r) = pick_source_output_ports(42, &ports).expect("blank-channel fallback");
assert_eq!(l, 11);
assert_eq!(r, 11);
}
#[test]
fn pick_source_output_ports_returns_none_when_no_outputs() {
let ports = HashMap::new();
assert!(pick_source_output_ports(42, &ports).is_none());
}
#[test]
fn pick_source_output_ports_returns_none_when_only_one_side_present() {
let mut ports = HashMap::new();
ports.insert(1, port(42, "out", "FL"));
assert!(pick_source_output_ports(42, &ports).is_none());
}
#[test]
fn direct_capture_apm_processes_one_full_frame_increments_counter() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
assert_eq!(apm.apm_frames_processed(), 0);
let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES;
let mut samples = vec![0.5_f32; frame_len];
let processed = apm.process_in_place(&mut samples).expect("process");
assert_eq!(processed, frame_len);
assert_eq!(apm.apm_frames_processed(), 1);
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn direct_capture_apm_accumulates_partial_frames_across_calls() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2;
let mut first = vec![0.1_f32; half];
let processed1 = apm.process_in_place(&mut first).expect("first");
assert_eq!(processed1, 0);
assert_eq!(apm.apm_frames_processed(), 0);
assert_eq!(apm.pending_accumulator_len(), half);
let mut second = vec![0.2_f32; half];
let processed2 = apm.process_in_place(&mut second).expect("second");
assert_eq!(processed2, half);
assert_eq!(apm.apm_frames_processed(), 1);
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn direct_capture_apm_handles_many_frames_in_one_call() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES;
let mut samples = vec![0.25_f32; frame_len * 5];
let processed = apm.process_in_place(&mut samples).expect("process");
assert_eq!(processed, frame_len * 5);
assert_eq!(apm.apm_frames_processed(), 5);
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn direct_capture_apm_stub_preserves_samples_within_tolerance() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES;
let mut samples = vec![0.0_f32; frame_len];
for n in 0..frame_len {
samples[n] = ((n as f32) / (frame_len as f32) - 0.5) * 0.5;
}
let original = samples.clone();
let _ = apm.process_in_place(&mut samples).expect("process");
for (after, before) in samples.iter().zip(original.iter()) {
let diff = (after - before).abs();
assert!(diff < 1e-3);
}
}
#[test]
fn direct_capture_apm_reconfigure_is_noop_when_format_unchanged() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2;
let mut samples = vec![0.3_f32; half];
let _ = apm.process_in_place(&mut samples).expect("first");
assert_eq!(apm.pending_accumulator_len(), half);
apm.reconfigure(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("noop");
assert_eq!(apm.pending_accumulator_len(), half);
}
#[test]
fn direct_capture_apm_reconfigure_changes_format_and_resets_accum() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2;
let mut samples = vec![0.3_f32; half];
let _ = apm.process_in_place(&mut samples).expect("first");
assert!(apm.pending_accumulator_len() > 0);
apm.reconfigure(16_000, 1).expect("reconfigure");
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn f32_to_i16_clamps_above_one() {
assert_eq!(f32_sample_to_i16(2.0), i16::MAX);
assert_eq!(f32_sample_to_i16(-2.0), i16::MIN);
assert_eq!(f32_sample_to_i16(0.0), 0);
}
#[test]
fn i16_to_f32_round_trip_is_bounded() {
for sample in [-32_768_i16, -1, 0, 1, 32_767] {
let f = i16_sample_to_f32(sample);
assert!((-1.001..=1.001).contains(&f));
}
}
use crate::ignore_audio_runtime::SOURCE_STALE_AFTER_NS;
use crate::pipewire::stream_ops::{build_test_user_data, process_audio_chunk};
use fluxer_rt_thread::MonotonicClock;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug)]
struct FakeClock {
value_ns: AtomicU64,
}
impl FakeClock {
fn new(initial_ns: u64) -> Self {
Self {
value_ns: AtomicU64::new(initial_ns),
}
}
fn set(&self, value_ns: u64) {
self.value_ns.store(value_ns, Ordering::Release);
}
}
impl MonotonicClock for FakeClock {
fn now_ns(&self) -> u64 {
self.value_ns.load(Ordering::Acquire)
}
}
fn make_f32_payload(samples: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(samples.len() * 4);
for sample in samples {
out.extend_from_slice(&sample.to_ne_bytes());
}
out
}
#[test]
fn production_callback_marks_freshness_with_monotonic_clock() {
let clock = Arc::new(FakeClock::new(7_500_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..960).map(|n| (n as f32) * 0.0001).collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
assert_eq!(observed, 7_500_000);
assert_ne!(observed, u64::MAX);
}
#[test]
fn freshness_age_grows_to_signal_stale_source_after_threshold() {
let clock = Arc::new(FakeClock::new(1_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
let frame = vec![0.1_f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES];
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload);
let after_first = last_push_ns.load(Ordering::Acquire);
assert_eq!(after_first, 1_000_000);
clock.set(1_000_000 + SOURCE_STALE_AFTER_NS + 1);
let age = clock.now_ns() - after_first;
assert!(age > SOURCE_STALE_AFTER_NS);
clock.set(2_000_000 + SOURCE_STALE_AFTER_NS + 1);
let payload2 = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload2);
let after_second = last_push_ns.load(Ordering::Acquire);
assert!(after_second > after_first);
assert_eq!(after_second, 2_000_000 + SOURCE_STALE_AFTER_NS + 1);
let fresh_age = clock.now_ns() - after_second;
assert_eq!(fresh_age, 0);
}
#[test]
fn callback_path_does_not_allocate_in_steady_state() {
let clock = Arc::new(FakeClock::new(1_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
let frame = vec![0.05_f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES];
let payload = make_f32_payload(&frame);
for _ in 0..400 {
clock.set(clock.now_ns() + 10_000_000);
process_audio_chunk(&mut data, &payload);
}
let allocs_before = crate::audio_mix_runtime::ALLOC_PROBE.load(Ordering::Relaxed);
crate::audio_mix_runtime::begin_thread_alloc_probe();
clock.set(clock.now_ns() + 10_000_000);
process_audio_chunk(&mut data, &payload);
let probed = crate::audio_mix_runtime::end_thread_alloc_probe();
let allocs_after = crate::audio_mix_runtime::ALLOC_PROBE.load(Ordering::Relaxed);
assert_eq!(
probed,
0,
"steady-state callback allocated {probed} times (global delta {})",
allocs_after.saturating_sub(allocs_before)
);
}
#[test]
fn production_callback_freshness_drives_audio_mix_runtime_mark_pushed() {
use crate::audio_mix_runtime::{
AudioMixRuntimeBuilder, CaptureSource, MIX_CHANNELS, MIX_SAMPLE_RATE_HZ,
NullMixOutputSink,
};
let clock = Arc::new(FakeClock::new(9_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
let source_id: u64 = 1;
let (_source, consumer) =
CaptureSource::create(source_id, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS).expect("source");
let mut runtime = AudioMixRuntimeBuilder::new()
.with_clock(Arc::clone(&clock) as Arc<dyn MonotonicClock>)
.add_source_with_freshness(source_id, consumer, Arc::clone(&last_push_ns))
.build(NullMixOutputSink)
.expect("build");
assert_eq!(runtime.mark_pushed_total(), 0);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0001)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
assert_eq!(observed, 9_000_000);
let _ = runtime.run_one_tick_blocking(observed).expect("frame");
assert!(
runtime.mark_pushed_total() >= 1,
"AudioMixRuntime.tick() did not invoke StaleSourceTracker::mark_pushed",
);
let not_stale = !runtime.is_source_stale(0, observed + 1_000_000, 5_000_000_000);
assert!(
not_stale,
"source must not be stale immediately after a fresh push"
);
}
}
@@ -0,0 +1,662 @@
#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::{HashMap, HashSet};
pub type PropMap = HashMap<String, String>;
pub type PropPattern = PropMap;
pub const MEDIA_CLASS_PLAYBACK_STREAM: &str = "Stream/Output/Audio";
const TARGET_OBJECTS_PATTERN_KEY: &str = "fluxer.target.objects";
const DISPLAY_PATTERN_PREFIX: &str = "fluxer.display.";
#[derive(Debug, Default, Clone)]
pub struct SelfIdentity {
pub pids: HashSet<String>,
pub binaries: HashSet<String>,
pub display_names: HashSet<String>,
pub display_prefixes: Vec<String>,
}
impl SelfIdentity {
pub fn add_pid(&mut self, pid: impl Into<String>) {
let value = pid.into();
if value.is_empty() {
return;
}
self.pids.insert(value);
}
pub fn add_binary(&mut self, name: impl Into<String>) {
let value = name.into();
if value.is_empty() {
return;
}
self.binaries.insert(value);
}
pub fn add_display_name(&mut self, name: impl Into<String>) {
let value = name.into();
if value.is_empty() {
return;
}
self.display_names.insert(value);
}
pub fn add_display_prefix(&mut self, prefix: impl Into<String>) {
let value = prefix.into();
if value.is_empty() {
return;
}
self.display_prefixes.push(value);
}
pub fn matches(&self, properties: &PropMap) -> bool {
if let Some(raw) = properties.get("application.process.id")
&& self.pids.contains(raw)
{
return true;
}
if let Some(raw) = properties.get("pipewire.sec.pid")
&& self.pids.contains(raw)
{
return true;
}
if let Some(raw) = properties.get("application.process.binary")
&& contains_case_insensitive(&self.binaries, raw)
{
return true;
}
for key in [
"application.name",
"node.name",
"node.nick",
"node.description",
] {
if let Some(raw) = properties.get(key)
&& self.matches_display_identity(raw)
{
return true;
}
}
false
}
fn matches_display_identity(&self, raw: &str) -> bool {
contains_case_insensitive(&self.binaries, raw)
|| contains_case_insensitive(&self.display_names, raw)
|| self
.display_prefixes
.iter()
.any(|prefix| starts_with_case_insensitive(raw, prefix))
}
}
fn contains_case_insensitive(values: &HashSet<String>, needle: &str) -> bool {
values
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(needle))
}
fn starts_with_case_insensitive(value: &str, prefix: &str) -> bool {
value
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
#[derive(Debug, Default, Clone)]
pub struct RoutingRule {
pub include_when: Vec<PropPattern>,
pub never_when: Vec<PropPattern>,
pub pin_target_for: Vec<PropPattern>,
pub skip_hardware_devices: bool,
pub only_audio_sinks: bool,
pub only_default_audio_sink: bool,
}
pub fn matches_pattern(candidate: &PropMap, expected: &PropPattern) -> bool {
for (key, value) in expected {
if key.starts_with(DISPLAY_PATTERN_PREFIX) {
continue;
}
if key == TARGET_OBJECTS_PATTERN_KEY {
if !matches_target_object(candidate, value) {
return false;
}
continue;
}
match candidate.get(key) {
Some(actual) if actual == value => {}
_ => return false,
}
}
true
}
fn matches_target_object(candidate: &PropMap, expected_values: &str) -> bool {
let Some(actual) = candidate
.get("target.object")
.or_else(|| candidate.get("node.target"))
else {
return false;
};
expected_values
.split('\n')
.filter(|value| !value.is_empty())
.any(|expected| actual == expected)
}
pub fn matches_any(candidate: &PropMap, patterns: &[PropPattern]) -> bool {
patterns.iter().any(|p| matches_pattern(candidate, p))
}
pub fn should_route_node(
id: u32,
properties: &PropMap,
rule: &RoutingRule,
default_sink_name: &str,
default_sink_target_id: &str,
sink_global_id: u32,
self_identity: &SelfIdentity,
) -> bool {
if id == sink_global_id {
return false;
}
if self_identity.matches(properties) {
return false;
}
if matches_any(properties, &rule.never_when) {
return false;
}
if rule.skip_hardware_devices && properties.contains_key("device.id") {
return false;
}
let Some(class) = properties.get("media.class") else {
return false;
};
if class != MEDIA_CLASS_PLAYBACK_STREAM {
return false;
}
if !rule.include_when.is_empty() {
return matches_any(properties, &rule.include_when);
}
if rule.only_audio_sinks {
return !rule.only_default_audio_sink
|| targets_default_sink(properties, default_sink_name, default_sink_target_id);
}
false
}
pub fn targets_default_sink(
properties: &PropMap,
default_sink_name: &str,
default_sink_target_id: &str,
) -> bool {
if default_sink_name.is_empty() && default_sink_target_id.is_empty() {
return true;
}
let Some(target) = properties
.get("target.object")
.or_else(|| properties.get("node.target"))
else {
return true;
};
(!default_sink_name.is_empty() && target == default_sink_name)
|| (!default_sink_target_id.is_empty() && target == default_sink_target_id)
}
pub fn parse_default_sink_name(blob: &str) -> String {
let trimmed = blob.trim();
if !trimmed.starts_with('{') {
return String::new();
}
let bytes = trimmed.as_bytes();
let mut i = 1usize;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'"' {
return String::new();
}
i += 1;
let key_start = i;
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' {
i += 2;
} else {
i += 1;
}
}
if i >= bytes.len() {
return String::new();
}
let key = &trimmed[key_start..i];
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b':' {
return String::new();
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
return String::new();
}
if bytes[i] != b'"' {
let mut depth = 0usize;
while i < bytes.len() {
match bytes[i] {
b'{' | b'[' => depth += 1,
b'}' | b']' => {
if depth == 0 {
return String::new();
}
depth -= 1;
}
b',' if depth == 0 => break,
_ => {}
}
i += 1;
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
continue;
}
return String::new();
}
i += 1;
let val_start = i;
let mut buf = String::new();
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
let escaped = bytes[i + 1];
buf.push(escaped as char);
i += 2;
} else {
buf.push(bytes[i] as char);
i += 1;
}
}
if i >= bytes.len() {
return String::new();
}
if key == "name" {
if buf.len() == i - val_start {
return trimmed[val_start..i].to_string();
}
return buf;
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
}
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_map(entries: &[(&str, &str)]) -> PropMap {
entries
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn system_rule() -> RoutingRule {
RoutingRule {
skip_hardware_devices: true,
only_audio_sinks: true,
only_default_audio_sink: true,
..Default::default()
}
}
#[test]
fn empty_pattern_matches_any_candidate() {
let candidate = make_map(&[("application.name", "Example")]);
let empty: PropPattern = PropPattern::new();
assert!(matches_pattern(&candidate, &empty));
}
#[test]
fn missing_keys_and_mismatched_values_do_not_match() {
let candidate = make_map(&[("application.name", "Example")]);
let missing = make_map(&[("application.process.id", "1234")]);
let mismatched = make_map(&[("application.name", "Other")]);
assert!(!matches_pattern(&candidate, &missing));
assert!(!matches_pattern(&candidate, &mismatched));
}
#[test]
fn synthetic_target_object_pattern_matches_name_or_serial() {
let candidate = make_map(&[("target.object", "42")]);
let deprecated = make_map(&[("node.target", "alsa_output.foo")]);
let pattern = make_map(&[(TARGET_OBJECTS_PATTERN_KEY, "alsa_output.foo\n42")]);
let mismatch = make_map(&[(TARGET_OBJECTS_PATTERN_KEY, "alsa_output.foo\n99")]);
assert!(matches_pattern(&candidate, &pattern));
assert!(matches_pattern(&deprecated, &pattern));
assert!(!matches_pattern(&candidate, &mismatch));
}
#[test]
fn synthetic_display_pattern_keys_do_not_affect_routing() {
let candidate = make_map(&[("application.name", "Example")]);
let pattern = make_map(&[
("application.name", "Example"),
("fluxer.display.name", "Living room speakers"),
]);
assert!(matches_pattern(&candidate, &pattern));
}
#[test]
fn matches_any_requires_at_least_one_matching_pattern() {
let candidate = make_map(&[("application.name", "Example")]);
let patterns = vec![
make_map(&[("application.name", "Other")]),
make_map(&[("application.name", "Example")]),
];
assert!(matches_any(&candidate, &patterns));
assert!(!matches_any(&candidate, &[]));
}
#[test]
fn system_mode_routes_only_default_playback_streams() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let hdmi = "alsa_output.pci-0000_01_00.1.hdmi-stereo";
let stream = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", analog),
]);
let other = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", hdmi),
]);
let rule = system_rule();
assert!(should_route_node(
100, &stream, &rule, analog, "", 1, &identity,
));
assert!(!should_route_node(
101, &other, &rule, analog, "", 1, &identity,
));
}
#[test]
fn structural_self_identity_wins_over_include_rules() {
let mut identity = SelfIdentity::default();
identity.add_pid("4242");
identity.add_binary("fluxer");
identity.add_display_name("Fluxer Canary");
identity.add_display_prefix("Fluxer ");
let rule = RoutingRule {
include_when: vec![make_map(&[("application.process.id", "4242")])],
..Default::default()
};
let by_pid = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("application.process.id", "4242"),
]);
let by_binary = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("application.process.binary", "fluxer"),
]);
let by_description = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("node.description", "Fluxer Direct Capture (pid 4242)"),
]);
assert!(!should_route_node(
200, &by_pid, &rule, "", "", 0, &identity
));
assert!(!should_route_node(
201, &by_binary, &rule, "", "", 0, &identity,
));
assert!(!should_route_node(
202,
&by_description,
&rule,
"",
"",
0,
&identity,
));
}
#[test]
fn routing_refuses_non_playback_media_classes_even_when_included() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Recorder")])],
..Default::default()
};
let input_stream = make_map(&[
("media.class", "Stream/Input/Audio"),
("application.name", "Recorder"),
]);
let device = make_map(&[
("media.class", "Audio/Source"),
("application.name", "Recorder"),
]);
assert!(!should_route_node(
300,
&input_stream,
&rule,
"",
"",
0,
&identity,
));
assert!(!should_route_node(
301, &device, &rule, "", "", 0, &identity
));
}
#[test]
fn system_mode_accepts_untargeted_and_node_target_streams() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let untargeted = make_map(&[("media.class", MEDIA_CLASS_PLAYBACK_STREAM)]);
let deprecated = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("node.target", analog),
]);
let rule = system_rule();
assert!(should_route_node(
100,
&untargeted,
&rule,
analog,
"",
1,
&identity,
));
assert!(should_route_node(
101,
&deprecated,
&rule,
analog,
"",
1,
&identity,
));
}
#[test]
fn system_mode_accepts_default_sink_object_id_targets() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let by_name = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", analog),
]);
let by_id = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", "42"),
]);
let other = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", "99"),
]);
let rule = system_rule();
assert!(should_route_node(
100, &by_name, &rule, analog, "42", 1, &identity,
));
assert!(should_route_node(
101, &by_id, &rule, analog, "42", 1, &identity,
));
assert!(!should_route_node(
102, &other, &rule, analog, "42", 1, &identity,
));
}
#[test]
fn include_mode_honors_hardware_filtering_and_never_rules() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Firefox")])],
never_when: vec![make_map(&[("application.process.id", "999")])],
skip_hardware_devices: true,
..Default::default()
};
let app = make_map(&[
("application.name", "Firefox"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let hardware = make_map(&[("application.name", "Firefox"), ("device.id", "5")]);
let blocked = make_map(&[
("application.name", "Firefox"),
("application.process.id", "999"),
]);
assert!(should_route_node(10, &app, &rule, "", "", 1, &identity));
assert!(!should_route_node(
11, &hardware, &rule, "", "", 1, &identity
));
assert!(!should_route_node(
12, &blocked, &rule, "", "", 1, &identity
));
}
#[test]
fn include_mode_rejects_non_playback_nodes_that_match_the_include_filter() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Chromium")])],
..Default::default()
};
let mic = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Source"),
]);
let sink = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Sink"),
]);
let input = make_map(&[
("application.name", "Chromium"),
("media.class", "Stream/Input/Audio"),
]);
let virtual_source = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Source/Virtual"),
]);
let playback = make_map(&[
("application.name", "Chromium"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let unclassified = make_map(&[("application.name", "Chromium")]);
assert!(!should_route_node(20, &mic, &rule, "", "", 1, &identity));
assert!(!should_route_node(21, &sink, &rule, "", "", 1, &identity));
assert!(!should_route_node(22, &input, &rule, "", "", 1, &identity));
assert!(!should_route_node(
23,
&virtual_source,
&rule,
"",
"",
1,
&identity,
));
assert!(!should_route_node(
24,
&unclassified,
&rule,
"",
"",
1,
&identity,
));
assert!(should_route_node(
25, &playback, &rule, "", "", 1, &identity
));
}
#[test]
fn empty_rules_route_nothing_and_sink_id_is_excluded() {
let identity = SelfIdentity::default();
let app = make_map(&[
("application.name", "Foo"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let empty = RoutingRule::default();
assert!(!should_route_node(1, &app, &empty, "", "", 0, &identity));
let rule = system_rule();
assert!(!should_route_node(7, &app, &rule, "", "", 7, &identity));
}
#[test]
fn parse_default_sink_name_is_strict_and_tolerant() {
assert_eq!(
"alsa_output.foo",
parse_default_sink_name(r#"{"name":"alsa_output.foo","other":"bar"}"#),
);
assert_eq!("", parse_default_sink_name("not-json"));
assert_eq!("", parse_default_sink_name(r#"{"name":42}"#));
}
#[test]
fn self_identity_matches_across_documented_pipewire_keys() {
let mut identity = SelfIdentity::default();
identity.add_pid("1234");
identity.add_binary("fluxer");
identity.add_display_name("Fluxer Canary");
identity.add_display_prefix("Fluxer ");
let by_pid = make_map(&[("application.process.id", "1234")]);
let by_sec_pid = make_map(&[("pipewire.sec.pid", "1234")]);
let by_binary = make_map(&[("application.process.binary", "fluxer")]);
let by_app_name = make_map(&[("application.name", "fluxer")]);
let by_node_name = make_map(&[("node.name", "fluxer")]);
let by_node_nick = make_map(&[("node.nick", "Fluxer Canary")]);
let by_node_description = make_map(&[("node.description", "Fluxer app audio capture")]);
let stranger = make_map(&[("application.process.id", "9999")]);
assert!(identity.matches(&by_pid));
assert!(identity.matches(&by_sec_pid));
assert!(identity.matches(&by_binary));
assert!(identity.matches(&by_app_name));
assert!(identity.matches(&by_node_name));
assert!(identity.matches(&by_node_nick));
assert!(identity.matches(&by_node_description));
assert!(!identity.matches(&stranger));
}
}
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use crate::routing::SelfIdentity;
const PRODUCT_DISPLAY_NAMES: &[&str] = &["Fluxer", "Fluxer Canary"];
const PRODUCT_DISPLAY_PREFIXES: &[&str] = &[
"Fluxer ", "fluxer ", "Fluxer-", "fluxer-", "Fluxer_", "fluxer_", "Fluxer.", "fluxer.",
];
pub fn populate_self_identity(out: &mut SelfIdentity) {
let own_pid = std::process::id();
out.add_pid(own_pid.to_string());
for name in PRODUCT_DISPLAY_NAMES {
out.add_display_name((*name).to_string());
}
for prefix in PRODUCT_DISPLAY_PREFIXES {
out.add_display_prefix((*prefix).to_string());
}
if let Ok(comm) = fs::read_to_string("/proc/self/comm") {
let trimmed = comm.trim();
if !trimmed.is_empty() {
out.add_binary(trimmed.to_string());
}
}
if let Ok(exe) = fs::read_link("/proc/self/exe")
&& let Some(name) = exe.file_name().and_then(|s| s.to_str())
{
out.add_binary(name.to_string());
if let Some(stripped) = strip_exe_suffix(name) {
out.add_binary(stripped.to_string());
}
}
out.add_binary("Electron".to_string());
out.add_binary("electron".to_string());
let _ = add_descendant_pids(out, own_pid);
}
fn strip_exe_suffix(name: &str) -> Option<&str> {
for suffix in [".AppImage", ".bin"] {
if name.len() >= suffix.len()
&& name[name.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
{
return Some(&name[..name.len() - suffix.len()]);
}
}
None
}
pub fn parse_ppid_from_stat(blob: &str) -> Option<u32> {
let close = blob.rfind(')')?;
let tail = &blob[close + 1..];
let mut fields = tail.split_ascii_whitespace();
let _state = fields.next()?;
let ppid = fields.next()?;
ppid.parse::<u32>().ok()
}
fn add_descendant_pids(out: &mut SelfIdentity, own_pid: u32) -> std::io::Result<()> {
let mut entries: Vec<(u32, u32)> = Vec::new();
for entry in fs::read_dir(Path::new("/proc"))? {
let Ok(entry) = entry else { continue };
let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) else {
continue;
};
let Ok(pid) = name.parse::<u32>() else {
continue;
};
let stat_path = format!("/proc/{pid}/stat");
let Ok(blob) = fs::read_to_string(&stat_path) else {
continue;
};
let Some(ppid) = parse_ppid_from_stat(&blob) else {
continue;
};
entries.push((pid, ppid));
}
let mut by_parent: HashMap<u32, Vec<u32>> = HashMap::new();
for &(pid, ppid) in &entries {
by_parent.entry(ppid).or_default().push(pid);
}
let mut ours: HashSet<u32> = HashSet::new();
ours.insert(own_pid);
let mut frontier = vec![own_pid];
while let Some(parent) = frontier.pop() {
if let Some(children) = by_parent.get(&parent) {
for &child in children {
if ours.insert(child) {
frontier.push(child);
}
}
}
}
for pid in ours {
out.add_pid(pid.to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ppid_handles_comm_with_spaces_and_parens() {
let stat = "1234 (weird (comm) name) S 4321 1234 1234 0 -1 4194304 0 0 0 0";
assert_eq!(Some(4321), parse_ppid_from_stat(stat));
}
#[test]
fn parse_ppid_handles_plain_stat() {
let stat = "42 (cat) R 7 42 7 34816 42 4194304 91 0 0 0";
assert_eq!(Some(7), parse_ppid_from_stat(stat));
}
#[test]
fn parse_ppid_returns_none_on_malformed_input() {
assert_eq!(None, parse_ppid_from_stat(""));
assert_eq!(None, parse_ppid_from_stat("no closing paren"));
assert_eq!(None, parse_ppid_from_stat("1 (cat)"));
}
#[test]
fn strip_exe_suffix_handles_known_extensions() {
assert_eq!(Some("fluxer"), strip_exe_suffix("fluxer.AppImage"));
assert_eq!(Some("fluxer"), strip_exe_suffix("fluxer.bin"));
assert_eq!(None, strip_exe_suffix("fluxer"));
assert_eq!(Some("fluxer"), strip_exe_suffix("fluxer.APPIMAGE"));
}
#[test]
fn populate_self_identity_records_own_pid() {
let mut id = SelfIdentity::default();
populate_self_identity(&mut id);
assert!(id.pids.contains(&std::process::id().to_string()));
assert!(id.binaries.contains("Electron"));
assert!(id.binaries.contains("electron"));
}
}
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {execFileSync} from 'node:child_process';
import {copyFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {setTimeout as sleep} from 'node:timers/promises';
const require = createRequire(import.meta.url);
const work = process.env.FLX_WORK;
const addonNode = `${work}/flx_direct_addon.node`;
copyFileSync(process.env.FLX_ADDON_SO, addonNode);
const addon = require(addonNode);
const results = [];
function record(name, ok, detail) {
results.push({name, ok});
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` -- ${detail}` : ''}`);
}
function pwDump() {
return JSON.parse(execFileSync('pw-dump', {encoding: 'utf8', maxBuffer: 64e6}));
}
function directSinkNode(dump) {
return dump.find(
(o) => o.type === 'PipeWire:Interface:Node' && /^fluxer-direct-capture-/.test(o.info?.props?.['node.name'] || ''),
);
}
async function waitFor(p, ms, step = 150) {
const end = Date.now() + ms;
let last;
while (Date.now() < end) {
last = p();
if (last) return last;
await sleep(step);
}
return last;
}
function rms(samples) {
if (samples.length === 0) return 0;
let sum = 0;
for (const s of samples) sum += s * s;
return Math.sqrt(sum / samples.length);
}
async function main() {
const dc = new addon.DirectAudioCapture();
let lifecycle = null;
dc.setLifecycleCallback((...args) => {
const flat = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
lifecycle = {kind: flat[0], msg: flat[1]};
});
const started = dc.start({include: [{'application.name': 'Music Player Demo'}]});
record('DirectAudioCapture.start(include rule) accepted', started === true);
const sinkUp = await waitFor(() => {
const d = pwDump();
return directSinkNode(d) ? d : null;
}, 8000);
record(
'hidden private sink fluxer-direct-capture-* created',
!!sinkUp,
sinkUp ? directSinkNode(sinkUp).info.props['node.name'] : 'timeout',
);
if (sinkUp) {
const sinkProps = directSinkNode(sinkUp).info.props;
record(
'private sink node.hidden=true (not user-visible)',
String(sinkProps['node.hidden']) === 'true',
`node.hidden=${sinkProps['node.hidden']}`,
);
record('private sink media.class=Audio/Sink', sinkProps['media.class'] === 'Audio/Sink', sinkProps['media.class']);
}
let maxRms = 0;
let frames = 0;
let totalSamples = 0;
const captureDeadline = Date.now() + 6000;
while (Date.now() < captureDeadline) {
const f = dc.read();
if (f) {
const samples = new Float32Array(f.samples);
frames += 1;
totalSamples += samples.length;
maxRms = Math.max(maxRms, rms(samples));
if (maxRms > 0.01 && frames > 5) break;
}
await sleep(20);
}
record('DirectAudioCapture yields real frames', frames > 0, `${frames} frames, ${totalSamples} samples`);
record('captured audio is non-silent (real 440Hz tone tapped)', maxRms > 0.01, `peak rms=${maxRms.toFixed(4)}`);
const dump = pwDump();
const sink = directSinkNode(dump);
const sinkId = Number(sink?.id);
const linkSrcNodes = new Set(
dump
.filter((o) => o.type === 'PipeWire:Interface:Link' && Number(o.info?.props?.['link.input.node']) === sinkId)
.map((o) => Number(o.info?.props?.['link.output.node'])),
);
const fluxerStreamIds = dump
.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['application.name'] === 'Fluxer')
.map((o) => Number(o.id));
record(
'Fluxer-named app excluded from per-process capture',
fluxerStreamIds.every((id) => !linkSrcNodes.has(id)),
`fluxer=${fluxerStreamIds} linkedSrc=${[...linkSrcNodes]}`,
);
const musicIds = dump
.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['application.name'] === 'Music Player Demo')
.map((o) => Number(o.id));
record(
'targeted app IS linked to the private sink',
musicIds.some((id) => linkSrcNodes.has(id)),
`music=${musicIds} linkedSrc=${[...linkSrcNodes]}`,
);
dc.stop();
await sleep(600);
record('stop() emits closed-clean lifecycle', lifecycle?.kind === 'closed-clean', JSON.stringify(lifecycle));
const afterStop = pwDump();
const sinkAfter = directSinkNode(afterStop);
const residualLinks = sinkAfter
? afterStop.filter(
(o) =>
o.type === 'PipeWire:Interface:Link' && Number(o.info?.props?.['link.input.node']) === Number(sinkAfter.id),
).length
: 0;
record('stop() removes capture links', residualLinks === 0, `residual=${residualLinks}`);
const failed = results.filter((r) => !r.ok).length;
console.log(`\n=== direct: ${results.length - failed}/${results.length} checks passed ===`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('HARNESS ERROR:', e?.stack || e);
process.exit(2);
});
@@ -0,0 +1,233 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {execFileSync, spawn} from 'node:child_process';
import {copyFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {setTimeout as sleep} from 'node:timers/promises';
const require = createRequire(import.meta.url);
const addonSo = req('FLX_ADDON_SO');
const work = req('FLX_WORK');
const tone = req('FLX_TONE');
const addonNode = `${work}/flx_audio_addon.node`;
copyFileSync(addonSo, addonNode);
const addon = require(addonNode);
const SINK_NAME = 'fluxer-screen-share';
const SINK_DESC = 'Fluxer Screen Share Audio';
const results = [];
const children = [];
function req(name) {
const v = process.env[name];
if (!v) {
console.error(`HARNESS ERROR: ${name} not set`);
process.exit(2);
}
return v;
}
function record(name, ok, detail) {
results.push({name, ok});
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` -- ${detail}` : ''}`);
}
function pwDump() {
return JSON.parse(execFileSync('pw-dump', {encoding: 'utf8', maxBuffer: 64e6}));
}
function nodesByName(dump, name) {
return dump.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === name);
}
function links(dump) {
return dump
.filter((o) => o.type === 'PipeWire:Interface:Link')
.map((o) => ({
inNode: Number(o.info?.props?.['link.input.node']),
outNode: Number(o.info?.props?.['link.output.node']),
}));
}
function streamNodes(dump) {
return dump.filter(
(o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['media.class'] === 'Stream/Output/Audio',
);
}
async function waitFor(predicate, timeoutMs, stepMs = 150) {
const deadline = Date.now() + timeoutMs;
let last;
while (Date.now() < deadline) {
last = predicate();
if (last) return last;
await sleep(stepMs);
}
return last;
}
function killAll() {
for (const c of children) {
try {
c.kill('SIGKILL');
} catch {}
}
}
async function main() {
record('backend is pipewire', addon.audioBackend() === 'pipewire', addon.audioBackend());
const descendant = spawn(
'pw-play',
['--target', 'test_speakers', '-P', '{ application.name = "Descendant Player" }', tone],
{
stdio: 'ignore',
env: process.env,
},
);
children.push(descendant);
const speakerIdOf = (d) => nodesByName(d, 'test_speakers')[0]?.id;
const sourcesInto = (dump) => {
const sid = Number(speakerIdOf(dump));
return new Set(
links(dump)
.filter((l) => l.inNode === sid)
.map((l) => l.outNode),
).size;
};
const pre = await waitFor(() => {
const d = pwDump();
return streamNodes(d).length >= 5 && sourcesInto(d) >= 5 ? d : null;
}, 12000);
record(
'all 5 playback streams routed to speakers pre-capture',
!!pre,
pre ? `${streamNodes(pre).length} streams, ${sourcesInto(pre)} routed` : 'timeout',
);
const preDump = pre || pwDump();
const speakers = nodesByName(preDump, 'test_speakers');
record('default sink test_speakers exists', speakers.length === 1);
const preSpeakerSources = sourcesInto(preDump);
record(
'every app is playing to the real speakers pre-capture',
preSpeakerSources >= 5,
`${preSpeakerSources} distinct app streams -> speakers`,
);
const bridge = new addon.AudioBridge();
record('AudioBridge on pipewire', bridge.backend() === 'pipewire', bridge.backend());
record(
'apply(system rule) accepted',
bridge.apply({onlySpeakers: true, onlyDefaultSpeakers: true, ignoreDevices: true}) === true,
);
const after = await waitFor(() => {
const d = pwDump();
if (nodesByName(d, SINK_NAME).length !== 1) return null;
const g = bridge.routingGraph();
return g.ownedLinks.length >= 2 ? {d, g} : null;
}, 10000);
if (!after) {
record('fluxer sink + capture links established', false, 'timeout');
await finish(bridge);
return;
}
const {d: dump, g: graph} = after;
const sink = nodesByName(dump, SINK_NAME);
record('exactly one fluxer-screen-share node', sink.length === 1, `count=${sink.length}`);
const sp = sink[0]?.info?.props ?? {};
record(
'sink node.description is "Fluxer Screen Share Audio"',
sp['node.description'] === SINK_DESC,
sp['node.description'],
);
record('sink media.class is Audio/Source/Virtual', sp['media.class'] === 'Audio/Source/Virtual', sp['media.class']);
record('sink node.virtual=true', String(sp['node.virtual']) === 'true');
const sinkId = Number(sink[0]?.id);
record(
'all owned links are passive',
graph.ownedLinks.every((l) => l.passive === true),
`${graph.ownedLinks.length} links`,
);
record(
'all owned links terminate at the fluxer sink',
graph.ownedLinks.every((l) => Number(l.inputNodeId) === sinkId),
);
const captured = new Set(graph.ownedLinks.map((l) => Number(l.outputNodeId)));
const idsByPredicate = (pred) =>
streamNodes(dump)
.filter((o) => pred(o.info.props))
.map((o) => Number(o.id));
const normalIds = idsByPredicate(
(p) =>
['pw-play', 'Music Player Demo'].includes(p['application.name']) &&
p['application.name'] !== 'Fluxer' &&
p['application.name'] !== 'Descendant Player' &&
!(p['node.name'] || '').startsWith('Fluxer '),
);
const fluxerAppIds = idsByPredicate((p) => p['application.name'] === 'Fluxer');
const fluxerNodeIds = idsByPredicate((p) => (p['node.name'] || '').startsWith('Fluxer '));
const descendantIds = idsByPredicate((p) => p['application.name'] === 'Descendant Player');
record(
'normal external apps ARE captured',
normalIds.length >= 2 && normalIds.every((id) => captured.has(id)),
`normal=${normalIds} captured=${[...captured]}`,
);
record(
'Fluxer-named app (application.name) is EXCLUDED',
fluxerAppIds.length >= 1 && fluxerAppIds.every((id) => !captured.has(id)),
`fluxerApp=${fluxerAppIds}`,
);
record(
'Fluxer-named app (node.name prefix) is EXCLUDED',
fluxerNodeIds.length >= 1 && fluxerNodeIds.every((id) => !captured.has(id)),
`fluxerNode=${fluxerNodeIds}`,
);
record(
'descendant-PID player is EXCLUDED (self-process tree)',
descendantIds.length >= 1 && descendantIds.every((id) => !captured.has(id)),
`descendant=${descendantIds}`,
);
const afterSpeakerSources = sourcesInto(dump);
record(
'apps STILL play to real speakers during capture (tap, not move)',
afterSpeakerSources >= preSpeakerSources,
`before=${preSpeakerSources} after=${afterSpeakerSources} distinct app streams -> speakers`,
);
const meta = dump.find((o) => o.type === 'PipeWire:Interface:Metadata' && o.props?.['metadata.name'] === 'default');
const def = meta?.metadata?.find((m) => m.key === 'default.audio.sink')?.value?.name;
record('default audio sink unchanged (test_speakers)', def === 'test_speakers', `default=${def}`);
await finish(bridge);
}
async function finish(bridge) {
bridge.release();
const cleared = await waitFor(() => (bridge.routingGraph().ownedLinks.length === 0 ? true : null), 5000);
record(
'release()+settle removes all owned links',
!!cleared,
cleared ? '0 owned links' : `still ${bridge.routingGraph().ownedLinks.length}`,
);
await sleep(500);
const d = pwDump();
const sinkId = Number(nodesByName(d, SINK_NAME)[0]?.id);
const residual = Number.isNaN(sinkId) ? 0 : links(d).filter((l) => l.inNode === sinkId).length;
record('no residual links into fluxer sink after release', residual === 0, `residual=${residual}`);
killAll();
const failed = results.filter((r) => !r.ok).length;
console.log(`\n=== ${results.length - failed}/${results.length} checks passed ===`);
process.exit(failed === 0 ? 0 : 1);
}
process.on('exit', killAll);
main().catch((e) => {
killAll();
console.error('HARNESS ERROR:', e?.stack || e);
process.exit(2);
});
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -uo pipefail
WORK="${FLX_WORK:-/home/parallels/flx-vmtest}"
ADDON_SO="${FLX_ADDON_SO:-/home/parallels/flx-target/debug/libfluxer_linux_audio_capture.so}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$WORK"
export XDG_RUNTIME_DIR="$WORK/xdg"
mkdir -p "$XDG_RUNTIME_DIR"; chmod 700 "$XDG_RUNTIME_DIR"
export PIPEWIRE_RUNTIME_DIR="$XDG_RUNTIME_DIR"
export PULSE_RUNTIME_PATH="$XDG_RUNTIME_DIR/pulse"
unset DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
PIDS=()
cleanup() {
for p in "${PIDS[@]:-}"; do kill -9 "$p" 2>/dev/null; done
pkill -9 -u "$(id -un)" -f "pipewire" 2>/dev/null
pkill -9 -u "$(id -un)" -f "wireplumber" 2>/dev/null
}
trap cleanup EXIT
echo "=== starting private pipewire server (runtime=$XDG_RUNTIME_DIR) ==="
pipewire >"$WORK/pipewire.log" 2>&1 & PIDS+=($!)
sleep 0.8
pipewire-pulse >"$WORK/pipewire-pulse.log" 2>&1 & PIDS+=($!)
sleep 0.5
wireplumber >"$WORK/wireplumber.log" 2>&1 & PIDS+=($!)
for _ in $(seq 1 40); do pw-cli info 0 >/dev/null 2>&1 && break; sleep 0.25; done
if ! pw-cli info 0 >/dev/null 2>&1; then
echo "FATAL: private pipewire did not come up"; cat "$WORK/pipewire.log"; exit 2
fi
echo "=== creating virtual speakers (default sink) ==="
pactl load-module module-null-sink sink_name=test_speakers sink_properties='device.description=Test_Speakers' >/dev/null 2>&1
pactl set-default-sink test_speakers 2>/dev/null
sleep 0.4
echo "=== generating a real 600s stereo tone ==="
TONE="$WORK/tone.wav"
[ -f "$TONE" ] || ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=440:duration=600" -ac 2 -ar 48000 "$TONE" </dev/null
export FLX_TONE="$TONE"
echo "=== spawning 4 external real-app players (outside the harness process tree) ==="
pw-play --target test_speakers "$TONE" >/dev/null 2>&1 & PIDS+=($!)
pw-play --target test_speakers -P '{ application.name = "Music Player Demo" }' "$TONE" >/dev/null 2>&1 & PIDS+=($!)
pw-play --target test_speakers -P '{ application.name = "Fluxer" }' "$TONE" >/dev/null 2>&1 & PIDS+=($!)
pw-play --target test_speakers -P '{ node.name = "Fluxer Helper Stream" }' "$TONE" >/dev/null 2>&1 & PIDS+=($!)
sleep 1.5
echo "=== pre-test graph (fluxer sink should be ABSENT) ==="
pw-dump | node -e 'const d=JSON.parse(require("fs").readFileSync(0));const f=d.filter(o=>o.type==="PipeWire:Interface:Node"&&/fluxer-screen-share/.test(o.info?.props?.["node.name"]||""));console.log("fluxer sink nodes pre-test:",f.length);'
echo "=== running napi SYSTEM-capture validation harness ==="
export FLX_ADDON_SO="$ADDON_SO" FLX_WORK="$WORK"
node "$HERE/pw_graph_validation.mjs"
HARNESS_RC=$?
echo "=== running napi DIRECT (per-process) capture validation harness ==="
node "$HERE/direct_capture_validation.mjs"
DIRECT_RC=$?
[ "$DIRECT_RC" = "0" ] || HARNESS_RC=$DIRECT_RC
echo "=== post-exit cleanup check (Drop must remove the fluxer sink) ==="
sleep 0.8
RESIDUAL=$(pw-dump | node -e 'const d=JSON.parse(require("fs").readFileSync(0));const f=d.filter(o=>(o.type==="PipeWire:Interface:Node"&&/fluxer/.test(o.info?.props?.["node.name"]||""))||(o.type==="PipeWire:Interface:Link"&&/fluxer/.test(JSON.stringify(o.info?.props||{}))));console.log(f.length);')
if [ "$RESIDUAL" = "0" ]; then
echo "PASS no fluxer nodes/links remain after addon process exit (clean teardown)"
else
echo "FAIL $RESIDUAL residual fluxer objects after addon process exit"
HARNESS_RC=1
fi
echo "=== DONE rc=$HARNESS_RC ==="
exit $HARNESS_RC