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:
+1342
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();
|
||||
}
|
||||
@@ -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: ®istry_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(®istry, &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: ®istry_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(
|
||||
®istry,
|
||||
&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
|
||||
Reference in New Issue
Block a user