stage 3: daemon core
Phase 3 — bring up the daemon end-to-end through six checkpoints:
3a Module skeleton (error, profile, routing, runtime, pw/*)
3b Pure routing engine + 13 tests (no PipeWire dep)
3c PwContext: main loop, sigprocmask-block SIGTERM/SIGINT before
add_signal_local so signalfd actually picks them up
3d headroom-processed virtual sink via the adapter factory with
factory.name=support.null-audio-sink
3e Filter: two pw_streams (capture from monitor / playback to real
sink) with an rtrb SPSC ring between them. DSP chain
(Compressor → two-tier Limiter) runs in the playback callback.
Allocation-free; #![forbid(unsafe_code)] preserved via
bytemuck::try_cast_slice for the byte↔f32 reinterpretation.
3f Registry watcher binds the default metadata, evaluates new
Stream/Output/Audio nodes against profile rules, writes
target.object for processed routes. Self-stream guard skips
anything whose node.name starts with 'headroom-filter'.
Workspace deps added: pipewire = { features = ["v0_3_44"] } for the
modern TARGET_OBJECT key, libspa, rtrb, nix (sigprocmask), bytemuck.
Tests: 65 passing (28 dsp, 20 ipc, 4 client, 13 core). Clippy clean
at default level under -D warnings.
PLAN.md §5 renumbered to fix stale subsection labels (was 4.1–4.4
from before the per-app insertion).
Known limitations punted to Phase 4 (documented in commit history
and team memory):
- WirePlumber doesn't always honor late target.object writes once
a stream is already linked (timing race).
- preferred_real_sink dynamic tracking stubbed.
- No auto-promote of headroom-processed to system default.
- application.process.binary occasionally arrives in late metadata
updates after the global registers; routing logs show '?' until
we add a re-read.
This commit is contained in:
parent
ca1910de60
commit
ae83310772
14 changed files with 2280 additions and 39 deletions
460
Cargo.lock
generated
460
Cargo.lock
generated
|
|
@ -11,6 +11,16 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotate-snippets"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
"yansi-term",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
|
|
@ -61,18 +71,91 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.69.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"bitflags",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[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 = "clap"
|
||||
version = "4.6.1"
|
||||
|
|
@ -119,6 +202,24 @@ version = "1.0.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie-factory"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
|
||||
dependencies = [
|
||||
"futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
|
|
@ -134,6 +235,12 @@ version = "0.8.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -150,6 +257,106 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[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 = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
|
|
@ -165,7 +372,7 @@ dependencies = [
|
|||
"headroom-core",
|
||||
"headroom-ipc",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
|
@ -177,21 +384,26 @@ dependencies = [
|
|||
"headroom-ipc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headroom-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"crossbeam-channel",
|
||||
"headroom-dsp",
|
||||
"headroom-ipc",
|
||||
"libspa",
|
||||
"nix",
|
||||
"parking_lot",
|
||||
"pipewire",
|
||||
"rtrb",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signal-hook",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
@ -207,7 +419,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -232,6 +444,15 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -244,12 +465,56 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[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 = "libspa"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cc",
|
||||
"convert_case",
|
||||
"cookie-factory",
|
||||
"libc",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nom",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libspa-sys"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf0d9716420364790e85cbb9d3ac2c950bde16a7dd36f3209b7dfdfc4a24d01f"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
|
|
@ -280,6 +545,33 @@ version = "2.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[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 = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -330,6 +622,40 @@ version = "0.2.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pipewire"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"once_cell",
|
||||
"pipewire-sys",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipewire-sys"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "849e188f90b1dda88fe2bfe1ad31fe5f158af2c98f80fb5d13726c44f3f01112"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"libspa-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
|
|
@ -357,6 +683,18 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
|
@ -374,6 +712,18 @@ 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 = "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"
|
||||
|
|
@ -441,6 +791,12 @@ dependencies = [
|
|||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
|
|
@ -461,6 +817,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
|
|
@ -484,13 +846,52 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -621,6 +1022,18 @@ 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 = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
|
|
@ -633,6 +1046,34 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[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-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"
|
||||
|
|
@ -657,6 +1098,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi-term"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5c30ade05e61656247b2e334a031dfd0cc466fadef865bdcdea8d537951bf1"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ clap = { version = "4.5", features = ["derive"] }
|
|||
crossbeam-channel = "0.5"
|
||||
parking_lot = "0.12"
|
||||
signal-hook = "0.3"
|
||||
nix = { version = "0.27", features = ["signal"] }
|
||||
|
||||
# Realtime audio
|
||||
rtrb = "0.3"
|
||||
|
|
@ -51,10 +52,13 @@ assert_no_alloc = "1.1"
|
|||
ebur128 = "0.1"
|
||||
fundsp = "0.20"
|
||||
|
||||
# PipeWire
|
||||
pipewire = "0.8"
|
||||
# PipeWire. `v0_3_44` exposes target.object key + related modern APIs.
|
||||
pipewire = { version = "0.8", features = ["v0_3_44"] }
|
||||
libspa = "0.8"
|
||||
|
||||
# Safe byte<->POD casts for audio buffers.
|
||||
bytemuck = "1.18"
|
||||
|
||||
# Profile hot-reload
|
||||
notify = "6.1"
|
||||
notify-debouncer-mini = "0.4"
|
||||
|
|
|
|||
15
PLAN.md
15
PLAN.md
|
|
@ -517,7 +517,7 @@ At realistic stream counts (2–5 managed apps): **<0.5% CPU total,
|
|||
|
||||
## 5. PipeWire integration
|
||||
|
||||
### 4.1 Sinks
|
||||
### 5.1 Sinks
|
||||
|
||||
Created on daemon startup by emitting a `pipewire.conf.d` fragment into
|
||||
`$XDG_CONFIG_HOME/pipewire/pipewire.conf.d/headroom.conf` (if not already
|
||||
|
|
@ -537,7 +537,7 @@ There is no second sink. Bypassed streams are routed directly at the
|
|||
current `preferred_real_sink` via `target.object` metadata writes
|
||||
(see §4.3).
|
||||
|
||||
### 4.2 The filter
|
||||
### 5.2 The filter
|
||||
|
||||
Two `pw_stream`s:
|
||||
|
||||
|
|
@ -551,8 +551,12 @@ Two `pw_stream`s:
|
|||
compressor → limiter → push to playback. Allocation-free. Parameter
|
||||
updates arrive over an `rtrb` SPSC queue from the control thread.
|
||||
|
||||
### 4.3 Routing
|
||||
### 5.3 Routing
|
||||
|
||||
- On startup, write `default.audio.sink` in the `default` metadata to
|
||||
point at `headroom-processed` so new streams default to the
|
||||
processor. The previous value (the user's hardware sink) is
|
||||
captured as the initial `preferred_real_sink`.
|
||||
- Subscribe to `pw_registry` global-added events.
|
||||
- On any new node with `media.class == "Stream/Output/Audio"` and
|
||||
`node.dont-move != true`:
|
||||
|
|
@ -560,7 +564,8 @@ updates arrive over an `rtrb` SPSC queue from the control thread.
|
|||
`pipewire.access.portal.app_id`, `media.role`.
|
||||
- Evaluate routing rules from the active profile to decide
|
||||
`processed` vs. `bypass`.
|
||||
- Write `target.object` into the `default` metadata:
|
||||
- Write `target.object` into the `default` metadata for the new
|
||||
stream:
|
||||
- `processed` → `headroom-processed`'s `object.serial`.
|
||||
- `bypass` → `preferred_real_sink`'s `object.serial`.
|
||||
WirePlumber honours this for any movable stream.
|
||||
|
|
@ -574,7 +579,7 @@ updates arrive over an `rtrb` SPSC queue from the control thread.
|
|||
(so subsequent app launches still land in the processor).
|
||||
- Hotplug (sink appears/disappears) goes through the same code path.
|
||||
|
||||
### 4.4 Stream identification
|
||||
### 5.4 Stream identification
|
||||
|
||||
| Property | Reliability | Use |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -22,14 +22,20 @@ tracing-subscriber = { workspace = true }
|
|||
crossbeam-channel = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
signal-hook = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
|
||||
# The PipeWire and audio-thread deps are declared but not yet wired up
|
||||
# in the v0 scaffolding. They are pulled in here so the workspace
|
||||
# resolves a consistent dep tree from the start.
|
||||
# pipewire = { workspace = true }
|
||||
# libspa = { workspace = true }
|
||||
# rtrb = { workspace = true }
|
||||
# PipeWire integration (Phase 3c onwards).
|
||||
pipewire = { workspace = true }
|
||||
libspa = { workspace = true }
|
||||
|
||||
# Audio-thread comms.
|
||||
rtrb = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
# basedrop is only needed once we have control-plane → audio-thread
|
||||
# shared ownership of dropping resources (Phase 4 parameter updates).
|
||||
# basedrop = { workspace = true }
|
||||
|
||||
# Slow AGC loop + profile hot-reload land in Phase 4.
|
||||
# ebur128 = { workspace = true }
|
||||
# notify = { workspace = true }
|
||||
# notify-debouncer-mini = { workspace = true }
|
||||
|
|
|
|||
55
crates/headroom-core/src/error.rs
Normal file
55
crates/headroom-core/src/error.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! Daemon error types.
|
||||
|
||||
/// All failure modes the daemon can surface.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum DaemonError {
|
||||
/// I/O failure (sockets, files, etc.).
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// JSON (de)serialization failure on the IPC plane.
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// TOML profile parse failure.
|
||||
#[error("toml: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
|
||||
/// PipeWire returned an error. The string is a human-readable
|
||||
/// description; for SPA error codes the source is included where
|
||||
/// available.
|
||||
#[error("pipewire: {0}")]
|
||||
PipeWire(String),
|
||||
|
||||
/// A required PipeWire object (sink, metadata, factory) was not
|
||||
/// found on the server.
|
||||
#[error("pipewire object not found: {0}")]
|
||||
PipeWireNotFound(String),
|
||||
|
||||
/// Profile-level configuration error (e.g. a setting out of range).
|
||||
#[error("profile: {0}")]
|
||||
Profile(String),
|
||||
|
||||
/// The daemon was asked to shut down.
|
||||
#[error("daemon shutting down")]
|
||||
Shutdown,
|
||||
|
||||
/// Catch-all with a custom message.
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl DaemonError {
|
||||
/// Construct a [`DaemonError::PipeWire`] from anything that
|
||||
/// implements `Display`.
|
||||
pub fn pipewire(msg: impl std::fmt::Display) -> Self {
|
||||
DaemonError::PipeWire(msg.to_string())
|
||||
}
|
||||
|
||||
/// Construct a [`DaemonError::Other`] from anything that
|
||||
/// implements `Display`.
|
||||
pub fn other(msg: impl std::fmt::Display) -> Self {
|
||||
DaemonError::Other(msg.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,33 @@
|
|||
//! Headroom daemon core.
|
||||
//!
|
||||
//! Phase 0/1 scaffolding: this crate currently exposes only the daemon
|
||||
//! entry-point shape that `headroom-cli` calls into. The real daemon
|
||||
//! (PipeWire integration, routing, slow AGC loop, IPC server) lands in
|
||||
//! Phase 3 and 4 per `PLAN.md`.
|
||||
//! See `PLAN.md` §5 for the PipeWire integration design and §11 for the
|
||||
//! phased implementation plan. This crate is the *daemon* — it owns
|
||||
//! the PipeWire main loop, the filter pipeline, the registry
|
||||
//! subscriber, the routing engine, the slow AGC loop, and (eventually)
|
||||
//! the IPC server.
|
||||
//!
|
||||
//! As of Phase 3, the routing engine and profile types are in place;
|
||||
//! the PipeWire integration modules are stubbed and land checkpoint by
|
||||
//! checkpoint.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
/// Run the daemon to completion. Currently a placeholder.
|
||||
pub mod error;
|
||||
pub mod profile;
|
||||
pub mod pw;
|
||||
pub mod routing;
|
||||
pub mod runtime;
|
||||
|
||||
pub use error::DaemonError;
|
||||
pub use profile::Profile;
|
||||
|
||||
/// Run the daemon to completion.
|
||||
///
|
||||
/// Blocks until the daemon shuts down (SIGTERM/SIGINT) or fails fatally.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `Err` if startup fails. The current scaffolding always
|
||||
/// returns `Ok` — it logs an "unimplemented" message and exits.
|
||||
/// Returns `Err` if startup or runtime processing fails.
|
||||
pub fn run() -> Result<(), DaemonError> {
|
||||
tracing::warn!("headroom-core::run is a placeholder; daemon not yet implemented");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Errors from running the daemon.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DaemonError {
|
||||
/// I/O error.
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Generic failure with a message.
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
runtime::run(Profile::default_v0())
|
||||
}
|
||||
|
|
|
|||
481
crates/headroom-core/src/profile.rs
Normal file
481
crates/headroom-core/src/profile.rs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
//! Profile types.
|
||||
//!
|
||||
//! Mirrors the TOML schema in `PLAN.md` §6. The actual TOML loader
|
||||
//! lands in Phase 4; for Phase 3 we ship a hardcoded
|
||||
//! [`Profile::default_v0`] so the rest of the daemon has something to
|
||||
//! drive itself with.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use headroom_dsp::{CompressorConfig, Detector, LimiterConfig, SoftTierConfig};
|
||||
|
||||
/// Profile-side mirror of [`Detector`] with serde support.
|
||||
///
|
||||
/// [`Detector`] itself lives in the dep-free `headroom-dsp` crate;
|
||||
/// this mirror keeps that crate's promise of zero external deps.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DetectorChoice {
|
||||
/// Peak detector — `max(|left|, |right|)`.
|
||||
#[default]
|
||||
Peak,
|
||||
/// RMS detector — windowed mean-square.
|
||||
Rms,
|
||||
}
|
||||
|
||||
impl From<DetectorChoice> for Detector {
|
||||
fn from(c: DetectorChoice) -> Self {
|
||||
match c {
|
||||
DetectorChoice::Peak => Detector::Peak,
|
||||
DetectorChoice::Rms => Detector::Rms,
|
||||
}
|
||||
}
|
||||
}
|
||||
use headroom_ipc::{Route, RouteRule, RouteRuleMatch};
|
||||
|
||||
/// A complete listening-scenario profile.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
/// Profile name. Must be unique within the profiles directory.
|
||||
pub name: String,
|
||||
/// One-line human-readable description.
|
||||
pub description: String,
|
||||
|
||||
/// AGC configuration.
|
||||
#[serde(default)]
|
||||
pub agc: AgcSection,
|
||||
/// Compressor configuration.
|
||||
#[serde(default)]
|
||||
pub compressor: CompressorSection,
|
||||
/// Limiter configuration.
|
||||
#[serde(default)]
|
||||
pub limiter: LimiterSection,
|
||||
/// Meter publishing configuration.
|
||||
#[serde(default)]
|
||||
pub meters: MetersSection,
|
||||
|
||||
/// Routing rules. Evaluated in order; first match wins.
|
||||
#[serde(default)]
|
||||
pub rules: Vec<RouteRule>,
|
||||
/// Fallback route applied when no rule matches.
|
||||
#[serde(default)]
|
||||
pub default_route: DefaultRouteSection,
|
||||
|
||||
/// Per-application level control (Layer A). Phase 6 work.
|
||||
#[serde(default)]
|
||||
pub per_app: PerAppSection,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Hardcoded v0 profile. Used while the TOML loader (Phase 4)
|
||||
/// isn't in place. Maps to the `default.toml` shipped profile.
|
||||
#[must_use]
|
||||
pub fn default_v0() -> Self {
|
||||
Self {
|
||||
name: "default".into(),
|
||||
description: "Gentle transparent processing for everyday use.".into(),
|
||||
agc: AgcSection::default(),
|
||||
compressor: CompressorSection::default(),
|
||||
limiter: LimiterSection::default(),
|
||||
meters: MetersSection::default(),
|
||||
rules: vec![
|
||||
RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec![
|
||||
"spotify".into(),
|
||||
"mpv".into(),
|
||||
"vlc".into(),
|
||||
"ardour".into(),
|
||||
"reaper".into(),
|
||||
"qpwgraph".into(),
|
||||
"carla".into(),
|
||||
"bitwig-studio".into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Bypass,
|
||||
},
|
||||
RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec![
|
||||
"firefox".into(),
|
||||
"chromium".into(),
|
||||
"google-chrome".into(),
|
||||
"Discord".into(),
|
||||
"discord".into(),
|
||||
"element-desktop".into(),
|
||||
"Slack".into(),
|
||||
"zoom".into(),
|
||||
"WEBRTC VoiceEngine".into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Processed,
|
||||
},
|
||||
],
|
||||
default_route: DefaultRouteSection {
|
||||
route: Route::Processed,
|
||||
},
|
||||
per_app: PerAppSection::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Materialize a [`LimiterConfig`] from this profile's `[limiter]` section.
|
||||
#[must_use]
|
||||
pub fn build_limiter_config(&self) -> LimiterConfig {
|
||||
let soft = self.limiter.soft.as_ref().map(|s| SoftTierConfig {
|
||||
max_psr_db: s.max_psr_db,
|
||||
static_ceiling_dbtp: s.static_ceiling_dbtp,
|
||||
attack_ms: s.attack_ms,
|
||||
release_ms: s.release_ms,
|
||||
});
|
||||
LimiterConfig {
|
||||
ceiling_dbtp: self.limiter.ceiling_dbtp,
|
||||
lookahead_ms: self.limiter.lookahead_ms,
|
||||
release_ms: self.limiter.release_ms,
|
||||
hold_ms: self.limiter.hold_ms,
|
||||
oversample: self.limiter.oversample,
|
||||
fir_taps: 31,
|
||||
soft,
|
||||
}
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
/// Materialize a [`CompressorConfig`] from this profile's
|
||||
/// `[compressor]` section.
|
||||
#[must_use]
|
||||
pub fn build_compressor_config(&self) -> CompressorConfig {
|
||||
let makeup_db = match self.compressor.makeup_db {
|
||||
MakeupGain::Auto => None,
|
||||
MakeupGain::Db(v) => Some(v),
|
||||
};
|
||||
CompressorConfig {
|
||||
threshold_db: self.compressor.threshold_db,
|
||||
ratio: self.compressor.ratio,
|
||||
knee_db: self.compressor.knee_db,
|
||||
attack_ms: self.compressor.attack_ms,
|
||||
release_ms: self.compressor.release_ms,
|
||||
makeup_db,
|
||||
detector: self.compressor.detector.into(),
|
||||
rms_window_ms: self.compressor.rms_window_ms,
|
||||
}
|
||||
.sanitized()
|
||||
}
|
||||
}
|
||||
|
||||
/// `[agc]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AgcSection {
|
||||
/// Master switch.
|
||||
pub enabled: bool,
|
||||
/// Target integrated loudness (LUFS).
|
||||
pub target_lufs: f32,
|
||||
/// Attack time toward the target (ms).
|
||||
pub attack_ms: f32,
|
||||
/// Release time (ms).
|
||||
pub release_ms: f32,
|
||||
/// Below this momentary loudness the AGC stops adjusting.
|
||||
pub silence_threshold_lufs: f32,
|
||||
/// Maximum boost the AGC may apply (dB).
|
||||
pub max_boost_db: f32,
|
||||
/// Maximum cut the AGC may apply (dB).
|
||||
pub max_cut_db: f32,
|
||||
}
|
||||
|
||||
impl Default for AgcSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
target_lufs: -18.0,
|
||||
attack_ms: 2000.0,
|
||||
release_ms: 800.0,
|
||||
silence_threshold_lufs: -70.0,
|
||||
max_boost_db: 12.0,
|
||||
max_cut_db: 12.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[compressor]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CompressorSection {
|
||||
/// Master switch.
|
||||
pub enabled: bool,
|
||||
/// Detector mode.
|
||||
pub detector: DetectorChoice,
|
||||
/// Threshold (dBFS).
|
||||
pub threshold_db: f32,
|
||||
/// Ratio (>= 1.0).
|
||||
pub ratio: f32,
|
||||
/// Knee width (dB).
|
||||
pub knee_db: f32,
|
||||
/// Attack time (ms).
|
||||
pub attack_ms: f32,
|
||||
/// Release time (ms).
|
||||
pub release_ms: f32,
|
||||
/// Makeup gain.
|
||||
pub makeup_db: MakeupGain,
|
||||
/// RMS window length (ms). Ignored when `detector == Peak`.
|
||||
pub rms_window_ms: f32,
|
||||
}
|
||||
|
||||
impl Default for CompressorSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
detector: DetectorChoice::Peak,
|
||||
threshold_db: -24.0,
|
||||
ratio: 2.5,
|
||||
knee_db: 6.0,
|
||||
attack_ms: 10.0,
|
||||
release_ms: 100.0,
|
||||
makeup_db: MakeupGain::Auto,
|
||||
rms_window_ms: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `makeup_db` field: either an explicit number of dB or `"auto"`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
#[serde(untagged)]
|
||||
pub enum MakeupGain {
|
||||
/// Numeric dB value.
|
||||
Db(f32),
|
||||
/// `"auto"` — compute conservative auto-makeup from threshold and ratio.
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// `[limiter]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LimiterSection {
|
||||
/// Hard-tier output ceiling (dBTP).
|
||||
pub ceiling_dbtp: f32,
|
||||
/// Lookahead (ms).
|
||||
pub lookahead_ms: f32,
|
||||
/// Hard-tier release (ms).
|
||||
pub release_ms: f32,
|
||||
/// Hard-tier hold (ms).
|
||||
pub hold_ms: f32,
|
||||
/// Oversampling factor (1/2/4/8).
|
||||
pub oversample: usize,
|
||||
/// Stereo-link mode.
|
||||
pub link: LinkMode,
|
||||
/// Soft tier. Omit for pure brickwall.
|
||||
pub soft: Option<LimiterSoftSection>,
|
||||
}
|
||||
|
||||
impl Default for LimiterSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ceiling_dbtp: -0.1,
|
||||
lookahead_ms: 2.0,
|
||||
release_ms: 80.0,
|
||||
hold_ms: 5.0,
|
||||
oversample: 4,
|
||||
link: LinkMode::Stereo,
|
||||
soft: Some(LimiterSoftSection::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[limiter.soft]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LimiterSoftSection {
|
||||
/// Maximum peak-to-loudness ratio (dB).
|
||||
pub max_psr_db: f32,
|
||||
/// Static fallback ceiling (dBTP).
|
||||
pub static_ceiling_dbtp: f32,
|
||||
/// Soft-tier attack (ms).
|
||||
pub attack_ms: f32,
|
||||
/// Soft-tier release (ms).
|
||||
pub release_ms: f32,
|
||||
}
|
||||
|
||||
impl Default for LimiterSoftSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_psr_db: 14.0,
|
||||
static_ceiling_dbtp: -6.0,
|
||||
attack_ms: 5.0,
|
||||
release_ms: 200.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stereo-link mode.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LinkMode {
|
||||
/// One envelope shared across channels (no image shift).
|
||||
#[default]
|
||||
Stereo,
|
||||
/// Independent envelopes per channel.
|
||||
DualMono,
|
||||
}
|
||||
|
||||
/// `[meters]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MetersSection {
|
||||
/// Maximum meter publish rate (Hz). Server may publish slower.
|
||||
pub publish_hz: f32,
|
||||
}
|
||||
|
||||
impl Default for MetersSection {
|
||||
fn default() -> Self {
|
||||
Self { publish_hz: 20.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// `[default_route]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DefaultRouteSection {
|
||||
/// Route applied to streams that match no `[[rules]]` entry.
|
||||
pub route: Route,
|
||||
}
|
||||
|
||||
impl Default for DefaultRouteSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
route: Route::Processed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[per_app]` section. Phase 6 work; the v0 daemon doesn't act on
|
||||
/// this yet but profiles parse it forward.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PerAppSection {
|
||||
/// Master switch for Layer A.
|
||||
pub enabled: bool,
|
||||
/// Default per-app state for streams not matched by any rule.
|
||||
pub default_enabled: bool,
|
||||
/// Per-app rules. Same `match` schema as routing rules.
|
||||
pub rules: Vec<PerAppRule>,
|
||||
}
|
||||
|
||||
/// One `[[per_app.rules]]` entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PerAppRule {
|
||||
/// Same matcher shape as the routing-rule match.
|
||||
#[serde(rename = "match", default)]
|
||||
pub match_: RouteRuleMatch,
|
||||
/// Whether per-app level control applies to matched streams.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Peak threshold (dBFS) above which the peak path cuts gain.
|
||||
#[serde(default = "default_peak_threshold_db")]
|
||||
pub peak_threshold_db: f32,
|
||||
/// Long-term RMS target (dBFS).
|
||||
#[serde(default = "default_rms_target_db")]
|
||||
pub rms_target_db: f32,
|
||||
/// Maximum gain cut (dB).
|
||||
#[serde(default = "default_max_cut_db")]
|
||||
pub max_cut_db: f32,
|
||||
/// Peak envelope attack time (ms).
|
||||
#[serde(default = "default_peak_attack_ms")]
|
||||
pub peak_attack_ms: f32,
|
||||
/// Peak envelope release time (ms).
|
||||
#[serde(default = "default_peak_release_ms")]
|
||||
pub peak_release_ms: f32,
|
||||
/// RMS window length (ms).
|
||||
#[serde(default = "default_rms_window_ms")]
|
||||
pub rms_window_ms: f32,
|
||||
/// Policy when the user adjusts the stream's volume externally.
|
||||
#[serde(default)]
|
||||
pub defer_to_user: DeferPolicy,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
const fn default_peak_threshold_db() -> f32 {
|
||||
-6.0
|
||||
}
|
||||
const fn default_rms_target_db() -> f32 {
|
||||
-20.0
|
||||
}
|
||||
const fn default_max_cut_db() -> f32 {
|
||||
12.0
|
||||
}
|
||||
const fn default_peak_attack_ms() -> f32 {
|
||||
5.0
|
||||
}
|
||||
const fn default_peak_release_ms() -> f32 {
|
||||
500.0
|
||||
}
|
||||
const fn default_rms_window_ms() -> f32 {
|
||||
1500.0
|
||||
}
|
||||
|
||||
/// Policy for handling user-initiated volume changes on a stream
|
||||
/// Headroom is managing.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeferPolicy {
|
||||
/// Treat the user value as a ceiling: keep cutting on spikes,
|
||||
/// never raise above what the user wanted. Principle of least
|
||||
/// surprise.
|
||||
#[default]
|
||||
Ceiling,
|
||||
/// Stop adjusting entirely until the user opts back in.
|
||||
Strict,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_v0_builds_sane_dsp_configs() {
|
||||
let p = Profile::default_v0();
|
||||
let lim = p.build_limiter_config();
|
||||
assert!((lim.ceiling_dbtp - (-0.1)).abs() < 1e-6);
|
||||
assert_eq!(lim.oversample, 4);
|
||||
assert!(lim.soft.is_some());
|
||||
|
||||
let comp = p.build_compressor_config();
|
||||
assert!((comp.threshold_db - (-24.0)).abs() < 1e-6);
|
||||
assert!((comp.ratio - 2.5).abs() < 1e-6);
|
||||
// Auto-makeup translates to `None`.
|
||||
assert!(comp.makeup_db.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_v0_has_expected_routing_rules() {
|
||||
let p = Profile::default_v0();
|
||||
assert_eq!(p.default_route.route, Route::Processed);
|
||||
// First rule should be the bypass list.
|
||||
assert_eq!(p.rules[0].route, Route::Bypass);
|
||||
assert!(p.rules[0].match_.process_binary.iter().any(|s| s == "mpv"));
|
||||
// Second the processed list.
|
||||
assert_eq!(p.rules[1].route, Route::Processed);
|
||||
assert!(p.rules[1]
|
||||
.match_
|
||||
.process_binary
|
||||
.iter()
|
||||
.any(|s| s == "firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn makeup_gain_serialises_as_string_or_number() {
|
||||
let auto = serde_json::to_string(&MakeupGain::Auto).unwrap();
|
||||
// Untagged enum: Auto serialises as its discriminant variant —
|
||||
// serde_json renders unit variant Auto as `"Auto"`. We don't
|
||||
// promise wire-format here; this is a profile concern. Just
|
||||
// verify round-trip works.
|
||||
let back: MakeupGain = serde_json::from_str(&auto).unwrap();
|
||||
assert!(matches!(back, MakeupGain::Auto));
|
||||
|
||||
let db = serde_json::to_string(&MakeupGain::Db(3.0)).unwrap();
|
||||
let back: MakeupGain = serde_json::from_str(&db).unwrap();
|
||||
assert!(matches!(back, MakeupGain::Db(v) if (v - 3.0).abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
339
crates/headroom-core/src/pw/filter.rs
Normal file
339
crates/headroom-core/src/pw/filter.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
//! The audio filter: two `pw_stream`s sandwiching the DSP chain.
|
||||
//!
|
||||
//! Phase 3 checkpoint 3e.
|
||||
//!
|
||||
//! Architecture:
|
||||
//!
|
||||
//! ```text
|
||||
//! headroom-processed.monitor
|
||||
//! │
|
||||
//! ▼ ┌────────────┐ ┌────────────┐
|
||||
//! capture pw_stream ──────►│ rtrb │───────►│ playback │──► real sink
|
||||
//! (Direction::Input, │ (SPSC ring,│ │ pw_stream │
|
||||
//! F32LE stereo) │ interleav-│ │ │
|
||||
//! │ ed f32) │ │ DSP runs │
|
||||
//! │ │ │ here: │
|
||||
//! │ │ │ Compressor │
|
||||
//! │ │ │ → Limiter │
|
||||
//! └────────────┘ └────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! Both pw_stream callbacks run on PipeWire's realtime data thread
|
||||
//! (the same thread, scheduled in sequence within each quantum). The
|
||||
//! `rtrb` SPSC ring is wait-free and contention-free in that
|
||||
//! arrangement — it's the right structure even though the producer
|
||||
//! and consumer happen to share a thread today.
|
||||
//!
|
||||
//! Allocation-free in both callbacks. The DSP kernels are constructed
|
||||
//! once at startup and moved into the playback state. Byte-to-f32
|
||||
//! reinterpretation goes through `bytemuck::try_cast_slice` so the
|
||||
//! crate remains `#![forbid(unsafe_code)]`.
|
||||
|
||||
use pipewire::{
|
||||
core::Core,
|
||||
keys,
|
||||
properties::properties,
|
||||
spa::{
|
||||
param::{
|
||||
audio::{AudioFormat, AudioInfoRaw},
|
||||
ParamType,
|
||||
},
|
||||
pod::{serialize::PodSerializer, Object, Pod, Value},
|
||||
utils::{Direction, SpaTypes},
|
||||
},
|
||||
stream::{Stream, StreamFlags, StreamListener},
|
||||
};
|
||||
use rtrb::{Consumer, Producer, RingBuffer};
|
||||
|
||||
use headroom_dsp::{Compressor, CompressorConfig, Limiter, LimiterConfig};
|
||||
|
||||
use crate::error::DaemonError;
|
||||
use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
|
||||
|
||||
/// Sample rate the filter operates at. The DSP kernels are
|
||||
/// constructed for this rate; if PipeWire negotiates a different
|
||||
/// rate the filter logs a warning and the DSP may sound slightly off
|
||||
/// in time-based parameters until Phase 4 wires rate updates.
|
||||
const FILTER_SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// Number of channels the filter operates on (stereo only in v0).
|
||||
const CHANNELS: u32 = 2;
|
||||
|
||||
/// Capacity of the capture→playback ring, in `f32` samples. Sized to
|
||||
/// hold ~4 quanta at the default 1024-frame quantum (4 × 1024 × 2 ch
|
||||
/// = 8192 samples), with some slack.
|
||||
const RING_CAPACITY: usize = 16_384;
|
||||
|
||||
/// State owned by the capture stream's process callback.
|
||||
struct CaptureState {
|
||||
producer: Producer<f32>,
|
||||
/// Counter of samples dropped because the ring was full.
|
||||
/// Surfaced via tracing at low rate; Phase 4 publishes via IPC.
|
||||
samples_dropped: u64,
|
||||
}
|
||||
|
||||
/// State owned by the playback stream's process callback.
|
||||
struct PlaybackState {
|
||||
consumer: Consumer<f32>,
|
||||
compressor: Compressor,
|
||||
limiter: Limiter,
|
||||
/// Counter of samples zero-filled because the ring was empty.
|
||||
samples_starved: u64,
|
||||
}
|
||||
|
||||
/// The filter pipeline.
|
||||
///
|
||||
/// Owns the capture and playback streams plus their listeners. Drop
|
||||
/// the [`Filter`] to tear down the audio path.
|
||||
pub struct Filter {
|
||||
_capture: Stream,
|
||||
_capture_listener: StreamListener<CaptureState>,
|
||||
_playback: Stream,
|
||||
_playback_listener: StreamListener<PlaybackState>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Create the capture+playback streams and connect them. The
|
||||
/// capture stream targets `headroom-processed.monitor`; the
|
||||
/// playback stream autoconnects to the system default real sink
|
||||
/// for now (3f will make this dynamic).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DaemonError::PipeWire`] if stream creation or connection
|
||||
/// fails.
|
||||
pub fn create(core: &Core) -> Result<Self, DaemonError> {
|
||||
let (producer, consumer) = RingBuffer::<f32>::new(RING_CAPACITY);
|
||||
|
||||
let compressor = Compressor::new(CompressorConfig::default(), FILTER_SAMPLE_RATE as f32);
|
||||
let limiter = Limiter::new(LimiterConfig::default(), FILTER_SAMPLE_RATE as f32);
|
||||
|
||||
let capture = build_capture_stream(core)?;
|
||||
let capture_listener = capture
|
||||
.add_local_listener_with_user_data(CaptureState {
|
||||
producer,
|
||||
samples_dropped: 0,
|
||||
})
|
||||
.process(capture_process)
|
||||
.register()
|
||||
.map_err(|e| DaemonError::pipewire(format!("capture register: {e}")))?;
|
||||
|
||||
let playback = build_playback_stream(core)?;
|
||||
let playback_listener = playback
|
||||
.add_local_listener_with_user_data(PlaybackState {
|
||||
consumer,
|
||||
compressor,
|
||||
limiter,
|
||||
samples_starved: 0,
|
||||
})
|
||||
.process(playback_process)
|
||||
.register()
|
||||
.map_err(|e| DaemonError::pipewire(format!("playback register: {e}")))?;
|
||||
|
||||
// One format POD, two connects. Both streams want the same
|
||||
// interpretation (F32LE stereo at FILTER_SAMPLE_RATE) and the
|
||||
// POD bytes live on this stack for the duration of both calls.
|
||||
let format_bytes = build_format_pod_bytes()?;
|
||||
let format_pod =
|
||||
Pod::from_bytes(&format_bytes).ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?;
|
||||
|
||||
let mut capture_params: [&Pod; 1] = [format_pod];
|
||||
capture
|
||||
.connect(
|
||||
Direction::Input,
|
||||
None,
|
||||
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS,
|
||||
&mut capture_params,
|
||||
)
|
||||
.map_err(|e| DaemonError::pipewire(format!("capture connect: {e}")))?;
|
||||
|
||||
let mut playback_params: [&Pod; 1] = [format_pod];
|
||||
playback
|
||||
.connect(
|
||||
Direction::Output,
|
||||
None,
|
||||
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS,
|
||||
&mut playback_params,
|
||||
)
|
||||
.map_err(|e| DaemonError::pipewire(format!("playback connect: {e}")))?;
|
||||
|
||||
tracing::info!(
|
||||
sample_rate = FILTER_SAMPLE_RATE,
|
||||
channels = CHANNELS,
|
||||
ring_capacity = RING_CAPACITY,
|
||||
"filter streams created and connected"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
_capture: capture,
|
||||
_capture_listener: capture_listener,
|
||||
_playback: playback,
|
||||
_playback_listener: playback_listener,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the capture stream. Targets `headroom-processed`'s monitor.
|
||||
fn build_capture_stream(core: &Core) -> Result<Stream, DaemonError> {
|
||||
let props = properties! {
|
||||
*keys::MEDIA_TYPE => "Audio",
|
||||
*keys::MEDIA_CATEGORY => "Capture",
|
||||
*keys::MEDIA_ROLE => "DSP",
|
||||
// Capture from a sink's monitor, not from a microphone.
|
||||
*keys::STREAM_CAPTURE_SINK => "true",
|
||||
// Target our virtual sink by name. PipeWire ≥ 0.3.44 accepts
|
||||
// node-name strings here (gated behind the v0_3_44 feature).
|
||||
*keys::TARGET_OBJECT => PROCESSED_SINK_NAME,
|
||||
*keys::NODE_NAME => "headroom-filter.capture",
|
||||
*keys::NODE_DESCRIPTION => "Headroom filter capture",
|
||||
// We own the linking decision for our own streams — the
|
||||
// routing engine must not move them and WirePlumber must not
|
||||
// re-target them on default-sink changes.
|
||||
*keys::NODE_DONT_RECONNECT => "true",
|
||||
"node.dont-move" => "true",
|
||||
};
|
||||
Stream::new(core, "headroom-filter-capture", props)
|
||||
.map_err(|e| DaemonError::pipewire(format!("capture Stream::new: {e}")))
|
||||
}
|
||||
|
||||
/// Build the playback stream. Autoconnects to the system default
|
||||
/// sink. Phase 3f rewires this to target the tracked
|
||||
/// `preferred_real_sink`.
|
||||
fn build_playback_stream(core: &Core) -> Result<Stream, DaemonError> {
|
||||
let props = properties! {
|
||||
*keys::MEDIA_TYPE => "Audio",
|
||||
*keys::MEDIA_CATEGORY => "Playback",
|
||||
*keys::MEDIA_ROLE => "DSP",
|
||||
*keys::NODE_NAME => "headroom-filter.playback",
|
||||
*keys::NODE_DESCRIPTION => "Headroom filter playback",
|
||||
// Same as capture: own the linking, refuse rerouting.
|
||||
*keys::NODE_DONT_RECONNECT => "true",
|
||||
"node.dont-move" => "true",
|
||||
};
|
||||
Stream::new(core, "headroom-filter-playback", props)
|
||||
.map_err(|e| DaemonError::pipewire(format!("playback Stream::new: {e}")))
|
||||
}
|
||||
|
||||
/// Serialize our preferred audio format (F32LE stereo at
|
||||
/// [`FILTER_SAMPLE_RATE`]) into a SPA POD byte buffer.
|
||||
fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(FILTER_SAMPLE_RATE);
|
||||
info.set_channels(CHANNELS);
|
||||
|
||||
let obj = Object {
|
||||
type_: SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: ParamType::EnumFormat.as_raw(),
|
||||
properties: info.into(),
|
||||
};
|
||||
let bytes = PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &Value::Object(obj))
|
||||
.map_err(|e| DaemonError::pipewire(format!("format pod serialize: {e}")))?
|
||||
.0
|
||||
.into_inner();
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Capture process callback. Realtime-thread, allocation-free.
|
||||
fn capture_process(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return; // Out of buffers; pipewire is queueing for us.
|
||||
};
|
||||
|
||||
let datas = buffer.datas_mut();
|
||||
let Some(data) = datas.first_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let n_bytes = data.chunk().size() as usize;
|
||||
if n_bytes == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(byte_slice) = data.data() else {
|
||||
return;
|
||||
};
|
||||
// PipeWire delivers F32LE interleaved. `try_cast_slice` verifies
|
||||
// alignment and length-divisibility; if the buffer is misaligned
|
||||
// (shouldn't happen for negotiated F32) we skip the block.
|
||||
let samples: &[f32] = match bytemuck::try_cast_slice::<u8, f32>(&byte_slice[..n_bytes]) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
tracing::warn!("capture buffer not f32-aligned; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut written = 0;
|
||||
for &s in samples {
|
||||
match state.producer.push(s) {
|
||||
Ok(()) => written += 1,
|
||||
Err(_) => break, // ring full
|
||||
}
|
||||
}
|
||||
if written < samples.len() {
|
||||
state.samples_dropped = state
|
||||
.samples_dropped
|
||||
.saturating_add((samples.len() - written) as u64);
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback process callback. Realtime-thread, allocation-free.
|
||||
fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let datas = buffer.datas_mut();
|
||||
let Some(data) = datas.first_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let stride_bytes = std::mem::size_of::<f32>() * CHANNELS as usize;
|
||||
let Some(out_bytes) = data.data() else {
|
||||
return;
|
||||
};
|
||||
let max_bytes = out_bytes.len();
|
||||
let max_frames = max_bytes / stride_bytes;
|
||||
if max_frames == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let out_samples: &mut [f32] =
|
||||
match bytemuck::try_cast_slice_mut::<u8, f32>(&mut out_bytes[..max_frames * stride_bytes]) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
tracing::warn!("playback buffer not f32-aligned; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut produced_frames = 0;
|
||||
for frame_idx in 0..max_frames {
|
||||
let (left_in, right_in) = match (state.consumer.pop(), state.consumer.pop()) {
|
||||
(Ok(l), Ok(r)) => (l, r),
|
||||
_ => break, // ring empty
|
||||
};
|
||||
// Compressor first, then the two-tier limiter (safety contract).
|
||||
let (lc, rc) = state.compressor.process_frame(left_in, right_in);
|
||||
let (lo, ro) = state.limiter.process_frame(lc, rc);
|
||||
out_samples[frame_idx * 2] = lo;
|
||||
out_samples[frame_idx * 2 + 1] = ro;
|
||||
produced_frames += 1;
|
||||
}
|
||||
|
||||
if produced_frames < max_frames {
|
||||
let starved_frames = max_frames - produced_frames;
|
||||
for slot in &mut out_samples[produced_frames * 2..max_frames * 2] {
|
||||
*slot = 0.0;
|
||||
}
|
||||
state.samples_starved = state
|
||||
.samples_starved
|
||||
.saturating_add((starved_frames * CHANNELS as usize) as u64);
|
||||
}
|
||||
|
||||
// Tell PipeWire how much we wrote.
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.size_mut() = (max_frames * stride_bytes) as u32;
|
||||
*chunk.stride_mut() = stride_bytes as i32;
|
||||
*chunk.offset_mut() = 0;
|
||||
}
|
||||
78
crates/headroom-core/src/pw/metadata.rs
Normal file
78
crates/headroom-core/src/pw/metadata.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! Metadata helpers.
|
||||
//!
|
||||
//! PipeWire exposes a `default` metadata object that carries
|
||||
//! `default.audio.sink` (the system default sink) and per-stream
|
||||
//! `target.object` overrides. We read both and write the latter to
|
||||
//! implement routing.
|
||||
//!
|
||||
//! Phase 3 checkpoints 3c-3f (varies per call site).
|
||||
|
||||
use crate::error::DaemonError;
|
||||
|
||||
/// Tracks the user's `preferred_real_sink` by watching
|
||||
/// `default.audio.sink` on the `default` metadata key. When the user
|
||||
/// switches the default to a hardware sink, the daemon adopts it.
|
||||
pub struct PreferredRealSinkTracker {
|
||||
/// Most recently observed real sink, by node id.
|
||||
current: Option<u32>,
|
||||
}
|
||||
|
||||
impl PreferredRealSinkTracker {
|
||||
/// Construct an empty tracker.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self { current: None }
|
||||
}
|
||||
|
||||
/// Currently-observed real sink, if any.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> Option<u32> {
|
||||
self.current
|
||||
}
|
||||
|
||||
/// Set the current real sink. Returns `true` if the value
|
||||
/// changed.
|
||||
pub fn set(&mut self, node_id: Option<u32>) -> bool {
|
||||
let changed = self.current != node_id;
|
||||
self.current = node_id;
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PreferredRealSinkTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `target.object = <serial>` for the named stream into the
|
||||
/// `default` metadata key. WirePlumber observes this and moves the
|
||||
/// stream accordingly.
|
||||
///
|
||||
/// # Errors
|
||||
/// Stub in checkpoint 3a; implemented in 3f.
|
||||
pub fn write_stream_target(_stream_node_id: u32, _target_serial: u32) -> Result<(), DaemonError> {
|
||||
Err(DaemonError::other(
|
||||
"metadata::write_stream_target not implemented (phase 3f)",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tracker_reports_changes() {
|
||||
let mut t = PreferredRealSinkTracker::new();
|
||||
assert!(t.current().is_none());
|
||||
assert!(t.set(Some(42)));
|
||||
assert_eq!(t.current(), Some(42));
|
||||
// Same value — no change.
|
||||
assert!(!t.set(Some(42)));
|
||||
// Different value — change.
|
||||
assert!(t.set(Some(43)));
|
||||
// Cleared.
|
||||
assert!(t.set(None));
|
||||
assert!(t.current().is_none());
|
||||
}
|
||||
}
|
||||
215
crates/headroom-core/src/pw/mod.rs
Normal file
215
crates/headroom-core/src/pw/mod.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
//! PipeWire integration layer.
|
||||
//!
|
||||
//! Organised by responsibility:
|
||||
//!
|
||||
//! - [`sink`] — create and own the `headroom-processed` virtual sink.
|
||||
//! - [`filter`] — the two `pw_stream`s (capture monitor + playback)
|
||||
//! plus the audio-thread process callback that runs the DSP chain.
|
||||
//! - [`registry`] — subscribe to `pw_registry` events; emit
|
||||
//! `StreamEvent`s for the routing engine to act on.
|
||||
//! - [`metadata`] — read `default.audio.sink`, write `target.object`
|
||||
//! on the `default` metadata key.
|
||||
//!
|
||||
//! [`PwContext`] is the top-level owner of the PipeWire main loop,
|
||||
//! `Context`, and `Core`. The daemon constructs one of these on
|
||||
//! startup and runs it until shutdown.
|
||||
|
||||
pub mod filter;
|
||||
pub mod metadata;
|
||||
pub mod registry;
|
||||
pub mod sink;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use pipewire::{context::Context, core::Core, loop_::Signal, main_loop::MainLoop};
|
||||
|
||||
use crate::error::DaemonError;
|
||||
use crate::profile::Profile;
|
||||
use crate::pw::registry::RegistryWatcher;
|
||||
use crate::pw::sink::VirtualSink;
|
||||
|
||||
/// Block `SIGTERM` and `SIGINT` in the calling thread (and, by
|
||||
/// inheritance, in all threads spawned after this call). This is the
|
||||
/// prerequisite for `signalfd`-based signal sources — including
|
||||
/// PipeWire's [`pipewire::loop_::LoopRef::add_signal_local`] — to
|
||||
/// receive these signals instead of being preempted by the kernel's
|
||||
/// default disposition.
|
||||
fn block_termination_signals() -> Result<(), DaemonError> {
|
||||
use nix::sys::signal::{SigSet, SigmaskHow};
|
||||
|
||||
let mut set = SigSet::empty();
|
||||
set.add(Signal::SIGTERM);
|
||||
set.add(Signal::SIGINT);
|
||||
nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&set), None)
|
||||
.map_err(|e| DaemonError::pipewire(format!("sigprocmask: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Owns the PipeWire main loop, context, and core. Lives for the
|
||||
/// daemon's entire run.
|
||||
///
|
||||
/// The main loop is single-threaded by design. Signal handlers are
|
||||
/// registered on it so SIGTERM / SIGINT delivered to the process are
|
||||
/// observed by the loop via PipeWire's internal `signalfd` plumbing,
|
||||
/// regardless of which thread originally received the signal.
|
||||
pub struct PwContext {
|
||||
main_loop: MainLoop,
|
||||
_context: Context,
|
||||
core: Core,
|
||||
/// Owns the `headroom-processed` virtual sink for the daemon's
|
||||
/// lifetime. Wrapped in `RefCell` because creation happens after
|
||||
/// construction (we need to be inside the main loop to do a
|
||||
/// proper roundtrip).
|
||||
sink: RefCell<VirtualSink>,
|
||||
/// Registry watcher + routing engine. Set up via
|
||||
/// [`Self::start_routing`]; `None` until then.
|
||||
routing: RefCell<Option<RegistryWatcher>>,
|
||||
}
|
||||
|
||||
impl PwContext {
|
||||
/// Initialise PipeWire, create the main loop, context, and
|
||||
/// connect to the running PipeWire daemon.
|
||||
///
|
||||
/// Also blocks SIGTERM and SIGINT in the calling thread so that
|
||||
/// PipeWire's `signalfd`-backed signal source — installed later
|
||||
/// in [`Self::run_until_signal`] — can observe them. Without this
|
||||
/// blocking step the kernel applies the default disposition
|
||||
/// (terminate the process) before the signalfd has a chance to
|
||||
/// read. Threads spawned later by PipeWire inherit the blocked
|
||||
/// mask.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DaemonError::PipeWire`] if any of the four steps
|
||||
/// fail. The most common cause is `Context::connect` failing
|
||||
/// because no PipeWire server is reachable on `$PIPEWIRE_RUNTIME_DIR`.
|
||||
pub fn new() -> Result<Self, DaemonError> {
|
||||
block_termination_signals()?;
|
||||
pipewire::init();
|
||||
let main_loop = MainLoop::new(None)
|
||||
.map_err(|e| DaemonError::pipewire(format!("MainLoop::new: {e}")))?;
|
||||
let context = Context::new(&main_loop)
|
||||
.map_err(|e| DaemonError::pipewire(format!("Context::new: {e}")))?;
|
||||
let core = context
|
||||
.connect(None)
|
||||
.map_err(|e| DaemonError::pipewire(format!("Context::connect: {e}")))?;
|
||||
tracing::info!("connected to pipewire");
|
||||
Ok(Self {
|
||||
main_loop,
|
||||
_context: context,
|
||||
core,
|
||||
sink: RefCell::new(VirtualSink::new()),
|
||||
routing: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start watching the PipeWire registry and routing new playback
|
||||
/// streams according to `profile`. Idempotent; calling twice
|
||||
/// replaces the previous watcher.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DaemonError::PipeWire`] if obtaining the registry fails.
|
||||
pub fn start_routing(&self, profile: Profile) -> Result<(), DaemonError> {
|
||||
let registry = self
|
||||
.core
|
||||
.get_registry()
|
||||
.map_err(|e| DaemonError::pipewire(format!("get_registry: {e}")))?;
|
||||
let watcher = RegistryWatcher::new(Rc::new(registry), profile);
|
||||
*self.routing.borrow_mut() = Some(watcher);
|
||||
tracing::info!("registry watcher + routing engine installed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Access the main loop for adding sources, timers, etc.
|
||||
#[must_use]
|
||||
pub fn main_loop(&self) -> &MainLoop {
|
||||
&self.main_loop
|
||||
}
|
||||
|
||||
/// Access the PipeWire core proxy.
|
||||
#[must_use]
|
||||
pub fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
/// Create `headroom-processed` and do a roundtrip to confirm it
|
||||
/// landed on the server.
|
||||
///
|
||||
/// Must be called before [`Self::run_until_signal`]; uses its own
|
||||
/// nested main-loop pass to synchronise. Returns the node id once
|
||||
/// the server has confirmed creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DaemonError::PipeWire`] if `create_object` fails, the
|
||||
/// `support.null-audio-sink` factory isn't available, or the
|
||||
/// roundtrip times out.
|
||||
pub fn create_processed_sink(&self) -> Result<(), DaemonError> {
|
||||
self.sink.borrow_mut().create(&self.core)?;
|
||||
self.roundtrip()?;
|
||||
tracing::info!("headroom-processed virtual sink created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Block until all currently-queued requests have been
|
||||
/// acknowledged by the server. Used to synchronise startup steps
|
||||
/// (create-sink, ensure-default-set, etc.).
|
||||
fn roundtrip(&self) -> Result<(), DaemonError> {
|
||||
let done = Rc::new(Cell::new(false));
|
||||
let done_cb = done.clone();
|
||||
let loop_for_cb = self.main_loop.clone();
|
||||
|
||||
let pending = self
|
||||
.core
|
||||
.sync(0)
|
||||
.map_err(|e| DaemonError::pipewire(format!("core.sync: {e}")))?;
|
||||
|
||||
let _listener = self
|
||||
.core
|
||||
.add_listener_local()
|
||||
.done(move |id, seq| {
|
||||
if id == pipewire::core::PW_ID_CORE && seq == pending {
|
||||
done_cb.set(true);
|
||||
loop_for_cb.quit();
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
while !done.get() {
|
||||
self.main_loop.run();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the main loop until SIGTERM or SIGINT is delivered to the
|
||||
/// process. Returns `Ok(())` on clean shutdown.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DaemonError::PipeWire`] if installing the signal
|
||||
/// sources fails.
|
||||
pub fn run_until_signal(&self) -> Result<(), DaemonError> {
|
||||
// SIGTERM: graceful service stop (systemd).
|
||||
let ml = self.main_loop.clone();
|
||||
let _sig_term = self
|
||||
.main_loop
|
||||
.loop_()
|
||||
.add_signal_local(Signal::SIGTERM, move || {
|
||||
tracing::info!("SIGTERM received, shutting down");
|
||||
ml.quit();
|
||||
});
|
||||
|
||||
// SIGINT: Ctrl-C in foreground.
|
||||
let ml = self.main_loop.clone();
|
||||
let _sig_int = self
|
||||
.main_loop
|
||||
.loop_()
|
||||
.add_signal_local(Signal::SIGINT, move || {
|
||||
tracing::info!("SIGINT received, shutting down");
|
||||
ml.quit();
|
||||
});
|
||||
|
||||
tracing::info!("entering pipewire main loop");
|
||||
self.main_loop.run();
|
||||
tracing::info!("main loop exited");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
223
crates/headroom-core/src/pw/registry.rs
Normal file
223
crates/headroom-core/src/pw/registry.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! PipeWire registry subscription + routing decisions.
|
||||
//!
|
||||
//! Phase 3 checkpoint 3f.
|
||||
//!
|
||||
//! Watches the PipeWire registry for new globals:
|
||||
//!
|
||||
//! - **Metadata objects** with `metadata.name = "default"` get bound
|
||||
//! so the daemon can write `target.object` for streams it routes.
|
||||
//! - **Node objects** with `media.class = "Stream/Output/Audio"` are
|
||||
//! evaluated against the active profile's routing rules. For
|
||||
//! processed routes the daemon writes `target.object` pointing the
|
||||
//! stream at `headroom-processed`. Bypassed streams are left alone
|
||||
//! for v0 — they default to the user's real sink. Phase 4 will
|
||||
//! make the bypass target explicit so it survives default-sink
|
||||
//! changes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use pipewire::{
|
||||
metadata::Metadata,
|
||||
registry::{GlobalObject, Listener, Registry},
|
||||
spa::utils::dict::DictRef,
|
||||
types::ObjectType,
|
||||
};
|
||||
|
||||
use headroom_ipc::Route;
|
||||
|
||||
use crate::profile::Profile;
|
||||
use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
|
||||
use crate::routing::{self, PwNodeInfo, RoutingDecision};
|
||||
|
||||
/// Shared mutable routing state. Lives behind `Rc<RefCell<...>>` so
|
||||
/// the registry-event callback can mutate it from the main loop
|
||||
/// thread.
|
||||
pub struct RoutingState {
|
||||
profile: Profile,
|
||||
/// Bound proxy for the `default` metadata object. `None` until
|
||||
/// the registry surfaces it (typically immediately on connect).
|
||||
default_metadata: Option<Metadata>,
|
||||
/// Clone of the registry — needed inside the global callback so
|
||||
/// it can bind metadata proxies. `Rc` because we share with the
|
||||
/// listener closure.
|
||||
registry: Rc<Registry>,
|
||||
}
|
||||
|
||||
impl RoutingState {
|
||||
/// Construct an empty state. Bind the default metadata after the
|
||||
/// registry's first event burst.
|
||||
pub fn new(profile: Profile, registry: Rc<Registry>) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
default_metadata: None,
|
||||
registry,
|
||||
}
|
||||
}
|
||||
|
||||
/// Active profile.
|
||||
#[must_use]
|
||||
pub fn profile(&self) -> &Profile {
|
||||
&self.profile
|
||||
}
|
||||
|
||||
/// True iff the default metadata has been bound.
|
||||
#[must_use]
|
||||
pub fn has_default_metadata(&self) -> bool {
|
||||
self.default_metadata.is_some()
|
||||
}
|
||||
|
||||
fn on_global(&mut self, global: &GlobalObject<&DictRef>) {
|
||||
match &global.type_ {
|
||||
ObjectType::Metadata => self.try_bind_default_metadata(global),
|
||||
ObjectType::Node => self.try_route_stream(global),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_bind_default_metadata(&mut self, global: &GlobalObject<&DictRef>) {
|
||||
if self.default_metadata.is_some() {
|
||||
return; // already bound
|
||||
}
|
||||
let Some(props) = &global.props else { return };
|
||||
let dict: &DictRef = props;
|
||||
if dict.get("metadata.name") != Some("default") {
|
||||
return;
|
||||
}
|
||||
match self.registry.bind::<Metadata, _>(global) {
|
||||
Ok(m) => {
|
||||
tracing::info!(global_id = global.id, "bound default metadata");
|
||||
self.default_metadata = Some(m);
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "failed to bind default metadata"),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_route_stream(&self, global: &GlobalObject<&DictRef>) {
|
||||
let Some(props) = &global.props else { return };
|
||||
let dict: &DictRef = props;
|
||||
if dict.get("media.class") != Some("Stream/Output/Audio") {
|
||||
return;
|
||||
}
|
||||
// Don't route the daemon's own filter streams back into the
|
||||
// processed sink — that'd be a feedback loop. `node.dont-move`
|
||||
// is set on the streams too, but it doesn't always propagate
|
||||
// into the registry view; matching the name prefix is the
|
||||
// belt-and-braces guard.
|
||||
if dict
|
||||
.get("node.name")
|
||||
.is_some_and(|n| n.starts_with("headroom-filter"))
|
||||
{
|
||||
tracing::trace!(node_id = global.id, "skipping headroom-internal stream");
|
||||
return;
|
||||
}
|
||||
|
||||
let info = build_node_info(global.id, dict);
|
||||
let decision = routing::evaluate(&info, &self.profile);
|
||||
|
||||
match decision {
|
||||
RoutingDecision::Route(Route::Processed) => self.write_processed_target(&info),
|
||||
RoutingDecision::Route(Route::Bypass) => {
|
||||
tracing::debug!(
|
||||
node_id = info.node_id,
|
||||
app = info.application_process_binary.as_deref().unwrap_or("?"),
|
||||
"bypass route — leaving stream at default"
|
||||
);
|
||||
}
|
||||
RoutingDecision::Skip => {
|
||||
tracing::trace!(node_id = info.node_id, "skip (not routable)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_processed_target(&self, info: &PwNodeInfo) {
|
||||
let Some(md) = &self.default_metadata else {
|
||||
tracing::warn!(
|
||||
node_id = info.node_id,
|
||||
"no default metadata bound; cannot apply target.object"
|
||||
);
|
||||
return;
|
||||
};
|
||||
// PipeWire accepts a node-name string for target.object since
|
||||
// 0.3.44. WirePlumber observes the metadata change and moves
|
||||
// the stream.
|
||||
md.set_property(
|
||||
info.node_id,
|
||||
"target.object",
|
||||
Some("Spa:String:JSON"),
|
||||
Some(&format!("{{\"name\":\"{PROCESSED_SINK_NAME}\"}}")),
|
||||
);
|
||||
tracing::info!(
|
||||
node_id = info.node_id,
|
||||
app = info.application_process_binary.as_deref().unwrap_or("?"),
|
||||
target = PROCESSED_SINK_NAME,
|
||||
"routed to processed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the PipeWire properties from a registry global and assemble
|
||||
/// the projection the routing engine needs.
|
||||
fn build_node_info(node_id: u32, dict: &DictRef) -> PwNodeInfo {
|
||||
PwNodeInfo {
|
||||
node_id,
|
||||
media_class: dict.get("media.class").map(str::to_owned),
|
||||
application_process_binary: dict.get("application.process.binary").map(str::to_owned),
|
||||
application_name: dict.get("application.name").map(str::to_owned),
|
||||
portal_app_id: dict
|
||||
.get("pipewire.access.portal.app_id")
|
||||
.map(str::to_owned),
|
||||
media_role: dict.get("media.role").map(str::to_owned),
|
||||
dont_move: dict.get("node.dont-move") == Some("true"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the registry global-add listener and return its handle.
|
||||
///
|
||||
/// The handle must outlive the `RoutingState` — drop it before
|
||||
/// dropping the registry. `RegistryWatcher` enforces this drop order
|
||||
/// by owning both.
|
||||
pub fn install_listener(
|
||||
registry: &Registry,
|
||||
state: Rc<RefCell<RoutingState>>,
|
||||
) -> Listener {
|
||||
let state_for_global = state;
|
||||
registry
|
||||
.add_listener_local()
|
||||
.global(move |global| {
|
||||
state_for_global.borrow_mut().on_global(global);
|
||||
})
|
||||
.register()
|
||||
}
|
||||
|
||||
/// Owns the registry, the routing state, and the listener.
|
||||
///
|
||||
/// Drop order is significant: the listener must drop before the
|
||||
/// registry. Rust's natural struct-field drop order is declaration
|
||||
/// order, so we declare them in the safe sequence.
|
||||
pub struct RegistryWatcher {
|
||||
_listener: Listener,
|
||||
/// `Rc<RefCell<RoutingState>>` so the closure can hold a clone.
|
||||
/// Exposed as a getter for testability.
|
||||
state: Rc<RefCell<RoutingState>>,
|
||||
_registry: Rc<Registry>,
|
||||
}
|
||||
|
||||
impl RegistryWatcher {
|
||||
/// Construct from a registry and the active profile.
|
||||
pub fn new(registry: Rc<Registry>, profile: Profile) -> Self {
|
||||
let state = Rc::new(RefCell::new(RoutingState::new(profile, registry.clone())));
|
||||
let listener = install_listener(®istry, state.clone());
|
||||
Self {
|
||||
_listener: listener,
|
||||
state,
|
||||
_registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference to the routing state. Mainly for tests and metrics.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &Rc<RefCell<RoutingState>> {
|
||||
&self.state
|
||||
}
|
||||
}
|
||||
93
crates/headroom-core/src/pw/sink.rs
Normal file
93
crates/headroom-core/src/pw/sink.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! `headroom-processed` virtual sink creation.
|
||||
//!
|
||||
//! Phase 3 checkpoint 3d. Creates a stereo virtual sink via the
|
||||
//! `support.null-audio-sink` factory. The sink's monitor is what
|
||||
//! [`crate::pw::filter`] later captures from (Phase 3e); bypassed
|
||||
//! streams skip this sink entirely and route directly to the user's
|
||||
//! hardware sink (see PLAN §2).
|
||||
//!
|
||||
//! The proxy returned by `core.create_object` keeps the sink alive on
|
||||
//! the server for as long as it's held — we store it in [`VirtualSink`]
|
||||
//! for the daemon's lifetime. Dropping the proxy destroys the sink.
|
||||
|
||||
use pipewire::{core::Core, node::Node, properties::properties};
|
||||
|
||||
use crate::error::DaemonError;
|
||||
|
||||
/// Node name used for the virtual sink. Stable; user-visible in
|
||||
/// `pavucontrol`, `pw-cli list-objects`, etc.
|
||||
pub const NODE_NAME: &str = "headroom-processed";
|
||||
|
||||
/// Human-readable description shown in tools that surface it.
|
||||
pub const NODE_DESCRIPTION: &str = "Headroom (processed)";
|
||||
|
||||
/// The `headroom-processed` virtual sink. The daemon's sole virtual
|
||||
/// sink — bypassed streams route directly to the real sink, see
|
||||
/// `PLAN.md` §2.
|
||||
pub struct VirtualSink {
|
||||
/// Holds the sink alive on the server. Dropping this destroys
|
||||
/// the sink. `None` until [`Self::create`] is called.
|
||||
proxy: Option<Node>,
|
||||
}
|
||||
|
||||
impl VirtualSink {
|
||||
/// Construct an unbound handle. Call [`Self::create`] to
|
||||
/// materialise the sink on the server.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self { proxy: None }
|
||||
}
|
||||
|
||||
/// Create the virtual sink on the PipeWire server.
|
||||
///
|
||||
/// Uses the generic `adapter` factory (always present in modern
|
||||
/// PipeWire) with `factory.name = support.null-audio-sink` as a
|
||||
/// property — that's the SPA-level factory the adapter wraps to
|
||||
/// produce a null sink with a monitor port.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DaemonError::PipeWire`] if the server rejects the
|
||||
/// create-object call.
|
||||
pub fn create(&mut self, core: &Core) -> Result<(), DaemonError> {
|
||||
let props = properties! {
|
||||
// The SPA-level factory the adapter wraps. This is what
|
||||
// makes the adapter behave as a null sink with monitor.
|
||||
"factory.name" => "support.null-audio-sink",
|
||||
// Stable identifier.
|
||||
"node.name" => NODE_NAME,
|
||||
// What pavucontrol / wpctl / tray applets display.
|
||||
"node.description" => NODE_DESCRIPTION,
|
||||
// Sink, with a monitor we can capture from.
|
||||
"media.class" => "Audio/Sink",
|
||||
// Stereo. v0 non-goal: >2-channel content bypasses
|
||||
// entirely (PLAN §1).
|
||||
"audio.position" => "FL,FR",
|
||||
// Suspend when nobody's streaming through it. Saves CPU
|
||||
// and makes pipewire happy when the daemon idles.
|
||||
"node.suspend-on-idle" => "true",
|
||||
};
|
||||
|
||||
let proxy: Node = core
|
||||
.create_object("adapter", &props)
|
||||
.map_err(|e| DaemonError::pipewire(format!("create_object: {e}")))?;
|
||||
|
||||
self.proxy = Some(proxy);
|
||||
tracing::debug!(
|
||||
node.name = NODE_NAME,
|
||||
"create_object(adapter, factory.name=support.null-audio-sink) queued"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the sink has been created on the server.
|
||||
#[must_use]
|
||||
pub fn is_created(&self) -> bool {
|
||||
self.proxy.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VirtualSink {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
244
crates/headroom-core/src/routing.rs
Normal file
244
crates/headroom-core/src/routing.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
//! Routing engine.
|
||||
//!
|
||||
//! Pure policy: given a stream's PipeWire properties and the active
|
||||
//! profile, decide whether the stream should be routed to
|
||||
//! `headroom-processed` or directly to the real sink. The PipeWire
|
||||
//! layer (`pw::registry`) is responsible for materialising
|
||||
//! [`PwNodeInfo`] from a real `pw_node` and applying the decision by
|
||||
//! writing `target.object`; this module is intentionally
|
||||
//! PipeWire-free so it can be unit-tested without the daemon running.
|
||||
|
||||
use headroom_ipc::{Route, RouteRuleMatch};
|
||||
|
||||
use crate::profile::Profile;
|
||||
|
||||
/// A minimal projection of a PipeWire node's properties — the subset
|
||||
/// the routing engine needs to make a decision. Constructed from a
|
||||
/// `pw_node`'s property dictionary on the daemon side; this struct
|
||||
/// itself has no PipeWire dependency.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PwNodeInfo {
|
||||
/// PipeWire node id. Used for logging and IPC events; not used by
|
||||
/// the routing decision itself.
|
||||
pub node_id: u32,
|
||||
/// `media.class` — e.g. `"Stream/Output/Audio"`, `"Audio/Sink"`.
|
||||
pub media_class: Option<String>,
|
||||
/// `application.process.binary` — kernel-sourced, highest reliability.
|
||||
pub application_process_binary: Option<String>,
|
||||
/// `application.name` — client-set.
|
||||
pub application_name: Option<String>,
|
||||
/// `pipewire.access.portal.app_id` — Flatpak-set, trustworthy when present.
|
||||
pub portal_app_id: Option<String>,
|
||||
/// `media.role` — bonus signal, rarely set.
|
||||
pub media_role: Option<String>,
|
||||
/// `node.dont-move` — if set true, the stream opted out of being
|
||||
/// rerouted. Honoured by skipping routing entirely.
|
||||
pub dont_move: bool,
|
||||
}
|
||||
|
||||
impl PwNodeInfo {
|
||||
/// True if this node is a playback stream we may route.
|
||||
#[must_use]
|
||||
pub fn is_routable_playback(&self) -> bool {
|
||||
!self.dont_move && self.media_class.as_deref() == Some("Stream/Output/Audio")
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of evaluating a stream against the active profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RoutingDecision {
|
||||
/// Route to `headroom-processed`.
|
||||
Route(Route),
|
||||
/// Skip routing entirely (e.g. stream isn't a routable playback
|
||||
/// stream, or it opted out via `node.dont-move`).
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Evaluate a stream against the profile's routing rules.
|
||||
///
|
||||
/// Returns [`RoutingDecision::Skip`] if the stream isn't a routable
|
||||
/// playback stream. Otherwise returns the first-match route, or the
|
||||
/// profile's `default_route` if no rule matches.
|
||||
#[must_use]
|
||||
pub fn evaluate(info: &PwNodeInfo, profile: &Profile) -> RoutingDecision {
|
||||
if !info.is_routable_playback() {
|
||||
return RoutingDecision::Skip;
|
||||
}
|
||||
for rule in &profile.rules {
|
||||
if matches(info, &rule.match_) {
|
||||
return RoutingDecision::Route(rule.route);
|
||||
}
|
||||
}
|
||||
RoutingDecision::Route(profile.default_route.route)
|
||||
}
|
||||
|
||||
/// True iff every present field in the matcher has at least one value
|
||||
/// that equals the corresponding property of the node. Empty fields
|
||||
/// are treated as "don't care."
|
||||
fn matches(info: &PwNodeInfo, m: &RouteRuleMatch) -> bool {
|
||||
let any_match = |needle: &Option<String>, hay: &[String]| -> bool {
|
||||
if hay.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match needle {
|
||||
Some(s) => hay.iter().any(|h| h == s),
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
|
||||
any_match(&info.application_process_binary, &m.process_binary)
|
||||
&& any_match(&info.application_name, &m.application_name)
|
||||
&& any_match(&info.portal_app_id, &m.portal_app_id)
|
||||
&& any_match(&info.media_role, &m.media_role)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn playback(binary: &str) -> PwNodeInfo {
|
||||
PwNodeInfo {
|
||||
node_id: 1,
|
||||
media_class: Some("Stream/Output/Audio".into()),
|
||||
application_process_binary: Some(binary.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_playback_streams_are_skipped() {
|
||||
let mut info = playback("firefox");
|
||||
info.media_class = Some("Stream/Input/Audio".into());
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dont_move_opts_out() {
|
||||
let mut info = playback("firefox");
|
||||
info.dont_move = true;
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_bypass_rule_for_known_music_player() {
|
||||
let info = playback("mpv");
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_processed_rule_for_browser() {
|
||||
let info = playback("firefox");
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_route_when_no_rule_matches() {
|
||||
let info = playback("some-obscure-binary");
|
||||
let profile = Profile::default_v0();
|
||||
// default_v0 has `default_route = Processed`.
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_matching_rule_wins() {
|
||||
// Build a profile whose first rule says everything matches
|
||||
// → bypass, and second rule contradicts. First should win.
|
||||
let mut profile = Profile::default_v0();
|
||||
profile.rules.clear();
|
||||
profile.rules.push(headroom_ipc::RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec!["firefox".into()],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Bypass,
|
||||
});
|
||||
profile.rules.push(headroom_ipc::RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec!["firefox".into()],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Processed,
|
||||
});
|
||||
let info = playback("firefox");
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_matcher_acts_as_wildcard() {
|
||||
let mut profile = Profile::default_v0();
|
||||
profile.rules.clear();
|
||||
profile.rules.push(headroom_ipc::RouteRule {
|
||||
match_: RouteRuleMatch::default(), // all fields empty
|
||||
route: Route::Bypass,
|
||||
});
|
||||
let info = playback("firefox");
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_match_fields_are_anded() {
|
||||
let mut profile = Profile::default_v0();
|
||||
profile.rules.clear();
|
||||
profile.rules.push(headroom_ipc::RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec!["firefox".into()],
|
||||
media_role: vec!["Communication".into()],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Bypass,
|
||||
});
|
||||
|
||||
// process_binary matches but media_role doesn't (None on info).
|
||||
let info = playback("firefox");
|
||||
assert_ne!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
|
||||
// Both match.
|
||||
let mut info2 = playback("firefox");
|
||||
info2.media_role = Some("Communication".into());
|
||||
assert_eq!(
|
||||
evaluate(&info2, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_app_id_can_match_when_present() {
|
||||
let mut profile = Profile::default_v0();
|
||||
profile.rules.clear();
|
||||
profile.rules.push(headroom_ipc::RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
portal_app_id: vec!["com.discordapp.Discord".into()],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Processed,
|
||||
});
|
||||
let mut info = playback("DiscordWrapper");
|
||||
info.portal_app_id = Some("com.discordapp.Discord".into());
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
}
|
||||
46
crates/headroom-core/src/runtime.rs
Normal file
46
crates/headroom-core/src/runtime.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Top-level orchestrator.
|
||||
//!
|
||||
//! Phase 3 scope: connect the [`pw`](crate::pw) layer to the routing
|
||||
//! engine, register signal-hook handlers for graceful shutdown, run
|
||||
//! the PipeWire main loop. The IPC server (Phase 4) and slow AGC loop
|
||||
//! (Phase 4) attach here as well in later checkpoints.
|
||||
|
||||
use crate::error::DaemonError;
|
||||
use crate::profile::Profile;
|
||||
use crate::pw::filter::Filter;
|
||||
use crate::pw::PwContext;
|
||||
|
||||
/// Run the daemon using `profile` as the active configuration.
|
||||
///
|
||||
/// Blocks until shutdown. Returns `Ok(())` on a clean exit (SIGTERM /
|
||||
/// SIGINT) or a [`DaemonError`] on startup or runtime failure.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if connecting to PipeWire fails, or if any of
|
||||
/// the per-checkpoint sub-systems fails to start.
|
||||
pub fn run(profile: Profile) -> Result<(), DaemonError> {
|
||||
tracing::info!(
|
||||
profile = profile.name.as_str(),
|
||||
rules = profile.rules.len(),
|
||||
"starting headroom daemon"
|
||||
);
|
||||
|
||||
let pw = PwContext::new()?;
|
||||
pw.create_processed_sink()?;
|
||||
|
||||
// Bring up the filter pipeline. The Filter holds two `pw_stream`s
|
||||
// (capture from headroom-processed monitor, playback to the
|
||||
// system default real sink) and the DSP chain that sits between
|
||||
// them. Drop on shutdown tears the audio path down cleanly.
|
||||
let _filter = Filter::create(pw.core())?;
|
||||
|
||||
// Subscribe to the registry. New `Stream/Output/Audio` nodes
|
||||
// matching a routing rule get `target.object` written via the
|
||||
// `default` metadata; WirePlumber moves them. Bypassed streams
|
||||
// are left at the user's default sink for v0.
|
||||
pw.start_routing(profile)?;
|
||||
|
||||
pw.run_until_signal()?;
|
||||
tracing::info!("headroom daemon stopped");
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue