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