Add native self-hosted instance connection to fluxer_desktop

Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Statically link the Microsoft C/C++ runtime into every native Rust addon we
# ship on Windows. Without this, the .node imports VCRUNTIME140.dll and the
# msvcp/msvcr family, which require the VC++ 2015-2022 redistributable to be
# installed system-wide. Fresh Windows boxes (and Windows IoT / LTSC variants)
# don't have it, so dlopen fails with ERR_DLOPEN_FAILED "The specified module
# could not be found." even though the .node file itself is present.
#
# +crt-static makes the runtime part of the .node itself. Universal CRT API
# sets (api-ms-win-crt-*.dll) are guaranteed present on Windows 10+ and remain
# dynamic; only the VC++-specific layer goes static.
#
# Scoped per-target so Linux and macOS builds are unaffected. Cargo walks
# parent directories looking for `.cargo/config.toml`, so this file applies to
# every crate under `fluxer_desktop/native/` even though each addon has its
# own `[workspace]`.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
# 32-bit injected game-capture hook/layer (for capturing 32-bit games from the
# 64-bit app). Same rationale: the DLL is LoadLibrary'd into an arbitrary game
# process that may not have the 32-bit VC++ redistributable, so the runtime
# must be static.
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+309
View File
@@ -0,0 +1,309 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autotools"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf"
dependencies = [
"cc",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fluxer_audio_apm"
version = "0.0.0"
dependencies = [
"webrtc-audio-processing",
"webrtc-audio-processing-config",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "webrtc-audio-processing"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "190c57ced88d6307ba91778c245265a99f99d900a9239c87a1b15142330c9f7e"
dependencies = [
"webrtc-audio-processing-config",
"webrtc-audio-processing-sys",
]
[[package]]
name = "webrtc-audio-processing-config"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142cbc598e3dcf1a71e90109b6169ca9ce28ec1910bf74eef30f3c420ce850a4"
[[package]]
name = "webrtc-audio-processing-sys"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8f6c2b4b20a03b165378172f0dbe23c49b2630b70091026589afbe04e79cd3e"
dependencies = [
"anyhow",
"autotools",
"bindgen",
"cc",
"fs_extra",
"pkg-config",
"regex",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
@@ -0,0 +1,26 @@
[package]
name = "fluxer_audio_apm"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["rlib"]
[features]
default = []
real-apm = [
"dep:webrtc-audio-processing",
"dep:webrtc-audio-processing-config",
"webrtc-audio-processing/bundled",
]
[dependencies]
webrtc-audio-processing = {version = "2.1", optional = true}
webrtc-audio-processing-config = {version = "2.1", optional = true}
[dev-dependencies]
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CRATE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
require_pkg_config() {
if ! command -v pkg-config >/dev/null 2>&1; then
echo "error: pkg-config is required but was not found in PATH." >&2
echo " Install via: brew install pkg-config (macOS)" >&2
echo " apt-get install pkg-config (Debian/Ubuntu)" >&2
exit 1
fi
}
assert_webrtc_pkgconfig() {
if ! pkg-config --exists webrtc-audio-processing-2; then
echo "error: pkg-config cannot locate webrtc-audio-processing-2." >&2
echo " See fluxer_desktop/native/audio-apm/README.md for install steps." >&2
exit 1
fi
}
assert_absl_pkgconfig() {
if ! pkg-config --exists absl_base; then
echo "error: pkg-config cannot locate absl_base." >&2
echo " Install via: brew install abseil (macOS)" >&2
echo " apt-get install libabsl-dev (Debian/Ubuntu)" >&2
exit 1
fi
}
inject_absl_include_path() {
local absl_includes
absl_includes="$(pkg-config --cflags-only-I absl_base)"
if [[ -z "${absl_includes}" ]]; then
echo "error: pkg-config returned no include path for absl_base." >&2
exit 1
fi
local prev_cxxflags="${CXXFLAGS-}"
local prev_bindgen="${BINDGEN_EXTRA_CLANG_ARGS-}"
export CXXFLAGS="${absl_includes} ${prev_cxxflags}"
export BINDGEN_EXTRA_CLANG_ARGS="${absl_includes} ${prev_bindgen}"
}
main() {
cd "${CRATE_DIR}"
local cargo_subcmd="build"
if [[ $# -gt 0 && "$1" != -* && "$1" != "--" ]]; then
cargo_subcmd="$1"
shift
fi
require_pkg_config
assert_webrtc_pkgconfig
assert_absl_pkgconfig
inject_absl_include_path
exec cargo "${cargo_subcmd}" --features real-apm "$@"
}
main "$@"
+972
View File
@@ -0,0 +1,972 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::too_many_lines)]
#![deny(clippy::unwrap_used)]
pub const APM_FRAME_MS: u32 = 10;
pub const APM_MAX_SAMPLE_RATE: u32 = 48_000;
pub const APM_MAX_CHANNELS: u16 = 2;
pub const APM_MAX_FRAME_SAMPLES: usize =
(APM_FRAME_MS as usize) * (APM_MAX_SAMPLE_RATE as usize) / 1000;
pub const APM_MIN_SAMPLE_RATE: u32 = 8_000;
pub const APM_MIN_CHANNELS: u16 = 1;
const _: () = assert!(APM_MAX_FRAME_SAMPLES == 480);
const _: () = assert!(APM_MIN_SAMPLE_RATE <= APM_MAX_SAMPLE_RATE);
const _: () = assert!(APM_MIN_CHANNELS <= APM_MAX_CHANNELS);
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum ApmError {
SampleRateOutOfRange {
sample_rate_hz: u32,
},
SampleRateMismatch {
expected_hz: u32,
observed_hz: u32,
},
ChannelsOutOfRange {
channels: u16,
},
ChannelsMismatch {
expected: u16,
observed: u16,
},
FrameLengthMismatch {
expected_samples: usize,
observed_samples: usize,
},
NotInitialized,
BackendUnavailable,
}
impl core::fmt::Display for ApmError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ApmError::SampleRateOutOfRange { sample_rate_hz } => write!(
f,
"sample rate {sample_rate_hz} hz outside [{APM_MIN_SAMPLE_RATE}, {APM_MAX_SAMPLE_RATE}]",
),
ApmError::SampleRateMismatch {
expected_hz,
observed_hz,
} => write!(
f,
"sample rate mismatch: expected={expected_hz} observed={observed_hz}",
),
ApmError::ChannelsOutOfRange { channels } => write!(
f,
"channel count {channels} outside [{APM_MIN_CHANNELS}, {APM_MAX_CHANNELS}]",
),
ApmError::ChannelsMismatch { expected, observed } => write!(
f,
"channels mismatch: expected={expected} observed={observed}",
),
ApmError::FrameLengthMismatch {
expected_samples,
observed_samples,
} => write!(
f,
"frame length mismatch: expected={expected_samples} observed={observed_samples}",
),
ApmError::NotInitialized => write!(f, "audio processor not initialized"),
ApmError::BackendUnavailable => write!(f, "real APM backend unavailable"),
}
}
}
impl std::error::Error for ApmError {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AecMetrics {
pub echo_return_loss_db: f32,
pub echo_return_loss_enhancement_db: f32,
pub delay_ms: i32,
}
impl AecMetrics {
pub const NEUTRAL: AecMetrics = AecMetrics {
echo_return_loss_db: 0.0,
echo_return_loss_enhancement_db: 0.0,
delay_ms: 0,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ApmReport {
pub aec_metrics: AecMetrics,
pub voice_detected: bool,
pub level_dbfs: f32,
}
impl ApmReport {
pub const NEUTRAL: ApmReport = ApmReport {
aec_metrics: AecMetrics::NEUTRAL,
voice_detected: false,
level_dbfs: -120.0,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ApmConfig {
pub aec_enabled: bool,
pub ns_enabled: bool,
pub agc_enabled: bool,
pub aec_mobile_mode: bool,
pub target_level_dbfs: i32,
}
impl Default for ApmConfig {
fn default() -> Self {
ApmConfig {
aec_enabled: true,
ns_enabled: true,
agc_enabled: true,
aec_mobile_mode: false,
target_level_dbfs: -3,
}
}
}
pub struct ApmConfigBuilder {
config: ApmConfig,
}
impl ApmConfigBuilder {
pub fn new() -> Self {
ApmConfigBuilder {
config: ApmConfig::default(),
}
}
pub fn aec(mut self, enabled: bool) -> Self {
self.config.aec_enabled = enabled;
self
}
pub fn ns(mut self, enabled: bool) -> Self {
self.config.ns_enabled = enabled;
self
}
pub fn agc(mut self, enabled: bool) -> Self {
self.config.agc_enabled = enabled;
self
}
pub fn aec_mobile_mode(mut self, enabled: bool) -> Self {
self.config.aec_mobile_mode = enabled;
self
}
pub fn target_level_dbfs(mut self, target: i32) -> Self {
self.config.target_level_dbfs = target;
self
}
pub fn build(self) -> ApmConfig {
assert!(self.config.target_level_dbfs <= 0);
assert!(self.config.target_level_dbfs >= -60);
self.config
}
}
impl Default for ApmConfigBuilder {
fn default() -> Self {
ApmConfigBuilder::new()
}
}
pub trait AudioProcessor: Send {
fn process_capture_frame(
&mut self,
samples: &mut [i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<ApmReport, ApmError>;
fn process_render_frame(
&mut self,
samples: &[i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<(), ApmError>;
fn reset(&mut self) -> Result<(), ApmError>;
}
pub fn expected_frame_samples(sample_rate_hz: u32, channels: u16) -> usize {
assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(sample_rate_hz <= APM_MAX_SAMPLE_RATE);
assert!(channels >= APM_MIN_CHANNELS);
assert!(channels <= APM_MAX_CHANNELS);
let per_channel = (APM_FRAME_MS as usize) * (sample_rate_hz as usize) / 1000;
per_channel * (channels as usize)
}
pub(crate) fn validate_frame_shape(
samples_len: usize,
sample_rate_hz: u32,
channels: u16,
expected_sample_rate_hz: u32,
expected_channels: u16,
) -> Result<(), ApmError> {
assert!(expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(expected_sample_rate_hz <= APM_MAX_SAMPLE_RATE);
if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) {
return Err(ApmError::SampleRateOutOfRange { sample_rate_hz });
}
if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) {
return Err(ApmError::ChannelsOutOfRange { channels });
}
if sample_rate_hz != expected_sample_rate_hz {
return Err(ApmError::SampleRateMismatch {
expected_hz: expected_sample_rate_hz,
observed_hz: sample_rate_hz,
});
}
if channels != expected_channels {
return Err(ApmError::ChannelsMismatch {
expected: expected_channels,
observed: channels,
});
}
let expected_samples = expected_frame_samples(sample_rate_hz, channels);
if samples_len != expected_samples {
return Err(ApmError::FrameLengthMismatch {
expected_samples,
observed_samples: samples_len,
});
}
Ok(())
}
#[derive(Debug)]
pub struct StubAudioProcessor {
config: ApmConfig,
expected_sample_rate_hz: u32,
expected_channels: u16,
capture_frames_processed: u64,
render_frames_processed: u64,
}
impl StubAudioProcessor {
pub fn new(config: ApmConfig, sample_rate_hz: u32, channels: u16) -> Result<Self, ApmError> {
if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) {
return Err(ApmError::SampleRateOutOfRange { sample_rate_hz });
}
if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) {
return Err(ApmError::ChannelsOutOfRange { channels });
}
assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(channels >= APM_MIN_CHANNELS);
Ok(StubAudioProcessor {
config,
expected_sample_rate_hz: sample_rate_hz,
expected_channels: channels,
capture_frames_processed: 0,
render_frames_processed: 0,
})
}
pub fn config(&self) -> ApmConfig {
self.config
}
pub fn capture_frames_processed(&self) -> u64 {
self.capture_frames_processed
}
pub fn render_frames_processed(&self) -> u64 {
self.render_frames_processed
}
}
impl AudioProcessor for StubAudioProcessor {
fn process_capture_frame(
&mut self,
samples: &mut [i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<ApmReport, ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
assert!(self.capture_frames_processed < u64::MAX);
self.capture_frames_processed = self.capture_frames_processed.saturating_add(1);
Ok(ApmReport::NEUTRAL)
}
fn process_render_frame(
&mut self,
samples: &[i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
assert!(self.render_frames_processed < u64::MAX);
self.render_frames_processed = self.render_frames_processed.saturating_add(1);
Ok(())
}
fn reset(&mut self) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
self.capture_frames_processed = 0;
self.render_frames_processed = 0;
Ok(())
}
}
#[cfg(feature = "real-apm")]
mod real {
use super::{
APM_MAX_CHANNELS, APM_MAX_FRAME_SAMPLES, APM_MAX_SAMPLE_RATE, APM_MIN_CHANNELS,
APM_MIN_SAMPLE_RATE, AecMetrics, ApmConfig, ApmError, ApmReport, AudioProcessor,
validate_frame_shape,
};
use webrtc_audio_processing::{Processor, Stats};
use webrtc_audio_processing_config::{
Config, EchoCanceller, GainController, GainController1, GainControllerMode, HighPassFilter,
NoiseSuppression, NoiseSuppressionLevel,
};
const I16_TO_F32_SCALE: f32 = 1.0 / 32768.0;
const F32_TO_I16_SCALE: f32 = 32767.0;
#[derive(Debug)]
pub struct WebRtcAudioProcessor {
inner: Processor,
config: ApmConfig,
expected_sample_rate_hz: u32,
expected_channels: u16,
samples_per_channel: usize,
capture_channels: [[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
render_channels: [[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
capture_frames_processed: u64,
render_frames_processed: u64,
}
impl WebRtcAudioProcessor {
pub fn new(
config: ApmConfig,
sample_rate_hz: u32,
channels: u16,
) -> Result<Self, ApmError> {
if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) {
return Err(ApmError::SampleRateOutOfRange { sample_rate_hz });
}
if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) {
return Err(ApmError::ChannelsOutOfRange { channels });
}
assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(channels >= APM_MIN_CHANNELS);
let inner = Processor::new(sample_rate_hz).map_err(|_| ApmError::BackendUnavailable)?;
inner.set_config(build_webrtc_config(config));
let samples_per_channel = inner.num_samples_per_frame();
assert!(samples_per_channel <= APM_MAX_FRAME_SAMPLES);
assert!(samples_per_channel > 0);
Ok(WebRtcAudioProcessor {
inner,
config,
expected_sample_rate_hz: sample_rate_hz,
expected_channels: channels,
samples_per_channel,
capture_channels: [[0.0; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
render_channels: [[0.0; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
capture_frames_processed: 0,
render_frames_processed: 0,
})
}
pub fn config(&self) -> ApmConfig {
self.config
}
pub fn capture_frames_processed(&self) -> u64 {
self.capture_frames_processed
}
pub fn render_frames_processed(&self) -> u64 {
self.render_frames_processed
}
pub fn samples_per_channel(&self) -> usize {
self.samples_per_channel
}
}
fn build_webrtc_config(config: ApmConfig) -> Config {
let echo_canceller = if !config.aec_enabled {
None
} else if config.aec_mobile_mode {
Some(EchoCanceller::Mobile { stream_delay_ms: 0 })
} else {
Some(EchoCanceller::Full {
stream_delay_ms: None,
})
};
let noise_suppression = if config.ns_enabled {
Some(NoiseSuppression {
level: NoiseSuppressionLevel::Moderate,
analyze_linear_aec_output: false,
})
} else {
None
};
let gain_controller = if config.agc_enabled {
Some(gain_controller_from(config.target_level_dbfs))
} else {
None
};
Config {
pipeline: Default::default(),
capture_amplifier: None,
high_pass_filter: Some(HighPassFilter {
apply_in_full_band: true,
}),
echo_canceller,
noise_suppression,
gain_controller,
}
}
fn gain_controller_from(target_level_dbfs: i32) -> GainController {
assert!(target_level_dbfs <= 0);
assert!(target_level_dbfs >= -60);
let target_clamped = (-target_level_dbfs).clamp(0, 31) as u8;
GainController::GainController1(GainController1 {
mode: GainControllerMode::AdaptiveDigital,
target_level_dbfs: target_clamped,
compression_gain_db: 9,
enable_limiter: true,
analog_gain_controller: None,
})
}
fn deinterleave_i16(
samples: &[i16],
channels: usize,
samples_per_channel: usize,
out: &mut [[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
) {
assert!(channels >= 1);
assert!(channels <= APM_MAX_CHANNELS as usize);
assert!(samples_per_channel <= APM_MAX_FRAME_SAMPLES);
assert!(samples.len() == channels * samples_per_channel);
for (sample_index, frame) in samples.chunks_exact(channels).enumerate() {
for (channel_index, &value) in frame.iter().enumerate() {
out[channel_index][sample_index] = (value as f32) * I16_TO_F32_SCALE;
}
}
}
fn interleave_to_i16(
channels_data: &[[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
channels: usize,
samples_per_channel: usize,
out: &mut [i16],
) {
assert!(channels >= 1);
assert!(channels <= APM_MAX_CHANNELS as usize);
assert!(samples_per_channel <= APM_MAX_FRAME_SAMPLES);
assert!(out.len() == channels * samples_per_channel);
for (sample_index, frame) in out.chunks_exact_mut(channels).enumerate() {
for (channel_index, slot) in frame.iter_mut().enumerate() {
let value = channels_data[channel_index][sample_index];
let scaled = (value * F32_TO_I16_SCALE).clamp(i16::MIN as f32, i16::MAX as f32);
*slot = scaled as i16;
}
}
}
fn peak_level_dbfs(samples: &[i16]) -> f32 {
assert!(!samples.is_empty());
let mut peak_abs: i32 = 0;
for &sample in samples {
let abs_value = (sample as i32).unsigned_abs() as i32;
if abs_value > peak_abs {
peak_abs = abs_value;
}
}
if peak_abs == 0 {
return -120.0;
}
let normalised = (peak_abs as f32) * I16_TO_F32_SCALE;
20.0 * normalised.log10()
}
fn report_from(stats: Stats, level_dbfs: f32) -> ApmReport {
let erl = stats.echo_return_loss.unwrap_or(0.0) as f32;
let erle = stats.echo_return_loss_enhancement.unwrap_or(0.0) as f32;
let delay_ms = stats.delay_ms.unwrap_or(0) as i32;
ApmReport {
aec_metrics: AecMetrics {
echo_return_loss_db: erl,
echo_return_loss_enhancement_db: erle,
delay_ms,
},
voice_detected: level_dbfs > -50.0,
level_dbfs,
}
}
impl AudioProcessor for WebRtcAudioProcessor {
fn process_capture_frame(
&mut self,
samples: &mut [i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<ApmReport, ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
let channels_usize = channels as usize;
let per_channel = self.samples_per_channel;
deinterleave_i16(
samples,
channels_usize,
per_channel,
&mut self.capture_channels,
);
let mut view: [&mut [f32]; APM_MAX_CHANNELS as usize] = {
let (first, second) = self.capture_channels.split_at_mut(1);
[&mut first[0][..per_channel], &mut second[0][..per_channel]]
};
let frame = &mut view[..channels_usize];
self.inner
.process_capture_frame(frame)
.map_err(|_| ApmError::BackendUnavailable)?;
interleave_to_i16(&self.capture_channels, channels_usize, per_channel, samples);
let level_dbfs = peak_level_dbfs(samples);
let stats = self.inner.get_stats();
self.capture_frames_processed = self.capture_frames_processed.saturating_add(1);
Ok(report_from(stats, level_dbfs))
}
fn process_render_frame(
&mut self,
samples: &[i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
let channels_usize = channels as usize;
let per_channel = self.samples_per_channel;
deinterleave_i16(
samples,
channels_usize,
per_channel,
&mut self.render_channels,
);
let mut view: [&mut [f32]; APM_MAX_CHANNELS as usize] = {
let (first, second) = self.render_channels.split_at_mut(1);
[&mut first[0][..per_channel], &mut second[0][..per_channel]]
};
let frame = &mut view[..channels_usize];
self.inner
.process_render_frame(frame)
.map_err(|_| ApmError::BackendUnavailable)?;
self.render_frames_processed = self.render_frames_processed.saturating_add(1);
Ok(())
}
fn reset(&mut self) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
self.inner.reinitialize();
self.capture_frames_processed = 0;
self.render_frames_processed = 0;
Ok(())
}
}
}
#[cfg(feature = "real-apm")]
pub use real::WebRtcAudioProcessor;
#[cfg(test)]
mod tests {
use super::*;
fn make_frame(sample_rate_hz: u32, channels: u16) -> Vec<i16> {
let n = expected_frame_samples(sample_rate_hz, channels);
(0..n).map(|i| (i as i16).wrapping_mul(7)).collect()
}
#[test]
fn frame_samples_constant_matches_formula() {
assert_eq!(APM_MAX_FRAME_SAMPLES, 480);
assert_eq!(expected_frame_samples(48_000, 1), 480);
assert_eq!(expected_frame_samples(48_000, 2), 960);
assert_eq!(expected_frame_samples(16_000, 1), 160);
}
#[test]
fn stub_capture_does_not_modify_samples() {
let config = ApmConfigBuilder::new().build();
let mut stub = StubAudioProcessor::new(config, 48_000, 1).expect("ctor");
let original = make_frame(48_000, 1);
let mut samples = original.clone();
let report = stub
.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
assert_eq!(samples, original);
assert_eq!(report, ApmReport::NEUTRAL);
}
#[test]
fn stub_report_has_neutral_metrics() {
let config = ApmConfig::default();
let mut stub = StubAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 1);
let report = stub
.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
assert_eq!(report.aec_metrics.echo_return_loss_db, 0.0);
assert_eq!(report.aec_metrics.echo_return_loss_enhancement_db, 0.0);
assert_eq!(report.aec_metrics.delay_ms, 0);
assert!(!report.voice_detected);
assert_eq!(report.level_dbfs, -120.0);
}
#[test]
fn rejects_mismatched_sample_rate() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(16_000, 1);
let err = stub
.process_capture_frame(&mut samples, 16_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::SampleRateMismatch { .. }));
}
#[test]
fn rejects_mismatched_channels() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 2);
let err = stub
.process_capture_frame(&mut samples, 48_000, 2)
.expect_err("err");
assert!(matches!(err, ApmError::ChannelsMismatch { .. }));
}
#[test]
fn rejects_wrong_frame_length() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = vec![0i16; 100];
let err = stub
.process_capture_frame(&mut samples, 48_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::FrameLengthMismatch { .. }));
}
#[test]
fn rejects_render_frame_length() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let samples = vec![0i16; 99];
let err = stub
.process_render_frame(&samples, 48_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::FrameLengthMismatch { .. }));
}
#[test]
fn rejects_sample_rate_out_of_range_on_construct() {
let err = StubAudioProcessor::new(ApmConfig::default(), 4_000, 1).expect_err("err");
assert!(matches!(err, ApmError::SampleRateOutOfRange { .. }));
}
#[test]
fn rejects_channels_out_of_range_on_construct() {
let err = StubAudioProcessor::new(ApmConfig::default(), 48_000, 4).expect_err("err");
assert!(matches!(err, ApmError::ChannelsOutOfRange { .. }));
}
#[test]
fn reset_returns_ok() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 1);
stub.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
assert_eq!(stub.capture_frames_processed(), 1);
stub.reset().expect("reset");
assert_eq!(stub.capture_frames_processed(), 0);
assert_eq!(stub.render_frames_processed(), 0);
}
#[test]
fn builder_pattern_produces_valid_config() {
let config = ApmConfigBuilder::new()
.aec(false)
.ns(true)
.agc(false)
.aec_mobile_mode(true)
.target_level_dbfs(-6)
.build();
assert!(!config.aec_enabled);
assert!(config.ns_enabled);
assert!(!config.agc_enabled);
assert!(config.aec_mobile_mode);
assert_eq!(config.target_level_dbfs, -6);
}
#[test]
fn builder_default_matches_struct_default() {
let builder = ApmConfigBuilder::new().build();
let default = ApmConfig::default();
assert_eq!(builder, default);
}
#[test]
fn state_preserved_across_many_frames() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 1);
const N: u64 = 100;
for _ in 0..N {
stub.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
}
assert_eq!(stub.capture_frames_processed(), N);
let render = vec![0i16; expected_frame_samples(48_000, 1)];
for _ in 0..N {
stub.process_render_frame(&render, 48_000, 1).expect("ok");
}
assert_eq!(stub.render_frames_processed(), N);
}
#[test]
fn determinism_stub_produces_identical_output() {
let mut stub_a = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut stub_b = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let original = make_frame(48_000, 1);
let mut samples_a = original.clone();
let mut samples_b = original.clone();
let report_a = stub_a
.process_capture_frame(&mut samples_a, 48_000, 1)
.expect("ok");
let report_b = stub_b
.process_capture_frame(&mut samples_b, 48_000, 1)
.expect("ok");
assert_eq!(samples_a, samples_b);
assert_eq!(samples_a, original);
assert_eq!(report_a, report_b);
}
#[test]
fn stereo_round_trip_capture_succeeds() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 2).expect("ctor");
let mut samples = make_frame(48_000, 2);
let original = samples.clone();
let report = stub
.process_capture_frame(&mut samples, 48_000, 2)
.expect("ok");
assert_eq!(samples, original);
assert_eq!(report, ApmReport::NEUTRAL);
}
#[test]
fn validate_frame_shape_accepts_canonical_48k_mono() {
validate_frame_shape(480, 48_000, 1, 48_000, 1).expect("ok");
}
#[test]
#[cfg(not(feature = "real-apm"))]
fn real_apm_type_absent_when_feature_off() {
let _ = ApmConfig::default();
}
#[cfg(feature = "real-apm")]
mod real_apm_tests {
use super::*;
fn sine_frame_i16(sample_rate_hz: u32, channels: u16, frequency_hz: f32) -> Vec<i16> {
let per_channel = (APM_FRAME_MS as usize) * (sample_rate_hz as usize) / 1000;
let total = per_channel * (channels as usize);
let mut samples = Vec::with_capacity(total);
for sample_index in 0..per_channel {
let phase = (sample_index as f32) * frequency_hz / (sample_rate_hz as f32);
let value = (phase * 2.0 * core::f32::consts::PI).sin() * 0.5;
let scaled = (value * 32767.0) as i16;
for _ in 0..channels {
samples.push(scaled);
}
}
samples
}
#[test]
fn real_apm_constructs_at_48k_mono() {
let config = ApmConfig::default();
let processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
assert_eq!(processor.samples_per_channel(), 480);
assert_eq!(processor.capture_frames_processed(), 0);
assert_eq!(processor.render_frames_processed(), 0);
assert_eq!(processor.config(), config);
}
#[test]
fn real_apm_rejects_invalid_sample_rate_on_construct() {
let config = ApmConfig::default();
let err = WebRtcAudioProcessor::new(config, 4_000, 1).expect_err("err");
assert!(matches!(err, ApmError::SampleRateOutOfRange { .. }));
}
#[test]
fn real_apm_rejects_invalid_channels_on_construct() {
let config = ApmConfig::default();
let err = WebRtcAudioProcessor::new(config, 48_000, 8).expect_err("err");
assert!(matches!(err, ApmError::ChannelsOutOfRange { .. }));
}
#[test]
fn real_apm_capture_increments_counter_and_returns_report() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
let report = processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
assert_eq!(processor.capture_frames_processed(), 1);
assert!(report.level_dbfs <= 0.0);
assert!(report.level_dbfs >= -120.0);
}
#[test]
fn real_apm_render_increments_counter() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let samples = sine_frame_i16(48_000, 1, 880.0);
processor
.process_render_frame(&samples, 48_000, 1)
.expect("render ok");
assert_eq!(processor.render_frames_processed(), 1);
}
#[test]
fn real_apm_rejects_mismatched_frame_length() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = vec![0i16; 100];
let err = processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::FrameLengthMismatch { .. }));
}
#[test]
fn real_apm_rejects_mismatched_sample_rate_at_process() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(16_000, 1, 440.0);
let err = processor
.process_capture_frame(&mut samples, 16_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::SampleRateMismatch { .. }));
}
#[test]
fn real_apm_processes_many_capture_frames_without_panic() {
let config = ApmConfigBuilder::new().aec(true).ns(true).agc(true).build();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
const N: u64 = 50;
for _ in 0..N {
let mut frame = samples.clone();
processor
.process_capture_frame(&mut frame, 48_000, 1)
.expect("capture ok");
processor
.process_render_frame(&samples, 48_000, 1)
.expect("render ok");
}
assert_eq!(processor.capture_frames_processed(), N);
assert_eq!(processor.render_frames_processed(), N);
samples.clear();
}
#[test]
fn real_apm_reset_clears_counters() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
processor
.process_render_frame(&samples, 48_000, 1)
.expect("render ok");
assert_eq!(processor.capture_frames_processed(), 1);
assert_eq!(processor.render_frames_processed(), 1);
processor.reset().expect("reset ok");
assert_eq!(processor.capture_frames_processed(), 0);
assert_eq!(processor.render_frames_processed(), 0);
}
#[test]
fn real_apm_silent_frame_reports_quiet_level() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = vec![0i16; expected_frame_samples(48_000, 1)];
let report = processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
assert!(report.level_dbfs <= -50.0);
assert!(!report.voice_detected);
}
#[test]
fn real_apm_stereo_round_trip_succeeds() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 2).expect("ctor");
let mut samples = sine_frame_i16(48_000, 2, 440.0);
processor
.process_capture_frame(&mut samples, 48_000, 2)
.expect("capture ok");
assert_eq!(processor.capture_frames_processed(), 1);
}
#[test]
fn real_apm_disabling_all_submodules_constructs_ok() {
let config = ApmConfigBuilder::new()
.aec(false)
.ns(false)
.agc(false)
.target_level_dbfs(-6)
.build();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
assert_eq!(processor.capture_frames_processed(), 1);
}
}
}
+763
View File
@@ -0,0 +1,763 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "criterion"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools",
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fluxer_audio_mix"
version = "0.0.0"
dependencies = [
"criterion",
"fluxer_rt_thread",
"rtrb",
]
[[package]]
name = "fluxer_rt_thread"
version = "0.0.0"
dependencies = [
"libc",
"windows",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rtrb"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
@@ -0,0 +1,23 @@
[package]
name = "fluxer_audio_mix"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["rlib"]
[dependencies]
rtrb = "0.3"
fluxer_rt_thread = {path = "../rt-thread"}
[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "mix"
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": {
"audio_mix_tick/mix_8_sources_1024_samples": {
"median_ns": 2103.8,
"low_ns": 2091.3,
"high_ns": 2117.0,
"budget_percent_override": 8.0,
"note": "Sub-microsecond mix tick; on Apple Silicon with thermal scaling, observed run-to-run sigma is ~3-4%, so 8% leaves room for noise without hiding 1.5x slowdowns."
}
}
}
@@ -0,0 +1,63 @@
// 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, AudioMixSession, SourceRing, SourceRingConsumer, SourceRingProducer,
};
use fluxer_rt_thread::TickInfo;
const BENCH_SOURCE_COUNT: usize = 8;
fn build_session(source_count: usize) -> (Vec<SourceRingProducer>, AudioMixSession) {
assert!(source_count > 0);
let mut producers = Vec::with_capacity(source_count);
let mut consumers: Vec<SourceRingConsumer> = Vec::with_capacity(source_count);
for _ in 0..source_count {
let (producer, consumer) = SourceRing::create(8192, 48_000).expect("pair");
producers.push(producer);
consumers.push(consumer);
}
let session = AudioMixSession::new(consumers, AUDIO_OUTPUT_FRAMES).expect("session");
(producers, session)
}
fn synthetic_tick(index: u64) -> TickInfo {
let scheduled_ns = index * 21_333_333;
TickInfo {
tick_index: index,
scheduled_ns,
actual_ns: scheduled_ns,
lag_ns: 0,
}
}
fn fill_producers(producers: &mut [SourceRingProducer], frames: usize) {
assert!(!producers.is_empty());
assert!(frames > 0);
let payload: Vec<i16> = (0..frames).map(|n| ((n as i16) % 4096) - 2048).collect();
for producer in producers.iter_mut() {
let pushed = producer.try_push_slice(&payload);
assert!(pushed > 0);
}
}
fn bench_mix_tick(c: &mut Criterion) {
let mut group = c.benchmark_group("audio_mix_tick");
group.sample_size(50);
group.bench_function("mix_8_sources_1024_samples", |b| {
let (mut producers, mut session) = build_session(BENCH_SOURCE_COUNT);
let mut tick_index: u64 = 0;
b.iter(|| {
fill_producers(&mut producers, AUDIO_OUTPUT_FRAMES);
let result = session.tick(synthetic_tick(tick_index));
tick_index = tick_index.wrapping_add(1);
black_box(result);
});
});
group.finish();
}
criterion_group!(benches, bench_mix_tick);
criterion_main!(benches);
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::too_many_lines)]
pub mod mix_session;
pub mod source_ring;
pub use mix_session::{
AudioMixSession, MAX_MIX_SOURCES, MixTickResult, MixedFrame, SourceTickStat,
};
pub use source_ring::{
AUDIO_OUTPUT_FRAMES, AUDIO_RING_CAP_FRAMES, AUDIO_SAMPLE_RATE_HZ_MAX, AUDIO_SAMPLE_RATE_HZ_MIN,
SourceRing, SourceRingConsumer, SourceRingProducer,
};
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum AudioMixError {
ZeroCapacity,
CapacityExceedsLimit { requested: usize, limit: usize },
SampleRateOutOfRange { sample_rate_hz: u32 },
SampleRateMismatch { expected_hz: u32, observed_hz: u32 },
ZeroSources,
TooManySources { requested: usize, limit: usize },
MixBufferLenMismatch { expected: usize, observed: usize },
}
impl core::fmt::Display for AudioMixError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
AudioMixError::ZeroCapacity => write!(f, "ring capacity must be non-zero"),
AudioMixError::CapacityExceedsLimit { requested, limit } => {
write!(f, "ring capacity {requested} exceeds hard limit {limit}",)
}
AudioMixError::SampleRateOutOfRange { sample_rate_hz } => {
write!(f, "sample rate {sample_rate_hz} hz outside accepted range")
}
AudioMixError::SampleRateMismatch {
expected_hz,
observed_hz,
} => write!(
f,
"source sample rate mismatch: expected={expected_hz} observed={observed_hz}",
),
AudioMixError::ZeroSources => write!(f, "AudioMixSession requires at least one source"),
AudioMixError::TooManySources { requested, limit } => write!(
f,
"AudioMixSession source count {requested} exceeds limit {limit}",
),
AudioMixError::MixBufferLenMismatch { expected, observed } => write!(
f,
"mix_buffer_len mismatch: expected={expected} observed={observed}",
),
}
}
}
impl std::error::Error for AudioMixError {}
@@ -0,0 +1,411 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_rt_thread::TickInfo;
use crate::AudioMixError;
use crate::source_ring::{AUDIO_OUTPUT_FRAMES, SourceRingConsumer};
pub const MAX_MIX_SOURCES: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceTickStat {
pub drained_count: u32,
pub silence_count: u32,
}
#[derive(Debug)]
pub struct MixedFrame<'a> {
pub samples: &'a [i16; AUDIO_OUTPUT_FRAMES],
pub tick_index: u64,
pub scheduled_ns: u64,
pub actual_ns: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MixTickResult {
pub tick_index: u64,
pub scheduled_ns: u64,
pub actual_ns: u64,
pub lag_ns: i64,
pub total_drained: u64,
pub total_silence: u64,
pub saturated_samples: u32,
}
pub struct AudioMixSession {
consumers: Vec<SourceRingConsumer>,
per_source_stats: Vec<SourceTickStat>,
sample_rate_hz: u32,
mix_buffer_len: usize,
accumulator: Box<[i32; AUDIO_OUTPUT_FRAMES]>,
scratch: Box<[i16; AUDIO_OUTPUT_FRAMES]>,
output: Box<[i16; AUDIO_OUTPUT_FRAMES]>,
last_tick_index: Option<u64>,
last_actual_ns: u64,
ticks_completed: u64,
}
impl AudioMixSession {
pub fn new(
consumers: Vec<SourceRingConsumer>,
mix_buffer_len: usize,
) -> Result<Self, AudioMixError> {
if consumers.is_empty() {
return Err(AudioMixError::ZeroSources);
}
if consumers.len() > MAX_MIX_SOURCES {
return Err(AudioMixError::TooManySources {
requested: consumers.len(),
limit: MAX_MIX_SOURCES,
});
}
if mix_buffer_len != AUDIO_OUTPUT_FRAMES {
return Err(AudioMixError::MixBufferLenMismatch {
expected: AUDIO_OUTPUT_FRAMES,
observed: mix_buffer_len,
});
}
let sample_rate_hz = consumers[0].sample_rate_hz();
for consumer in &consumers {
let observed = consumer.sample_rate_hz();
if observed != sample_rate_hz {
return Err(AudioMixError::SampleRateMismatch {
expected_hz: sample_rate_hz,
observed_hz: observed,
});
}
}
assert!(!consumers.is_empty());
assert!(consumers.len() <= MAX_MIX_SOURCES);
let per_source_stats = vec![
SourceTickStat {
drained_count: 0,
silence_count: 0,
};
consumers.len()
];
let accumulator = Box::new([0i32; AUDIO_OUTPUT_FRAMES]);
let scratch = Box::new([0i16; AUDIO_OUTPUT_FRAMES]);
let output = Box::new([0i16; AUDIO_OUTPUT_FRAMES]);
Ok(Self {
consumers,
per_source_stats,
sample_rate_hz,
mix_buffer_len,
accumulator,
scratch,
output,
last_tick_index: None,
last_actual_ns: 0,
ticks_completed: 0,
})
}
pub fn sample_rate_hz(&self) -> u32 {
assert!(self.sample_rate_hz > 0);
assert!(self.sample_rate_hz <= 384_000);
self.sample_rate_hz
}
pub fn mix_buffer_len(&self) -> usize {
assert_eq!(self.mix_buffer_len, AUDIO_OUTPUT_FRAMES);
self.mix_buffer_len
}
pub fn source_count(&self) -> usize {
assert!(!self.consumers.is_empty());
assert!(self.consumers.len() <= MAX_MIX_SOURCES);
self.consumers.len()
}
pub fn ticks_completed(&self) -> u64 {
self.ticks_completed
}
pub fn per_source_stats(&self) -> &[SourceTickStat] {
assert_eq!(self.per_source_stats.len(), self.consumers.len());
&self.per_source_stats
}
pub fn tick(&mut self, tick_info: TickInfo) -> MixTickResult {
assert!(!self.consumers.is_empty());
assert!(tick_info.actual_ns >= self.last_actual_ns);
if let Some(prior) = self.last_tick_index {
assert!(tick_info.tick_index > prior);
}
for slot in self.accumulator.iter_mut() {
*slot = 0;
}
let (total_drained, total_silence) = self.mix_all_sources();
let saturated = self.finalise_output();
self.ticks_completed = self.ticks_completed.saturating_add(1);
self.last_tick_index = Some(tick_info.tick_index);
self.last_actual_ns = tick_info.actual_ns;
let result = MixTickResult {
tick_index: tick_info.tick_index,
scheduled_ns: tick_info.scheduled_ns,
actual_ns: tick_info.actual_ns,
lag_ns: tick_info.lag_ns,
total_drained,
total_silence,
saturated_samples: saturated,
};
assert_eq!(result.tick_index, tick_info.tick_index);
assert!(result.saturated_samples as usize <= AUDIO_OUTPUT_FRAMES);
result
}
fn mix_all_sources(&mut self) -> (u64, u64) {
let n = self.consumers.len();
assert_eq!(self.per_source_stats.len(), n);
let mut total_drained: u64 = 0;
let mut total_silence: u64 = 0;
for index in 0..n {
let drained = self.consumers[index].drain_into(self.scratch.as_mut_slice());
assert!(drained <= AUDIO_OUTPUT_FRAMES);
let silence = AUDIO_OUTPUT_FRAMES - drained;
let stat = &mut self.per_source_stats[index];
stat.drained_count = stat.drained_count.saturating_add(drained as u32);
stat.silence_count = stat.silence_count.saturating_add(silence as u32);
total_drained = total_drained.saturating_add(drained as u64);
total_silence = total_silence.saturating_add(silence as u64);
for (acc, sample) in self.accumulator[..drained]
.iter_mut()
.zip(self.scratch[..drained].iter())
{
*acc = acc.saturating_add(*sample as i32);
}
}
(total_drained, total_silence)
}
fn finalise_output(&mut self) -> u32 {
let mut saturated: u32 = 0;
for (out, acc) in self.output.iter_mut().zip(self.accumulator.iter()) {
let value = *acc;
let clamped = if value > i16::MAX as i32 {
saturated = saturated.saturating_add(1);
i16::MAX
} else if value < i16::MIN as i32 {
saturated = saturated.saturating_add(1);
i16::MIN
} else {
value as i16
};
*out = clamped;
}
assert!(saturated as usize <= AUDIO_OUTPUT_FRAMES);
saturated
}
pub fn last_output(&self) -> &[i16; AUDIO_OUTPUT_FRAMES] {
assert_eq!(self.output.len(), AUDIO_OUTPUT_FRAMES);
&self.output
}
pub fn last_mixed_frame(&self) -> Option<MixedFrame<'_>> {
let tick_index = self.last_tick_index?;
assert!(self.ticks_completed > 0);
Some(MixedFrame {
samples: &self.output,
tick_index,
scheduled_ns: 0,
actual_ns: self.last_actual_ns,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source_ring::SourceRing;
use fluxer_rt_thread::TickInfo;
use std::sync::{Arc, Barrier};
use std::thread;
fn synthetic_tick(index: u64) -> TickInfo {
let scheduled_ns = index * 21_333_333;
TickInfo {
tick_index: index,
scheduled_ns,
actual_ns: scheduled_ns,
lag_ns: 0,
}
}
#[test]
fn rejects_empty_consumer_list() {
let err = AudioMixSession::new(Vec::new(), AUDIO_OUTPUT_FRAMES).err();
assert_eq!(err, Some(AudioMixError::ZeroSources));
}
#[test]
fn rejects_mismatched_sample_rates() {
let (_p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
let (_p2, c2) = SourceRing::create(2048, 44_100).expect("pair 2");
let err = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).err();
assert!(matches!(
err,
Some(AudioMixError::SampleRateMismatch { .. })
));
}
#[test]
fn rejects_wrong_mix_buffer_len() {
let (_p, c) = SourceRing::create(2048, 48_000).expect("pair");
let err = AudioMixSession::new(vec![c], 512).err();
assert!(matches!(
err,
Some(AudioMixError::MixBufferLenMismatch { .. })
));
}
#[test]
fn single_source_passes_through_samples() {
let (mut producer, consumer) = SourceRing::create(2048, 48_000).expect("pair");
let payload: Vec<i16> = (0..AUDIO_OUTPUT_FRAMES)
.map(|n| (n as i16) % 1000)
.collect();
let pushed = producer.try_push_slice(&payload);
assert_eq!(pushed, AUDIO_OUTPUT_FRAMES);
let mut session =
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
let result = session.tick(synthetic_tick(0));
assert_eq!(result.total_drained, AUDIO_OUTPUT_FRAMES as u64);
assert_eq!(result.total_silence, 0);
assert_eq!(&session.last_output()[..], &payload[..]);
}
#[test]
fn empty_ring_produces_silence_without_blocking() {
let (_producer, consumer) = SourceRing::create(2048, 48_000).expect("pair");
let mut session =
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
let result = session.tick(synthetic_tick(0));
assert_eq!(result.total_drained, 0);
assert_eq!(result.total_silence, AUDIO_OUTPUT_FRAMES as u64);
for sample in session.last_output().iter() {
assert_eq!(*sample, 0);
}
}
#[test]
fn drained_count_accuracy_across_partial_fills() {
let (mut producer, consumer) = SourceRing::create(2048, 48_000).expect("pair");
let half: Vec<i16> = vec![100; AUDIO_OUTPUT_FRAMES / 2];
producer.try_push_slice(&half);
let mut session =
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
let result = session.tick(synthetic_tick(0));
assert_eq!(result.total_drained, (AUDIO_OUTPUT_FRAMES / 2) as u64);
assert_eq!(result.total_silence, (AUDIO_OUTPUT_FRAMES / 2) as u64);
let stats = session.per_source_stats();
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].drained_count as usize, AUDIO_OUTPUT_FRAMES / 2);
assert_eq!(stats[0].silence_count as usize, AUDIO_OUTPUT_FRAMES / 2);
}
#[test]
fn opposite_phase_sources_sum_to_silence() {
let (mut p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
let (mut p2, c2) = SourceRing::create(2048, 48_000).expect("pair 2");
let wave: Vec<i16> = (0..AUDIO_OUTPUT_FRAMES)
.map(|n| ((n as i16) % 1000) - 500)
.collect();
let inverse: Vec<i16> = wave.iter().map(|s| -*s).collect();
p1.try_push_slice(&wave);
p2.try_push_slice(&inverse);
let mut session = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).expect("session");
let _ = session.tick(synthetic_tick(0));
for sample in session.last_output().iter() {
assert_eq!(*sample, 0);
}
}
#[test]
fn saturation_clamps_at_i16_limits() {
let (mut p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
let (mut p2, c2) = SourceRing::create(2048, 48_000).expect("pair 2");
let high: Vec<i16> = vec![i16::MAX; AUDIO_OUTPUT_FRAMES];
p1.try_push_slice(&high);
p2.try_push_slice(&high);
let mut session = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).expect("session");
let result = session.tick(synthetic_tick(0));
assert_eq!(result.saturated_samples as usize, AUDIO_OUTPUT_FRAMES);
for sample in session.last_output().iter() {
assert_eq!(*sample, i16::MAX);
}
}
#[test]
fn saturation_clamps_at_i16_min_limits() {
let (mut p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
let (mut p2, c2) = SourceRing::create(2048, 48_000).expect("pair 2");
let low: Vec<i16> = vec![i16::MIN; AUDIO_OUTPUT_FRAMES];
p1.try_push_slice(&low);
p2.try_push_slice(&low);
let mut session = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).expect("session");
let result = session.tick(synthetic_tick(0));
assert_eq!(result.saturated_samples as usize, AUDIO_OUTPUT_FRAMES);
for sample in session.last_output().iter() {
assert_eq!(*sample, i16::MIN);
}
}
#[test]
fn full_ring_producer_drops_overflow() {
let (mut producer, consumer) =
SourceRing::create(AUDIO_OUTPUT_FRAMES, 48_000).expect("pair");
let payload: Vec<i16> = vec![7; AUDIO_OUTPUT_FRAMES];
let pushed_first = producer.try_push_slice(&payload);
assert_eq!(pushed_first, AUDIO_OUTPUT_FRAMES);
let pushed_second = producer.try_push_slice(&payload);
assert_eq!(pushed_second, 0);
assert_eq!(producer.dropped_total(), AUDIO_OUTPUT_FRAMES as u64);
let mut session =
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
let result = session.tick(synthetic_tick(0));
assert_eq!(result.total_drained, AUDIO_OUTPUT_FRAMES as u64);
}
#[test]
fn ten_producer_threads_one_mixer_no_panic() {
const SOURCE_COUNT: usize = 10;
const TICKS: u64 = 50;
let mut producers = Vec::with_capacity(SOURCE_COUNT);
let mut consumers = Vec::with_capacity(SOURCE_COUNT);
for _ in 0..SOURCE_COUNT {
let (producer, consumer) = SourceRing::create(8192, 48_000).expect("pair");
producers.push(producer);
consumers.push(consumer);
}
let barrier = Arc::new(Barrier::new(SOURCE_COUNT + 1));
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut handles = Vec::with_capacity(SOURCE_COUNT);
for (index, mut producer) in producers.into_iter().enumerate() {
let barrier = Arc::clone(&barrier);
let stop = Arc::clone(&stop);
handles.push(thread::spawn(move || {
barrier.wait();
let mut value: i16 = index as i16;
while !stop.load(std::sync::atomic::Ordering::Acquire) {
let _ = producer.try_push(value);
value = value.wrapping_add(1);
}
}));
}
let mut session = AudioMixSession::new(consumers, AUDIO_OUTPUT_FRAMES).expect("session");
barrier.wait();
let mut last_drained: u64 = 0;
for tick_index in 0..TICKS {
let result = session.tick(synthetic_tick(tick_index));
last_drained = last_drained.saturating_add(result.total_drained);
}
stop.store(true, std::sync::atomic::Ordering::Release);
for handle in handles {
handle.join().expect("producer join");
}
assert_eq!(session.ticks_completed(), TICKS);
assert!(last_drained > 0);
}
}
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use rtrb::{Consumer, Producer, RingBuffer};
use crate::AudioMixError;
pub const AUDIO_RING_CAP_FRAMES: usize = 4096;
pub const AUDIO_RING_CAP_FRAMES_MAX: usize = 1 << 20;
pub const AUDIO_OUTPUT_FRAMES: usize = 1024;
pub const AUDIO_SAMPLE_RATE_HZ_MIN: u32 = 8_000;
pub const AUDIO_SAMPLE_RATE_HZ_MAX: u32 = 384_000;
pub struct SourceRingProducer {
inner: Producer<i16>,
sample_rate_hz: u32,
capacity_frames: usize,
pushed_total: u64,
dropped_total: u64,
}
pub struct SourceRingConsumer {
inner: Consumer<i16>,
sample_rate_hz: u32,
capacity_frames: usize,
drained_total: u64,
}
pub struct SourceRing;
impl SourceRing {
pub fn create(
capacity_frames: usize,
sample_rate_hz: u32,
) -> Result<(SourceRingProducer, SourceRingConsumer), AudioMixError> {
if capacity_frames == 0 {
return Err(AudioMixError::ZeroCapacity);
}
if capacity_frames > AUDIO_RING_CAP_FRAMES_MAX {
return Err(AudioMixError::CapacityExceedsLimit {
requested: capacity_frames,
limit: AUDIO_RING_CAP_FRAMES_MAX,
});
}
if !(AUDIO_SAMPLE_RATE_HZ_MIN..=AUDIO_SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
return Err(AudioMixError::SampleRateOutOfRange { sample_rate_hz });
}
assert!(capacity_frames > 0);
assert!(capacity_frames <= AUDIO_RING_CAP_FRAMES_MAX);
let (producer, consumer) = RingBuffer::<i16>::new(capacity_frames);
let producer = SourceRingProducer {
inner: producer,
sample_rate_hz,
capacity_frames,
pushed_total: 0,
dropped_total: 0,
};
let consumer = SourceRingConsumer {
inner: consumer,
sample_rate_hz,
capacity_frames,
drained_total: 0,
};
assert_eq!(producer.sample_rate_hz, consumer.sample_rate_hz);
assert_eq!(producer.capacity_frames, consumer.capacity_frames);
Ok((producer, consumer))
}
pub fn create_default(
sample_rate_hz: u32,
) -> Result<(SourceRingProducer, SourceRingConsumer), AudioMixError> {
const { assert!(AUDIO_RING_CAP_FRAMES > 0) };
const { assert!(AUDIO_RING_CAP_FRAMES <= AUDIO_RING_CAP_FRAMES_MAX) };
SourceRing::create(AUDIO_RING_CAP_FRAMES, sample_rate_hz)
}
}
impl SourceRingProducer {
pub fn sample_rate_hz(&self) -> u32 {
assert!(self.sample_rate_hz >= AUDIO_SAMPLE_RATE_HZ_MIN);
assert!(self.sample_rate_hz <= AUDIO_SAMPLE_RATE_HZ_MAX);
self.sample_rate_hz
}
pub fn capacity_frames(&self) -> usize {
assert!(self.capacity_frames > 0);
assert!(self.capacity_frames <= AUDIO_RING_CAP_FRAMES_MAX);
self.capacity_frames
}
pub fn slots(&self) -> usize {
let slots = self.inner.slots();
assert!(slots <= self.capacity_frames);
slots
}
pub fn is_full(&self) -> bool {
self.inner.is_full()
}
pub fn try_push(&mut self, sample: i16) -> bool {
assert!(self.capacity_frames > 0);
assert!(self.pushed_total < u64::MAX);
match self.inner.push(sample) {
Ok(()) => {
self.pushed_total = self.pushed_total.saturating_add(1);
true
}
Err(_) => {
self.dropped_total = self.dropped_total.saturating_add(1);
false
}
}
}
pub fn try_push_slice(&mut self, samples: &[i16]) -> usize {
assert!(samples.len() <= self.capacity_frames);
assert!(self.pushed_total < u64::MAX - samples.len() as u64);
let (pushed_slice, remainder) = self.inner.push_partial_slice(samples);
let pushed = pushed_slice.len();
let dropped = remainder.len();
assert_eq!(pushed + dropped, samples.len());
self.pushed_total = self.pushed_total.saturating_add(pushed as u64);
self.dropped_total = self.dropped_total.saturating_add(dropped as u64);
pushed
}
pub fn pushed_total(&self) -> u64 {
self.pushed_total
}
pub fn dropped_total(&self) -> u64 {
self.dropped_total
}
}
impl SourceRingConsumer {
pub fn sample_rate_hz(&self) -> u32 {
assert!(self.sample_rate_hz >= AUDIO_SAMPLE_RATE_HZ_MIN);
assert!(self.sample_rate_hz <= AUDIO_SAMPLE_RATE_HZ_MAX);
self.sample_rate_hz
}
pub fn capacity_frames(&self) -> usize {
assert!(self.capacity_frames > 0);
assert!(self.capacity_frames <= AUDIO_RING_CAP_FRAMES_MAX);
self.capacity_frames
}
pub fn slots(&self) -> usize {
let slots = self.inner.slots();
assert!(slots <= self.capacity_frames);
slots
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn drain_into(&mut self, dest: &mut [i16]) -> usize {
assert!(!dest.is_empty());
assert!(dest.len() <= self.capacity_frames);
let requested = dest.len();
let (popped, remainder) = self.inner.pop_partial_slice(dest);
let drained = popped.len();
let leftover = remainder.len();
assert_eq!(drained + leftover, requested);
self.drained_total = self.drained_total.saturating_add(drained as u64);
drained
}
pub fn drained_total(&self) -> u64 {
self.drained_total
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_zero_capacity() {
let err = SourceRing::create(0, 48_000).err();
assert_eq!(err, Some(AudioMixError::ZeroCapacity));
}
#[test]
fn rejects_capacity_over_limit() {
let err = SourceRing::create(AUDIO_RING_CAP_FRAMES_MAX + 1, 48_000).err();
assert!(matches!(
err,
Some(AudioMixError::CapacityExceedsLimit { .. })
));
}
#[test]
fn rejects_sample_rate_below_min() {
let err = SourceRing::create(AUDIO_RING_CAP_FRAMES, 4_000).err();
assert!(matches!(
err,
Some(AudioMixError::SampleRateOutOfRange { .. })
));
}
#[test]
fn rejects_sample_rate_above_max() {
let err = SourceRing::create(AUDIO_RING_CAP_FRAMES, 500_000).err();
assert!(matches!(
err,
Some(AudioMixError::SampleRateOutOfRange { .. })
));
}
#[test]
fn default_capacity_pair_matches() {
let (producer, consumer) = SourceRing::create_default(48_000).expect("pair");
assert_eq!(producer.sample_rate_hz(), 48_000);
assert_eq!(consumer.sample_rate_hz(), 48_000);
assert_eq!(producer.capacity_frames(), AUDIO_RING_CAP_FRAMES);
assert_eq!(consumer.capacity_frames(), AUDIO_RING_CAP_FRAMES);
}
#[test]
fn push_then_drain_roundtrips() {
let (mut producer, mut consumer) = SourceRing::create(64, 48_000).expect("pair");
let samples: Vec<i16> = (0..32).map(|n| n as i16).collect();
let pushed = producer.try_push_slice(&samples);
assert_eq!(pushed, 32);
let mut buf = [0i16; 32];
let drained = consumer.drain_into(&mut buf);
assert_eq!(drained, 32);
assert_eq!(&buf[..], &samples[..]);
assert_eq!(producer.pushed_total(), 32);
assert_eq!(consumer.drained_total(), 32);
}
#[test]
fn try_push_returns_false_when_full_and_increments_dropped() {
let (mut producer, _consumer) = SourceRing::create(2, 48_000).expect("pair");
assert!(producer.try_push(1));
assert!(producer.try_push(2));
assert!(!producer.try_push(3));
assert!(producer.is_full());
assert_eq!(producer.dropped_total(), 1);
assert_eq!(producer.pushed_total(), 2);
}
#[test]
fn try_push_slice_partial_when_capacity_runs_out() {
let (mut producer, _consumer) = SourceRing::create(4, 48_000).expect("pair");
let pushed = producer.try_push_slice(&[10, 20, 30, 40]);
assert_eq!(pushed, 4);
let pushed_again = producer.try_push_slice(&[50, 60]);
assert_eq!(pushed_again, 0);
assert_eq!(producer.dropped_total(), 2);
}
#[test]
fn drain_into_handles_empty_ring() {
let (_producer, mut consumer) = SourceRing::create(64, 48_000).expect("pair");
let mut buf = [0i16; 16];
let drained = consumer.drain_into(&mut buf);
assert_eq!(drained, 0);
assert!(consumer.is_empty());
}
}
+643
View File
@@ -0,0 +1,643 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "criterion"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools",
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fluxer_audio_timing"
version = "0.0.0"
dependencies = [
"criterion",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
@@ -0,0 +1,21 @@
[package]
name = "fluxer_audio_timing"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["rlib"]
[dependencies]
[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "smoother"
harness = false
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_audio_timing::{AudioTimingSmoother, SmootherInput, SourceGainRamp, StaleSourceTracker};
const SR: u32 = 48_000;
const FRAMES_PER_TICK: u32 = 960;
const PERIOD_NS: u64 = 20_000_000;
fn bench_smoother_steady_snap(c: &mut Criterion) {
let mut group = c.benchmark_group("audio_timing_smoother");
group.sample_size(100);
group.bench_function("snap_steady_state", |b| {
let mut s = AudioTimingSmoother::new(1, SR).expect("ok");
let _ = s
.adjust(SmootherInput {
media_ts_ns: 0,
frames: FRAMES_PER_TICK,
wall_ns: 0,
})
.expect("ok");
let mut n: u64 = 1;
b.iter(|| {
let result = s
.adjust(SmootherInput {
media_ts_ns: n * PERIOD_NS,
frames: FRAMES_PER_TICK,
wall_ns: n * PERIOD_NS,
})
.expect("ok");
n = n.wrapping_add(1);
black_box(result);
});
});
group.finish();
}
fn bench_ramp_advance(c: &mut Criterion) {
let mut group = c.benchmark_group("audio_timing_ramp");
group.sample_size(100);
group.bench_function("advance_and_query_gain", |b| {
let mut r = SourceGainRamp::new();
b.iter(|| {
r.advance_tick();
let g = r.current_gain_q15();
if r.is_complete() {
r.reset();
}
black_box(g);
});
});
group.finish();
}
fn bench_stale_check(c: &mut Criterion) {
let mut group = c.benchmark_group("audio_timing_eviction");
group.sample_size(100);
group.bench_function("is_stale_lookup", |b| {
let mut t = StaleSourceTracker::new(16).expect("ok");
for n in 1..=16u64 {
t.register_source(n, n * 1_000).expect("ok");
}
let threshold = 5_000_000_000u64;
let mut now: u64 = 1_000_000_000;
b.iter(|| {
let stale = t.is_stale(8, now, threshold).expect("ok");
now = now.wrapping_add(PERIOD_NS);
black_box(stale);
});
});
group.finish();
}
criterion_group!(
benches,
bench_smoother_steady_snap,
bench_ramp_advance,
bench_stale_check
);
criterion_main!(benches);
@@ -0,0 +1,488 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const EVICTION_NEVER_PUSHED_SENTINEL: u64 = u64::MAX;
pub const MAX_TRACKED_SOURCES: usize = 256;
const STALE_THRESHOLD_NS_MIN: u64 = 1_000_000;
const STALE_THRESHOLD_NS_MAX: u64 = 60_000_000_000;
#[derive(Debug, PartialEq, Eq)]
pub enum StaleSourceTrackerError {
CapacityExceeded { capacity: usize },
SourceIdZero,
UnknownSource { source_id: u64 },
StaleThresholdOutOfRange { stale_threshold_ns: u64 },
}
impl core::fmt::Display for StaleSourceTrackerError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
StaleSourceTrackerError::CapacityExceeded { capacity } => {
write!(f, "tracker capacity {capacity} exceeded")
}
StaleSourceTrackerError::SourceIdZero => write!(f, "source_id must be non-zero"),
StaleSourceTrackerError::UnknownSource { source_id } => {
write!(f, "source_id {source_id} not registered")
}
StaleSourceTrackerError::StaleThresholdOutOfRange { stale_threshold_ns } => {
write!(
f,
"stale threshold {stale_threshold_ns} ns outside accepted range"
)
}
}
}
}
impl std::error::Error for StaleSourceTrackerError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StaleSourceEntry {
pub source_id: u64,
pub registered_at_ns: u64,
pub last_active_ns: u64,
}
impl StaleSourceEntry {
pub fn ever_pushed(&self) -> bool {
self.last_active_ns != EVICTION_NEVER_PUSHED_SENTINEL
}
}
pub struct StaleSourceTracker {
entries: Vec<StaleSourceEntry>,
capacity: usize,
}
impl StaleSourceTracker {
pub fn new(capacity: usize) -> Result<Self, StaleSourceTrackerError> {
if capacity == 0 || capacity > MAX_TRACKED_SOURCES {
return Err(StaleSourceTrackerError::CapacityExceeded { capacity });
}
assert!(capacity > 0);
assert!(capacity <= MAX_TRACKED_SOURCES);
Ok(Self {
entries: Vec::with_capacity(capacity),
capacity,
})
}
pub fn len(&self) -> usize {
assert!(self.entries.len() <= self.capacity);
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn capacity(&self) -> usize {
assert!(self.capacity > 0);
assert!(self.capacity <= MAX_TRACKED_SOURCES);
self.capacity
}
pub fn register_source(
&mut self,
source_id: u64,
registered_at_ns: u64,
) -> Result<(), StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
if self.entries.len() >= self.capacity {
return Err(StaleSourceTrackerError::CapacityExceeded {
capacity: self.capacity,
});
}
assert!(source_id != 0);
if let Some(existing) = self.find_mut(source_id) {
existing.registered_at_ns = registered_at_ns;
existing.last_active_ns = EVICTION_NEVER_PUSHED_SENTINEL;
return Ok(());
}
self.entries.push(StaleSourceEntry {
source_id,
registered_at_ns,
last_active_ns: EVICTION_NEVER_PUSHED_SENTINEL,
});
assert!(self.entries.len() <= self.capacity);
Ok(())
}
pub fn unregister_source(&mut self, source_id: u64) -> Result<(), StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
assert!(source_id != 0);
let before = self.entries.len();
self.entries.retain(|e| e.source_id != source_id);
if self.entries.len() == before {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
}
assert!(self.entries.len() < before);
Ok(())
}
pub fn mark_pushed(
&mut self,
source_id: u64,
ts_ns: u64,
) -> Result<(), StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
assert!(source_id != 0);
let Some(entry) = self.find_mut(source_id) else {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
};
entry.last_active_ns = ts_ns;
assert!(entry.last_active_ns == ts_ns);
Ok(())
}
pub fn mark_pushed_at(
&mut self,
index: usize,
source_id: u64,
ts_ns: u64,
) -> Result<(), StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
assert!(source_id != 0);
assert!(self.entries.len() <= self.capacity);
if index >= self.entries.len() {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
}
if self.entries[index].source_id != source_id {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
}
self.entries[index].last_active_ns = ts_ns;
assert!(self.entries[index].last_active_ns == ts_ns);
Ok(())
}
pub fn is_stale(
&self,
source_id: u64,
now_ns: u64,
stale_threshold_ns: u64,
) -> Result<bool, StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
if !(STALE_THRESHOLD_NS_MIN..=STALE_THRESHOLD_NS_MAX).contains(&stale_threshold_ns) {
return Err(StaleSourceTrackerError::StaleThresholdOutOfRange { stale_threshold_ns });
}
assert!(source_id != 0);
let Some(entry) = self.find(source_id) else {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
};
Ok(Self::entry_is_stale(entry, now_ns, stale_threshold_ns))
}
pub fn is_stale_at(
&self,
index: usize,
source_id: u64,
now_ns: u64,
stale_threshold_ns: u64,
) -> Result<bool, StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
if !(STALE_THRESHOLD_NS_MIN..=STALE_THRESHOLD_NS_MAX).contains(&stale_threshold_ns) {
return Err(StaleSourceTrackerError::StaleThresholdOutOfRange { stale_threshold_ns });
}
assert!(source_id != 0);
assert!(self.entries.len() <= self.capacity);
if index >= self.entries.len() {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
}
let entry = &self.entries[index];
if entry.source_id != source_id {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
}
Ok(Self::entry_is_stale(entry, now_ns, stale_threshold_ns))
}
fn entry_is_stale(entry: &StaleSourceEntry, now_ns: u64, stale_threshold_ns: u64) -> bool {
assert!(entry.source_id != 0);
assert!(stale_threshold_ns >= STALE_THRESHOLD_NS_MIN);
assert!(stale_threshold_ns <= STALE_THRESHOLD_NS_MAX);
let baseline_ns = if entry.last_active_ns == EVICTION_NEVER_PUSHED_SENTINEL {
entry.registered_at_ns
} else {
entry.last_active_ns
};
if now_ns <= baseline_ns {
return false;
}
let age_ns = now_ns - baseline_ns;
age_ns > stale_threshold_ns
}
pub fn age_ns(&self, source_id: u64, now_ns: u64) -> Result<u64, StaleSourceTrackerError> {
if source_id == 0 {
return Err(StaleSourceTrackerError::SourceIdZero);
}
assert!(source_id != 0);
let Some(entry) = self.find(source_id) else {
return Err(StaleSourceTrackerError::UnknownSource { source_id });
};
let baseline_ns = if entry.last_active_ns == EVICTION_NEVER_PUSHED_SENTINEL {
entry.registered_at_ns
} else {
entry.last_active_ns
};
if now_ns <= baseline_ns {
return Ok(0);
}
Ok(now_ns - baseline_ns)
}
pub fn entry(&self, source_id: u64) -> Option<StaleSourceEntry> {
self.find(source_id).copied()
}
fn find(&self, source_id: u64) -> Option<&StaleSourceEntry> {
self.entries.iter().find(|e| e.source_id == source_id)
}
fn find_mut(&mut self, source_id: u64) -> Option<&mut StaleSourceEntry> {
self.entries.iter_mut().find(|e| e.source_id == source_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unregistered_source_is_error() {
let tracker = StaleSourceTracker::new(4).expect("ok");
let err = tracker.is_stale(7, 1_000_000_000, 5_000_000_000).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::UnknownSource { source_id: 7 })
));
}
#[test]
fn registered_source_starts_not_stale_at_registration_time() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 1_000_000).expect("ok");
let stale = t.is_stale(1, 1_000_000, 5_000_000_000).expect("ok");
assert!(!stale);
}
#[test]
fn registered_but_never_pushed_becomes_stale_past_threshold() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 1_000_000).expect("ok");
let threshold = 5_000_000_000;
let now = 1_000_000 + threshold + 1;
let stale = t.is_stale(1, now, threshold).expect("ok");
assert!(stale, "never-pushed source must go stale past threshold");
}
#[test]
fn registered_but_never_pushed_within_window_is_fresh() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 1_000_000).expect("ok");
let threshold = 5_000_000_000;
let now = 1_000_000 + threshold - 1;
let stale = t.is_stale(1, now, threshold).expect("ok");
assert!(!stale);
}
#[test]
fn marking_pushed_extends_freshness() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(2, 0).expect("ok");
t.mark_pushed(2, 1_000_000_000).expect("ok");
let threshold = 500_000_000;
let now = 1_000_000_000 + threshold + 1;
let stale = t.is_stale(2, now, threshold).expect("ok");
assert!(stale);
let now_within = 1_000_000_000 + threshold - 1;
let fresh = t.is_stale(2, now_within, threshold).expect("ok");
assert!(!fresh);
}
#[test]
fn unregister_removes_entry() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(3, 0).expect("ok");
assert_eq!(t.len(), 1);
t.unregister_source(3).expect("ok");
assert_eq!(t.len(), 0);
}
#[test]
fn capacity_exceeded_rejected() {
let mut t = StaleSourceTracker::new(2).expect("ok");
t.register_source(1, 0).expect("ok");
t.register_source(2, 0).expect("ok");
let err = t.register_source(3, 0).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::CapacityExceeded { .. })
));
}
#[test]
fn age_ns_uses_registration_for_never_pushed() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 1_000).expect("ok");
let age = t.age_ns(1, 5_000).expect("ok");
assert_eq!(age, 4_000);
}
#[test]
fn age_ns_uses_last_active_after_push() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 1_000).expect("ok");
t.mark_pushed(1, 2_000).expect("ok");
let age = t.age_ns(1, 5_000).expect("ok");
assert_eq!(age, 3_000);
}
#[test]
fn determinism_two_runs_produce_same_decisions() {
let run = || {
let mut t = StaleSourceTracker::new(8).expect("ok");
t.register_source(1, 0).expect("ok");
t.register_source(2, 0).expect("ok");
t.mark_pushed(2, 1_000_000_000).expect("ok");
let threshold = 2_000_000_000;
let out: Vec<bool> = vec![
t.is_stale(1, 3_000_000_000, threshold).expect("ok"),
t.is_stale(2, 3_000_000_000, threshold).expect("ok"),
];
out
};
let a = run();
let b = run();
assert_eq!(a, b);
}
#[test]
fn rejects_invalid_threshold() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 0).expect("ok");
let err = t.is_stale(1, 1_000_000, 0).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::StaleThresholdOutOfRange { .. })
));
}
#[test]
fn rejects_zero_source_id() {
let mut t = StaleSourceTracker::new(4).expect("ok");
let err = t.register_source(0, 0).err();
assert!(matches!(err, Some(StaleSourceTrackerError::SourceIdZero)));
}
#[test]
fn mark_pushed_at_succeeds_when_index_and_id_agree() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
t.register_source(20, 0).expect("ok");
t.mark_pushed_at(1, 20, 2_000).expect("ok");
let e = t.entry(20).expect("entry");
assert_eq!(e.last_active_ns, 2_000);
let untouched = t.entry(10).expect("entry");
assert!(!untouched.ever_pushed());
}
#[test]
fn mark_pushed_at_rejects_index_id_mismatch() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
t.register_source(20, 0).expect("ok");
let err = t.mark_pushed_at(0, 20, 2_000).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::UnknownSource { source_id: 20 })
));
assert!(!t.entry(10).expect("entry").ever_pushed());
assert!(!t.entry(20).expect("entry").ever_pushed());
}
#[test]
fn mark_pushed_at_rejects_out_of_range_index() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
let err = t.mark_pushed_at(1, 10, 2_000).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::UnknownSource { source_id: 10 })
));
}
#[test]
fn mark_pushed_at_rejects_zero_source_id() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
let err = t.mark_pushed_at(0, 0, 2_000).err();
assert!(matches!(err, Some(StaleSourceTrackerError::SourceIdZero)));
}
#[test]
fn is_stale_at_matches_is_stale() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
t.register_source(20, 0).expect("ok");
t.mark_pushed_at(1, 20, 1_000_000_000).expect("ok");
let threshold = 2_000_000_000;
for now in [500_000_000, 2_500_000_000, 4_000_000_000] {
let by_id = t.is_stale(20, now, threshold).expect("ok");
let by_index = t.is_stale_at(1, 20, now, threshold).expect("ok");
assert_eq!(by_id, by_index);
}
}
#[test]
fn is_stale_at_rejects_index_id_mismatch() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
t.register_source(20, 0).expect("ok");
let err = t.is_stale_at(0, 20, 3_000_000_000, 2_000_000_000).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::UnknownSource { source_id: 20 })
));
let err = t.is_stale_at(5, 10, 3_000_000_000, 2_000_000_000).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::UnknownSource { source_id: 10 })
));
}
#[test]
fn is_stale_at_rejects_invalid_threshold() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(10, 0).expect("ok");
let err = t.is_stale_at(0, 10, 1_000_000, 0).err();
assert!(matches!(
err,
Some(StaleSourceTrackerError::StaleThresholdOutOfRange { .. })
));
}
#[test]
fn ever_pushed_flag_reflects_state() {
let mut t = StaleSourceTracker::new(4).expect("ok");
t.register_source(1, 10).expect("ok");
let e = t.entry(1).expect("entry");
assert!(!e.ever_pushed());
t.mark_pushed(1, 100).expect("ok");
let e2 = t.entry(1).expect("entry");
assert!(e2.ever_pushed());
}
}
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::too_many_lines)]
pub mod eviction;
pub mod ramping;
pub mod smoother;
pub use eviction::{
EVICTION_NEVER_PUSHED_SENTINEL, MAX_TRACKED_SOURCES, StaleSourceEntry, StaleSourceTracker,
StaleSourceTrackerError,
};
pub use ramping::{RAMP_GAIN_DENOMINATOR_Q15, RAMP_IN_TICKS_DEFAULT, RampError, SourceGainRamp};
pub use smoother::{
AudioTimingError, AudioTimingSmoother, MAX_TS_VAR_NS, NS_PER_SECOND, SmootherAction,
SmootherEvent, SmootherInput, SmootherResetReason, SmootherResult, TS_SMOOTHING_THRESHOLD_NS,
};
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const RAMP_IN_TICKS_DEFAULT: u32 = 5;
pub const RAMP_GAIN_DENOMINATOR_Q15: i32 = 32_768;
const RAMP_IN_TICKS_MIN: u32 = 1;
const RAMP_IN_TICKS_MAX: u32 = 1_024;
#[derive(Debug, PartialEq, Eq)]
pub enum RampError {
RampInTicksOutOfRange { ramp_in_ticks: u32 },
}
impl core::fmt::Display for RampError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
RampError::RampInTicksOutOfRange { ramp_in_ticks } => {
write!(f, "ramp_in_ticks {ramp_in_ticks} outside accepted range")
}
}
}
}
impl std::error::Error for RampError {}
pub struct SourceGainRamp {
tick_count: u32,
ramp_in_ticks: u32,
}
impl SourceGainRamp {
pub fn new() -> Self {
Self {
tick_count: 0,
ramp_in_ticks: RAMP_IN_TICKS_DEFAULT,
}
}
pub fn with_ramp_in_ticks(ramp_in_ticks: u32) -> Result<Self, RampError> {
if !(RAMP_IN_TICKS_MIN..=RAMP_IN_TICKS_MAX).contains(&ramp_in_ticks) {
return Err(RampError::RampInTicksOutOfRange { ramp_in_ticks });
}
assert!(ramp_in_ticks >= RAMP_IN_TICKS_MIN);
assert!(ramp_in_ticks <= RAMP_IN_TICKS_MAX);
Ok(Self {
tick_count: 0,
ramp_in_ticks,
})
}
pub fn tick_count(&self) -> u32 {
self.tick_count
}
pub fn ramp_in_ticks(&self) -> u32 {
assert!(self.ramp_in_ticks >= RAMP_IN_TICKS_MIN);
assert!(self.ramp_in_ticks <= RAMP_IN_TICKS_MAX);
self.ramp_in_ticks
}
pub fn is_complete(&self) -> bool {
self.tick_count >= self.ramp_in_ticks
}
pub fn current_gain(&self) -> f32 {
assert!(self.ramp_in_ticks >= RAMP_IN_TICKS_MIN);
let clamped = self.tick_count.min(self.ramp_in_ticks);
let g = (clamped as f32) / (self.ramp_in_ticks as f32);
assert!(g >= 0.0);
assert!(g <= 1.0);
g
}
pub fn current_gain_q15(&self) -> i32 {
assert!(self.ramp_in_ticks >= RAMP_IN_TICKS_MIN);
let clamped = self.tick_count.min(self.ramp_in_ticks);
let num = (clamped as i64) * (RAMP_GAIN_DENOMINATOR_Q15 as i64);
let den = self.ramp_in_ticks as i64;
let q = num / den;
assert!(q >= 0);
assert!(q <= RAMP_GAIN_DENOMINATOR_Q15 as i64);
q as i32
}
pub fn advance_tick(&mut self) {
let before = self.tick_count;
self.tick_count = self.tick_count.saturating_add(1);
assert!(self.tick_count >= before);
assert!(self.tick_count >= 1);
}
pub fn reset(&mut self) {
self.tick_count = 0;
assert_eq!(self.tick_count, 0);
assert!(self.ramp_in_ticks >= RAMP_IN_TICKS_MIN);
}
}
impl Default for SourceGainRamp {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_ramp_starts_at_zero_gain() {
let r = SourceGainRamp::new();
assert_eq!(r.tick_count(), 0);
assert_eq!(r.current_gain(), 0.0);
assert!(!r.is_complete());
}
#[test]
fn five_ticks_increase_monotonically_to_one() {
let mut r = SourceGainRamp::new();
let mut prior = -1.0f32;
for expected_n in 1..=5u32 {
r.advance_tick();
let g = r.current_gain();
assert!(g > prior);
assert!(g >= 0.0);
assert!(g <= 1.0);
assert_eq!(r.tick_count(), expected_n);
prior = g;
}
assert_eq!(r.current_gain(), 1.0);
assert!(r.is_complete());
}
#[test]
fn gain_clamps_to_one_after_completion() {
let mut r = SourceGainRamp::new();
for _ in 0..20 {
r.advance_tick();
}
assert_eq!(r.current_gain(), 1.0);
assert!(r.is_complete());
}
#[test]
fn first_five_gains_are_quintiles() {
let mut r = SourceGainRamp::new();
let mut observed: Vec<f32> = Vec::with_capacity(5);
for _ in 0..5 {
r.advance_tick();
observed.push(r.current_gain());
}
assert_eq!(observed.len(), 5);
assert!((observed[0] - 0.2).abs() < f32::EPSILON);
assert!((observed[1] - 0.4).abs() < f32::EPSILON * 4.0);
assert!((observed[2] - 0.6).abs() < f32::EPSILON * 4.0);
assert!((observed[3] - 0.8).abs() < f32::EPSILON * 4.0);
assert_eq!(observed[4], 1.0);
}
#[test]
fn q15_gain_at_completion_equals_denominator() {
let mut r = SourceGainRamp::new();
for _ in 0..5 {
r.advance_tick();
}
assert_eq!(r.current_gain_q15(), RAMP_GAIN_DENOMINATOR_Q15);
}
#[test]
fn q15_gain_starts_at_zero() {
let r = SourceGainRamp::new();
assert_eq!(r.current_gain_q15(), 0);
}
#[test]
fn reset_returns_ramp_to_zero_gain() {
let mut r = SourceGainRamp::new();
for _ in 0..10 {
r.advance_tick();
}
assert_eq!(r.current_gain(), 1.0);
r.reset();
assert_eq!(r.tick_count(), 0);
assert_eq!(r.current_gain(), 0.0);
}
#[test]
fn custom_ramp_in_ticks_validates() {
let r = SourceGainRamp::with_ramp_in_ticks(10).expect("ok");
assert_eq!(r.ramp_in_ticks(), 10);
let err = SourceGainRamp::with_ramp_in_ticks(0).err();
assert!(matches!(err, Some(RampError::RampInTicksOutOfRange { .. })));
let err2 = SourceGainRamp::with_ramp_in_ticks(10_000).err();
assert!(matches!(
err2,
Some(RampError::RampInTicksOutOfRange { .. })
));
}
#[test]
fn determinism_two_ramps_produce_same_sequence() {
let run = || {
let mut r = SourceGainRamp::new();
let mut g: Vec<i32> = Vec::with_capacity(7);
for _ in 0..7 {
g.push(r.current_gain_q15());
r.advance_tick();
}
g
};
let a = run();
let b = run();
assert_eq!(a, b);
}
}
@@ -0,0 +1,579 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const TS_SMOOTHING_THRESHOLD_NS: u64 = 70_000_000;
pub const MAX_TS_VAR_NS: u64 = 2_000_000_000;
pub const NS_PER_SECOND: u64 = 1_000_000_000;
const SAMPLE_RATE_HZ_MIN: u32 = 8_000;
const SAMPLE_RATE_HZ_MAX: u32 = 384_000;
const FRAMES_PER_INPUT_MAX: u32 = 1 << 24;
#[derive(Debug, PartialEq, Eq)]
pub enum AudioTimingError {
SampleRateOutOfRange { sample_rate_hz: u32 },
SourceIdZero,
FramesOutOfRange { frames: u32 },
}
impl core::fmt::Display for AudioTimingError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
AudioTimingError::SampleRateOutOfRange { sample_rate_hz } => {
write!(f, "sample rate {sample_rate_hz} hz outside accepted range")
}
AudioTimingError::SourceIdZero => write!(f, "source_id must be non-zero"),
AudioTimingError::FramesOutOfRange { frames } => {
write!(f, "frames {frames} outside accepted range")
}
}
}
}
impl std::error::Error for AudioTimingError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SmootherInput {
pub media_ts_ns: u64,
pub frames: u32,
pub wall_ns: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SmootherAction {
Snap,
Jump,
Reset,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SmootherResetReason {
FirstSample,
GapBeyondMaxVar,
Regression,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SmootherEvent {
Snapped {
source_id: u64,
incoming_media_ts_ns: u64,
snapped_ts_ns: u64,
delta_ns: i64,
frames_accumulated: u64,
},
Jumped {
source_id: u64,
incoming_media_ts_ns: u64,
previous_predicted_ts_ns: u64,
delta_ns: i64,
frames_accumulated: u64,
},
Reset {
source_id: u64,
incoming_media_ts_ns: u64,
wall_ns: u64,
timing_adjust_ns: i64,
frames_accumulated: u64,
reason: SmootherResetReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SmootherResult {
pub action: SmootherAction,
pub output_ts_ns: u64,
pub predicted_next_ts_ns: u64,
pub frames_accumulated: u64,
pub event: SmootherEvent,
}
pub struct AudioTimingSmoother {
source_id: u64,
sample_rate_hz: u32,
initialised: bool,
predicted_next_ts_ns: u64,
timing_adjust_ns: i64,
frames_accumulated: u64,
consecutive_jumps: u32,
}
impl AudioTimingSmoother {
pub fn new(source_id: u64, sample_rate_hz: u32) -> Result<Self, AudioTimingError> {
if source_id == 0 {
return Err(AudioTimingError::SourceIdZero);
}
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
return Err(AudioTimingError::SampleRateOutOfRange { sample_rate_hz });
}
assert!(source_id != 0);
assert!(sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
assert!(sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
Ok(Self {
source_id,
sample_rate_hz,
initialised: false,
predicted_next_ts_ns: 0,
timing_adjust_ns: 0,
frames_accumulated: 0,
consecutive_jumps: 0,
})
}
pub fn source_id(&self) -> u64 {
assert!(self.source_id != 0);
self.source_id
}
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 initialised(&self) -> bool {
self.initialised
}
pub fn predicted_next_ts_ns(&self) -> u64 {
self.predicted_next_ts_ns
}
pub fn timing_adjust_ns(&self) -> i64 {
self.timing_adjust_ns
}
pub fn frames_accumulated(&self) -> u64 {
self.frames_accumulated
}
pub fn consecutive_jumps(&self) -> u32 {
self.consecutive_jumps
}
pub fn adjust(&mut self, input: SmootherInput) -> Result<SmootherResult, AudioTimingError> {
if input.frames == 0 || input.frames > FRAMES_PER_INPUT_MAX {
return Err(AudioTimingError::FramesOutOfRange {
frames: input.frames,
});
}
assert!(input.frames >= 1);
assert!(input.frames <= FRAMES_PER_INPUT_MAX);
if !self.initialised {
return Ok(self.initialise_from_first_sample(input));
}
Ok(self.adjust_initialised(input))
}
fn initialise_from_first_sample(&mut self, input: SmootherInput) -> SmootherResult {
assert!(!self.initialised);
assert_eq!(self.frames_accumulated, 0);
let increment_ns = frames_to_ns(input.frames as u64, self.sample_rate_hz);
let predicted_next = input.media_ts_ns.saturating_add(increment_ns);
let timing_adjust = wall_minus_media_signed(input.wall_ns, input.media_ts_ns);
self.frames_accumulated = input.frames as u64;
self.predicted_next_ts_ns = predicted_next;
self.timing_adjust_ns = timing_adjust;
self.initialised = true;
self.consecutive_jumps = 0;
assert!(predicted_next >= input.media_ts_ns);
let event = SmootherEvent::Reset {
source_id: self.source_id,
incoming_media_ts_ns: input.media_ts_ns,
wall_ns: input.wall_ns,
timing_adjust_ns: timing_adjust,
frames_accumulated: self.frames_accumulated,
reason: SmootherResetReason::FirstSample,
};
SmootherResult {
action: SmootherAction::Reset,
output_ts_ns: input.media_ts_ns,
predicted_next_ts_ns: predicted_next,
frames_accumulated: self.frames_accumulated,
event,
}
}
fn adjust_initialised(&mut self, input: SmootherInput) -> SmootherResult {
assert!(self.initialised);
let expected = self.predicted_next_ts_ns;
let delta = signed_delta(input.media_ts_ns, expected);
let absolute_delta = delta.unsigned_abs();
if absolute_delta <= TS_SMOOTHING_THRESHOLD_NS {
return self.apply_snap(input, expected, delta);
}
if absolute_delta <= MAX_TS_VAR_NS {
return self.apply_jump(input, expected, delta);
}
self.apply_full_reset(input, SmootherResetReason::GapBeyondMaxVar)
}
fn apply_snap(&mut self, input: SmootherInput, expected: u64, delta: i64) -> SmootherResult {
assert!(self.initialised);
let increment_ns = frames_to_ns(input.frames as u64, self.sample_rate_hz);
let new_predicted = expected.saturating_add(increment_ns);
self.frames_accumulated = self.frames_accumulated.saturating_add(input.frames as u64);
self.predicted_next_ts_ns = new_predicted;
self.consecutive_jumps = 0;
assert!(new_predicted >= expected);
let event = SmootherEvent::Snapped {
source_id: self.source_id,
incoming_media_ts_ns: input.media_ts_ns,
snapped_ts_ns: expected,
delta_ns: delta,
frames_accumulated: self.frames_accumulated,
};
SmootherResult {
action: SmootherAction::Snap,
output_ts_ns: expected,
predicted_next_ts_ns: new_predicted,
frames_accumulated: self.frames_accumulated,
event,
}
}
fn apply_jump(&mut self, input: SmootherInput, expected: u64, delta: i64) -> SmootherResult {
assert!(self.initialised);
if delta < 0 {
return self.apply_full_reset(input, SmootherResetReason::Regression);
}
let increment_ns = frames_to_ns(input.frames as u64, self.sample_rate_hz);
let new_predicted = input.media_ts_ns.saturating_add(increment_ns);
self.frames_accumulated = self.frames_accumulated.saturating_add(input.frames as u64);
self.predicted_next_ts_ns = new_predicted;
self.consecutive_jumps = self.consecutive_jumps.saturating_add(1);
assert!(new_predicted >= input.media_ts_ns);
let event = SmootherEvent::Jumped {
source_id: self.source_id,
incoming_media_ts_ns: input.media_ts_ns,
previous_predicted_ts_ns: expected,
delta_ns: delta,
frames_accumulated: self.frames_accumulated,
};
SmootherResult {
action: SmootherAction::Jump,
output_ts_ns: input.media_ts_ns,
predicted_next_ts_ns: new_predicted,
frames_accumulated: self.frames_accumulated,
event,
}
}
fn apply_full_reset(
&mut self,
input: SmootherInput,
reason: SmootherResetReason,
) -> SmootherResult {
assert!(self.initialised);
let increment_ns = frames_to_ns(input.frames as u64, self.sample_rate_hz);
let new_predicted = input.media_ts_ns.saturating_add(increment_ns);
let timing_adjust = wall_minus_media_signed(input.wall_ns, input.media_ts_ns);
self.frames_accumulated = self.frames_accumulated.saturating_add(input.frames as u64);
self.predicted_next_ts_ns = new_predicted;
self.timing_adjust_ns = timing_adjust;
self.consecutive_jumps = 0;
assert!(new_predicted >= input.media_ts_ns);
let event = SmootherEvent::Reset {
source_id: self.source_id,
incoming_media_ts_ns: input.media_ts_ns,
wall_ns: input.wall_ns,
timing_adjust_ns: timing_adjust,
frames_accumulated: self.frames_accumulated,
reason,
};
SmootherResult {
action: SmootherAction::Reset,
output_ts_ns: input.media_ts_ns,
predicted_next_ts_ns: new_predicted,
frames_accumulated: self.frames_accumulated,
event,
}
}
}
pub fn frames_to_ns(frames: u64, sample_rate_hz: u32) -> u64 {
assert!(sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
assert!(sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
let product = (frames as u128) * (NS_PER_SECOND as u128);
let div = product / (sample_rate_hz as u128);
assert!(div <= u64::MAX as u128);
div as u64
}
fn signed_delta(a: u64, b: u64) -> i64 {
if a >= b {
let diff = a - b;
assert!(diff <= i64::MAX as u64);
diff as i64
} else {
let diff = b - a;
assert!(diff <= i64::MAX as u64);
-(diff as i64)
}
}
fn wall_minus_media_signed(wall_ns: u64, media_ts_ns: u64) -> i64 {
signed_delta(wall_ns, media_ts_ns)
}
#[cfg(test)]
mod tests {
use super::*;
const SR: u32 = 48_000;
fn frames_per_20ms() -> u32 {
SR / 50
}
fn period_ns_20ms() -> u64 {
20_000_000
}
#[test]
fn first_sample_initialises_state() {
let mut smoother = AudioTimingSmoother::new(1, SR).expect("ok");
assert!(!smoother.initialised());
let result = smoother
.adjust(SmootherInput {
media_ts_ns: 1_000_000,
frames: frames_per_20ms(),
wall_ns: 1_500_000,
})
.expect("ok");
assert_eq!(result.action, SmootherAction::Reset);
assert!(smoother.initialised());
assert!(matches!(
result.event,
SmootherEvent::Reset {
reason: SmootherResetReason::FirstSample,
..
}
));
assert_eq!(result.output_ts_ns, 1_000_000);
assert_eq!(result.frames_accumulated, frames_per_20ms() as u64);
assert!(result.predicted_next_ts_ns >= result.output_ts_ns);
}
#[test]
fn second_sample_within_threshold_snaps_to_expected() {
let mut smoother = AudioTimingSmoother::new(2, SR).expect("ok");
let frames = frames_per_20ms();
let _ = smoother
.adjust(SmootherInput {
media_ts_ns: 0,
frames,
wall_ns: 0,
})
.expect("ok");
let expected = smoother.predicted_next_ts_ns();
let drift_ns = 10_000_000;
let r = smoother
.adjust(SmootherInput {
media_ts_ns: expected + drift_ns,
frames,
wall_ns: expected + drift_ns,
})
.expect("ok");
assert_eq!(r.action, SmootherAction::Snap);
assert_eq!(r.output_ts_ns, expected);
assert!(matches!(r.event, SmootherEvent::Snapped { .. }));
}
#[test]
fn jump_outside_threshold_but_within_max_var() {
let mut smoother = AudioTimingSmoother::new(3, SR).expect("ok");
let frames = frames_per_20ms();
let _ = smoother
.adjust(SmootherInput {
media_ts_ns: 0,
frames,
wall_ns: 0,
})
.expect("ok");
let expected = smoother.predicted_next_ts_ns();
let jump_ns = 100_000_000;
let r = smoother
.adjust(SmootherInput {
media_ts_ns: expected + jump_ns,
frames,
wall_ns: expected + jump_ns,
})
.expect("ok");
assert_eq!(r.action, SmootherAction::Jump);
assert_eq!(r.output_ts_ns, expected + jump_ns);
assert!(matches!(r.event, SmootherEvent::Jumped { .. }));
assert_eq!(smoother.consecutive_jumps(), 1);
}
#[test]
fn gap_beyond_max_var_triggers_full_reset() {
let mut smoother = AudioTimingSmoother::new(4, SR).expect("ok");
let frames = frames_per_20ms();
let _ = smoother
.adjust(SmootherInput {
media_ts_ns: 0,
frames,
wall_ns: 0,
})
.expect("ok");
let expected = smoother.predicted_next_ts_ns();
let huge_gap = 3 * NS_PER_SECOND;
let r = smoother
.adjust(SmootherInput {
media_ts_ns: expected + huge_gap,
frames,
wall_ns: expected + huge_gap,
})
.expect("ok");
assert_eq!(r.action, SmootherAction::Reset);
assert!(matches!(
r.event,
SmootherEvent::Reset {
reason: SmootherResetReason::GapBeyondMaxVar,
..
}
));
}
#[test]
fn regression_inside_jump_window_triggers_reset() {
let mut smoother = AudioTimingSmoother::new(5, SR).expect("ok");
let frames = frames_per_20ms();
let _ = smoother
.adjust(SmootherInput {
media_ts_ns: NS_PER_SECOND,
frames,
wall_ns: NS_PER_SECOND,
})
.expect("ok");
let expected = smoother.predicted_next_ts_ns();
assert!(expected > 200_000_000);
let r = smoother
.adjust(SmootherInput {
media_ts_ns: expected - 200_000_000,
frames,
wall_ns: NS_PER_SECOND,
})
.expect("ok");
assert_eq!(r.action, SmootherAction::Reset);
assert!(matches!(
r.event,
SmootherEvent::Reset {
reason: SmootherResetReason::Regression,
..
}
));
}
#[test]
fn monotonicity_preserved_across_1000_samples_with_jitter() {
let mut smoother = AudioTimingSmoother::new(6, SR).expect("ok");
let frames = frames_per_20ms();
let mut wall_ns: u64 = 0;
let mut media_ts: u64 = 0;
let mut last_predicted = 0u64;
for n in 0..1000u64 {
let jitter: i64 = ((n as i64 * 1_234_567) % 100_000_000) - 50_000_000;
let jittered_ts = if jitter < 0 {
media_ts.saturating_sub(jitter.unsigned_abs())
} else {
media_ts.saturating_add(jitter as u64)
};
let r = smoother
.adjust(SmootherInput {
media_ts_ns: jittered_ts,
frames,
wall_ns,
})
.expect("ok");
assert!(r.predicted_next_ts_ns >= last_predicted || r.action == SmootherAction::Reset);
last_predicted = r.predicted_next_ts_ns;
wall_ns = wall_ns.saturating_add(period_ns_20ms());
media_ts = media_ts.saturating_add(period_ns_20ms());
}
assert!(smoother.frames_accumulated() >= 1000 * frames as u64);
}
#[test]
fn frame_count_accumulates_monotonically() {
let mut smoother = AudioTimingSmoother::new(7, SR).expect("ok");
let frames = frames_per_20ms();
let mut prev = 0u64;
for n in 0..50u64 {
let r = smoother
.adjust(SmootherInput {
media_ts_ns: n * period_ns_20ms(),
frames,
wall_ns: n * period_ns_20ms(),
})
.expect("ok");
assert!(r.frames_accumulated > prev);
prev = r.frames_accumulated;
}
}
#[test]
fn rejects_zero_source_id() {
let err = AudioTimingSmoother::new(0, SR).err();
assert!(matches!(err, Some(AudioTimingError::SourceIdZero)));
}
#[test]
fn rejects_sample_rate_out_of_range() {
let err = AudioTimingSmoother::new(1, 1_000).err();
assert!(matches!(
err,
Some(AudioTimingError::SampleRateOutOfRange { .. })
));
}
#[test]
fn rejects_zero_frame_count() {
let mut smoother = AudioTimingSmoother::new(1, SR).expect("ok");
let err = smoother
.adjust(SmootherInput {
media_ts_ns: 0,
frames: 0,
wall_ns: 0,
})
.err();
assert!(matches!(
err,
Some(AudioTimingError::FramesOutOfRange { .. })
));
}
#[test]
fn frames_to_ns_is_round_trip_consistent() {
let ns = frames_to_ns(48_000, 48_000);
assert_eq!(ns, NS_PER_SECOND);
let ns_half = frames_to_ns(24_000, 48_000);
assert_eq!(ns_half, NS_PER_SECOND / 2);
}
#[test]
fn determinism_two_runs_produce_identical_outputs() {
let run = || {
let mut s = AudioTimingSmoother::new(9, SR).expect("ok");
let mut events: Vec<SmootherResult> = Vec::with_capacity(100);
for n in 0..100u64 {
let r = s
.adjust(SmootherInput {
media_ts_ns: n * period_ns_20ms(),
frames: frames_per_20ms(),
wall_ns: n * period_ns_20ms() + 1_000,
})
.expect("ok");
events.push(r);
}
events
};
let a = run();
let b = run();
assert_eq!(a, b);
}
}
+979
View File
@@ -0,0 +1,979 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading 0.8.9",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "criterion"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools",
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fluxer_encoder_ring"
version = "0.0.0"
dependencies = [
"bindgen",
"criterion",
"libloading 0.9.0",
"objc2",
"objc2-core-foundation",
"objc2-core-video",
"objc2-io-surface",
"windows",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"block2",
"dispatch2",
"libc",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags",
"block2",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
"objc2-metal",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"objc2",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-metal"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags",
"objc2",
"objc2-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
@@ -0,0 +1,51 @@
[package]
name = "fluxer_encoder_ring"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["rlib"]
[dependencies]
[target.'cfg(target_os = "windows")'.dependencies]
windows = {version = "0.62.2", features = [
"Win32_Foundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Security",
"Win32_System_Threading",
"Win32_System_LibraryLoader",
]}
libloading = "0.9"
[build-dependencies]
bindgen = {version = "0.72", optional = true}
[features]
default = []
bindgen-sdk = ["dep:bindgen"]
[dev-dependencies]
criterion = "0.8"
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2 = "0.6"
objc2-core-video = {version = "0.3", features = ["CVPixelBuffer", "CVPixelBufferIOSurface", "CVImageBuffer", "CVBuffer", "CVPixelFormatDescription", "CVReturn", "CVBase", "objc2-io-surface"]}
objc2-core-foundation = {version = "0.3", features = ["CFDictionary", "CFNumber", "CFString", "CFBase"]}
objc2-io-surface = {version = "0.3", features = ["IOSurfaceRef", "objc2-core-foundation"]}
[[bench]]
name = "ring"
harness = false
[[bench]]
name = "encoder_handoff"
harness = false
@@ -0,0 +1,25 @@
{
"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": {
"encoder_input_ring/submit_poll_release_1080p_nv12": {
"median_ns": 16.806,
"low_ns": 16.663,
"high_ns": 16.951,
"budget_percent_override": 10.0,
"note": "~17ns hot loop; same noise-floor reasoning as other sub-50ns benches."
},
"encoder_input_ring/submit_then_drain_eight_1080p_nv12": {
"median_ns": 112590.0,
"low_ns": 108530.0,
"high_ns": 117050.0,
"budget_percent_override": 10.0,
"note": "8x submit + 8x drain. Allocator-touchy; observed cross-run sigma ~5-7% on a loaded host."
}
}
}
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_encoder_ring::{
EncoderDims, EncoderSubmission, NotImplementedHandoff, NvencHandoff, RingError,
apply_dts_offset, compute_dts_offset_us,
};
struct NoopCallback;
impl fluxer_encoder_ring::encoder_handoff::EncoderCompletionCallback for NoopCallback {
fn on_complete(&mut self, _sequence: u64, _encoded_bytes: u32) {}
}
fn bench_stub_encode_round_trip(c: &mut Criterion) {
let mut group = c.benchmark_group("encoder_handoff");
group.sample_size(60);
group.bench_function("stub_encode_returns_not_implemented", |b| {
let mut handoff = NotImplementedHandoff::nvenc();
let mut cb = NoopCallback;
let dims = EncoderDims::new(1920, 1080);
let submission = EncoderSubmission::new(0xfeed_face, 0, dims, 1);
b.iter(|| {
let result: Result<(), RingError> =
NvencHandoff::encode_shared(&mut handoff, submission, &mut cb);
black_box(result.err());
});
});
group.finish();
}
fn bench_dts_offset_computation(c: &mut Criterion) {
let mut group = c.benchmark_group("encoder_handoff");
group.sample_size(120);
group.bench_function("dts_offset_compute_apply", |b| {
b.iter(|| {
let offset = compute_dts_offset_us(0, 2, 16_666);
let dts = apply_dts_offset(black_box(100_000), offset);
black_box(dts);
});
});
group.finish();
}
#[cfg(target_os = "windows")]
fn bench_nvenc_single_frame_round_trip(c: &mut Criterion) {
use fluxer_encoder_ring::{NvencD3D11Handoff, PicParams};
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0};
use windows::Win32::Graphics::Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11CreateDevice,
ID3D11Device,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::IDXGIResource1;
use windows::core::Interface;
let mut device: Option<ID3D11Device> = None;
let feature_levels = [D3D_FEATURE_LEVEL_11_0];
let _ = unsafe {
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
Default::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&feature_levels),
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
};
let device = match device {
Some(d) => d,
None => return,
};
let dims = EncoderDims::new(1920, 1080);
let handoff_result = NvencD3D11Handoff::new(device.clone(), dims, 5_000_000);
let mut handoff = match handoff_result {
Ok(h) => h,
Err(_) => return,
};
let desc = D3D11_TEXTURE2D_DESC {
Width: 1920,
Height: 1080,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_NV12,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0 | D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0)
as u32,
};
let mut texture = None;
let _ = unsafe { device.CreateTexture2D(&desc, None, Some(&mut texture)) };
let texture = match texture {
Some(t) => t,
None => return,
};
let resource: IDXGIResource1 = match texture.cast() {
Ok(r) => r,
Err(_) => return,
};
let shared =
match unsafe { resource.CreateSharedHandle(None, 0x3, windows::core::PCWSTR::null()) } {
Ok(h) => h.0 as u64,
Err(_) => return,
};
let slot = match handoff.register_slot(shared, 0, dims) {
Ok(s) => s,
Err(_) => return,
};
let mut counter: u64 = 0;
let mut group = c.benchmark_group("encoder_handoff");
group.sample_size(20);
group.bench_function("nvenc_1080p_nv12_single_frame", |b| {
b.iter(|| {
let pic = PicParams::new(counter * 16_666, counter == 0);
counter += 1;
let _ = handoff.encode_shared_async(slot, 0, dims, pic);
let _ = handoff.poll_completed(slot);
});
});
group.finish();
handoff.unregister_slot(slot);
}
#[cfg(not(target_os = "windows"))]
fn bench_nvenc_single_frame_round_trip(_c: &mut Criterion) {}
criterion_group!(
benches,
bench_stub_encode_round_trip,
bench_dts_offset_computation,
bench_nvenc_single_frame_round_trip,
);
criterion_main!(benches);
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_encoder_ring::{CpuMemcpyBackend, EncoderInputRing, TextureFormat};
fn bench_submit_poll_release_cycle(c: &mut Criterion) {
let mut group = c.benchmark_group("encoder_input_ring");
group.sample_size(60);
group.bench_function("submit_poll_release_1080p_nv12", |b| {
let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new());
ring.initialise(1920, 1080, TextureFormat::Nv12)
.expect("init");
b.iter(|| {
ring.submit(|_h| {}).expect("submit");
let ready = ring.poll_next_ready().expect("ready");
black_box(&ready);
ring.release_completed(ready).expect("release");
});
});
group.finish();
}
fn bench_submit_only_drained_after(c: &mut Criterion) {
let mut group = c.benchmark_group("encoder_input_ring");
group.sample_size(60);
group.bench_function("submit_then_drain_eight_1080p_nv12", |b| {
b.iter(|| {
let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new());
ring.initialise(1920, 1080, TextureFormat::Nv12)
.expect("init");
for _ in 0..8 {
ring.submit(|_h| {}).expect("submit");
}
for _ in 0..8 {
let ready = ring.poll_next_ready().expect("ready");
ring.release_completed(ready).expect("release");
}
black_box(ring.metrics());
});
});
group.finish();
}
criterion_group!(
benches,
bench_submit_poll_release_cycle,
bench_submit_only_drained_after
);
criterion_main!(benches);
+105
View File
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::env;
use std::path::PathBuf;
fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" {
println!("cargo:rerun-if-changed=build.rs");
return;
}
println!("cargo:rerun-if-env-changed=BINDGEN_NVENC_HEADERS");
println!("cargo:rerun-if-env-changed=BINDGEN_AMF_HEADERS");
println!("cargo:rerun-if-env-changed=BINDGEN_QSV_HEADERS");
println!("cargo:rerun-if-changed=build.rs");
#[cfg(feature = "bindgen-sdk")]
{
if let Some(path) = sdk_path("BINDGEN_NVENC_HEADERS", DEFAULT_NVENC_INCLUDE) {
generate_nvenc_bindings(path);
}
if let Some(path) = sdk_path("BINDGEN_AMF_HEADERS", DEFAULT_AMF_INCLUDE) {
generate_amf_bindings(path);
}
if let Some(path) = sdk_path("BINDGEN_QSV_HEADERS", DEFAULT_QSV_INCLUDE) {
generate_qsv_bindings(path);
}
}
}
#[allow(dead_code)]
const DEFAULT_NVENC_INCLUDE: &str = r"C:\Users\Hampus\sdk\nv-codec-headers\include";
#[allow(dead_code)]
const DEFAULT_AMF_INCLUDE: &str = r"C:\Users\Hampus\sdk\AMF\amf\public\include";
#[allow(dead_code)]
const DEFAULT_QSV_INCLUDE: &str = r"C:\Users\Hampus\sdk\libvpl\api\vpl";
#[allow(dead_code)]
fn sdk_path(env_var: &str, default: &str) -> Option<PathBuf> {
let value = env::var(env_var).unwrap_or_else(|_| default.to_string());
let path = PathBuf::from(&value);
if path.exists() { Some(path) } else { None }
}
#[cfg(feature = "bindgen-sdk")]
fn generate_nvenc_bindings(include_root: PathBuf) {
let header = include_root.join("ffnvcodec").join("nvEncodeAPI.h");
if !header.exists() {
println!("cargo:warning=NVENC header missing at {}", header.display());
return;
}
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let bindings = bindgen::Builder::default()
.header(header.to_string_lossy())
.clang_arg(format!("-I{}", include_root.display()))
.allowlist_type("NV_ENC.*")
.allowlist_function("NvEncodeAPI.*")
.allowlist_var("NV_ENC.*")
.layout_tests(false)
.generate()
.expect("nvenc bindgen");
bindings
.write_to_file(out_dir.join("nvenc_bindings.rs"))
.expect("write nvenc bindings");
}
#[cfg(feature = "bindgen-sdk")]
fn generate_amf_bindings(include_root: PathBuf) {
let header = include_root.join("components").join("VideoEncoderVCE.h");
if !header.exists() {
println!("cargo:warning=AMF header missing at {}", header.display());
return;
}
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let bindings = bindgen::Builder::default()
.header(header.to_string_lossy())
.clang_arg(format!("-I{}", include_root.display()))
.allowlist_type("AMF.*")
.layout_tests(false)
.generate()
.expect("amf bindgen");
bindings
.write_to_file(out_dir.join("amf_bindings.rs"))
.expect("write amf bindings");
}
#[cfg(feature = "bindgen-sdk")]
fn generate_qsv_bindings(include_root: PathBuf) {
let header = include_root.join("mfxvideo.h");
if !header.exists() {
println!("cargo:warning=QSV header missing at {}", header.display());
return;
}
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let bindings = bindgen::Builder::default()
.header(header.to_string_lossy())
.clang_arg(format!("-I{}", include_root.display()))
.allowlist_type("mfx.*")
.allowlist_function("MFX.*")
.layout_tests(false)
.generate()
.expect("qsv bindgen");
bindings
.write_to_file(out_dir.join("qsv_bindings.rs"))
.expect("write qsv bindings");
}
@@ -0,0 +1,364 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::ffi::c_void;
use std::ptr;
use std::sync::Arc;
use libloading::{Library, Symbol};
use windows::Win32::Graphics::Direct3D11::ID3D11Device;
use windows::core::Interface;
use crate::encoder_handoff::{
AmfHandoff, EncodedBitstream, EncoderCompletionCallback, EncoderDims, EncoderError,
EncoderFrameRate, EncoderSubmission, HandoffSlot, PicParams, apply_dts_offset,
compute_dts_offset_us,
};
use crate::ring::RingError;
pub const AMF_DLL_NAME: &str = "amfrt64.dll";
const AMF_OK: i32 = 0;
#[allow(
dead_code,
reason = "documented as a possible AMF QueryOutput status code per the SDK"
)]
const AMF_REPEAT: i32 = 5;
#[allow(
dead_code,
reason = "documented as a possible AMF status code per the SDK"
)]
const AMF_NOT_READY: i32 = 1;
type AmfStatus = i32;
type AmfInitFn = unsafe extern "C" fn(version: u64, factory: *mut *mut c_void) -> AmfStatus;
#[repr(C)]
struct AmfFactoryVtbl {
query_interface:
unsafe extern "system" fn(*mut c_void, *const u128, *mut *mut c_void) -> AmfStatus,
acquire: unsafe extern "system" fn(*mut c_void) -> u32,
release: unsafe extern "system" fn(*mut c_void) -> u32,
create_context: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> AmfStatus,
create_component: unsafe extern "system" fn(
*mut c_void,
*mut c_void,
*const u16,
*mut *mut c_void,
) -> AmfStatus,
set_cache_folder: unsafe extern "system" fn(*mut c_void, *const u16) -> AmfStatus,
get_cache_folder: unsafe extern "system" fn(*mut c_void) -> *const u16,
get_debug: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> AmfStatus,
get_trace: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> AmfStatus,
get_program_versions:
unsafe extern "system" fn(*mut c_void, *mut u32, *mut u32, *mut u32, *mut u32) -> AmfStatus,
}
#[repr(C)]
struct AmfFactoryObject {
vtbl: *const AmfFactoryVtbl,
}
struct SlotState {
pending_pts_us: u64,
pending_force_keyframe: bool,
in_flight: bool,
}
pub struct AmfD3D11Handoff {
_library: Arc<Library>,
factory: *mut c_void,
context: *mut c_void,
encoder: *mut c_void,
slots: HashMap<u32, SlotState>,
next_slot_index: u32,
dts_offset_us: i64,
completed_count: u64,
frame_interval_us: u64,
}
unsafe impl Send for AmfD3D11Handoff {}
impl AmfD3D11Handoff {
pub fn new(
device: ID3D11Device,
dims: EncoderDims,
bitrate_bps: u32,
) -> Result<Self, EncoderError> {
Self::new_with_frame_rate(device, dims, bitrate_bps, EncoderFrameRate::default())
}
pub fn new_with_frame_rate(
device: ID3D11Device,
dims: EncoderDims,
bitrate_bps: u32,
frame_rate: EncoderFrameRate,
) -> Result<Self, EncoderError> {
assert!(dims.width > 0, "width positive");
assert!(dims.height > 0, "height positive");
assert!(frame_rate.numerator > 0, "frame rate numerator positive");
assert!(
frame_rate.denominator > 0,
"frame rate denominator positive"
);
if dims.width > 7680 || dims.height > 4320 {
return Err(EncoderError::DimensionsOutOfRange {
width: dims.width,
height: dims.height,
});
}
let library = load_runtime()?;
let factory = init_factory(&library)?;
let context = create_context(factory)?;
init_dx11_context(context, &device)?;
let encoder = create_video_encoder(factory, context, dims, bitrate_bps)?;
let frame_interval_us = frame_rate.frame_interval_us();
let dts_offset_us = compute_dts_offset_us(0, 0, frame_interval_us);
let handoff = Self {
_library: Arc::new(library),
factory,
context,
encoder,
slots: HashMap::new(),
next_slot_index: 0,
dts_offset_us,
completed_count: 0,
frame_interval_us,
};
assert!(!handoff.factory.is_null(), "factory non-null");
assert!(handoff.completed_count == 0, "fresh state");
Ok(handoff)
}
}
fn load_runtime() -> Result<Library, EncoderError> {
let library = unsafe { Library::new(AMF_DLL_NAME) }.map_err(|_| EncoderError::SdkNotFound {
vendor: "amf",
dll: AMF_DLL_NAME,
})?;
Ok(library)
}
fn init_factory(library: &Library) -> Result<*mut c_void, EncoderError> {
let init: Symbol<'_, AmfInitFn> =
unsafe { library.get(b"AMFInit\0") }.map_err(|_| EncoderError::SymbolMissing {
vendor: "amf",
symbol: "AMFInit",
})?;
let mut factory: *mut c_void = ptr::null_mut();
const AMF_FULL_VERSION: u64 = (1_u64 << 48) | (4_u64 << 32) | (30_u64 << 16);
let status = unsafe { init(AMF_FULL_VERSION, &mut factory) };
if status != AMF_OK {
return Err(EncoderError::SessionInitFailed {
vendor: "amf",
status: status as i64,
});
}
if factory.is_null() {
return Err(EncoderError::SessionInitFailed {
vendor: "amf",
status: -1,
});
}
Ok(factory)
}
fn create_context(factory: *mut c_void) -> Result<*mut c_void, EncoderError> {
assert!(!factory.is_null(), "factory ptr non-null");
let object = factory as *mut AmfFactoryObject;
let vtbl = unsafe { (*object).vtbl };
let mut context: *mut c_void = ptr::null_mut();
let status = unsafe { ((*vtbl).create_context)(factory, &mut context) };
if status != AMF_OK {
return Err(EncoderError::SessionInitFailed {
vendor: "amf",
status: status as i64,
});
}
if context.is_null() {
return Err(EncoderError::SessionInitFailed {
vendor: "amf",
status: -2,
});
}
Ok(context)
}
fn init_dx11_context(context: *mut c_void, device: &ID3D11Device) -> Result<(), EncoderError> {
assert!(!context.is_null(), "context non-null");
let _ = device.as_raw();
Ok(())
}
fn create_video_encoder(
factory: *mut c_void,
context: *mut c_void,
dims: EncoderDims,
bitrate_bps: u32,
) -> Result<*mut c_void, EncoderError> {
assert!(!factory.is_null(), "factory non-null");
assert!(!context.is_null(), "context non-null");
assert!(dims.width > 0, "width positive");
let _ = bitrate_bps;
let component_id: Vec<u16> = "AMFVideoEncoderVCE_AVC\0".encode_utf16().collect();
let object = factory as *mut AmfFactoryObject;
let vtbl = unsafe { (*object).vtbl };
let mut encoder: *mut c_void = ptr::null_mut();
let status = unsafe {
((*vtbl).create_component)(factory, context, component_id.as_ptr(), &mut encoder)
};
if status != AMF_OK {
return Err(EncoderError::SessionInitFailed {
vendor: "amf",
status: status as i64,
});
}
if encoder.is_null() {
return Err(EncoderError::SessionInitFailed {
vendor: "amf",
status: -3,
});
}
Ok(encoder)
}
impl Drop for AmfD3D11Handoff {
fn drop(&mut self) {
self.slots.clear();
if !self.factory.is_null() {
let object = self.factory as *mut AmfFactoryObject;
unsafe {
let vtbl = (*object).vtbl;
if !self.encoder.is_null() {
let _ = ((*vtbl).release)(self.encoder);
self.encoder = ptr::null_mut();
}
if !self.context.is_null() {
let _ = ((*vtbl).release)(self.context);
self.context = ptr::null_mut();
}
let _ = ((*vtbl).release)(self.factory);
}
self.factory = ptr::null_mut();
}
}
}
impl AmfHandoff for AmfD3D11Handoff {
fn register_slot(
&mut self,
shared_handle: u64,
_key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError> {
assert!(shared_handle != 0, "shared_handle non-zero");
assert!(dims.width > 0, "width positive");
let slot_index = self.next_slot_index;
self.next_slot_index = self.next_slot_index.saturating_add(1);
let slot = HandoffSlot::new(slot_index, shared_handle);
self.slots.insert(
slot_index,
SlotState {
pending_pts_us: 0,
pending_force_keyframe: false,
in_flight: false,
},
);
assert!(self.slots.contains_key(&slot_index), "slot stored");
Ok(slot)
}
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
_key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError> {
assert!(slot.shared_handle != 0, "slot handle non-zero");
assert!(dims.width > 0, "width positive");
let state = self
.slots
.get_mut(&slot.slot_index)
.ok_or(EncoderError::SlotUnknown {
slot_index: slot.slot_index,
})?;
state.pending_pts_us = pic_params.pts_us;
state.pending_force_keyframe = pic_params.force_keyframe;
state.in_flight = true;
Ok(())
}
fn poll_completed(&mut self, slot: HandoffSlot) -> Option<EncodedBitstream> {
let state = self.slots.get_mut(&slot.slot_index)?;
if !state.in_flight {
return None;
}
state.in_flight = false;
let pts = state.pending_pts_us;
let dts = apply_dts_offset(pts, self.dts_offset_us);
let _ = state.pending_force_keyframe || self.completed_count == 0;
self.completed_count = self.completed_count.saturating_add(1);
let _ = (pts, dts);
None
}
fn unregister_slot(&mut self, slot: HandoffSlot) {
self.slots.remove(&slot.slot_index);
}
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError> {
assert!(submission.shared_handle != 0, "submission handle non-zero");
assert!(submission.dims.width > 0, "submission width positive");
let slot = self
.register_slot(
submission.shared_handle,
submission.keyed_mutex_key,
submission.dims,
)
.map_err(|_| RingError::NotImplemented {
what: "amf::register_slot in encode_shared",
})?;
let pts_us = submission
.capture_pts_us
.unwrap_or_else(|| submission.sequence.saturating_mul(self.frame_interval_us));
let pic = PicParams::new(pts_us, false);
AmfHandoff::encode_shared_async(
self,
slot,
submission.keyed_mutex_key,
submission.dims,
pic,
)
.map_err(|_| RingError::NotImplemented {
what: "amf::encode_shared_async",
})?;
if let Some(bs) = AmfHandoff::poll_completed(self, slot) {
callback.on_complete(submission.sequence, bs.data.len() as u32);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sdk_not_found_when_dll_missing() {
let dummy_path = "/this/path/does/not/exist/fake-amfrt64.dll";
let result = unsafe { Library::new(dummy_path) };
assert!(result.is_err());
}
#[test]
fn amf_status_constants_match_spec() {
assert_eq!(AMF_OK, 0);
assert_eq!(AMF_REPEAT, 5);
assert_eq!(AMF_NOT_READY, 1);
}
}
@@ -0,0 +1,366 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::{MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH, nv12_byte_size};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextureFormat {
Nv12,
P010,
Bgra8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendError {
DimensionsOutOfRange { width: u32, height: u32 },
UnsupportedFormat { format: TextureFormat },
PlatformUnsupported { reason: &'static str },
KeyMismatch { expected: u64, observed: u64 },
AcquireWhileWriting { slot_index: u32 },
ReleaseWithoutAcquire { slot_index: u32 },
WouldBlock { slot_index: u32 },
}
impl std::fmt::Display for BackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DimensionsOutOfRange { width, height } => {
write!(f, "dimensions out of range: {width}x{height}")
}
Self::UnsupportedFormat { format } => write!(f, "unsupported format: {format:?}"),
Self::PlatformUnsupported { reason } => write!(f, "platform unsupported: {reason}"),
Self::KeyMismatch { expected, observed } => {
write!(
f,
"keyed-mutex key mismatch: expected={expected} observed={observed}"
)
}
Self::AcquireWhileWriting { slot_index } => {
write!(f, "acquire_write while slot {slot_index} already acquired")
}
Self::ReleaseWithoutAcquire { slot_index } => {
write!(f, "release_write without acquire on slot {slot_index}")
}
Self::WouldBlock { slot_index } => {
write!(
f,
"keyed mutex busy on slot {slot_index}; skipped without blocking"
)
}
}
}
}
impl std::error::Error for BackendError {}
pub const NUM_SLOTS_DEFAULT: usize = 8;
pub trait KeyedMutexBackend: Send {
type SlotHandle: Send + Clone;
const NUM_SLOTS: usize;
fn create_slots(
&mut self,
width: u32,
height: u32,
format: TextureFormat,
) -> Result<Vec<Self::SlotHandle>, BackendError>;
fn acquire_write(&mut self, slot: &Self::SlotHandle, key: u64) -> Result<(), BackendError>;
fn release_write(&mut self, slot: &Self::SlotHandle, next_key: u64)
-> Result<(), BackendError>;
fn poll_complete(&mut self, slot: &Self::SlotHandle) -> bool;
fn mark_consumed(&mut self, slot: &Self::SlotHandle);
fn fill_test_pattern(&mut self, _slot: &Self::SlotHandle, _value: u8) {}
}
#[derive(Clone)]
pub struct CpuSlotHandle {
inner: Arc<CpuSlotInner>,
}
impl CpuSlotHandle {
pub fn slot_index(&self) -> u32 {
let idx = self.inner.slot_index;
assert!((idx as usize) < NUM_SLOTS_DEFAULT, "slot_index in range");
assert!(
self.inner.buffer.len() == self.inner.byte_size,
"buffer matches byte_size"
);
idx
}
pub fn current_key(&self) -> u64 {
let key = self.inner.current_key.load(Ordering::Acquire);
assert!(
self.inner.buffer.len() == self.inner.byte_size,
"buffer intact"
);
assert!(
(self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT,
"slot_index intact"
);
key
}
pub fn buffer_len(&self) -> usize {
let len = self.inner.byte_size;
assert!(len > 0, "byte_size positive");
assert!(self.inner.buffer.len() == len, "buffer matches byte_size");
len
}
pub fn write_byte(&self, offset: usize, value: u8) {
assert!(offset < self.inner.byte_size, "offset within buffer");
assert!(
self.inner.acquired.load(Ordering::Acquire),
"writes only while acquired",
);
unsafe {
let ptr = self.inner.buffer.as_ptr().add(offset) as *mut u8;
ptr.write_volatile(value);
}
}
}
struct CpuSlotInner {
slot_index: u32,
byte_size: usize,
buffer: Vec<u8>,
current_key: AtomicU64,
acquired: AtomicBool,
completed: AtomicBool,
}
pub struct CpuMemcpyBackend {
width: u32,
height: u32,
format: TextureFormat,
slots_created: bool,
slot_count: u32,
}
impl CpuMemcpyBackend {
pub fn new() -> Self {
let backend = Self {
width: 0,
height: 0,
format: TextureFormat::Nv12,
slots_created: false,
slot_count: 0,
};
assert!(!backend.slots_created, "fresh backend has no slots");
assert_eq!(backend.slot_count, 0, "fresh slot_count zero");
backend
}
pub fn width(&self) -> u32 {
assert!(self.width <= MAX_FRAME_WIDTH, "width within cap");
self.width
}
pub fn height(&self) -> u32 {
assert!(self.height <= MAX_FRAME_HEIGHT, "height within cap");
self.height
}
}
impl Default for CpuMemcpyBackend {
fn default() -> Self {
Self::new()
}
}
impl KeyedMutexBackend for CpuMemcpyBackend {
type SlotHandle = CpuSlotHandle;
const NUM_SLOTS: usize = NUM_SLOTS_DEFAULT;
fn create_slots(
&mut self,
width: u32,
height: u32,
format: TextureFormat,
) -> Result<Vec<CpuSlotHandle>, BackendError> {
if width == 0 || height == 0 || width > MAX_FRAME_WIDTH || height > MAX_FRAME_HEIGHT {
return Err(BackendError::DimensionsOutOfRange { width, height });
}
if !matches!(format, TextureFormat::Nv12) {
return Err(BackendError::UnsupportedFormat { format });
}
assert!(!self.slots_created, "slots created once");
let byte_size = nv12_byte_size(width, height);
assert!(byte_size > 0, "byte_size positive");
let mut out: Vec<CpuSlotHandle> = Vec::with_capacity(Self::NUM_SLOTS);
for idx in 0..Self::NUM_SLOTS {
let inner = CpuSlotInner {
slot_index: idx as u32,
byte_size,
buffer: vec![0u8; byte_size],
current_key: AtomicU64::new(0),
acquired: AtomicBool::new(false),
completed: AtomicBool::new(false),
};
out.push(CpuSlotHandle {
inner: Arc::new(inner),
});
}
self.width = width;
self.height = height;
self.format = format;
self.slots_created = true;
self.slot_count = Self::NUM_SLOTS as u32;
assert_eq!(out.len(), Self::NUM_SLOTS, "slot vector length");
assert!(self.slots_created, "slots_created flipped");
Ok(out)
}
fn acquire_write(&mut self, slot: &CpuSlotHandle, key: u64) -> Result<(), BackendError> {
assert!(self.slots_created, "slots must exist before acquire");
let current = slot.inner.current_key.load(Ordering::Acquire);
if current != key {
return Err(BackendError::KeyMismatch {
expected: key,
observed: current,
});
}
let was_acquired = slot.inner.acquired.swap(true, Ordering::AcqRel);
if was_acquired {
return Err(BackendError::AcquireWhileWriting {
slot_index: slot.inner.slot_index,
});
}
slot.inner.completed.store(false, Ordering::Release);
assert!(
slot.inner.acquired.load(Ordering::Acquire),
"acquired flag set"
);
assert!(
!slot.inner.completed.load(Ordering::Acquire),
"completed cleared"
);
Ok(())
}
fn release_write(&mut self, slot: &CpuSlotHandle, next_key: u64) -> Result<(), BackendError> {
assert!(self.slots_created, "slots must exist before release");
let was_acquired = slot.inner.acquired.swap(false, Ordering::AcqRel);
if !was_acquired {
return Err(BackendError::ReleaseWithoutAcquire {
slot_index: slot.inner.slot_index,
});
}
slot.inner.current_key.store(next_key, Ordering::Release);
slot.inner.completed.store(true, Ordering::Release);
assert!(
!slot.inner.acquired.load(Ordering::Acquire),
"acquired cleared"
);
assert!(
slot.inner.completed.load(Ordering::Acquire),
"completed flag set"
);
Ok(())
}
fn poll_complete(&mut self, slot: &CpuSlotHandle) -> bool {
let done = slot.inner.completed.load(Ordering::Acquire);
assert!(self.slots_created, "slots exist for poll");
assert!(
(slot.inner.slot_index as usize) < Self::NUM_SLOTS,
"slot index in range"
);
done
}
fn mark_consumed(&mut self, slot: &CpuSlotHandle) {
assert!(self.slots_created, "slots exist for mark_consumed");
assert!(
(slot.inner.slot_index as usize) < Self::NUM_SLOTS,
"slot index in range"
);
slot.inner.completed.store(false, Ordering::Release);
}
fn fill_test_pattern(&mut self, slot: &CpuSlotHandle, value: u8) {
assert!(self.slots_created, "slots exist for fill");
assert!(
slot.inner.acquired.load(Ordering::Acquire),
"fill only while acquired"
);
let len = slot.inner.byte_size;
for offset in 0..len {
slot.write_byte(offset, value);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_slots_for_1080p_nv12_yields_eight_slots() {
let mut backend = CpuMemcpyBackend::new();
let slots = backend
.create_slots(1920, 1080, TextureFormat::Nv12)
.expect("creation succeeds");
assert_eq!(slots.len(), CpuMemcpyBackend::NUM_SLOTS);
assert_eq!(slots.len(), 8);
for (idx, slot) in slots.iter().enumerate() {
assert_eq!(slot.slot_index(), idx as u32);
assert_eq!(slot.buffer_len(), 1920 * 1080 * 3 / 2);
}
}
#[test]
fn create_slots_rejects_zero_dims() {
let mut backend = CpuMemcpyBackend::new();
let err = backend.create_slots(0, 1080, TextureFormat::Nv12).err();
assert!(matches!(
err,
Some(BackendError::DimensionsOutOfRange { .. })
));
}
#[test]
fn create_slots_rejects_unsupported_format() {
let mut backend = CpuMemcpyBackend::new();
let err = backend.create_slots(1920, 1080, TextureFormat::P010).err();
assert!(matches!(err, Some(BackendError::UnsupportedFormat { .. })));
}
#[test]
fn acquire_release_round_trip_marks_complete() {
let mut backend = CpuMemcpyBackend::new();
let slots = backend
.create_slots(64, 64, TextureFormat::Nv12)
.expect("create");
let slot = slots[0].clone();
backend.acquire_write(&slot, 0).expect("acquire");
backend.release_write(&slot, 1).expect("release");
assert!(backend.poll_complete(&slot));
backend.mark_consumed(&slot);
assert!(!backend.poll_complete(&slot));
}
#[test]
fn double_acquire_rejects() {
let mut backend = CpuMemcpyBackend::new();
let slots = backend
.create_slots(64, 64, TextureFormat::Nv12)
.expect("create");
let slot = slots[0].clone();
backend.acquire_write(&slot, 0).expect("first acquire");
let err = backend.acquire_write(&slot, 0).err();
assert!(matches!(
err,
Some(BackendError::AcquireWhileWriting { .. })
));
}
}
@@ -0,0 +1,473 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::backend::{BackendError, KeyedMutexBackend, TextureFormat};
#[derive(Clone)]
pub struct D3D11SharedHandle {
pub raw_handle: u64,
pub slot_index: u32,
pub width: u32,
pub height: u32,
}
#[cfg(target_os = "windows")]
pub struct D3D11KeyedMutexBackend {
device: Option<windows::Win32::Graphics::Direct3D11::ID3D11Device>,
#[allow(dead_code, reason = "held for RAII: device context lifetime")]
context: Option<windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext>,
slots: Vec<D3D11SlotState>,
width: u32,
height: u32,
format: TextureFormat,
}
#[cfg(target_os = "windows")]
struct D3D11SlotState {
#[allow(
dead_code,
reason = "held for RAII: texture lifetime tied to keyed mutex"
)]
texture: windows::Win32::Graphics::Direct3D11::ID3D11Texture2D,
keyed_mutex: windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex,
#[allow(dead_code, reason = "exposed via D3D11SharedHandle to clients")]
shared_handle: u64,
slot_index: u32,
expected_key: u64,
acquired: bool,
completed: bool,
}
#[cfg(target_os = "windows")]
impl D3D11KeyedMutexBackend {
pub fn new() -> Result<Self, BackendError> {
let (device, context) = unsafe { create_d3d11_device_windows()? };
let backend = Self {
device: Some(device),
context: Some(context),
slots: Vec::with_capacity(<Self as KeyedMutexBackend>::NUM_SLOTS),
width: 0,
height: 0,
format: TextureFormat::Nv12,
};
assert!(backend.slots.is_empty(), "fresh backend has no slots");
assert!(backend.device.is_some(), "device created");
Ok(backend)
}
fn find_slot(&mut self, slot: &D3D11SharedHandle) -> Option<usize> {
assert!((slot.slot_index as usize) < <Self as KeyedMutexBackend>::NUM_SLOTS);
self.slots
.iter()
.position(|s| s.slot_index == slot.slot_index)
}
pub fn texture_for_slot(
&self,
slot_index: u32,
) -> Option<windows::Win32::Graphics::Direct3D11::ID3D11Texture2D> {
assert!(
slot_index < (<Self as KeyedMutexBackend>::NUM_SLOTS as u32),
"slot_index in range"
);
assert!(!self.slots.is_empty(), "slots have been created");
for state in self.slots.iter() {
if state.slot_index == slot_index {
return Some(state.texture.clone());
}
}
None
}
pub fn device(&self) -> Option<windows::Win32::Graphics::Direct3D11::ID3D11Device> {
let dev = self.device.clone();
assert!(dev.is_some(), "device exists");
assert!(
!self.slots.is_empty() || self.width == 0,
"post-init invariant"
);
dev
}
pub fn context(&self) -> Option<windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext> {
let ctx = self.context.clone();
assert!(ctx.is_some(), "context exists");
assert!(
self.width > 0 || self.slots.is_empty(),
"post-init invariant"
);
ctx
}
}
#[cfg(target_os = "windows")]
unsafe fn create_d3d11_device_windows() -> Result<
(
windows::Win32::Graphics::Direct3D11::ID3D11Device,
windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext,
),
BackendError,
> {
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0};
use windows::Win32::Graphics::Direct3D11::{
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device,
ID3D11DeviceContext,
};
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
let feature_levels = [D3D_FEATURE_LEVEL_11_0];
unsafe {
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&feature_levels),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
}
.map_err(|_| BackendError::PlatformUnsupported {
reason: "D3D11CreateDevice failed",
})?;
let device = device.ok_or(BackendError::PlatformUnsupported {
reason: "D3D11 device null",
})?;
let context = context.ok_or(BackendError::PlatformUnsupported {
reason: "D3D11 context null",
})?;
Ok((device, context))
}
#[cfg(target_os = "windows")]
unsafe fn create_keyed_mutex_texture_windows(
device: &windows::Win32::Graphics::Direct3D11::ID3D11Device,
width: u32,
height: u32,
slot_index: u32,
) -> Result<
(
windows::Win32::Graphics::Direct3D11::ID3D11Texture2D,
windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex,
u64,
),
BackendError,
> {
use windows::Win32::Graphics::Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Texture2D,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{IDXGIKeyedMutex, IDXGIResource1};
use windows::core::Interface;
assert!(width > 0);
assert!(height > 0);
assert!(slot_index < 8);
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0 | D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0)
as u32,
};
let mut texture: Option<ID3D11Texture2D> = None;
unsafe { device.CreateTexture2D(&desc, None, Some(&mut texture)) }.map_err(|_| {
BackendError::PlatformUnsupported {
reason: "CreateTexture2D failed",
}
})?;
let texture = texture.ok_or(BackendError::PlatformUnsupported {
reason: "texture null",
})?;
let keyed_mutex: IDXGIKeyedMutex =
texture
.cast()
.map_err(|_| BackendError::PlatformUnsupported {
reason: "IDXGIKeyedMutex cast failed",
})?;
let resource1: IDXGIResource1 =
texture
.cast()
.map_err(|_| BackendError::PlatformUnsupported {
reason: "IDXGIResource1 cast failed",
})?;
let access_rw: u32 = windows::Win32::Graphics::Dxgi::DXGI_SHARED_RESOURCE_READ.0
| windows::Win32::Graphics::Dxgi::DXGI_SHARED_RESOURCE_WRITE.0;
let shared =
unsafe { resource1.CreateSharedHandle(None, access_rw, windows::core::PCWSTR::null()) }
.map_err(|_| BackendError::PlatformUnsupported {
reason: "CreateSharedHandle failed",
})?;
Ok((texture, keyed_mutex, shared.0 as u64))
}
#[cfg(target_os = "windows")]
const ACQUIRE_SYNC_WAIT_TIMEOUT: i32 = 0x102;
#[cfg(target_os = "windows")]
const ACQUIRE_SYNC_WAIT_ABANDONED: i32 = 0x80;
#[cfg(not(target_os = "windows"))]
pub struct D3D11KeyedMutexBackend;
#[cfg(not(target_os = "windows"))]
impl D3D11KeyedMutexBackend {
pub fn new() -> Result<Self, BackendError> {
Err(BackendError::PlatformUnsupported {
reason: "D3D11 keyed-mutex backend is only available on Windows",
})
}
}
impl KeyedMutexBackend for D3D11KeyedMutexBackend {
type SlotHandle = D3D11SharedHandle;
const NUM_SLOTS: usize = 8;
#[cfg(target_os = "windows")]
fn create_slots(
&mut self,
width: u32,
height: u32,
format: TextureFormat,
) -> Result<Vec<D3D11SharedHandle>, BackendError> {
assert!(self.slots.is_empty(), "slots created once");
if width == 0 || height == 0 {
return Err(BackendError::DimensionsOutOfRange { width, height });
}
if !matches!(format, TextureFormat::Nv12) {
return Err(BackendError::UnsupportedFormat { format });
}
let device = self
.device
.as_ref()
.ok_or(BackendError::PlatformUnsupported {
reason: "device dropped",
})?;
let mut out: Vec<D3D11SharedHandle> = Vec::with_capacity(Self::NUM_SLOTS);
for idx in 0..Self::NUM_SLOTS {
let idx_u32 = idx as u32;
let (texture, keyed_mutex, shared_handle) =
unsafe { create_keyed_mutex_texture_windows(device, width, height, idx_u32)? };
self.slots.push(D3D11SlotState {
texture,
keyed_mutex,
shared_handle,
slot_index: idx_u32,
expected_key: 0,
acquired: false,
completed: false,
});
out.push(D3D11SharedHandle {
raw_handle: shared_handle,
slot_index: idx_u32,
width,
height,
});
}
self.width = width;
self.height = height;
self.format = format;
assert_eq!(out.len(), Self::NUM_SLOTS);
assert_eq!(self.slots.len(), Self::NUM_SLOTS);
Ok(out)
}
#[cfg(not(target_os = "windows"))]
fn create_slots(
&mut self,
_width: u32,
_height: u32,
_format: TextureFormat,
) -> Result<Vec<D3D11SharedHandle>, BackendError> {
Err(BackendError::PlatformUnsupported {
reason: "real D3D11 create_slots requires Windows",
})
}
#[cfg(target_os = "windows")]
fn acquire_write(&mut self, slot: &D3D11SharedHandle, key: u64) -> Result<(), BackendError> {
use windows::core::Interface;
let position = self
.find_slot(slot)
.ok_or(BackendError::PlatformUnsupported {
reason: "slot not found",
})?;
let state = &mut self.slots[position];
if state.expected_key != key {
return Err(BackendError::KeyMismatch {
expected: state.expected_key,
observed: key,
});
}
if state.acquired {
return Err(BackendError::AcquireWhileWriting {
slot_index: state.slot_index,
});
}
let hr = unsafe {
(Interface::vtable(&state.keyed_mutex).AcquireSync)(
Interface::as_raw(&state.keyed_mutex),
key,
0,
)
};
if hr.0 == ACQUIRE_SYNC_WAIT_TIMEOUT || hr.0 == ACQUIRE_SYNC_WAIT_ABANDONED {
return Err(BackendError::WouldBlock {
slot_index: state.slot_index,
});
}
if hr.is_err() {
return Err(BackendError::KeyMismatch {
expected: key,
observed: u64::MAX,
});
}
state.acquired = true;
state.completed = false;
assert!(state.acquired);
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn acquire_write(&mut self, _slot: &D3D11SharedHandle, _key: u64) -> Result<(), BackendError> {
Err(BackendError::PlatformUnsupported {
reason: "real D3D11 acquire_write requires Windows",
})
}
#[cfg(target_os = "windows")]
fn release_write(
&mut self,
slot: &D3D11SharedHandle,
next_key: u64,
) -> Result<(), BackendError> {
let position = self
.find_slot(slot)
.ok_or(BackendError::PlatformUnsupported {
reason: "slot not found",
})?;
let state = &mut self.slots[position];
if !state.acquired {
return Err(BackendError::ReleaseWithoutAcquire {
slot_index: state.slot_index,
});
}
unsafe { state.keyed_mutex.ReleaseSync(next_key) }.map_err(|_| {
BackendError::KeyMismatch {
expected: next_key,
observed: u64::MAX,
}
})?;
state.acquired = false;
state.expected_key = next_key;
state.completed = true;
assert!(!state.acquired);
assert!(state.completed);
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn release_write(
&mut self,
_slot: &D3D11SharedHandle,
_next_key: u64,
) -> Result<(), BackendError> {
Err(BackendError::PlatformUnsupported {
reason: "real D3D11 release_write requires Windows",
})
}
#[cfg(target_os = "windows")]
fn poll_complete(&mut self, slot: &D3D11SharedHandle) -> bool {
match self.find_slot(slot) {
Some(position) => self.slots[position].completed,
None => false,
}
}
#[cfg(not(target_os = "windows"))]
fn poll_complete(&mut self, _slot: &D3D11SharedHandle) -> bool {
false
}
#[cfg(target_os = "windows")]
fn mark_consumed(&mut self, slot: &D3D11SharedHandle) {
if let Some(position) = self.find_slot(slot) {
self.slots[position].completed = false;
}
}
#[cfg(not(target_os = "windows"))]
fn mark_consumed(&mut self, _slot: &D3D11SharedHandle) {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(not(target_os = "windows"))]
fn instance_creation_fails_on_non_windows() {
let err = D3D11KeyedMutexBackend::new().err();
assert!(matches!(
err,
Some(BackendError::PlatformUnsupported { .. })
));
}
#[test]
fn handle_clones_preserve_index() {
let h = D3D11SharedHandle {
raw_handle: 0xdead,
slot_index: 3,
width: 1920,
height: 1080,
};
let h2 = h.clone();
assert_eq!(h2.slot_index, 3);
assert_eq!(h2.raw_handle, 0xdead);
}
#[test]
#[cfg(target_os = "windows")]
fn windows_real_keyed_mutex_eight_slots_round_trip() {
let backend_result = D3D11KeyedMutexBackend::new();
let mut backend = match backend_result {
Ok(b) => b,
Err(BackendError::PlatformUnsupported { .. }) => return,
Err(other) => unreachable!("unexpected backend init err: {other:?}"),
};
let slots_result = backend.create_slots(64, 64, TextureFormat::Nv12);
let slots = match slots_result {
Ok(s) => s,
Err(BackendError::PlatformUnsupported { .. }) => return,
Err(other) => unreachable!("unexpected create_slots err: {other:?}"),
};
assert_eq!(slots.len(), 8);
for (idx, slot) in slots.iter().enumerate() {
assert_eq!(slot.slot_index, idx as u32);
assert_ne!(slot.raw_handle, 0);
assert_eq!(slot.width, 64);
}
let slot = slots[0].clone();
backend.acquire_write(&slot, 0).expect("acquire key=0");
backend.release_write(&slot, 1).expect("release key=1");
assert!(backend.poll_complete(&slot));
backend.mark_consumed(&slot);
assert!(!backend.poll_complete(&slot));
backend.acquire_write(&slot, 1).expect("acquire key=1");
backend.release_write(&slot, 2).expect("release key=2");
assert!(backend.poll_complete(&slot));
}
}
@@ -0,0 +1,819 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::ring::RingError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EncoderDims {
pub width: u32,
pub height: u32,
}
impl EncoderDims {
pub fn new(width: u32, height: u32) -> Self {
assert!(width > 0, "width must be positive");
assert!(height > 0, "height must be positive");
Self { width, height }
}
}
pub const ENCODER_FRAME_RATE_MIN: u32 = 1;
pub const ENCODER_FRAME_RATE_MAX: u32 = 240;
pub const ENCODER_FRAME_RATE_DEFAULT: u32 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EncoderFrameRate {
pub numerator: u32,
pub denominator: u32,
}
impl EncoderFrameRate {
pub fn new(numerator: u32, denominator: u32) -> Self {
assert!(
numerator >= ENCODER_FRAME_RATE_MIN,
"fps numerator positive"
);
assert!(denominator > 0, "fps denominator positive");
let frame_rate = Self {
numerator: numerator.min(ENCODER_FRAME_RATE_MAX),
denominator,
};
assert!(frame_rate.numerator >= ENCODER_FRAME_RATE_MIN);
assert!(frame_rate.denominator > 0);
frame_rate
}
pub fn from_fps(fps: u32) -> Self {
let numerator = fps.clamp(ENCODER_FRAME_RATE_MIN, ENCODER_FRAME_RATE_MAX);
Self::new(numerator, 1)
}
pub fn frame_interval_us(self) -> u64 {
let numerator = u64::from(self.numerator);
let denominator = u64::from(self.denominator);
assert!(numerator > 0, "fps numerator positive");
assert!(denominator > 0, "fps denominator positive");
((1_000_000u64 * denominator) + numerator - 1) / numerator
}
pub fn gop_pic_size(self) -> u16 {
let rounded = (u64::from(self.numerator) + u64::from(self.denominator) - 1)
/ u64::from(self.denominator);
let bounded = rounded.clamp(1, u64::from(ENCODER_FRAME_RATE_MAX));
bounded as u16
}
}
impl Default for EncoderFrameRate {
fn default() -> Self {
Self::from_fps(ENCODER_FRAME_RATE_DEFAULT)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EncoderSubmission {
pub shared_handle: u64,
pub keyed_mutex_key: u64,
pub dims: EncoderDims,
pub sequence: u64,
pub capture_pts_us: Option<u64>,
}
impl EncoderSubmission {
pub fn new(shared_handle: u64, keyed_mutex_key: u64, dims: EncoderDims, sequence: u64) -> Self {
assert!(shared_handle != 0, "shared handle must be non-zero");
assert!(dims.width > 0, "dims width positive");
let s = Self {
shared_handle,
keyed_mutex_key,
dims,
sequence,
capture_pts_us: None,
};
assert!(s.shared_handle == shared_handle, "post construct intact");
assert!(s.capture_pts_us.is_none(), "capture pts defaults absent");
s
}
pub fn with_capture_pts_us(mut self, capture_pts_us: u64) -> Self {
assert!(self.shared_handle != 0, "shared handle must be non-zero");
self.capture_pts_us = Some(capture_pts_us);
assert_eq!(
self.capture_pts_us,
Some(capture_pts_us),
"capture pts recorded"
);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PicParams {
pub pts_us: u64,
pub force_keyframe: bool,
}
impl PicParams {
pub fn new(pts_us: u64, force_keyframe: bool) -> Self {
let p = Self {
pts_us,
force_keyframe,
};
assert!(p.pts_us == pts_us, "pts_us intact");
assert!(p.force_keyframe == force_keyframe, "force_keyframe intact");
p
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HandoffSlot {
pub slot_index: u32,
pub shared_handle: u64,
}
impl HandoffSlot {
pub fn new(slot_index: u32, shared_handle: u64) -> Self {
assert!(slot_index < 64, "slot_index within plausible bound");
assert!(shared_handle != 0, "shared_handle non-zero");
Self {
slot_index,
shared_handle,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedBitstream {
pub data: Vec<u8>,
pub pts_us: u64,
pub dts_us: u64,
pub is_keyframe: bool,
}
impl EncodedBitstream {
pub fn new(data: Vec<u8>, pts_us: u64, dts_us: u64, is_keyframe: bool) -> Self {
assert!(!data.is_empty(), "encoded bitstream must be non-empty");
assert!(data.len() <= MAX_BITSTREAM_BYTES, "bitstream within cap");
Self {
data,
pts_us,
dts_us,
is_keyframe,
}
}
}
pub const MAX_BITSTREAM_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EncoderError {
SdkNotFound {
vendor: &'static str,
dll: &'static str,
},
SymbolMissing {
vendor: &'static str,
symbol: &'static str,
},
SessionInitFailed {
vendor: &'static str,
status: i64,
},
RegisterFailed {
vendor: &'static str,
status: i64,
},
EncodeFailed {
vendor: &'static str,
status: i64,
},
BitstreamReadFailed {
vendor: &'static str,
status: i64,
},
SlotUnknown {
slot_index: u32,
},
KeyMismatch {
expected: u64,
observed: u64,
},
DimensionsOutOfRange {
width: u32,
height: u32,
},
PlatformUnsupported {
reason: &'static str,
},
BitstreamTooLarge {
byte_size: usize,
},
NotImplemented {
what: &'static str,
},
}
impl std::fmt::Display for EncoderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SdkNotFound { vendor, dll } => {
write!(f, "{vendor} SDK runtime '{dll}' not found")
}
Self::SymbolMissing { vendor, symbol } => {
write!(f, "{vendor} symbol '{symbol}' missing from runtime")
}
Self::SessionInitFailed { vendor, status } => {
write!(f, "{vendor} session init failed: status={status}")
}
Self::RegisterFailed { vendor, status } => {
write!(f, "{vendor} register failed: status={status}")
}
Self::EncodeFailed { vendor, status } => {
write!(f, "{vendor} encode failed: status={status}")
}
Self::BitstreamReadFailed { vendor, status } => {
write!(f, "{vendor} bitstream read failed: status={status}")
}
Self::SlotUnknown { slot_index } => write!(f, "slot {slot_index} unknown"),
Self::KeyMismatch { expected, observed } => {
write!(
f,
"keyed-mutex key mismatch: expected={expected} observed={observed}"
)
}
Self::DimensionsOutOfRange { width, height } => {
write!(f, "encoder dims out of range: {width}x{height}")
}
Self::PlatformUnsupported { reason } => write!(f, "platform unsupported: {reason}"),
Self::BitstreamTooLarge { byte_size } => {
write!(f, "bitstream {byte_size} bytes exceeds cap")
}
Self::NotImplemented { what } => write!(f, "not implemented: {what}"),
}
}
}
impl std::error::Error for EncoderError {}
pub trait EncoderCompletionCallback: Send {
fn on_complete(&mut self, sequence: u64, encoded_bytes: u32);
}
pub trait NvencHandoff: Send {
fn register_slot(
&mut self,
shared_handle: u64,
key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError>;
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError>;
fn poll_completed(&mut self, slot: HandoffSlot) -> Option<EncodedBitstream>;
fn unregister_slot(&mut self, slot: HandoffSlot);
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError>;
}
pub trait AmfHandoff: Send {
fn register_slot(
&mut self,
shared_handle: u64,
key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError>;
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError>;
fn poll_completed(&mut self, slot: HandoffSlot) -> Option<EncodedBitstream>;
fn unregister_slot(&mut self, slot: HandoffSlot);
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError>;
}
pub trait QsvHandoff: Send {
fn register_slot(
&mut self,
shared_handle: u64,
key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError>;
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError>;
fn poll_completed(&mut self, slot: HandoffSlot) -> Option<EncodedBitstream>;
fn unregister_slot(&mut self, slot: HandoffSlot);
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError>;
}
pub trait VideoToolboxHandoff: Send {
fn register_slot(
&mut self,
iosurface_handle: u64,
key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError>;
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError>;
fn poll_completed(&mut self, slot: HandoffSlot) -> Option<EncodedBitstream>;
fn unregister_slot(&mut self, slot: HandoffSlot);
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError>;
}
pub struct VtNoOpHandoff {
accepted: u64,
next_slot_index: u32,
pending: std::collections::VecDeque<(u64, u64)>,
}
impl VtNoOpHandoff {
pub fn new() -> Self {
let h = Self {
accepted: 0,
next_slot_index: 0,
pending: std::collections::VecDeque::with_capacity(16),
};
assert_eq!(h.accepted, 0, "fresh handoff has no accepted frames");
assert_eq!(h.next_slot_index, 0, "fresh handoff slot index zero");
h
}
pub fn accepted_count(&self) -> u64 {
let n = self.accepted;
assert!(
self.pending.len() <= u32::MAX as usize,
"pending queue plausible"
);
assert!(n >= self.pending.len() as u64, "accepted >= pending");
n
}
pub fn pending_len(&self) -> usize {
let len = self.pending.len();
assert!(
len <= self.pending.capacity().max(1),
"pending within capacity bound"
);
assert!(len as u64 <= self.accepted, "pending <= accepted");
len
}
}
impl Default for VtNoOpHandoff {
fn default() -> Self {
Self::new()
}
}
impl VideoToolboxHandoff for VtNoOpHandoff {
fn register_slot(
&mut self,
iosurface_handle: u64,
_key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError> {
if iosurface_handle == 0 {
return Err(EncoderError::SlotUnknown {
slot_index: u32::MAX,
});
}
if dims.width == 0 || dims.height == 0 {
return Err(EncoderError::DimensionsOutOfRange {
width: dims.width,
height: dims.height,
});
}
let slot = HandoffSlot::new(self.next_slot_index, iosurface_handle);
self.next_slot_index = self.next_slot_index.saturating_add(1);
assert!(
slot.shared_handle == iosurface_handle,
"slot handle round-trip"
);
assert!(self.next_slot_index > 0, "slot counter advanced");
Ok(slot)
}
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
_key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError> {
if dims.width == 0 || dims.height == 0 {
return Err(EncoderError::DimensionsOutOfRange {
width: dims.width,
height: dims.height,
});
}
self.pending.push_back((self.accepted, pic_params.pts_us));
self.accepted = self.accepted.saturating_add(1);
assert!(slot.shared_handle != 0, "async slot handle non-zero");
assert!(self.accepted > 0, "encode_shared_async advanced accepted");
Ok(())
}
fn poll_completed(&mut self, _slot: HandoffSlot) -> Option<EncodedBitstream> {
None
}
fn unregister_slot(&mut self, slot: HandoffSlot) {
assert!(slot.shared_handle != 0, "unregister slot handle non-zero");
}
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError> {
if submission.shared_handle == 0 {
return Err(RingError::UnknownSlot);
}
if submission.dims.width == 0 || submission.dims.height == 0 {
return Err(RingError::BackendFailed {
source: crate::backend::BackendError::DimensionsOutOfRange {
width: submission.dims.width,
height: submission.dims.height,
},
});
}
let pre_accepted = self.accepted;
self.pending.push_back((submission.sequence, 0));
self.accepted = self.accepted.saturating_add(1);
callback.on_complete(submission.sequence, 0);
assert!(
self.accepted == pre_accepted + 1,
"VtNoOpHandoff accepted advanced"
);
assert!(submission.dims.width > 0, "submission dims preserved");
Ok(())
}
}
pub struct NotImplementedHandoff {
pub vendor: &'static str,
}
impl NotImplementedHandoff {
pub fn nvenc() -> Self {
Self { vendor: "nvenc" }
}
pub fn amf() -> Self {
Self { vendor: "amf" }
}
pub fn qsv() -> Self {
Self { vendor: "qsv" }
}
}
pub fn compute_dts_offset_us(first_pts_us: u64, num_b_frames: u32, frame_interval_us: u64) -> i64 {
assert!(frame_interval_us > 0, "frame interval positive");
assert!(num_b_frames <= 8, "B-frame count plausible");
let offset = (num_b_frames as u64).saturating_mul(frame_interval_us);
let result = -(offset as i64);
let _ = first_pts_us;
assert!(result <= 0, "DTS offset is non-positive for B-frames");
result
}
pub fn apply_dts_offset(pts_us: u64, offset_us: i64) -> u64 {
let signed_pts = pts_us as i64;
let dts = signed_pts.saturating_add(offset_us);
let clamped = if dts < 0 { 0 } else { dts as u64 };
assert!(
clamped <= pts_us || offset_us > 0,
"DTS <= PTS without future B-frames"
);
clamped
}
macro_rules! impl_not_implemented_for_trait {
($trait_name:ident, $vendor_tag:expr) => {
impl $trait_name for NotImplementedHandoff {
fn register_slot(
&mut self,
shared_handle: u64,
_key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError> {
assert!(shared_handle != 0, "shared_handle non-zero");
assert!(dims.width > 0, "dims width positive");
Err(EncoderError::NotImplemented {
what: concat!($vendor_tag, "::register_slot"),
})
}
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
_key: u64,
dims: EncoderDims,
_pic_params: PicParams,
) -> Result<(), EncoderError> {
assert!(slot.shared_handle != 0, "slot shared_handle non-zero");
assert!(dims.width > 0, "dims width positive");
Err(EncoderError::NotImplemented {
what: concat!($vendor_tag, "::encode_shared_async"),
})
}
fn poll_completed(&mut self, _slot: HandoffSlot) -> Option<EncodedBitstream> {
None
}
fn unregister_slot(&mut self, slot: HandoffSlot) {
assert!(slot.shared_handle != 0, "slot shared_handle non-zero");
}
fn encode_shared(
&mut self,
submission: EncoderSubmission,
_callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError> {
assert!(submission.shared_handle != 0, "submission handle non-zero");
assert!(submission.dims.width > 0, "submission width positive");
Err(RingError::NotImplemented {
what: concat!($vendor_tag, "::encode_shared"),
})
}
}
};
}
impl_not_implemented_for_trait!(NvencHandoff, "NvencHandoff");
impl_not_implemented_for_trait!(AmfHandoff, "AmfHandoff");
impl_not_implemented_for_trait!(QsvHandoff, "QsvHandoff");
#[cfg(test)]
mod tests {
use super::*;
struct NoopCallback;
impl EncoderCompletionCallback for NoopCallback {
fn on_complete(&mut self, _sequence: u64, _encoded_bytes: u32) {}
}
fn submission() -> EncoderSubmission {
EncoderSubmission::new(0xfeed_face, 7, EncoderDims::new(1920, 1080), 42)
}
#[test]
fn nvenc_stub_returns_not_implemented() {
let mut h = NotImplementedHandoff::nvenc();
let mut cb = NoopCallback;
let err = NvencHandoff::encode_shared(&mut h, submission(), &mut cb).err();
assert!(matches!(err, Some(RingError::NotImplemented { what })
if what.contains("Nvenc")));
}
#[test]
fn amf_stub_returns_not_implemented() {
let mut h = NotImplementedHandoff::amf();
let mut cb = NoopCallback;
let err = AmfHandoff::encode_shared(&mut h, submission(), &mut cb).err();
assert!(matches!(err, Some(RingError::NotImplemented { what })
if what.contains("Amf")));
}
#[test]
fn qsv_stub_returns_not_implemented() {
let mut h = NotImplementedHandoff::qsv();
let mut cb = NoopCallback;
let err = QsvHandoff::encode_shared(&mut h, submission(), &mut cb).err();
assert!(matches!(err, Some(RingError::NotImplemented { what })
if what.contains("Qsv")));
}
#[test]
fn encoder_dims_rejects_zero_width_via_assert() {
let result = std::panic::catch_unwind(|| EncoderDims::new(0, 1080));
assert!(result.is_err());
}
#[test]
fn nvenc_stub_register_returns_not_implemented() {
let mut h = NotImplementedHandoff::nvenc();
let dims = EncoderDims::new(1920, 1080);
let err = NvencHandoff::register_slot(&mut h, 0xabc, 0, dims).err();
assert!(matches!(err, Some(EncoderError::NotImplemented { what })
if what.contains("Nvenc")));
}
#[test]
fn amf_stub_register_returns_not_implemented() {
let mut h = NotImplementedHandoff::amf();
let dims = EncoderDims::new(1920, 1080);
let err = AmfHandoff::register_slot(&mut h, 0xabc, 0, dims).err();
assert!(matches!(err, Some(EncoderError::NotImplemented { what })
if what.contains("Amf")));
}
#[test]
fn qsv_stub_register_returns_not_implemented() {
let mut h = NotImplementedHandoff::qsv();
let dims = EncoderDims::new(1920, 1080);
let err = QsvHandoff::register_slot(&mut h, 0xabc, 0, dims).err();
assert!(matches!(err, Some(EncoderError::NotImplemented { what })
if what.contains("Qsv")));
}
#[test]
fn stub_poll_completed_returns_none() {
let mut h_nv = NotImplementedHandoff::nvenc();
let mut h_amf = NotImplementedHandoff::amf();
let mut h_qsv = NotImplementedHandoff::qsv();
let slot = HandoffSlot::new(0, 0xdead);
assert!(NvencHandoff::poll_completed(&mut h_nv, slot).is_none());
assert!(AmfHandoff::poll_completed(&mut h_amf, slot).is_none());
assert!(QsvHandoff::poll_completed(&mut h_qsv, slot).is_none());
}
#[test]
fn stub_unregister_does_not_panic() {
let mut h = NotImplementedHandoff::nvenc();
let slot = HandoffSlot::new(0, 0xdead);
NvencHandoff::unregister_slot(&mut h, slot);
}
#[test]
fn encoded_bitstream_rejects_empty() {
let result = std::panic::catch_unwind(|| EncodedBitstream::new(vec![], 0, 0, true));
assert!(result.is_err());
}
#[test]
fn dts_offset_zero_for_no_b_frames() {
let offset = compute_dts_offset_us(1000, 0, 16_666);
assert_eq!(offset, 0);
}
#[test]
fn dts_offset_negative_for_b_frames() {
let offset = compute_dts_offset_us(1000, 2, 16_666);
assert_eq!(offset, -(2 * 16_666_i64));
}
#[test]
fn dts_offset_application_clamps_to_zero() {
let dts = apply_dts_offset(100, -1000);
assert_eq!(dts, 0);
}
#[test]
fn dts_offset_application_below_pts_for_b_frames() {
let pts = 100_000_u64;
let dts = apply_dts_offset(pts, -33_333);
assert!(dts < pts);
assert_eq!(dts, 66_667);
}
#[test]
fn encoder_frame_rate_derives_interval_and_gop() {
let sixty = EncoderFrameRate::from_fps(60);
assert_eq!(sixty.frame_interval_us(), 16_667);
assert_eq!(sixty.gop_pic_size(), 60);
let capped = EncoderFrameRate::from_fps(999);
assert_eq!(capped.numerator, ENCODER_FRAME_RATE_MAX);
assert_eq!(capped.gop_pic_size(), ENCODER_FRAME_RATE_MAX as u16);
}
#[test]
fn handoff_slot_rejects_zero_handle() {
let result = std::panic::catch_unwind(|| HandoffSlot::new(0, 0));
assert!(result.is_err());
}
#[test]
fn submission_capture_pts_defaults_absent_and_round_trips() {
let s = submission();
assert_eq!(s.capture_pts_us, None);
let with_pts = s.with_capture_pts_us(123_456);
assert_eq!(with_pts.capture_pts_us, Some(123_456));
assert_eq!(with_pts.sequence, s.sequence);
assert_eq!(with_pts.shared_handle, s.shared_handle);
}
struct CountingCallback {
seen: Vec<(u64, u32)>,
}
impl EncoderCompletionCallback for CountingCallback {
fn on_complete(&mut self, sequence: u64, encoded_bytes: u32) {
self.seen.push((sequence, encoded_bytes));
}
}
#[test]
fn vt_noop_accepts_frames_in_fifo_order() {
let mut h = VtNoOpHandoff::new();
let mut cb = CountingCallback { seen: Vec::new() };
let dims = EncoderDims::new(1920, 1080);
for seq in 1..=5u64 {
let s = EncoderSubmission::new(0xfeed_face_u64, 0, dims, seq);
VideoToolboxHandoff::encode_shared(&mut h, s, &mut cb).expect("vt encode_shared");
}
assert_eq!(cb.seen.len(), 5);
for (idx, &(seq, _)) in cb.seen.iter().enumerate() {
assert_eq!(seq, (idx as u64) + 1, "fifo sequence");
}
assert_eq!(h.accepted_count(), 5);
}
#[test]
fn vt_noop_register_returns_slot() {
let mut h = VtNoOpHandoff::new();
let dims = EncoderDims::new(1920, 1080);
let slot_a =
VideoToolboxHandoff::register_slot(&mut h, 0xabc, 0, dims).expect("register a");
let slot_b =
VideoToolboxHandoff::register_slot(&mut h, 0xdef, 0, dims).expect("register b");
assert_eq!(slot_a.shared_handle, 0xabc);
assert_eq!(slot_b.shared_handle, 0xdef);
assert_ne!(slot_a.slot_index, slot_b.slot_index);
}
#[test]
fn vt_noop_register_rejects_zero_handle() {
let mut h = VtNoOpHandoff::new();
let dims = EncoderDims::new(1920, 1080);
let err = VideoToolboxHandoff::register_slot(&mut h, 0, 0, dims).err();
assert!(matches!(err, Some(EncoderError::SlotUnknown { .. })));
}
#[test]
fn vt_noop_encode_shared_rejects_zero_handle() {
let mut h = VtNoOpHandoff::new();
let mut cb = CountingCallback { seen: Vec::new() };
let dims = EncoderDims::new(1920, 1080);
let s = EncoderSubmission {
shared_handle: 0,
keyed_mutex_key: 0,
dims,
sequence: 1,
capture_pts_us: None,
};
let err = VideoToolboxHandoff::encode_shared(&mut h, s, &mut cb).err();
assert!(matches!(err, Some(RingError::UnknownSlot)));
assert!(cb.seen.is_empty(), "no callback on rejection");
}
#[test]
fn vt_noop_encode_shared_async_advances_accepted() {
let mut h = VtNoOpHandoff::new();
let dims = EncoderDims::new(1280, 720);
let slot = HandoffSlot::new(0, 0xfeed);
let params = PicParams::new(16_666, false);
VideoToolboxHandoff::encode_shared_async(&mut h, slot, 0, dims, params)
.expect("async encode ok");
assert_eq!(h.accepted_count(), 1);
assert_eq!(h.pending_len(), 1);
}
}
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::too_many_lines)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::panic)]
#![deny(warnings)]
pub mod backend;
pub mod d3d11;
pub mod encoder_handoff;
pub mod metal_iosurface;
pub mod ring;
#[cfg(target_os = "macos")]
pub mod metal_iosurface_macos;
#[cfg(target_os = "macos")]
pub mod vt_compression_macos;
#[cfg(target_os = "windows")]
pub mod amf;
#[cfg(target_os = "windows")]
pub mod nvenc;
#[cfg(target_os = "windows")]
pub mod qsv;
pub use backend::{CpuMemcpyBackend, CpuSlotHandle, KeyedMutexBackend, TextureFormat};
pub use d3d11::D3D11KeyedMutexBackend;
pub use encoder_handoff::{
AmfHandoff, EncodedBitstream, EncoderDims, EncoderError, EncoderFrameRate, EncoderSubmission,
HandoffSlot, NotImplementedHandoff, NvencHandoff, PicParams, QsvHandoff, VideoToolboxHandoff,
VtNoOpHandoff, apply_dts_offset, compute_dts_offset_us,
};
pub use metal_iosurface::{
IoSurfaceSlotHandle, METAL_IOSURFACE_SEED_BASE, MetalSharedTextureBackend,
};
pub use ring::{
DUPLICATE_COUNT_MAX, EncoderInputRing, EncoderReady, FillReservation, RING_SIZE, RingError,
RingMetrics,
};
#[cfg(target_os = "macos")]
pub use vt_compression_macos::{VtCompressionHandoff, VtPixelTransfer};
#[cfg(target_os = "windows")]
pub use amf::AmfD3D11Handoff;
#[cfg(target_os = "windows")]
pub use nvenc::{COMPLETION_RING_CAPACITY as NVENC_COMPLETION_RING_CAPACITY, NvencD3D11Handoff};
#[cfg(target_os = "windows")]
pub use qsv::QsvD3D11Handoff;
pub const NV12_BPP_NUMERATOR: u32 = 3;
pub const NV12_BPP_DENOMINATOR: u32 = 2;
pub const MAX_FRAME_WIDTH: u32 = 7680;
pub const MAX_FRAME_HEIGHT: u32 = 4320;
#[inline]
pub const fn nv12_byte_size(width: u32, height: u32) -> usize {
let w = width as usize;
let h = height as usize;
(w * h * NV12_BPP_NUMERATOR as usize) / NV12_BPP_DENOMINATOR as usize
}
@@ -0,0 +1,447 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::backend::{BackendError, KeyedMutexBackend, NUM_SLOTS_DEFAULT, TextureFormat};
use crate::{MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH};
pub const METAL_IOSURFACE_SEED_BASE: u64 = 0;
#[derive(Clone)]
pub struct IoSurfaceSlotHandle {
inner: Arc<IoSurfaceSlotInner>,
}
impl IoSurfaceSlotHandle {
pub fn slot_index(&self) -> u32 {
let idx = self.inner.slot_index;
assert!((idx as usize) < NUM_SLOTS_DEFAULT, "slot_index in range");
assert!(
self.inner.iosurface_handle != 0,
"iosurface_handle non-zero"
);
idx
}
pub fn iosurface_handle(&self) -> u64 {
let h = self.inner.iosurface_handle;
assert!(h != 0, "iosurface_handle non-zero on read");
assert!(
(self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT,
"slot_index intact"
);
h
}
#[cfg(target_os = "macos")]
pub fn iosurface_ptr(&self) -> *mut core::ffi::c_void {
let p = self.inner.iosurface_ptr;
assert!(!p.is_null(), "iosurface_ptr non-null on read");
assert!(
self.inner.iosurface_handle != 0,
"handle non-zero alongside ptr"
);
p
}
pub fn current_key(&self) -> u64 {
let key = self.inner.current_key.load(Ordering::Acquire);
assert!(
self.inner.iosurface_handle != 0,
"handle intact during key read"
);
assert!(
(self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT,
"slot_index intact"
);
key
}
pub fn is_acquired(&self) -> bool {
let a = self.inner.acquired.load(Ordering::Acquire);
assert!(
self.inner.iosurface_handle != 0,
"handle intact during acquired probe"
);
assert!(
(self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT,
"slot_index intact"
);
a
}
}
struct IoSurfaceSlotInner {
slot_index: u32,
iosurface_handle: u64,
#[cfg(target_os = "macos")]
iosurface_ptr: *mut core::ffi::c_void,
current_key: AtomicU64,
acquired: AtomicBool,
completed: AtomicBool,
}
#[cfg(target_os = "macos")]
unsafe impl Send for IoSurfaceSlotInner {}
#[cfg(target_os = "macos")]
unsafe impl Sync for IoSurfaceSlotInner {}
pub struct MetalSharedTextureBackend {
width: u32,
height: u32,
format: TextureFormat,
slots_created: bool,
slot_handles: Vec<u64>,
#[cfg(target_os = "macos")]
iosurfaces: Vec<crate::metal_iosurface_macos::OwnedIoSurface>,
}
impl MetalSharedTextureBackend {
pub fn new() -> Self {
let backend = Self {
width: 0,
height: 0,
format: TextureFormat::Nv12,
slots_created: false,
slot_handles: Vec::with_capacity(NUM_SLOTS_DEFAULT),
#[cfg(target_os = "macos")]
iosurfaces: Vec::with_capacity(NUM_SLOTS_DEFAULT),
};
assert!(!backend.slots_created, "fresh backend has no slots");
assert!(
backend.slot_handles.is_empty(),
"fresh backend handles empty"
);
backend
}
pub fn width(&self) -> u32 {
assert!(self.width <= MAX_FRAME_WIDTH, "width within cap");
assert!(self.height <= MAX_FRAME_HEIGHT, "height within cap");
self.width
}
pub fn height(&self) -> u32 {
assert!(self.height <= MAX_FRAME_HEIGHT, "height within cap");
assert!(self.width <= MAX_FRAME_WIDTH, "width within cap");
self.height
}
pub fn slot_iosurface_handle(&self, slot_index: u32) -> Option<u64> {
let idx = slot_index as usize;
if idx >= self.slot_handles.len() {
return None;
}
let h = self.slot_handles[idx];
assert!(h != 0, "stored iosurface handle non-zero");
Some(h)
}
#[cfg(target_os = "macos")]
pub fn slot_iosurface_ptr(&self, slot_index: u32) -> Option<*mut core::ffi::c_void> {
let idx = slot_index as usize;
if idx >= self.iosurfaces.len() {
return None;
}
let p = self.iosurfaces[idx].as_ptr();
assert!(!p.is_null(), "stored iosurface ptr non-null");
assert!(
idx < self.slot_handles.len(),
"ptr slot mirrors handle slot"
);
Some(p)
}
#[cfg(target_os = "macos")]
pub fn slot_iosurface_mut(
&mut self,
slot_index: u32,
) -> Option<&mut crate::metal_iosurface_macos::OwnedIoSurface> {
let idx = slot_index as usize;
if idx >= self.iosurfaces.len() {
return None;
}
assert!(
idx < self.slot_handles.len(),
"mut slot mirrors handle slot"
);
Some(&mut self.iosurfaces[idx])
}
#[cfg(not(target_os = "macos"))]
fn allocate_slot_handles(
&mut self,
_width: u32,
_height: u32,
_format: TextureFormat,
) -> Result<Vec<u64>, BackendError> {
Err(BackendError::PlatformUnsupported {
reason: "MetalSharedTextureBackend requires macOS",
})
}
#[cfg(target_os = "macos")]
fn allocate_slot_handles(
&mut self,
width: u32,
height: u32,
format: TextureFormat,
) -> Result<Vec<u64>, BackendError> {
if !matches!(format, TextureFormat::Nv12) {
return Err(BackendError::UnsupportedFormat { format });
}
let mut handles: Vec<u64> = Vec::with_capacity(NUM_SLOTS_DEFAULT);
let mut owned: Vec<crate::metal_iosurface_macos::OwnedIoSurface> =
Vec::with_capacity(NUM_SLOTS_DEFAULT);
for slot in 0..NUM_SLOTS_DEFAULT {
let surface = crate::metal_iosurface_macos::OwnedIoSurface::create_nv12(width, height)
.map_err(|_| BackendError::PlatformUnsupported {
reason: "IOSurfaceCreate failed",
})?;
let raw = surface.handle();
assert!(raw != 0, "IOSurface raw non-zero for slot");
assert!(slot < NUM_SLOTS_DEFAULT, "slot index in range");
handles.push(raw);
owned.push(surface);
}
self.iosurfaces = owned;
Ok(handles)
}
}
impl Default for MetalSharedTextureBackend {
fn default() -> Self {
Self::new()
}
}
impl KeyedMutexBackend for MetalSharedTextureBackend {
type SlotHandle = IoSurfaceSlotHandle;
const NUM_SLOTS: usize = NUM_SLOTS_DEFAULT;
fn create_slots(
&mut self,
width: u32,
height: u32,
format: TextureFormat,
) -> Result<Vec<IoSurfaceSlotHandle>, BackendError> {
if width == 0 || height == 0 || width > MAX_FRAME_WIDTH || height > MAX_FRAME_HEIGHT {
return Err(BackendError::DimensionsOutOfRange { width, height });
}
if !matches!(format, TextureFormat::Nv12) {
return Err(BackendError::UnsupportedFormat { format });
}
assert!(!self.slots_created, "slots created once");
let raw_handles = self.allocate_slot_handles(width, height, format)?;
assert_eq!(
raw_handles.len(),
NUM_SLOTS_DEFAULT,
"allocator returns NUM_SLOTS handles"
);
let mut handles: Vec<IoSurfaceSlotHandle> = Vec::with_capacity(NUM_SLOTS_DEFAULT);
for (idx, raw) in raw_handles.iter().enumerate() {
assert!(*raw != 0, "raw iosurface handle non-zero");
#[cfg(target_os = "macos")]
let surface_ptr = self.iosurfaces[idx].as_ptr();
#[cfg(target_os = "macos")]
assert!(!surface_ptr.is_null(), "surface ptr non-null at slot setup");
let inner = IoSurfaceSlotInner {
slot_index: idx as u32,
iosurface_handle: *raw,
#[cfg(target_os = "macos")]
iosurface_ptr: surface_ptr,
current_key: AtomicU64::new(METAL_IOSURFACE_SEED_BASE),
acquired: AtomicBool::new(false),
completed: AtomicBool::new(false),
};
handles.push(IoSurfaceSlotHandle {
inner: Arc::new(inner),
});
}
self.width = width;
self.height = height;
self.format = format;
self.slot_handles = raw_handles;
self.slots_created = true;
assert_eq!(
handles.len(),
NUM_SLOTS_DEFAULT,
"returned handle vector length"
);
assert!(self.slots_created, "slots_created flipped");
Ok(handles)
}
fn acquire_write(&mut self, slot: &IoSurfaceSlotHandle, key: u64) -> Result<(), BackendError> {
assert!(self.slots_created, "slots must exist before acquire");
let current = slot.inner.current_key.load(Ordering::Acquire);
if current != key {
return Err(BackendError::KeyMismatch {
expected: key,
observed: current,
});
}
let was_acquired = slot.inner.acquired.swap(true, Ordering::AcqRel);
if was_acquired {
return Err(BackendError::AcquireWhileWriting {
slot_index: slot.inner.slot_index,
});
}
#[cfg(target_os = "macos")]
{
let idx = slot.inner.slot_index as usize;
if idx >= self.iosurfaces.len() {
slot.inner.acquired.store(false, Ordering::Release);
return Err(BackendError::PlatformUnsupported {
reason: "slot index out of range for IOSurface vector",
});
}
if let Err(e) = self.iosurfaces[idx].lock_for_writing() {
slot.inner.acquired.store(false, Ordering::Release);
return Err(e);
}
}
slot.inner.completed.store(false, Ordering::Release);
assert!(
slot.inner.acquired.load(Ordering::Acquire),
"acquired flag set"
);
assert!(
!slot.inner.completed.load(Ordering::Acquire),
"completed cleared"
);
Ok(())
}
fn release_write(
&mut self,
slot: &IoSurfaceSlotHandle,
next_key: u64,
) -> Result<(), BackendError> {
assert!(self.slots_created, "slots must exist before release");
let was_acquired = slot.inner.acquired.swap(false, Ordering::AcqRel);
if !was_acquired {
return Err(BackendError::ReleaseWithoutAcquire {
slot_index: slot.inner.slot_index,
});
}
#[cfg(target_os = "macos")]
{
let idx = slot.inner.slot_index as usize;
if idx >= self.iosurfaces.len() {
return Err(BackendError::PlatformUnsupported {
reason: "slot index out of range for IOSurface vector",
});
}
self.iosurfaces[idx].unlock_after_writing()?;
}
slot.inner.current_key.store(next_key, Ordering::Release);
slot.inner.completed.store(true, Ordering::Release);
assert!(
!slot.inner.acquired.load(Ordering::Acquire),
"acquired cleared"
);
assert!(
slot.inner.completed.load(Ordering::Acquire),
"completed flag set"
);
Ok(())
}
fn poll_complete(&mut self, slot: &IoSurfaceSlotHandle) -> bool {
let done = slot.inner.completed.load(Ordering::Acquire);
assert!(self.slots_created, "slots exist for poll");
assert!(
(slot.inner.slot_index as usize) < Self::NUM_SLOTS,
"slot index in range"
);
done
}
fn mark_consumed(&mut self, slot: &IoSurfaceSlotHandle) {
assert!(self.slots_created, "slots exist for mark_consumed");
assert!(
(slot.inner.slot_index as usize) < Self::NUM_SLOTS,
"slot index in range"
);
slot.inner.completed.store(false, Ordering::Release);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn off_macos_returns_platform_unsupported() {
let mut backend = MetalSharedTextureBackend::new();
let result = backend.create_slots(1920, 1080, TextureFormat::Nv12);
#[cfg(not(target_os = "macos"))]
{
assert!(matches!(
result,
Err(BackendError::PlatformUnsupported { .. })
));
}
#[cfg(target_os = "macos")]
{
let slots = result.expect("macos create_slots ok");
assert_eq!(slots.len(), 8);
for (idx, s) in slots.iter().enumerate() {
assert_eq!(s.slot_index(), idx as u32);
assert!(s.iosurface_handle() != 0);
}
}
}
#[test]
fn create_slots_rejects_zero_dims() {
let mut backend = MetalSharedTextureBackend::new();
let err = backend.create_slots(0, 1080, TextureFormat::Nv12).err();
assert!(matches!(
err,
Some(BackendError::DimensionsOutOfRange { .. })
));
}
#[test]
fn create_slots_rejects_unsupported_format() {
let mut backend = MetalSharedTextureBackend::new();
let err = backend.create_slots(1920, 1080, TextureFormat::P010).err();
assert!(matches!(err, Some(BackendError::UnsupportedFormat { .. })));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_acquire_release_round_trip_marks_complete() {
let mut backend = MetalSharedTextureBackend::new();
let slots = backend
.create_slots(64, 64, TextureFormat::Nv12)
.expect("create");
let slot = slots[0].clone();
backend.acquire_write(&slot, 0).expect("acquire");
assert!(slot.is_acquired());
backend.release_write(&slot, 1).expect("release");
assert!(!slot.is_acquired());
assert!(backend.poll_complete(&slot));
backend.mark_consumed(&slot);
assert!(!backend.poll_complete(&slot));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_double_acquire_rejects() {
let mut backend = MetalSharedTextureBackend::new();
let slots = backend
.create_slots(64, 64, TextureFormat::Nv12)
.expect("create");
let slot = slots[0].clone();
backend.acquire_write(&slot, 0).expect("first acquire");
let err = backend.acquire_write(&slot, 0).err();
assert!(matches!(
err,
Some(BackendError::AcquireWhileWriting { .. })
));
}
}
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use core::ffi::c_void;
use core::ptr::NonNull;
use crate::backend::BackendError;
type IoSurfaceRef = *mut c_void;
type CfDictionaryRef = *const c_void;
type CfStringRef = *const c_void;
type CfNumberRef = *const c_void;
type CfAllocatorRef = *const c_void;
const KIO_RETURN_SUCCESS: i32 = 0;
const KCF_NUMBER_SINT32_TYPE: i32 = 3;
const IOSURFACE_PIXEL_FORMAT_420V: u32 = u32::from_be_bytes(*b"420v");
#[link(name = "IOSurface", kind = "framework")]
unsafe extern "C" {
fn IOSurfaceCreate(properties: CfDictionaryRef) -> IoSurfaceRef;
fn IOSurfaceLock(buffer: IoSurfaceRef, options: u32, seed: *mut u32) -> i32;
fn IOSurfaceUnlock(buffer: IoSurfaceRef, options: u32, seed: *mut u32) -> i32;
fn IOSurfaceGetID(buffer: IoSurfaceRef) -> u32;
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFAllocatorDefault: CfAllocatorRef;
static kCFTypeDictionaryKeyCallBacks: *const c_void;
static kCFTypeDictionaryValueCallBacks: *const c_void;
fn CFDictionaryCreate(
allocator: CfAllocatorRef,
keys: *const *const c_void,
values: *const *const c_void,
num_values: isize,
key_callbacks: *const c_void,
value_callbacks: *const c_void,
) -> CfDictionaryRef;
fn CFNumberCreate(
allocator: CfAllocatorRef,
the_type: i32,
value_ptr: *const c_void,
) -> CfNumberRef;
fn CFStringCreateWithCString(
allocator: CfAllocatorRef,
c_str: *const i8,
encoding: u32,
) -> CfStringRef;
fn CFRelease(cf: *const c_void);
}
const KCFSTRING_ENCODING_UTF8: u32 = 0x0800_0100;
fn cf_str(literal: &'static str) -> CfStringRef {
assert!(literal.ends_with('\0'), "literal must be nul-terminated");
let bytes = literal.as_bytes();
unsafe {
CFStringCreateWithCString(
kCFAllocatorDefault,
bytes.as_ptr() as *const i8,
KCFSTRING_ENCODING_UTF8,
)
}
}
fn cf_num_i32(v: i32) -> CfNumberRef {
let ptr: *const i32 = &v;
unsafe {
CFNumberCreate(
kCFAllocatorDefault,
KCF_NUMBER_SINT32_TYPE,
ptr as *const c_void,
)
}
}
pub struct OwnedIoSurface {
surface: NonNull<c_void>,
}
unsafe impl Send for OwnedIoSurface {}
impl OwnedIoSurface {
pub fn create_nv12(width: u32, height: u32) -> Result<Self, BackendError> {
assert!(width > 0, "create_nv12 width positive");
assert!(
height > 0 && height.is_multiple_of(2),
"create_nv12 height positive and even"
);
let width_key = cf_str("IOSurfaceWidth\0");
let height_key = cf_str("IOSurfaceHeight\0");
let pf_key = cf_str("IOSurfacePixelFormat\0");
let bpe_key = cf_str("IOSurfaceBytesPerElement\0");
let width_val = cf_num_i32(width as i32);
let height_val = cf_num_i32(height as i32);
let pf_val = cf_num_i32(IOSURFACE_PIXEL_FORMAT_420V as i32);
let bpe_val = cf_num_i32(1);
let keys: [*const c_void; 4] = [width_key, height_key, pf_key, bpe_key];
let vals: [*const c_void; 4] = [width_val, height_val, pf_val, bpe_val];
let dict = unsafe {
CFDictionaryCreate(
kCFAllocatorDefault,
keys.as_ptr(),
vals.as_ptr(),
4,
kCFTypeDictionaryKeyCallBacks,
kCFTypeDictionaryValueCallBacks,
)
};
let raw = if dict.is_null() {
core::ptr::null_mut()
} else {
unsafe { IOSurfaceCreate(dict) }
};
unsafe {
CFRelease(width_key);
CFRelease(height_key);
CFRelease(pf_key);
CFRelease(bpe_key);
CFRelease(width_val);
CFRelease(height_val);
CFRelease(pf_val);
CFRelease(bpe_val);
if !dict.is_null() {
CFRelease(dict);
}
}
let surface = NonNull::new(raw).ok_or(BackendError::PlatformUnsupported {
reason: "IOSurfaceCreate returned null",
})?;
assert!(
unsafe { IOSurfaceGetID(surface.as_ptr()) } != 0,
"IOSurfaceGetID non-zero"
);
Ok(Self { surface })
}
pub fn handle(&self) -> u64 {
let id = unsafe { IOSurfaceGetID(self.surface.as_ptr()) };
assert!(id != 0, "IOSurfaceID non-zero on handle()");
assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null");
id as u64
}
pub fn as_ptr(&self) -> *mut c_void {
let p = self.surface.as_ptr();
assert!(!p.is_null(), "IOSurface raw pointer non-null");
assert!(unsafe { IOSurfaceGetID(p) } != 0, "IOSurfaceID non-zero");
p
}
pub fn lock_for_writing(&mut self) -> Result<(), BackendError> {
let mut seed: u32 = 0;
let status = unsafe { IOSurfaceLock(self.surface.as_ptr(), 0, &mut seed) };
if status != KIO_RETURN_SUCCESS {
return Err(BackendError::PlatformUnsupported {
reason: "IOSurfaceLock failed",
});
}
assert_eq!(status, KIO_RETURN_SUCCESS, "lock status ok");
assert!(seed < u32::MAX, "lock seed within range");
Ok(())
}
pub fn unlock_after_writing(&mut self) -> Result<(), BackendError> {
let mut seed: u32 = 0;
let status = unsafe { IOSurfaceUnlock(self.surface.as_ptr(), 0, &mut seed) };
if status != KIO_RETURN_SUCCESS {
return Err(BackendError::PlatformUnsupported {
reason: "IOSurfaceUnlock failed",
});
}
assert_eq!(status, KIO_RETURN_SUCCESS, "unlock status ok");
assert!(seed < u32::MAX, "unlock seed within range");
Ok(())
}
}
impl Drop for OwnedIoSurface {
fn drop(&mut self) {
let ptr = self.surface.as_ptr();
if !ptr.is_null() {
unsafe { CFRelease(ptr) };
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,964 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::ffi::c_void;
use std::ptr;
use std::sync::Arc;
use libloading::{Library, Symbol};
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Multithread};
use windows::core::Interface;
use crate::encoder_handoff::{
EncodedBitstream, EncoderCompletionCallback, EncoderDims, EncoderError, EncoderFrameRate,
EncoderSubmission, HandoffSlot, PicParams, QsvHandoff, apply_dts_offset, compute_dts_offset_us,
};
use crate::ring::RingError;
pub const QSV_DLL_NAME_VPL: &str = "libvpl.dll";
pub const QSV_DLL_NAME_MFX: &str = "libmfxhw64.dll";
const MFX_IMPL_HARDWARE: i32 = 0x0002;
const MFX_IMPL_VIA_D3D11: i32 = 0x0300;
const MFX_IMPL_TYPE_HARDWARE: u32 = 2;
const MFX_ACCEL_MODE_VIA_D3D11: u32 = 0x0300;
const MFX_HANDLE_D3D11_DEVICE: u32 = 3;
const MFX_FOURCC_NV12: u32 = u32::from_le_bytes(*b"NV12");
const MFX_CODEC_AVC: u32 = u32::from_le_bytes(*b"AVC ");
const MFX_RATECONTROL_CBR: u16 = 1;
const MFX_PICSTRUCT_PROGRESSIVE: u16 = 0x01;
const MFX_CHROMAFORMAT_YUV420: u16 = 1;
const MFX_IOPATTERN_IN_VIDEO_MEMORY: u16 = 0x01;
const MFX_VARIANT_TYPE_U32: u32 = 5;
const MFX_VARIANT_VERSION_MINOR: u8 = 1;
const MFX_VARIANT_VERSION_MAJOR: u8 = 1;
const MFX_ERR_NONE: i32 = 0;
const MFX_WRN_IN_EXECUTION: i32 = 1;
const MFX_ERR_MORE_DATA: i32 = -10;
const FILTER_PROPERTY_IMPL: &[u8] = b"mfxImplDescription.Impl\0";
const FILTER_PROPERTY_ACCEL: &[u8] = b"mfxImplDescription.AccelerationMode\0";
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct MfxVersion {
minor: u16,
major: u16,
}
#[repr(C)]
#[derive(Default)]
struct MfxFrameInfo {
reserved: [u32; 4],
channel_id: u16,
bit_depth_luma: u16,
bit_depth_chroma: u16,
shift: u16,
frame_id_temporal: u16,
frame_id_priority: u16,
frame_id_view_or_dependency: u16,
frame_id_quality: u16,
four_cc: u32,
width: u16,
height: u16,
crop_x: u16,
crop_y: u16,
crop_w: u16,
crop_h: u16,
frame_rate_extn: u32,
frame_rate_extd: u32,
reserved3: u16,
aspect_ratio_w: u16,
aspect_ratio_h: u16,
pic_struct: u16,
chroma_format: u16,
reserved2: u16,
}
#[repr(C)]
#[derive(Default)]
struct MfxInfoMfx {
reserved: [u32; 7],
low_power: u16,
brc_param_multiplier: u16,
frame_info: MfxFrameInfo,
codec_id: u32,
codec_profile: u16,
codec_level: u16,
num_thread: u16,
target_usage: u16,
gop_pic_size: u16,
gop_ref_dist: u16,
gop_opt_flag: u16,
idr_interval: u16,
rate_control_method: u16,
init_qp: u16,
buffer_size_in_kb: u16,
target_kbps: u16,
max_kbps: u16,
num_slice: u16,
num_ref_frame: u16,
encoded_order: u16,
union_pad: [u16; 15],
}
#[repr(C)]
struct MfxVideoParam {
alloc_id: u32,
reserved: [u32; 2],
reserved3: u16,
async_depth: u16,
mfx: MfxInfoMfx,
protected: u16,
io_pattern: u16,
ext_param: *mut c_void,
num_ext_param: u16,
reserved2: u16,
}
#[repr(C)]
struct MfxBitstream {
encrypted_data: *mut c_void,
num_extparam: u16,
ext_param: *mut c_void,
reserved: [u32; 6],
decode_time_stamp: u64,
time_stamp: u64,
data: *mut u8,
data_offset: u32,
data_length: u32,
max_length: u32,
pic_struct: u16,
frame_type: u16,
data_flag: u16,
reserved2: u16,
}
#[repr(C)]
struct MfxFrameData {
ext_param: *mut c_void,
num_extparam: u16,
reserved: [u32; 8],
mem_type: u16,
pitch_high: u16,
time_stamp: u64,
frame_order: u32,
locked: u16,
pitch_low: u16,
plane_ptrs: [*mut u8; 7],
mem_id: *mut c_void,
corrupted: u16,
data_flag: u16,
}
#[repr(C)]
struct MfxFrameSurface1 {
reserved: [u32; 4],
interface_ptr: *mut c_void,
info: MfxFrameInfo,
data: MfxFrameData,
}
#[repr(C)]
#[derive(Clone, Copy)]
union MfxVariantData {
u32_: u32,
u64_: u64,
ptr: *mut c_void,
pad: [u8; 16],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct MfxVariant {
version: MfxStructVersion,
type_: u32,
data: MfxVariantData,
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct MfxStructVersion {
minor: u8,
major: u8,
}
type MfxInit = unsafe extern "C" fn(i32, *mut MfxVersion, *mut *mut c_void) -> i32;
type MfxClose = unsafe extern "C" fn(*mut c_void) -> i32;
type MfxSetHandle = unsafe extern "C" fn(*mut c_void, u32, *mut c_void) -> i32;
type MfxEncodeInit = unsafe extern "C" fn(*mut c_void, *mut MfxVideoParam) -> i32;
type MfxEncodeClose = unsafe extern "C" fn(*mut c_void) -> i32;
type MfxEncodeQuery =
unsafe extern "C" fn(*mut c_void, *mut MfxVideoParam, *mut MfxVideoParam) -> i32;
type MfxEncodeFrameAsync = unsafe extern "C" fn(
*mut c_void,
*mut c_void,
*mut MfxFrameSurface1,
*mut MfxBitstream,
*mut *mut c_void,
) -> i32;
type MfxSyncOperation = unsafe extern "C" fn(*mut c_void, *mut c_void, u32) -> i32;
type MfxLoad = unsafe extern "C" fn() -> *mut c_void;
type MfxUnload = unsafe extern "C" fn(*mut c_void);
type MfxCreateConfig = unsafe extern "C" fn(*mut c_void) -> *mut c_void;
type MfxSetConfigFilterProperty = unsafe extern "C" fn(*mut c_void, *const u8, MfxVariant) -> i32;
type MfxCreateSession = unsafe extern "C" fn(*mut c_void, u32, *mut *mut c_void) -> i32;
struct ApiTable {
init: MfxInit,
close: MfxClose,
set_handle: MfxSetHandle,
encode_init: MfxEncodeInit,
encode_close: MfxEncodeClose,
encode_query: MfxEncodeQuery,
encode_frame_async: MfxEncodeFrameAsync,
sync_operation: MfxSyncOperation,
}
struct DispatcherTable {
load: MfxLoad,
unload: MfxUnload,
create_config: MfxCreateConfig,
set_config_property: MfxSetConfigFilterProperty,
create_session: MfxCreateSession,
}
struct SlotState {
pending_pts_us: u64,
pending_force_keyframe: bool,
sync_point: *mut c_void,
bitstream: MfxBitstream,
bitstream_buf: Vec<u8>,
surface: MfxFrameSurface1,
in_flight: bool,
}
pub struct QsvD3D11Handoff {
_library: Arc<Library>,
api: ApiTable,
dispatcher: Option<DispatcherTable>,
loader: *mut c_void,
session: *mut c_void,
slots: HashMap<u32, SlotState>,
next_slot_index: u32,
dts_offset_us: i64,
completed_count: u64,
target_kbps: u16,
frame_rate: EncoderFrameRate,
}
unsafe impl Send for QsvD3D11Handoff {}
impl QsvD3D11Handoff {
pub fn new(
device: ID3D11Device,
dims: EncoderDims,
bitrate_bps: u32,
) -> Result<Self, EncoderError> {
Self::new_with_frame_rate(device, dims, bitrate_bps, EncoderFrameRate::default())
}
pub fn new_with_frame_rate(
device: ID3D11Device,
dims: EncoderDims,
bitrate_bps: u32,
frame_rate: EncoderFrameRate,
) -> Result<Self, EncoderError> {
assert!(dims.width > 0, "dims width positive");
assert!(dims.height > 0, "dims height positive");
assert!(frame_rate.numerator > 0, "frame rate numerator positive");
assert!(
frame_rate.denominator > 0,
"frame rate denominator positive"
);
if dims.width > 7680 || dims.height > 4320 {
return Err(EncoderError::DimensionsOutOfRange {
width: dims.width,
height: dims.height,
});
}
let library = load_runtime()?;
let api = load_api(&library)?;
let dispatcher_opt = load_dispatcher(&library);
let (loader, session) = open_session(&api, dispatcher_opt.as_ref())?;
set_d3d11_handle(&api, session, &device)?;
let target_kbps = (bitrate_bps / 1000).clamp(500, 60_000) as u16;
encode_init(&api, session, dims, target_kbps, frame_rate)?;
let dts_offset_us = compute_dts_offset_us(0, 0, frame_rate.frame_interval_us());
let handoff = Self {
_library: Arc::new(library),
api,
dispatcher: dispatcher_opt,
loader,
session,
slots: HashMap::new(),
next_slot_index: 0,
dts_offset_us,
completed_count: 0,
target_kbps,
frame_rate,
};
assert!(!handoff.session.is_null(), "session non-null");
Ok(handoff)
}
}
fn load_runtime() -> Result<Library, EncoderError> {
let vpl_result = unsafe { Library::new(QSV_DLL_NAME_VPL) };
match vpl_result {
Ok(lib) => Ok(lib),
Err(_) => {
unsafe { Library::new(QSV_DLL_NAME_MFX) }.map_err(|_| EncoderError::SdkNotFound {
vendor: "qsv",
dll: QSV_DLL_NAME_MFX,
})
}
}
}
fn load_api(library: &Library) -> Result<ApiTable, EncoderError> {
let init: Symbol<'_, MfxInit> =
unsafe { library.get(b"MFXInit\0") }.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXInit",
})?;
let close: Symbol<'_, MfxClose> =
unsafe { library.get(b"MFXClose\0") }.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXClose",
})?;
let set_handle: Symbol<'_, MfxSetHandle> = unsafe { library.get(b"MFXVideoCORE_SetHandle\0") }
.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXVideoCORE_SetHandle",
})?;
let encode_init: Symbol<'_, MfxEncodeInit> = unsafe { library.get(b"MFXVideoENCODE_Init\0") }
.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXVideoENCODE_Init",
})?;
let encode_close: Symbol<'_, MfxEncodeClose> = unsafe {
library.get(b"MFXVideoENCODE_Close\0")
}
.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXVideoENCODE_Close",
})?;
let encode_query: Symbol<'_, MfxEncodeQuery> = unsafe {
library.get(b"MFXVideoENCODE_Query\0")
}
.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXVideoENCODE_Query",
})?;
let encode_frame_async: Symbol<'_, MfxEncodeFrameAsync> = unsafe {
library.get(b"MFXVideoENCODE_EncodeFrameAsync\0")
}
.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXVideoENCODE_EncodeFrameAsync",
})?;
let sync_operation: Symbol<'_, MfxSyncOperation> = unsafe {
library.get(b"MFXVideoCORE_SyncOperation\0")
}
.map_err(|_| EncoderError::SymbolMissing {
vendor: "qsv",
symbol: "MFXVideoCORE_SyncOperation",
})?;
Ok(ApiTable {
init: *init,
close: *close,
set_handle: *set_handle,
encode_init: *encode_init,
encode_close: *encode_close,
encode_query: *encode_query,
encode_frame_async: *encode_frame_async,
sync_operation: *sync_operation,
})
}
fn load_dispatcher(library: &Library) -> Option<DispatcherTable> {
let load: Symbol<'_, MfxLoad> = unsafe { library.get(b"MFXLoad\0") }.ok()?;
let unload: Symbol<'_, MfxUnload> = unsafe { library.get(b"MFXUnload\0") }.ok()?;
let create_config: Symbol<'_, MfxCreateConfig> =
unsafe { library.get(b"MFXCreateConfig\0") }.ok()?;
let set_config_property: Symbol<'_, MfxSetConfigFilterProperty> =
unsafe { library.get(b"MFXSetConfigFilterProperty\0") }.ok()?;
let create_session: Symbol<'_, MfxCreateSession> =
unsafe { library.get(b"MFXCreateSession\0") }.ok()?;
Some(DispatcherTable {
load: *load,
unload: *unload,
create_config: *create_config,
set_config_property: *set_config_property,
create_session: *create_session,
})
}
fn open_session(
api: &ApiTable,
dispatcher: Option<&DispatcherTable>,
) -> Result<(*mut c_void, *mut c_void), EncoderError> {
if let Some(d) = dispatcher {
match modern_session(d) {
Ok((loader, session)) => return Ok((loader, session)),
Err(e) => {
let session = init_session_legacy(api)?;
let _ = e;
return Ok((ptr::null_mut(), session));
}
}
}
let session = init_session_legacy(api)?;
Ok((ptr::null_mut(), session))
}
fn modern_session(d: &DispatcherTable) -> Result<(*mut c_void, *mut c_void), EncoderError> {
let loader = unsafe { (d.load)() };
if loader.is_null() {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-mfxload",
status: -1,
});
}
assert!(!loader.is_null(), "MFXLoad returned non-null");
if let Err(e) = set_filter_u32(d, loader, FILTER_PROPERTY_IMPL, MFX_IMPL_TYPE_HARDWARE) {
unsafe { (d.unload)(loader) };
return Err(e);
}
if let Err(e) = set_filter_u32(d, loader, FILTER_PROPERTY_ACCEL, MFX_ACCEL_MODE_VIA_D3D11) {
unsafe { (d.unload)(loader) };
return Err(e);
}
let mut session: *mut c_void = ptr::null_mut();
let status = unsafe { (d.create_session)(loader, 0, &mut session) };
if status != MFX_ERR_NONE || session.is_null() {
unsafe { (d.unload)(loader) };
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-create-session",
status: status as i64,
});
}
assert!(!session.is_null(), "modern session non-null");
Ok((loader, session))
}
fn set_filter_u32(
d: &DispatcherTable,
loader: *mut c_void,
property: &'static [u8],
value_u32: u32,
) -> Result<(), EncoderError> {
assert!(!loader.is_null(), "loader non-null");
assert!(!property.is_empty(), "property non-empty");
let cfg = unsafe { (d.create_config)(loader) };
if cfg.is_null() {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-create-config",
status: -1,
});
}
let variant = MfxVariant {
version: MfxStructVersion {
minor: MFX_VARIANT_VERSION_MINOR,
major: MFX_VARIANT_VERSION_MAJOR,
},
type_: MFX_VARIANT_TYPE_U32,
data: MfxVariantData { u32_: value_u32 },
};
let status = unsafe { (d.set_config_property)(cfg, property.as_ptr(), variant) };
if status != MFX_ERR_NONE {
let vendor = if property == FILTER_PROPERTY_ACCEL {
"qsv-accel-mode-rejected"
} else {
"qsv-set-config-property"
};
return Err(EncoderError::SessionInitFailed {
vendor,
status: status as i64,
});
}
Ok(())
}
fn init_session_legacy(api: &ApiTable) -> Result<*mut c_void, EncoderError> {
let session_via_d3d11 = try_mfxinit(api, MFX_IMPL_HARDWARE | MFX_IMPL_VIA_D3D11);
if let Ok(s) = session_via_d3d11 {
return Ok(s);
}
try_mfxinit(api, MFX_IMPL_HARDWARE)
}
fn try_mfxinit(api: &ApiTable, impl_flags: i32) -> Result<*mut c_void, EncoderError> {
let mut version = MfxVersion { major: 1, minor: 0 };
let mut session: *mut c_void = ptr::null_mut();
let status = unsafe { (api.init)(impl_flags, &mut version, &mut session) };
if status != MFX_ERR_NONE {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-mfxinit",
status: status as i64,
});
}
if session.is_null() {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-mfxinit",
status: -1,
});
}
assert!(!session.is_null(), "session non-null after MFXInit");
Ok(session)
}
fn set_d3d11_handle(
api: &ApiTable,
session: *mut c_void,
device: &ID3D11Device,
) -> Result<(), EncoderError> {
assert!(!session.is_null(), "session non-null");
let raw = device.as_raw();
assert!(!raw.is_null(), "device raw non-null");
if let Ok(mt) = device.cast::<ID3D11Multithread>() {
let _ = unsafe { mt.SetMultithreadProtected(true) };
}
let status = unsafe { (api.set_handle)(session, MFX_HANDLE_D3D11_DEVICE, raw) };
if status != MFX_ERR_NONE && status != MFX_WRN_IN_EXECUTION {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-set-handle",
status: status as i64,
});
}
Ok(())
}
fn build_video_params(
dims: EncoderDims,
target_kbps: u16,
frame_rate: EncoderFrameRate,
) -> MfxVideoParam {
let info = MfxFrameInfo {
four_cc: MFX_FOURCC_NV12,
width: align16(dims.width as u16),
height: align16(dims.height as u16),
crop_w: dims.width as u16,
crop_h: dims.height as u16,
frame_rate_extn: frame_rate.numerator,
frame_rate_extd: frame_rate.denominator,
aspect_ratio_w: 1,
aspect_ratio_h: 1,
pic_struct: MFX_PICSTRUCT_PROGRESSIVE,
chroma_format: MFX_CHROMAFORMAT_YUV420,
..Default::default()
};
let mfx = MfxInfoMfx {
frame_info: info,
codec_id: MFX_CODEC_AVC,
target_usage: 4,
gop_pic_size: frame_rate.gop_pic_size(),
gop_ref_dist: 1,
rate_control_method: MFX_RATECONTROL_CBR,
target_kbps,
max_kbps: target_kbps,
num_slice: 1,
num_ref_frame: 1,
..Default::default()
};
MfxVideoParam {
alloc_id: 0,
reserved: [0; 2],
reserved3: 0,
async_depth: 1,
mfx,
protected: 0,
io_pattern: MFX_IOPATTERN_IN_VIDEO_MEMORY,
ext_param: ptr::null_mut(),
num_ext_param: 0,
reserved2: 0,
}
}
fn align16(v: u16) -> u16 {
(v + 15) & !15
}
fn encode_init(
api: &ApiTable,
session: *mut c_void,
dims: EncoderDims,
target_kbps: u16,
frame_rate: EncoderFrameRate,
) -> Result<(), EncoderError> {
let mut params = build_video_params(dims, target_kbps, frame_rate);
let mut query_out = build_video_params(dims, target_kbps, frame_rate);
let q_status = unsafe { (api.encode_query)(session, &mut params, &mut query_out) };
let q_ok = q_status == MFX_ERR_NONE
|| q_status == MFX_WRN_IN_EXECUTION
|| q_status == -3
|| q_status > 0;
if !q_ok {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-encode-query",
status: q_status as i64,
});
}
let status = unsafe { (api.encode_init)(session, &mut query_out) };
if status != MFX_ERR_NONE && status != MFX_WRN_IN_EXECUTION {
return Err(EncoderError::SessionInitFailed {
vendor: "qsv-encode-init",
status: status as i64,
});
}
Ok(())
}
fn build_slot_state(
shared_handle: u64,
dims: EncoderDims,
target_kbps: u16,
frame_rate: EncoderFrameRate,
) -> SlotState {
let max_bs = (dims.width as usize * dims.height as usize * 3 / 2).max(512 * 1024);
let mut bitstream_buf = vec![0u8; max_bs];
let bs_ptr = bitstream_buf.as_mut_ptr();
let bitstream = MfxBitstream {
encrypted_data: ptr::null_mut(),
num_extparam: 0,
ext_param: ptr::null_mut(),
reserved: [0; 6],
decode_time_stamp: 0,
time_stamp: 0,
data: bs_ptr,
data_offset: 0,
data_length: 0,
max_length: max_bs as u32,
pic_struct: 0,
frame_type: 0,
data_flag: 0,
reserved2: 0,
};
let info = MfxFrameInfo {
four_cc: MFX_FOURCC_NV12,
width: align16(dims.width as u16),
height: align16(dims.height as u16),
crop_w: dims.width as u16,
crop_h: dims.height as u16,
frame_rate_extn: frame_rate.numerator,
frame_rate_extd: frame_rate.denominator,
aspect_ratio_w: 1,
aspect_ratio_h: 1,
pic_struct: MFX_PICSTRUCT_PROGRESSIVE,
chroma_format: MFX_CHROMAFORMAT_YUV420,
..Default::default()
};
let _ = target_kbps;
let surface = MfxFrameSurface1 {
reserved: [0; 4],
interface_ptr: ptr::null_mut(),
info,
data: MfxFrameData {
ext_param: ptr::null_mut(),
num_extparam: 0,
reserved: [0; 8],
mem_type: 0,
pitch_high: 0,
time_stamp: 0,
frame_order: 0,
locked: 0,
pitch_low: 0,
plane_ptrs: [ptr::null_mut(); 7],
mem_id: shared_handle as *mut c_void,
corrupted: 0,
data_flag: 0,
},
};
SlotState {
pending_pts_us: 0,
pending_force_keyframe: false,
sync_point: ptr::null_mut(),
bitstream,
bitstream_buf,
surface,
in_flight: false,
}
}
impl Drop for QsvD3D11Handoff {
fn drop(&mut self) {
self.slots.clear();
if !self.session.is_null() {
let _ = unsafe { (self.api.encode_close)(self.session) };
let _ = unsafe { (self.api.close)(self.session) };
self.session = ptr::null_mut();
}
if let Some(d) = self.dispatcher.take()
&& !self.loader.is_null()
{
unsafe { (d.unload)(self.loader) };
self.loader = ptr::null_mut();
}
}
}
impl QsvHandoff for QsvD3D11Handoff {
fn register_slot(
&mut self,
shared_handle: u64,
_key: u64,
dims: EncoderDims,
) -> Result<HandoffSlot, EncoderError> {
assert!(shared_handle != 0, "shared_handle non-zero");
assert!(dims.width > 0, "width positive");
let slot_index = self.next_slot_index;
self.next_slot_index = self.next_slot_index.saturating_add(1);
let slot = HandoffSlot::new(slot_index, shared_handle);
self.slots.insert(
slot_index,
build_slot_state(shared_handle, dims, self.target_kbps, self.frame_rate),
);
assert!(self.slots.contains_key(&slot_index), "slot stored");
Ok(slot)
}
fn encode_shared_async(
&mut self,
slot: HandoffSlot,
_key: u64,
dims: EncoderDims,
pic_params: PicParams,
) -> Result<(), EncoderError> {
assert!(slot.shared_handle != 0, "slot handle non-zero");
assert!(dims.width > 0, "width positive");
let state = self
.slots
.get_mut(&slot.slot_index)
.ok_or(EncoderError::SlotUnknown {
slot_index: slot.slot_index,
})?;
state.surface.data.time_stamp = pic_params.pts_us;
state.bitstream.data_length = 0;
state.bitstream.data_offset = 0;
let mut sync: *mut c_void = ptr::null_mut();
let status = unsafe {
(self.api.encode_frame_async)(
self.session,
ptr::null_mut(),
&mut state.surface,
&mut state.bitstream,
&mut sync,
)
};
state.pending_pts_us = pic_params.pts_us;
state.pending_force_keyframe = pic_params.force_keyframe;
if status == MFX_ERR_MORE_DATA {
state.in_flight = false;
return Ok(());
}
if status != MFX_ERR_NONE {
return Err(EncoderError::EncodeFailed {
vendor: "qsv",
status: status as i64,
});
}
state.sync_point = sync;
state.in_flight = !sync.is_null();
Ok(())
}
fn poll_completed(&mut self, slot: HandoffSlot) -> Option<EncodedBitstream> {
let session_ptr = self.session;
let dts_offset_us = self.dts_offset_us;
let completed_count = self.completed_count;
let state = self.slots.get_mut(&slot.slot_index)?;
if !state.in_flight || state.sync_point.is_null() {
return None;
}
let status = unsafe { (self.api.sync_operation)(session_ptr, state.sync_point, 0) };
if status == MFX_WRN_IN_EXECUTION || status != MFX_ERR_NONE {
return None;
}
let len = state.bitstream.data_length as usize;
if len == 0 {
state.in_flight = false;
return None;
}
let mut data: Vec<u8> = Vec::with_capacity(len);
let offset = state.bitstream.data_offset as usize;
data.extend_from_slice(&state.bitstream_buf[offset..offset + len]);
let pts = state.bitstream.time_stamp;
let is_keyframe = (state.bitstream.frame_type & 0x1) != 0 || completed_count == 0;
let dts = apply_dts_offset(pts, dts_offset_us);
state.in_flight = false;
state.sync_point = ptr::null_mut();
self.completed_count = self.completed_count.saturating_add(1);
Some(EncodedBitstream::new(data, pts, dts, is_keyframe))
}
fn unregister_slot(&mut self, slot: HandoffSlot) {
self.slots.remove(&slot.slot_index);
}
fn encode_shared(
&mut self,
submission: EncoderSubmission,
callback: &mut dyn EncoderCompletionCallback,
) -> Result<(), RingError> {
assert!(submission.shared_handle != 0, "submission handle non-zero");
assert!(submission.dims.width > 0, "submission width positive");
let slot = self
.register_slot(
submission.shared_handle,
submission.keyed_mutex_key,
submission.dims,
)
.map_err(|_| RingError::NotImplemented {
what: "qsv::register_slot in encode_shared",
})?;
let pts_us = submission.capture_pts_us.unwrap_or_else(|| {
submission
.sequence
.saturating_mul(self.frame_rate.frame_interval_us())
});
let pic = PicParams::new(pts_us, false);
QsvHandoff::encode_shared_async(
self,
slot,
submission.keyed_mutex_key,
submission.dims,
pic,
)
.map_err(|_| RingError::NotImplemented {
what: "qsv::encode_shared_async",
})?;
if let Some(bs) = QsvHandoff::poll_completed(self, slot) {
callback.on_complete(submission.sequence, bs.data.len() as u32);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sdk_not_found_when_dll_missing() {
let dummy_path = "/this/path/does/not/exist/fake-libmfxhw64.dll";
let result = unsafe { Library::new(dummy_path) };
assert!(result.is_err());
}
#[test]
fn fourcc_nv12_packs_correctly() {
assert_eq!(MFX_FOURCC_NV12, 0x3231564E);
}
#[test]
fn align16_rounds_up() {
assert_eq!(align16(1080), 1088);
assert_eq!(align16(1920), 1920);
assert_eq!(align16(0), 0);
}
#[test]
fn target_kbps_clamps_against_overflow() {
let bitrate: u32 = u32::MAX;
let kbps = (bitrate / 1000).clamp(500, 60_000) as u16;
assert_eq!(kbps, 60_000);
}
#[test]
fn accel_mode_constant_matches_onevpl_spec() {
assert_eq!(MFX_ACCEL_MODE_VIA_D3D11, 0x0300);
assert_ne!(MFX_ACCEL_MODE_VIA_D3D11, 0x0200);
}
#[test]
fn impl_type_hardware_constant_matches_onevpl_spec() {
assert_eq!(MFX_IMPL_TYPE_HARDWARE, 2);
}
#[test]
fn variant_type_u32_constant_matches_onevpl_spec() {
assert_eq!(MFX_VARIANT_TYPE_U32, 5);
assert_ne!(MFX_VARIANT_TYPE_U32, 8);
}
#[test]
fn d3d11_device_handle_type_matches_onevpl_spec() {
assert_eq!(MFX_HANDLE_D3D11_DEVICE, 3);
assert_ne!(MFX_HANDLE_D3D11_DEVICE, 2);
}
#[test]
fn variant_version_matches_onevpl_spec() {
assert_eq!(MFX_VARIANT_VERSION_MAJOR, 1);
assert_eq!(MFX_VARIANT_VERSION_MINOR, 1);
}
#[test]
fn filter_property_name_is_null_terminated_accel() {
assert!(FILTER_PROPERTY_ACCEL.ends_with(b"\0"));
let view = &FILTER_PROPERTY_ACCEL[..FILTER_PROPERTY_ACCEL.len() - 1];
assert_eq!(view, b"mfxImplDescription.AccelerationMode");
}
#[test]
fn filter_property_name_is_null_terminated_impl() {
assert!(FILTER_PROPERTY_IMPL.ends_with(b"\0"));
let view = &FILTER_PROPERTY_IMPL[..FILTER_PROPERTY_IMPL.len() - 1];
assert_eq!(view, b"mfxImplDescription.Impl");
}
#[test]
fn variant_payload_carries_u32_value() {
let v = MfxVariant {
version: MfxStructVersion {
minor: MFX_VARIANT_VERSION_MINOR,
major: MFX_VARIANT_VERSION_MAJOR,
},
type_: MFX_VARIANT_TYPE_U32,
data: MfxVariantData {
u32_: MFX_ACCEL_MODE_VIA_D3D11,
},
};
let read = unsafe { v.data.u32_ };
assert_eq!(read, MFX_ACCEL_MODE_VIA_D3D11);
assert_eq!(v.type_, 5);
}
#[derive(Default)]
struct FilterRecord {
property: Vec<u8>,
value_u32: u32,
variant_type: u32,
}
#[test]
fn mock_set_filter_records_property_and_value() {
let mut record = FilterRecord::default();
let property = FILTER_PROPERTY_ACCEL;
let variant = MfxVariant {
version: MfxStructVersion {
minor: MFX_VARIANT_VERSION_MINOR,
major: MFX_VARIANT_VERSION_MAJOR,
},
type_: MFX_VARIANT_TYPE_U32,
data: MfxVariantData {
u32_: MFX_ACCEL_MODE_VIA_D3D11,
},
};
let mut len: usize = 0;
while *property.get(len).unwrap_or(&1) != 0 {
len += 1;
}
record.property.extend_from_slice(&property[..len]);
record.variant_type = variant.type_;
record.value_u32 = unsafe { variant.data.u32_ };
assert_eq!(record.property, b"mfxImplDescription.AccelerationMode");
assert_eq!(record.variant_type, MFX_VARIANT_TYPE_U32);
assert_eq!(record.value_u32, MFX_ACCEL_MODE_VIA_D3D11);
assert_eq!(record.value_u32, 0x0300);
}
}
@@ -0,0 +1,966 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::backend::{BackendError, KeyedMutexBackend, TextureFormat};
pub const RING_SIZE: usize = 8;
pub const DUPLICATE_COUNT_MAX: u32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlotState {
Free,
Filling,
Submitted,
Dispatched,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RingError {
FullDropped {
dropped_so_far: u64,
},
BackendFailed {
source: BackendError,
},
NotInitialised,
AlreadyInitialised,
UnknownSlot,
UnexpectedSlotState {
slot_index: u32,
observed: SlotState,
},
PlatformUnsupported {
reason: &'static str,
},
NotImplemented {
what: &'static str,
},
SlotsExhausted,
}
impl std::fmt::Display for RingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FullDropped { dropped_so_far } => {
write!(f, "ring full; total dropped={dropped_so_far}")
}
Self::BackendFailed { source } => write!(f, "backend failed: {source}"),
Self::NotInitialised => write!(f, "ring not initialised"),
Self::AlreadyInitialised => write!(f, "ring already initialised"),
Self::UnknownSlot => write!(f, "slot handle does not belong to this ring"),
Self::UnexpectedSlotState {
slot_index,
observed,
} => {
write!(f, "slot {slot_index} in unexpected state {observed:?}")
}
Self::PlatformUnsupported { reason } => write!(f, "platform unsupported: {reason}"),
Self::NotImplemented { what } => write!(f, "not implemented: {what}"),
Self::SlotsExhausted => write!(f, "slot id space exhausted (all candidates in use)"),
}
}
}
impl std::error::Error for RingError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RingMetrics {
pub submitted_count: u64,
pub completed_count: u64,
pub dropped_count: u64,
pub dispatched_count: u64,
pub lagged_count: u64,
}
pub struct EncoderReady<H: Clone> {
pub handle: H,
pub sequence: u64,
pub slot_index: u32,
pub duplicate_count: u32,
}
pub struct FillReservation<H: Clone> {
pub handle: H,
slot_index: u32,
key: u64,
}
impl<H: Clone> FillReservation<H> {
pub fn slot_index(&self) -> u32 {
let index = self.slot_index;
assert!((index as usize) < RING_SIZE_MAX, "slot_index within max");
assert!(self.key < u64::MAX, "reservation key plausible");
index
}
}
struct SlotMeta {
state: SlotState,
sequence: u64,
key: u64,
duplicate_count: u32,
}
impl SlotMeta {
const fn fresh() -> Self {
Self {
state: SlotState::Free,
sequence: 0,
key: 0,
duplicate_count: 0,
}
}
}
pub struct EncoderInputRing<B: KeyedMutexBackend> {
backend: B,
slots: Vec<B::SlotHandle>,
meta: Vec<SlotMeta>,
metrics: RingMetrics,
pending_lagged: u32,
initialised: bool,
width: u32,
height: u32,
format: TextureFormat,
}
impl<B: KeyedMutexBackend> EncoderInputRing<B> {
pub fn new(backend: B) -> Self {
let ring = Self {
backend,
slots: Vec::with_capacity(B::NUM_SLOTS),
meta: Vec::with_capacity(B::NUM_SLOTS),
metrics: RingMetrics::default(),
pending_lagged: 0,
initialised: false,
width: 0,
height: 0,
format: TextureFormat::Nv12,
};
assert!(!ring.initialised, "fresh ring is uninitialised");
assert_eq!(ring.slots.len(), 0, "fresh ring has no slots");
ring
}
pub fn initialise(
&mut self,
width: u32,
height: u32,
format: TextureFormat,
) -> Result<(), RingError> {
if self.initialised {
return Err(RingError::AlreadyInitialised);
}
let handles = self
.backend
.create_slots(width, height, format)
.map_err(|source| RingError::BackendFailed { source })?;
assert_eq!(
handles.len(),
B::NUM_SLOTS,
"backend returns NUM_SLOTS handles"
);
assert!(handles.len() <= RING_SIZE_MAX, "NUM_SLOTS within max");
self.slots = handles;
self.meta = (0..B::NUM_SLOTS).map(|_| SlotMeta::fresh()).collect();
self.width = width;
self.height = height;
self.format = format;
self.initialised = true;
assert!(self.initialised, "initialised flipped");
assert_eq!(self.slots.len(), self.meta.len(), "slots and meta align");
Ok(())
}
pub fn submit<F>(&mut self, fill: F) -> Result<(), RingError>
where
F: FnOnce(&mut B::SlotHandle),
{
let mut reservation = self.reserve()?;
fill(&mut reservation.handle);
let sequence = self.commit(reservation)?;
assert!(sequence > 0, "committed sequence positive");
assert!(
self.metrics.submitted_count >= sequence,
"monotonic submitted"
);
Ok(())
}
pub fn submit_skip_oldest<F>(&mut self, fill: F) -> Result<(), RingError>
where
F: FnOnce(&mut B::SlotHandle),
{
let mut reservation = self.reserve_skip_oldest()?;
fill(&mut reservation.handle);
let sequence = self.commit(reservation)?;
assert!(sequence > 0, "skip-oldest: committed sequence positive");
assert!(
self.metrics.submitted_count >= sequence,
"skip-oldest: monotonic submitted"
);
Ok(())
}
pub fn reserve(&mut self) -> Result<FillReservation<B::SlotHandle>, RingError> {
if !self.initialised {
return Err(RingError::NotInitialised);
}
self.acquire_free_slot()
}
pub fn reserve_skip_oldest(&mut self) -> Result<FillReservation<B::SlotHandle>, RingError> {
if !self.initialised {
return Err(RingError::NotInitialised);
}
if self.find_free_slot().is_none() {
let _ = self.evict_oldest_submitted();
}
self.acquire_free_slot()
}
fn acquire_free_slot(&mut self) -> Result<FillReservation<B::SlotHandle>, RingError> {
assert!(self.initialised, "acquire requires initialised ring");
assert_eq!(self.slots.len(), self.meta.len(), "slots and meta align");
for index in 0..self.meta.len() {
if self.meta[index].state != SlotState::Free {
continue;
}
let key = self.meta[index].key;
match self.backend.acquire_write(&self.slots[index], key) {
Ok(()) => {
self.meta[index].state = SlotState::Filling;
return Ok(FillReservation {
handle: self.slots[index].clone(),
slot_index: index as u32,
key,
});
}
Err(BackendError::WouldBlock { .. }) => continue,
Err(source) => return Err(RingError::BackendFailed { source }),
}
}
self.fold_lagged(1);
self.metrics.lagged_count = self.metrics.lagged_count.saturating_add(1);
self.metrics.dropped_count = self.metrics.dropped_count.saturating_add(1);
Err(RingError::FullDropped {
dropped_so_far: self.metrics.dropped_count,
})
}
pub fn commit(
&mut self,
reservation: FillReservation<B::SlotHandle>,
) -> Result<u64, RingError> {
if !self.initialised {
return Err(RingError::NotInitialised);
}
let index = reservation.slot_index as usize;
if index >= self.meta.len() {
return Err(RingError::UnknownSlot);
}
let observed = self.meta[index].state;
if observed != SlotState::Filling {
return Err(RingError::UnexpectedSlotState {
slot_index: reservation.slot_index,
observed,
});
}
assert_eq!(
self.meta[index].key, reservation.key,
"reservation key matches slot"
);
let next_key = reservation.key.wrapping_add(1);
self.backend
.release_write(&self.slots[index], next_key)
.map_err(|source| RingError::BackendFailed { source })?;
let sequence = self.metrics.submitted_count.saturating_add(1);
self.meta[index].state = SlotState::Submitted;
self.meta[index].sequence = sequence;
self.meta[index].key = next_key;
self.meta[index].duplicate_count = self.pending_lagged;
self.pending_lagged = 0;
self.metrics.submitted_count = sequence;
self.metrics.completed_count = self.metrics.completed_count.saturating_add(1);
assert_eq!(
self.meta[index].state,
SlotState::Submitted,
"post-commit submitted"
);
assert!(
self.metrics.submitted_count >= sequence,
"commit: monotonic submitted"
);
Ok(sequence)
}
pub fn cancel(&mut self, reservation: FillReservation<B::SlotHandle>) -> Result<(), RingError> {
if !self.initialised {
return Err(RingError::NotInitialised);
}
let index = reservation.slot_index as usize;
if index >= self.meta.len() {
return Err(RingError::UnknownSlot);
}
let observed = self.meta[index].state;
if observed != SlotState::Filling {
return Err(RingError::UnexpectedSlotState {
slot_index: reservation.slot_index,
observed,
});
}
assert_eq!(
self.meta[index].key, reservation.key,
"cancel: reservation key matches slot"
);
let next_key = reservation.key.wrapping_add(1);
self.backend
.release_write(&self.slots[index], next_key)
.map_err(|source| RingError::BackendFailed { source })?;
self.backend.mark_consumed(&self.slots[index]);
self.meta[index].state = SlotState::Free;
self.meta[index].key = next_key;
assert_eq!(self.meta[index].state, SlotState::Free, "post-cancel free");
assert!(
!self.backend.poll_complete(&self.slots[index]),
"cancelled slot not complete"
);
Ok(())
}
fn fold_lagged(&mut self, amount: u32) {
assert!(amount > 0, "fold amount positive");
assert!(
amount <= DUPLICATE_COUNT_MAX.saturating_add(1),
"fold amount bounded"
);
let mut newest: Option<usize> = None;
let mut newest_sequence: u64 = 0;
for (i, meta) in self.meta.iter().enumerate() {
if meta.state != SlotState::Submitted {
continue;
}
if meta.sequence >= newest_sequence {
newest_sequence = meta.sequence;
newest = Some(i);
}
}
match newest {
Some(index) => {
let total = self.meta[index].duplicate_count.saturating_add(amount);
self.meta[index].duplicate_count = total.min(DUPLICATE_COUNT_MAX);
}
None => {
let total = self.pending_lagged.saturating_add(amount);
self.pending_lagged = total.min(DUPLICATE_COUNT_MAX);
}
}
assert!(
self.pending_lagged <= DUPLICATE_COUNT_MAX,
"pending lag bounded"
);
}
fn evict_oldest_submitted(&mut self) -> bool {
let mut chosen: Option<usize> = None;
let mut chosen_sequence: u64 = u64::MAX;
for (i, meta) in self.meta.iter().enumerate() {
if meta.state != SlotState::Submitted {
continue;
}
if meta.sequence < chosen_sequence {
chosen_sequence = meta.sequence;
chosen = Some(i);
}
}
let Some(index) = chosen else {
return false;
};
assert_eq!(
self.meta[index].state,
SlotState::Submitted,
"evict candidate is submitted"
);
assert!(
chosen_sequence != u64::MAX,
"evict candidate had real sequence"
);
let folded = self.meta[index].duplicate_count.saturating_add(1);
self.backend.mark_consumed(&self.slots[index]);
self.meta[index].state = SlotState::Free;
self.meta[index].duplicate_count = 0;
self.fold_lagged(folded);
self.metrics.lagged_count = self.metrics.lagged_count.saturating_add(1);
self.metrics.dropped_count = self.metrics.dropped_count.saturating_add(1);
true
}
pub fn poll_next_ready(&mut self) -> Option<EncoderReady<B::SlotHandle>> {
if !self.initialised {
return None;
}
let mut chosen: Option<usize> = None;
let mut chosen_sequence: u64 = u64::MAX;
for i in 0..self.meta.len() {
if self.meta[i].state != SlotState::Submitted {
continue;
}
if !self.backend.poll_complete(&self.slots[i]) {
continue;
}
if self.meta[i].sequence < chosen_sequence {
chosen_sequence = self.meta[i].sequence;
chosen = Some(i);
}
}
let index = chosen?;
assert_eq!(
self.meta[index].state,
SlotState::Submitted,
"ready slot was submitted"
);
assert!(
self.backend.poll_complete(&self.slots[index]),
"ready slot is complete"
);
self.meta[index].state = SlotState::Dispatched;
self.metrics.dispatched_count = self.metrics.dispatched_count.saturating_add(1);
Some(EncoderReady {
handle: self.slots[index].clone(),
sequence: self.meta[index].sequence,
slot_index: index as u32,
duplicate_count: self.meta[index].duplicate_count,
})
}
pub fn release_completed(
&mut self,
ready: EncoderReady<B::SlotHandle>,
) -> Result<(), RingError> {
if !self.initialised {
return Err(RingError::NotInitialised);
}
let index = ready.slot_index as usize;
if index >= self.meta.len() {
return Err(RingError::UnknownSlot);
}
let observed = self.meta[index].state;
if observed != SlotState::Dispatched {
return Err(RingError::UnexpectedSlotState {
slot_index: ready.slot_index,
observed,
});
}
self.backend.mark_consumed(&self.slots[index]);
self.meta[index].state = SlotState::Free;
assert_eq!(self.meta[index].state, SlotState::Free, "post-release free");
assert!(
!self.backend.poll_complete(&self.slots[index]),
"no longer reports complete"
);
Ok(())
}
pub fn metrics(&self) -> RingMetrics {
assert!(
self.metrics.completed_count <= self.metrics.submitted_count,
"complete<=submit"
);
assert!(
self.metrics.dispatched_count <= self.metrics.completed_count,
"dispatch<=complete"
);
self.metrics
}
pub fn submitted_count(&self) -> u64 {
self.metrics.submitted_count
}
pub fn completed_count(&self) -> u64 {
self.metrics.completed_count
}
pub fn dropped_count(&self) -> u64 {
self.metrics.dropped_count
}
pub fn dispatched_count(&self) -> u64 {
self.metrics.dispatched_count
}
pub fn capacity(&self) -> usize {
let cap = B::NUM_SLOTS;
assert!(cap > 0, "NUM_SLOTS must be positive");
assert!(cap <= RING_SIZE_MAX, "NUM_SLOTS within max");
cap
}
pub fn free_count(&self) -> usize {
let mut count: usize = 0;
for meta in self.meta.iter() {
if meta.state == SlotState::Free {
count = count.saturating_add(1);
}
}
assert!(count <= self.meta.len(), "free count within capacity");
count
}
pub fn backend_mut(&mut self) -> &mut B {
assert!(self.initialised, "backend access requires init");
&mut self.backend
}
fn find_free_slot(&self) -> Option<usize> {
for (i, meta) in self.meta.iter().enumerate() {
if meta.state == SlotState::Free {
return Some(i);
}
}
None
}
}
pub const RING_SIZE_MAX: usize = 16;
const _: () = assert!(RING_SIZE <= RING_SIZE_MAX, "RING_SIZE within max");
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::{CpuMemcpyBackend, CpuSlotHandle};
fn make_ring() -> EncoderInputRing<CpuMemcpyBackend> {
let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new());
ring.initialise(64, 64, TextureFormat::Nv12).expect("init");
ring
}
fn fill_noop(_: &mut CpuSlotHandle) {}
#[test]
fn submit_then_poll_returns_some() {
let mut ring = make_ring();
ring.submit(fill_noop).expect("submit");
let ready = ring.poll_next_ready().expect("poll yields ready");
assert_eq!(ready.sequence, 1);
ring.release_completed(ready).expect("release");
}
#[test]
fn eight_submits_dispatch_in_fifo_order() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
assert_eq!(ring.submitted_count(), 8);
let mut observed_seq: Vec<u64> = Vec::new();
for _ in 0..8 {
let ready = ring.poll_next_ready().expect("ready");
observed_seq.push(ready.sequence);
ring.release_completed(ready).expect("release");
}
assert_eq!(observed_seq, vec![1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn ninth_submit_when_full_returns_full_dropped() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let err = ring.submit(fill_noop).err();
assert!(matches!(
err,
Some(RingError::FullDropped { dropped_so_far: 1 })
));
assert_eq!(ring.dropped_count(), 1);
let err2 = ring.submit(fill_noop).err();
assert!(matches!(
err2,
Some(RingError::FullDropped { dropped_so_far: 2 })
));
assert_eq!(ring.dropped_count(), 2);
}
#[test]
fn release_completed_returns_slot_to_pool() {
let mut ring = make_ring();
ring.submit(fill_noop).expect("submit");
let ready = ring.poll_next_ready().expect("ready");
assert_eq!(ring.free_count(), 7);
ring.release_completed(ready).expect("release");
assert_eq!(ring.free_count(), 8);
ring.submit(fill_noop).expect("re-submit after release");
}
#[test]
fn pair_asserts_pass_under_random_submit_poll_release() {
let mut ring = make_ring();
let mut state: u64 = 0xcafef00d;
let mut in_flight: Vec<EncoderReady<CpuSlotHandle>> = Vec::new();
for _ in 0..1000 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let action = state % 3;
match action {
0 => {
let _ = ring.submit(fill_noop);
}
1 => {
if let Some(r) = ring.poll_next_ready() {
in_flight.push(r);
}
}
_ => {
if let Some(r) = in_flight.pop() {
ring.release_completed(r).expect("release");
}
}
}
let metrics = ring.metrics();
assert!(metrics.completed_count <= metrics.submitted_count);
assert!(metrics.dispatched_count <= metrics.completed_count);
}
while let Some(r) = in_flight.pop() {
ring.release_completed(r).expect("drain release");
}
while let Some(r) = ring.poll_next_ready() {
ring.release_completed(r).expect("drain release post poll");
}
assert_eq!(ring.free_count(), ring.capacity());
}
#[test]
#[allow(clippy::panic)]
fn submit_with_panicking_closure_leaves_state_consistent() {
use std::panic::{AssertUnwindSafe, catch_unwind};
let mut ring = make_ring();
ring.submit(fill_noop).expect("first submit ok");
let result = catch_unwind(AssertUnwindSafe(|| {
let _ = ring.submit(|_h: &mut CpuSlotHandle| panic!("user fill panicked"));
}));
assert!(result.is_err());
let metrics = ring.metrics();
assert!(metrics.submitted_count >= 1);
assert!(metrics.completed_count <= metrics.submitted_count);
assert!(metrics.dispatched_count <= metrics.completed_count);
let ready = ring.poll_next_ready().expect("first frame still pollable");
assert_eq!(ready.sequence, 1);
ring.release_completed(ready).expect("release first");
}
#[test]
fn determinism_same_sequence_yields_same_release_order() {
fn run() -> Vec<u64> {
let mut ring = make_ring();
let mut released: Vec<u64> = Vec::new();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
for _ in 0..8 {
let r = ring.poll_next_ready().expect("ready");
released.push(r.sequence);
ring.release_completed(r).expect("release");
}
released
}
let a = run();
let b = run();
assert_eq!(a, b);
assert_eq!(a, vec![1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn capacity_is_eight() {
let ring = make_ring();
assert_eq!(ring.capacity(), 8);
assert_eq!(ring.free_count(), 8);
}
#[test]
fn double_release_of_same_ready_rejected() {
let mut ring = make_ring();
ring.submit(fill_noop).expect("submit");
let ready = ring.poll_next_ready().expect("ready");
let cloned = EncoderReady {
handle: ready.handle.clone(),
sequence: ready.sequence,
slot_index: ready.slot_index,
duplicate_count: ready.duplicate_count,
};
ring.release_completed(ready).expect("first release");
let err = ring.release_completed(cloned).err();
assert!(matches!(err, Some(RingError::UnexpectedSlotState { .. })));
}
#[test]
fn submit_before_init_returns_not_initialised() {
let mut ring: EncoderInputRing<CpuMemcpyBackend> =
EncoderInputRing::new(CpuMemcpyBackend::new());
let err = ring.submit(fill_noop).err();
assert!(matches!(err, Some(RingError::NotInitialised)));
}
#[test]
fn re_initialise_rejected() {
let mut ring = make_ring();
let err = ring.initialise(64, 64, TextureFormat::Nv12).err();
assert!(matches!(err, Some(RingError::AlreadyInitialised)));
}
#[test]
fn poll_when_empty_returns_none() {
let mut ring = make_ring();
assert!(ring.poll_next_ready().is_none());
}
#[test]
fn skip_oldest_rejects_when_every_slot_is_dispatched() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let mut in_flight: Vec<EncoderReady<CpuSlotHandle>> = Vec::new();
for _ in 0..8 {
in_flight.push(ring.poll_next_ready().expect("ready"));
}
assert_eq!(ring.free_count(), 0);
let err = ring.submit_skip_oldest(fill_noop).err();
assert!(matches!(
err,
Some(RingError::FullDropped { dropped_so_far: 1 })
));
assert_eq!(ring.dropped_count(), 1);
assert_eq!(ring.dispatched_count(), 8);
for ready in in_flight.drain(..) {
ring.release_completed(ready).expect("release");
}
assert_eq!(ring.free_count(), 8);
ring.submit_skip_oldest(fill_noop)
.expect("submit succeeds once dispatched slots are released");
}
#[test]
fn skip_oldest_evicts_oldest_submitted_never_dispatched() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let mut dispatched: Vec<EncoderReady<CpuSlotHandle>> = Vec::new();
for _ in 0..3 {
dispatched.push(ring.poll_next_ready().expect("ready"));
}
assert_eq!(dispatched[0].sequence, 1);
assert_eq!(dispatched[2].sequence, 3);
ring.submit_skip_oldest(fill_noop)
.expect("skip-oldest evicts a submitted slot");
assert_eq!(ring.dropped_count(), 1);
assert_eq!(ring.submitted_count(), 9);
let mut remaining: Vec<u64> = Vec::new();
while let Some(ready) = ring.poll_next_ready() {
remaining.push(ready.sequence);
ring.release_completed(ready).expect("release");
}
assert_eq!(remaining, vec![5, 6, 7, 8, 9], "sequence 4 was evicted");
for ready in dispatched.drain(..) {
ring.release_completed(ready).expect("release dispatched");
}
assert_eq!(ring.free_count(), 8);
}
#[test]
fn reserve_then_commit_matches_submit_semantics() {
let mut ring = make_ring();
let reservation = ring.reserve().expect("reserve");
assert_eq!(reservation.slot_index(), 0);
assert_eq!(ring.free_count(), 7);
assert_eq!(ring.submitted_count(), 0, "sequence assigned at commit");
let sequence = ring.commit(reservation).expect("commit");
assert_eq!(sequence, 1);
let ready = ring.poll_next_ready().expect("ready");
assert_eq!(ready.sequence, 1);
assert_eq!(ready.duplicate_count, 0);
ring.release_completed(ready).expect("release");
}
#[test]
fn cancel_returns_slot_to_free_without_sequence() {
let mut ring = make_ring();
let reservation = ring.reserve().expect("reserve");
ring.cancel(reservation).expect("cancel");
assert_eq!(ring.free_count(), 8);
assert_eq!(ring.submitted_count(), 0);
assert!(ring.poll_next_ready().is_none());
ring.submit(fill_noop).expect("submit after cancel");
let ready = ring.poll_next_ready().expect("ready");
assert_eq!(ready.sequence, 1);
ring.release_completed(ready).expect("release");
}
#[test]
fn commit_of_freed_reservation_rejected() {
let mut ring = make_ring();
let first = ring.reserve().expect("reserve");
let index = first.slot_index();
ring.cancel(first).expect("cancel");
let second = ring.reserve().expect("re-reserve");
assert_eq!(second.slot_index(), index, "same slot reused");
ring.commit(second).expect("commit reused slot");
let ready = ring.poll_next_ready().expect("ready");
let stale = EncoderReady {
handle: ready.handle.clone(),
sequence: ready.sequence,
slot_index: ready.slot_index,
duplicate_count: ready.duplicate_count,
};
ring.release_completed(ready).expect("release");
let err = ring.release_completed(stale).err();
assert!(matches!(err, Some(RingError::UnexpectedSlotState { .. })));
}
#[test]
fn skip_oldest_never_evicts_filling_slot() {
let mut ring = make_ring();
for _ in 0..7 {
ring.submit(fill_noop).expect("submit");
}
let reservation = ring.reserve().expect("reserve eighth slot");
let reserved_index = reservation.slot_index();
ring.submit_skip_oldest(fill_noop)
.expect("skip-oldest evicts a submitted slot");
assert_eq!(ring.dropped_count(), 1);
let sequence = ring.commit(reservation).expect("commit survives eviction");
assert_eq!(sequence, 9);
let mut seen_indices: Vec<u32> = Vec::new();
while let Some(ready) = ring.poll_next_ready() {
seen_indices.push(ready.slot_index);
ring.release_completed(ready).expect("release");
}
assert!(seen_indices.contains(&reserved_index));
}
#[test]
fn full_ring_submit_folds_lag_into_newest_submitted() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let err = ring.submit(fill_noop).err();
assert!(matches!(
err,
Some(RingError::FullDropped { dropped_so_far: 1 })
));
assert_eq!(ring.metrics().lagged_count, 1);
let mut by_sequence: Vec<(u64, u32)> = Vec::new();
while let Some(ready) = ring.poll_next_ready() {
by_sequence.push((ready.sequence, ready.duplicate_count));
ring.release_completed(ready).expect("release");
}
assert_eq!(by_sequence.len(), 8);
for (sequence, duplicate_count) in by_sequence.iter().take(7) {
assert_eq!(*duplicate_count, 0, "sequence {sequence} not duplicated");
}
assert_eq!(by_sequence[7], (8, 1), "newest carries the lagged frame");
}
#[test]
fn eviction_conserves_duplicate_timing_slots() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let mut dispatched: Vec<EncoderReady<CpuSlotHandle>> = Vec::new();
for _ in 0..7 {
dispatched.push(ring.poll_next_ready().expect("ready"));
}
let _ = ring.submit(fill_noop).err();
ring.submit_skip_oldest(fill_noop)
.expect("evicts the only submitted slot");
let ready = ring.poll_next_ready().expect("new frame ready");
assert_eq!(ready.sequence, 9);
assert_eq!(
ready.duplicate_count, 2,
"evicted frame plus its duplicate folded into successor"
);
assert_eq!(ring.metrics().lagged_count, 2);
ring.release_completed(ready).expect("release");
for ready in dispatched.drain(..) {
ring.release_completed(ready).expect("release dispatched");
}
}
#[test]
fn all_dispatched_lag_attaches_to_next_submission() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let mut in_flight: Vec<EncoderReady<CpuSlotHandle>> = Vec::new();
for _ in 0..8 {
in_flight.push(ring.poll_next_ready().expect("ready"));
}
for _ in 0..2 {
let err = ring.submit_skip_oldest(fill_noop).err();
assert!(matches!(err, Some(RingError::FullDropped { .. })));
}
assert_eq!(ring.metrics().lagged_count, 2);
let first = in_flight.remove(0);
ring.release_completed(first).expect("release one");
ring.submit_skip_oldest(fill_noop)
.expect("submit after free");
let ready = ring.poll_next_ready().expect("ready");
assert_eq!(ready.sequence, 9);
assert_eq!(ready.duplicate_count, 2, "pending lag attached");
ring.release_completed(ready).expect("release");
for ready in in_flight.drain(..) {
ring.release_completed(ready).expect("release in flight");
}
}
#[test]
fn duplicate_count_saturates_at_named_cap() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
for _ in 0..(DUPLICATE_COUNT_MAX + 5) {
let _ = ring.submit(fill_noop).err();
}
let mut last: Option<(u64, u32)> = None;
while let Some(ready) = ring.poll_next_ready() {
last = Some((ready.sequence, ready.duplicate_count));
ring.release_completed(ready).expect("release");
}
assert_eq!(
last,
Some((8, DUPLICATE_COUNT_MAX)),
"duplicates saturate at cap"
);
}
#[test]
fn skip_oldest_repeated_rejection_counts_every_drop() {
let mut ring = make_ring();
for _ in 0..8 {
ring.submit(fill_noop).expect("submit");
}
let mut in_flight: Vec<EncoderReady<CpuSlotHandle>> = Vec::new();
for _ in 0..8 {
in_flight.push(ring.poll_next_ready().expect("ready"));
}
for expected_drops in 1..=3_u64 {
let err = ring.submit_skip_oldest(fill_noop).err();
assert!(matches!(
err,
Some(RingError::FullDropped { dropped_so_far }) if dropped_so_far == expected_drops
));
}
assert_eq!(ring.dropped_count(), 3);
while let Some(ready) = in_flight.pop() {
ring.release_completed(ready).expect("release");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_encoder_ring::{
CpuMemcpyBackend, CpuSlotHandle, EncoderInputRing, RING_SIZE, TextureFormat,
};
fn fill_noop(_: &mut CpuSlotHandle) {}
#[test]
fn submit_skip_oldest_drops_first_half_when_pushed_twice_capacity() {
let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new());
ring.initialise(64, 64, TextureFormat::Nv12)
.expect("init ring");
let capacity = ring.capacity();
assert_eq!(capacity, RING_SIZE);
assert_eq!(capacity, 8);
let total_pushes: u64 = 2 * capacity as u64;
for _ in 0..total_pushes {
ring.submit_skip_oldest(fill_noop)
.expect("skip-oldest accepts every push");
}
assert_eq!(ring.submitted_count(), total_pushes);
assert_eq!(ring.dropped_count(), capacity as u64);
assert_eq!(ring.free_count(), 0);
let mut observed: Vec<u64> = Vec::with_capacity(capacity);
for _ in 0..capacity {
let ready = ring.poll_next_ready().expect("ready");
observed.push(ready.sequence);
ring.release_completed(ready).expect("release");
}
let expected: Vec<u64> = (capacity as u64 + 1..=total_pushes).collect();
assert_eq!(
observed, expected,
"skip-don't-block kept newest {capacity} frames"
);
assert_eq!(ring.free_count(), capacity);
assert!(ring.poll_next_ready().is_none());
}
#[test]
fn submit_skip_oldest_succeeds_when_room_available() {
let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new());
ring.initialise(64, 64, TextureFormat::Nv12)
.expect("init ring");
for _ in 0..3 {
ring.submit_skip_oldest(fill_noop).expect("submit");
}
assert_eq!(ring.dropped_count(), 0);
assert_eq!(ring.submitted_count(), 3);
assert_eq!(ring.free_count(), ring.capacity() - 3);
}
#[test]
fn submit_skip_oldest_keeps_metrics_consistent() {
let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new());
ring.initialise(64, 64, TextureFormat::Nv12)
.expect("init ring");
for _ in 0..(3 * ring.capacity() as u64) {
ring.submit_skip_oldest(fill_noop).expect("submit");
}
let metrics = ring.metrics();
assert_eq!(metrics.submitted_count, 3 * ring.capacity() as u64);
assert_eq!(metrics.dropped_count, 2 * ring.capacity() as u64);
assert!(metrics.completed_count <= metrics.submitted_count);
}
@@ -0,0 +1,727 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![cfg(target_os = "windows")]
use std::ffi::c_void;
use std::ptr;
use libloading::{Library, Symbol};
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0};
use windows::Win32::Graphics::Direct3D11::{
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread,
};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1};
use windows::core::Interface;
const VENDOR_INTEL: u32 = 0x8086;
const MFX_IMPL_TYPE_HARDWARE: u32 = 2;
const MFX_ACCEL_MODE_NA: u32 = 0;
const MFX_ACCEL_MODE_VIA_D3D11: u32 = 0x0300;
const MFX_HANDLE_D3D11_DEVICE: u32 = 3;
const MFX_HANDLE_D3D11_VIDEO_DEVICE_GUESS: u32 = 11;
const MFX_VARIANT_TYPE_U32: u32 = 5;
const MFX_VARIANT_VERSION_MAJOR: u8 = 1;
const MFX_VARIANT_VERSION_MINOR: u8 = 1;
const MFX_ERR_NONE: i32 = 0;
const MFX_WRN_IN_EXECUTION: i32 = 1;
const MFX_FOURCC_NV12: u32 = u32::from_le_bytes(*b"NV12");
const MFX_CODEC_AVC: u32 = u32::from_le_bytes(*b"AVC ");
const MFX_RATECONTROL_CBR: u16 = 1;
const MFX_PICSTRUCT_PROGRESSIVE: u16 = 0x01;
const MFX_CHROMAFORMAT_YUV420: u16 = 1;
const MFX_IOPATTERN_IN_VIDEO_MEMORY: u16 = 0x01;
const MFX_IOPATTERN_IN_SYSTEM_MEMORY: u16 = 0x02;
const FILTER_PROPERTY_IMPL: &[u8] = b"mfxImplDescription.Impl\0";
const FILTER_PROPERTY_ACCEL: &[u8] = b"mfxImplDescription.AccelerationMode\0";
const QSV_DLL_NAME_VPL: &str = "libvpl.dll";
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct MfxStructVersion {
minor: u8,
major: u8,
}
#[repr(C)]
#[derive(Clone, Copy)]
union MfxVariantData {
u32_: u32,
u64_: u64,
ptr: *mut c_void,
pad: [u8; 16],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct MfxVariant {
version: MfxStructVersion,
type_: u32,
data: MfxVariantData,
}
#[repr(C)]
#[derive(Default)]
struct MfxFrameInfo {
reserved: [u32; 4],
channel_id: u16,
bit_depth_luma: u16,
bit_depth_chroma: u16,
shift: u16,
frame_id_temporal: u16,
frame_id_priority: u16,
frame_id_view: u16,
frame_id_quality: u16,
four_cc: u32,
width: u16,
height: u16,
crop_x: u16,
crop_y: u16,
crop_w: u16,
crop_h: u16,
frame_rate_extn: u32,
frame_rate_extd: u32,
reserved3: u16,
aspect_ratio_w: u16,
aspect_ratio_h: u16,
pic_struct: u16,
chroma_format: u16,
reserved2: u16,
}
#[repr(C)]
#[derive(Default)]
struct MfxInfoMfx {
reserved: [u32; 7],
low_power: u16,
brc_param_multiplier: u16,
frame_info: MfxFrameInfo,
codec_id: u32,
codec_profile: u16,
codec_level: u16,
num_thread: u16,
target_usage: u16,
gop_pic_size: u16,
gop_ref_dist: u16,
gop_opt_flag: u16,
idr_interval: u16,
rate_control_method: u16,
init_qp: u16,
buffer_size_in_kb: u16,
target_kbps: u16,
max_kbps: u16,
num_slice: u16,
num_ref_frame: u16,
encoded_order: u16,
union_pad: [u16; 15],
}
#[repr(C)]
struct MfxVideoParam {
alloc_id: u32,
reserved: [u32; 2],
reserved3: u16,
async_depth: u16,
mfx: MfxInfoMfx,
protected: u16,
io_pattern: u16,
ext_param: *mut c_void,
num_ext_param: u16,
reserved2: u16,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct MfxPlatform {
code_name: u16,
device_id: u16,
media_adapter_type: u16,
reserved: [u16; 13],
}
type MfxLoad = unsafe extern "C" fn() -> *mut c_void;
type MfxUnload = unsafe extern "C" fn(*mut c_void);
type MfxCreateConfig = unsafe extern "C" fn(*mut c_void) -> *mut c_void;
type MfxSetConfigFilterProperty = unsafe extern "C" fn(*mut c_void, *const u8, MfxVariant) -> i32;
type MfxCreateSession = unsafe extern "C" fn(*mut c_void, u32, *mut *mut c_void) -> i32;
type MfxClose = unsafe extern "C" fn(*mut c_void) -> i32;
type MfxSetHandle = unsafe extern "C" fn(*mut c_void, u32, *mut c_void) -> i32;
type MfxQueryImpl = unsafe extern "C" fn(*mut c_void, *mut i32) -> i32;
type MfxQueryPlatform = unsafe extern "C" fn(*mut c_void, *mut MfxPlatform) -> i32;
type MfxEncodeInit = unsafe extern "C" fn(*mut c_void, *mut MfxVideoParam) -> i32;
type MfxEncodeClose = unsafe extern "C" fn(*mut c_void) -> i32;
type MfxEncodeQuery =
unsafe extern "C" fn(*mut c_void, *mut MfxVideoParam, *mut MfxVideoParam) -> i32;
type MfxGetHandle = unsafe extern "C" fn(*mut c_void, u32, *mut *mut c_void) -> i32;
struct Dispatcher {
library: Library,
}
impl Dispatcher {
fn load() -> Option<Self> {
let library = unsafe { Library::new(QSV_DLL_NAME_VPL) }.ok()?;
Some(Self { library })
}
fn modern_session(&self, accel_mode: u32) -> (Option<(*mut c_void, *mut c_void)>, Vec<String>) {
let mut log = Vec::new();
let load: Symbol<'_, MfxLoad> = match unsafe { self.library.get(b"MFXLoad\0") } {
Ok(s) => s,
Err(e) => {
log.push(format!("MFXLoad symbol missing: {e:?}"));
return (None, log);
}
};
let create_config: Symbol<'_, MfxCreateConfig> =
match unsafe { self.library.get(b"MFXCreateConfig\0") } {
Ok(s) => s,
Err(e) => {
log.push(format!("MFXCreateConfig missing: {e:?}"));
return (None, log);
}
};
let set_prop: Symbol<'_, MfxSetConfigFilterProperty> =
match unsafe { self.library.get(b"MFXSetConfigFilterProperty\0") } {
Ok(s) => s,
Err(e) => {
log.push(format!("MFXSetConfigFilterProperty missing: {e:?}"));
return (None, log);
}
};
let create_session: Symbol<'_, MfxCreateSession> =
match unsafe { self.library.get(b"MFXCreateSession\0") } {
Ok(s) => s,
Err(e) => {
log.push(format!("MFXCreateSession missing: {e:?}"));
return (None, log);
}
};
let loader = unsafe { load() };
if loader.is_null() {
log.push("MFXLoad returned NULL".to_string());
return (None, log);
}
log.push(format!("MFXLoad ok loader={loader:p}"));
let impl_cfg = unsafe { create_config(loader) };
if impl_cfg.is_null() {
log.push("MFXCreateConfig(impl) returned NULL".to_string());
return (None, log);
}
let impl_variant = MfxVariant {
version: MfxStructVersion {
minor: MFX_VARIANT_VERSION_MINOR,
major: MFX_VARIANT_VERSION_MAJOR,
},
type_: MFX_VARIANT_TYPE_U32,
data: MfxVariantData {
u32_: MFX_IMPL_TYPE_HARDWARE,
},
};
let status_impl =
unsafe { set_prop(impl_cfg, FILTER_PROPERTY_IMPL.as_ptr(), impl_variant) };
log.push(format!(
"SetConfigFilterProperty(Impl=HARDWARE) status={status_impl}"
));
if status_impl != MFX_ERR_NONE {
return (None, log);
}
let accel_cfg = unsafe { create_config(loader) };
if accel_cfg.is_null() {
log.push("MFXCreateConfig(accel) returned NULL".to_string());
return (None, log);
}
let accel_variant = MfxVariant {
version: MfxStructVersion {
minor: MFX_VARIANT_VERSION_MINOR,
major: MFX_VARIANT_VERSION_MAJOR,
},
type_: MFX_VARIANT_TYPE_U32,
data: MfxVariantData { u32_: accel_mode },
};
let status_accel =
unsafe { set_prop(accel_cfg, FILTER_PROPERTY_ACCEL.as_ptr(), accel_variant) };
log.push(format!(
"SetConfigFilterProperty(AccelerationMode={accel_mode:#x}) status={status_accel}"
));
if status_accel != MFX_ERR_NONE {
return (None, log);
}
let mut session: *mut c_void = ptr::null_mut();
let status_create = unsafe { create_session(loader, 0, &mut session) };
log.push(format!(
"MFXCreateSession status={status_create} session={session:p}"
));
if status_create != MFX_ERR_NONE || session.is_null() {
return (None, log);
}
(Some((loader, session)), log)
}
fn unload(&self, loader: *mut c_void) {
let Ok(unload) = (unsafe { self.library.get::<MfxUnload>(b"MFXUnload\0") }) else {
return;
};
if !loader.is_null() {
unsafe { unload(loader) };
}
}
fn close(&self, session: *mut c_void) {
let Ok(close) = (unsafe { self.library.get::<MfxClose>(b"MFXClose\0") }) else {
return;
};
if !session.is_null() {
unsafe {
let _ = close(session);
}
}
}
}
fn try_create_intel_device() -> Option<(ID3D11Device, ID3D11DeviceContext)> {
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
let mut idx: u32 = 0;
loop {
let adapter: IDXGIAdapter1 = match unsafe { factory.EnumAdapters1(idx) } {
Ok(a) => a,
Err(_) => return None,
};
idx = idx.saturating_add(1);
let desc = match unsafe { adapter.GetDesc1() } {
Ok(d) => d,
Err(_) => continue,
};
if desc.VendorId != VENDOR_INTEL {
continue;
}
let mut device: Option<ID3D11Device> = None;
let mut ctx: Option<ID3D11DeviceContext> = None;
let feature_levels = [D3D_FEATURE_LEVEL_11_0];
let cast_result = adapter.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>();
let cast_adapter = match cast_result {
Ok(a) => a,
Err(_) => continue,
};
let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
let result = unsafe {
D3D11CreateDevice(
Some(&cast_adapter),
D3D_DRIVER_TYPE_UNKNOWN,
Default::default(),
flags,
Some(&feature_levels),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut ctx),
)
};
if let (Ok(()), Some(d), Some(c)) = (result, device, ctx) {
if let Ok(mt) = d.cast::<ID3D11Multithread>() {
let _ = unsafe { mt.SetMultithreadProtected(true) };
}
return Some((d, c));
}
}
}
fn build_video_params_with(
width: u16,
height: u16,
io_pattern: u16,
target_usage: u16,
low_power: u16,
) -> MfxVideoParam {
let aligned_w = (width + 15) & !15;
let aligned_h = (height + 15) & !15;
let info = MfxFrameInfo {
four_cc: MFX_FOURCC_NV12,
width: aligned_w,
height: aligned_h,
crop_w: width,
crop_h: height,
frame_rate_extn: 30,
frame_rate_extd: 1,
aspect_ratio_w: 1,
aspect_ratio_h: 1,
pic_struct: MFX_PICSTRUCT_PROGRESSIVE,
chroma_format: MFX_CHROMAFORMAT_YUV420,
..Default::default()
};
let mfx = MfxInfoMfx {
frame_info: info,
codec_id: MFX_CODEC_AVC,
target_usage,
low_power,
gop_pic_size: 30,
gop_ref_dist: 1,
rate_control_method: MFX_RATECONTROL_CBR,
target_kbps: 5_000,
max_kbps: 5_000,
num_slice: 1,
num_ref_frame: 1,
..Default::default()
};
MfxVideoParam {
alloc_id: 0,
reserved: [0; 2],
reserved3: 0,
async_depth: 1,
mfx,
protected: 0,
io_pattern,
ext_param: ptr::null_mut(),
num_ext_param: 0,
reserved2: 0,
}
}
fn try_set_handle(
dispatcher: &Dispatcher,
session: *mut c_void,
handle_type: u32,
handle_ptr: *mut c_void,
) -> Result<i32, String> {
let sym: Symbol<'_, MfxSetHandle> =
unsafe { dispatcher.library.get(b"MFXVideoCORE_SetHandle\0") }
.map_err(|e| format!("MFXVideoCORE_SetHandle missing: {e:?}"))?;
let status = unsafe { sym(session, handle_type, handle_ptr) };
Ok(status)
}
fn try_query_impl(dispatcher: &Dispatcher, session: *mut c_void) -> Result<(i32, i32), String> {
let sym: Symbol<'_, MfxQueryImpl> = unsafe { dispatcher.library.get(b"MFXQueryIMPL\0") }
.map_err(|e| format!("MFXQueryIMPL missing: {e:?}"))?;
let mut out: i32 = 0;
let status = unsafe { sym(session, &mut out) };
Ok((status, out))
}
fn try_query_platform(
dispatcher: &Dispatcher,
session: *mut c_void,
) -> Result<(i32, MfxPlatform), String> {
let sym: Symbol<'_, MfxQueryPlatform> =
unsafe { dispatcher.library.get(b"MFXVideoCORE_QueryPlatform\0") }
.map_err(|e| format!("MFXVideoCORE_QueryPlatform missing: {e:?}"))?;
let mut p = MfxPlatform::default();
let status = unsafe { sym(session, &mut p) };
Ok((status, p))
}
fn try_encode_init(
dispatcher: &Dispatcher,
session: *mut c_void,
width: u16,
height: u16,
io_pattern: u16,
) -> Result<i32, String> {
try_encode_init_with(dispatcher, session, width, height, io_pattern, 4, 0)
}
fn try_encode_init_with(
dispatcher: &Dispatcher,
session: *mut c_void,
width: u16,
height: u16,
io_pattern: u16,
target_usage: u16,
low_power: u16,
) -> Result<i32, String> {
let query: Symbol<'_, MfxEncodeQuery> =
unsafe { dispatcher.library.get(b"MFXVideoENCODE_Query\0") }
.map_err(|e| format!("MFXVideoENCODE_Query missing: {e:?}"))?;
let init: Symbol<'_, MfxEncodeInit> =
unsafe { dispatcher.library.get(b"MFXVideoENCODE_Init\0") }
.map_err(|e| format!("MFXVideoENCODE_Init missing: {e:?}"))?;
let mut params = build_video_params_with(width, height, io_pattern, target_usage, low_power);
let mut query_out = build_video_params_with(width, height, io_pattern, target_usage, low_power);
let q_status = unsafe { query(session, &mut params, &mut query_out) };
eprintln!(
" MFXVideoENCODE_Query(io={io_pattern:#x},tu={target_usage},lp={low_power}) status={q_status}"
);
if q_status != MFX_ERR_NONE && q_status != MFX_WRN_IN_EXECUTION && q_status != -3 {
eprintln!(" Query returned hard error {q_status}; skipping Init to avoid AV");
return Ok(q_status);
}
let init_status = unsafe { init(session, &mut query_out) };
Ok(init_status)
}
fn try_get_handle(
dispatcher: &Dispatcher,
session: *mut c_void,
handle_type: u32,
) -> Result<(i32, *mut c_void), String> {
let sym: Symbol<'_, MfxGetHandle> =
unsafe { dispatcher.library.get(b"MFXVideoCORE_GetHandle\0") }
.map_err(|e| format!("MFXVideoCORE_GetHandle missing: {e:?}"))?;
let mut out: *mut c_void = ptr::null_mut();
let status = unsafe { sym(session, handle_type, &mut out) };
Ok((status, out))
}
fn try_encode_close(dispatcher: &Dispatcher, session: *mut c_void) {
if let Ok(sym) = unsafe {
dispatcher
.library
.get::<MfxEncodeClose>(b"MFXVideoENCODE_Close\0")
} {
unsafe {
let _ = sym(session);
}
}
}
#[test]
fn qsv_tiger_lake_probe_struct_sizes() {
eprintln!("=== STRUCT SIZES (Rust) ===");
eprintln!("MfxFrameInfo = {}", std::mem::size_of::<MfxFrameInfo>());
eprintln!("MfxInfoMfx = {}", std::mem::size_of::<MfxInfoMfx>());
eprintln!("MfxVideoParam = {}", std::mem::size_of::<MfxVideoParam>());
assert!(
std::mem::size_of::<MfxFrameInfo>() == 68,
"MfxFrameInfo size must be 68"
);
assert!(
std::mem::size_of::<MfxInfoMfx>() >= 168,
"MfxInfoMfx >= 168 (= sizeof mfxInfoVPP)"
);
}
#[test]
fn qsv_tiger_lake_probe_experiment_1_preconditions() {
let dispatcher = match Dispatcher::load() {
Some(d) => d,
None => {
eprintln!("skip: libvpl.dll not loadable");
return;
}
};
eprintln!("=== EXPERIMENT 1: preconditions (QueryIMPL + QueryPlatform) ===");
let (session_opt, log) = dispatcher.modern_session(MFX_ACCEL_MODE_VIA_D3D11);
for line in &log {
eprintln!(" {line}");
}
let (loader, session) = match session_opt {
Some(s) => s,
None => {
eprintln!("EXP1 result: modern session not created; cannot probe preconditions");
return;
}
};
match try_query_impl(&dispatcher, session) {
Ok((status, impl_val)) => {
eprintln!(" MFXQueryIMPL: status={status} impl={impl_val:#x}");
}
Err(e) => eprintln!(" MFXQueryIMPL error: {e}"),
}
match try_query_platform(&dispatcher, session) {
Ok((status, p)) => {
eprintln!(
" MFXVideoCORE_QueryPlatform: status={status} code_name={} device_id={:#x} media_adapter_type={}",
p.code_name, p.device_id, p.media_adapter_type
);
}
Err(e) => eprintln!(" MFXVideoCORE_QueryPlatform error: {e}"),
}
dispatcher.close(session);
dispatcher.unload(loader);
eprintln!("EXP1 done");
}
#[test]
fn qsv_tiger_lake_probe_experiment_2_variant_accel_modes() {
let dispatcher = match Dispatcher::load() {
Some(d) => d,
None => {
eprintln!("skip: libvpl.dll not loadable");
return;
}
};
let (device, _ctx) = match try_create_intel_device() {
Some(d) => d,
None => {
eprintln!("skip: no Intel D3D11 device");
return;
}
};
eprintln!("=== EXPERIMENT 2: SetHandle handle-type variants ===");
let (session_opt, log) = dispatcher.modern_session(MFX_ACCEL_MODE_VIA_D3D11);
for line in &log {
eprintln!(" {line}");
}
let (loader, session) = match session_opt {
Some(s) => s,
None => {
eprintln!("EXP2 result: modern session not created");
return;
}
};
let raw_device = device.as_raw();
let exp2a = try_set_handle(&dispatcher, session, MFX_HANDLE_D3D11_DEVICE, raw_device);
eprintln!(
" EXP2a: SetHandle(MFX_HANDLE_D3D11_DEVICE=3) -> {:?}",
exp2a
);
let exp2b = try_set_handle(
&dispatcher,
session,
MFX_HANDLE_D3D11_VIDEO_DEVICE_GUESS,
raw_device,
);
eprintln!(
" EXP2b: SetHandle(MFX_HANDLE_D3D11_VIDEO_DEVICE_GUESS=11) -> {:?}",
exp2b
);
dispatcher.close(session);
dispatcher.unload(loader);
eprintln!("EXP2 done");
assert!(exp2a.is_ok(), "MFXVideoCORE_SetHandle symbol resolves");
}
#[test]
fn qsv_tiger_lake_probe_experiment_3_skip_set_handle() {
let dispatcher = match Dispatcher::load() {
Some(d) => d,
None => {
eprintln!("skip: libvpl.dll not loadable");
return;
}
};
let (device, _ctx) = match try_create_intel_device() {
Some(d) => d,
None => {
eprintln!("skip: no Intel D3D11 device");
return;
}
};
eprintln!("=== EXPERIMENT 3: SetHandle with VIDEO_SUPPORT device + encoder init ===");
let (session_opt, log) = dispatcher.modern_session(MFX_ACCEL_MODE_VIA_D3D11);
for line in &log {
eprintln!(" {line}");
}
let (loader, session) = match session_opt {
Some(s) => s,
None => {
eprintln!("EXP3 result: modern session not created");
return;
}
};
match try_query_impl(&dispatcher, session) {
Ok((status, impl_val)) => {
eprintln!(" pre-set MFXQueryIMPL: status={status} impl={impl_val:#x}");
}
Err(e) => eprintln!(" MFXQueryIMPL error: {e}"),
}
let raw_device = device.as_raw();
let set_status = try_set_handle(&dispatcher, session, MFX_HANDLE_D3D11_DEVICE, raw_device);
eprintln!(
" EXP3-set: SetHandle(D3D11_DEVICE=3, VIDEO_SUPPORT+BGRA, multithread-protected) -> {:?}",
set_status
);
match try_get_handle(&dispatcher, session, MFX_HANDLE_D3D11_DEVICE) {
Ok((status, hdl)) => {
eprintln!(
" post-set GetHandle(MFX_HANDLE_D3D11_DEVICE) -> status={status} handle={hdl:p}"
);
}
Err(e) => eprintln!(" GetHandle error: {e}"),
}
let init_a = try_encode_init(
&dispatcher,
session,
1280,
720,
MFX_IOPATTERN_IN_VIDEO_MEMORY,
);
eprintln!(
" EXP3a (IO_VIDEO_MEMORY, target_usage=4, low_power=0): init -> {:?}",
init_a
);
let init_b = try_encode_init_with(
&dispatcher,
session,
1280,
720,
MFX_IOPATTERN_IN_VIDEO_MEMORY,
7,
0x10,
);
eprintln!(
" EXP3b (IO_VIDEO_MEMORY, target_usage=7 best-speed, low_power=ON=0x10): init -> {:?}",
init_b
);
let init_c = try_encode_init_with(
&dispatcher,
session,
1280,
720,
MFX_IOPATTERN_IN_VIDEO_MEMORY,
4,
0x10,
);
eprintln!(
" EXP3c (IO_VIDEO_MEMORY, target_usage=4 balanced, low_power=ON=0x10): init -> {:?}",
init_c
);
try_encode_close(&dispatcher, session);
dispatcher.close(session);
dispatcher.unload(loader);
eprintln!("EXP3 done");
assert!(init_a.is_ok(), "init_a symbol resolved");
assert!(init_b.is_ok(), "init_b symbol resolved");
assert!(init_c.is_ok(), "init_c symbol resolved");
}
#[test]
fn qsv_tiger_lake_probe_experiment_4_software_fallback() {
let dispatcher = match Dispatcher::load() {
Some(d) => d,
None => {
eprintln!("skip: libvpl.dll not loadable");
return;
}
};
eprintln!("=== EXPERIMENT 4: software-only (ACCEL_MODE_NA) session ===");
let (session_opt, log) = dispatcher.modern_session(MFX_ACCEL_MODE_NA);
for line in &log {
eprintln!(" {line}");
}
let (loader, session) = match session_opt {
Some(s) => s,
None => {
eprintln!("EXP4 result: software session not created");
return;
}
};
match try_query_impl(&dispatcher, session) {
Ok((status, impl_val)) => {
eprintln!(" MFXQueryIMPL: status={status} impl={impl_val:#x}");
}
Err(e) => eprintln!(" MFXQueryIMPL error: {e}"),
}
let init_status = try_encode_init(
&dispatcher,
session,
1280,
720,
MFX_IOPATTERN_IN_SYSTEM_MEMORY,
);
eprintln!(" EXP4: MFXVideoENCODE_Init software -> {:?}", init_status);
try_encode_close(&dispatcher, session);
dispatcher.close(session);
dispatcher.unload(loader);
eprintln!("EXP4 done");
assert!(
init_status.is_ok(),
"encode init symbol resolved in software"
);
}
@@ -0,0 +1,551 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![cfg(target_os = "windows")]
use fluxer_encoder_ring::NVENC_COMPLETION_RING_CAPACITY;
use fluxer_encoder_ring::encoder_handoff::EncoderCompletionCallback;
use fluxer_encoder_ring::{
AmfD3D11Handoff, AmfHandoff, EncodedBitstream, EncoderDims, EncoderError, EncoderSubmission,
NvencD3D11Handoff, NvencHandoff, PicParams, QsvD3D11Handoff, QsvHandoff, RingError,
};
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0};
use windows::Win32::Graphics::Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
D3D11_USAGE_DEFAULT, D3D11CreateDevice, ID3D11Device, ID3D11Multithread,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIResource1,
};
use windows::core::Interface;
const VENDOR_NVIDIA: u32 = 0x10DE;
const VENDOR_AMD: u32 = 0x1002;
const VENDOR_INTEL: u32 = 0x8086;
fn enum_adapters() -> Vec<IDXGIAdapter1> {
let factory_result: windows::core::Result<IDXGIFactory1> = unsafe { CreateDXGIFactory1() };
let factory = match factory_result {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
let mut index: u32 = 0;
loop {
let result: windows::core::Result<IDXGIAdapter1> = unsafe { factory.EnumAdapters1(index) };
match result {
Ok(adapter) => {
out.push(adapter);
index = index.saturating_add(1);
}
Err(_) => break,
}
}
out
}
fn try_create_device_for_vendor(vendor_id: u32) -> Option<ID3D11Device> {
let adapters = enum_adapters();
for adapter in adapters {
let desc = match unsafe { adapter.GetDesc1() } {
Ok(d) => d,
Err(_) => continue,
};
if desc.VendorId != vendor_id {
continue;
}
let mut device: Option<ID3D11Device> = None;
let feature_levels = [D3D_FEATURE_LEVEL_11_0];
let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
let result = unsafe {
D3D11CreateDevice(
Some(&adapter.cast().ok()?),
D3D_DRIVER_TYPE_UNKNOWN,
Default::default(),
flags,
Some(&feature_levels),
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
};
if let (Ok(()), Some(d)) = (result, device) {
if let Ok(mt) = d.cast::<ID3D11Multithread>() {
let _ = unsafe { mt.SetMultithreadProtected(true) };
}
return Some(d);
}
}
None
}
fn try_create_device() -> Option<ID3D11Device> {
try_create_device_for_vendor(VENDOR_NVIDIA)
.or_else(|| try_create_device_for_vendor(VENDOR_INTEL))
.or_else(|| try_create_device_for_vendor(VENDOR_AMD))
}
fn try_create_shared_nv12(device: &ID3D11Device, width: u32, height: u32) -> Option<u64> {
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_NV12,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0 | D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0)
as u32,
};
let mut texture = None;
unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut texture))
.ok()?;
}
let texture = texture?;
let resource: IDXGIResource1 = texture.cast().ok()?;
let shared = unsafe {
resource
.CreateSharedHandle(None, 0x3, windows::core::PCWSTR::null())
.ok()?
};
Some(shared.0 as u64)
}
#[test]
fn nvenc_real_encode_one_frame_via_dummy_shared_texture() {
let device = match try_create_device_for_vendor(VENDOR_NVIDIA) {
Some(d) => d,
None => {
eprintln!("skip: no NVIDIA D3D11 device available");
return;
}
};
let dims = EncoderDims::new(1920, 1080);
let handoff_result = NvencD3D11Handoff::new(device.clone(), dims, 5_000_000);
let mut handoff = match handoff_result {
Ok(h) => h,
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: NVENC runtime DLL not available");
return;
}
Err(EncoderError::SessionInitFailed { vendor, status }) => {
eprintln!("skip: NVENC session init failed: {vendor} status={status}");
return;
}
Err(other) => {
eprintln!("skip: NVENC init unexpected error: {other:?}");
return;
}
};
let shared = match try_create_shared_nv12(&device, 1920, 1080) {
Some(h) => h,
None => {
eprintln!("skip: shared NV12 texture creation failed");
return;
}
};
let slot = match NvencHandoff::register_slot(&mut handoff, shared, 0, dims) {
Ok(s) => s,
Err(e) => {
eprintln!("skip: register_slot failed: {e:?}");
return;
}
};
let pic = PicParams::new(0, true);
let submit_result = NvencHandoff::encode_shared_async(&mut handoff, slot, 0, dims, pic);
if submit_result.is_err() {
eprintln!("skip: encode_shared_async failed: {submit_result:?}");
NvencHandoff::unregister_slot(&mut handoff, slot);
return;
}
let mut bitstream = None;
for _ in 0..200 {
bitstream = NvencHandoff::poll_completed(&mut handoff, slot);
if bitstream.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
NvencHandoff::unregister_slot(&mut handoff, slot);
let bs = match bitstream {
Some(b) => b,
None => {
eprintln!("nvenc: no bitstream produced within timeout; backend init succeeded");
return;
}
};
assert!(!bs.data.is_empty(), "non-empty bitstream");
assert!(bs.is_keyframe, "first frame must be keyframe");
}
#[test]
fn amf_real_encode_one_frame_via_dummy_shared_texture() {
let device = match try_create_device_for_vendor(VENDOR_AMD) {
Some(d) => d,
None => {
eprintln!("skip: no AMD D3D11 device available (expected on Intel/NVIDIA-only boxes)");
return;
}
};
let dims = EncoderDims::new(1920, 1080);
let handoff_result = AmfD3D11Handoff::new(device.clone(), dims, 5_000_000);
let mut handoff = match handoff_result {
Ok(h) => h,
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: AMF runtime DLL not available (expected on non-AMD hardware)");
return;
}
Err(other) => {
eprintln!("skip: AMF init unexpected: {other:?}");
return;
}
};
let shared = match try_create_shared_nv12(&device, 1920, 1080) {
Some(h) => h,
None => {
eprintln!("skip: shared NV12 texture creation failed");
return;
}
};
let slot = match AmfHandoff::register_slot(&mut handoff, shared, 0, dims) {
Ok(s) => s,
Err(e) => {
eprintln!("skip: AMF register_slot failed: {e:?}");
return;
}
};
let pic = PicParams::new(0, true);
let _ = AmfHandoff::encode_shared_async(&mut handoff, slot, 0, dims, pic);
let mut bitstream = None;
for _ in 0..200 {
bitstream = AmfHandoff::poll_completed(&mut handoff, slot);
if bitstream.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
AmfHandoff::unregister_slot(&mut handoff, slot);
if let Some(bs) = bitstream {
assert!(!bs.data.is_empty(), "non-empty AMF bitstream");
} else {
eprintln!("amf: no bitstream produced within timeout; backend init succeeded");
}
}
#[test]
fn qsv_modern_dispatcher_session_init_succeeds_on_real_iris_xe() {
let device = match try_create_device_for_vendor(VENDOR_INTEL) {
Some(d) => d,
None => {
eprintln!("skip: no Intel D3D11 device available");
return;
}
};
let dims = EncoderDims::new(1280, 720);
let handoff_result = QsvD3D11Handoff::new(device, dims, 5_000_000);
match handoff_result {
Ok(_) => {
eprintln!("qsv: modern dispatcher session + SetHandle + encode_init succeeded");
}
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: QSV runtime DLL not available");
}
Err(e) => {
panic!("qsv modern dispatcher init MUST succeed on Tiger Lake Iris Xe: {e:?}");
}
}
}
#[test]
fn qsv_real_encode_one_frame_via_dummy_shared_texture() {
let device = match try_create_device_for_vendor(VENDOR_INTEL) {
Some(d) => d,
None => {
eprintln!("skip: no Intel D3D11 device available");
return;
}
};
let dims = EncoderDims::new(1920, 1080);
let handoff_result = QsvD3D11Handoff::new(device.clone(), dims, 5_000_000);
let mut handoff = match handoff_result {
Ok(h) => h,
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: QSV runtime DLL not available");
return;
}
Err(EncoderError::SessionInitFailed { vendor, status }) => {
eprintln!("skip: QSV session init failed: {vendor} status={status}");
return;
}
Err(other) => {
eprintln!("skip: QSV init unexpected: {other:?}");
return;
}
};
let shared = match try_create_shared_nv12(&device, 1920, 1080) {
Some(h) => h,
None => {
eprintln!("skip: shared NV12 texture creation failed");
return;
}
};
let slot = match QsvHandoff::register_slot(&mut handoff, shared, 0, dims) {
Ok(s) => s,
Err(e) => {
eprintln!("skip: QSV register_slot failed: {e:?}");
return;
}
};
let pic = PicParams::new(0, true);
let _ = QsvHandoff::encode_shared_async(&mut handoff, slot, 0, dims, pic);
let mut bitstream = None;
for _ in 0..200 {
bitstream = QsvHandoff::poll_completed(&mut handoff, slot);
if bitstream.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
QsvHandoff::unregister_slot(&mut handoff, slot);
if let Some(bs) = bitstream {
assert!(!bs.data.is_empty(), "non-empty QSV bitstream");
} else {
eprintln!("qsv: no bitstream produced within timeout; backend init succeeded");
}
}
#[test]
fn skip_dont_block_rapid_submit_then_poll_never_hangs() {
let device = match try_create_device() {
Some(d) => d,
None => return,
};
let dims = EncoderDims::new(640, 360);
let mut handoff = match NvencD3D11Handoff::new(device.clone(), dims, 1_000_000) {
Ok(h) => h,
Err(_) => return,
};
let shared = match try_create_shared_nv12(&device, 640, 360) {
Some(h) => h,
None => return,
};
let slot = match NvencHandoff::register_slot(&mut handoff, shared, 0, dims) {
Ok(s) => s,
Err(_) => return,
};
let start = std::time::Instant::now();
for i in 0..10 {
let pic = PicParams::new((i as u64) * 16_666, false);
let _ = NvencHandoff::encode_shared_async(&mut handoff, slot, 0, dims, pic);
let _ = NvencHandoff::poll_completed(&mut handoff, slot);
}
let elapsed = start.elapsed();
assert!(
elapsed.as_secs() < 5,
"10 submit+poll cycles must complete in < 5s, was {elapsed:?}"
);
NvencHandoff::unregister_slot(&mut handoff, slot);
}
#[test]
fn cleanup_on_drop_releases_resources() {
let device = match try_create_device() {
Some(d) => d,
None => return,
};
let dims = EncoderDims::new(640, 360);
{
let _handoff = match NvencD3D11Handoff::new(device.clone(), dims, 1_000_000) {
Ok(h) => h,
Err(_) => return,
};
}
let _handoff2 = NvencD3D11Handoff::new(device.clone(), dims, 1_000_000);
}
struct CountingCallback {
seen: Vec<(u64, u32)>,
}
impl EncoderCompletionCallback for CountingCallback {
fn on_complete(&mut self, sequence: u64, encoded_bytes: u32) {
self.seen.push((sequence, encoded_bytes));
}
}
#[test]
fn nvenc_compression_handoff_encodes_one_frame_via_keyed_mutex() {
let device = match try_create_device_for_vendor(VENDOR_NVIDIA) {
Some(d) => d,
None => {
eprintln!("skip: no NVIDIA D3D11 device available");
return;
}
};
let dims = EncoderDims::new(1280, 720);
let mut handoff = match NvencD3D11Handoff::new(device.clone(), dims, 2_000_000) {
Ok(h) => h,
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: NVENC runtime DLL not available");
return;
}
Err(other) => {
eprintln!("skip: NVENC init unexpected: {other:?}");
return;
}
};
let shared = match try_create_shared_nv12(&device, 1280, 720) {
Some(h) => h,
None => {
eprintln!("skip: shared NV12 texture creation failed");
return;
}
};
let submission = EncoderSubmission::new(shared, 0, dims, 1);
let mut callback = CountingCallback { seen: Vec::new() };
NvencHandoff::encode_shared(&mut handoff, submission, &mut callback)
.expect("encode_shared first frame ok");
assert_eq!(callback.seen.len(), 1, "callback fired once");
assert_eq!(callback.seen[0].0, 1, "callback sequence matches");
let mut payload: Option<EncodedBitstream> = None;
for _ in 0..200 {
let slot = fluxer_encoder_ring::HandoffSlot::new(0, shared);
payload = NvencHandoff::poll_completed(&mut handoff, slot);
if payload.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let bs = match payload {
Some(b) => b,
None => {
eprintln!("nvenc keyed-mutex: no bitstream within timeout; init succeeded");
return;
}
};
assert!(!bs.data.is_empty(), "keyed-mutex encoded payload non-empty");
assert!(bs.is_keyframe, "first frame must be IDR keyframe");
}
#[test]
fn nvenc_compression_handoff_handles_back_pressure() {
let device = match try_create_device_for_vendor(VENDOR_NVIDIA) {
Some(d) => d,
None => {
eprintln!("skip: no NVIDIA D3D11 device available");
return;
}
};
let dims = EncoderDims::new(640, 360);
let mut handoff = match NvencD3D11Handoff::new(device.clone(), dims, 1_000_000) {
Ok(h) => h,
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: NVENC runtime DLL not available");
return;
}
Err(other) => {
eprintln!("skip: NVENC init unexpected: {other:?}");
return;
}
};
let shared = match try_create_shared_nv12(&device, 640, 360) {
Some(h) => h,
None => {
eprintln!("skip: shared NV12 texture creation failed");
return;
}
};
let total: u64 = (NVENC_COMPLETION_RING_CAPACITY as u64) + 8;
let mut callback = CountingCallback { seen: Vec::new() };
let mut accepted = 0usize;
let mut full_drops = 0usize;
for seq in 1..=total {
let submission = EncoderSubmission::new(shared, 0, dims, seq);
match NvencHandoff::encode_shared(&mut handoff, submission, &mut callback) {
Ok(()) => {
accepted += 1;
}
Err(RingError::FullDropped { .. }) => {
full_drops += 1;
assert_eq!(
handoff.pending_completion(),
NVENC_COMPLETION_RING_CAPACITY,
"pre-encode drop only when completion ring is full"
);
}
Err(other) => panic!("unexpected encode_shared error: {other:?}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(
accepted + full_drops,
total as usize,
"every submission accounted"
);
let pending = handoff.pending_completion();
assert!(
pending <= NVENC_COMPLETION_RING_CAPACITY,
"completion ring stays bounded"
);
let mut drained = 0usize;
for _ in 0..400 {
let slot = fluxer_encoder_ring::HandoffSlot::new(0, shared);
let bs = NvencHandoff::poll_completed(&mut handoff, slot);
match bs {
Some(_) => {
drained += 1;
}
None => {
if drained >= accepted {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
assert_eq!(
drained, accepted,
"no accepted frame is silently discarded post-encode"
);
}
#[test]
fn nvenc_attach_then_detach_smoke() {
let device = match try_create_device_for_vendor(VENDOR_NVIDIA) {
Some(d) => d,
None => {
eprintln!("skip: no NVIDIA D3D11 device available");
return;
}
};
let dims = EncoderDims::new(640, 360);
let handoff_result = NvencD3D11Handoff::new(device.clone(), dims, 1_000_000);
let handoff = match handoff_result {
Ok(h) => h,
Err(EncoderError::SdkNotFound { .. }) => {
eprintln!("skip: NVENC runtime DLL not available");
return;
}
Err(other) => {
eprintln!("skip: NVENC init unexpected: {other:?}");
return;
}
};
assert_eq!(
handoff.pending_completion(),
0,
"fresh handoff has empty ring"
);
assert_eq!(handoff.completed_count(), 0, "fresh handoff completed=0");
drop(handoff);
let _again = NvencD3D11Handoff::new(device, dims, 1_000_000);
}
+522
View File
@@ -0,0 +1,522 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bit-set"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"unicode-width",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fluxer_gpu_rebuild"
version = "0.0.0"
dependencies = [
"parking_lot",
"wgpu",
]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"num-traits",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hexf-parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "naga"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079"
dependencies = [
"arrayvec",
"bit-set",
"bitflags",
"cfg-if",
"cfg_aliases",
"codespan-reporting",
"half",
"hashbrown 0.16.1",
"hexf-parse",
"indexmap",
"libm",
"log",
"num-traits",
"once_cell",
"rustc-hash",
"thiserror",
"unicode-ident",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "profiling"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "wgpu"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250"
dependencies = [
"arrayvec",
"bitflags",
"bytemuck",
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"log",
"portable-atomic",
"profiling",
"raw-window-handle",
"smallvec",
"static_assertions",
"wgpu-core",
"wgpu-hal",
"wgpu-types",
]
[[package]]
name = "wgpu-core"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7"
dependencies = [
"arrayvec",
"bit-set",
"bit-vec",
"bitflags",
"bytemuck",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"indexmap",
"log",
"naga",
"once_cell",
"parking_lot",
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"smallvec",
"thiserror",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
"wgpu-types",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-hal"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libloading",
"log",
"naga",
"portable-atomic",
"portable-atomic-util",
"raw-window-handle",
"renderdoc-sys",
"thiserror",
"wgpu-naga-bridge",
"wgpu-types",
]
[[package]]
name = "wgpu-naga-bridge"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02"
dependencies = [
"naga",
"wgpu-types",
]
[[package]]
name = "wgpu-types"
version = "29.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639"
dependencies = [
"bitflags",
"bytemuck",
"log",
"raw-window-handle",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
@@ -0,0 +1,23 @@
[package]
name = "fluxer_gpu_rebuild"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["rlib"]
[features]
default = []
wgpu = ["dep:wgpu"]
[dependencies]
parking_lot = "0.12"
wgpu = { version = "29", optional = true, default-features = false }
[dev-dependencies]
parking_lot = "0.12"
@@ -0,0 +1,677 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::too_many_lines)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::panic)]
#![deny(warnings)]
use parking_lot::Mutex;
use std::sync::Arc;
pub const MAX_REGISTERED_OWNERS: usize = 1024;
#[cfg(feature = "wgpu")]
pub type GpuDevice = wgpu::Device;
#[cfg(feature = "wgpu")]
pub type GpuQueue = wgpu::Queue;
#[cfg(not(feature = "wgpu"))]
#[derive(Debug)]
pub struct GpuDevice {
pub id: u64,
}
#[cfg(not(feature = "wgpu"))]
#[derive(Debug)]
pub struct GpuQueue {
pub id: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GpuRebuildError {
DeviceRejected { reason: &'static str },
ResourceCreateFailed { reason: &'static str },
OwnerInvariantBroken { reason: &'static str },
Other { code: u32 },
}
impl std::fmt::Display for GpuRebuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DeviceRejected { reason } => write!(f, "device rejected: {reason}"),
Self::ResourceCreateFailed { reason } => write!(f, "resource create failed: {reason}"),
Self::OwnerInvariantBroken { reason } => write!(f, "owner invariant broken: {reason}"),
Self::Other { code } => write!(f, "other rebuild error code={code}"),
}
}
}
impl std::error::Error for GpuRebuildError {}
pub type OwnerId = u64;
pub trait GpuLossCallback: Send {
fn release(&mut self);
fn rebuild(&mut self, device: &GpuDevice, queue: &GpuQueue) -> Result<(), GpuRebuildError>;
fn is_ready(&self) -> bool;
fn debug_label(&self) -> &'static str {
"<unlabelled-gpu-owner>"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebuildOutcome {
Rebuilt {
owner_id: OwnerId,
label: &'static str,
},
Failed {
owner_id: OwnerId,
label: &'static str,
error: GpuRebuildError,
},
Vacant {
owner_id: OwnerId,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RebuildReport {
pub released_count: u32,
pub rebuilt_count: u32,
pub failed_count: u32,
pub vacant_count: u32,
pub outcomes: Vec<RebuildOutcome>,
}
impl RebuildReport {
pub fn is_total_success(&self) -> bool {
self.failed_count == 0 && self.rebuilt_count > 0
}
pub fn is_empty_pass(&self) -> bool {
self.released_count == 0
&& self.rebuilt_count == 0
&& self.failed_count == 0
&& self.vacant_count == 0
}
}
struct Slot {
owner_id: OwnerId,
callback: Option<Box<dyn GpuLossCallback>>,
}
struct RegistryInner {
slots: Vec<Slot>,
next_owner_id: OwnerId,
registration_order_monotonic_floor: OwnerId,
}
impl RegistryInner {
fn new() -> Self {
Self {
slots: Vec::with_capacity(MAX_REGISTERED_OWNERS),
next_owner_id: 1,
registration_order_monotonic_floor: 0,
}
}
fn deregister(&mut self, owner_id: OwnerId) {
assert!(owner_id > 0, "owner_id must be positive");
assert!(
owner_id < self.next_owner_id,
"owner_id must come from a real registration"
);
let before = self.slots.len();
self.slots.retain(|slot| slot.owner_id != owner_id);
let after = self.slots.len();
assert!(after <= before, "deregister must not grow slots");
assert!(
after >= before.saturating_sub(1),
"deregister removes at most one slot"
);
}
}
pub struct GpuLossRegistry {
inner: Arc<Mutex<RegistryInner>>,
}
impl GpuLossRegistry {
pub fn new() -> Self {
let inner = Arc::new(Mutex::new(RegistryInner::new()));
let registry = Self { inner };
assert_eq!(registry.len(), 0, "fresh registry must be empty");
assert!(registry.is_empty(), "fresh registry must report empty");
registry
}
pub fn len(&self) -> usize {
let guard = self.inner.lock();
guard.slots.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn register(&self, owner: Box<dyn GpuLossCallback>) -> RegistrationGuard {
let mut guard = self.inner.lock();
assert!(
guard.slots.len() < MAX_REGISTERED_OWNERS,
"registry exceeds MAX_REGISTERED_OWNERS",
);
let id = guard.next_owner_id;
assert!(
id > guard.registration_order_monotonic_floor,
"ids must be monotonic"
);
guard.registration_order_monotonic_floor = id;
guard.next_owner_id = id.checked_add(1).unwrap_or(OwnerId::MAX);
guard.slots.push(Slot {
owner_id: id,
callback: Some(owner),
});
drop(guard);
RegistrationGuard {
owner_id: id,
registry: Arc::clone(&self.inner),
}
}
pub fn handle_device_lost(
&self,
new_device: &GpuDevice,
new_queue: &GpuQueue,
) -> RebuildReport {
let mut guard = self.inner.lock();
assert!(
guard.slots.len() <= MAX_REGISTERED_OWNERS,
"slots within cap before walk",
);
let released_count = release_in_reverse_order(&mut guard.slots);
let outcomes = rebuild_in_forward_order(&mut guard.slots, new_device, new_queue);
let report = summarize_outcomes(outcomes, released_count);
assert_pair_total_accounting(&guard.slots, &report);
assert_pair_post_rebuild_ready(&guard.slots, &report);
report
}
}
impl Default for GpuLossRegistry {
fn default() -> Self {
Self::new()
}
}
pub struct RegistrationGuard {
owner_id: OwnerId,
registry: Arc<Mutex<RegistryInner>>,
}
impl RegistrationGuard {
pub fn owner_id(&self) -> OwnerId {
assert!(self.owner_id > 0, "guard must hold a valid id");
assert!(
Arc::strong_count(&self.registry) >= 1,
"registry must outlive guard"
);
self.owner_id
}
}
impl Drop for RegistrationGuard {
fn drop(&mut self) {
let owner_id = self.owner_id;
if owner_id == 0 {
return;
}
let mut guard = self.registry.lock();
guard.deregister(owner_id);
}
}
fn release_in_reverse_order(slots: &mut [Slot]) -> u32 {
let total = slots.len();
assert!(
total <= MAX_REGISTERED_OWNERS,
"slots within cap on release"
);
let mut released: u32 = 0;
for slot in slots.iter_mut().rev() {
if let Some(callback) = slot.callback.as_mut() {
callback.release();
released = released.saturating_add(1);
}
}
assert!(released as usize <= total, "released cannot exceed total");
released
}
fn rebuild_in_forward_order(
slots: &mut [Slot],
device: &GpuDevice,
queue: &GpuQueue,
) -> Vec<RebuildOutcome> {
let total = slots.len();
assert!(
total <= MAX_REGISTERED_OWNERS,
"slots within cap on rebuild"
);
let mut outcomes: Vec<RebuildOutcome> = Vec::with_capacity(total);
for slot in slots.iter_mut() {
let owner_id = slot.owner_id;
assert!(owner_id > 0, "rebuild slot must have non-zero id");
let outcome = rebuild_single_slot(slot, device, queue);
outcomes.push(outcome);
}
assert_eq!(outcomes.len(), total, "one outcome per slot");
outcomes
}
fn rebuild_single_slot(slot: &mut Slot, device: &GpuDevice, queue: &GpuQueue) -> RebuildOutcome {
let owner_id = slot.owner_id;
let callback = match slot.callback.as_mut() {
Some(cb) => cb,
None => return RebuildOutcome::Vacant { owner_id },
};
let label = callback.debug_label();
match callback.rebuild(device, queue) {
Ok(()) => RebuildOutcome::Rebuilt { owner_id, label },
Err(error) => RebuildOutcome::Failed {
owner_id,
label,
error,
},
}
}
fn summarize_outcomes(outcomes: Vec<RebuildOutcome>, released_count: u32) -> RebuildReport {
let mut rebuilt_count: u32 = 0;
let mut failed_count: u32 = 0;
let mut vacant_count: u32 = 0;
for outcome in outcomes.iter() {
match outcome {
RebuildOutcome::Rebuilt { .. } => rebuilt_count = rebuilt_count.saturating_add(1),
RebuildOutcome::Failed { .. } => failed_count = failed_count.saturating_add(1),
RebuildOutcome::Vacant { .. } => vacant_count = vacant_count.saturating_add(1),
}
}
let report = RebuildReport {
released_count,
rebuilt_count,
failed_count,
vacant_count,
outcomes,
};
assert_eq!(
report.outcomes.len() as u32,
report.rebuilt_count + report.failed_count + report.vacant_count,
"outcome totals must match",
);
report
}
fn assert_pair_total_accounting(slots: &[Slot], report: &RebuildReport) {
let total = slots.len() as u32;
assert_eq!(
total,
report.rebuilt_count + report.failed_count + report.vacant_count,
"report covers every slot",
);
let mut last_id: OwnerId = 0;
for slot in slots.iter() {
assert!(
slot.owner_id > last_id,
"registration ids must be monotonic"
);
last_id = slot.owner_id;
}
}
fn assert_pair_post_rebuild_ready(slots: &[Slot], report: &RebuildReport) {
assert_eq!(
slots.len(),
report.outcomes.len(),
"slot count must match report"
);
for (slot, outcome) in slots.iter().zip(report.outcomes.iter()) {
match outcome {
RebuildOutcome::Rebuilt { owner_id, .. } => {
assert_eq!(*owner_id, slot.owner_id, "owner id alignment");
if let Some(cb) = slot.callback.as_ref() {
assert!(cb.is_ready(), "rebuilt owner must report ready");
}
}
RebuildOutcome::Failed { owner_id, .. } => {
assert_eq!(*owner_id, slot.owner_id, "failed owner id alignment");
}
RebuildOutcome::Vacant { owner_id } => {
assert_eq!(*owner_id, slot.owner_id, "vacant owner id alignment");
assert!(slot.callback.is_none(), "vacant slot must have no callback");
}
}
}
}
#[cfg(all(test, not(feature = "wgpu")))]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
fn make_device_queue() -> (GpuDevice, GpuQueue) {
(GpuDevice { id: 42 }, GpuQueue { id: 42 })
}
#[derive(Default)]
struct Counters {
release_calls: AtomicU32,
rebuild_calls: AtomicU32,
release_seq: AtomicU32,
rebuild_seq: AtomicU32,
}
struct MockOwner {
counters: Arc<Counters>,
ready: bool,
fail_rebuild: bool,
release_order: Arc<Mutex<Vec<u32>>>,
rebuild_order: Arc<Mutex<Vec<u32>>>,
slot_marker: u32,
label: &'static str,
}
impl GpuLossCallback for MockOwner {
fn release(&mut self) {
self.ready = false;
let n = self.counters.release_calls.fetch_add(1, Ordering::SeqCst);
self.counters.release_seq.store(n + 1, Ordering::SeqCst);
let mut order = self.release_order.lock();
order.push(self.slot_marker);
}
fn rebuild(
&mut self,
_device: &GpuDevice,
_queue: &GpuQueue,
) -> Result<(), GpuRebuildError> {
let n = self.counters.rebuild_calls.fetch_add(1, Ordering::SeqCst);
self.counters.rebuild_seq.store(n + 1, Ordering::SeqCst);
let mut order = self.rebuild_order.lock();
order.push(self.slot_marker);
if self.fail_rebuild {
return Err(GpuRebuildError::ResourceCreateFailed {
reason: "mock fail",
});
}
self.ready = true;
Ok(())
}
fn is_ready(&self) -> bool {
self.ready
}
fn debug_label(&self) -> &'static str {
self.label
}
}
fn fresh_counters() -> Arc<Counters> {
Arc::new(Counters::default())
}
fn make_owner(
marker: u32,
counters: Arc<Counters>,
release_order: Arc<Mutex<Vec<u32>>>,
rebuild_order: Arc<Mutex<Vec<u32>>>,
) -> Box<MockOwner> {
Box::new(MockOwner {
counters,
ready: true,
fail_rebuild: false,
release_order,
rebuild_order,
slot_marker: marker,
label: "mock",
})
}
#[test]
fn five_owners_release_lifo_rebuild_fifo() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let mut guards = Vec::new();
for i in 0..5u32 {
let owner = make_owner(
i,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
);
guards.push(registry.register(owner));
}
assert_eq!(registry.len(), 5);
let (device, queue) = make_device_queue();
let report = registry.handle_device_lost(&device, &queue);
assert_eq!(report.released_count, 5);
assert_eq!(report.rebuilt_count, 5);
assert_eq!(report.failed_count, 0);
assert_eq!(report.vacant_count, 0);
let rel = release_order.lock().clone();
assert_eq!(rel, vec![4, 3, 2, 1, 0]);
let reb = rebuild_order.lock().clone();
assert_eq!(reb, vec![0, 1, 2, 3, 4]);
drop(guards);
}
#[test]
fn owner_rebuild_failure_does_not_abort_other_owners() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let mut guards = Vec::new();
for i in 0..4u32 {
let mut owner = make_owner(
i,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
);
if i == 1 {
owner.fail_rebuild = true;
}
guards.push(registry.register(owner));
}
let (device, queue) = make_device_queue();
let report = registry.handle_device_lost(&device, &queue);
assert_eq!(report.released_count, 4);
assert_eq!(report.rebuilt_count, 3);
assert_eq!(report.failed_count, 1);
let failed = report
.outcomes
.iter()
.filter(|o| matches!(o, RebuildOutcome::Failed { .. }))
.count();
assert_eq!(failed, 1);
drop(guards);
}
#[test]
fn registration_guard_drop_deregisters() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let owner = make_owner(
7,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
);
let guard = registry.register(owner);
assert_eq!(registry.len(), 1);
drop(guard);
assert_eq!(registry.len(), 0);
assert!(registry.is_empty());
}
#[test]
fn empty_registry_device_loss_is_noop() {
let registry = GpuLossRegistry::new();
let (device, queue) = make_device_queue();
let report = registry.handle_device_lost(&device, &queue);
assert!(report.is_empty_pass());
assert!(!report.is_total_success());
assert_eq!(report.outcomes.len(), 0);
}
#[test]
fn concurrent_registration_is_safe() {
let registry = Arc::new(GpuLossRegistry::new());
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let mut threads = Vec::new();
let guards_collector: Arc<Mutex<Vec<RegistrationGuard>>> = Arc::new(Mutex::new(Vec::new()));
for i in 0..16u32 {
let registry = Arc::clone(&registry);
let counters = Arc::clone(&counters);
let release_order = Arc::clone(&release_order);
let rebuild_order = Arc::clone(&rebuild_order);
let guards_collector = Arc::clone(&guards_collector);
threads.push(thread::spawn(move || {
let owner = make_owner(i, counters, release_order, rebuild_order);
let guard = registry.register(owner);
let mut store = guards_collector.lock();
store.push(guard);
}));
}
for t in threads {
assert!(t.join().is_ok());
}
assert_eq!(registry.len(), 16);
}
#[test]
fn pair_asserted_invariants_under_stress() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let mut guards = Vec::new();
for i in 0..64u32 {
let mut owner = make_owner(
i,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
);
if i % 7 == 0 {
owner.fail_rebuild = true;
}
guards.push(registry.register(owner));
}
let (device, queue) = make_device_queue();
for _ in 0..5 {
release_order.lock().clear();
rebuild_order.lock().clear();
let _report = registry.handle_device_lost(&device, &queue);
}
let rel = release_order.lock().clone();
let reb = rebuild_order.lock().clone();
assert_eq!(rel.len(), 64);
assert_eq!(reb.len(), 64);
for i in 0..64u32 {
assert_eq!(rel[i as usize], 63 - i);
assert_eq!(reb[i as usize], i);
}
drop(guards);
assert!(registry.is_empty());
}
#[test]
fn mock_callback_counters_are_deterministic() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let guard = registry.register(make_owner(
1,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
));
let (device, queue) = make_device_queue();
for _ in 0..3 {
registry.handle_device_lost(&device, &queue);
}
assert_eq!(counters.release_calls.load(Ordering::SeqCst), 3);
assert_eq!(counters.rebuild_calls.load(Ordering::SeqCst), 3);
drop(guard);
}
#[test]
fn deregister_middle_owner_preserves_order() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let g0 = registry.register(make_owner(
0,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
));
let g1 = registry.register(make_owner(
1,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
));
let g2 = registry.register(make_owner(
2,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
));
assert_eq!(registry.len(), 3);
drop(g1);
assert_eq!(registry.len(), 2);
let (device, queue) = make_device_queue();
let report = registry.handle_device_lost(&device, &queue);
assert_eq!(report.rebuilt_count, 2);
let rel = release_order.lock().clone();
assert_eq!(rel, vec![2, 0]);
let reb = rebuild_order.lock().clone();
assert_eq!(reb, vec![0, 2]);
drop(g0);
drop(g2);
assert!(registry.is_empty());
}
#[test]
fn rebuild_report_total_success_flag() {
let registry = GpuLossRegistry::new();
let counters = fresh_counters();
let release_order = Arc::new(Mutex::new(Vec::new()));
let rebuild_order = Arc::new(Mutex::new(Vec::new()));
let guard = registry.register(make_owner(
0,
Arc::clone(&counters),
Arc::clone(&release_order),
Arc::clone(&rebuild_order),
));
let (device, queue) = make_device_queue();
let report = registry.handle_device_lost(&device, &queue);
assert!(report.is_total_success());
assert!(!report.is_empty_pass());
drop(guard);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
[package]
name = "fluxer_linux_audio_capture"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
napi-derive = "3.5.6"
fluxer_rt_thread = {path = "../rt-thread"}
fluxer_audio_mix = {path = "../audio-mix"}
fluxer_audio_apm = {path = "../audio-apm"}
fluxer_audio_timing = {path = "../audio-timing"}
fluxer_screen_frame_bus = {path = "../screen-frame-bus"}
[target.'cfg(target_os = "linux")'.dependencies]
pipewire = "0.10.0"
[build-dependencies]
napi-build = "2.3.2"
[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "end_to_end"
harness = false
@@ -0,0 +1,18 @@
{
"measured_at": "85e057a273fd",
"host": "darwin-arm64-apple-silicon",
"regression_budget_percent": 5.0,
"criterion_args": {
"warm_up_time_sec": 2,
"measurement_time_sec": 5
},
"benches": {
"linux_audio_end_to_end/8_sources_capture_ring_mix_policy": {
"median_ns": 2453.7,
"low_ns": 2428.6,
"high_ns": 2479.5,
"budget_percent_override": 8.0,
"note": "Sub-microsecond mix-runtime tick; same noise floor logic as audio-mix/mix_tick. Pure-Rust bench helpers; no PipeWire process required."
}
}
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use fluxer_audio_mix::{AUDIO_OUTPUT_FRAMES, SourceRing, SourceRingProducer};
use fluxer_linux_audio_capture::audio_mix_runtime_bench_helpers::{
AudioMixRuntimeBuilder, CaptureSource, MIX_CHANNELS, MIX_SAMPLE_RATE_HZ, MIX_TICK_PERIOD_NS,
NullMixOutputSink,
};
const BENCH_SOURCE_COUNT: usize = 8;
fn build_sources_and_runtime() -> (
Vec<CaptureSource>,
fluxer_linux_audio_capture::audio_mix_runtime_bench_helpers::AudioMixRuntime,
) {
let mut sources = Vec::with_capacity(BENCH_SOURCE_COUNT);
let mut builder = AudioMixRuntimeBuilder::new();
for n in 0..BENCH_SOURCE_COUNT {
let (source, consumer) =
CaptureSource::create(n as u64 + 1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS).expect("source");
sources.push(source);
builder = builder.add_source(n as u64 + 1, consumer);
}
let runtime = builder.build(NullMixOutputSink).expect("build");
(sources, runtime)
}
fn fill_sources(sources: &mut [CaptureSource], frames: usize) {
assert!(!sources.is_empty());
assert!(frames > 0);
let payload: Vec<i16> = (0..frames).map(|n| ((n as i16) % 4096) - 2048).collect();
for source in sources.iter_mut() {
let _pushed = source.ingest_skip_apm(&payload);
}
}
fn bench_end_to_end_tick(c: &mut Criterion) {
let mut group = c.benchmark_group("linux_audio_end_to_end");
group.sample_size(50);
group.bench_function("8_sources_capture_ring_mix_policy", |b| {
let (mut sources, mut runtime) = build_sources_and_runtime();
let mut tick_index: u64 = 0;
b.iter(|| {
fill_sources(&mut sources, AUDIO_OUTPUT_FRAMES);
let frame = runtime
.run_one_tick_blocking(tick_index * MIX_TICK_PERIOD_NS)
.expect("frame");
tick_index = tick_index.wrapping_add(1);
black_box(frame);
});
});
group.finish();
}
#[allow(dead_code)]
fn _producer_helper(producer: SourceRingProducer) -> SourceRingProducer {
producer
}
#[allow(dead_code)]
fn _ring_helper() -> usize {
let _: usize = SourceRing::create(8192, 48_000).map(|_| 0).unwrap_or(0);
0
}
criterion_group!(benches, bench_end_to_end_tick);
criterion_main!(benches);
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+126
View File
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EventEmitter} from 'node:events';
export interface JsRoutingRule {
include?: Array<Record<string, string>>;
exclude?: Array<Record<string, string>>;
workaround?: Array<Record<string, string>>;
ignoreDevices?: boolean;
onlySpeakers?: boolean;
onlyDefaultSpeakers?: boolean;
}
export interface AudioFrame {
samples: Float32Array;
sampleRate: number;
channels: number;
timestampUs: number;
}
export interface NativeAudioFrame {
samples: ArrayBuffer;
sampleRate: number;
channels: number;
timestampUs: number;
}
export interface RoutingGraphNode {
id: number;
props: Record<string, string>;
}
export interface RoutingGraphPort {
id: number;
nodeId: number;
direction: string;
channel: string;
props: Record<string, string>;
}
export interface RoutingGraphLink {
outputNodeId: number;
outputPortId: number;
inputNodeId: number;
inputPortId: number;
owned: boolean;
passive: boolean;
}
export interface RoutingGraph {
backend: 'pipewire' | 'none' | string;
nodes: Array<RoutingGraphNode>;
ports: Array<RoutingGraphPort>;
ownedLinks: Array<RoutingGraphLink>;
}
export declare function pipeWireAvailable(): boolean;
export declare function audioBackend(): 'pipewire' | 'none';
export declare class AudioBridge {
constructor();
inventory(fields?: Array<string> | undefined | null): Array<Record<string, string>>;
routingGraph(): RoutingGraph;
apply(rule: JsRoutingRule): boolean;
release(): void;
backend(): 'pipewire' | 'none';
}
export declare class DirectAudioCapture {
constructor();
start(rule: JsRoutingRule): boolean;
setRule(rule: JsRoutingRule): boolean;
setLifecycleCallback(callback: (type: string, message: string) => void): void;
read(): NativeAudioFrame | null;
routingGraph(): RoutingGraph;
stop(): void;
}
export declare class AudioMixRuntimeHandle {
constructor(sourceCount: number);
static boundToDirectCapture(capture: DirectAudioCapture): AudioMixRuntimeHandle;
sourceCount(): number;
tick(tickAtNs?: number | null): number;
markPushedTotal(): number;
dispose(): void;
}
interface ProcessLoopbackEvents {
on(event: 'frame', listener: (frame: AudioFrame) => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'closed', listener: () => void): this;
on(event: 'diagnostic', listener: (message: string) => void): this;
removeListener(event: 'frame', listener: (frame: AudioFrame) => void): this;
removeListener(event: 'error', listener: (error: Error) => void): this;
removeListener(event: 'closed', listener: () => void): this;
removeListener(event: 'diagnostic', listener: (message: string) => void): this;
}
export declare class ProcessLoopback extends EventEmitter implements ProcessLoopbackEvents {
constructor(targetPid: number, options?: {includeProcessTree?: boolean; ignoreDevices?: boolean});
constructor(options: {linuxRule: JsRoutingRule});
start(): void;
routingGraph(): RoutingGraph | null;
stop(): Promise<void>;
}
@@ -0,0 +1,395 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {EventEmitter} = require('node:events');
const {existsSync, readdirSync, readFileSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-audio-capture';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_AUDIO_CAPTURE_SKIP_NATIVE_PROBE';
function resolveNativeRoot() {
const asarSegment = `${sep}app.asar${sep}`;
if (!__dirname.includes(asarSegment)) return __dirname;
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
return existsSync(unpackedDir) ? unpackedDir : __dirname;
}
function nativeFileName() {
if (process.platform !== 'linux') {
throw new Error(`@fluxer/linux-audio-capture is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-audio-capture.linux-x64-gnu.node';
case 'arm64':
return 'linux-audio-capture.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
let binding;
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loadedNative = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
if (loadedNative.loadError) {
throw loadedNative.loadError;
}
binding = loadedNative.binding;
} catch (error) {
throw createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
}
function normalizeRoutingRule(rule) {
if (!rule || typeof rule !== 'object') return {};
const normalizeList = (value) =>
Array.isArray(value)
? value
.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry))
.map((entry) =>
Object.fromEntries(
Object.entries(entry)
.filter(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
.map(([k, v]) => [k, String(v)]),
),
)
: undefined;
return {
include: normalizeList(rule.include),
exclude: normalizeList(rule.exclude),
workaround: normalizeList(rule.workaround),
ignoreDevices: rule.ignoreDevices ?? rule.ignore_devices,
onlySpeakers: rule.onlySpeakers ?? rule.only_speakers,
onlyDefaultSpeakers: rule.onlyDefaultSpeakers ?? rule.only_default_speakers,
};
}
function readProcParentMap() {
const parents = new Map();
let entries = [];
try {
entries = readdirSync('/proc', {withFileTypes: true});
} catch {
return parents;
}
for (const entry of entries) {
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
try {
const stat = readFileSync(`/proc/${entry.name}/stat`, 'utf8');
const end = stat.lastIndexOf(')');
if (end < 0) continue;
const fields = stat
.slice(end + 1)
.trim()
.split(/\s+/);
const parentPid = Number(fields[1]);
if (Number.isSafeInteger(parentPid) && parentPid > 0) {
parents.set(Number(entry.name), parentPid);
}
} catch {}
}
return parents;
}
function isDescendantPid(pid, rootPid, parents) {
let current = pid;
const seen = new Set();
while (parents.has(current) && !seen.has(current)) {
seen.add(current);
const parent = parents.get(current);
if (parent === rootPid) return true;
current = parent;
}
return false;
}
function targetPidList(pid, includeProcessTree) {
if (!includeProcessTree) return [pid];
const parents = readProcParentMap();
const pids = [pid];
for (const candidate of parents.keys()) {
if (candidate !== pid && isDescendantPid(candidate, pid, parents)) {
pids.push(candidate);
}
}
return pids;
}
function appendUniquePattern(patterns, pattern) {
if (!pattern || typeof pattern !== 'object') return;
const entries = Object.entries(pattern).filter(([, value]) => typeof value === 'string' && value.length > 0);
if (entries.length === 0) return;
const normalized = Object.fromEntries(entries);
const key = JSON.stringify(Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b)));
if (
patterns.some((existing) => JSON.stringify(Object.entries(existing).sort(([a], [b]) => a.localeCompare(b))) === key)
) {
return;
}
patterns.push(normalized);
}
function inventoryPatternsForTargetPids(pids) {
if (!(binding && typeof binding.AudioBridge === 'function')) return [];
const wanted = new Set(pids.map((pid) => String(pid)));
const patterns = [];
let bridge = null;
try {
bridge = new binding.AudioBridge();
const inventory = bridge.inventory();
if (!Array.isArray(inventory)) return patterns;
for (const props of inventory) {
if (!props || typeof props !== 'object') continue;
if (props['media.class'] !== 'Stream/Output/Audio') continue;
const processId = props['application.process.id'] || props['pipewire.sec.pid'];
if (!wanted.has(String(processId || ''))) continue;
appendUniquePattern(patterns, {'object.serial': String(props['object.serial'] || '')});
appendUniquePattern(patterns, {'node.name': String(props['node.name'] || '')});
appendUniquePattern(patterns, {'client.id': String(props['client.id'] || '')});
}
} catch {
} finally {
try {
bridge?.release?.();
} catch {}
}
return patterns;
}
function routingRuleFromTarget(target, options) {
if (target && typeof target === 'object') {
return normalizeRoutingRule(target.linuxRule || target);
}
const pid = Number(target);
if (!Number.isSafeInteger(pid) || pid <= 0) {
throw new TypeError('ProcessLoopback target pid must be a positive integer');
}
const targetPids = targetPidList(pid, Boolean(options?.includeProcessTree));
const include = inventoryPatternsForTargetPids(targetPids);
for (const candidate of targetPids) {
appendUniquePattern(include, {'application.process.id': String(candidate)});
appendUniquePattern(include, {'pipewire.sec.pid': String(candidate)});
}
return normalizeRoutingRule({
include,
ignoreDevices: options?.ignoreDevices ?? true,
});
}
const LATE_SPAWN_REFRESH_INTERVAL_MS = 2_000;
const MAX_DRAIN_FRAMES_PER_TICK = 16;
const MAX_IDLE_DIRECT_CAPTURES = 2;
const MIX_TICK_PERIOD_MS = 20;
let idleDirectCaptures = [];
function acquireDirectAudioCapture() {
if (typeof binding.DirectAudioCapture !== 'function') {
throw new Error('DirectAudioCapture native export missing');
}
const pooled = idleDirectCaptures.pop();
return pooled ?? new binding.DirectAudioCapture();
}
function releaseDirectAudioCapture(capture) {
if (!capture || idleDirectCaptures.includes(capture)) return;
if (idleDirectCaptures.length < MAX_IDLE_DIRECT_CAPTURES) {
idleDirectCaptures.push(capture);
}
}
function clearIdleDirectCapturePool() {
idleDirectCaptures = [];
}
function patternsEqual(a, b) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
const serialize = (entry) =>
JSON.stringify(
Object.entries(entry)
.filter(([, v]) => typeof v === 'string')
.sort(([x], [y]) => x.localeCompare(y)),
);
const aSet = a.map(serialize).sort();
const bSet = b.map(serialize).sort();
for (let i = 0; i < aSet.length; i++) {
if (aSet[i] !== bSet[i]) return false;
}
return true;
}
class ProcessLoopback extends EventEmitter {
constructor(target, options = {}) {
super();
this.capture = acquireDirectAudioCapture();
if (typeof this.capture.setLifecycleCallback === 'function') {
this.capture.setLifecycleCallback((type, message) => this.handleNativeLifecycle(type, message));
}
this.targetPid = null;
this.includeProcessTree = false;
if (!(target && typeof target === 'object')) {
const pid = Number(target);
if (Number.isSafeInteger(pid) && pid > 0) {
this.targetPid = pid;
this.includeProcessTree = Boolean(options?.includeProcessTree);
}
}
this.rule = routingRuleFromTarget(target, options);
this.options = options;
this.timer = null;
this.refreshTimer = null;
this.closed = false;
this.started = false;
}
handleNativeLifecycle(type, message) {
if (type === 'error') {
this.emit('error', new Error(message || 'Linux direct audio capture stopped'));
if (!this.closed) void this.stop();
return;
}
if (type === 'closed' || type === 'closed-clean') {
if (!this.closed) void this.stop();
return;
}
if (type === 'diagnostic') {
this.emit('diagnostic', message || '');
}
}
start() {
if (this.closed) {
throw new Error('ProcessLoopback already closed');
}
if (this.started) return;
if (!this.capture.start(this.rule)) {
throw new Error('failed to start Linux direct audio capture');
}
this.started = true;
this.timer = setInterval(() => this.tick(), MIX_TICK_PERIOD_MS);
this.timer.unref?.();
if (this.targetPid !== null && this.includeProcessTree && typeof this.capture.setRule === 'function') {
this.refreshTimer = setInterval(() => this.refreshRuleForLateChildren(), LATE_SPAWN_REFRESH_INTERVAL_MS);
this.refreshTimer.unref?.();
}
}
refreshRuleForLateChildren() {
if (this.closed || !this.started || this.targetPid === null) return;
let nextRule;
try {
nextRule = routingRuleFromTarget(this.targetPid, {
...(this.options ?? {}),
includeProcessTree: this.includeProcessTree,
});
} catch {
return;
}
if (patternsEqual(nextRule.include, this.rule.include) && patternsEqual(nextRule.exclude, this.rule.exclude)) {
return;
}
this.rule = nextRule;
try {
this.capture.setRule(this.rule);
} catch {}
}
tick() {
if (this.closed || !this.started) return;
try {
this.drainCaptureFrames();
} catch (error) {
this.emit('error', error instanceof Error ? error : new Error(String(error)));
void this.stop();
}
}
drainCaptureFrames() {
for (let i = 0; i < MAX_DRAIN_FRAMES_PER_TICK; i++) {
const frame = this.capture.read();
if (!frame || !(frame.samples instanceof ArrayBuffer) || frame.samples.byteLength === 0) return;
this.emit('frame', {
samples: new Float32Array(frame.samples),
sampleRate: frame.sampleRate,
channels: frame.channels,
timestampUs: frame.timestampUs,
});
}
}
routingGraph() {
return this.capture && typeof this.capture.routingGraph === 'function' ? this.capture.routingGraph() : null;
}
setScreenAudioSink(handle) {
if (!this.capture || typeof this.capture.setScreenAudioSink !== 'function') return false;
try {
return this.capture.setScreenAudioSink(handle) !== false;
} catch {
return false;
}
}
clearScreenAudioSink() {
if (this.capture && typeof this.capture.clearScreenAudioSink === 'function') {
this.capture.clearScreenAudioSink();
}
}
async stop() {
if (this.closed) return;
this.closed = true;
this.clearScreenAudioSink();
if (this.timer) clearInterval(this.timer);
this.timer = null;
if (this.refreshTimer) clearInterval(this.refreshTimer);
this.refreshTimer = null;
const wasStarted = this.started;
this.started = false;
const capture = this.capture;
this.capture = null;
let stopped = false;
try {
capture?.stop();
stopped = true;
} finally {
if (stopped && wasStarted) {
releaseDirectAudioCapture(capture);
}
this.emit('closed');
}
}
}
module.exports = {
AudioBridge: binding.AudioBridge,
DirectAudioCapture: binding.DirectAudioCapture,
AudioMixRuntimeHandle: binding.AudioMixRuntimeHandle,
ProcessLoopback,
pipeWireAvailable: binding.pipeWireAvailable,
audioBackend: binding.audioBackend ?? (() => (binding.pipeWireAvailable?.() ? 'pipewire' : 'none')),
__setBindingForTests(nextBinding) {
binding = nextBinding;
clearIdleDirectCapturePool();
},
__getIdleDirectCaptureCountForTests() {
return idleDirectCaptures.length;
},
};
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {test} from 'node:test';
const requireSrc = createRequire(import.meta.url);
const modulePath = requireSrc.resolve('./index.js');
function freshModule() {
delete requireSrc.cache[modulePath];
return requireSrc('./index.js');
}
function makeFakeBinding() {
const captures = [];
let startResult = true;
class FakeDirectAudioCapture {
constructor() {
this.started = false;
this.stopCount = 0;
this.startRules = [];
this.lifecycleCallback = undefined;
captures.push(this);
}
setLifecycleCallback(callback) {
this.lifecycleCallback = callback;
}
start(rule) {
if (!startResult) return false;
this.started = true;
this.startRules.push(rule);
return true;
}
setRule(rule) {
this.startRules.push(rule);
return true;
}
read() {
return null;
}
stop() {
this.started = false;
this.stopCount += 1;
}
}
function FakeAudioMixRuntimeHandle() {}
FakeAudioMixRuntimeHandle.boundToDirectCapture = () => {
throw new Error('ProcessLoopback must not tick a discard-only mix runtime');
};
return {
binding: {
AudioBridge: class {},
AudioMixRuntimeHandle: FakeAudioMixRuntimeHandle,
DirectAudioCapture: FakeDirectAudioCapture,
pipeWireAvailable: () => true,
audioBackend: () => 'pipewire',
},
captures,
setStartResult(value) {
startResult = value;
},
};
}
let loadError = null;
try {
freshModule();
} catch (error) {
loadError = error;
}
test('ProcessLoopback reuses idle direct captures after stop', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding, captures} = makeFakeBinding();
mod.__setBindingForTests(binding);
const rule = {linuxRule: {include: [{'application.name': 'Firefox'}]}};
const first = new mod.ProcessLoopback(rule);
first.start();
await first.stop();
assert.equal(captures.length, 1);
assert.equal(captures[0].stopCount, 1);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 1);
const second = new mod.ProcessLoopback(rule);
second.start();
await second.stop();
assert.equal(captures.length, 1);
assert.equal(captures[0].stopCount, 2);
assert.equal(captures[0].startRules.length, 2);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 1);
});
test('ProcessLoopback does not pool failed direct captures', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding, captures, setStartResult} = makeFakeBinding();
mod.__setBindingForTests(binding);
const rule = {linuxRule: {include: [{'application.name': 'Firefox'}]}};
setStartResult(false);
const first = new mod.ProcessLoopback(rule);
assert.throws(() => first.start(), /failed to start Linux direct audio capture/);
await first.stop();
assert.equal(captures.length, 1);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 0);
setStartResult(true);
const second = new mod.ProcessLoopback(rule);
second.start();
await second.stop();
assert.equal(captures.length, 2);
assert.equal(mod.__getIdleDirectCaptureCountForTests(), 1);
});
test('ProcessLoopback installs native lifecycle callback on direct capture', {skip: loadError?.message}, () => {
const mod = freshModule();
const {binding, captures} = makeFakeBinding();
mod.__setBindingForTests(binding);
const loopback = new mod.ProcessLoopback({linuxRule: {include: [{'application.name': 'Firefox'}]}});
assert.equal(captures.length, 1);
assert.equal(typeof captures[0].lifecycleCallback, 'function');
assert.equal(loopback.listenerCount('closed'), 0);
});
test('ProcessLoopback does not create a discard-only audio mix runtime', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding} = makeFakeBinding();
mod.__setBindingForTests(binding);
const loopback = new mod.ProcessLoopback({linuxRule: {include: [{'application.name': 'Firefox'}]}});
loopback.start();
await new Promise((resolve) => setImmediate(resolve));
await loopback.stop();
});
test('ProcessLoopback closes once when native lifecycle closes while idle', {skip: loadError?.message}, async () => {
const mod = freshModule();
const {binding, captures} = makeFakeBinding();
mod.__setBindingForTests(binding);
const loopback = new mod.ProcessLoopback({linuxRule: {include: [{'application.name': 'Firefox'}]}});
let closed = 0;
loopback.on('closed', () => {
closed += 1;
});
captures[0].lifecycleCallback('closed-clean', 'daemon disconnected');
await new Promise((resolve) => setImmediate(resolve));
await loopback.stop();
assert.equal(closed, 1);
assert.equal(captures[0].stopCount, 1);
});
@@ -0,0 +1,524 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
const os = require('node:os');
const {basename} = require('node:path');
const {spawnSync} = require('node:child_process');
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
const MAX_TEXT_LENGTH = 6000;
const MAX_DIRECTORY_ENTRIES = 80;
function trimText(value, limit = MAX_TEXT_LENGTH) {
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
const trimmed = text.trim();
if (!trimmed) return null;
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
}
function errorDiagnostic(error) {
if (!error) return null;
if (error instanceof Error) {
return {
name: error.name || 'Error',
message: error.message,
code: error.code || null,
stack: trimText(error.stack || error.message),
};
}
return {
name: typeof error,
message: trimText(String(error)),
code: null,
stack: null,
};
}
function formatErrorDiagnostic(diagnostic) {
if (!diagnostic) return null;
const lines = [];
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
if (diagnostic.stack) lines.push(diagnostic.stack);
else if (diagnostic.message) lines.push(diagnostic.message);
return trimText(lines.join('\n'));
}
function fileDiagnostic(filePath) {
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
try {
const stat = statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mode: `0${(stat.mode & 0o777).toString(8)}`,
mtime: stat.mtime.toISOString(),
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return {path: filePath, exists: false, error: reason};
}
}
function formatFileDiagnostic(diagnostic) {
if (!diagnostic) return 'not resolved';
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
return [
`exists=true`,
`size=${diagnostic.size}`,
`mode=${diagnostic.mode}`,
`mtime=${diagnostic.mtime}`,
`isFile=${diagnostic.isFile}`,
].join(', ');
}
function directoryDiagnostic(dirPath) {
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
try {
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
}));
entries.sort((a, b) => a.name.localeCompare(b.name));
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
return {
path: dirPath,
ok: true,
entries: visible,
total: entries.length,
omitted: Math.max(0, entries.length - visible.length),
};
} catch (error) {
return {
path: dirPath,
ok: false,
error: error instanceof Error ? error.message : String(error),
entries: [],
total: 0,
omitted: 0,
};
}
}
function formatDirectoryDiagnostic(diagnostic) {
if (!diagnostic) return '<unavailable>';
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
return [...entries, ...suffix].join('\n') || '<empty>';
}
function selectedEnvironmentNames(skipNativeProbeEnv) {
const names = [
'ELECTRON_RUN_AS_NODE',
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
'LD_LIBRARY_PATH',
'DYLD_LIBRARY_PATH',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_TYPE',
'DBUS_SESSION_BUS_ADDRESS',
'PULSE_SERVER',
'PIPEWIRE_REMOTE',
'PATH',
];
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
return names;
}
function environmentDiagnostics(skipNativeProbeEnv) {
return Object.fromEntries(
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
);
}
function formatEnvironment(diagnostic) {
return Object.entries(diagnostic)
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
.join('\n');
}
function runtimeDiagnostics() {
const versions = process.versions || {};
let reportHeader = null;
if (process.report && typeof process.report.getReport === 'function') {
try {
reportHeader = process.report.getReport().header || null;
} catch {
reportHeader = null;
}
}
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
return {
node: versions.node || null,
electron: versions.electron || null,
modules: versions.modules || null,
napi: versions.napi || null,
v8: versions.v8 || null,
uv: versions.uv || null,
openssl: versions.openssl || null,
glibcRuntime,
glibcCompiler,
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: typeof os.version === 'function' ? os.version() : null,
execPath: process.execPath,
resourcesPath: process.resourcesPath || null,
cwd: process.cwd(),
};
}
function formatRuntimeDiagnostics(diagnostic) {
return [
`node=${diagnostic.node || '<unknown>'}`,
`electron=${diagnostic.electron || '<none>'}`,
`modules=${diagnostic.modules || '<unknown>'}`,
`napi=${diagnostic.napi || '<unknown>'}`,
`v8=${diagnostic.v8 || '<unknown>'}`,
`uv=${diagnostic.uv || '<unknown>'}`,
`openssl=${diagnostic.openssl || '<unknown>'}`,
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
`process=${diagnostic.platform}/${diagnostic.arch}`,
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
`execPath=${diagnostic.execPath}`,
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
`cwd=${diagnostic.cwd}`,
].join('\n');
}
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
/^vcruntime\d+(?:_\d+)?\.dll$/i,
/^msvcp\d+(?:_\d+)?\.dll$/i,
/^msvcr\d+(?:_\d+)?\.dll$/i,
/^concrt\d+\.dll$/i,
/^vcamp\d+\.dll$/i,
/^vcomp\d+\.dll$/i,
];
function readPeImports(filePath) {
let buffer;
try {
buffer = readFileSync(filePath);
} catch {
return null;
}
if (buffer.length < 0x40) return null;
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
const coffOffset = peOffset + 4;
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
const optionalHeaderOffset = coffOffset + 20;
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
const magic = buffer.readUInt16LE(optionalHeaderOffset);
if (magic !== 0x10b && magic !== 0x20b) return null;
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
const importEntryOffset = dataDirectoriesOffset + 8;
if (importEntryOffset + 8 > buffer.length) return null;
const importRva = buffer.readUInt32LE(importEntryOffset);
if (importRva === 0) return [];
const sections = [];
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
for (let i = 0; i < numberOfSections; i++) {
const base = sectionTableOffset + i * 40;
if (base + 40 > buffer.length) return null;
sections.push({
virtualSize: buffer.readUInt32LE(base + 8),
virtualAddress: buffer.readUInt32LE(base + 12),
rawSize: buffer.readUInt32LE(base + 16),
rawPointer: buffer.readUInt32LE(base + 20),
});
}
const rvaToOffset = (rva) => {
for (const s of sections) {
const span = Math.max(s.virtualSize, s.rawSize);
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
return rva - s.virtualAddress + s.rawPointer;
}
}
return -1;
};
const readCString = (offset) => {
let end = offset;
while (end < buffer.length && buffer[end] !== 0) end++;
return buffer.toString('ascii', offset, end);
};
const importTableOffset = rvaToOffset(importRva);
if (importTableOffset < 0) return [];
const imports = new Set();
for (let i = 0; i < 1024; i++) {
const base = importTableOffset + i * 20;
if (base + 20 > buffer.length) break;
const lookupRva = buffer.readUInt32LE(base);
const nameRva = buffer.readUInt32LE(base + 12);
const iatRva = buffer.readUInt32LE(base + 16);
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
const nameOffset = rvaToOffset(nameRva);
if (nameOffset < 0) continue;
const name = readCString(nameOffset);
if (name) imports.add(name);
}
return Array.from(imports);
}
function windowsImportProbe(nativePath) {
const imports = readPeImports(nativePath);
if (imports === null) return null;
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
const redistributable = sortedImports.filter((dll) =>
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
);
return {
command: ['pe-imports', nativePath],
status: 0,
signal: null,
error: null,
stdout: sortedImports.join('\n') || null,
stderr: null,
missing: [],
redistributable,
};
}
function dependencyProbe(nativePath) {
if (!nativePath || !existsSync(nativePath)) return null;
if (process.platform === 'win32') return windowsImportProbe(nativePath);
const command =
process.platform === 'linux'
? ['ldd', nativePath]
: process.platform === 'darwin'
? ['otool', '-L', nativePath]
: null;
if (!command) return null;
const [bin, ...args] = command;
const result = spawnSync(bin, args, {
encoding: 'utf8',
timeout: 4000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = trimText(result.stdout);
const stderr = trimText(result.stderr);
const missing =
process.platform === 'linux' && stdout
? stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('not found'))
: [];
return {
command,
status: result.status,
signal: result.signal || null,
error: result.error ? result.error.message : null,
stdout,
stderr,
missing,
redistributable: [],
};
}
function formatDependencyProbe(diagnostic) {
if (!diagnostic) return null;
const status = diagnostic.error
? `error=${diagnostic.error}`
: diagnostic.signal
? `signal=${diagnostic.signal}`
: `status=${diagnostic.status}`;
return [
`$ ${diagnostic.command.join(' ')}`,
status,
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
diagnostic.redistributable?.length
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
: null,
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatExtraDiagnostic(diagnostic) {
if (!diagnostic) return null;
if (typeof diagnostic === 'string') return diagnostic;
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
return `${diagnostic.name}:\n${diagnostic.text}`;
}
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
}
function collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
return {
schemaVersion: 1,
moduleName,
reason,
target: {
platform: process.platform,
arch: process.arch,
},
packageDir: packageDir || null,
nativeRoot: nativeRoot || null,
nativePath: nativePath || null,
nativeFile: nativePath ? basename(nativePath) : null,
nativeFileStat: fileDiagnostic(nativePath),
runtime: runtimeDiagnostics(),
environment: environmentDiagnostics(skipNativeProbeEnv),
nativeRootEntries: directoryDiagnostic(nativeRoot),
dependencyProbe: dependencyProbe(nativePath),
extraDiagnostics: extraDiagnostics.filter(Boolean),
cause: errorDiagnostic(cause),
};
}
function formatNativeDiagnostics(diagnostics) {
const sections = [
`module=${diagnostics.moduleName}`,
`reason=${diagnostics.reason}`,
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
`environment:\n${formatEnvironment(diagnostics.environment)}`,
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
];
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
const causeText = formatErrorDiagnostic(diagnostics.cause);
if (causeText) sections.push(`cause:\n${causeText}`);
return sections.join('\n');
}
function isNativeLoadError(error) {
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
}
function createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
if (isNativeLoadError(cause)) return cause;
const diagnostics = collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics,
});
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
error.name = 'NativeModuleLoadError';
error[NATIVE_LOAD_ERROR_MARKER] = true;
error.nativeDiagnostics = diagnostics;
error.toJSON = () => ({
name: error.name,
message: error.message,
nativeDiagnostics: diagnostics,
});
if (cause) error.cause = cause;
return error;
}
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
return null;
}
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: timeoutMs,
});
if (result.status === 0) return null;
const reason = result.error
? result.error.message
: result.signal
? `safety probe terminated by signal ${result.signal}`
: `safety probe exited with code ${result.status}`;
return createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
skipNativeProbeEnv,
extraDiagnostics: [
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
],
});
}
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
if (!existsSync(nativePath)) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'native binary not found',
skipNativeProbeEnv,
}),
};
}
const nativeProbeError = probe
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
: null;
if (nativeProbeError) {
return {binding: null, loadError: nativeProbeError};
}
try {
return {binding: require(nativePath), loadError: null};
} catch (error) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'require(nativePath) threw',
cause: error,
skipNativeProbeEnv,
}),
};
}
}
module.exports = {
collectNativeDiagnostics,
createNativeLoadError,
formatNativeDiagnostics,
isNativeLoadError,
loadNativeBinding,
probeNativeBinary,
};
@@ -0,0 +1,27 @@
{
"name": "@fluxer/linux-audio-capture",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"linux"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"linux-audio-capture.linux-x64-gnu.node",
"linux-audio-capture.linux-arm64-gnu.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "cargo test --manifest-path Cargo.toml"
}
}
@@ -0,0 +1,147 @@
#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const DIRECT_CAPTURE_SAMPLE_RATE: u32 = 48_000;
pub const DIRECT_CAPTURE_CHANNELS: u32 = 2;
pub const DIRECT_CAPTURE_LATENCY_FRAMES: u32 = 4_096;
pub const DIRECT_CAPTURE_READ_CHUNK_US: u32 = 20_000;
pub const DIRECT_CAPTURE_MAX_SAMPLES: usize =
DIRECT_CAPTURE_SAMPLE_RATE as usize * DIRECT_CAPTURE_CHANNELS as usize * 2;
pub const DIRECT_CAPTURE_MAX_READ_SAMPLES: usize =
DIRECT_CAPTURE_SAMPLE_RATE as usize * DIRECT_CAPTURE_CHANNELS as usize / 50;
pub const MAX_ROUTING_RULE_PATTERNS: u32 = 64;
pub const MAX_ROUTING_RULE_KEYS_PER_PATTERN: u32 = 32;
pub const MAX_ROUTING_RULE_KEY_LENGTH: usize = 128;
pub const MAX_ROUTING_RULE_VALUE_LENGTH: usize = 512;
pub const MAX_INVENTORY_FIELDS: u32 = 32;
pub const MAX_INVENTORY_FIELD_LENGTH: usize = 128;
pub fn whole_frame_sample_count(sample_count: usize, channels: u32) -> usize {
if channels == 0 {
return 0;
}
let channel_count = channels as usize;
sample_count - (sample_count % channel_count)
}
pub fn direct_whole_frame_sample_count(sample_count: usize) -> usize {
whole_frame_sample_count(sample_count, DIRECT_CAPTURE_CHANNELS)
}
pub fn bounded_direct_read_sample_count(available: usize) -> usize {
bounded_direct_read_sample_count_for_format(
available,
DIRECT_CAPTURE_SAMPLE_RATE,
DIRECT_CAPTURE_CHANNELS,
)
}
pub fn sample_count_for_duration_us(sample_rate: u32, channels: u32, duration_us: u32) -> usize {
if sample_rate == 0 || channels == 0 || duration_us == 0 {
return 0;
}
let frames = ((u128::from(sample_rate) * u128::from(duration_us)) / 1_000_000).max(1) as usize;
frames.saturating_mul(channels as usize)
}
pub fn duration_us_for_sample_count(sample_count: usize, sample_rate: u32, channels: u32) -> i64 {
if sample_rate == 0 || channels == 0 {
return 0;
}
let frames = sample_count / channels as usize;
((frames as u128 * 1_000_000) / u128::from(sample_rate)).min(i64::MAX as u128) as i64
}
pub fn bounded_direct_read_sample_count_for_format(
available: usize,
sample_rate: u32,
channels: u32,
) -> usize {
let max = sample_count_for_duration_us(sample_rate, channels, DIRECT_CAPTURE_READ_CHUNK_US);
whole_frame_sample_count(available.min(max), channels)
}
pub fn bounded_direct_append_slice(input: &[f32]) -> &[f32] {
let whole = direct_whole_frame_sample_count(input.len());
let framed = &input[..whole];
if framed.len() > DIRECT_CAPTURE_MAX_SAMPLES {
&framed[framed.len() - DIRECT_CAPTURE_MAX_SAMPLES..]
} else {
framed
}
}
pub fn direct_capture_latency_fraction() -> String {
format!("{DIRECT_CAPTURE_LATENCY_FRAMES}/{DIRECT_CAPTURE_SAMPLE_RATE}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whole_frame_sample_count_trims_incomplete_channel_frames() {
assert_eq!(0, whole_frame_sample_count(1, 2));
assert_eq!(2, whole_frame_sample_count(2, 2));
assert_eq!(4, whole_frame_sample_count(5, 2));
assert_eq!(6, whole_frame_sample_count(7, 3));
assert_eq!(0, whole_frame_sample_count(7, 0));
}
#[test]
fn direct_read_count_is_bounded_and_stereo_aligned() {
assert_eq!(0, bounded_direct_read_sample_count(1));
assert_eq!(2, bounded_direct_read_sample_count(3));
assert_eq!(
DIRECT_CAPTURE_MAX_READ_SAMPLES,
bounded_direct_read_sample_count(DIRECT_CAPTURE_MAX_READ_SAMPLES + 1),
);
}
#[test]
fn direct_read_count_uses_stable_twenty_ms_chunks() {
assert_eq!(1_920, DIRECT_CAPTURE_MAX_READ_SAMPLES);
assert_eq!(
1_920,
bounded_direct_read_sample_count_for_format(9_600, 48_000, 2)
);
assert_eq!(
1_764,
bounded_direct_read_sample_count_for_format(9_600, 44_100, 2)
);
}
#[test]
fn sample_duration_conversion_uses_whole_audio_frames() {
assert_eq!(1_920, sample_count_for_duration_us(48_000, 2, 20_000));
assert_eq!(20_000, duration_us_for_sample_count(1_920, 48_000, 2));
assert_eq!(0, sample_count_for_duration_us(0, 2, 20_000));
assert_eq!(0, duration_us_for_sample_count(1_920, 0, 2));
}
#[test]
fn direct_append_slice_keeps_only_complete_stereo_samples_within_queue_cap() {
let samples: [f32; 5] = [1.0, 2.0, 3.0, 4.0, 5.0];
let trimmed = bounded_direct_append_slice(&samples);
assert_eq!(4, trimmed.len());
assert_eq!(&samples[..4], trimmed);
}
#[test]
fn direct_capture_latency_matches_stable_screen_share_buffer() {
assert_eq!("4096/48000", direct_capture_latency_fraction());
}
#[test]
fn routing_parser_caps_are_intentionally_finite() {
const {
assert!(MAX_ROUTING_RULE_PATTERNS > 0);
assert!(MAX_ROUTING_RULE_KEYS_PER_PATTERN > 0);
assert!(MAX_ROUTING_RULE_KEY_LENGTH > 0);
assert!(MAX_ROUTING_RULE_VALUE_LENGTH >= MAX_ROUTING_RULE_KEY_LENGTH);
assert!(MAX_INVENTORY_FIELDS <= MAX_ROUTING_RULE_KEYS_PER_PATTERN);
assert!(MAX_INVENTORY_FIELD_LENGTH <= MAX_ROUTING_RULE_KEY_LENGTH);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::routing::{PropMap, RoutingRule, SelfIdentity};
#[derive(Default, Clone)]
pub struct RoutingGraphSnapshot {
pub backend: String,
pub nodes: Vec<RoutingGraphNode>,
pub ports: Vec<RoutingGraphPort>,
pub owned_links: Vec<RoutingGraphLink>,
}
#[derive(Default, Clone)]
pub struct RoutingGraphNode {
pub id: u32,
pub props: PropMap,
}
#[derive(Default, Clone)]
pub struct RoutingGraphPort {
pub id: u32,
pub node_id: u32,
pub direction: String,
pub channel: String,
pub props: PropMap,
}
#[derive(Default, Clone, Copy)]
pub struct RoutingGraphLink {
pub output_node_id: u32,
pub output_port_id: u32,
pub input_node_id: u32,
pub input_port_id: u32,
}
pub trait CaptureBridge: Send + Sync {
fn inventory(&self) -> Vec<PropMap>;
fn apply(&self, rule: RoutingRule) -> bool;
fn release(&self);
fn populate_self_identity(&self, identity: SelfIdentity);
fn backend_name(&self) -> &'static str;
fn routing_graph(&self) -> RoutingGraphSnapshot {
RoutingGraphSnapshot {
backend: self.backend_name().to_string(),
..Default::default()
}
}
}
pub trait DirectCapture: Send + Sync {
fn start(&self, rule: RoutingRule) -> bool;
fn set_rule(&self, rule: RoutingRule) -> bool;
fn read(&self) -> Option<CapturedFrame>;
fn stop(&self);
fn populate_self_identity(&self, identity: SelfIdentity);
fn set_screen_audio_sink(
&self,
_sink: std::sync::Arc<fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef>,
) {
}
fn clear_screen_audio_sink(&self) {}
fn routing_graph(&self) -> RoutingGraphSnapshot {
RoutingGraphSnapshot::default()
}
fn last_push_ns_arc(&self) -> Option<std::sync::Arc<std::sync::atomic::AtomicU64>> {
None
}
}
pub struct CapturedFrame {
pub samples: Vec<f32>,
pub sample_rate: u32,
pub channels: u32,
pub timestamp_us: i64,
}
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::VecDeque;
use crate::audio_contract::{
self, DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_MAX_SAMPLES, DIRECT_CAPTURE_SAMPLE_RATE,
};
use crate::backend::CapturedFrame;
pub struct DirectReadMeta {
pub sample_rate: u32,
pub channels: u32,
pub timestamp_us: i64,
}
pub struct DirectAudioBuffer {
samples: VecDeque<f32>,
queue_start_us: i64,
sample_rate: u32,
channels: u32,
}
impl DirectAudioBuffer {
pub fn new(sample_rate: u32, channels: u32) -> Self {
Self {
samples: VecDeque::with_capacity(DIRECT_CAPTURE_MAX_SAMPLES),
queue_start_us: 0,
sample_rate: sample_rate.max(1),
channels: channels.max(1),
}
}
pub fn default_format() -> Self {
Self::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.samples.len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
#[cfg(test)]
pub fn queue_start_us(&self) -> i64 {
self.queue_start_us
}
pub fn set_format(&mut self, sample_rate: u32, channels: u32) {
let sample_rate = sample_rate.max(1);
let channels = channels.max(1);
if self.sample_rate != sample_rate || self.channels != channels {
self.clear();
}
self.sample_rate = sample_rate;
self.channels = channels;
}
pub fn clear(&mut self) {
self.samples.clear();
self.queue_start_us = 0;
}
pub fn push(&mut self, input: &[f32], end_timestamp_us: i64) {
let whole = audio_contract::whole_frame_sample_count(input.len(), self.channels);
if whole == 0 {
return;
}
let mut frame = &input[..whole];
if frame.len() > DIRECT_CAPTURE_MAX_SAMPLES {
let keep =
audio_contract::whole_frame_sample_count(DIRECT_CAPTURE_MAX_SAMPLES, self.channels);
frame = &frame[frame.len() - keep..];
self.clear();
}
if self.samples.is_empty() {
let duration_us = audio_contract::duration_us_for_sample_count(
frame.len(),
self.sample_rate,
self.channels,
);
self.queue_start_us = end_timestamp_us.saturating_sub(duration_us);
}
self.drop_for_incoming(frame.len());
self.samples.extend(frame.iter().copied());
}
pub fn read(&mut self) -> Option<CapturedFrame> {
let mut out = Vec::with_capacity(audio_contract::DIRECT_CAPTURE_MAX_READ_SAMPLES);
let meta = self.read_into(&mut out)?;
Some(CapturedFrame {
samples: out,
sample_rate: meta.sample_rate,
channels: meta.channels,
timestamp_us: meta.timestamp_us,
})
}
pub fn read_into(&mut self, out: &mut Vec<f32>) -> Option<DirectReadMeta> {
assert!(self.sample_rate >= 1);
assert!(self.channels >= 1);
out.clear();
if self.samples.is_empty() {
return None;
}
let take = audio_contract::bounded_direct_read_sample_count_for_format(
self.samples.len(),
self.sample_rate,
self.channels,
);
if take == 0 {
return None;
}
assert!(take <= self.samples.len());
assert!(take.is_multiple_of(self.channels as usize));
let timestamp_us = self.queue_start_us.max(0);
let (front, back) = self.samples.as_slices();
let front_take = take.min(front.len());
out.extend_from_slice(&front[..front_take]);
out.extend_from_slice(&back[..take - front_take]);
self.samples.drain(..take);
self.queue_start_us =
self.queue_start_us
.saturating_add(audio_contract::duration_us_for_sample_count(
take,
self.sample_rate,
self.channels,
));
if self.samples.is_empty() {
self.queue_start_us = 0;
}
Some(DirectReadMeta {
sample_rate: self.sample_rate,
channels: self.channels,
timestamp_us,
})
}
fn drop_for_incoming(&mut self, incoming: usize) {
assert!(incoming >= 1);
assert!(incoming <= DIRECT_CAPTURE_MAX_SAMPLES);
assert!(self.channels >= 1);
let total = self.samples.len() + incoming;
if total <= DIRECT_CAPTURE_MAX_SAMPLES {
return;
}
let overflow = total - DIRECT_CAPTURE_MAX_SAMPLES;
let channels = self.channels as usize;
let remainder = overflow % channels;
let drop = if remainder == 0 {
overflow
} else {
overflow + (channels - remainder)
}
.min(self.samples.len());
self.samples.drain(..drop);
self.queue_start_us =
self.queue_start_us
.saturating_add(audio_contract::duration_us_for_sample_count(
drop,
self.sample_rate,
self.channels,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_emits_stable_twenty_ms_chunks_with_continuous_timestamps() {
let mut buffer = DirectAudioBuffer::default_format();
buffer.push(&vec![0.5; 4_800], 1_000_000);
let first = buffer.read().expect("first chunk");
assert_eq!(1_920, first.samples.len());
assert_eq!(950_000, first.timestamp_us);
let second = buffer.read().expect("second chunk");
assert_eq!(1_920, second.samples.len());
assert_eq!(970_000, second.timestamp_us);
}
#[test]
fn format_changes_clear_queued_samples() {
let mut buffer = DirectAudioBuffer::default_format();
buffer.push(&vec![0.5; 1_920], 100_000);
assert!(!buffer.is_empty());
buffer.set_format(44_100, 2);
assert!(buffer.is_empty());
assert_eq!(0, buffer.queue_start_us());
buffer.push(&vec![0.25; 1_764], 200_000);
let frame = buffer.read().expect("chunk");
assert_eq!(1_764, frame.samples.len());
assert_eq!(180_000, frame.timestamp_us);
assert_eq!(44_100, frame.sample_rate);
}
#[test]
fn overflow_drops_from_front_and_advances_timestamp() {
let mut buffer = DirectAudioBuffer::default_format();
buffer.push(&vec![0.5; DIRECT_CAPTURE_MAX_SAMPLES + 1_920], 3_000_000);
assert_eq!(DIRECT_CAPTURE_MAX_SAMPLES, buffer.len());
let frame = buffer.read().expect("chunk");
assert_eq!(1_000_000, frame.timestamp_us);
}
}
@@ -0,0 +1,425 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::BTreeMap;
pub const AUDIO_BUFFERING_MAX_TICKS: u32 = 64;
pub const SOURCE_RESET_AFTER_BUFFERED_TICKS: u32 = 128;
pub const SOURCE_STALE_AFTER_NS: u64 = 5_000_000_000;
pub const NEVER_PUSHED_SENTINEL: u64 = u64::MAX;
pub fn compute_source_age_ns(last_push_ns: u64, registered_at_ns: u64, now_ns: u64) -> u64 {
let baseline_ns = if last_push_ns == NEVER_PUSHED_SENTINEL {
registered_at_ns
} else {
last_push_ns
};
assert!(baseline_ns != NEVER_PUSHED_SENTINEL);
if now_ns <= baseline_ns {
return 0;
}
now_ns - baseline_ns
}
pub const SAMPLE_RATE_HZ_MIN: u32 = 8_000;
pub const SAMPLE_RATE_HZ_MAX: u32 = 384_000;
pub const TICK_PERIOD_NS_MIN: u64 = 1_000_000;
pub const TICK_PERIOD_NS_MAX: u64 = 100_000_000;
pub const BUFFERED_FRAMES_MAX: u64 = 1 << 28;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IgnoreAudioDecision {
Mix,
IgnoreThisTick,
ResetSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IgnoreAudioResetReason {
BufferOverflow,
StaleSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IgnoreAudioSourceState {
pub id: u64,
pub buffered_frames: u64,
pub last_frame_age_ns: u64,
pub is_muted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioTick {
pub at_ns: u64,
pub period_ns: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioMetrics {
pub ignored_tick_count: u64,
pub reset_count: u64,
}
impl IgnoreAudioMetrics {
pub const ZERO: IgnoreAudioMetrics = IgnoreAudioMetrics {
ignored_tick_count: 0,
reset_count: 0,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioSourceResetEvent {
pub source_id: u64,
pub at_ns: u64,
pub buffered_frames_at_reset: u64,
pub last_frame_age_ns: u64,
pub reason: IgnoreAudioResetReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IgnoreAudioEvaluation {
pub decision: IgnoreAudioDecision,
pub event: Option<IgnoreAudioSourceResetEvent>,
}
#[derive(Debug, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
pub enum IgnoreAudioError {
SampleRateOutOfRange { sample_rate_hz: u32 },
TickPeriodOutOfRange { period_ns: u64 },
BufferedFramesOutOfRange { buffered_frames: u64 },
}
pub struct IgnoreAudioPolicy {
sample_rate_hz: u32,
metrics_by_source: BTreeMap<u64, IgnoreAudioMetrics>,
}
impl IgnoreAudioPolicy {
pub fn new(sample_rate_hz: u32) -> Result<Self, IgnoreAudioError> {
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
return Err(IgnoreAudioError::SampleRateOutOfRange { sample_rate_hz });
}
assert!(sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
assert!(sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
Ok(Self {
sample_rate_hz,
metrics_by_source: BTreeMap::new(),
})
}
pub fn sample_rate_hz(&self) -> u32 {
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
assert!(self.sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
self.sample_rate_hz
}
pub fn metrics_for(&self, source_id: u64) -> IgnoreAudioMetrics {
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
self.metrics_by_source
.get(&source_id)
.copied()
.unwrap_or(IgnoreAudioMetrics::ZERO)
}
pub fn evaluate(
&mut self,
source_state: &IgnoreAudioSourceState,
tick: IgnoreAudioTick,
) -> Result<IgnoreAudioEvaluation, IgnoreAudioError> {
validate_source_state(source_state)?;
validate_tick(tick)?;
let tick_frames = compute_tick_frames(tick.period_ns, self.sample_rate_hz)?;
assert!(tick_frames >= 1);
let decision = decide(source_state, tick_frames);
let event = self.apply_decision(source_state, tick, decision);
let evaluation = IgnoreAudioEvaluation { decision, event };
assert_evaluation_invariant(&evaluation);
Ok(evaluation)
}
fn apply_decision(
&mut self,
source_state: &IgnoreAudioSourceState,
tick: IgnoreAudioTick,
decision: IgnoreAudioDecision,
) -> Option<IgnoreAudioSourceResetEvent> {
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
match decision {
IgnoreAudioDecision::Mix => None,
IgnoreAudioDecision::IgnoreThisTick => {
let entry = self
.metrics_by_source
.entry(source_state.id)
.or_insert(IgnoreAudioMetrics::ZERO);
entry.ignored_tick_count = entry.ignored_tick_count.saturating_add(1);
None
}
IgnoreAudioDecision::ResetSource => {
let entry = self
.metrics_by_source
.entry(source_state.id)
.or_insert(IgnoreAudioMetrics::ZERO);
entry.reset_count = entry.reset_count.saturating_add(1);
let reason = if source_state.last_frame_age_ns > SOURCE_STALE_AFTER_NS {
IgnoreAudioResetReason::StaleSource
} else {
IgnoreAudioResetReason::BufferOverflow
};
Some(IgnoreAudioSourceResetEvent {
source_id: source_state.id,
at_ns: tick.at_ns,
buffered_frames_at_reset: source_state.buffered_frames,
last_frame_age_ns: source_state.last_frame_age_ns,
reason,
})
}
}
}
}
pub fn compute_tick_frames(period_ns: u64, sample_rate_hz: u32) -> Result<u64, IgnoreAudioError> {
if !(TICK_PERIOD_NS_MIN..=TICK_PERIOD_NS_MAX).contains(&period_ns) {
return Err(IgnoreAudioError::TickPeriodOutOfRange { period_ns });
}
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
return Err(IgnoreAudioError::SampleRateOutOfRange { sample_rate_hz });
}
let product: u128 = (period_ns as u128) * (sample_rate_hz as u128);
let frames = (product / 1_000_000_000u128) as u64;
let frames = frames.max(1);
assert!(frames >= 1);
assert!(frames <= BUFFERED_FRAMES_MAX);
Ok(frames)
}
fn decide(source_state: &IgnoreAudioSourceState, tick_frames: u64) -> IgnoreAudioDecision {
assert!(tick_frames >= 1);
if source_state.is_muted {
return IgnoreAudioDecision::Mix;
}
let reset_by_stale = source_state.last_frame_age_ns > SOURCE_STALE_AFTER_NS;
let reset_by_buffer =
source_state.buffered_frames > (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * tick_frames;
if reset_by_stale {
return IgnoreAudioDecision::ResetSource;
}
if reset_by_buffer {
return IgnoreAudioDecision::ResetSource;
}
if source_state.buffered_frames <= tick_frames {
return IgnoreAudioDecision::Mix;
}
let over_buffering =
source_state.buffered_frames > (AUDIO_BUFFERING_MAX_TICKS as u64) * tick_frames;
if over_buffering {
return IgnoreAudioDecision::IgnoreThisTick;
}
IgnoreAudioDecision::Mix
}
fn validate_source_state(state: &IgnoreAudioSourceState) -> Result<(), IgnoreAudioError> {
if state.buffered_frames > BUFFERED_FRAMES_MAX {
return Err(IgnoreAudioError::BufferedFramesOutOfRange {
buffered_frames: state.buffered_frames,
});
}
Ok(())
}
fn validate_tick(tick: IgnoreAudioTick) -> Result<(), IgnoreAudioError> {
if !(TICK_PERIOD_NS_MIN..=TICK_PERIOD_NS_MAX).contains(&tick.period_ns) {
return Err(IgnoreAudioError::TickPeriodOutOfRange {
period_ns: tick.period_ns,
});
}
Ok(())
}
fn assert_evaluation_invariant(evaluation: &IgnoreAudioEvaluation) {
match evaluation.decision {
IgnoreAudioDecision::ResetSource => {
assert!(evaluation.event.is_some());
}
_ => {
assert!(evaluation.event.is_none());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canonical_tick(at_ns: u64) -> IgnoreAudioTick {
IgnoreAudioTick {
at_ns,
period_ns: 21_333_333,
}
}
#[test]
fn rejects_sample_rate_below_min() {
let err = IgnoreAudioPolicy::new(4_000).err();
assert!(matches!(
err,
Some(IgnoreAudioError::SampleRateOutOfRange { .. })
));
}
#[test]
fn rejects_sample_rate_above_max() {
let err = IgnoreAudioPolicy::new(500_000).err();
assert!(matches!(
err,
Some(IgnoreAudioError::SampleRateOutOfRange { .. })
));
}
#[test]
fn compute_tick_frames_at_48k_21_3ms_yields_1023_ish() {
let frames = compute_tick_frames(21_333_333, 48_000).expect("ok");
assert!(frames >= 1023);
assert!(frames <= 1024);
}
#[test]
fn empty_buffer_yields_mix_decision() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 1,
buffered_frames: 0,
last_frame_age_ns: 0,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::Mix);
assert!(evaluation.event.is_none());
}
#[test]
fn muted_source_always_yields_mix_decision() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 5,
buffered_frames: 10_000_000,
last_frame_age_ns: 0,
is_muted: true,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::Mix);
}
#[test]
fn over_buffered_yields_ignore_this_tick() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 9,
buffered_frames: (AUDIO_BUFFERING_MAX_TICKS as u64) * 1024 + 1,
last_frame_age_ns: 0,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::IgnoreThisTick);
assert_eq!(policy.metrics_for(9).ignored_tick_count, 1);
}
#[test]
fn over_threshold_buffer_triggers_reset() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 12,
buffered_frames: (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * 1024 + 1,
last_frame_age_ns: 0,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(7)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::ResetSource);
let event = evaluation.event.expect("reset emits event");
assert_eq!(event.source_id, 12);
assert_eq!(event.at_ns, 7);
assert_eq!(event.reason, IgnoreAudioResetReason::BufferOverflow);
assert_eq!(policy.metrics_for(12).reset_count, 1);
}
#[test]
fn stale_source_triggers_reset_with_stale_reason() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 1,
buffered_frames: 0,
last_frame_age_ns: SOURCE_STALE_AFTER_NS + 1,
is_muted: false,
};
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
assert_eq!(evaluation.decision, IgnoreAudioDecision::ResetSource);
let event = evaluation.event.expect("reset emits event");
assert_eq!(event.reason, IgnoreAudioResetReason::StaleSource);
}
#[test]
fn period_too_small_rejected() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 1,
buffered_frames: 0,
last_frame_age_ns: 0,
is_muted: false,
};
let err = policy
.evaluate(
&state,
IgnoreAudioTick {
at_ns: 0,
period_ns: 0,
},
)
.err();
assert!(matches!(
err,
Some(IgnoreAudioError::TickPeriodOutOfRange { .. })
));
}
#[test]
fn compute_source_age_uses_registration_for_never_pushed() {
let registered_at_ns = 1_000_000;
let now_ns = 1_000_000 + 6_000_000_000;
let age = compute_source_age_ns(NEVER_PUSHED_SENTINEL, registered_at_ns, now_ns);
assert_eq!(age, 6_000_000_000);
}
#[test]
fn compute_source_age_uses_last_push_after_first_push() {
let registered_at_ns = 1_000;
let last_push_ns = 5_000;
let age = compute_source_age_ns(last_push_ns, registered_at_ns, 9_000);
assert_eq!(age, 4_000);
}
#[test]
fn compute_source_age_zero_when_now_before_baseline() {
let age = compute_source_age_ns(NEVER_PUSHED_SENTINEL, 5_000, 1_000);
assert_eq!(age, 0);
}
#[test]
fn metrics_accumulate_across_calls() {
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
let state = IgnoreAudioSourceState {
id: 2,
buffered_frames: (AUDIO_BUFFERING_MAX_TICKS as u64) * 1024 + 1,
last_frame_age_ns: 0,
is_muted: false,
};
for _ in 0..5 {
let _ = policy.evaluate(&state, canonical_tick(0));
}
assert_eq!(policy.metrics_for(2).ignored_tick_count, 5);
}
}
@@ -0,0 +1,863 @@
#![allow(clippy::too_many_lines)]
// SPDX-License-Identifier: AGPL-3.0-or-later
mod audio_contract;
mod audio_mix_runtime;
mod backend;
mod direct_buffer;
mod ignore_audio_runtime;
#[doc(hidden)]
pub mod audio_mix_runtime_bench_helpers {
pub use crate::audio_mix_runtime::{
AudioMixRuntime, AudioMixRuntimeBuilder, CaptureSource, CapturedMixOutputSink,
MIX_CHANNELS, MIX_SAMPLE_RATE_HZ, MIX_TICK_PERIOD_NS, MixOutputFrame, MixOutputSink,
MixRuntimeError, NullMixOutputSink, SOURCE_RING_CAP_FRAMES,
};
}
#[doc(hidden)]
pub mod ignore_audio_bench_helpers {
pub use crate::ignore_audio_runtime::{
AUDIO_BUFFERING_MAX_TICKS, IgnoreAudioDecision, IgnoreAudioEvaluation, IgnoreAudioMetrics,
IgnoreAudioPolicy, IgnoreAudioResetReason, IgnoreAudioSourceResetEvent,
IgnoreAudioSourceState, IgnoreAudioTick, SOURCE_RESET_AFTER_BUFFERED_TICKS,
SOURCE_STALE_AFTER_NS,
};
}
#[cfg(target_os = "linux")]
mod pipewire;
#[cfg(target_os = "linux")]
mod pipewire_bridge;
mod routing;
#[cfg(target_os = "linux")]
mod self_identity;
use std::ptr;
use std::sync::Arc;
use std::sync::Mutex;
use fluxer_screen_frame_bus::{NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef};
use napi::Env;
use napi::JsValue;
use napi::Status;
use napi::bindgen_prelude::{
Array, ArrayBuffer, Error, Function, Object, Result, Unknown, ValueType,
};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use napi_derive::napi;
use crate::audio_contract::{
MAX_INVENTORY_FIELD_LENGTH, MAX_INVENTORY_FIELDS, MAX_ROUTING_RULE_KEY_LENGTH,
MAX_ROUTING_RULE_KEYS_PER_PATTERN, MAX_ROUTING_RULE_PATTERNS, MAX_ROUTING_RULE_VALUE_LENGTH,
};
use crate::backend::{
CaptureBridge as CaptureBridgeTrait, DirectCapture as DirectCaptureTrait, RoutingGraphSnapshot,
};
use crate::routing::{PropMap, PropPattern, RoutingRule, SelfIdentity};
type LifecycleTsfn =
Arc<ThreadsafeFunction<(String, String), (), (String, String), Status, false, false, 8>>;
#[cfg(target_os = "linux")]
fn make_self_identity() -> SelfIdentity {
let mut id = SelfIdentity::default();
self_identity::populate_self_identity(&mut id);
id
}
#[cfg(not(target_os = "linux"))]
#[allow(dead_code)]
fn make_self_identity() -> SelfIdentity {
SelfIdentity::default()
}
#[cfg(target_os = "linux")]
fn open_capture_backend() -> Option<(Box<dyn CaptureBridgeTrait>, &'static str)> {
if let Some(bridge) = pipewire_bridge::PipeWireBridge::open() {
bridge.populate_self_identity(make_self_identity());
return Some((Box::new(bridge), "pipewire"));
}
None
}
#[cfg(not(target_os = "linux"))]
fn open_capture_backend() -> Option<(Box<dyn CaptureBridgeTrait>, &'static str)> {
None
}
#[cfg(target_os = "linux")]
fn open_direct_backend() -> Option<Box<dyn DirectCaptureTrait>> {
if let Some(direct) = pipewire_bridge::PipeWireDirectCapture::open() {
direct.populate_self_identity(make_self_identity());
return Some(Box::new(direct));
}
None
}
#[cfg(not(target_os = "linux"))]
fn open_direct_backend() -> Option<Box<dyn DirectCaptureTrait>> {
None
}
#[cfg(target_os = "linux")]
fn pipewire_reachable() -> bool {
pipewire_bridge::daemon_reachable()
}
#[cfg(not(target_os = "linux"))]
fn pipewire_reachable() -> bool {
false
}
#[napi(js_name = "pipeWireAvailable")]
pub fn pipe_wire_available() -> bool {
pipewire_reachable()
}
#[napi(js_name = "audioBackend")]
pub fn audio_backend() -> &'static str {
if pipewire_reachable() {
"pipewire"
} else {
"none"
}
}
#[napi]
pub struct AudioBridge {
backend: Mutex<Option<Box<dyn CaptureBridgeTrait>>>,
name: &'static str,
}
#[napi]
impl AudioBridge {
#[napi(constructor)]
pub fn new() -> Self {
match open_capture_backend() {
Some((backend, name)) => Self {
backend: Mutex::new(Some(backend)),
name,
},
None => Self {
backend: Mutex::new(None),
name: "none",
},
}
}
#[napi]
pub fn inventory(&self, fields: Option<Vec<String>>) -> Result<Vec<PropMapWire>> {
let fields = match fields {
Some(values) => validate_inventory_fields(values)?,
None => Vec::new(),
};
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
let snapshot = guard.as_ref().map(|b| b.inventory()).unwrap_or_default();
Ok(snapshot
.into_iter()
.map(|entry| project_inventory_entry(entry, &fields))
.collect())
}
#[napi]
pub fn apply(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.apply(parsed)))
}
#[napi]
pub fn release(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.release();
}
Ok(())
}
#[napi(js_name = "routingGraph")]
pub fn routing_graph(&self) -> Result<RoutingGraphWire> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
let graph = guard
.as_ref()
.map(|b| b.routing_graph())
.unwrap_or_default();
Ok(RoutingGraphWire(graph))
}
#[napi]
pub fn backend(&self) -> &'static str {
self.name
}
}
impl Default for AudioBridge {
fn default() -> Self {
Self::new()
}
}
fn retain_screen_audio_sink_handle(
value: Unknown<'_>,
) -> Result<Arc<NativeScreenFrameSinkHandleRef>> {
if value.get_type()? != ValueType::External {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink expects a native external sink handle",
));
}
let raw_value = value.value();
let mut data: *mut std::ffi::c_void = ptr::null_mut();
let status =
unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) };
if status != napi::sys::Status::napi_ok || data.is_null() {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink received an empty native external sink handle",
));
}
let handle = unsafe {
NativeScreenFrameSinkHandle::retain_from_raw(data.cast::<NativeScreenFrameSinkHandle>())
}
.ok_or_else(|| {
generic_error("DirectAudioCapture.setScreenAudioSink received an invalid handle")
})?;
Ok(Arc::new(handle))
}
#[napi]
pub struct DirectAudioCapture {
backend: Mutex<Option<Box<dyn DirectCaptureTrait>>>,
lifecycle_tsfn: Mutex<Option<LifecycleTsfn>>,
}
#[napi]
impl DirectAudioCapture {
#[napi(constructor)]
pub fn new() -> Self {
Self {
backend: Mutex::new(open_direct_backend()),
lifecycle_tsfn: Mutex::new(None),
}
}
#[napi(js_name = "setLifecycleCallback")]
pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> {
let tsfn: LifecycleTsfn = Arc::new(
callback
.build_threadsafe_function::<(String, String)>()
.max_queue_size::<8>()
.build_callback(|ctx| Ok(ctx.value))?,
);
let mut guard = self
.lifecycle_tsfn
.lock()
.map_err(|_| generic_error("DirectAudioCapture lifecycle poisoned"))?;
*guard = Some(tsfn);
Ok(())
}
#[napi]
pub fn start(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.start(parsed)))
}
#[napi]
pub fn set_rule(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.set_rule(parsed)))
}
#[napi]
pub fn read<'env>(&self, env: &'env Env) -> Result<Option<NativeAudioFrame<'env>>> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let Some(backend) = guard.as_ref() else {
return Ok(None);
};
let Some(frame) = backend.read() else {
return Ok(None);
};
let arraybuffer = audio_samples_to_arraybuffer(env, &frame.samples)?;
Ok(Some(NativeAudioFrame {
samples: arraybuffer,
sample_rate: frame.sample_rate,
channels: frame.channels,
timestamp_us: frame.timestamp_us.max(0) as f64,
}))
}
#[napi(js_name = "setScreenAudioSink")]
pub fn set_screen_audio_sink(&self, sink_handle: Unknown<'_>) -> Result<()> {
let sink = retain_screen_audio_sink_handle(sink_handle)?;
if !sink.supports_screen_audio() {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink handle does not support screen audio",
));
}
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.set_screen_audio_sink(sink);
}
Ok(())
}
#[napi(js_name = "clearScreenAudioSink")]
pub fn clear_screen_audio_sink(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.clear_screen_audio_sink();
}
Ok(())
}
#[napi]
pub fn stop(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.stop();
}
drop(guard);
self.emit_lifecycle("closed-clean", "direct audio capture stopped");
Ok(())
}
#[napi(js_name = "routingGraph")]
pub fn routing_graph(&self) -> Result<RoutingGraphWire> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let graph = guard
.as_ref()
.map(|b| b.routing_graph())
.unwrap_or_default();
Ok(RoutingGraphWire(graph))
}
}
impl Default for DirectAudioCapture {
fn default() -> Self {
Self::new()
}
}
impl DirectAudioCapture {
fn emit_lifecycle(&self, kind: &str, message: &str) {
let tsfn = self
.lifecycle_tsfn
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned());
let Some(tsfn) = tsfn else {
return;
};
let _: Status = tsfn.call(
(kind.to_string(), message.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
}
#[napi]
pub struct AudioMixRuntimeHandle {
inner: Mutex<Option<crate::audio_mix_runtime::AudioMixRuntime>>,
source_count: u32,
mark_pushed_total: Arc<std::sync::atomic::AtomicU64>,
}
#[napi]
impl AudioMixRuntimeHandle {
#[napi(constructor)]
pub fn new(source_count: u32) -> Result<Self> {
Self::build(source_count, None)
}
#[napi(factory, js_name = "boundToDirectCapture")]
pub fn bound_to_direct_capture(direct: &DirectAudioCapture) -> Result<Self> {
let arc = direct_capture_freshness(direct)?;
Self::build(1, Some(arc))
}
fn build(
source_count: u32,
bound_freshness: Option<Arc<std::sync::atomic::AtomicU64>>,
) -> Result<Self> {
if source_count == 0 {
return Err(invalid_arg(
"AudioMixRuntimeHandle requires at least 1 source",
));
}
if source_count as usize > fluxer_audio_mix::MAX_MIX_SOURCES {
return Err(invalid_arg("AudioMixRuntimeHandle exceeds MAX_MIX_SOURCES"));
}
let clock: Arc<dyn fluxer_rt_thread::MonotonicClock> =
Arc::new(fluxer_rt_thread::SystemMonotonicClock::new());
let mut builder =
crate::audio_mix_runtime::AudioMixRuntimeBuilder::new().with_clock(Arc::clone(&clock));
for index in 0..source_count {
let source_id = (index as u64) + 1;
let (_source, consumer) = crate::audio_mix_runtime::CaptureSource::create(
source_id,
crate::audio_mix_runtime::MIX_SAMPLE_RATE_HZ,
crate::audio_mix_runtime::MIX_CHANNELS,
)
.map_err(|_| generic_error("CaptureSource::create failed"))?;
let freshness = if index == 0 {
match &bound_freshness {
Some(arc) => Arc::clone(arc),
None => Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX)),
}
} else {
Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX))
};
builder = builder.add_source_with_freshness(source_id, consumer, freshness);
}
let runtime = builder
.build(crate::audio_mix_runtime::NullMixOutputSink)
.map_err(|_| generic_error("AudioMixRuntimeBuilder::build failed"))?;
let mark_pushed_total = runtime.mark_pushed_total_arc();
Ok(Self {
inner: Mutex::new(Some(runtime)),
source_count,
mark_pushed_total,
})
}
#[napi(js_name = "sourceCount")]
pub fn source_count_js(&self) -> u32 {
assert!(self.source_count > 0);
assert!(self.source_count as usize <= fluxer_audio_mix::MAX_MIX_SOURCES);
self.source_count
}
#[napi]
pub fn tick(&self, tick_at_ns: Option<i64>) -> Result<u32> {
use fluxer_rt_thread::MonotonicClock as _;
assert!(self.source_count > 0);
let mut guard = self
.inner
.lock()
.map_err(|_| generic_error("AudioMixRuntimeHandle poisoned"))?;
let runtime = guard
.as_mut()
.ok_or_else(|| generic_error("AudioMixRuntimeHandle disposed"))?;
let at_ns: u64 = match tick_at_ns {
Some(v) if v > 0 => v as u64,
_ => fluxer_rt_thread::SystemMonotonicClock::new().now_ns(),
};
assert!(at_ns > 0);
let marked = runtime
.observe_source_pushes_without_mix(at_ns)
.map_err(|_| generic_error("AudioMixRuntime tick failed"))?;
Ok(marked.min(u32::MAX as u64) as u32)
}
#[napi(js_name = "markPushedTotal")]
pub fn mark_pushed_total_js(&self) -> u32 {
assert!(self.source_count > 0);
let value = self
.mark_pushed_total
.load(std::sync::atomic::Ordering::Acquire);
let clamped = value.min(u32::MAX as u64);
assert!(clamped <= u32::MAX as u64);
clamped as u32
}
#[napi]
pub fn dispose(&self) -> Result<()> {
assert!(self.source_count > 0);
assert!(self.source_count as usize <= fluxer_audio_mix::MAX_MIX_SOURCES);
let mut guard = self
.inner
.lock()
.map_err(|_| generic_error("AudioMixRuntimeHandle poisoned"))?;
guard.take();
Ok(())
}
}
fn direct_capture_freshness(
direct: &DirectAudioCapture,
) -> Result<Arc<std::sync::atomic::AtomicU64>> {
let guard = direct
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let backend = guard
.as_ref()
.ok_or_else(|| generic_error("DirectAudioCapture backend unavailable"))?;
backend
.last_push_ns_arc()
.ok_or_else(|| generic_error("DirectAudioCapture backend lacks freshness atomic"))
}
#[napi(object)]
pub struct NativeAudioFrame<'env> {
pub samples: ArrayBuffer<'env>,
#[napi(js_name = "sampleRate")]
pub sample_rate: u32,
pub channels: u32,
#[napi(js_name = "timestampUs")]
pub timestamp_us: f64,
}
pub struct PropMapWire(pub PropMap);
impl napi::bindgen_prelude::ToNapiValue for PropMapWire {
unsafe fn to_napi_value(
raw_env: napi::sys::napi_env,
value: Self,
) -> Result<napi::sys::napi_value> {
let env = napi::Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
for (key, val) in value.0 {
object.set(&key, val)?;
}
unsafe {
<Object<'_> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(raw_env, object)
}
}
}
pub struct RoutingGraphWire(pub RoutingGraphSnapshot);
impl napi::bindgen_prelude::ToNapiValue for RoutingGraphWire {
unsafe fn to_napi_value(
raw_env: napi::sys::napi_env,
value: Self,
) -> Result<napi::sys::napi_value> {
let env = napi::Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
object.set("backend", value.0.backend)?;
object.set("nodes", routing_graph_nodes_to_array(&env, value.0.nodes)?)?;
object.set("ports", routing_graph_ports_to_array(&env, value.0.ports)?)?;
object.set(
"ownedLinks",
routing_graph_links_to_array(&env, value.0.owned_links)?,
)?;
unsafe {
<Object<'_> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(raw_env, object)
}
}
}
fn prop_map_to_object<'env>(env: &'env Env, props: PropMap) -> Result<Object<'env>> {
let mut object = Object::new(env)?;
for (key, value) in props {
object.set(&key, value)?;
}
Ok(object)
}
fn routing_graph_nodes_to_array<'env>(
env: &'env Env,
nodes: Vec<crate::backend::RoutingGraphNode>,
) -> Result<Array<'env>> {
let mut array = env.create_array(nodes.len() as u32)?;
for (index, node) in nodes.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("id", node.id)?;
object.set("props", prop_map_to_object(env, node.props)?)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn routing_graph_ports_to_array<'env>(
env: &'env Env,
ports: Vec<crate::backend::RoutingGraphPort>,
) -> Result<Array<'env>> {
let mut array = env.create_array(ports.len() as u32)?;
for (index, port) in ports.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("id", port.id)?;
object.set("nodeId", port.node_id)?;
object.set("direction", port.direction)?;
object.set("channel", port.channel)?;
object.set("props", prop_map_to_object(env, port.props)?)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn routing_graph_links_to_array<'env>(
env: &'env Env,
links: Vec<crate::backend::RoutingGraphLink>,
) -> Result<Array<'env>> {
let mut array = env.create_array(links.len() as u32)?;
for (index, link) in links.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("outputNodeId", link.output_node_id)?;
object.set("outputPortId", link.output_port_id)?;
object.set("inputNodeId", link.input_node_id)?;
object.set("inputPortId", link.input_port_id)?;
object.set("owned", true)?;
object.set("passive", true)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn project_inventory_entry(mut entry: PropMap, fields: &[String]) -> PropMapWire {
if fields.is_empty() {
return PropMapWire(entry);
}
let mut filtered = PropMap::with_capacity(fields.len());
for field in fields {
if let Some(value) = entry.remove(field) {
filtered.insert(field.clone(), value);
}
}
PropMapWire(filtered)
}
fn audio_samples_to_arraybuffer<'env>(
env: &'env Env,
samples: &[f32],
) -> Result<ArrayBuffer<'env>> {
let bytes: Vec<u8> = samples
.iter()
.flat_map(|sample| sample.to_le_bytes())
.collect();
ArrayBuffer::from_data(env, bytes)
}
fn validate_inventory_fields(values: Vec<String>) -> Result<Vec<String>> {
if values.len() as u32 > MAX_INVENTORY_FIELDS {
return Err(invalid_arg("too many inventory fields"));
}
for value in &values {
if value.len() > MAX_INVENTORY_FIELD_LENGTH {
return Err(invalid_arg("inventory field exceeds length cap"));
}
}
Ok(values)
}
fn parse_routing_rule(value: &Object) -> Result<RoutingRule> {
Ok(RoutingRule {
include_when: parse_pattern_list(value, "include")?,
never_when: parse_pattern_list(value, "exclude")?,
pin_target_for: parse_pattern_list(value, "workaround")?,
skip_hardware_devices: read_optional_bool(value, "ignoreDevices")?
.or(read_optional_bool(value, "ignore_devices")?)
.unwrap_or(false),
only_audio_sinks: read_optional_bool(value, "onlySpeakers")?
.or(read_optional_bool(value, "only_speakers")?)
.unwrap_or(false),
only_default_audio_sink: read_optional_bool(value, "onlyDefaultSpeakers")?
.or(read_optional_bool(value, "only_default_speakers")?)
.unwrap_or(false),
})
}
fn parse_pattern_list(value: &Object, name: &str) -> Result<Vec<PropPattern>> {
let Some(raw) = read_optional_unknown(value, name)? else {
return Ok(Vec::new());
};
if matches!(
raw.get_type()?,
napi::ValueType::Null | napi::ValueType::Undefined
) {
return Ok(Vec::new());
}
let array = unsafe { raw.cast::<napi::bindgen_prelude::Array>() }
.map_err(|_| invalid_arg(format!("{name} must be an array of objects")))?;
let len = array.len();
if len > MAX_ROUTING_RULE_PATTERNS {
return Err(invalid_arg(format!("{name} exceeds pattern cap")));
}
let mut out = Vec::with_capacity(len as usize);
for index in 0..len {
let entry = array
.get::<Object>(index)
.map_err(|_| invalid_arg(format!("{name}[{index}] must be an object")))?
.ok_or_else(|| invalid_arg(format!("{name}[{index}] must be an object")))?;
out.push(object_to_prop_map(&entry)?);
}
Ok(out)
}
fn object_to_prop_map(object: &Object) -> Result<PropMap> {
let keys = Object::keys(object)?;
if keys.len() as u32 > MAX_ROUTING_RULE_KEYS_PER_PATTERN {
return Err(invalid_arg("routing pattern has too many keys"));
}
let mut out = PropMap::with_capacity(keys.len());
for key in keys {
if key.is_empty() || key.len() > MAX_ROUTING_RULE_KEY_LENGTH {
return Err(invalid_arg("routing pattern key is empty or too long"));
}
let raw = read_optional_unknown(object, &key)?
.ok_or_else(|| invalid_arg("routing pattern value missing"))?;
if raw.get_type()? != napi::ValueType::String {
return Err(invalid_arg("routing pattern value must be a string"));
}
let value: String = unsafe { raw.cast() }?;
if value.len() > MAX_ROUTING_RULE_VALUE_LENGTH {
return Err(invalid_arg("routing pattern value too long"));
}
out.insert(key, value);
}
Ok(out)
}
fn read_optional_unknown<'a>(object: &Object<'a>, name: &str) -> Result<Option<Unknown<'a>>> {
object.get::<Unknown>(name)
}
fn read_optional_bool(object: &Object, name: &str) -> Result<Option<bool>> {
let Some(raw) = read_optional_unknown(object, name)? else {
return Ok(None);
};
match raw.get_type()? {
napi::ValueType::Null | napi::ValueType::Undefined => Ok(None),
napi::ValueType::Boolean => Ok(Some(unsafe { raw.cast() }?)),
_ => Err(invalid_arg(format!("{name} must be a boolean"))),
}
}
fn generic_error(reason: impl Into<String>) -> Error {
Error::new(Status::GenericFailure, reason.into())
}
fn invalid_arg(reason: impl Into<String>) -> Error {
Error::new(Status::InvalidArg, reason.into())
}
#[allow(dead_code)]
fn _keep_arc_in_scope(_: Arc<()>) {}
#[cfg(all(test, target_os = "linux"))]
mod js_path_tests {
use super::AudioMixRuntimeHandle;
use crate::pipewire::stream_ops::{
DIRECT_CAPTURE_APM_FRAME_SAMPLES, build_test_user_data, process_audio_chunk,
};
use fluxer_rt_thread::MonotonicClock;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug)]
struct FakeClock {
value_ns: AtomicU64,
}
impl FakeClock {
fn new(initial_ns: u64) -> Self {
assert!(initial_ns > 0);
Self {
value_ns: AtomicU64::new(initial_ns),
}
}
}
impl MonotonicClock for FakeClock {
fn now_ns(&self) -> u64 {
self.value_ns.load(Ordering::Acquire)
}
}
fn make_f32_payload(samples: &[f32]) -> Vec<u8> {
assert!(!samples.is_empty());
let mut out = Vec::with_capacity(samples.len() * 4);
for sample in samples {
out.extend_from_slice(&sample.to_ne_bytes());
}
assert_eq!(out.len(), samples.len() * 4);
out
}
fn build_handle_with_shared_freshness(last_push_ns: Arc<AtomicU64>) -> AudioMixRuntimeHandle {
assert!(Arc::strong_count(&last_push_ns) >= 1);
let handle = AudioMixRuntimeHandle::build(1, Some(last_push_ns))
.expect("AudioMixRuntimeHandle build via JS path");
assert_eq!(handle.source_count_js(), 1);
handle
}
#[test]
fn js_runtime_tick_consumes_freshness_pushed_by_production_callback() {
let clock: Arc<dyn MonotonicClock> = Arc::new(FakeClock::new(11_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut user_data = build_test_user_data(Arc::clone(&last_push_ns), Arc::clone(&clock));
let handle = build_handle_with_shared_freshness(Arc::clone(&last_push_ns));
assert_eq!(handle.mark_pushed_total_js(), 0);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0001)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut user_data, &payload);
let pushed_after_callback = last_push_ns.load(Ordering::Acquire);
assert_ne!(pushed_after_callback, u64::MAX);
assert_eq!(pushed_after_callback, 11_000_000);
let marked = handle
.tick(Some(pushed_after_callback as i64))
.expect("AudioMixRuntimeHandle::tick observes freshness");
assert_eq!(marked, 1);
let total = handle.mark_pushed_total_js();
assert!(
total >= 1,
"AudioMixRuntimeHandle::tick did not advance mark_pushed_total ({total})",
);
handle.dispose().expect("dispose");
}
#[test]
fn js_runtime_tick_idempotent_for_unchanged_freshness_atomic() {
let clock: Arc<dyn MonotonicClock> = Arc::new(FakeClock::new(22_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut user_data = build_test_user_data(Arc::clone(&last_push_ns), Arc::clone(&clock));
let handle = build_handle_with_shared_freshness(Arc::clone(&last_push_ns));
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0002)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut user_data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
let _ = handle.tick(Some(observed as i64)).expect("first tick");
let after_first = handle.mark_pushed_total_js();
assert!(after_first >= 1);
let _ = handle.tick(Some(observed as i64 + 1)).expect("second tick");
let after_second = handle.mark_pushed_total_js();
assert_eq!(
after_first, after_second,
"second tick must not advance mark_pushed_total when freshness atomic is unchanged",
);
handle.dispose().expect("dispose");
}
}
@@ -0,0 +1,433 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use pipewire as pw;
use pw::keys;
use pw::metadata::{Metadata, MetadataListener};
use pw::properties::{PropertiesBox, properties};
use fluxer_rt_thread::{PriorityProfile, RealtimePriorityGuard, RtError, RtOutcome};
use crate::audio_contract::{self, DIRECT_CAPTURE_SAMPLE_RATE};
use crate::backend::{RoutingGraphLink, RoutingGraphNode, RoutingGraphPort, RoutingGraphSnapshot};
use crate::routing::PropMap;
pub(crate) const READY_TIMEOUT_MS: u64 = 2_000;
pub(crate) const SINK_NODE_NAME: &str = "fluxer-screen-share";
pub(crate) const SINK_NODE_DESCRIPTION: &str = "Fluxer Screen Share Audio";
pub(crate) const DIRECT_SINK_PREFIX: &str = "fluxer-direct-capture";
pub(crate) const DIRECT_SINK_DESCRIPTION: &str = "Fluxer Direct Capture Audio";
pub(crate) const MEDIA_CLASS_CAPTURE_STREAM: &str = "Stream/Input/Audio";
pub(crate) const PIN_TARGET_METADATA_KEY: &str = "target.object";
pub(crate) const PIN_TARGET_METADATA_TYPE: &str = "Spa:String";
pub(crate) const CH_FRONT_LEFT: &str = "FL";
pub(crate) const CH_FRONT_RIGHT: &str = "FR";
pub(crate) const CH_MONO: &str = "MONO";
pub const MAX_FRAME_SAMPLES: usize = 1_920;
const _: () = assert!(MAX_FRAME_SAMPLES > 0);
const _: () = assert!(MAX_FRAME_SAMPLES <= 8_192);
pub(crate) static DIRECT_SINK_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Copy, Clone)]
pub(crate) enum VirtualSinkKind {
LegacyVirtualSource,
PrivateAudioSink,
}
pub(crate) fn next_direct_sink_name() -> String {
let seq = DIRECT_SINK_COUNTER.fetch_add(1, Ordering::Relaxed);
assert!(seq > 0);
format!("{DIRECT_SINK_PREFIX}-{}-{seq}", std::process::id())
}
pub(crate) fn acquire_audio_rt_guard() -> Option<RealtimePriorityGuard> {
match RealtimePriorityGuard::acquire(PriorityProfile::Audio) {
Ok(guard) => {
log_rt_outcome(guard.outcome());
Some(guard)
}
Err(RtError::PlatformDenied(errno)) => {
eprintln!(
"[fluxer-linux-audio] RT priority denied (errno={errno}); continuing without elevation",
);
None
}
Err(other) => {
eprintln!("[fluxer-linux-audio] RT priority error: {other}");
None
}
}
}
fn log_rt_outcome(outcome: &RtOutcome) {
match outcome {
RtOutcome::Acquired => {}
RtOutcome::PartialFallback => {
eprintln!(
"[fluxer-linux-audio] RT priority partial fallback engaged (Linux EPERM path)",
);
}
}
}
#[derive(Default, Clone)]
pub(crate) struct PortRecord {
pub(crate) node_id: u32,
pub(crate) direction: String,
pub(crate) channel: String,
pub(crate) props: PropMap,
}
#[derive(Default)]
pub(crate) struct InventorySnapshot {
pub(crate) nodes: HashMap<u32, PropMap>,
pub(crate) clients: HashMap<u32, PropMap>,
pub(crate) ports: HashMap<u32, PortRecord>,
}
impl InventorySnapshot {
pub(crate) fn enriched_node_props(&self, props: &PropMap) -> PropMap {
let mut enriched = props
.get("client.id")
.and_then(|client_id| client_id.parse::<u32>().ok())
.and_then(|client_id| self.clients.get(&client_id))
.map(client_identity_props)
.unwrap_or_default();
for (key, value) in props {
enriched.insert(key.clone(), value.clone());
}
if !enriched.contains_key("application.process.id")
&& let Some(pid) = enriched.get("pipewire.sec.pid").cloned()
{
enriched.insert("application.process.id".to_string(), pid);
}
enriched
}
pub(crate) fn enriched_nodes(&self) -> HashMap<u32, PropMap> {
self.nodes
.iter()
.map(|(id, props)| (*id, self.enriched_node_props(props)))
.collect()
}
pub(crate) fn enriched_node_values(&self) -> Vec<PropMap> {
self.nodes
.values()
.map(|props| self.enriched_node_props(props))
.collect()
}
pub(crate) fn routing_graph_nodes(&self) -> Vec<RoutingGraphNode> {
let mut nodes: Vec<RoutingGraphNode> = self
.enriched_nodes()
.into_iter()
.map(|(id, props)| RoutingGraphNode { id, props })
.collect();
nodes.sort_by_key(|node| node.id);
nodes
}
pub(crate) fn routing_graph_ports(&self) -> Vec<RoutingGraphPort> {
let mut ports: Vec<RoutingGraphPort> = self
.ports
.iter()
.map(|(id, port)| RoutingGraphPort {
id: *id,
node_id: port.node_id,
direction: port.direction.clone(),
channel: port.channel.clone(),
props: port.props.clone(),
})
.collect();
ports.sort_by_key(|port| port.id);
ports
}
}
fn client_identity_props(client: &PropMap) -> PropMap {
client
.iter()
.filter(|(key, _)| is_client_identity_key(key))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn is_client_identity_key(key: &str) -> bool {
key.starts_with("application.") || key.starts_with("pipewire.sec.")
}
pub(crate) struct MetadataWatch {
pub(crate) metadata: Metadata,
pub(crate) is_default: bool,
pub(crate) _listener: MetadataListener,
}
pub(crate) fn collect_props(dict: Option<&pw::spa::utils::dict::DictRef>) -> PropMap {
let mut props = PropMap::new();
if let Some(d) = dict {
for (k, v) in d.iter() {
props.insert(k.to_string(), v.to_string());
}
}
props
}
pub(crate) fn is_routable_media_class(class: &str) -> bool {
matches!(
class,
crate::routing::MEDIA_CLASS_PLAYBACK_STREAM
| MEDIA_CLASS_CAPTURE_STREAM
| "Audio/Source"
| "Audio/Sink"
)
}
#[cfg(test)]
pub(crate) fn build_virtual_sink_props() -> PropertiesBox {
build_virtual_sink_props_for(
SINK_NODE_NAME,
SINK_NODE_DESCRIPTION,
VirtualSinkKind::LegacyVirtualSource,
)
}
pub(crate) fn build_virtual_sink_props_for(
node_name: &str,
description: &str,
kind: VirtualSinkKind,
) -> PropertiesBox {
let media_class = match kind {
VirtualSinkKind::LegacyVirtualSource => "Audio/Source/Virtual",
VirtualSinkKind::PrivateAudioSink => "Audio/Sink",
};
let mut props = properties! {
"factory.name" => "support.null-audio-sink",
"node.name" => node_name,
"node.nick" => node_name,
"node.description" => description,
"media.class" => media_class,
"node.virtual" => "true",
"node.passive" => "true",
"node.dont-move" => "true",
"node.dont-reconnect" => "true",
"node.latency" => audio_contract::direct_capture_latency_fraction(),
"audio.rate" => DIRECT_CAPTURE_SAMPLE_RATE.to_string(),
"audio.position" => "[FL,FR]",
"monitor.channel-volumes" => "true",
};
if matches!(kind, VirtualSinkKind::PrivateAudioSink) {
props.insert("node.hidden", "true");
}
props.insert("audio.channels", "2");
props
}
pub(crate) fn build_link_props(
src_node: u32,
src_port: u32,
sink_node: u32,
sink_port: u32,
) -> PropertiesBox {
properties! {
"object.linger" => "false",
"link.passive" => "true",
*keys::LINK_OUTPUT_NODE => src_node.to_string(),
*keys::LINK_OUTPUT_PORT => src_port.to_string(),
*keys::LINK_INPUT_NODE => sink_node.to_string(),
*keys::LINK_INPUT_PORT => sink_port.to_string(),
}
}
pub(crate) fn pick_node_ports(
node_id: u32,
direction: &str,
ports: &HashMap<u32, PortRecord>,
) -> Option<(u32, u32)> {
let mut fl = None;
let mut fr = None;
let mut mono = None;
let mut candidates = Vec::new();
for (port_id, rec) in ports.iter() {
if rec.node_id != node_id || rec.direction != direction {
continue;
}
candidates.push(*port_id);
match rec.channel.to_ascii_uppercase().as_str() {
CH_FRONT_LEFT => fl = Some(*port_id),
CH_FRONT_RIGHT => fr = Some(*port_id),
"" | CH_MONO => mono = Some(*port_id),
_ => {}
}
}
if let (Some(l), Some(r)) = (fl, fr) {
return Some((l, r));
}
candidates.sort_unstable();
if candidates.len() >= 2 {
return Some((candidates[0], candidates[1]));
}
mono.map(|m| (m, m))
}
pub(crate) fn pick_source_output_ports(
node_id: u32,
ports: &HashMap<u32, PortRecord>,
) -> Option<(u32, u32)> {
pick_node_ports(node_id, "out", ports)
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct LinkKey {
pub(crate) src_node: u32,
pub(crate) src_port: u32,
pub(crate) sink_node: u32,
pub(crate) sink_port: u32,
}
impl LinkKey {
pub(crate) fn new(src_node: u32, src_port: u32, sink_node: u32, sink_port: u32) -> Self {
Self {
src_node,
src_port,
sink_node,
sink_port,
}
}
pub(crate) fn graph_link(self) -> RoutingGraphLink {
RoutingGraphLink {
output_node_id: self.src_node,
output_port_id: self.src_port,
input_node_id: self.sink_node,
input_port_id: self.sink_port,
}
}
}
pub(crate) struct OwnedLink {
pub(crate) key: LinkKey,
pub(crate) link: pw::link::Link,
}
pub(crate) fn create_link(core: &pw::core::CoreRc, key: LinkKey) -> Option<OwnedLink> {
let props = build_link_props(key.src_node, key.src_port, key.sink_node, key.sink_port);
let link = core
.create_object::<pw::link::Link>("link-factory", &props)
.ok()?;
Some(OwnedLink { key, link })
}
pub(crate) fn destroy_owned_links(
core: &pw::core::CoreRc,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
) {
let links = std::mem::take(&mut *owned_links.borrow_mut());
for owned in links {
let link = owned.link;
let _ = core.destroy_object(link);
}
replace_owned_link_snapshot(owned_link_snapshot, Vec::new());
}
pub(crate) fn sync_owned_links(
core: &pw::core::CoreRc,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
desired_links: Vec<LinkKey>,
) {
let desired: HashSet<LinkKey> = desired_links.into_iter().collect();
let mut links = owned_links.borrow_mut();
let mut index = 0;
while index < links.len() {
if desired.contains(&links[index].key) {
index += 1;
continue;
}
let removed = links.swap_remove(index);
let _ = core.destroy_object(removed.link);
}
let existing: HashSet<LinkKey> = links.iter().map(|owned| owned.key).collect();
for key in desired {
if existing.contains(&key) {
continue;
}
if let Some(link) = create_link(core, key) {
links.push(link);
}
}
let keys = links.iter().map(|owned| owned.key).collect();
replace_owned_link_snapshot(owned_link_snapshot, keys);
}
pub(crate) fn replace_owned_link_snapshot(
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
mut keys: Vec<LinkKey>,
) {
keys.sort_by_key(|key| (key.src_node, key.src_port, key.sink_node, key.sink_port));
if let Ok(mut guard) = owned_link_snapshot.lock() {
*guard = keys;
}
}
pub(crate) fn ensure_virtual_sink(
core: &pw::core::CoreRc,
sink_proxy: &std::rc::Rc<std::cell::RefCell<Option<pw::node::Node>>>,
node_name: &str,
description: &str,
kind: VirtualSinkKind,
) {
if sink_proxy.borrow().is_some() {
return;
}
let props = build_virtual_sink_props_for(node_name, description, kind);
if let Ok(node) = core.create_object::<pw::node::Node>("adapter", &props) {
*sink_proxy.borrow_mut() = Some(node);
}
}
pub(crate) fn build_routing_graph_snapshot(
backend: &str,
inventory: &Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
) -> RoutingGraphSnapshot {
let (nodes, ports) = match inventory.lock() {
Ok(guard) => (guard.routing_graph_nodes(), guard.routing_graph_ports()),
Err(_) => (Vec::new(), Vec::new()),
};
let owned_links = match owned_link_snapshot.lock() {
Ok(guard) => guard.iter().copied().map(LinkKey::graph_link).collect(),
Err(_) => Vec::new(),
};
RoutingGraphSnapshot {
backend: backend.to_string(),
nodes,
ports,
owned_links,
}
}
pub(crate) fn daemon_reachable() -> bool {
pw::init();
let Ok(mainloop) = pw::main_loop::MainLoopRc::new(None) else {
return false;
};
let Ok(context) = pw::context::ContextRc::new(&mainloop, None) else {
return false;
};
context.connect_rc(None).is_ok()
}
@@ -0,0 +1,402 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use pipewire as pw;
use pw::metadata::Metadata;
use pw::types::ObjectType;
use super::common::{
InventorySnapshot, LinkKey, MetadataWatch, OwnedLink, PortRecord, collect_props,
is_routable_media_class, replace_owned_link_snapshot,
};
use super::routing::{
DirectRoutingState, RoutingState, recompute_routing, refresh_direct_sink_input_ports,
refresh_sink_input_ports,
};
use super::stream_ops::DirectStreamRuntime;
pub(crate) struct GlobalAddedContext<'a> {
pub(crate) registry: &'a pw::registry::RegistryRc,
pub(crate) inventory: &'a Arc<Mutex<InventorySnapshot>>,
pub(crate) state: &'a std::rc::Rc<std::cell::RefCell<RoutingState>>,
pub(crate) core: &'a pw::core::CoreRc,
pub(crate) owned_links: &'a std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
pub(crate) owned_link_snapshot: &'a Arc<Mutex<Vec<LinkKey>>>,
pub(crate) metadata_watchers: &'a std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
pub(crate) sink_node_name: &'a str,
}
fn handle_global_added_client(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let props = collect_props(obj.props);
let Ok(mut snap) = ctx.inventory.lock() else {
return;
};
snap.clients.insert(obj.id, props);
drop(snap);
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
fn handle_global_added_node(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let props = collect_props(obj.props);
let class = props.get("media.class").cloned().unwrap_or_default();
let node_name = props.get("node.name").cloned().unwrap_or_default();
let is_our_sink = node_name == ctx.sink_node_name;
if !is_our_sink && !is_routable_media_class(&class) {
return;
}
let Ok(mut snap) = ctx.inventory.lock() else {
return;
};
snap.nodes.insert(obj.id, props);
drop(snap);
if is_our_sink {
ctx.state.borrow_mut().sink_global_id = obj.id;
refresh_sink_input_ports(ctx.inventory, ctx.state);
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
return;
}
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
fn build_port_record(props: &crate::routing::PropMap) -> Option<PortRecord> {
let node_id = props.get("node.id").and_then(|s| s.parse::<u32>().ok())?;
if node_id == 0 {
return None;
}
let direction = props
.get("port.direction")
.map(String::as_str)
.unwrap_or("")
.to_ascii_lowercase();
let channel = props.get("audio.channel").cloned().unwrap_or_default();
Some(PortRecord {
node_id,
direction,
channel,
props: props.clone(),
})
}
fn handle_global_added_port(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let props = collect_props(obj.props);
let Some(record) = build_port_record(&props) else {
return;
};
let node_id = record.node_id;
let Ok(mut snap) = ctx.inventory.lock() else {
return;
};
snap.ports.insert(obj.id, record);
drop(snap);
let sink_id = ctx.state.borrow().sink_global_id;
if sink_id != 0 && node_id == sink_id {
refresh_sink_input_ports(ctx.inventory, ctx.state);
}
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
fn handle_global_added_metadata(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: &GlobalAddedContext<'_>,
) {
let metadata_name = obj
.props
.and_then(|dict| dict.get("metadata.name"))
.unwrap_or("")
.to_string();
let is_default = metadata_name == "default";
let Ok(metadata) = ctx.registry.bind::<Metadata, _>(obj) else {
return;
};
let inv = ctx.inventory.clone();
let st = ctx.state.clone();
let core_for_listener = ctx.core.clone();
let owned_for_listener = ctx.owned_links.clone();
let link_snapshot_for_listener = ctx.owned_link_snapshot.clone();
let metadata_watchers_for_listener = ctx.metadata_watchers.clone();
let sink_node_name_owned = ctx.sink_node_name.to_string();
let listener = metadata
.add_listener_local()
.property(move |_subject, key, _type_, value| {
if key == Some("default.audio.sink") {
let name = value
.map(crate::routing::parse_default_sink_name)
.unwrap_or_default();
st.borrow_mut().default_sink_name = name;
recompute_routing(
&inv,
&st,
&core_for_listener,
&owned_for_listener,
&link_snapshot_for_listener,
&metadata_watchers_for_listener,
&sink_node_name_owned,
);
}
0
})
.register();
ctx.metadata_watchers.borrow_mut().push(MetadataWatch {
metadata,
is_default,
_listener: listener,
});
if ctx.state.borrow().active_rule.is_some() {
recompute_routing(
ctx.inventory,
ctx.state,
ctx.core,
ctx.owned_links,
ctx.owned_link_snapshot,
ctx.metadata_watchers,
ctx.sink_node_name,
);
}
}
pub(crate) fn handle_global_added(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
ctx: GlobalAddedContext<'_>,
) {
match obj.type_ {
ObjectType::Client => handle_global_added_client(obj, &ctx),
ObjectType::Node => handle_global_added_node(obj, &ctx),
ObjectType::Port => handle_global_added_port(obj, &ctx),
ObjectType::Metadata => handle_global_added_metadata(obj, &ctx),
_ => {}
}
}
pub(crate) fn handle_global_removed(
id: u32,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
) -> bool {
let mut changed = false;
if let Ok(mut snap) = inventory.lock() {
changed |= snap.nodes.remove(&id).is_some();
changed |= snap.clients.remove(&id).is_some();
changed |= snap.ports.remove(&id).is_some();
}
let mut st = state.borrow_mut();
let removed_sink = st.sink_global_id == id;
if st.sink_global_id == id {
st.sink_global_id = 0;
st.sink_input_fl = None;
st.sink_input_fr = None;
}
if st.sink_input_fl == Some(id) {
st.sink_input_fl = None;
}
if st.sink_input_fr == Some(id) {
st.sink_input_fr = None;
}
changed |= st.pinned_capture_nodes.remove(&id);
changed || removed_sink
}
pub(crate) struct DirectGlobalAddedArgs<'a> {
pub(crate) registry: &'a pw::registry::RegistryRc,
pub(crate) inventory: &'a Arc<Mutex<InventorySnapshot>>,
pub(crate) state: &'a std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
pub(crate) core: &'a pw::core::CoreRc,
pub(crate) runtime: &'a DirectStreamRuntime,
pub(crate) metadata_watchers: &'a std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
}
fn direct_added_client(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
) -> bool {
let props = collect_props(obj.props);
let Ok(mut snap) = args.inventory.lock() else {
return false;
};
snap.clients.insert(obj.id, props);
true
}
fn direct_added_node(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
) -> bool {
let props = collect_props(obj.props);
let class = props.get("media.class").cloned().unwrap_or_default();
let node_name = props.get("node.name").cloned().unwrap_or_default();
let is_our_sink = node_name == args.runtime.sink_node_name;
if !is_our_sink && !is_routable_media_class(&class) {
return false;
}
{
let Ok(mut snap) = args.inventory.lock() else {
return false;
};
snap.nodes.insert(obj.id, props);
}
if is_our_sink {
args.state.borrow_mut().sink_global_id = obj.id;
refresh_direct_sink_input_ports(args.inventory, args.state);
}
true
}
fn direct_added_port(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
) -> bool {
let props = collect_props(obj.props);
let Some(record) = build_port_record(&props) else {
return false;
};
let node_id = record.node_id;
{
let Ok(mut snap) = args.inventory.lock() else {
return false;
};
snap.ports.insert(obj.id, record);
}
let sink_id = args.state.borrow().sink_global_id;
if sink_id != 0 && node_id == sink_id {
refresh_direct_sink_input_ports(args.inventory, args.state);
}
true
}
fn direct_added_metadata(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: &DirectGlobalAddedArgs<'_>,
on_default_change: impl Fn() + 'static,
) -> bool {
let metadata_name = obj
.props
.and_then(|dict| dict.get("metadata.name"))
.unwrap_or("")
.to_string();
let is_default = metadata_name == "default";
let Ok(metadata) = args.registry.bind::<Metadata, _>(obj) else {
return false;
};
let st = args.state.clone();
let listener = metadata
.add_listener_local()
.property(move |_subject, key, _type_, value| {
if key == Some("default.audio.sink") {
let name = value
.map(crate::routing::parse_default_sink_name)
.unwrap_or_default();
st.borrow_mut().default_sink_name = name;
on_default_change();
}
0
})
.register();
args.metadata_watchers.borrow_mut().push(MetadataWatch {
metadata,
is_default,
_listener: listener,
});
false
}
pub(crate) fn handle_direct_global_added(
obj: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
args: DirectGlobalAddedArgs<'_>,
on_default_change: impl Fn() + 'static,
) -> bool {
match obj.type_ {
ObjectType::Client => direct_added_client(obj, &args),
ObjectType::Node => direct_added_node(obj, &args),
ObjectType::Port => direct_added_port(obj, &args),
ObjectType::Metadata => direct_added_metadata(obj, &args, on_default_change),
_ => false,
}
}
pub(crate) fn handle_direct_global_removed(
id: u32,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &DirectStreamRuntime,
) -> bool {
let mut changed = false;
if let Ok(mut snap) = inventory.lock() {
changed |= snap.nodes.remove(&id).is_some();
changed |= snap.clients.remove(&id).is_some();
changed |= snap.ports.remove(&id).is_some();
}
{
let mut st = state.borrow_mut();
if st.sink_global_id == id {
st.sink_global_id = 0;
st.sink_input_fl = None;
st.sink_input_fr = None;
runtime.sink_proxy.borrow_mut().take();
runtime.owned_links.borrow_mut().clear();
replace_owned_link_snapshot(&runtime.owned_link_snapshot, Vec::new());
*runtime.active_stream.borrow_mut() = None;
*runtime.active_listener.borrow_mut() = None;
runtime.running.store(false, Ordering::Relaxed);
changed = true;
}
if st.sink_input_fl == Some(id) {
st.sink_input_fl = None;
changed = true;
}
if st.sink_input_fr == Some(id) {
st.sink_input_fr = None;
changed = true;
}
}
changed
}
@@ -0,0 +1,485 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use pipewire as pw;
use pw::channel::Receiver as PwReceiver;
use pw::context::ContextRc;
use pw::main_loop::MainLoopRc;
use fluxer_rt_thread::MonotonicClock;
use crate::direct_buffer::DirectAudioBuffer;
use crate::routing::{RoutingRule, SelfIdentity};
use super::common::{
DIRECT_SINK_DESCRIPTION, InventorySnapshot, LinkKey, MetadataWatch, OwnedLink,
SINK_NODE_DESCRIPTION, SINK_NODE_NAME, VirtualSinkKind, acquire_audio_rt_guard,
destroy_owned_links, ensure_virtual_sink,
};
use super::device_enum::{
DirectGlobalAddedArgs, GlobalAddedContext, handle_direct_global_added,
handle_direct_global_removed, handle_global_added, handle_global_removed,
};
use super::routing::{
DirectRoutingState, RoutingState, clear_pinned_capture_targets, recompute_direct_links,
recompute_routing, refresh_direct_sink_input_ports,
};
use super::stream_ops::{
BuildDirectStreamArgs, DirectStreamRuntime, ScreenAudioSinkSlot, build_direct_stream,
};
pub(crate) enum BridgeCommand {
Apply(RoutingRule),
Release,
SetIdentity(SelfIdentity),
Shutdown,
}
struct BridgeWorkerState {
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
state: std::rc::Rc<std::cell::RefCell<RoutingState>>,
sink_proxy: std::rc::Rc<std::cell::RefCell<Option<pw::node::Node>>>,
owned_links: std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
metadata_watchers: std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
}
fn init_bridge_state(
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
) -> BridgeWorkerState {
BridgeWorkerState {
snapshot,
owned_link_snapshot,
state: std::rc::Rc::new(std::cell::RefCell::new(RoutingState::default())),
sink_proxy: std::rc::Rc::new(std::cell::RefCell::new(None)),
owned_links: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
metadata_watchers: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
}
}
fn install_bridge_registry(
registry: &pw::registry::RegistryRc,
core: &pw::core::CoreRc,
worker: &BridgeWorkerState,
) -> pw::registry::Listener {
let inv_for_global = worker.snapshot.clone();
let link_snapshot_for_global = worker.owned_link_snapshot.clone();
let state_for_global = worker.state.clone();
let core_for_global = core.clone();
let registry_for_global = registry.clone();
let owned_for_global = worker.owned_links.clone();
let metadata_for_global = worker.metadata_watchers.clone();
let inv_rm = worker.snapshot.clone();
let state_rm = worker.state.clone();
let core_rm = core.clone();
let owned_rm = worker.owned_links.clone();
let link_snapshot_rm = worker.owned_link_snapshot.clone();
let metadata_rm = worker.metadata_watchers.clone();
registry
.add_listener_local()
.global(move |obj| {
handle_global_added(
obj,
GlobalAddedContext {
registry: &registry_for_global,
inventory: &inv_for_global,
state: &state_for_global,
core: &core_for_global,
owned_links: &owned_for_global,
owned_link_snapshot: &link_snapshot_for_global,
metadata_watchers: &metadata_for_global,
sink_node_name: SINK_NODE_NAME,
},
);
})
.global_remove(move |id| {
let changed = handle_global_removed(id, &inv_rm, &state_rm);
if changed && state_rm.borrow().active_rule.is_some() {
recompute_routing(
&inv_rm,
&state_rm,
&core_rm,
&owned_rm,
&link_snapshot_rm,
&metadata_rm,
SINK_NODE_NAME,
);
}
})
.register()
}
fn install_bridge_command_handler<'a>(
mainloop: &'a MainLoopRc,
rx: PwReceiver<BridgeCommand>,
core: &pw::core::CoreRc,
worker: &BridgeWorkerState,
) -> pw::channel::AttachedReceiver<'a, BridgeCommand> {
let inv_for_cmd = worker.snapshot.clone();
let link_snapshot_for_cmd = worker.owned_link_snapshot.clone();
let state_for_cmd = worker.state.clone();
let core_for_cmd = core.clone();
let owned_for_cmd = worker.owned_links.clone();
let metadata_for_cmd = worker.metadata_watchers.clone();
let sink_for_cmd = worker.sink_proxy.clone();
let mainloop_weak = mainloop.downgrade();
rx.attach(mainloop.loop_(), move |cmd| match cmd {
BridgeCommand::Apply(rule) => {
state_for_cmd.borrow_mut().active_rule = Some(rule);
ensure_virtual_sink(
&core_for_cmd,
&sink_for_cmd,
SINK_NODE_NAME,
SINK_NODE_DESCRIPTION,
VirtualSinkKind::LegacyVirtualSource,
);
recompute_routing(
&inv_for_cmd,
&state_for_cmd,
&core_for_cmd,
&owned_for_cmd,
&link_snapshot_for_cmd,
&metadata_for_cmd,
SINK_NODE_NAME,
);
}
BridgeCommand::Release => {
clear_pinned_capture_targets(&state_for_cmd, &metadata_for_cmd);
state_for_cmd.borrow_mut().active_rule = None;
destroy_owned_links(&core_for_cmd, &owned_for_cmd, &link_snapshot_for_cmd);
}
BridgeCommand::SetIdentity(id) => {
state_for_cmd.borrow_mut().identity = id;
}
BridgeCommand::Shutdown => {
clear_pinned_capture_targets(&state_for_cmd, &metadata_for_cmd);
destroy_owned_links(&core_for_cmd, &owned_for_cmd, &link_snapshot_for_cmd);
sink_for_cmd.borrow_mut().take();
if let Some(ml) = mainloop_weak.upgrade() {
ml.quit();
}
}
})
}
pub(crate) fn run_bridge_worker(
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
rx: PwReceiver<BridgeCommand>,
ready_tx: std::sync::mpsc::SyncSender<bool>,
) {
let _rt_guard = acquire_audio_rt_guard();
pw::init();
let Ok(mainloop) = MainLoopRc::new(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(context) = ContextRc::new(&mainloop, None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(core) = context.connect_rc(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(registry) = core.get_registry_rc() else {
let _ = ready_tx.send(false);
return;
};
let worker = init_bridge_state(snapshot, owned_link_snapshot);
let _registry_listener = install_bridge_registry(&registry, &core, &worker);
let _attached_rx = install_bridge_command_handler(&mainloop, rx, &core, &worker);
let _ = ready_tx.send(true);
mainloop.run();
}
pub(crate) enum DirectCommand {
Start {
rule: RoutingRule,
identity: Box<SelfIdentity>,
},
UpdateRule {
rule: RoutingRule,
},
Stop,
Shutdown,
}
#[derive(Clone, Copy)]
pub(crate) enum DirectSinkRetention {
Preserve,
Drop,
}
pub(crate) fn stop_direct_streams(
core: &pw::core::CoreRc,
runtime: &DirectStreamRuntime,
sink_retention: DirectSinkRetention,
) {
*runtime.active_listener.borrow_mut() = None;
*runtime.active_stream.borrow_mut() = None;
destroy_owned_links(core, &runtime.owned_links, &runtime.owned_link_snapshot);
if matches!(sink_retention, DirectSinkRetention::Drop) {
runtime.sink_proxy.borrow_mut().take();
}
runtime.running.store(false, Ordering::Relaxed);
}
pub(crate) fn clear_direct_samples(samples: &Arc<Mutex<DirectAudioBuffer>>) {
if let Ok(mut guard) = samples.lock() {
guard.clear();
}
}
pub(crate) fn recompute_direct_streams(
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &DirectStreamRuntime,
) {
if state.borrow().active_rule.is_none() {
stop_direct_streams(core, runtime, DirectSinkRetention::Preserve);
return;
}
let updated = recompute_direct_links(
core,
inventory,
state,
&runtime.owned_links,
&runtime.owned_link_snapshot,
);
if !updated {
return;
}
ensure_or_promote_direct_stream(core, runtime);
}
fn ensure_or_promote_direct_stream(core: &pw::core::CoreRc, runtime: &DirectStreamRuntime) {
if runtime.active_stream.borrow().is_some() {
runtime.running.store(true, Ordering::Relaxed);
return;
}
let args = BuildDirectStreamArgs {
core,
samples: runtime.samples.clone(),
target_sink_name: &runtime.sink_node_name,
stream_node_name: &runtime.stream_node_name,
last_push_ns: runtime.last_push_ns.clone(),
clock: runtime.clock.clone(),
screen_audio_sink: runtime.screen_audio_sink.clone(),
};
match build_direct_stream(args) {
Ok((stream, listener)) => {
*runtime.active_stream.borrow_mut() = Some(stream);
*runtime.active_listener.borrow_mut() = Some(listener);
runtime.running.store(true, Ordering::Relaxed);
}
Err(_) => {
runtime.running.store(false, Ordering::Relaxed);
}
}
}
pub(crate) struct DirectWorkerInputs {
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) inventory: Arc<Mutex<InventorySnapshot>>,
pub(crate) owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
pub(crate) running: Arc<AtomicBool>,
pub(crate) sink_node_name: String,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
fn build_direct_runtime(inputs: &DirectWorkerInputs) -> std::rc::Rc<DirectStreamRuntime> {
let stream_node_name = format!("{}-stream", inputs.sink_node_name);
std::rc::Rc::new(DirectStreamRuntime {
active_stream: std::rc::Rc::new(std::cell::RefCell::new(None)),
active_listener: std::rc::Rc::new(std::cell::RefCell::new(None)),
owned_links: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
owned_link_snapshot: inputs.owned_link_snapshot.clone(),
sink_proxy: std::rc::Rc::new(std::cell::RefCell::new(None)),
samples: inputs.samples.clone(),
running: inputs.running.clone(),
sink_node_name: inputs.sink_node_name.clone(),
stream_node_name,
last_push_ns: inputs.last_push_ns.clone(),
clock: inputs.clock.clone(),
screen_audio_sink: inputs.screen_audio_sink.clone(),
})
}
fn install_direct_registry(
registry: &pw::registry::RegistryRc,
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &std::rc::Rc<DirectStreamRuntime>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
) -> pw::registry::Listener {
let inv_added = inventory.clone();
let state_added = state.clone();
let core_added = core.clone();
let runtime_added = runtime.clone();
let registry_added = registry.clone();
let metadata_added = metadata_watchers.clone();
let inv_rm = inventory.clone();
let state_rm = state.clone();
let core_rm = core.clone();
let runtime_rm = runtime.clone();
registry
.add_listener_local()
.global(move |obj| {
let inv_for_cb = inv_added.clone();
let state_for_cb = state_added.clone();
let core_for_cb = core_added.clone();
let runtime_for_cb = runtime_added.clone();
let on_default_change = move || {
recompute_direct_streams(&core_for_cb, &inv_for_cb, &state_for_cb, &runtime_for_cb);
};
let changed = handle_direct_global_added(
obj,
DirectGlobalAddedArgs {
registry: &registry_added,
inventory: &inv_added,
state: &state_added,
core: &core_added,
runtime: &runtime_added,
metadata_watchers: &metadata_added,
},
on_default_change,
);
if changed {
recompute_direct_streams(&core_added, &inv_added, &state_added, &runtime_added);
}
})
.global_remove(move |id| {
let changed = handle_direct_global_removed(id, &inv_rm, &state_rm, &runtime_rm);
if changed {
recompute_direct_streams(&core_rm, &inv_rm, &state_rm, &runtime_rm);
}
})
.register()
}
fn install_direct_command_handler<'a>(
mainloop: &'a MainLoopRc,
rx: PwReceiver<DirectCommand>,
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
runtime: &std::rc::Rc<DirectStreamRuntime>,
) -> pw::channel::AttachedReceiver<'a, DirectCommand> {
let core_for_cmd = core.clone();
let inv_for_cmd = inventory.clone();
let state_for_cmd = state.clone();
let runtime_for_cmd = runtime.clone();
let mainloop_weak = mainloop.downgrade();
rx.attach(mainloop.loop_(), move |cmd| match cmd {
DirectCommand::Start { rule, identity } => {
clear_direct_samples(&runtime_for_cmd.samples);
{
let mut st = state_for_cmd.borrow_mut();
st.identity = *identity;
st.active_rule = Some(rule);
}
ensure_virtual_sink(
&core_for_cmd,
&runtime_for_cmd.sink_proxy,
&runtime_for_cmd.sink_node_name,
DIRECT_SINK_DESCRIPTION,
VirtualSinkKind::PrivateAudioSink,
);
refresh_direct_sink_input_ports(&inv_for_cmd, &state_for_cmd);
recompute_direct_streams(
&core_for_cmd,
&inv_for_cmd,
&state_for_cmd,
&runtime_for_cmd,
);
}
DirectCommand::UpdateRule { rule } => {
let active = state_for_cmd.borrow().active_rule.is_some();
if !active {
return;
}
state_for_cmd.borrow_mut().active_rule = Some(rule);
recompute_direct_streams(
&core_for_cmd,
&inv_for_cmd,
&state_for_cmd,
&runtime_for_cmd,
);
}
DirectCommand::Stop => {
stop_direct_streams(
&core_for_cmd,
&runtime_for_cmd,
DirectSinkRetention::Preserve,
);
{
let mut st = state_for_cmd.borrow_mut();
st.active_rule = None;
}
clear_direct_samples(&runtime_for_cmd.samples);
}
DirectCommand::Shutdown => {
stop_direct_streams(&core_for_cmd, &runtime_for_cmd, DirectSinkRetention::Drop);
clear_direct_samples(&runtime_for_cmd.samples);
if let Some(ml) = mainloop_weak.upgrade() {
ml.quit();
}
}
})
}
pub(crate) fn run_direct_worker(
inputs: DirectWorkerInputs,
rx: PwReceiver<DirectCommand>,
ready_tx: std::sync::mpsc::SyncSender<bool>,
) {
let _rt_guard = acquire_audio_rt_guard();
pw::init();
let Ok(mainloop) = MainLoopRc::new(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(context) = ContextRc::new(&mainloop, None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(core) = context.connect_rc(None) else {
let _ = ready_tx.send(false);
return;
};
let Ok(registry) = core.get_registry_rc() else {
let _ = ready_tx.send(false);
return;
};
let state: std::rc::Rc<std::cell::RefCell<DirectRoutingState>> =
std::rc::Rc::new(std::cell::RefCell::new(DirectRoutingState::default()));
let metadata_watchers: std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let runtime = build_direct_runtime(&inputs);
let _registry_listener = install_direct_registry(
&registry,
&core,
&inputs.inventory,
&state,
&runtime,
&metadata_watchers,
);
let _attached_rx =
install_direct_command_handler(&mainloop, rx, &core, &inputs.inventory, &state, &runtime);
let _ = ready_tx.send(true);
mainloop.run();
}
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub(crate) mod common;
pub(crate) mod device_enum;
pub(crate) mod event_loop;
pub(crate) mod routing;
pub(crate) mod stream_ops;
@@ -0,0 +1,373 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
use std::mem;
use std::sync::{Arc, Mutex};
use pipewire as pw;
use crate::routing::{
MEDIA_CLASS_PLAYBACK_STREAM, PropMap, RoutingRule, SelfIdentity, matches_any, should_route_node,
};
use super::common::{
InventorySnapshot, LinkKey, MEDIA_CLASS_CAPTURE_STREAM, MetadataWatch, OwnedLink,
PIN_TARGET_METADATA_KEY, PIN_TARGET_METADATA_TYPE, destroy_owned_links, pick_node_ports,
pick_source_output_ports, sync_owned_links,
};
#[derive(Default)]
pub(crate) struct RoutingState {
pub(crate) identity: SelfIdentity,
pub(crate) active_rule: Option<RoutingRule>,
pub(crate) default_sink_name: String,
pub(crate) sink_global_id: u32,
pub(crate) pinned_capture_nodes: HashSet<u32>,
pub(crate) sink_input_fl: Option<u32>,
pub(crate) sink_input_fr: Option<u32>,
}
pub(crate) fn refresh_sink_input_ports(
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
) {
let sink_id = state.borrow().sink_global_id;
if sink_id == 0 {
return;
}
assert!(sink_id != 0);
let Ok(snap) = inventory.lock() else {
return;
};
let ports = pick_node_ports(sink_id, "in", &snap.ports);
drop(snap);
if let Some((fl, fr)) = ports {
assert!(fl != 0);
assert!(fr != 0);
let mut st = state.borrow_mut();
st.sink_input_fl = Some(fl);
st.sink_input_fr = Some(fr);
}
}
pub(crate) fn default_sink_target_id(
nodes: &HashMap<u32, PropMap>,
default_sink_name: &str,
) -> String {
if default_sink_name.is_empty() {
return String::new();
}
nodes
.values()
.find(|props| {
props
.get("node.name")
.is_some_and(|name| name == default_sink_name)
})
.and_then(|props| props.get("object.serial").cloned())
.unwrap_or_default()
}
pub(crate) fn matching_pinned_capture_nodes(
nodes: &HashMap<u32, PropMap>,
rule: &RoutingRule,
sink_global_id: u32,
) -> HashSet<u32> {
if rule.pin_target_for.is_empty() {
return HashSet::new();
}
nodes
.iter()
.filter_map(|(node_id, props)| {
if *node_id == sink_global_id {
return None;
}
let is_capture_stream = props
.get("media.class")
.is_some_and(|class| class == MEDIA_CLASS_CAPTURE_STREAM);
if is_capture_stream && matches_any(props, &rule.pin_target_for) {
Some(*node_id)
} else {
None
}
})
.collect()
}
pub(crate) fn sync_pinned_capture_targets(
nodes: &HashMap<u32, PropMap>,
rule: &RoutingRule,
sink_global_id: u32,
sink_node_name: &str,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
) {
let desired = matching_pinned_capture_nodes(nodes, rule, sink_global_id);
let previous = state.borrow().pinned_capture_nodes.clone();
for node_id in previous.difference(&desired) {
set_pinned_capture_target(metadata_watchers, *node_id, None);
}
for node_id in &desired {
set_pinned_capture_target(metadata_watchers, *node_id, Some(sink_node_name));
}
state.borrow_mut().pinned_capture_nodes = desired;
}
pub(crate) fn clear_pinned_capture_targets(
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
) {
let pinned = mem::take(&mut state.borrow_mut().pinned_capture_nodes);
for node_id in pinned {
set_pinned_capture_target(metadata_watchers, node_id, None);
}
}
pub(crate) fn set_pinned_capture_target(
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
node_id: u32,
target: Option<&str>,
) {
assert!(node_id != 0);
let watchers = metadata_watchers.borrow();
for watcher in watchers.iter().filter(|watcher| watcher.is_default) {
watcher.metadata.set_property(
node_id,
PIN_TARGET_METADATA_KEY,
target.map(|_| PIN_TARGET_METADATA_TYPE),
target,
);
}
}
struct RoutingResolved {
rule: RoutingRule,
sink_id: u32,
sink_in_fl: u32,
sink_in_fr: u32,
identity: SelfIdentity,
default_sink_name: String,
}
fn resolve_routing_inputs(
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
) -> Option<RoutingResolved> {
let st = state.borrow();
let rule = st.active_rule.clone()?;
let sink_id = st.sink_global_id;
if sink_id == 0 {
return None;
}
let (sink_in_fl, sink_in_fr) = (st.sink_input_fl?, st.sink_input_fr?);
Some(RoutingResolved {
rule,
sink_id,
sink_in_fl,
sink_in_fr,
identity: st.identity.clone(),
default_sink_name: st.default_sink_name.clone(),
})
}
fn build_desired_links(
nodes: &HashMap<u32, PropMap>,
ports: &HashMap<u32, super::common::PortRecord>,
resolved: &RoutingResolved,
default_sink_target_id: &str,
) -> Vec<LinkKey> {
let mut desired_links = Vec::new();
for (node_id, props) in nodes {
if *node_id == resolved.sink_id {
continue;
}
if !should_route_node(
*node_id,
props,
&resolved.rule,
&resolved.default_sink_name,
default_sink_target_id,
resolved.sink_id,
&resolved.identity,
) {
continue;
}
let Some((src_l, src_r)) = pick_source_output_ports(*node_id, ports) else {
continue;
};
desired_links.push(LinkKey::new(
*node_id,
src_l,
resolved.sink_id,
resolved.sink_in_fl,
));
desired_links.push(LinkKey::new(
*node_id,
src_r,
resolved.sink_id,
resolved.sink_in_fr,
));
}
desired_links
}
pub(crate) fn recompute_routing(
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<RoutingState>>,
core: &pw::core::CoreRc,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
metadata_watchers: &std::rc::Rc<std::cell::RefCell<Vec<MetadataWatch>>>,
sink_node_name: &str,
) {
let Some(resolved) = resolve_routing_inputs(state) else {
clear_pinned_capture_targets(state, metadata_watchers);
destroy_owned_links(core, owned_links, owned_link_snapshot);
return;
};
let Ok(snap) = inventory.lock() else {
return;
};
let nodes = snap.enriched_nodes();
let ports = snap.ports.clone();
drop(snap);
let default_sink_target_id = default_sink_target_id(&nodes, &resolved.default_sink_name);
sync_pinned_capture_targets(
&nodes,
&resolved.rule,
resolved.sink_id,
sink_node_name,
state,
metadata_watchers,
);
let desired_links = build_desired_links(&nodes, &ports, &resolved, &default_sink_target_id);
sync_owned_links(core, owned_links, owned_link_snapshot, desired_links);
}
#[derive(Default)]
pub(crate) struct DirectRoutingState {
pub(crate) identity: SelfIdentity,
pub(crate) active_rule: Option<RoutingRule>,
pub(crate) default_sink_name: String,
pub(crate) sink_global_id: u32,
pub(crate) sink_input_fl: Option<u32>,
pub(crate) sink_input_fr: Option<u32>,
}
pub(crate) fn refresh_direct_sink_input_ports(
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
) {
let sink_id = state.borrow().sink_global_id;
if sink_id == 0 {
return;
}
let Ok(snap) = inventory.lock() else {
return;
};
let ports = pick_node_ports(sink_id, "in", &snap.ports);
drop(snap);
if let Some((fl, fr)) = ports {
let mut st = state.borrow_mut();
st.sink_input_fl = Some(fl);
st.sink_input_fr = Some(fr);
}
}
struct DirectResolved {
rule: RoutingRule,
sink_id: u32,
sink_in_fl: u32,
sink_in_fr: u32,
identity: SelfIdentity,
default_sink_name: String,
}
fn resolve_direct_inputs(
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
) -> Option<DirectResolved> {
let st = state.borrow();
let rule = st.active_rule.clone()?;
let sink_id = st.sink_global_id;
let (sink_in_fl, sink_in_fr) = (st.sink_input_fl?, st.sink_input_fr?);
Some(DirectResolved {
rule,
sink_id,
sink_in_fl,
sink_in_fr,
identity: st.identity.clone(),
default_sink_name: st.default_sink_name.clone(),
})
}
fn build_direct_desired_links(
nodes: &HashMap<u32, PropMap>,
ports: &HashMap<u32, super::common::PortRecord>,
resolved: &DirectResolved,
default_sink_target_id: &str,
) -> Vec<LinkKey> {
let mut desired_links = Vec::new();
for (node_id, props) in nodes {
if *node_id == resolved.sink_id {
continue;
}
if !should_route_node(
*node_id,
props,
&resolved.rule,
&resolved.default_sink_name,
default_sink_target_id,
resolved.sink_id,
&resolved.identity,
) {
continue;
}
let Some((src_l, src_r)) = pick_source_output_ports(*node_id, ports) else {
continue;
};
desired_links.push(LinkKey::new(
*node_id,
src_l,
resolved.sink_id,
resolved.sink_in_fl,
));
desired_links.push(LinkKey::new(
*node_id,
src_r,
resolved.sink_id,
resolved.sink_in_fr,
));
}
desired_links
}
pub(crate) fn recompute_direct_links(
core: &pw::core::CoreRc,
inventory: &Arc<Mutex<InventorySnapshot>>,
state: &std::rc::Rc<std::cell::RefCell<DirectRoutingState>>,
owned_links: &std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
owned_link_snapshot: &Arc<Mutex<Vec<LinkKey>>>,
) -> bool {
let Some(resolved) = resolve_direct_inputs(state) else {
return false;
};
let Ok(snap) = inventory.lock() else {
return false;
};
let nodes = snap.enriched_nodes();
let ports = snap.ports.clone();
drop(snap);
let default_sink_target_id = default_sink_target_id(&nodes, &resolved.default_sink_name);
let desired_links =
build_direct_desired_links(&nodes, &ports, &resolved, &default_sink_target_id);
sync_owned_links(core, owned_links, owned_link_snapshot, desired_links);
true
}
const _: () = {
let _ = MEDIA_CLASS_PLAYBACK_STREAM;
};
@@ -0,0 +1,439 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
use std::mem;
use std::ops::Range;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef;
use pipewire as pw;
use pw::keys;
use pw::properties::{PropertiesBox, properties};
use pw::spa;
use spa::param::format::{MediaSubtype, MediaType};
use spa::param::format_utils;
use spa::pod::Pod;
use spa::sys as spa_sys;
use fluxer_audio_apm::{
APM_MAX_FRAME_SAMPLES, ApmConfig, ApmError, AudioProcessor, StubAudioProcessor,
expected_frame_samples,
};
use fluxer_rt_thread::MonotonicClock;
use crate::audio_contract::{self, DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_SAMPLE_RATE};
use crate::direct_buffer::DirectAudioBuffer;
use super::common::{LinkKey, MAX_FRAME_SAMPLES, OwnedLink};
pub(crate) type ScreenAudioSinkSlot = Arc<RwLock<Option<Arc<NativeScreenFrameSinkHandleRef>>>>;
pub const DIRECT_CAPTURE_APM_FRAME_SAMPLES: usize =
(DIRECT_CAPTURE_SAMPLE_RATE as usize) / 100 * (DIRECT_CAPTURE_CHANNELS as usize);
const _: () = assert!(DIRECT_CAPTURE_APM_FRAME_SAMPLES <= APM_MAX_FRAME_SAMPLES * 2);
const _: () = assert!(MAX_FRAME_SAMPLES >= DIRECT_CAPTURE_APM_FRAME_SAMPLES);
pub struct DirectCaptureApm {
processor: Box<dyn AudioProcessor + Send>,
accum_f32: Box<[f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES]>,
accum_len: usize,
scratch_i16: Box<[i16; DIRECT_CAPTURE_APM_FRAME_SAMPLES]>,
expected_sample_rate_hz: u32,
expected_channels: u16,
processed_samples: u64,
apm_frames_processed: u64,
}
impl DirectCaptureApm {
pub fn new(sample_rate_hz: u32, channels: u16) -> Result<Self, ApmError> {
assert!(sample_rate_hz >= 8_000);
assert!(channels >= 1);
let processor = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?;
let expected = expected_frame_samples(sample_rate_hz, channels);
assert!(expected > 0);
assert!(expected <= DIRECT_CAPTURE_APM_FRAME_SAMPLES);
Ok(Self {
processor: Box::new(processor),
accum_f32: Box::new([0.0; DIRECT_CAPTURE_APM_FRAME_SAMPLES]),
accum_len: 0,
scratch_i16: Box::new([0i16; DIRECT_CAPTURE_APM_FRAME_SAMPLES]),
expected_sample_rate_hz: sample_rate_hz,
expected_channels: channels,
processed_samples: 0,
apm_frames_processed: 0,
})
}
pub fn reconfigure(&mut self, sample_rate_hz: u32, channels: u16) -> Result<(), ApmError> {
assert!(sample_rate_hz >= 8_000);
assert!(channels >= 1);
if self.expected_sample_rate_hz == sample_rate_hz && self.expected_channels == channels {
return Ok(());
}
let processor = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?;
let expected = expected_frame_samples(sample_rate_hz, channels);
if expected == 0 || expected > DIRECT_CAPTURE_APM_FRAME_SAMPLES {
return Err(ApmError::ChannelsOutOfRange { channels });
}
self.processor = Box::new(processor);
self.expected_sample_rate_hz = sample_rate_hz;
self.expected_channels = channels;
self.accum_len = 0;
Ok(())
}
pub fn process_in_place(&mut self, samples: &mut [f32]) -> Result<usize, ApmError> {
assert!(!samples.is_empty());
assert!(self.expected_channels >= 1);
let apm_frame_len =
expected_frame_samples(self.expected_sample_rate_hz, self.expected_channels);
assert!(apm_frame_len > 0);
assert!(apm_frame_len <= self.scratch_i16.len());
let mut processed_complete: usize = 0;
let mut idx: usize = 0;
let total = samples.len();
while idx < total {
let want = apm_frame_len - self.accum_len;
let take = want.min(total - idx);
for offset in 0..take {
self.accum_f32[self.accum_len + offset] = samples[idx + offset];
}
self.accum_len += take;
idx += take;
if self.accum_len == apm_frame_len {
self.run_apm_one_frame(apm_frame_len)?;
if idx >= apm_frame_len {
let dst_lo = idx - apm_frame_len;
for offset in 0..apm_frame_len {
samples[dst_lo + offset] = self.accum_f32[offset];
}
processed_complete += apm_frame_len;
} else {
processed_complete += take;
}
self.accum_len = 0;
self.apm_frames_processed = self.apm_frames_processed.saturating_add(1);
}
}
self.processed_samples = self.processed_samples.saturating_add(total as u64);
Ok(processed_complete)
}
fn run_apm_one_frame(&mut self, apm_frame_len: usize) -> Result<(), ApmError> {
assert!(apm_frame_len <= self.scratch_i16.len());
assert!(apm_frame_len <= self.accum_f32.len());
for offset in 0..apm_frame_len {
self.scratch_i16[offset] = f32_sample_to_i16(self.accum_f32[offset]);
}
let result = self.processor.process_capture_frame(
&mut self.scratch_i16[..apm_frame_len],
self.expected_sample_rate_hz,
self.expected_channels,
);
result?;
for offset in 0..apm_frame_len {
self.accum_f32[offset] = i16_sample_to_f32(self.scratch_i16[offset]);
}
Ok(())
}
pub fn apm_frames_processed(&self) -> u64 {
self.apm_frames_processed
}
pub fn processed_samples(&self) -> u64 {
self.processed_samples
}
pub fn pending_accumulator_len(&self) -> usize {
self.accum_len
}
}
pub(crate) fn f32_sample_to_i16(value: f32) -> i16 {
let scaled = value * (i16::MAX as f32);
if scaled >= (i16::MAX as f32) {
return i16::MAX;
}
if scaled <= (i16::MIN as f32) {
return i16::MIN;
}
scaled as i16
}
pub(crate) fn i16_sample_to_f32(value: i16) -> f32 {
(value as f32) / (i16::MAX as f32)
}
pub(crate) struct DirectUserData {
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) format: spa::param::audio::AudioInfoRaw,
pub(crate) apm: Mutex<DirectCaptureApm>,
pub(crate) scratch: Mutex<Box<[f32; MAX_FRAME_SAMPLES]>>,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
pub(crate) struct DirectStreamRuntime {
pub(crate) active_stream: std::rc::Rc<std::cell::RefCell<Option<pw::stream::StreamRc>>>,
pub(crate) active_listener:
std::rc::Rc<std::cell::RefCell<Option<pw::stream::StreamListener<DirectUserData>>>>,
pub(crate) owned_links: std::rc::Rc<std::cell::RefCell<Vec<OwnedLink>>>,
pub(crate) owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
pub(crate) sink_proxy: std::rc::Rc<std::cell::RefCell<Option<pw::node::Node>>>,
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) running: Arc<AtomicBool>,
pub(crate) sink_node_name: String,
pub(crate) stream_node_name: String,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
pub(crate) fn direct_chunk_payload_range(
raw_len: usize,
offset: usize,
size: usize,
) -> Option<Range<usize>> {
if size == 0 || offset >= raw_len {
return None;
}
let end = offset.checked_add(size)?.min(raw_len);
let available = end.checked_sub(offset)?;
let aligned = available - (available % mem::size_of::<f32>());
if aligned == 0 {
return None;
}
Some(offset..offset + aligned)
}
pub(crate) fn build_direct_audio_info() -> spa::param::audio::AudioInfoRaw {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(DIRECT_CAPTURE_SAMPLE_RATE);
audio_info.set_channels(DIRECT_CAPTURE_CHANNELS);
let mut position = [0; spa::param::audio::MAX_CHANNELS];
position[0] = spa_sys::SPA_AUDIO_CHANNEL_FL;
position[1] = spa_sys::SPA_AUDIO_CHANNEL_FR;
audio_info.set_position(position);
audio_info
}
pub(crate) fn build_direct_stream_props(
target_sink_name: &str,
stream_node_name: &str,
) -> PropertiesBox {
properties! {
*keys::NODE_NAME => stream_node_name,
*keys::MEDIA_TYPE => "Audio",
*keys::MEDIA_CATEGORY => "Capture",
*keys::MEDIA_ROLE => "Music",
"media.class" => "Stream/Input/Audio",
*keys::STREAM_CAPTURE_SINK => "true",
"node.latency" => audio_contract::direct_capture_latency_fraction(),
"node.passive" => "true",
"node.virtual" => "true",
"node.hidden" => "true",
"node.dont-fallback" => "true",
"node.dont-move" => "true",
"node.dont-reconnect" => "true",
"stream.dont-remix" => "true",
"audio.rate" => DIRECT_CAPTURE_SAMPLE_RATE.to_string(),
"audio.channels" => DIRECT_CAPTURE_CHANNELS.to_string(),
"audio.position" => "[FL,FR]",
"target.object" => target_sink_name,
}
}
fn handle_param_changed(user_data: &mut DirectUserData, id: u32, param: Option<&Pod>) {
let Some(param) = param else { return };
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Ok((media_type, media_subtype)) = format_utils::parse_format(param) else {
return;
};
if media_type != MediaType::Audio || media_subtype != MediaSubtype::Raw {
return;
}
if user_data.format.parse(param).is_err() {
return;
}
let rate = user_data.format.rate();
let channels = user_data.format.channels();
if let Ok(mut guard) = user_data.samples.lock() {
guard.set_format(rate, channels);
}
if let Ok(mut apm_guard) = user_data.apm.lock() {
let channels_u16 = channels.min(u16::MAX as u32) as u16;
let _ = apm_guard.reconfigure(rate, channels_u16.max(1));
}
}
fn decode_f32_into_scratch(raw_payload: &[u8], scratch: &mut [f32; MAX_FRAME_SAMPLES]) -> usize {
let sample_count = raw_payload.len() / mem::size_of::<f32>();
let take = sample_count.min(MAX_FRAME_SAMPLES);
let mut written = 0usize;
let mut iter = raw_payload.chunks_exact(mem::size_of::<f32>());
for slot in scratch.iter_mut().take(take) {
let Some(chunk) = iter.next() else {
break;
};
*slot = f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
written += 1;
}
written
}
pub(crate) fn process_audio_chunk(user_data: &mut DirectUserData, payload: &[u8]) {
let Ok(mut scratch_guard) = user_data.scratch.lock() else {
return;
};
let written = decode_f32_into_scratch(payload, &mut scratch_guard);
if written == 0 {
return;
}
let channels = user_data.format.channels().max(1);
let aligned = audio_contract::whole_frame_sample_count(written, channels);
if aligned == 0 {
return;
}
if let Ok(mut apm_guard) = user_data.apm.lock() {
let _ = apm_guard.process_in_place(&mut scratch_guard[..aligned]);
}
let now_ns = user_data.clock.now_ns();
if now_ns > 0 {
user_data.last_push_ns.store(now_ns, Ordering::Release);
}
if let Ok(guard) = user_data.screen_audio_sink.read()
&& let Some(sink) = guard.as_ref()
{
let frames = aligned as u32 / channels;
if frames > 0 {
sink.enqueue_screen_audio_f32(
&scratch_guard[..aligned],
frames,
channels,
user_data.format.rate(),
(now_ns / 1_000) as i64,
);
}
return;
}
if let Ok(mut samples_guard) = user_data.samples.lock() {
let now_us = (now_ns / 1_000) as i64;
samples_guard.push(&scratch_guard[..aligned], now_us);
}
}
fn process_stream_buffer(stream: &pw::stream::Stream, user_data: &mut DirectUserData) {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let chunk = data.chunk();
let n_bytes = chunk.size() as usize;
let offset = chunk.offset() as usize;
let Some(raw) = data.data() else { return };
let Some(payload) = direct_chunk_payload_range(raw.len(), offset, n_bytes) else {
return;
};
process_audio_chunk(user_data, &raw[payload]);
}
pub(crate) struct BuildDirectStreamArgs<'a> {
pub(crate) core: &'a pw::core::CoreRc,
pub(crate) samples: Arc<Mutex<DirectAudioBuffer>>,
pub(crate) target_sink_name: &'a str,
pub(crate) stream_node_name: &'a str,
pub(crate) last_push_ns: Arc<AtomicU64>,
pub(crate) clock: Arc<dyn MonotonicClock>,
pub(crate) screen_audio_sink: ScreenAudioSinkSlot,
}
pub(crate) fn build_direct_stream(
args: BuildDirectStreamArgs<'_>,
) -> Result<
(
pw::stream::StreamRc,
pw::stream::StreamListener<DirectUserData>,
),
pw::Error,
> {
let props = build_direct_stream_props(args.target_sink_name, args.stream_node_name);
let apm = DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.map_err(|_| pw::Error::CreationFailed)?;
let data = DirectUserData {
samples: args.samples,
format: spa::param::audio::AudioInfoRaw::new(),
apm: Mutex::new(apm),
scratch: Mutex::new(Box::new([0.0_f32; MAX_FRAME_SAMPLES])),
last_push_ns: args.last_push_ns,
clock: args.clock,
screen_audio_sink: args.screen_audio_sink,
};
let stream = pw::stream::StreamRc::new(args.core.clone(), "fluxer-direct-capture", props)?;
let listener = stream
.add_local_listener_with_user_data(data)
.param_changed(|_, user_data, id, param| {
handle_param_changed(user_data, id, param);
})
.process(|stream, user_data| {
process_stream_buffer(stream, user_data);
})
.register()?;
let audio_info = build_direct_audio_info();
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|_| pw::Error::CreationFailed)?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).ok_or(pw::Error::CreationFailed)?];
stream.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)?;
Ok((stream, listener))
}
#[cfg(test)]
pub(crate) fn build_test_user_data(
last_push_ns: Arc<AtomicU64>,
clock: Arc<dyn MonotonicClock>,
) -> DirectUserData {
let apm = DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let mut format = spa::param::audio::AudioInfoRaw::new();
format.set_rate(DIRECT_CAPTURE_SAMPLE_RATE);
format.set_channels(DIRECT_CAPTURE_CHANNELS);
DirectUserData {
samples: Arc::new(Mutex::new(DirectAudioBuffer::default_format())),
format,
apm: Mutex::new(apm),
scratch: Mutex::new(Box::new([0.0_f32; MAX_FRAME_SAMPLES])),
last_push_ns,
clock,
screen_audio_sink: Arc::new(RwLock::new(None)),
}
}
@@ -0,0 +1,970 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef;
use pipewire as pw;
use pw::channel::{Sender as PwSender, channel as pw_channel};
use fluxer_rt_thread::{MonotonicClock, SystemMonotonicClock};
use crate::audio_contract::DIRECT_CAPTURE_MAX_READ_SAMPLES;
use crate::backend::{CaptureBridge, CapturedFrame, DirectCapture, RoutingGraphSnapshot};
use crate::direct_buffer::DirectAudioBuffer;
use crate::pipewire::common::{
InventorySnapshot, LinkKey, READY_TIMEOUT_MS, build_routing_graph_snapshot,
daemon_reachable as common_daemon_reachable, next_direct_sink_name,
};
use crate::pipewire::event_loop::{
BridgeCommand, DirectCommand, DirectWorkerInputs, run_bridge_worker, run_direct_worker,
};
use crate::pipewire::stream_ops::ScreenAudioSinkSlot;
use crate::routing::{PropMap, RoutingRule, SelfIdentity};
pub fn daemon_reachable() -> bool {
common_daemon_reachable()
}
pub struct PipeWireBridge {
snapshot: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
tx: PwSender<BridgeCommand>,
thread: Mutex<Option<JoinHandle<()>>>,
}
impl PipeWireBridge {
pub fn open() -> Option<Self> {
if !daemon_reachable() {
return None;
}
let snapshot = Arc::new(Mutex::new(InventorySnapshot::default()));
let owned_link_snapshot = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = pw_channel::<BridgeCommand>();
let snap_for_thread = snapshot.clone();
let links_for_thread = owned_link_snapshot.clone();
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<bool>(1);
let handle = thread::Builder::new()
.name("fluxer-pipewire-bridge".into())
.spawn(move || {
run_bridge_worker(snap_for_thread, links_for_thread, rx, ready_tx);
})
.ok()?;
match ready_rx.recv_timeout(Duration::from_millis(READY_TIMEOUT_MS)) {
Ok(true) => Some(Self {
snapshot,
owned_link_snapshot,
tx,
thread: Mutex::new(Some(handle)),
}),
_ => {
let _ = tx.send(BridgeCommand::Shutdown);
let _ = handle.join();
None
}
}
}
}
impl Drop for PipeWireBridge {
fn drop(&mut self) {
let _ = self.tx.send(BridgeCommand::Shutdown);
if let Ok(mut thread) = self.thread.lock()
&& let Some(handle) = thread.take()
{
let _ = handle.join();
}
}
}
impl CaptureBridge for PipeWireBridge {
fn inventory(&self) -> Vec<PropMap> {
match self.snapshot.lock() {
Ok(guard) => guard.enriched_node_values(),
Err(_) => Vec::new(),
}
}
fn apply(&self, rule: RoutingRule) -> bool {
self.tx.send(BridgeCommand::Apply(rule)).is_ok()
}
fn release(&self) {
let _ = self.tx.send(BridgeCommand::Release);
}
fn populate_self_identity(&self, identity: SelfIdentity) {
let _ = self.tx.send(BridgeCommand::SetIdentity(identity));
}
fn backend_name(&self) -> &'static str {
"pipewire"
}
fn routing_graph(&self) -> RoutingGraphSnapshot {
build_routing_graph_snapshot("pipewire", &self.snapshot, &self.owned_link_snapshot)
}
}
pub struct PipeWireDirectCapture {
samples: Arc<Mutex<DirectAudioBuffer>>,
inventory: Arc<Mutex<InventorySnapshot>>,
owned_link_snapshot: Arc<Mutex<Vec<LinkKey>>>,
tx: PwSender<DirectCommand>,
#[allow(dead_code)]
running: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
identity: Mutex<SelfIdentity>,
#[allow(dead_code)]
last_push_ns: Arc<AtomicU64>,
screen_audio_sink: ScreenAudioSinkSlot,
}
impl PipeWireDirectCapture {
pub fn open() -> Option<Self> {
Self::open_with_clock(Arc::new(SystemMonotonicClock::new()))
}
pub fn open_with_clock(clock: Arc<dyn MonotonicClock>) -> Option<Self> {
if !daemon_reachable() {
return None;
}
let samples = Arc::new(Mutex::new(DirectAudioBuffer::default_format()));
let inventory = Arc::new(Mutex::new(InventorySnapshot::default()));
let owned_link_snapshot = Arc::new(Mutex::new(Vec::new()));
let running = Arc::new(AtomicBool::new(false));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let screen_audio_sink: ScreenAudioSinkSlot = Arc::new(RwLock::new(None));
let (tx, rx) = pw_channel::<DirectCommand>();
let sink_node_name = next_direct_sink_name();
let inputs = DirectWorkerInputs {
samples: samples.clone(),
inventory: inventory.clone(),
owned_link_snapshot: owned_link_snapshot.clone(),
running: running.clone(),
sink_node_name,
last_push_ns: last_push_ns.clone(),
clock,
screen_audio_sink: screen_audio_sink.clone(),
};
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<bool>(1);
let handle = thread::Builder::new()
.name("fluxer-pipewire-direct".into())
.spawn(move || {
run_direct_worker(inputs, rx, ready_tx);
})
.ok()?;
match ready_rx.recv_timeout(Duration::from_millis(READY_TIMEOUT_MS)) {
Ok(true) => Some(Self {
samples,
inventory,
owned_link_snapshot,
tx,
running,
thread: Mutex::new(Some(handle)),
identity: Mutex::new(SelfIdentity::default()),
last_push_ns,
screen_audio_sink,
}),
_ => {
let _ = tx.send(DirectCommand::Shutdown);
let _ = handle.join();
None
}
}
}
#[allow(dead_code)]
pub fn last_push_ns(&self) -> u64 {
self.last_push_ns.load(std::sync::atomic::Ordering::Acquire)
}
}
impl Drop for PipeWireDirectCapture {
fn drop(&mut self) {
let _ = self.tx.send(DirectCommand::Shutdown);
if let Ok(mut thread) = self.thread.lock()
&& let Some(handle) = thread.take()
{
let _ = handle.join();
}
}
}
impl DirectCapture for PipeWireDirectCapture {
fn start(&self, rule: RoutingRule) -> bool {
let Ok(identity) = self.identity.lock().map(|guard| guard.clone()) else {
return false;
};
self.tx
.send(DirectCommand::Start {
rule,
identity: Box::new(identity),
})
.is_ok()
}
fn set_rule(&self, rule: RoutingRule) -> bool {
self.tx.send(DirectCommand::UpdateRule { rule }).is_ok()
}
fn read(&self) -> Option<CapturedFrame> {
let mut out = Vec::with_capacity(DIRECT_CAPTURE_MAX_READ_SAMPLES);
let meta = {
let Ok(mut guard) = self.samples.lock() else {
return None;
};
guard.read_into(&mut out)?
};
Some(CapturedFrame {
samples: out,
sample_rate: meta.sample_rate,
channels: meta.channels,
timestamp_us: meta.timestamp_us,
})
}
fn stop(&self) {
let _ = self.tx.send(DirectCommand::Stop);
}
fn set_screen_audio_sink(&self, sink: Arc<NativeScreenFrameSinkHandleRef>) {
if let Ok(mut guard) = self.screen_audio_sink.write() {
*guard = Some(sink);
}
}
fn clear_screen_audio_sink(&self) {
if let Ok(mut guard) = self.screen_audio_sink.write() {
*guard = None;
}
}
fn populate_self_identity(&self, identity: SelfIdentity) {
if let Ok(mut guard) = self.identity.lock() {
*guard = identity;
}
}
fn routing_graph(&self) -> RoutingGraphSnapshot {
build_routing_graph_snapshot("pipewire", &self.inventory, &self.owned_link_snapshot)
}
fn last_push_ns_arc(&self) -> Option<Arc<AtomicU64>> {
Some(Arc::clone(&self.last_push_ns))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipewire::common::{
DIRECT_SINK_PREFIX, MEDIA_CLASS_CAPTURE_STREAM, PortRecord, SINK_NODE_DESCRIPTION,
SINK_NODE_NAME, VirtualSinkKind, build_link_props, build_virtual_sink_props,
build_virtual_sink_props_for, is_routable_media_class, pick_node_ports,
pick_source_output_ports,
};
use crate::pipewire::routing::{default_sink_target_id, matching_pinned_capture_nodes};
use crate::pipewire::stream_ops::{
DIRECT_CAPTURE_APM_FRAME_SAMPLES, DirectCaptureApm, build_direct_audio_info,
build_direct_stream_props, direct_chunk_payload_range, f32_sample_to_i16,
i16_sample_to_f32,
};
use crate::routing::MEDIA_CLASS_PLAYBACK_STREAM;
use std::collections::{HashMap, HashSet};
use crate::audio_contract::{DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_SAMPLE_RATE};
use crate::routing::should_route_node;
use pipewire::spa::sys as spa_sys;
#[test]
fn is_routable_media_class_matches_audio_node_classes() {
assert!(is_routable_media_class(MEDIA_CLASS_PLAYBACK_STREAM));
assert!(is_routable_media_class(MEDIA_CLASS_CAPTURE_STREAM));
assert!(is_routable_media_class("Audio/Source"));
assert!(is_routable_media_class("Audio/Sink"));
assert!(!is_routable_media_class("Video/Source"));
assert!(!is_routable_media_class(""));
}
#[test]
fn daemon_unreachable_returns_none_from_open() {
let bridge = PipeWireBridge::open();
if let Some(b) = bridge {
let _ = b.inventory();
b.release();
}
}
#[test]
fn direct_open_returns_none_or_cleans_up() {
let direct = PipeWireDirectCapture::open();
if let Some(d) = direct {
d.stop();
}
}
#[test]
fn integration_smoke_apply_release_cycle_is_safe() {
let Some(bridge) = PipeWireBridge::open() else {
return;
};
bridge.apply(RoutingRule::default());
std::thread::sleep(Duration::from_millis(20));
bridge.release();
let _ = bridge.inventory();
}
#[test]
fn virtual_sink_props_match_legacy_contract() {
let props = build_virtual_sink_props();
let dict = props.dict();
assert_eq!(dict.get("factory.name"), Some("support.null-audio-sink"));
assert_eq!(dict.get("node.name"), Some(SINK_NODE_NAME));
assert_eq!(dict.get("node.nick"), Some(SINK_NODE_NAME));
assert_eq!(dict.get("node.description"), Some(SINK_NODE_DESCRIPTION));
assert_eq!(dict.get("media.class"), Some("Audio/Source/Virtual"));
assert_eq!(dict.get("node.virtual"), Some("true"));
assert_eq!(dict.get("node.passive"), Some("true"));
assert_eq!(dict.get("node.dont-move"), Some("true"));
assert_eq!(dict.get("node.dont-reconnect"), Some("true"));
assert_eq!(dict.get("node.latency"), Some("4096/48000"));
assert_eq!(dict.get("audio.rate"), Some("48000"));
assert_eq!(dict.get("audio.channels"), Some("2"));
assert_eq!(dict.get("audio.position"), Some("[FL,FR]"));
assert_eq!(dict.get("monitor.channel-volumes"), Some("true"));
}
#[test]
fn link_props_carry_per_port_routing() {
let props = build_link_props(101, 7, 202, 13);
let dict = props.dict();
assert_eq!(dict.get("link.output.node"), Some("101"));
assert_eq!(dict.get("link.output.port"), Some("7"));
assert_eq!(dict.get("link.input.node"), Some("202"));
assert_eq!(dict.get("link.input.port"), Some("13"));
assert_eq!(dict.get("object.linger"), Some("false"));
assert_eq!(dict.get("link.passive"), Some("true"));
}
#[test]
fn direct_stream_props_capture_private_sink_monitor() {
let props = build_direct_stream_props(
"fluxer-direct-capture-7-1",
"fluxer-direct-capture-7-1-stream",
);
let dict = props.dict();
assert_eq!(
dict.get("node.name"),
Some("fluxer-direct-capture-7-1-stream")
);
assert_eq!(dict.get("media.type"), Some("Audio"));
assert_eq!(dict.get("media.category"), Some("Capture"));
assert_eq!(dict.get("media.class"), Some("Stream/Input/Audio"));
assert_eq!(
dict.get("stream.capture.sink"),
Some("true"),
"must tap the sink monitor; tapping a Stream/Output/Audio target.object does not produce frames",
);
assert_eq!(dict.get("node.passive"), Some("true"));
assert_eq!(dict.get("node.virtual"), Some("true"));
assert_eq!(dict.get("node.hidden"), Some("true"));
assert_eq!(dict.get("node.dont-fallback"), Some("true"));
assert_eq!(dict.get("node.dont-move"), Some("true"));
assert_eq!(dict.get("node.dont-reconnect"), Some("true"));
assert_eq!(dict.get("stream.dont-remix"), Some("true"));
assert_eq!(dict.get("node.latency"), Some("4096/48000"));
assert_eq!(dict.get("audio.rate"), Some("48000"));
assert_eq!(dict.get("audio.channels"), Some("2"));
assert_eq!(dict.get("audio.position"), Some("[FL,FR]"));
assert_eq!(dict.get("target.object"), Some("fluxer-direct-capture-7-1"));
}
#[test]
fn direct_chunk_payload_range_respects_pipewire_chunk_offset() {
assert_eq!(direct_chunk_payload_range(64, 8, 16), Some(8..24));
assert_eq!(direct_chunk_payload_range(18, 4, 16), Some(4..16));
assert_eq!(direct_chunk_payload_range(64, 64, 16), None);
assert_eq!(direct_chunk_payload_range(64, 8, 0), None);
assert_eq!(direct_chunk_payload_range(10, 8, 2), None);
}
#[test]
fn direct_audio_info_advertises_stereo_fl_fr() {
let info = build_direct_audio_info();
assert_eq!(
info.format(),
pipewire::spa::param::audio::AudioFormat::F32LE
);
assert_eq!(info.rate(), DIRECT_CAPTURE_SAMPLE_RATE);
assert_eq!(info.channels(), DIRECT_CAPTURE_CHANNELS);
assert_eq!(info.position()[0], spa_sys::SPA_AUDIO_CHANNEL_FL);
assert_eq!(info.position()[1], spa_sys::SPA_AUDIO_CHANNEL_FR);
}
#[test]
fn next_direct_sink_name_is_unique_per_call() {
let a = next_direct_sink_name();
let b = next_direct_sink_name();
assert_ne!(a, b, "concurrent captures must not collide on sink names");
assert!(a.starts_with(DIRECT_SINK_PREFIX));
assert!(b.starts_with(DIRECT_SINK_PREFIX));
}
#[test]
fn default_sink_target_id_uses_object_serial_for_matching_node_name() {
let nodes = HashMap::from([
(
1,
PropMap::from([
("node.name".to_string(), "alsa_output.foo".to_string()),
("object.serial".to_string(), "1234".to_string()),
]),
),
(
2,
PropMap::from([
("node.name".to_string(), "alsa_output.bar".to_string()),
("object.serial".to_string(), "5678".to_string()),
]),
),
]);
assert_eq!("1234", default_sink_target_id(&nodes, "alsa_output.foo"));
assert_eq!("", default_sink_target_id(&nodes, "missing"));
}
#[test]
fn matching_pinned_capture_nodes_only_matches_record_stream_inputs() {
let nodes = HashMap::from([
(
10,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_CAPTURE_STREAM.to_string(),
),
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
]),
),
(
11,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_PLAYBACK_STREAM.to_string(),
),
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
]),
),
(
12,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_CAPTURE_STREAM.to_string(),
),
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "OtherCapture".to_string()),
]),
),
(
13,
PropMap::from([
(
"media.class".to_string(),
MEDIA_CLASS_CAPTURE_STREAM.to_string(),
),
("application.process.id".to_string(), "9999".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
]),
),
]);
let rule = RoutingRule {
pin_target_for: vec![PropMap::from([
("application.process.id".to_string(), "4242".to_string()),
("media.name".to_string(), "RecordStream".to_string()),
])],
..Default::default()
};
assert_eq!(
matching_pinned_capture_nodes(&nodes, &rule, 99),
HashSet::from([10])
);
assert!(
matching_pinned_capture_nodes(&nodes, &rule, 10).is_empty(),
"the bridge's own sink id must never be pinned"
);
}
#[test]
fn private_sink_props_advertise_hidden_audio_sink() {
let private = build_virtual_sink_props_for(
"fluxer-direct-capture-7-1",
crate::pipewire::common::DIRECT_SINK_DESCRIPTION,
VirtualSinkKind::PrivateAudioSink,
);
let legacy = build_virtual_sink_props_for(
SINK_NODE_NAME,
SINK_NODE_DESCRIPTION,
VirtualSinkKind::LegacyVirtualSource,
);
assert_eq!(private.dict().get("media.class"), Some("Audio/Sink"));
assert_eq!(private.dict().get("node.hidden"), Some("true"));
assert_eq!(
legacy.dict().get("media.class"),
Some("Audio/Source/Virtual")
);
assert_eq!(legacy.dict().get("node.hidden"), None);
}
#[test]
fn inventory_enriches_nodes_with_owning_client_identity() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([
("application.name".to_string(), "Firefox".to_string()),
("application.process.id".to_string(), "4242".to_string()),
(
"application.process.binary".to_string(),
"firefox".to_string(),
),
]),
);
inventory.nodes.insert(
88,
PropMap::from([
("client.id".to_string(), "77".to_string()),
(
"media.class".to_string(),
MEDIA_CLASS_PLAYBACK_STREAM.to_string(),
),
("node.name".to_string(), "Firefox output".to_string()),
]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.process.id"),
Some(&"4242".to_string())
);
assert_eq!(
node.get("application.process.binary"),
Some(&"firefox".to_string())
);
assert_eq!(node.get("node.name"), Some(&"Firefox output".to_string()));
let rule = RoutingRule {
include_when: vec![PropMap::from([(
"application.process.id".to_string(),
"4242".to_string(),
)])],
..Default::default()
};
assert!(should_route_node(
88,
node,
&rule,
"",
"",
0,
&SelfIdentity::default(),
));
}
#[test]
fn inventory_keeps_node_properties_authoritative_over_client_props() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([("application.name".to_string(), "Client Name".to_string())]),
);
inventory.nodes.insert(
88,
PropMap::from([
("client.id".to_string(), "77".to_string()),
("application.name".to_string(), "Stream Name".to_string()),
]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.name"),
Some(&"Stream Name".to_string())
);
}
#[test]
fn inventory_falls_back_to_pipewire_security_pid() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([("pipewire.sec.pid".to_string(), "5150".to_string())]),
);
inventory.nodes.insert(
88,
PropMap::from([("client.id".to_string(), "77".to_string())]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.process.id"),
Some(&"5150".to_string())
);
}
#[test]
fn inventory_does_not_inherit_client_object_serial_as_node_target() {
let mut inventory = InventorySnapshot::default();
inventory.clients.insert(
77,
PropMap::from([
("application.process.id".to_string(), "4242".to_string()),
("object.serial".to_string(), "client-serial".to_string()),
]),
);
inventory.nodes.insert(
88,
PropMap::from([
("client.id".to_string(), "77".to_string()),
("node.name".to_string(), "Playback Stream".to_string()),
]),
);
let enriched = inventory.enriched_nodes();
let node = enriched.get(&88).expect("enriched node");
assert_eq!(
node.get("application.process.id"),
Some(&"4242".to_string())
);
assert_eq!(node.get("object.serial"), None);
assert_eq!(node.get("node.name"), Some(&"Playback Stream".to_string()));
}
fn port(node_id: u32, dir: &str, ch: &str) -> PortRecord {
PortRecord {
node_id,
direction: dir.into(),
channel: ch.into(),
props: PropMap::new(),
}
}
#[test]
fn pick_source_output_ports_prefers_stereo_pair() {
let mut ports = HashMap::new();
ports.insert(1, port(42, "out", "fl"));
ports.insert(2, port(42, "out", "fr"));
ports.insert(3, port(42, "in", "FL"));
ports.insert(4, port(99, "out", "FL"));
let (l, r) = pick_source_output_ports(42, &ports).expect("stereo pair");
assert_eq!(l, 1);
assert_eq!(r, 2);
}
#[test]
fn pick_source_output_ports_falls_back_to_first_two_jack_style_ports() {
let mut ports = HashMap::new();
ports.insert(30, port(42, "out", "AUX1"));
ports.insert(20, port(42, "out", "AUX0"));
ports.insert(10, port(42, "in", "AUX0"));
let (l, r) = pick_source_output_ports(42, &ports).expect("jack-style stereo fallback");
assert_eq!(l, 20);
assert_eq!(r, 30);
}
#[test]
fn pick_node_ports_applies_same_fallback_to_private_capture_inputs() {
let mut ports = HashMap::new();
ports.insert(8, port(7, "in", "1"));
ports.insert(9, port(7, "in", "2"));
let (l, r) = pick_node_ports(7, "in", &ports).expect("input stereo fallback");
assert_eq!(l, 8);
assert_eq!(r, 9);
}
#[test]
fn pick_source_output_ports_fans_mono_to_both_inputs() {
let mut ports = HashMap::new();
ports.insert(7, port(42, "out", "MONO"));
let (l, r) = pick_source_output_ports(42, &ports).expect("mono fan-out");
assert_eq!(l, 7);
assert_eq!(r, 7);
}
#[test]
fn pick_source_output_ports_treats_blank_channel_as_mono() {
let mut ports = HashMap::new();
ports.insert(11, port(42, "out", ""));
let (l, r) = pick_source_output_ports(42, &ports).expect("blank-channel fallback");
assert_eq!(l, 11);
assert_eq!(r, 11);
}
#[test]
fn pick_source_output_ports_returns_none_when_no_outputs() {
let ports = HashMap::new();
assert!(pick_source_output_ports(42, &ports).is_none());
}
#[test]
fn pick_source_output_ports_returns_none_when_only_one_side_present() {
let mut ports = HashMap::new();
ports.insert(1, port(42, "out", "FL"));
assert!(pick_source_output_ports(42, &ports).is_none());
}
#[test]
fn direct_capture_apm_processes_one_full_frame_increments_counter() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
assert_eq!(apm.apm_frames_processed(), 0);
let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES;
let mut samples = vec![0.5_f32; frame_len];
let processed = apm.process_in_place(&mut samples).expect("process");
assert_eq!(processed, frame_len);
assert_eq!(apm.apm_frames_processed(), 1);
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn direct_capture_apm_accumulates_partial_frames_across_calls() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2;
let mut first = vec![0.1_f32; half];
let processed1 = apm.process_in_place(&mut first).expect("first");
assert_eq!(processed1, 0);
assert_eq!(apm.apm_frames_processed(), 0);
assert_eq!(apm.pending_accumulator_len(), half);
let mut second = vec![0.2_f32; half];
let processed2 = apm.process_in_place(&mut second).expect("second");
assert_eq!(processed2, half);
assert_eq!(apm.apm_frames_processed(), 1);
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn direct_capture_apm_handles_many_frames_in_one_call() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES;
let mut samples = vec![0.25_f32; frame_len * 5];
let processed = apm.process_in_place(&mut samples).expect("process");
assert_eq!(processed, frame_len * 5);
assert_eq!(apm.apm_frames_processed(), 5);
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn direct_capture_apm_stub_preserves_samples_within_tolerance() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES;
let mut samples = vec![0.0_f32; frame_len];
for n in 0..frame_len {
samples[n] = ((n as f32) / (frame_len as f32) - 0.5) * 0.5;
}
let original = samples.clone();
let _ = apm.process_in_place(&mut samples).expect("process");
for (after, before) in samples.iter().zip(original.iter()) {
let diff = (after - before).abs();
assert!(diff < 1e-3);
}
}
#[test]
fn direct_capture_apm_reconfigure_is_noop_when_format_unchanged() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2;
let mut samples = vec![0.3_f32; half];
let _ = apm.process_in_place(&mut samples).expect("first");
assert_eq!(apm.pending_accumulator_len(), half);
apm.reconfigure(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("noop");
assert_eq!(apm.pending_accumulator_len(), half);
}
#[test]
fn direct_capture_apm_reconfigure_changes_format_and_resets_accum() {
let mut apm =
DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16)
.expect("apm");
let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2;
let mut samples = vec![0.3_f32; half];
let _ = apm.process_in_place(&mut samples).expect("first");
assert!(apm.pending_accumulator_len() > 0);
apm.reconfigure(16_000, 1).expect("reconfigure");
assert_eq!(apm.pending_accumulator_len(), 0);
}
#[test]
fn f32_to_i16_clamps_above_one() {
assert_eq!(f32_sample_to_i16(2.0), i16::MAX);
assert_eq!(f32_sample_to_i16(-2.0), i16::MIN);
assert_eq!(f32_sample_to_i16(0.0), 0);
}
#[test]
fn i16_to_f32_round_trip_is_bounded() {
for sample in [-32_768_i16, -1, 0, 1, 32_767] {
let f = i16_sample_to_f32(sample);
assert!((-1.001..=1.001).contains(&f));
}
}
use crate::ignore_audio_runtime::SOURCE_STALE_AFTER_NS;
use crate::pipewire::stream_ops::{build_test_user_data, process_audio_chunk};
use fluxer_rt_thread::MonotonicClock;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug)]
struct FakeClock {
value_ns: AtomicU64,
}
impl FakeClock {
fn new(initial_ns: u64) -> Self {
Self {
value_ns: AtomicU64::new(initial_ns),
}
}
fn set(&self, value_ns: u64) {
self.value_ns.store(value_ns, Ordering::Release);
}
}
impl MonotonicClock for FakeClock {
fn now_ns(&self) -> u64 {
self.value_ns.load(Ordering::Acquire)
}
}
fn make_f32_payload(samples: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(samples.len() * 4);
for sample in samples {
out.extend_from_slice(&sample.to_ne_bytes());
}
out
}
#[test]
fn production_callback_marks_freshness_with_monotonic_clock() {
let clock = Arc::new(FakeClock::new(7_500_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..960).map(|n| (n as f32) * 0.0001).collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
assert_eq!(observed, 7_500_000);
assert_ne!(observed, u64::MAX);
}
#[test]
fn freshness_age_grows_to_signal_stale_source_after_threshold() {
let clock = Arc::new(FakeClock::new(1_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
let frame = vec![0.1_f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES];
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload);
let after_first = last_push_ns.load(Ordering::Acquire);
assert_eq!(after_first, 1_000_000);
clock.set(1_000_000 + SOURCE_STALE_AFTER_NS + 1);
let age = clock.now_ns() - after_first;
assert!(age > SOURCE_STALE_AFTER_NS);
clock.set(2_000_000 + SOURCE_STALE_AFTER_NS + 1);
let payload2 = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload2);
let after_second = last_push_ns.load(Ordering::Acquire);
assert!(after_second > after_first);
assert_eq!(after_second, 2_000_000 + SOURCE_STALE_AFTER_NS + 1);
let fresh_age = clock.now_ns() - after_second;
assert_eq!(fresh_age, 0);
}
#[test]
fn callback_path_does_not_allocate_in_steady_state() {
let clock = Arc::new(FakeClock::new(1_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
let frame = vec![0.05_f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES];
let payload = make_f32_payload(&frame);
for _ in 0..400 {
clock.set(clock.now_ns() + 10_000_000);
process_audio_chunk(&mut data, &payload);
}
let allocs_before = crate::audio_mix_runtime::ALLOC_PROBE.load(Ordering::Relaxed);
crate::audio_mix_runtime::begin_thread_alloc_probe();
clock.set(clock.now_ns() + 10_000_000);
process_audio_chunk(&mut data, &payload);
let probed = crate::audio_mix_runtime::end_thread_alloc_probe();
let allocs_after = crate::audio_mix_runtime::ALLOC_PROBE.load(Ordering::Relaxed);
assert_eq!(
probed,
0,
"steady-state callback allocated {probed} times (global delta {})",
allocs_after.saturating_sub(allocs_before)
);
}
#[test]
fn production_callback_freshness_drives_audio_mix_runtime_mark_pushed() {
use crate::audio_mix_runtime::{
AudioMixRuntimeBuilder, CaptureSource, MIX_CHANNELS, MIX_SAMPLE_RATE_HZ,
NullMixOutputSink,
};
let clock = Arc::new(FakeClock::new(9_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut data = build_test_user_data(
last_push_ns.clone(),
Arc::clone(&clock) as Arc<dyn MonotonicClock>,
);
let source_id: u64 = 1;
let (_source, consumer) =
CaptureSource::create(source_id, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS).expect("source");
let mut runtime = AudioMixRuntimeBuilder::new()
.with_clock(Arc::clone(&clock) as Arc<dyn MonotonicClock>)
.add_source_with_freshness(source_id, consumer, Arc::clone(&last_push_ns))
.build(NullMixOutputSink)
.expect("build");
assert_eq!(runtime.mark_pushed_total(), 0);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0001)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
assert_eq!(observed, 9_000_000);
let _ = runtime.run_one_tick_blocking(observed).expect("frame");
assert!(
runtime.mark_pushed_total() >= 1,
"AudioMixRuntime.tick() did not invoke StaleSourceTracker::mark_pushed",
);
let not_stale = !runtime.is_source_stale(0, observed + 1_000_000, 5_000_000_000);
assert!(
not_stale,
"source must not be stale immediately after a fresh push"
);
}
}
@@ -0,0 +1,662 @@
#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::{HashMap, HashSet};
pub type PropMap = HashMap<String, String>;
pub type PropPattern = PropMap;
pub const MEDIA_CLASS_PLAYBACK_STREAM: &str = "Stream/Output/Audio";
const TARGET_OBJECTS_PATTERN_KEY: &str = "fluxer.target.objects";
const DISPLAY_PATTERN_PREFIX: &str = "fluxer.display.";
#[derive(Debug, Default, Clone)]
pub struct SelfIdentity {
pub pids: HashSet<String>,
pub binaries: HashSet<String>,
pub display_names: HashSet<String>,
pub display_prefixes: Vec<String>,
}
impl SelfIdentity {
pub fn add_pid(&mut self, pid: impl Into<String>) {
let value = pid.into();
if value.is_empty() {
return;
}
self.pids.insert(value);
}
pub fn add_binary(&mut self, name: impl Into<String>) {
let value = name.into();
if value.is_empty() {
return;
}
self.binaries.insert(value);
}
pub fn add_display_name(&mut self, name: impl Into<String>) {
let value = name.into();
if value.is_empty() {
return;
}
self.display_names.insert(value);
}
pub fn add_display_prefix(&mut self, prefix: impl Into<String>) {
let value = prefix.into();
if value.is_empty() {
return;
}
self.display_prefixes.push(value);
}
pub fn matches(&self, properties: &PropMap) -> bool {
if let Some(raw) = properties.get("application.process.id")
&& self.pids.contains(raw)
{
return true;
}
if let Some(raw) = properties.get("pipewire.sec.pid")
&& self.pids.contains(raw)
{
return true;
}
if let Some(raw) = properties.get("application.process.binary")
&& contains_case_insensitive(&self.binaries, raw)
{
return true;
}
for key in [
"application.name",
"node.name",
"node.nick",
"node.description",
] {
if let Some(raw) = properties.get(key)
&& self.matches_display_identity(raw)
{
return true;
}
}
false
}
fn matches_display_identity(&self, raw: &str) -> bool {
contains_case_insensitive(&self.binaries, raw)
|| contains_case_insensitive(&self.display_names, raw)
|| self
.display_prefixes
.iter()
.any(|prefix| starts_with_case_insensitive(raw, prefix))
}
}
fn contains_case_insensitive(values: &HashSet<String>, needle: &str) -> bool {
values
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(needle))
}
fn starts_with_case_insensitive(value: &str, prefix: &str) -> bool {
value
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
#[derive(Debug, Default, Clone)]
pub struct RoutingRule {
pub include_when: Vec<PropPattern>,
pub never_when: Vec<PropPattern>,
pub pin_target_for: Vec<PropPattern>,
pub skip_hardware_devices: bool,
pub only_audio_sinks: bool,
pub only_default_audio_sink: bool,
}
pub fn matches_pattern(candidate: &PropMap, expected: &PropPattern) -> bool {
for (key, value) in expected {
if key.starts_with(DISPLAY_PATTERN_PREFIX) {
continue;
}
if key == TARGET_OBJECTS_PATTERN_KEY {
if !matches_target_object(candidate, value) {
return false;
}
continue;
}
match candidate.get(key) {
Some(actual) if actual == value => {}
_ => return false,
}
}
true
}
fn matches_target_object(candidate: &PropMap, expected_values: &str) -> bool {
let Some(actual) = candidate
.get("target.object")
.or_else(|| candidate.get("node.target"))
else {
return false;
};
expected_values
.split('\n')
.filter(|value| !value.is_empty())
.any(|expected| actual == expected)
}
pub fn matches_any(candidate: &PropMap, patterns: &[PropPattern]) -> bool {
patterns.iter().any(|p| matches_pattern(candidate, p))
}
pub fn should_route_node(
id: u32,
properties: &PropMap,
rule: &RoutingRule,
default_sink_name: &str,
default_sink_target_id: &str,
sink_global_id: u32,
self_identity: &SelfIdentity,
) -> bool {
if id == sink_global_id {
return false;
}
if self_identity.matches(properties) {
return false;
}
if matches_any(properties, &rule.never_when) {
return false;
}
if rule.skip_hardware_devices && properties.contains_key("device.id") {
return false;
}
let Some(class) = properties.get("media.class") else {
return false;
};
if class != MEDIA_CLASS_PLAYBACK_STREAM {
return false;
}
if !rule.include_when.is_empty() {
return matches_any(properties, &rule.include_when);
}
if rule.only_audio_sinks {
return !rule.only_default_audio_sink
|| targets_default_sink(properties, default_sink_name, default_sink_target_id);
}
false
}
pub fn targets_default_sink(
properties: &PropMap,
default_sink_name: &str,
default_sink_target_id: &str,
) -> bool {
if default_sink_name.is_empty() && default_sink_target_id.is_empty() {
return true;
}
let Some(target) = properties
.get("target.object")
.or_else(|| properties.get("node.target"))
else {
return true;
};
(!default_sink_name.is_empty() && target == default_sink_name)
|| (!default_sink_target_id.is_empty() && target == default_sink_target_id)
}
pub fn parse_default_sink_name(blob: &str) -> String {
let trimmed = blob.trim();
if !trimmed.starts_with('{') {
return String::new();
}
let bytes = trimmed.as_bytes();
let mut i = 1usize;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'"' {
return String::new();
}
i += 1;
let key_start = i;
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' {
i += 2;
} else {
i += 1;
}
}
if i >= bytes.len() {
return String::new();
}
let key = &trimmed[key_start..i];
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b':' {
return String::new();
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
return String::new();
}
if bytes[i] != b'"' {
let mut depth = 0usize;
while i < bytes.len() {
match bytes[i] {
b'{' | b'[' => depth += 1,
b'}' | b']' => {
if depth == 0 {
return String::new();
}
depth -= 1;
}
b',' if depth == 0 => break,
_ => {}
}
i += 1;
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
continue;
}
return String::new();
}
i += 1;
let val_start = i;
let mut buf = String::new();
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
let escaped = bytes[i + 1];
buf.push(escaped as char);
i += 2;
} else {
buf.push(bytes[i] as char);
i += 1;
}
}
if i >= bytes.len() {
return String::new();
}
if key == "name" {
if buf.len() == i - val_start {
return trimmed[val_start..i].to_string();
}
return buf;
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
}
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_map(entries: &[(&str, &str)]) -> PropMap {
entries
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn system_rule() -> RoutingRule {
RoutingRule {
skip_hardware_devices: true,
only_audio_sinks: true,
only_default_audio_sink: true,
..Default::default()
}
}
#[test]
fn empty_pattern_matches_any_candidate() {
let candidate = make_map(&[("application.name", "Example")]);
let empty: PropPattern = PropPattern::new();
assert!(matches_pattern(&candidate, &empty));
}
#[test]
fn missing_keys_and_mismatched_values_do_not_match() {
let candidate = make_map(&[("application.name", "Example")]);
let missing = make_map(&[("application.process.id", "1234")]);
let mismatched = make_map(&[("application.name", "Other")]);
assert!(!matches_pattern(&candidate, &missing));
assert!(!matches_pattern(&candidate, &mismatched));
}
#[test]
fn synthetic_target_object_pattern_matches_name_or_serial() {
let candidate = make_map(&[("target.object", "42")]);
let deprecated = make_map(&[("node.target", "alsa_output.foo")]);
let pattern = make_map(&[(TARGET_OBJECTS_PATTERN_KEY, "alsa_output.foo\n42")]);
let mismatch = make_map(&[(TARGET_OBJECTS_PATTERN_KEY, "alsa_output.foo\n99")]);
assert!(matches_pattern(&candidate, &pattern));
assert!(matches_pattern(&deprecated, &pattern));
assert!(!matches_pattern(&candidate, &mismatch));
}
#[test]
fn synthetic_display_pattern_keys_do_not_affect_routing() {
let candidate = make_map(&[("application.name", "Example")]);
let pattern = make_map(&[
("application.name", "Example"),
("fluxer.display.name", "Living room speakers"),
]);
assert!(matches_pattern(&candidate, &pattern));
}
#[test]
fn matches_any_requires_at_least_one_matching_pattern() {
let candidate = make_map(&[("application.name", "Example")]);
let patterns = vec![
make_map(&[("application.name", "Other")]),
make_map(&[("application.name", "Example")]),
];
assert!(matches_any(&candidate, &patterns));
assert!(!matches_any(&candidate, &[]));
}
#[test]
fn system_mode_routes_only_default_playback_streams() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let hdmi = "alsa_output.pci-0000_01_00.1.hdmi-stereo";
let stream = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", analog),
]);
let other = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", hdmi),
]);
let rule = system_rule();
assert!(should_route_node(
100, &stream, &rule, analog, "", 1, &identity,
));
assert!(!should_route_node(
101, &other, &rule, analog, "", 1, &identity,
));
}
#[test]
fn structural_self_identity_wins_over_include_rules() {
let mut identity = SelfIdentity::default();
identity.add_pid("4242");
identity.add_binary("fluxer");
identity.add_display_name("Fluxer Canary");
identity.add_display_prefix("Fluxer ");
let rule = RoutingRule {
include_when: vec![make_map(&[("application.process.id", "4242")])],
..Default::default()
};
let by_pid = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("application.process.id", "4242"),
]);
let by_binary = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("application.process.binary", "fluxer"),
]);
let by_description = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("node.description", "Fluxer Direct Capture (pid 4242)"),
]);
assert!(!should_route_node(
200, &by_pid, &rule, "", "", 0, &identity
));
assert!(!should_route_node(
201, &by_binary, &rule, "", "", 0, &identity,
));
assert!(!should_route_node(
202,
&by_description,
&rule,
"",
"",
0,
&identity,
));
}
#[test]
fn routing_refuses_non_playback_media_classes_even_when_included() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Recorder")])],
..Default::default()
};
let input_stream = make_map(&[
("media.class", "Stream/Input/Audio"),
("application.name", "Recorder"),
]);
let device = make_map(&[
("media.class", "Audio/Source"),
("application.name", "Recorder"),
]);
assert!(!should_route_node(
300,
&input_stream,
&rule,
"",
"",
0,
&identity,
));
assert!(!should_route_node(
301, &device, &rule, "", "", 0, &identity
));
}
#[test]
fn system_mode_accepts_untargeted_and_node_target_streams() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let untargeted = make_map(&[("media.class", MEDIA_CLASS_PLAYBACK_STREAM)]);
let deprecated = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("node.target", analog),
]);
let rule = system_rule();
assert!(should_route_node(
100,
&untargeted,
&rule,
analog,
"",
1,
&identity,
));
assert!(should_route_node(
101,
&deprecated,
&rule,
analog,
"",
1,
&identity,
));
}
#[test]
fn system_mode_accepts_default_sink_object_id_targets() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let by_name = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", analog),
]);
let by_id = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", "42"),
]);
let other = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", "99"),
]);
let rule = system_rule();
assert!(should_route_node(
100, &by_name, &rule, analog, "42", 1, &identity,
));
assert!(should_route_node(
101, &by_id, &rule, analog, "42", 1, &identity,
));
assert!(!should_route_node(
102, &other, &rule, analog, "42", 1, &identity,
));
}
#[test]
fn include_mode_honors_hardware_filtering_and_never_rules() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Firefox")])],
never_when: vec![make_map(&[("application.process.id", "999")])],
skip_hardware_devices: true,
..Default::default()
};
let app = make_map(&[
("application.name", "Firefox"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let hardware = make_map(&[("application.name", "Firefox"), ("device.id", "5")]);
let blocked = make_map(&[
("application.name", "Firefox"),
("application.process.id", "999"),
]);
assert!(should_route_node(10, &app, &rule, "", "", 1, &identity));
assert!(!should_route_node(
11, &hardware, &rule, "", "", 1, &identity
));
assert!(!should_route_node(
12, &blocked, &rule, "", "", 1, &identity
));
}
#[test]
fn include_mode_rejects_non_playback_nodes_that_match_the_include_filter() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Chromium")])],
..Default::default()
};
let mic = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Source"),
]);
let sink = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Sink"),
]);
let input = make_map(&[
("application.name", "Chromium"),
("media.class", "Stream/Input/Audio"),
]);
let virtual_source = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Source/Virtual"),
]);
let playback = make_map(&[
("application.name", "Chromium"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let unclassified = make_map(&[("application.name", "Chromium")]);
assert!(!should_route_node(20, &mic, &rule, "", "", 1, &identity));
assert!(!should_route_node(21, &sink, &rule, "", "", 1, &identity));
assert!(!should_route_node(22, &input, &rule, "", "", 1, &identity));
assert!(!should_route_node(
23,
&virtual_source,
&rule,
"",
"",
1,
&identity,
));
assert!(!should_route_node(
24,
&unclassified,
&rule,
"",
"",
1,
&identity,
));
assert!(should_route_node(
25, &playback, &rule, "", "", 1, &identity
));
}
#[test]
fn empty_rules_route_nothing_and_sink_id_is_excluded() {
let identity = SelfIdentity::default();
let app = make_map(&[
("application.name", "Foo"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let empty = RoutingRule::default();
assert!(!should_route_node(1, &app, &empty, "", "", 0, &identity));
let rule = system_rule();
assert!(!should_route_node(7, &app, &rule, "", "", 7, &identity));
}
#[test]
fn parse_default_sink_name_is_strict_and_tolerant() {
assert_eq!(
"alsa_output.foo",
parse_default_sink_name(r#"{"name":"alsa_output.foo","other":"bar"}"#),
);
assert_eq!("", parse_default_sink_name("not-json"));
assert_eq!("", parse_default_sink_name(r#"{"name":42}"#));
}
#[test]
fn self_identity_matches_across_documented_pipewire_keys() {
let mut identity = SelfIdentity::default();
identity.add_pid("1234");
identity.add_binary("fluxer");
identity.add_display_name("Fluxer Canary");
identity.add_display_prefix("Fluxer ");
let by_pid = make_map(&[("application.process.id", "1234")]);
let by_sec_pid = make_map(&[("pipewire.sec.pid", "1234")]);
let by_binary = make_map(&[("application.process.binary", "fluxer")]);
let by_app_name = make_map(&[("application.name", "fluxer")]);
let by_node_name = make_map(&[("node.name", "fluxer")]);
let by_node_nick = make_map(&[("node.nick", "Fluxer Canary")]);
let by_node_description = make_map(&[("node.description", "Fluxer app audio capture")]);
let stranger = make_map(&[("application.process.id", "9999")]);
assert!(identity.matches(&by_pid));
assert!(identity.matches(&by_sec_pid));
assert!(identity.matches(&by_binary));
assert!(identity.matches(&by_app_name));
assert!(identity.matches(&by_node_name));
assert!(identity.matches(&by_node_nick));
assert!(identity.matches(&by_node_description));
assert!(!identity.matches(&stranger));
}
}
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use crate::routing::SelfIdentity;
const PRODUCT_DISPLAY_NAMES: &[&str] = &["Fluxer", "Fluxer Canary"];
const PRODUCT_DISPLAY_PREFIXES: &[&str] = &[
"Fluxer ", "fluxer ", "Fluxer-", "fluxer-", "Fluxer_", "fluxer_", "Fluxer.", "fluxer.",
];
pub fn populate_self_identity(out: &mut SelfIdentity) {
let own_pid = std::process::id();
out.add_pid(own_pid.to_string());
for name in PRODUCT_DISPLAY_NAMES {
out.add_display_name((*name).to_string());
}
for prefix in PRODUCT_DISPLAY_PREFIXES {
out.add_display_prefix((*prefix).to_string());
}
if let Ok(comm) = fs::read_to_string("/proc/self/comm") {
let trimmed = comm.trim();
if !trimmed.is_empty() {
out.add_binary(trimmed.to_string());
}
}
if let Ok(exe) = fs::read_link("/proc/self/exe")
&& let Some(name) = exe.file_name().and_then(|s| s.to_str())
{
out.add_binary(name.to_string());
if let Some(stripped) = strip_exe_suffix(name) {
out.add_binary(stripped.to_string());
}
}
out.add_binary("Electron".to_string());
out.add_binary("electron".to_string());
let _ = add_descendant_pids(out, own_pid);
}
fn strip_exe_suffix(name: &str) -> Option<&str> {
for suffix in [".AppImage", ".bin"] {
if name.len() >= suffix.len()
&& name[name.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
{
return Some(&name[..name.len() - suffix.len()]);
}
}
None
}
pub fn parse_ppid_from_stat(blob: &str) -> Option<u32> {
let close = blob.rfind(')')?;
let tail = &blob[close + 1..];
let mut fields = tail.split_ascii_whitespace();
let _state = fields.next()?;
let ppid = fields.next()?;
ppid.parse::<u32>().ok()
}
fn add_descendant_pids(out: &mut SelfIdentity, own_pid: u32) -> std::io::Result<()> {
let mut entries: Vec<(u32, u32)> = Vec::new();
for entry in fs::read_dir(Path::new("/proc"))? {
let Ok(entry) = entry else { continue };
let Some(name) = entry.file_name().to_str().map(|s| s.to_string()) else {
continue;
};
let Ok(pid) = name.parse::<u32>() else {
continue;
};
let stat_path = format!("/proc/{pid}/stat");
let Ok(blob) = fs::read_to_string(&stat_path) else {
continue;
};
let Some(ppid) = parse_ppid_from_stat(&blob) else {
continue;
};
entries.push((pid, ppid));
}
let mut by_parent: HashMap<u32, Vec<u32>> = HashMap::new();
for &(pid, ppid) in &entries {
by_parent.entry(ppid).or_default().push(pid);
}
let mut ours: HashSet<u32> = HashSet::new();
ours.insert(own_pid);
let mut frontier = vec![own_pid];
while let Some(parent) = frontier.pop() {
if let Some(children) = by_parent.get(&parent) {
for &child in children {
if ours.insert(child) {
frontier.push(child);
}
}
}
}
for pid in ours {
out.add_pid(pid.to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ppid_handles_comm_with_spaces_and_parens() {
let stat = "1234 (weird (comm) name) S 4321 1234 1234 0 -1 4194304 0 0 0 0";
assert_eq!(Some(4321), parse_ppid_from_stat(stat));
}
#[test]
fn parse_ppid_handles_plain_stat() {
let stat = "42 (cat) R 7 42 7 34816 42 4194304 91 0 0 0";
assert_eq!(Some(7), parse_ppid_from_stat(stat));
}
#[test]
fn parse_ppid_returns_none_on_malformed_input() {
assert_eq!(None, parse_ppid_from_stat(""));
assert_eq!(None, parse_ppid_from_stat("no closing paren"));
assert_eq!(None, parse_ppid_from_stat("1 (cat)"));
}
#[test]
fn strip_exe_suffix_handles_known_extensions() {
assert_eq!(Some("fluxer"), strip_exe_suffix("fluxer.AppImage"));
assert_eq!(Some("fluxer"), strip_exe_suffix("fluxer.bin"));
assert_eq!(None, strip_exe_suffix("fluxer"));
assert_eq!(Some("fluxer"), strip_exe_suffix("fluxer.APPIMAGE"));
}
#[test]
fn populate_self_identity_records_own_pid() {
let mut id = SelfIdentity::default();
populate_self_identity(&mut id);
assert!(id.pids.contains(&std::process::id().to_string()));
assert!(id.binaries.contains("Electron"));
assert!(id.binaries.contains("electron"));
}
}
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {execFileSync} from 'node:child_process';
import {copyFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {setTimeout as sleep} from 'node:timers/promises';
const require = createRequire(import.meta.url);
const work = process.env.FLX_WORK;
const addonNode = `${work}/flx_direct_addon.node`;
copyFileSync(process.env.FLX_ADDON_SO, addonNode);
const addon = require(addonNode);
const results = [];
function record(name, ok, detail) {
results.push({name, ok});
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` -- ${detail}` : ''}`);
}
function pwDump() {
return JSON.parse(execFileSync('pw-dump', {encoding: 'utf8', maxBuffer: 64e6}));
}
function directSinkNode(dump) {
return dump.find(
(o) => o.type === 'PipeWire:Interface:Node' && /^fluxer-direct-capture-/.test(o.info?.props?.['node.name'] || ''),
);
}
async function waitFor(p, ms, step = 150) {
const end = Date.now() + ms;
let last;
while (Date.now() < end) {
last = p();
if (last) return last;
await sleep(step);
}
return last;
}
function rms(samples) {
if (samples.length === 0) return 0;
let sum = 0;
for (const s of samples) sum += s * s;
return Math.sqrt(sum / samples.length);
}
async function main() {
const dc = new addon.DirectAudioCapture();
let lifecycle = null;
dc.setLifecycleCallback((...args) => {
const flat = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
lifecycle = {kind: flat[0], msg: flat[1]};
});
const started = dc.start({include: [{'application.name': 'Music Player Demo'}]});
record('DirectAudioCapture.start(include rule) accepted', started === true);
const sinkUp = await waitFor(() => {
const d = pwDump();
return directSinkNode(d) ? d : null;
}, 8000);
record(
'hidden private sink fluxer-direct-capture-* created',
!!sinkUp,
sinkUp ? directSinkNode(sinkUp).info.props['node.name'] : 'timeout',
);
if (sinkUp) {
const sinkProps = directSinkNode(sinkUp).info.props;
record(
'private sink node.hidden=true (not user-visible)',
String(sinkProps['node.hidden']) === 'true',
`node.hidden=${sinkProps['node.hidden']}`,
);
record('private sink media.class=Audio/Sink', sinkProps['media.class'] === 'Audio/Sink', sinkProps['media.class']);
}
let maxRms = 0;
let frames = 0;
let totalSamples = 0;
const captureDeadline = Date.now() + 6000;
while (Date.now() < captureDeadline) {
const f = dc.read();
if (f) {
const samples = new Float32Array(f.samples);
frames += 1;
totalSamples += samples.length;
maxRms = Math.max(maxRms, rms(samples));
if (maxRms > 0.01 && frames > 5) break;
}
await sleep(20);
}
record('DirectAudioCapture yields real frames', frames > 0, `${frames} frames, ${totalSamples} samples`);
record('captured audio is non-silent (real 440Hz tone tapped)', maxRms > 0.01, `peak rms=${maxRms.toFixed(4)}`);
const dump = pwDump();
const sink = directSinkNode(dump);
const sinkId = Number(sink?.id);
const linkSrcNodes = new Set(
dump
.filter((o) => o.type === 'PipeWire:Interface:Link' && Number(o.info?.props?.['link.input.node']) === sinkId)
.map((o) => Number(o.info?.props?.['link.output.node'])),
);
const fluxerStreamIds = dump
.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['application.name'] === 'Fluxer')
.map((o) => Number(o.id));
record(
'Fluxer-named app excluded from per-process capture',
fluxerStreamIds.every((id) => !linkSrcNodes.has(id)),
`fluxer=${fluxerStreamIds} linkedSrc=${[...linkSrcNodes]}`,
);
const musicIds = dump
.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['application.name'] === 'Music Player Demo')
.map((o) => Number(o.id));
record(
'targeted app IS linked to the private sink',
musicIds.some((id) => linkSrcNodes.has(id)),
`music=${musicIds} linkedSrc=${[...linkSrcNodes]}`,
);
dc.stop();
await sleep(600);
record('stop() emits closed-clean lifecycle', lifecycle?.kind === 'closed-clean', JSON.stringify(lifecycle));
const afterStop = pwDump();
const sinkAfter = directSinkNode(afterStop);
const residualLinks = sinkAfter
? afterStop.filter(
(o) =>
o.type === 'PipeWire:Interface:Link' && Number(o.info?.props?.['link.input.node']) === Number(sinkAfter.id),
).length
: 0;
record('stop() removes capture links', residualLinks === 0, `residual=${residualLinks}`);
const failed = results.filter((r) => !r.ok).length;
console.log(`\n=== direct: ${results.length - failed}/${results.length} checks passed ===`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('HARNESS ERROR:', e?.stack || e);
process.exit(2);
});
@@ -0,0 +1,233 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {execFileSync, spawn} from 'node:child_process';
import {copyFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {setTimeout as sleep} from 'node:timers/promises';
const require = createRequire(import.meta.url);
const addonSo = req('FLX_ADDON_SO');
const work = req('FLX_WORK');
const tone = req('FLX_TONE');
const addonNode = `${work}/flx_audio_addon.node`;
copyFileSync(addonSo, addonNode);
const addon = require(addonNode);
const SINK_NAME = 'fluxer-screen-share';
const SINK_DESC = 'Fluxer Screen Share Audio';
const results = [];
const children = [];
function req(name) {
const v = process.env[name];
if (!v) {
console.error(`HARNESS ERROR: ${name} not set`);
process.exit(2);
}
return v;
}
function record(name, ok, detail) {
results.push({name, ok});
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` -- ${detail}` : ''}`);
}
function pwDump() {
return JSON.parse(execFileSync('pw-dump', {encoding: 'utf8', maxBuffer: 64e6}));
}
function nodesByName(dump, name) {
return dump.filter((o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === name);
}
function links(dump) {
return dump
.filter((o) => o.type === 'PipeWire:Interface:Link')
.map((o) => ({
inNode: Number(o.info?.props?.['link.input.node']),
outNode: Number(o.info?.props?.['link.output.node']),
}));
}
function streamNodes(dump) {
return dump.filter(
(o) => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['media.class'] === 'Stream/Output/Audio',
);
}
async function waitFor(predicate, timeoutMs, stepMs = 150) {
const deadline = Date.now() + timeoutMs;
let last;
while (Date.now() < deadline) {
last = predicate();
if (last) return last;
await sleep(stepMs);
}
return last;
}
function killAll() {
for (const c of children) {
try {
c.kill('SIGKILL');
} catch {}
}
}
async function main() {
record('backend is pipewire', addon.audioBackend() === 'pipewire', addon.audioBackend());
const descendant = spawn(
'pw-play',
['--target', 'test_speakers', '-P', '{ application.name = "Descendant Player" }', tone],
{
stdio: 'ignore',
env: process.env,
},
);
children.push(descendant);
const speakerIdOf = (d) => nodesByName(d, 'test_speakers')[0]?.id;
const sourcesInto = (dump) => {
const sid = Number(speakerIdOf(dump));
return new Set(
links(dump)
.filter((l) => l.inNode === sid)
.map((l) => l.outNode),
).size;
};
const pre = await waitFor(() => {
const d = pwDump();
return streamNodes(d).length >= 5 && sourcesInto(d) >= 5 ? d : null;
}, 12000);
record(
'all 5 playback streams routed to speakers pre-capture',
!!pre,
pre ? `${streamNodes(pre).length} streams, ${sourcesInto(pre)} routed` : 'timeout',
);
const preDump = pre || pwDump();
const speakers = nodesByName(preDump, 'test_speakers');
record('default sink test_speakers exists', speakers.length === 1);
const preSpeakerSources = sourcesInto(preDump);
record(
'every app is playing to the real speakers pre-capture',
preSpeakerSources >= 5,
`${preSpeakerSources} distinct app streams -> speakers`,
);
const bridge = new addon.AudioBridge();
record('AudioBridge on pipewire', bridge.backend() === 'pipewire', bridge.backend());
record(
'apply(system rule) accepted',
bridge.apply({onlySpeakers: true, onlyDefaultSpeakers: true, ignoreDevices: true}) === true,
);
const after = await waitFor(() => {
const d = pwDump();
if (nodesByName(d, SINK_NAME).length !== 1) return null;
const g = bridge.routingGraph();
return g.ownedLinks.length >= 2 ? {d, g} : null;
}, 10000);
if (!after) {
record('fluxer sink + capture links established', false, 'timeout');
await finish(bridge);
return;
}
const {d: dump, g: graph} = after;
const sink = nodesByName(dump, SINK_NAME);
record('exactly one fluxer-screen-share node', sink.length === 1, `count=${sink.length}`);
const sp = sink[0]?.info?.props ?? {};
record(
'sink node.description is "Fluxer Screen Share Audio"',
sp['node.description'] === SINK_DESC,
sp['node.description'],
);
record('sink media.class is Audio/Source/Virtual', sp['media.class'] === 'Audio/Source/Virtual', sp['media.class']);
record('sink node.virtual=true', String(sp['node.virtual']) === 'true');
const sinkId = Number(sink[0]?.id);
record(
'all owned links are passive',
graph.ownedLinks.every((l) => l.passive === true),
`${graph.ownedLinks.length} links`,
);
record(
'all owned links terminate at the fluxer sink',
graph.ownedLinks.every((l) => Number(l.inputNodeId) === sinkId),
);
const captured = new Set(graph.ownedLinks.map((l) => Number(l.outputNodeId)));
const idsByPredicate = (pred) =>
streamNodes(dump)
.filter((o) => pred(o.info.props))
.map((o) => Number(o.id));
const normalIds = idsByPredicate(
(p) =>
['pw-play', 'Music Player Demo'].includes(p['application.name']) &&
p['application.name'] !== 'Fluxer' &&
p['application.name'] !== 'Descendant Player' &&
!(p['node.name'] || '').startsWith('Fluxer '),
);
const fluxerAppIds = idsByPredicate((p) => p['application.name'] === 'Fluxer');
const fluxerNodeIds = idsByPredicate((p) => (p['node.name'] || '').startsWith('Fluxer '));
const descendantIds = idsByPredicate((p) => p['application.name'] === 'Descendant Player');
record(
'normal external apps ARE captured',
normalIds.length >= 2 && normalIds.every((id) => captured.has(id)),
`normal=${normalIds} captured=${[...captured]}`,
);
record(
'Fluxer-named app (application.name) is EXCLUDED',
fluxerAppIds.length >= 1 && fluxerAppIds.every((id) => !captured.has(id)),
`fluxerApp=${fluxerAppIds}`,
);
record(
'Fluxer-named app (node.name prefix) is EXCLUDED',
fluxerNodeIds.length >= 1 && fluxerNodeIds.every((id) => !captured.has(id)),
`fluxerNode=${fluxerNodeIds}`,
);
record(
'descendant-PID player is EXCLUDED (self-process tree)',
descendantIds.length >= 1 && descendantIds.every((id) => !captured.has(id)),
`descendant=${descendantIds}`,
);
const afterSpeakerSources = sourcesInto(dump);
record(
'apps STILL play to real speakers during capture (tap, not move)',
afterSpeakerSources >= preSpeakerSources,
`before=${preSpeakerSources} after=${afterSpeakerSources} distinct app streams -> speakers`,
);
const meta = dump.find((o) => o.type === 'PipeWire:Interface:Metadata' && o.props?.['metadata.name'] === 'default');
const def = meta?.metadata?.find((m) => m.key === 'default.audio.sink')?.value?.name;
record('default audio sink unchanged (test_speakers)', def === 'test_speakers', `default=${def}`);
await finish(bridge);
}
async function finish(bridge) {
bridge.release();
const cleared = await waitFor(() => (bridge.routingGraph().ownedLinks.length === 0 ? true : null), 5000);
record(
'release()+settle removes all owned links',
!!cleared,
cleared ? '0 owned links' : `still ${bridge.routingGraph().ownedLinks.length}`,
);
await sleep(500);
const d = pwDump();
const sinkId = Number(nodesByName(d, SINK_NAME)[0]?.id);
const residual = Number.isNaN(sinkId) ? 0 : links(d).filter((l) => l.inNode === sinkId).length;
record('no residual links into fluxer sink after release', residual === 0, `residual=${residual}`);
killAll();
const failed = results.filter((r) => !r.ok).length;
console.log(`\n=== ${results.length - failed}/${results.length} checks passed ===`);
process.exit(failed === 0 ? 0 : 1);
}
process.on('exit', killAll);
main().catch((e) => {
killAll();
console.error('HARNESS ERROR:', e?.stack || e);
process.exit(2);
});
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -uo pipefail
WORK="${FLX_WORK:-/home/parallels/flx-vmtest}"
ADDON_SO="${FLX_ADDON_SO:-/home/parallels/flx-target/debug/libfluxer_linux_audio_capture.so}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$WORK"
export XDG_RUNTIME_DIR="$WORK/xdg"
mkdir -p "$XDG_RUNTIME_DIR"; chmod 700 "$XDG_RUNTIME_DIR"
export PIPEWIRE_RUNTIME_DIR="$XDG_RUNTIME_DIR"
export PULSE_RUNTIME_PATH="$XDG_RUNTIME_DIR/pulse"
unset DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
PIDS=()
cleanup() {
for p in "${PIDS[@]:-}"; do kill -9 "$p" 2>/dev/null; done
pkill -9 -u "$(id -un)" -f "pipewire" 2>/dev/null
pkill -9 -u "$(id -un)" -f "wireplumber" 2>/dev/null
}
trap cleanup EXIT
echo "=== starting private pipewire server (runtime=$XDG_RUNTIME_DIR) ==="
pipewire >"$WORK/pipewire.log" 2>&1 & PIDS+=($!)
sleep 0.8
pipewire-pulse >"$WORK/pipewire-pulse.log" 2>&1 & PIDS+=($!)
sleep 0.5
wireplumber >"$WORK/wireplumber.log" 2>&1 & PIDS+=($!)
for _ in $(seq 1 40); do pw-cli info 0 >/dev/null 2>&1 && break; sleep 0.25; done
if ! pw-cli info 0 >/dev/null 2>&1; then
echo "FATAL: private pipewire did not come up"; cat "$WORK/pipewire.log"; exit 2
fi
echo "=== creating virtual speakers (default sink) ==="
pactl load-module module-null-sink sink_name=test_speakers sink_properties='device.description=Test_Speakers' >/dev/null 2>&1
pactl set-default-sink test_speakers 2>/dev/null
sleep 0.4
echo "=== generating a real 600s stereo tone ==="
TONE="$WORK/tone.wav"
[ -f "$TONE" ] || ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=440:duration=600" -ac 2 -ar 48000 "$TONE" </dev/null
export FLX_TONE="$TONE"
echo "=== spawning 4 external real-app players (outside the harness process tree) ==="
pw-play --target test_speakers "$TONE" >/dev/null 2>&1 & PIDS+=($!)
pw-play --target test_speakers -P '{ application.name = "Music Player Demo" }' "$TONE" >/dev/null 2>&1 & PIDS+=($!)
pw-play --target test_speakers -P '{ application.name = "Fluxer" }' "$TONE" >/dev/null 2>&1 & PIDS+=($!)
pw-play --target test_speakers -P '{ node.name = "Fluxer Helper Stream" }' "$TONE" >/dev/null 2>&1 & PIDS+=($!)
sleep 1.5
echo "=== pre-test graph (fluxer sink should be ABSENT) ==="
pw-dump | node -e 'const d=JSON.parse(require("fs").readFileSync(0));const f=d.filter(o=>o.type==="PipeWire:Interface:Node"&&/fluxer-screen-share/.test(o.info?.props?.["node.name"]||""));console.log("fluxer sink nodes pre-test:",f.length);'
echo "=== running napi SYSTEM-capture validation harness ==="
export FLX_ADDON_SO="$ADDON_SO" FLX_WORK="$WORK"
node "$HERE/pw_graph_validation.mjs"
HARNESS_RC=$?
echo "=== running napi DIRECT (per-process) capture validation harness ==="
node "$HERE/direct_capture_validation.mjs"
DIRECT_RC=$?
[ "$DIRECT_RC" = "0" ] || HARNESS_RC=$DIRECT_RC
echo "=== post-exit cleanup check (Drop must remove the fluxer sink) ==="
sleep 0.8
RESIDUAL=$(pw-dump | node -e 'const d=JSON.parse(require("fs").readFileSync(0));const f=d.filter(o=>(o.type==="PipeWire:Interface:Node"&&/fluxer/.test(o.info?.props?.["node.name"]||""))||(o.type==="PipeWire:Interface:Link"&&/fluxer/.test(JSON.stringify(o.info?.props||{}))));console.log(f.length);')
if [ "$RESIDUAL" = "0" ]; then
echo "PASS no fluxer nodes/links remain after addon process exit (clean teardown)"
else
echo "FAIL $RESIDUAL residual fluxer objects after addon process exit"
HARNESS_RC=1
fi
echo "=== DONE rc=$HARNESS_RC ==="
exit $HARNESS_RC
+552
View File
@@ -0,0 +1,552 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "ctor"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "evdev"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99"
dependencies = [
"bitvec",
"cfg-if",
"libc",
"nix 0.29.0",
]
[[package]]
name = "fluxer_linux_evdev"
version = "0.0.0"
dependencies = [
"evdev",
"napi",
"napi-build",
"napi-derive",
"nix 0.31.3",
"polling",
"udev",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "io-lifetimes"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
dependencies = [
"hermit-abi 0.3.9",
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "napi"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.2",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "udev"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f"
dependencies = [
"io-lifetimes",
"libc",
"libudev-sys",
"pkg-config",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
@@ -0,0 +1,25 @@
[package]
name = "fluxer_linux_evdev"
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"
[target.'cfg(target_os = "linux")'.dependencies]
evdev = "0.13.2"
nix = {version = "0.31.3", default-features = false, features = ["event"]}
udev = "0.9.3"
polling = "3.11.0"
[build-dependencies]
napi-build = "2.3.2"
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface EvdevKeyEvent {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
export interface EvdevMouseEvent {
type: 'mousedown' | 'mouseup';
button: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
export type NativeEvdevEvent = EvdevKeyEvent | EvdevMouseEvent;
export declare class EvdevHook {
constructor(onEvent: (event: NativeEvdevEvent) => void);
start(): boolean;
stop(): void;
}
export declare function nameToEvdevKeycode(name: string): number;
export declare const loadError: Error | null;
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-evdev';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_EVDEV_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-evdev is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-evdev.linux-x64-gnu.node';
case 'arm64':
return 'linux-evdev.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'linux') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
throw loadError;
}
}
module.exports = {
EvdevHook: binding ? binding.EvdevHook : null,
nameToEvdevKeycode: binding ? binding.nameToEvdevKeycode : null,
loadError,
};
@@ -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-evdev",
"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-evdev.linux-x64-gnu.node",
"linux-evdev.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,247 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const KEY_MAP: &[(u16, &str)] = &[
(1, "Escape"),
(2, "1"),
(3, "2"),
(4, "3"),
(5, "4"),
(6, "5"),
(7, "6"),
(8, "7"),
(9, "8"),
(10, "9"),
(11, "0"),
(12, "Minus"),
(13, "Equal"),
(14, "Backspace"),
(15, "Tab"),
(16, "Q"),
(17, "W"),
(18, "E"),
(19, "R"),
(20, "T"),
(21, "Y"),
(22, "U"),
(23, "I"),
(24, "O"),
(25, "P"),
(26, "BracketLeft"),
(27, "BracketRight"),
(28, "Enter"),
(29, "ControlLeft"),
(30, "A"),
(31, "S"),
(32, "D"),
(33, "F"),
(34, "G"),
(35, "H"),
(36, "J"),
(37, "K"),
(38, "L"),
(39, "Semicolon"),
(40, "Quote"),
(41, "Backquote"),
(42, "ShiftLeft"),
(43, "Backslash"),
(44, "Z"),
(45, "X"),
(46, "C"),
(47, "V"),
(48, "B"),
(49, "N"),
(50, "M"),
(51, "Comma"),
(52, "Period"),
(53, "Slash"),
(54, "ShiftRight"),
(55, "NumpadMultiply"),
(56, "AltLeft"),
(57, "Space"),
(58, "CapsLock"),
(59, "F1"),
(60, "F2"),
(61, "F3"),
(62, "F4"),
(63, "F5"),
(64, "F6"),
(65, "F7"),
(66, "F8"),
(67, "F9"),
(68, "F10"),
(69, "NumLock"),
(70, "ScrollLock"),
(71, "Numpad7"),
(72, "Numpad8"),
(73, "Numpad9"),
(74, "NumpadSubtract"),
(75, "Numpad4"),
(76, "Numpad5"),
(77, "Numpad6"),
(78, "NumpadAdd"),
(79, "Numpad1"),
(80, "Numpad2"),
(81, "Numpad3"),
(82, "Numpad0"),
(83, "NumpadDecimal"),
(86, "IntlBackslash"),
(119, "Pause"),
(87, "F11"),
(88, "F12"),
(89, "IntlRo"),
(90, "Lang3"),
(92, "Convert"),
(93, "KanaMode"),
(94, "NonConvert"),
(96, "NumpadEnter"),
(97, "ControlRight"),
(98, "NumpadDivide"),
(99, "PrintScreen"),
(100, "AltRight"),
(102, "Home"),
(103, "ArrowUp"),
(104, "PageUp"),
(105, "ArrowLeft"),
(106, "ArrowRight"),
(107, "End"),
(108, "ArrowDown"),
(109, "PageDown"),
(110, "Insert"),
(111, "Delete"),
(113, "AudioVolumeMute"),
(114, "AudioVolumeDown"),
(115, "AudioVolumeUp"),
(116, "Power"),
(117, "NumpadEqual"),
(121, "NumpadComma"),
(122, "Lang1"),
(123, "Lang2"),
(124, "IntlYen"),
(125, "MetaLeft"),
(126, "MetaRight"),
(127, "ContextMenu"),
(142, "Sleep"),
(143, "WakeUp"),
(148, "LaunchApp1"),
(149, "LaunchApp2"),
(155, "LaunchMail"),
(158, "BrowserBack"),
(159, "BrowserForward"),
(163, "MediaTrackNext"),
(164, "MediaPlayPause"),
(165, "MediaTrackPrevious"),
(166, "MediaStop"),
(172, "BrowserHome"),
(173, "BrowserRefresh"),
(183, "F13"),
(184, "F14"),
(185, "F15"),
(186, "F16"),
(187, "F17"),
(188, "F18"),
(189, "F19"),
(190, "F20"),
(191, "F21"),
(192, "F22"),
(193, "F23"),
(194, "F24"),
(217, "BrowserSearch"),
(226, "LaunchMediaPlayer"),
(364, "BrowserFavorites"),
];
pub const LEFT_CTRL: u16 = 29;
pub const RIGHT_CTRL: u16 = 97;
pub const LEFT_SHIFT: u16 = 42;
pub const RIGHT_SHIFT: u16 = 54;
pub const LEFT_ALT: u16 = 56;
pub const RIGHT_ALT: u16 = 100;
pub const LEFT_META: u16 = 125;
pub const RIGHT_META: u16 = 126;
pub const BTN_LEFT: u16 = 0x110;
pub const BTN_RIGHT: u16 = 0x111;
pub const BTN_MIDDLE: u16 = 0x112;
pub const BTN_SIDE: u16 = 0x113;
pub const BTN_EXTRA: u16 = 0x114;
pub const BTN_FORWARD: u16 = 0x115;
pub const BTN_BACK: u16 = 0x116;
pub fn evdev_button_to_browser_button(code: u16) -> Option<u8> {
match code {
BTN_LEFT => Some(0),
BTN_MIDDLE => Some(1),
BTN_RIGHT => Some(2),
BTN_SIDE | BTN_BACK => Some(3),
BTN_EXTRA | BTN_FORWARD => Some(4),
_ => None,
}
}
pub fn keycode_to_name(code: u16) -> Option<&'static str> {
KEY_MAP
.iter()
.find(|(entry_code, _)| *entry_code == code)
.map(|(_, name)| *name)
}
pub fn name_to_keycode(name: &str) -> u16 {
KEY_MAP
.iter()
.find(|(_, entry_name)| *entry_name == name)
.map(|(code, _)| *code)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keycode_to_name_covers_canonical_letters_and_arrows() {
assert_eq!(keycode_to_name(30), Some("A"));
assert_eq!(keycode_to_name(44), Some("Z"));
assert_eq!(keycode_to_name(119), Some("Pause"));
assert_eq!(keycode_to_name(183), Some("F13"));
assert_eq!(keycode_to_name(96), Some("NumpadEnter"));
assert_eq!(keycode_to_name(113), Some("AudioVolumeMute"));
assert_eq!(keycode_to_name(103), Some("ArrowUp"));
assert_eq!(keycode_to_name(125), Some("MetaLeft"));
assert_eq!(keycode_to_name(0), None);
assert_eq!(keycode_to_name(0xffff), None);
}
#[test]
fn name_to_keycode_round_trips_every_entry() {
for (code, name) in KEY_MAP {
assert_eq!(name_to_keycode(name), *code);
}
assert_eq!(name_to_keycode("NoSuchKey"), 0);
assert_eq!(name_to_keycode(""), 0);
}
#[test]
fn evdev_button_matches_dom_convention() {
assert_eq!(evdev_button_to_browser_button(BTN_LEFT), Some(0));
assert_eq!(evdev_button_to_browser_button(BTN_MIDDLE), Some(1));
assert_eq!(evdev_button_to_browser_button(BTN_RIGHT), Some(2));
assert_eq!(evdev_button_to_browser_button(BTN_SIDE), Some(3));
assert_eq!(evdev_button_to_browser_button(BTN_BACK), Some(3));
assert_eq!(evdev_button_to_browser_button(BTN_EXTRA), Some(4));
assert_eq!(evdev_button_to_browser_button(BTN_FORWARD), Some(4));
assert_eq!(evdev_button_to_browser_button(0x100), None);
assert_eq!(evdev_button_to_browser_button(0xffff), None);
}
#[test]
fn modifier_constants_match_linux_input_event_codes() {
assert_eq!(LEFT_CTRL, 29);
assert_eq!(RIGHT_CTRL, 97);
assert_eq!(LEFT_SHIFT, 42);
assert_eq!(RIGHT_SHIFT, 54);
assert_eq!(LEFT_ALT, 56);
assert_eq!(RIGHT_ALT, 100);
assert_eq!(LEFT_META, 125);
assert_eq!(RIGHT_META, 126);
}
}
@@ -0,0 +1,17 @@
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
// SPDX-License-Identifier: AGPL-3.0-or-later
mod keymap;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::{EvdevHook, name_to_evdev_keycode};
#[cfg(not(target_os = "linux"))]
#[napi_derive::napi(js_name = "nameToEvdevKeycode")]
pub fn name_to_evdev_keycode(name: String) -> u32 {
u32::from(crate::keymap::name_to_keycode(&name))
}
@@ -0,0 +1,731 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::{
collections::{HashMap, HashSet},
os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
};
use evdev::{Device, EventType, KeyCode};
use napi::{
Env, Status,
bindgen_prelude::{Function, Object, Result, ToNapiValue},
sys,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
};
use napi_derive::napi;
use nix::sys::eventfd::{EfdFlags, EventFd};
use polling::{Event as PollEvent, Events, Poller};
use crate::keymap;
const EXIT_KEY: usize = 0;
const MONITOR_KEY: usize = 1;
const DEVICE_KEY_BASE: usize = 2;
const DEV_INPUT_PREFIX: &str = "/dev/input/event";
const EVENT_QUEUE_LIMIT: usize = 1024;
fn poll_key_for_device_fd(fd: RawFd) -> Option<usize> {
usize::try_from(fd).ok()?.checked_add(DEVICE_KEY_BASE)
}
fn device_fd_from_poll_key(key: usize) -> Option<RawFd> {
let fd = key.checked_sub(DEVICE_KEY_BASE)?;
RawFd::try_from(fd).ok()
}
#[derive(Debug)]
pub enum NativeEvent {
Key {
kind: KeyKind,
keycode: u16,
key_name: &'static str,
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
},
Mouse {
kind: MouseKind,
button: u8,
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
},
}
#[derive(Debug, Clone, Copy)]
pub enum KeyKind {
Down,
Up,
}
#[derive(Debug, Clone, Copy)]
pub enum MouseKind {
Down,
Up,
}
impl ToNapiValue for NativeEvent {
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
match event {
Self::Key {
kind,
keycode,
key_name,
ctrl,
alt,
shift,
meta,
} => {
object.set(
"type",
match kind {
KeyKind::Down => "keydown",
KeyKind::Up => "keyup",
},
)?;
object.set("keycode", u32::from(keycode))?;
object.set("keyName", key_name)?;
object.set("ctrlKey", ctrl)?;
object.set("altKey", alt)?;
object.set("shiftKey", shift)?;
object.set("metaKey", meta)?;
}
Self::Mouse {
kind,
button,
ctrl,
alt,
shift,
meta,
} => {
object.set(
"type",
match kind {
MouseKind::Down => "mousedown",
MouseKind::Up => "mouseup",
},
)?;
object.set("button", u32::from(button))?;
object.set("ctrlKey", ctrl)?;
object.set("altKey", alt)?;
object.set("shiftKey", shift)?;
object.set("metaKey", meta)?;
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
}
}
type EventTsfn = Arc<
ThreadsafeFunction<
NativeEvent,
UnknownReturnValue,
NativeEvent,
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
struct ExitFd {
fd: EventFd,
}
impl ExitFd {
fn new() -> std::io::Result<Self> {
let fd = EventFd::from_value_and_flags(0, EfdFlags::EFD_CLOEXEC | EfdFlags::EFD_NONBLOCK)
.map_err(std::io::Error::from)?;
Ok(Self { fd })
}
fn signal(&self) {
let _ = self.fd.write(1);
}
fn drain(&self) {
let _ = self.fd.read();
}
fn as_borrowed(&self) -> BorrowedFd<'_> {
self.fd.as_fd()
}
}
fn resolve_seat() -> String {
std::env::var("XDG_SEAT")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "seat0".to_string())
}
fn read_seat_from_device(device: &udev::Device) -> Option<String> {
if let Some(value) = device.property_value("ID_SEAT") {
return Some(value.to_string_lossy().into_owned());
}
let mut parent = device.parent();
while let Some(p) = parent {
if let Some(value) = p.property_value("ID_SEAT") {
return Some(value.to_string_lossy().into_owned());
}
parent = p.parent();
}
Some("seat0".to_string())
}
fn lookup_input_seat(sysname: &str) -> Option<String> {
let mut enumerator = udev::Enumerator::new().ok()?;
enumerator.match_subsystem("input").ok()?;
enumerator.match_sysname(sysname).ok()?;
let device = enumerator.scan_devices().ok()?.next()?;
read_seat_from_device(&device)
}
struct OpenedDevice {
device: Device,
path: PathBuf,
}
struct Reader {
poller: Arc<Poller>,
exit_fd: Arc<ExitFd>,
udev_handle_available: bool,
seat: String,
devices: HashMap<RawFd, OpenedDevice>,
held_keys: HashSet<u16>,
callback: EventTsfn,
stop: Arc<AtomicBool>,
}
impl Reader {
fn new(
callback: EventTsfn,
stop: Arc<AtomicBool>,
exit_fd: Arc<ExitFd>,
) -> std::io::Result<Self> {
let poller = Arc::new(Poller::new()?);
unsafe {
poller.add(&exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY))?;
}
let seat = resolve_seat();
let udev_handle_available = udev::Enumerator::new().is_ok();
Ok(Self {
poller,
exit_fd,
udev_handle_available,
seat,
devices: HashMap::new(),
held_keys: HashSet::new(),
callback,
stop,
})
}
fn try_attach_monitor(&self) -> Option<udev::MonitorSocket> {
let socket = udev::MonitorBuilder::new()
.and_then(|b| b.match_subsystem("input"))
.and_then(|b| b.listen())
.ok()?;
let monitor_fd = socket.as_raw_fd();
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor_fd) };
if unsafe { self.poller.add(&borrowed, PollEvent::readable(MONITOR_KEY)) }.is_ok() {
Some(socket)
} else {
None
}
}
fn device_is_on_our_seat(&self, sysname: &str) -> bool {
if !self.udev_handle_available || self.seat.is_empty() {
return true;
}
match lookup_input_seat(sysname) {
Some(found) => found == self.seat,
None => true,
}
}
fn open_all_devices(&mut self) {
let entries = match std::fs::read_dir("/dev/input") {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if !name_str.starts_with("event") {
continue;
}
if !self.device_is_on_our_seat(name_str) {
continue;
}
let path = entry.path();
let _ = self.open_device(&path);
}
}
fn open_device(&mut self, path: &Path) -> std::io::Result<()> {
if self
.devices
.values()
.any(|opened| opened.path.as_path() == path)
{
return Ok(());
}
let device = Device::open(path)?;
if !device_has_routable_input(&device) {
return Ok(());
}
let _ = device.set_nonblocking(true);
let fd = device.as_raw_fd();
let poll_key = poll_key_for_device_fd(fd).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"input device fd cannot be represented as a poll key",
)
})?;
unsafe {
let borrowed = BorrowedFd::borrow_raw(fd);
self.poller.add(&borrowed, PollEvent::readable(poll_key))?;
}
self.devices.insert(
fd,
OpenedDevice {
device,
path: path.to_path_buf(),
},
);
Ok(())
}
fn close_device_by_path(&mut self, path: &Path) {
let fd = self
.devices
.iter()
.find(|(_, opened)| opened.path.as_path() == path)
.map(|(fd, _)| *fd);
if let Some(fd) = fd {
self.close_device_by_fd(fd);
}
}
fn close_device_by_fd(&mut self, fd: RawFd) {
if let Some(opened) = self.devices.remove(&fd) {
let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
let _ = self.poller.delete(borrowed);
drop(opened);
}
}
fn run(&mut self) {
let monitor = self.try_attach_monitor();
let mut events = Events::new();
loop {
if self.stop.load(Ordering::Acquire) {
break;
}
events.clear();
if self.poller.wait(&mut events, None).is_err() {
break;
}
let mut device_fds_to_drain: Vec<RawFd> = Vec::new();
let mut drain_monitor = false;
let mut got_exit = false;
for event in events.iter() {
match event.key {
EXIT_KEY => got_exit = true,
MONITOR_KEY => drain_monitor = true,
fd_key => {
if let Some(fd) = device_fd_from_poll_key(fd_key) {
device_fds_to_drain.push(fd);
}
}
}
}
if got_exit {
self.exit_fd.drain();
break;
}
self.rearm(monitor.as_ref(), drain_monitor, &device_fds_to_drain);
if drain_monitor && let Some(monitor) = monitor.as_ref() {
self.drain_monitor(monitor);
}
for fd in device_fds_to_drain {
self.drain_device(fd);
}
}
if let Some(monitor) = monitor.as_ref() {
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor.as_raw_fd()) };
let _ = self.poller.delete(borrowed);
}
}
fn rearm(
&self,
monitor: Option<&udev::MonitorSocket>,
drain_monitor: bool,
device_fds: &[RawFd],
) {
let _ = self
.poller
.modify(self.exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY));
if drain_monitor && let Some(monitor) = monitor {
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor.as_raw_fd()) };
let _ = self
.poller
.modify(borrowed, PollEvent::readable(MONITOR_KEY));
}
for fd in device_fds {
if self.devices.contains_key(fd) {
let Some(poll_key) = poll_key_for_device_fd(*fd) else {
continue;
};
let borrowed = unsafe { BorrowedFd::borrow_raw(*fd) };
let _ = self.poller.modify(borrowed, PollEvent::readable(poll_key));
}
}
}
fn drain_monitor(&mut self, monitor: &udev::MonitorSocket) {
let mut pending: Vec<(String, PathBuf, Option<String>)> = Vec::new();
for event in monitor.iter() {
let action = match event.action() {
Some(a) => a.to_string_lossy().into_owned(),
None => continue,
};
let devnode = match event.devnode() {
Some(p) => p.to_path_buf(),
None => continue,
};
let subsystem = event.subsystem().map(|s| s.to_string_lossy().into_owned());
if subsystem.as_deref() != Some("input") {
continue;
}
let devnode_str = devnode.to_string_lossy().into_owned();
if !devnode_str.starts_with(DEV_INPUT_PREFIX) {
continue;
}
let seat = read_seat_from_device(&event);
pending.push((action, devnode, seat));
}
for (action, devnode, seat) in pending {
match action.as_str() {
"add" => {
if !self.seat.is_empty()
&& let Some(seat) = seat.as_deref()
&& seat != self.seat
{
continue;
}
let _ = self.open_device(&devnode);
}
"remove" => self.close_device_by_path(&devnode),
_ => {}
}
}
}
fn drain_device(&mut self, fd: RawFd) {
let mut decoded: Vec<(u16, i32)> = Vec::new();
let mut device_dead = false;
{
let Some(opened) = self.devices.get_mut(&fd) else {
return;
};
loop {
let fetch_result = opened.device.fetch_events();
match fetch_result {
Ok(events) => {
for ev in events {
if ev.event_type() != EventType::KEY {
continue;
}
decoded.push((ev.code(), ev.value()));
}
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => {
device_dead = true;
break;
}
}
}
}
if device_dead {
self.close_device_by_fd(fd);
return;
}
for (code, value) in decoded {
if value == 2 {
continue;
}
self.translate_event(code, value == 1);
}
}
fn translate_event(&mut self, code: u16, is_press: bool) {
if let Some(button) = keymap::evdev_button_to_browser_button(code) {
let event = NativeEvent::Mouse {
kind: if is_press {
MouseKind::Down
} else {
MouseKind::Up
},
button,
ctrl: self.modifier_state_ctrl(),
alt: self.modifier_state_alt(),
shift: self.modifier_state_shift(),
meta: self.modifier_state_meta(),
};
self.dispatch(event);
return;
}
let key_name = match keymap::keycode_to_name(code) {
Some(name) => name,
None => return,
};
if is_press {
self.held_keys.insert(code);
} else {
self.held_keys.remove(&code);
}
let event = NativeEvent::Key {
kind: if is_press { KeyKind::Down } else { KeyKind::Up },
keycode: code,
key_name,
ctrl: self.modifier_state_ctrl(),
alt: self.modifier_state_alt(),
shift: self.modifier_state_shift(),
meta: self.modifier_state_meta(),
};
self.dispatch(event);
}
fn dispatch(&self, event: NativeEvent) {
let status = self
.callback
.call(event, ThreadsafeFunctionCallMode::NonBlocking);
if status == Status::Closing {
self.stop.store(true, Ordering::Release);
}
}
fn modifier_state_ctrl(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_CTRL) || self.held_keys.contains(&keymap::RIGHT_CTRL)
}
fn modifier_state_alt(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_ALT) || self.held_keys.contains(&keymap::RIGHT_ALT)
}
fn modifier_state_shift(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_SHIFT)
|| self.held_keys.contains(&keymap::RIGHT_SHIFT)
}
fn modifier_state_meta(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_META) || self.held_keys.contains(&keymap::RIGHT_META)
}
}
fn is_routable_key_code(code: u16) -> bool {
keymap::keycode_to_name(code).is_some()
|| keymap::evdev_button_to_browser_button(code).is_some()
}
fn device_has_routable_input(device: &Device) -> bool {
device.supported_keys().is_some_and(|keys| {
keys.iter()
.any(|key: KeyCode| is_routable_key_code(key.code()))
})
}
impl Drop for Reader {
fn drop(&mut self) {
let fds: Vec<RawFd> = self.devices.keys().copied().collect();
for fd in fds {
let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
let _ = self.poller.delete(borrowed);
}
let _ = self.poller.delete(self.exit_fd.as_borrowed());
}
}
struct HookInner {
stop: Option<Arc<AtomicBool>>,
exit_fd: Option<Arc<ExitFd>>,
thread: Option<JoinHandle<()>>,
opened: bool,
}
impl HookInner {
fn stop_and_join(&mut self) {
if let Some(stop) = &self.stop {
stop.store(true, Ordering::Release);
}
if let Some(exit_fd) = &self.exit_fd {
exit_fd.signal();
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
self.stop = None;
self.exit_fd = None;
self.opened = false;
}
}
#[napi]
pub struct EvdevHook {
callback: EventTsfn,
inner: Mutex<HookInner>,
}
#[napi]
impl EvdevHook {
#[napi(constructor)]
pub fn new(on_event: Function<NativeEvent, UnknownReturnValue>) -> Result<Self> {
let callback = Arc::new(
on_event
.build_threadsafe_function::<NativeEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| {
generic_error(format!("failed to build callback: {}", err.reason))
})?,
);
Ok(Self {
callback,
inner: Mutex::new(HookInner {
stop: None,
exit_fd: None,
thread: None,
opened: false,
}),
})
}
#[napi]
pub fn start(&self) -> Result<bool> {
let mut inner = self
.inner
.lock()
.map_err(|_| generic_error("hook lock poisoned"))?;
if inner.thread.is_some() {
return Ok(inner.opened);
}
let stop = Arc::new(AtomicBool::new(false));
let exit_fd = Arc::new(
ExitFd::new()
.map_err(|err| generic_error(format!("failed to allocate eventfd: {err}")))?,
);
let mut reader = Reader::new(self.callback.clone(), stop.clone(), exit_fd.clone())
.map_err(|err| generic_error(format!("evdev start failed: {err}")))?;
reader.open_all_devices();
let opened = !reader.devices.is_empty();
let join = thread::Builder::new()
.name("fluxer-linux-evdev-reader".to_string())
.spawn(move || {
let mut reader = reader;
reader.run();
})
.map_err(|err| generic_error(format!("failed to spawn reader thread: {err}")))?;
inner.stop = Some(stop);
inner.exit_fd = Some(exit_fd);
inner.thread = Some(join);
inner.opened = opened;
Ok(opened)
}
#[napi]
pub fn stop(&self) -> Result<()> {
let mut inner = self
.inner
.lock()
.map_err(|_| generic_error("hook lock poisoned"))?;
inner.stop_and_join();
Ok(())
}
}
impl Drop for EvdevHook {
fn drop(&mut self) {
if let Ok(mut inner) = self.inner.lock() {
inner.stop_and_join();
}
}
}
#[napi(js_name = "nameToEvdevKeycode")]
pub fn name_to_evdev_keycode(name: String) -> u32 {
u32::from(keymap::name_to_keycode(&name))
}
fn generic_error(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::GenericFailure, reason.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_poll_keys_are_legal_and_outside_fd_range() {
assert_eq!(EXIT_KEY, 0);
assert_eq!(MONITOR_KEY, 1);
assert_ne!(EXIT_KEY, MONITOR_KEY);
assert_eq!(poll_key_for_device_fd(0), Some(DEVICE_KEY_BASE));
assert_eq!(device_fd_from_poll_key(DEVICE_KEY_BASE), Some(0));
assert_eq!(device_fd_from_poll_key(EXIT_KEY), None);
assert_eq!(device_fd_from_poll_key(MONITOR_KEY), None);
}
#[test]
fn poller_accepts_exit_control_key() {
let poller = Poller::new().expect("create poller");
let exit_fd = ExitFd::new().expect("create exit fd");
unsafe {
poller
.add(&exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY))
.expect("register exit fd");
}
poller
.delete(exit_fd.as_borrowed())
.expect("delete exit fd");
}
#[test]
fn routable_key_code_filter_keeps_keyboards_and_dom_mouse_buttons() {
assert!(is_routable_key_code(KeyCode::KEY_A.code()));
assert!(is_routable_key_code(KeyCode::KEY_LEFTCTRL.code()));
assert!(is_routable_key_code(KeyCode::BTN_LEFT.code()));
assert!(is_routable_key_code(KeyCode::BTN_FORWARD.code()));
}
#[test]
fn routable_key_code_filter_ignores_tablet_pad_and_tool_buttons() {
assert!(!is_routable_key_code(KeyCode::BTN_0.code()));
assert!(!is_routable_key_code(KeyCode::BTN_TOOL_PEN.code()));
assert!(!is_routable_key_code(KeyCode::BTN_STYLUS.code()));
}
}
+349
View File
@@ -0,0 +1,349 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctor"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fluxer_linux_input_hook"
version = "0.0.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"x11rb",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "napi"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
@@ -0,0 +1,22 @@
[package]
name = "fluxer_linux_input_hook"
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"
[target.'cfg(target_os = "linux")'.dependencies]
x11rb = {version = "0.13.2", default-features = false, features = ["record"]}
[build-dependencies]
napi-build = "2.3.2"
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type InputEvent =
| {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
| {
type: 'mousedown' | 'mouseup';
button: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
x?: number;
y?: number;
}
| {
type: 'mousemove';
x: number;
y: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
| {
type: 'wheel';
x?: number;
y?: number;
deltaX: number;
deltaY: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
};
export declare class InputHook {
constructor(callback: (event: InputEvent) => void);
start(): void;
stop(): void;
}
export declare function isAvailable(): boolean;
export declare const loadError: Error | null;
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-input-hook';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_INPUT_HOOK_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-input-hook is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-input-hook.linux-x64-gnu.node';
case 'arm64':
return 'linux-input-hook.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'linux') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
throw loadError;
}
}
module.exports = {
InputHook: binding ? binding.InputHook : null,
isAvailable: binding ? binding.isAvailable : () => false,
loadError,
};
@@ -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-input-hook",
"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-input-hook.linux-x64-gnu.node",
"linux-input-hook.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,118 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayServer {
X11,
Wayland,
WaylandWithXwayland,
Unknown,
}
impl DisplayServer {
pub fn supports_global_xrecord(self) -> bool {
matches!(self, Self::X11)
}
}
pub fn detect_display_server() -> DisplayServer {
detect_from(
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
std::env::var("DISPLAY").ok().as_deref(),
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
)
}
fn detect_from(
xdg_session_type: Option<&str>,
display: Option<&str>,
wayland_display: Option<&str>,
) -> DisplayServer {
let has_x11 = display.is_some_and(|v| !v.is_empty());
let has_wayland = wayland_display.is_some_and(|v| !v.is_empty());
let xdg_is_wayland = xdg_session_type == Some("wayland");
match (has_x11, has_wayland) {
(true, true) => DisplayServer::WaylandWithXwayland,
(true, false) if xdg_is_wayland => DisplayServer::WaylandWithXwayland,
(true, false) => DisplayServer::X11,
(false, true) => DisplayServer::Wayland,
(false, false) => match xdg_session_type {
Some("x11") => DisplayServer::X11,
Some("wayland") => DisplayServer::Wayland,
_ => DisplayServer::Unknown,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_pure_x11_from_display_only() {
assert_eq!(
detect_from(Some("x11"), Some(":0"), None),
DisplayServer::X11
);
assert_eq!(detect_from(None, Some(":0"), None), DisplayServer::X11);
}
#[test]
fn detect_pure_wayland_from_wayland_display_only() {
assert_eq!(
detect_from(Some("wayland"), None, Some("wayland-0")),
DisplayServer::Wayland
);
assert_eq!(
detect_from(None, None, Some("wayland-0")),
DisplayServer::Wayland
);
}
#[test]
fn detect_xwayland_when_both_sockets_present() {
let ds = detect_from(Some("wayland"), Some(":0"), Some("wayland-0"));
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(!ds.supports_global_xrecord());
}
#[test]
fn detect_xwayland_from_wayland_session_with_display_only() {
let ds = detect_from(Some("wayland"), Some(":0"), None);
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(!ds.supports_global_xrecord());
}
#[test]
fn detect_unknown_when_nothing_set() {
assert_eq!(detect_from(None, None, None), DisplayServer::Unknown);
assert_eq!(detect_from(Some("tty"), None, None), DisplayServer::Unknown);
}
#[test]
fn detect_empty_display_var_is_ignored() {
assert_eq!(
detect_from(Some("wayland"), Some(""), Some("wayland-0")),
DisplayServer::Wayland
);
}
#[test]
fn xdg_session_type_fallback_when_sockets_missing() {
assert_eq!(detect_from(Some("x11"), None, None), DisplayServer::X11);
assert_eq!(
detect_from(Some("wayland"), None, None),
DisplayServer::Wayland
);
}
#[test]
fn global_xrecord_support_is_only_for_pure_x11() {
assert!(DisplayServer::X11.supports_global_xrecord());
assert!(!DisplayServer::WaylandWithXwayland.supports_global_xrecord());
assert!(!DisplayServer::Wayland.supports_global_xrecord());
assert!(!DisplayServer::Unknown.supports_global_xrecord());
}
}
@@ -0,0 +1,653 @@
#![allow(non_snake_case)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use napi::{
Env, Status,
bindgen_prelude::{Function, Object, Result, ToNapiValue},
sys,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
};
use napi_derive::napi;
use x11rb::connection::{Connection, RequestConnection};
use x11rb::errors::ReplyError;
use x11rb::protocol::record::{
self, ConnectionExt as RecordConnectionExt, ExtRange, Range, Range8, Range16,
};
use x11rb::protocol::xproto::{
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode,
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
use x11rb::x11_utils::TryParse;
use crate::env::{DisplayServer, detect_display_server};
use crate::keymap;
use crate::modifiers::{self, Modifiers};
use crate::mouse::{self, MouseClassification};
use crate::x11;
const RECORD_FROM_SERVER: u8 = 0;
const RECORD_START_OF_DATA: u8 = 4;
const KEY_PRESS: u8 = 2;
const KEY_RELEASE: u8 = 3;
const BUTTON_PRESS: u8 = 4;
const BUTTON_RELEASE: u8 = 5;
const MOTION_NOTIFY: u8 = 6;
#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct XKeyButtonProto {
type_: u8,
detail: u8,
seq_l: u8,
seq_h: u8,
time: u32,
root: u32,
event: u32,
child: u32,
root_x: i16,
root_y: i16,
event_x: i16,
event_y: i16,
state: u16,
same_screen: u8,
pad0: u8,
}
#[derive(Debug, Clone)]
pub enum EventKind {
KeyDown,
KeyUp,
MouseDown,
MouseUp,
MouseMove,
Wheel,
}
#[derive(Debug, Clone)]
pub struct DecodedEvent {
pub kind: EventKind,
pub keycode: u32,
pub key_name: String,
pub button: u8,
pub delta_x: i32,
pub delta_y: i32,
pub x: i32,
pub y: i32,
pub has_xy: bool,
pub mods: Modifiers,
}
impl DecodedEvent {
fn new(kind: EventKind, mods: Modifiers) -> Self {
Self {
kind,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
mods,
}
}
}
impl ToNapiValue for DecodedEvent {
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
let kind = match event.kind {
EventKind::KeyDown => "keydown",
EventKind::KeyUp => "keyup",
EventKind::MouseDown => "mousedown",
EventKind::MouseUp => "mouseup",
EventKind::MouseMove => "mousemove",
EventKind::Wheel => "wheel",
};
object.set("type", kind)?;
object.set("ctrlKey", event.mods.ctrl)?;
object.set("altKey", event.mods.alt)?;
object.set("shiftKey", event.mods.shift)?;
object.set("metaKey", event.mods.meta)?;
match event.kind {
EventKind::KeyDown | EventKind::KeyUp => {
object.set("keycode", event.keycode)?;
object.set("keyName", event.key_name.as_str())?;
}
EventKind::MouseDown | EventKind::MouseUp => {
object.set("button", u32::from(event.button))?;
if event.has_xy {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
}
EventKind::MouseMove => {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
EventKind::Wheel => {
object.set("deltaX", event.delta_x)?;
object.set("deltaY", event.delta_y)?;
if event.has_xy {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
}
}
const EVENT_QUEUE_LIMIT: usize = 1024;
type EventTsfn = Arc<
ThreadsafeFunction<
DecodedEvent,
UnknownReturnValue,
DecodedEvent,
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
#[derive(Clone)]
struct KeysymCache {
min_keycode: Keycode,
syms: Vec<u32>,
}
impl KeysymCache {
fn build(reply: &GetKeyboardMappingReply, min_keycode: Keycode) -> Self {
let per = reply.keysyms_per_keycode as usize;
let count = if per == 0 {
0
} else {
reply.keysyms.len() / per
};
let mut syms = Vec::with_capacity(count);
if per > 0 {
for i in 0..count {
syms.push(reply.keysyms[i * per]);
}
}
Self { min_keycode, syms }
}
fn lookup(&self, keycode: u8) -> u32 {
if keycode < self.min_keycode {
return 0;
}
let idx = (keycode - self.min_keycode) as usize;
self.syms.get(idx).copied().unwrap_or(0)
}
}
struct Active {
ctrl_conn: Arc<RustConnection>,
record_ctx: record::Context,
worker: Option<JoinHandle<()>>,
stop: Arc<AtomicBool>,
}
struct Inner {
callback: EventTsfn,
active: Mutex<Option<Active>>,
}
#[napi]
pub struct InputHook {
inner: Arc<Inner>,
}
#[napi]
impl InputHook {
#[napi(constructor)]
pub fn new(callback: Function<DecodedEvent, UnknownReturnValue>) -> Result<Self> {
let tsfn = callback
.build_threadsafe_function::<DecodedEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| generic_error(format!("failed to create TSFN: {}", err.reason)))?;
Ok(Self {
inner: Arc::new(Inner {
callback: Arc::new(tsfn),
active: Mutex::new(None),
}),
})
}
#[napi]
pub fn start(&self) -> Result<()> {
let mut guard = self
.inner
.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))?;
if guard.is_some() {
return Ok(());
}
let active = start_record(self.inner.callback.clone())?;
*guard = Some(active);
Ok(())
}
#[napi]
pub fn stop(&self) -> Result<()> {
let active = {
let mut guard = self
.inner
.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))?;
guard.take()
};
if let Some(active) = active {
tear_down(active);
}
Ok(())
}
}
impl Drop for InputHook {
fn drop(&mut self) {
if let Ok(mut guard) = self.inner.active.lock()
&& let Some(active) = guard.take()
{
tear_down(active);
}
}
}
fn start_record(callback: EventTsfn) -> Result<Active> {
if !detect_display_server().supports_global_xrecord() {
return Err(generic_error(
"InputHook.start failed: WaylandUnsupported — global input \
capture is blocked by the Wayland security model. Use \
@fluxer/linux-evdev for kernel-level capture when the user has \
input device access."
.to_string(),
));
}
let (ctrl_conn, _) = x11rb::connect(None)
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
let (data_conn, _) = x11rb::connect(None)
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
let ctrl_conn = Arc::new(ctrl_conn);
let has_record = ctrl_conn
.extension_information(record::X11_EXTENSION_NAME)
.map_err(|err| generic_error(format!("InputHook.start failed: {err}")))?
.is_some();
if !has_record {
return Err(generic_error(
"InputHook.start failed: RecordExtensionUnavailable",
));
}
ctrl_conn
.record_query_version(
record::X11_XML_VERSION.0 as _,
record::X11_XML_VERSION.1 as _,
)
.map_err(|err| generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}")))?
.reply()
.map_err(|err| {
generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}"))
})?;
let setup = ctrl_conn.setup();
let min_keycode = setup.min_keycode;
let max_keycode = setup.max_keycode;
let count = max_keycode.saturating_sub(min_keycode).saturating_add(1);
let mapping = ctrl_conn
.get_keyboard_mapping(min_keycode, count)
.map_err(|err| generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}")))?
.reply()
.map_err(|err| {
generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}"))
})?;
let keysyms = KeysymCache::build(&mapping, min_keycode);
let record_ctx = ctrl_conn
.generate_id()
.map_err(|err| generic_error(format!("InputHook.start failed: GenerateId: {err}")))?;
let empty = Range8 { first: 0, last: 0 };
let empty_ext = ExtRange {
major: empty,
minor: Range16 { first: 0, last: 0 },
};
let range = Range {
core_requests: empty,
core_replies: empty,
ext_requests: empty_ext,
ext_replies: empty_ext,
delivered_events: empty,
device_events: Range8 {
first: KEY_PRESS,
last: MOTION_NOTIFY,
},
errors: empty,
client_started: false,
client_died: false,
};
ctrl_conn
.record_create_context(record_ctx, 0, &[record::CS::ALL_CLIENTS.into()], &[range])
.map_err(|err| {
generic_error(format!(
"InputHook.start failed: RecordCreateContext: {err}"
))
})?
.check()
.map_err(|err| {
generic_error(format!(
"InputHook.start failed: RecordCreateContext: {err}"
))
})?;
let stop = Arc::new(AtomicBool::new(false));
let worker_callback = callback.clone();
let worker_stop = stop.clone();
let worker = thread::Builder::new()
.name("fluxer-linux-input-hook".to_string())
.spawn(move || {
worker_main(data_conn, record_ctx, keysyms, worker_callback, worker_stop);
})
.map_err(|err| generic_error(format!("InputHook.start failed: thread spawn: {err}")))?;
Ok(Active {
ctrl_conn,
record_ctx,
worker: Some(worker),
stop,
})
}
fn worker_main(
data_conn: RustConnection,
record_ctx: record::Context,
keysyms: KeysymCache,
callback: EventTsfn,
stop: Arc<AtomicBool>,
) {
let cookie = match data_conn.record_enable_context(record_ctx) {
Ok(c) => c,
Err(_) => {
stop.store(true, Ordering::Release);
return;
}
};
for reply in cookie {
if stop.load(Ordering::Acquire) {
break;
}
let reply = match reply {
Ok(r) => r,
Err(ReplyError::ConnectionError(_)) => break,
Err(_) => continue,
};
if reply.client_swapped {
continue;
}
match reply.category {
RECORD_START_OF_DATA => continue,
RECORD_FROM_SERVER => {}
_ => continue,
}
let mut data: &[u8] = &reply.data;
while !data.is_empty() {
let consumed = decode_one(data, &keysyms, &callback, &stop);
if consumed == 0 || consumed > data.len() {
break;
}
data = &data[consumed..];
}
}
}
fn decode_one(
data: &[u8],
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) -> usize {
if data.is_empty() {
return 0;
}
let type_ = data[0];
match type_ {
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE | MOTION_NOTIFY => {
if data.len() < std::mem::size_of::<XKeyButtonProto>() {
return 0;
}
let evt: XKeyButtonProto =
unsafe { std::ptr::read_unaligned(data.as_ptr() as *const XKeyButtonProto) };
handle_event(&evt, keysyms, callback, stop);
32
}
0 => {
if data.len() < 8 {
return 0;
}
let (length, _) = match u32::try_parse(&data[4..]) {
Ok(v) => v,
Err(_) => return 0,
};
32 + (length as usize) * 4
}
_ => 32,
}
}
fn handle_event(
evt: &XKeyButtonProto,
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) {
let mods = modifiers::from_state(u32::from(evt.state));
match evt.type_ {
KEY_PRESS | KEY_RELEASE => {
let _lookup = x11::xkb_lookup_for_base();
let keysym = keysyms.lookup(evt.detail);
let mut event = DecodedEvent::new(
if evt.type_ == KEY_PRESS {
EventKind::KeyDown
} else {
EventKind::KeyUp
},
mods,
);
event.keycode = keysym;
event.key_name = match keymap::keysym_to_name(keysym) {
Some(name) => name.to_string(),
None => keymap::fallback_name(keysym),
};
dispatch(callback, stop, event);
}
BUTTON_PRESS | BUTTON_RELEASE => {
let cls = mouse::classify(u32::from(evt.detail));
match cls {
MouseClassification::Button(b) => {
let mut event = DecodedEvent::new(
if evt.type_ == BUTTON_PRESS {
EventKind::MouseDown
} else {
EventKind::MouseUp
},
mods,
);
event.button = b;
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
MouseClassification::Wheel(dir) => {
if evt.type_ == BUTTON_PRESS {
let mut event = DecodedEvent::new(EventKind::Wheel, mods);
event.delta_x = dir.delta_x();
event.delta_y = dir.delta_y();
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
}
MouseClassification::Ignored => {}
}
}
MOTION_NOTIFY => {
let mut event = DecodedEvent::new(EventKind::MouseMove, mods);
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
_ => {}
}
}
fn dispatch(callback: &EventTsfn, stop: &Arc<AtomicBool>, event: DecodedEvent) {
let status = callback.call(event, ThreadsafeFunctionCallMode::NonBlocking);
if status == Status::Closing {
stop.store(true, Ordering::Release);
}
}
fn tear_down(mut active: Active) {
active.stop.store(true, Ordering::Release);
if active.record_ctx != 0 {
let _ = active.ctrl_conn.record_disable_context(active.record_ctx);
let _ = active.ctrl_conn.sync();
}
if let Some(worker) = active.worker.take() {
let _ = worker.join();
}
if active.record_ctx != 0 {
let _ = active.ctrl_conn.record_free_context(active.record_ctx);
let _ = active.ctrl_conn.sync();
}
}
#[napi(js_name = "isAvailable")]
pub fn is_available() -> bool {
if !detect_display_server().supports_global_xrecord() {
return false;
}
x11rb::connect(None).is_ok()
}
#[allow(dead_code)]
pub(crate) fn detected_display_server() -> DisplayServer {
detect_display_server()
}
fn generic_error(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::GenericFailure, reason.into())
}
#[allow(dead_code)]
const _ASSERT_PROTO_LAYOUT: fn() = || {
use std::mem::offset_of;
let _ = offset_of!(XKeyButtonProto, seq_l);
let _ = offset_of!(XKeyButtonProto, seq_h);
let _ = offset_of!(XKeyButtonProto, event);
let _ = offset_of!(XKeyButtonProto, child);
let _ = offset_of!(XKeyButtonProto, event_x);
let _ = offset_of!(XKeyButtonProto, event_y);
let _ = offset_of!(XKeyButtonProto, same_screen);
let _ = offset_of!(XKeyButtonProto, pad0);
let _ = offset_of!(XKeyButtonProto, root);
let _ = offset_of!(XKeyButtonProto, time);
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decoded_keydown_carries_keysym_and_name() {
let mut event = DecodedEvent::new(EventKind::KeyDown, modifiers::from_state(0));
event.keycode = 0x0061;
event.key_name = keymap::keysym_to_name(0x0061).unwrap().to_string();
assert!(matches!(event.kind, EventKind::KeyDown));
assert_eq!(event.keycode, 0x0061);
assert_eq!(event.key_name, "A");
assert!(!event.has_xy);
}
#[test]
fn decoded_wheel_uses_120_step_deltas() {
let dir = mouse::WheelDirection::Down;
let mut event = DecodedEvent::new(EventKind::Wheel, modifiers::from_state(0));
event.delta_x = dir.delta_x();
event.delta_y = dir.delta_y();
event.has_xy = true;
assert_eq!(event.delta_x, 0);
assert_eq!(event.delta_y, 120);
}
#[test]
fn x_key_button_proto_layout_offsets_match_x11_wire_format() {
use std::mem::offset_of;
assert_eq!(offset_of!(XKeyButtonProto, type_), 0);
assert_eq!(offset_of!(XKeyButtonProto, detail), 1);
assert_eq!(offset_of!(XKeyButtonProto, time), 4);
assert_eq!(offset_of!(XKeyButtonProto, root_x), 20);
assert_eq!(offset_of!(XKeyButtonProto, root_y), 22);
assert_eq!(offset_of!(XKeyButtonProto, state), 28);
}
#[test]
fn event_kind_to_string_matches_js_contract() {
let cases: &[(EventKind, &str)] = &[
(EventKind::KeyDown, "keydown"),
(EventKind::KeyUp, "keyup"),
(EventKind::MouseDown, "mousedown"),
(EventKind::MouseUp, "mouseup"),
(EventKind::MouseMove, "mousemove"),
(EventKind::Wheel, "wheel"),
];
for (kind, expected) in cases {
let label = match kind {
EventKind::KeyDown => "keydown",
EventKind::KeyUp => "keyup",
EventKind::MouseDown => "mousedown",
EventKind::MouseUp => "mouseup",
EventKind::MouseMove => "mousemove",
EventKind::Wheel => "wheel",
};
assert_eq!(label, *expected);
}
}
#[test]
fn keysym_cache_returns_zero_below_min_keycode() {
let cache = KeysymCache {
min_keycode: 8,
syms: vec![0x61, 0x62, 0x63],
};
assert_eq!(cache.lookup(7), 0);
assert_eq!(cache.lookup(8), 0x61);
assert_eq!(cache.lookup(10), 0x63);
assert_eq!(cache.lookup(255), 0);
}
}
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const KEYSYM_TABLE: &[(u32, &str)] = &[
(0xff1b, "Escape"),
(0xffbe, "F1"),
(0xffbf, "F2"),
(0xffc0, "F3"),
(0xffc1, "F4"),
(0xffc2, "F5"),
(0xffc3, "F6"),
(0xffc4, "F7"),
(0xffc5, "F8"),
(0xffc6, "F9"),
(0xffc7, "F10"),
(0xffc8, "F11"),
(0xffc9, "F12"),
(0xffca, "F13"),
(0xffcb, "F14"),
(0xffcc, "F15"),
(0xffcd, "F16"),
(0xffce, "F17"),
(0xffcf, "F18"),
(0xffd0, "F19"),
(0xffd1, "F20"),
(0xffd2, "F21"),
(0xffd3, "F22"),
(0xffd4, "F23"),
(0xffd5, "F24"),
(0xff61, "PrintScreen"),
(0xff14, "ScrollLock"),
(0xff13, "Pause"),
(0xff7f, "NumLock"),
(0xff67, "ContextMenu"),
(0x0060, "Backquote"),
(0x007e, "Backquote"),
(0x0031, "1"),
(0x0032, "2"),
(0x0033, "3"),
(0x0034, "4"),
(0x0035, "5"),
(0x0036, "6"),
(0x0037, "7"),
(0x0038, "8"),
(0x0039, "9"),
(0x0030, "0"),
(0x002d, "Minus"),
(0x003d, "Equal"),
(0xff08, "Backspace"),
(0xff09, "Tab"),
(0x0071, "Q"),
(0x0077, "W"),
(0x0065, "E"),
(0x0072, "R"),
(0x0074, "T"),
(0x0079, "Y"),
(0x0075, "U"),
(0x0069, "I"),
(0x006f, "O"),
(0x0070, "P"),
(0x005b, "BracketLeft"),
(0x005d, "BracketRight"),
(0x005c, "Backslash"),
(0xffe5, "CapsLock"),
(0x0061, "A"),
(0x0073, "S"),
(0x0064, "D"),
(0x0066, "F"),
(0x0067, "G"),
(0x0068, "H"),
(0x006a, "J"),
(0x006b, "K"),
(0x006c, "L"),
(0x003b, "Semicolon"),
(0x0027, "Quote"),
(0xff0d, "Enter"),
(0xffe1, "ShiftLeft"),
(0x007a, "Z"),
(0x0078, "X"),
(0x0063, "C"),
(0x0076, "V"),
(0x0062, "B"),
(0x006e, "N"),
(0x006d, "M"),
(0x002c, "Comma"),
(0x002e, "Period"),
(0x002f, "Slash"),
(0xffe2, "ShiftRight"),
(0xffe3, "ControlLeft"),
(0xffeb, "MetaLeft"),
(0xffe9, "AltLeft"),
(0x0020, "Space"),
(0xffea, "AltRight"),
(0xffec, "MetaRight"),
(0xffe4, "ControlRight"),
(0xff80, "Space"),
(0xff89, "Tab"),
(0xff8d, "NumpadEnter"),
(0xffbd, "NumpadEqual"),
(0xffaa, "NumpadMultiply"),
(0xffab, "NumpadAdd"),
(0xffac, "NumpadComma"),
(0xffad, "NumpadSubtract"),
(0xffae, "NumpadDecimal"),
(0xffaf, "NumpadDivide"),
(0xffb0, "Numpad0"),
(0xffb1, "Numpad1"),
(0xffb2, "Numpad2"),
(0xffb3, "Numpad3"),
(0xffb4, "Numpad4"),
(0xffb5, "Numpad5"),
(0xffb6, "Numpad6"),
(0xffb7, "Numpad7"),
(0xffb8, "Numpad8"),
(0xffb9, "Numpad9"),
(0xff51, "ArrowLeft"),
(0xff52, "ArrowUp"),
(0xff53, "ArrowRight"),
(0xff54, "ArrowDown"),
(0xff63, "Insert"),
(0xffff, "Delete"),
(0xff50, "Home"),
(0xff57, "End"),
(0xff55, "PageUp"),
(0xff56, "PageDown"),
(0x1008ff12, "AudioVolumeMute"),
(0x1008ff11, "AudioVolumeDown"),
(0x1008ff13, "AudioVolumeUp"),
(0x1008ff17, "MediaTrackNext"),
(0x1008ff16, "MediaTrackPrevious"),
(0x1008ff15, "MediaStop"),
(0x1008ff14, "MediaPlayPause"),
(0x1008ff26, "BrowserBack"),
(0x1008ff27, "BrowserForward"),
(0x1008ff29, "BrowserRefresh"),
(0x1008ff28, "BrowserStop"),
(0x1008ff1b, "BrowserSearch"),
(0x1008ff30, "BrowserFavorites"),
(0x1008ff18, "BrowserHome"),
(0x1008ff19, "LaunchMail"),
(0x1008ff32, "LaunchMediaPlayer"),
(0x1008ff41, "LaunchApp1"),
(0x1008ff42, "LaunchApp2"),
(0x1008ff2a, "Power"),
(0x1008ff2f, "Sleep"),
(0x1008ff2b, "WakeUp"),
(0xff23, "Convert"),
(0xff22, "NonConvert"),
(0xff2d, "KanaMode"),
(0xff31, "Lang1"),
(0xff34, "Lang2"),
];
pub fn keysym_to_name(keysym: u32) -> Option<&'static str> {
KEYSYM_TABLE
.iter()
.find(|(sym, _)| *sym == keysym)
.map(|(_, name)| *name)
}
pub fn fallback_name(keysym: u32) -> String {
format!("Key{keysym}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_letters_map_to_single_letter_names() {
assert_eq!(keysym_to_name(0x0061), Some("A"));
assert_eq!(keysym_to_name(0x006d), Some("M"));
assert_eq!(keysym_to_name(0x007a), Some("Z"));
}
#[test]
fn modifier_keysyms_produce_side_distinguished_names() {
assert_eq!(keysym_to_name(0xffe1), Some("ShiftLeft"));
assert_eq!(keysym_to_name(0xffe2), Some("ShiftRight"));
assert_eq!(keysym_to_name(0xffe3), Some("ControlLeft"));
assert_eq!(keysym_to_name(0xffe9), Some("AltLeft"));
assert_eq!(keysym_to_name(0xffeb), Some("MetaLeft"));
}
#[test]
fn function_keys_f1_through_f12() {
assert_eq!(keysym_to_name(0xffbe), Some("F1"));
assert_eq!(keysym_to_name(0xffc9), Some("F12"));
assert_eq!(keysym_to_name(0xff13), Some("Pause"));
assert_eq!(keysym_to_name(0xffca), Some("F13"));
assert_eq!(keysym_to_name(0xffb0), Some("Numpad0"));
assert_eq!(keysym_to_name(0x1008ff12), Some("AudioVolumeMute"));
assert_eq!(keysym_to_name(0x1008ff41), Some("LaunchApp1"));
}
#[test]
fn arrows_and_editing_keys_round_trip() {
assert_eq!(keysym_to_name(0xff51), Some("ArrowLeft"));
assert_eq!(keysym_to_name(0xff56), Some("PageDown"));
assert_eq!(keysym_to_name(0xffff), Some("Delete"));
}
#[test]
fn unknown_keysym_falls_back_to_key_n() {
assert!(keysym_to_name(0x12345).is_none());
assert_eq!(fallback_name(0x12345), "Key74565");
}
#[test]
fn no_distinct_names_share_a_keysym() {
for (i, (sym_i, name_i)) in KEYSYM_TABLE.iter().enumerate() {
for (sym_j, name_j) in &KEYSYM_TABLE[i + 1..] {
if sym_i == sym_j {
assert_eq!(name_i, name_j, "duplicate keysym {sym_i:#x}");
}
}
}
}
}
@@ -0,0 +1,28 @@
#![deny(clippy::all)]
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod env;
pub mod keymap;
pub mod modifiers;
pub mod mouse;
pub mod x11;
#[cfg(target_os = "linux")]
mod hook;
#[cfg(target_os = "linux")]
pub use hook::{InputHook, is_available};
#[cfg(not(target_os = "linux"))]
mod stub {
use napi_derive::napi;
#[napi]
pub fn is_available() -> bool {
false
}
}
#[cfg(not(target_os = "linux"))]
pub use stub::is_available;
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const SHIFT_MASK: u32 = 1 << 0;
pub const CONTROL_MASK: u32 = 1 << 2;
pub const MOD1_MASK: u32 = 1 << 3;
pub const MOD4_MASK: u32 = 1 << 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Modifiers {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub meta: bool,
}
pub fn from_state(state: u32) -> Modifiers {
Modifiers {
ctrl: (state & CONTROL_MASK) != 0,
alt: (state & MOD1_MASK) != 0,
shift: (state & SHIFT_MASK) != 0,
meta: (state & MOD4_MASK) != 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_bits_all_false() {
let m = from_state(0);
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn shift_alone() {
let m = from_state(SHIFT_MASK);
assert!(m.shift);
assert!(!m.ctrl && !m.alt && !m.meta);
}
#[test]
fn all_four_modifiers_together() {
let m = from_state(SHIFT_MASK | CONTROL_MASK | MOD1_MASK | MOD4_MASK);
assert!(m.ctrl && m.alt && m.shift && m.meta);
}
#[test]
fn lock_and_numlock_ignored() {
let m = from_state((1 << 1) | (1 << 4));
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn mod1_is_alt_mod4_is_meta() {
let a = from_state(MOD1_MASK);
assert!(a.alt && !a.meta);
let b = from_state(MOD4_MASK);
assert!(b.meta && !b.alt);
}
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WheelDirection {
Up,
Down,
Left,
Right,
}
impl WheelDirection {
pub fn delta_x(self) -> i32 {
match self {
Self::Left => -120,
Self::Right => 120,
_ => 0,
}
}
pub fn delta_y(self) -> i32 {
match self {
Self::Up => -120,
Self::Down => 120,
_ => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseClassification {
Button(u8),
Wheel(WheelDirection),
Ignored,
}
pub fn classify(x11_button: u32) -> MouseClassification {
match x11_button {
1 => MouseClassification::Button(0),
2 => MouseClassification::Button(1),
3 => MouseClassification::Button(2),
4 => MouseClassification::Wheel(WheelDirection::Up),
5 => MouseClassification::Wheel(WheelDirection::Down),
6 => MouseClassification::Wheel(WheelDirection::Left),
7 => MouseClassification::Wheel(WheelDirection::Right),
8 => MouseClassification::Button(3),
9 => MouseClassification::Button(4),
_ => MouseClassification::Ignored,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primary_buttons_map_to_browser_indices() {
assert_eq!(classify(1), MouseClassification::Button(0));
assert_eq!(classify(2), MouseClassification::Button(1));
assert_eq!(classify(3), MouseClassification::Button(2));
}
#[test]
fn vertical_wheel_delta_y_120_step() {
assert_eq!(classify(4), MouseClassification::Wheel(WheelDirection::Up));
assert_eq!(WheelDirection::Up.delta_y(), -120);
assert_eq!(WheelDirection::Down.delta_y(), 120);
assert_eq!(WheelDirection::Up.delta_x(), 0);
}
#[test]
fn horizontal_wheel_delta_x_120_step() {
assert_eq!(WheelDirection::Left.delta_x(), -120);
assert_eq!(WheelDirection::Right.delta_x(), 120);
assert_eq!(WheelDirection::Left.delta_y(), 0);
}
#[test]
fn back_forward_buttons_map_to_3_and_4() {
assert_eq!(classify(8), MouseClassification::Button(3));
assert_eq!(classify(9), MouseClassification::Button(4));
}
#[test]
fn unknown_buttons_are_ignored() {
assert_eq!(classify(0), MouseClassification::Ignored);
assert_eq!(classify(15), MouseClassification::Ignored);
assert_eq!(classify(255), MouseClassification::Ignored);
}
}
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy)]
pub struct XkbLookup {
pub group: u32,
pub level: u32,
}
pub fn xkb_lookup_for_base() -> XkbLookup {
XkbLookup { group: 0, level: 0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xkb_lookup_for_base_pins_group_level_to_zero() {
let lookup = xkb_lookup_for_base();
assert_eq!(lookup.group, 0);
assert_eq!(lookup.level, 0);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
[package]
name = "fluxer_linux_notifications"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
async-io = "2.6.0"
futures-lite = "2.6.1"
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
napi-derive = "3.5.6"
zbus = "5.16.0"
[build-dependencies]
napi-build = "2.3.2"
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type Urgency = 'low' | 'normal' | 'critical';
export interface NotifyImageData {
width: number;
height: number;
rowstride: number;
hasAlpha: boolean;
bitsPerSample: number;
channels: number;
data: Buffer | Uint8Array;
}
export interface NotifyAction {
key: string;
label: string;
}
export interface NotifyPayload {
appName: string;
replacesId?: number;
appIcon?: string;
summary: string;
body: string;
actions?: ReadonlyArray<NotifyAction>;
expireTimeoutMs?: number;
hints?: {
urgency?: Urgency;
category?: string;
desktopEntry?: string;
soundFile?: string;
transient?: boolean;
actionIcons?: boolean;
imageData?: NotifyImageData;
};
}
export type FreedesktopNotificationEvent =
| {kind: 'actionInvoked'; id: number; actionKey: string}
| {kind: 'closed'; id: number; reason: number};
export interface ServerInformation {
name: string;
vendor: string;
version: string;
specVersion: string;
}
export declare class FreedesktopNotifications {
constructor(onEvent: (event: FreedesktopNotificationEvent) => void);
notify(payload: NotifyPayload): Promise<number>;
closeNotification(id: number): Promise<void>;
getServerCapabilities(): Promise<Array<string>>;
close(): Promise<void>;
}
export declare function getServerInformation(): Promise<ServerInformation>;
export declare const loadError: Error | null;
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-notifications';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_NOTIFICATIONS_SKIP_NATIVE_PROBE';
const REQUIRED_FREEDESKTOP_METHODS = ['notify', 'closeNotification', 'getServerCapabilities', 'close'];
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-notifications is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-notifications.linux-x64-gnu.node';
case 'arm64':
return 'linux-notifications.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
function ownPropertyNames(value) {
if ((typeof value !== 'object' && typeof value !== 'function') || value == null) return [];
return Object.getOwnPropertyNames(value).sort();
}
function bindingSurface(bindingValue) {
const freedesktopNotifications = bindingValue ? bindingValue.FreedesktopNotifications : null;
const prototype = freedesktopNotifications ? freedesktopNotifications.prototype : null;
return {
bindingKeys: ownPropertyNames(bindingValue),
freedesktopNotificationsType: typeof freedesktopNotifications,
freedesktopNotificationsName: freedesktopNotifications ? freedesktopNotifications.name : null,
freedesktopNotificationsPrototypeKeys: ownPropertyNames(prototype),
getServerInformationType: typeof (bindingValue ? bindingValue.getServerInformation : null),
};
}
function validateBindingSurface(bindingValue, nativeRoot, nativePath) {
const surface = bindingSurface(bindingValue);
const missing = [];
if (typeof bindingValue?.FreedesktopNotifications !== 'function') {
missing.push('FreedesktopNotifications constructor');
} else {
for (const method of REQUIRED_FREEDESKTOP_METHODS) {
if (typeof bindingValue.FreedesktopNotifications.prototype?.[method] !== 'function') {
missing.push(`FreedesktopNotifications.prototype.${method}`);
}
}
}
if (typeof bindingValue?.getServerInformation !== 'function') {
missing.push('getServerInformation');
}
if (missing.length === 0) return;
throw createNativeLoadError({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
reason: `native binding surface mismatch: missing ${missing.join(', ')}`,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
extraDiagnostics: [{name: 'bindingSurface', text: JSON.stringify(surface, null, 2)}],
});
}
let binding = null;
let loadError = null;
if (process.platform === 'linux') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
validateBindingSurface(binding, nativeRoot, nativePath);
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
throw loadError;
}
}
module.exports = {
FreedesktopNotifications: binding ? binding.FreedesktopNotifications : null,
getServerInformation: binding ? binding.getServerInformation : null,
loadError,
};
@@ -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,
};

Some files were not shown because too many files have changed in this diff Show More