From fcf421b94cf15f9f92e37215e150fd85e248392e Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 20 May 2026 23:47:19 +1000 Subject: [PATCH] stage 6: per-app --- Cargo.lock | 429 +++++++- Cargo.toml | 4 + PLAN.md | 227 +++- README.md | 6 +- crates/headroom-core/Cargo.toml | 22 +- crates/headroom-core/benches/app_level.rs | 78 ++ crates/headroom-core/src/agc.rs | 337 ++++++ crates/headroom-core/src/app_level.rs | 551 ++++++++++ crates/headroom-core/src/ipc/mod.rs | 5 + crates/headroom-core/src/ipc/ops.rs | 597 +++++++--- crates/headroom-core/src/ipc/server.rs | 86 +- crates/headroom-core/src/lib.rs | 12 +- crates/headroom-core/src/profile.rs | 26 + crates/headroom-core/src/profile_store.rs | 1084 +++++++++++++++++++ crates/headroom-core/src/profile_watcher.rs | 178 +++ crates/headroom-core/src/pw/command.rs | 56 + crates/headroom-core/src/pw/filter.rs | 421 ++++++- crates/headroom-core/src/pw/metadata.rs | 132 +-- crates/headroom-core/src/pw/mod.rs | 53 +- crates/headroom-core/src/pw/registry.rs | 922 ++++++++++++++-- crates/headroom-core/src/pw/tap.rs | 283 +++++ crates/headroom-core/src/routing.rs | 5 +- crates/headroom-core/src/runtime.rs | 109 +- crates/headroom-core/src/state.rs | 137 ++- crates/headroom-dsp/Cargo.toml | 7 + crates/headroom-dsp/benches/layer_a.rs | 130 +++ crates/headroom-dsp/src/agc.rs | 211 ++++ crates/headroom-dsp/src/level_envelopes.rs | 427 ++++++++ crates/headroom-dsp/src/lib.rs | 6 +- crates/headroom-dsp/src/limiter.rs | 156 +++ crates/headroom-ipc/src/proto.rs | 7 + 31 files changed, 6360 insertions(+), 344 deletions(-) create mode 100644 crates/headroom-core/benches/app_level.rs create mode 100644 crates/headroom-core/src/agc.rs create mode 100644 crates/headroom-core/src/app_level.rs create mode 100644 crates/headroom-core/src/profile_store.rs create mode 100644 crates/headroom-core/src/profile_watcher.rs create mode 100644 crates/headroom-core/src/pw/command.rs create mode 100644 crates/headroom-core/src/pw/tap.rs create mode 100644 crates/headroom-dsp/benches/layer_a.rs create mode 100644 crates/headroom-dsp/src/agc.rs create mode 100644 crates/headroom-dsp/src/level_envelopes.rs diff --git a/Cargo.lock b/Cargo.lock index 62a6a47..5d6e4ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "annotate-snippets" version = "0.9.2" @@ -57,7 +63,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -68,7 +74,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -77,6 +83,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bindgen" version = "0.69.5" @@ -84,10 +96,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ "annotate-snippets", - "bitflags", + "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", @@ -98,6 +110,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -110,6 +128,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.62" @@ -145,6 +169,33 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -220,6 +271,40 @@ dependencies = [ "futures", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -235,6 +320,39 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "ebur128" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9" +dependencies = [ + "bitflags 1.3.2", + "dasp_frame", + "dasp_sample", + "smallvec", +] + [[package]] name = "either" version = "1.15.0" @@ -254,7 +372,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", ] [[package]] @@ -263,6 +391,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -357,6 +494,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -392,12 +540,17 @@ name = "headroom-core" version = "0.1.0" dependencies = [ "bytemuck", + "criterion", "crossbeam-channel", + "ebur128", "headroom-client", "headroom-dsp", "headroom-ipc", "libspa", + "libspa-sys", "nix", + "notify", + "notify-debouncer-mini", "parking_lot", "pipewire", "rtrb", @@ -413,6 +566,9 @@ dependencies = [ [[package]] name = "headroom-dsp" version = "0.1.0" +dependencies = [ + "criterion", +] [[package]] name = "headroom-ipc" @@ -429,6 +585,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "indexmap" version = "2.14.0" @@ -439,12 +601,52 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -460,6 +662,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -494,7 +716,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cc", "convert_case", "cookie-factory", @@ -552,13 +774,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "nix" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -573,13 +807,52 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -594,6 +867,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -630,7 +909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "libc", "libspa", "libspa-sys", @@ -681,7 +960,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -725,6 +1004,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -915,6 +1203,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "toml" version = "0.8.23" @@ -1053,6 +1351,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "winapi" version = "0.3.9" @@ -1069,6 +1383,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1081,6 +1404,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1090,6 +1422,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winnow" version = "0.7.15" @@ -1108,6 +1497,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 58ed008..61cbdd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ fundsp = "0.20" # PipeWire. `v0_3_44` exposes target.object key + related modern APIs. pipewire = { version = "0.8", features = ["v0_3_44"] } libspa = "0.8" +libspa-sys = "0.8" # Safe byte<->POD casts for audio buffers. bytemuck = "1.18" @@ -63,6 +64,9 @@ bytemuck = "1.18" notify = "6.1" notify-debouncer-mini = "0.4" +# Benchmarking — dev-dep only. +criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } + # Logging — journald optional tracing-journald = "0.3" diff --git a/PLAN.md b/PLAN.md index d13fc78..7d3e62b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,10 +13,16 @@ conversational sketch. ### Goals -- **Hard safety net.** Output is guaranteed to stay below a configurable - ceiling (default **−0.1 dBTP**) with proper inter-sample peak handling. - This guarantee survives daemon misbehaviour, profile reloads, and bad - routing decisions — it is enforced inline in the audio path. +- **Hard safety net on the processed route.** Audio routed through + `headroom-processed` is guaranteed to leave the filter below a + configurable ceiling (default **−0.1 dBTP**) with proper inter-sample + peak handling. The guarantee is enforced inline in the filter, + downstream of every control-plane code path, and survives daemon + misbehaviour, profile reloads, and bad routing decisions. Streams + routed `bypass` ride the real sink directly and are **not** subject + to this contract (see §2 path ①); the contract also does not extend + to whatever resampling or post-processing the downstream device + path applies after the filter's output. - **Per-application exclusion.** Music players, games, and DAWs route around the processor; browsers, voice chat, and "everything else" go through it. Rules are app-level and live in profiles. @@ -472,10 +478,13 @@ is the irreducible cost of "no lookahead allowed." For absolute spike prevention you need lookahead, which means latency, which contradicts the constraint of this layer. -The bus-level Layer C limiter (§3.1) catches anything that would -exceed the absolute ceiling regardless of whether Layer A has caught -up. Layer A reduces *workload* on Layer C by pre-attenuating noisy -apps; it doesn't replace it. +On the processed route the bus-level Layer C limiter (§3.1) catches +anything that would exceed the ceiling regardless of whether Layer A +has caught up; on bypass routes Layer A is the only thing watching, so +isolated one-block transients reach the real sink. Layer A reduces +*workload* on Layer C where Layer C is in the path, and is a +best-effort comfort filter where it isn't; it doesn't replace the +limiter. ### 4.6 Layered budget summary @@ -593,9 +602,24 @@ updates arrive over an `rtrb` SPSC queue from the control thread. ## 6. Profiles -Location: `$XDG_CONFIG_HOME/headroom/profiles/*.toml` (overriding -shipped defaults in `/usr/share/headroom/profiles/` if installed -system-wide). Hot-reloaded via `notify-debouncer-mini`. +Profile files live in `$XDG_CONFIG_HOME/headroom/profiles/*.toml`, +shadowing shipped defaults in `/usr/share/headroom/profiles/` by +name. Profile files are user-authored configuration — they're the +thing you open in `$EDITOR`. File-watcher hot-reload via +`notify-debouncer-mini` is planned; in the meantime `profile.reload` +re-scans on demand. + +Daemon-managed user state — active profile name, per-app route +overrides made via `route.set`, dotted-key tweaks made via +`setting.set`, the global bypass flag — is *not* mixed in with the +profile TOMLs. It lives in a single `overlay.toml` at +`$XDG_STATE_HOME/headroom/overlay.toml`, written atomically by the +daemon (stage to `overlay.toml.tmp-…`, then rename). The overlay +rides on top of whichever profile is active, so `route.set obs +bypass` persists across `profile.use night` — that's a user +preference, not a tweak of `default`. If the overlay names an active +profile that's not on disk, the daemon falls back to the built-in +default and surfaces a warning; it does not refuse to start. Each profile is a complete listening scenario. Schema (`headroom-core::profile`): @@ -664,6 +688,10 @@ max_cut_db = 12.0 # never cut more than this peak_attack_ms = 5.0 peak_release_ms = 500.0 rms_window_ms = 1500.0 +# Controller-side knobs (all optional; defaults shown). +smoother_ms = 30.0 # anti-bounce smoother on max(peak,rms) +write_db_threshold = 0.5 # dB diff below which we don't fire a write +min_write_interval_ms = 100.0 # min ms between writes per stream (10 Hz cap) defer_to_user = "ceiling" # "ceiling" | "strict" [[per_app.rules]] @@ -826,6 +854,88 @@ routing engine. Hardcoded profile, no IPC server yet. IPC schema. Profile loading + hot-reload. Slow AGC loop ticking on real loudness measurements. +Sub-stages used in commits / TODOs: + +- **4a–4d** — Unix socket server, op dispatch, mutating ops, event + broadcaster. +- **4e** — `ProfileStore`: shipped + user profiles, atomic reload, + user overlay at `$XDG_STATE_HOME/headroom/overlay.toml`. `profile.use`, + `profile.reload`, `setting.set`, `route.set` all dispatch through it. +- **4f** — DSP parameter propagation: `setting.set` reaches the running + filter via the `rtrb` control queue, so live profile/setting edits + take effect without restart. +- **4h** — `preferred_real_sink` tracking: subscribe to + `default.audio.sink`, snapshot the prior default, promote + `headroom-processed`, retarget every bypassed stream on + default-sink change, on hotplug, and on Bluetooth handoff. Also + pins the filter's playback to the tracked real sink so processed + audio follows when the user switches default, and resolves the + real sink's node id from the registry for `status` reporting. +- **4i** — `route.stream processed|bypass`: ad-hoc per-stream + override that doesn't write a profile rule. Crosses the + IPC-thread → PipeWire-thread boundary via a `crossbeam` channel + drained by a 50 ms timer source on the main loop. State updates + synchronously; metadata write follows ≤ ~50 ms later. + +- **Slow AGC loop** — wraps up Phase 4. Audio-thread `AgcGain` stage + sits at the head of the DSP chain (anti-zipper smoother around a + per-sample multiplier). Filter pushes *pre-AGC* input samples into a + dedicated measurement ring. A `AgcController` on the PipeWire main + loop ticks at 50 ms: drains the ring into `ebur128` (Mode S | M | + TRUE_PEAK), reads `[agc]` config from the active profile, computes + `target_lufs − short_term_lufs` clamped to `[-max_cut_db, + +max_boost_db]`, gates below `silence_threshold_lufs`, slow-smooths + via leaky integrator, and pushes the result through `FilterControl` + on the same `rtrb` channel `setting.set` uses. + +### Tracked follow-ups (carried past their sub-stage) + +Items deliberately deferred from earlier sub-stages so they don't get +lost. Pick up by name when the phase that consumes them lands. + +- **Ephemeral overlay mutations.** *(4e follow-up.)* All `route.set` + / `setting.set` changes are persisted to `overlay.toml`. A + `--ephemeral` flag (or `--volatile`) on the CLI for one-shot tweaks + that don't outlive the daemon was considered and dropped from v0 + for simplicity. Revisit if real users ask for it; the store-level + change is a flag on the setter methods. +- **Filter playback BUSY spikes (periodic, ~10 s cadence).** *(6c + manual smoke finding, 2026-05.)* On a quiet system with AGC and + per-app both off, the filter's `playback_process` BUSY + occasionally spikes from its ~240 μs steady-state to ~2.0 ms, + correlating with output-sink WAIT spikes of similar size. No + audible impact (sub-quantum at 21 ms). The ~10 s cadence rules + out sliding-max worst-case (which would be input-pattern-driven, + not periodic) and Layer A (the spikes persist with `per_app.enabled + = false`). Suspects with 10 s clocks somewhere: WirePlumber session + policy heartbeat, PipeWire internal graph re-eval, or system-level + scheduling (CPU governor, kernel housekeeping). Diagnostic for + Phase 8: timestamp the playback callback, log when its measured + duration crosses ~1 ms; correlate with `journalctl`, + `wireplumber --verbose`, and `pw-dump` snapshots taken around the + spike. If we can't attribute it to PipeWire-side reschedule and + it's something we can fix in our callback, the candidate + workaround is to break the limiter's per-block work into smaller + chunks (cap allocations / pops / branches per call) for more + predictable timing. +- **Sub-millisecond dispatch primitive for spike-reactive writes.** + *(Phase 6 optimisation, downgraded from prerequisite.)* The 4i + `PwCommand` channel uses a 50 ms polling timer, fine for + `route.stream` and slow AGC. Layer A's per-app + `Props.channelVolumes` writes were originally feared to need a + sub-ms wake primitive. After 6a/6b benches landed (see + §11.6 below) we re-evaluated: at a 5 ms polling timer and 21 ms + PipeWire quantum, the worst-case detection-to-write latency stays + well inside one quantum, which is what PLAN §4.5 actually + promises. Polling reuses existing infrastructure and is cheap + (controller tick is ~30 ns; even at 200 Hz it's lost in the + noise). The tighter primitive — `EventSource::signal` with an + `unsafe impl Send` shim around `spa_loop_utils.signal_event`, or a + pipe + `IoSource` — stays on the table as an optimisation if + manual testing shows audible spike-leak artefacts. `pw::command` + module docs still carry the constraint warning for future variants + that might be tempted to share the 50 ms timer. + **Phase 5 — CLI + monitor TUI.** `headroom-cli` implements all the subcommands above, plus a `monitor` TUI built on the meters subscription. @@ -837,6 +947,101 @@ tap creation, `AppLevelController` with peak + RMS envelopes, per-stream meter event on the IPC. Land after the bus path is stable so we have a baseline to compare against. +Sub-stages: + +- **6a** — Pure DSP. `headroom_dsp::LevelEnvelopes`: two-tier (peak + + RMS) block-rate detector, `max(peak_reduction, rms_reduction)` + combined, clamped to `max_cut_db`. Allocation-free, + block-rate-driven (audio thread emits one `(peak, mean_sq)` pair + per quantum). +- **6b** — Daemon-side glue. + `headroom_core::app_level::AppLevelController`: rule snapshot, + envelopes, 30 ms anti-bounce smoother, 0.5 dB / 100 ms write + gate, ceiling vs strict deference state. + `app_level::evaluate` matches `[[per_app.rules]]` against + `PwNodeInfo` using the same matcher the routing engine uses. +- **6c** — PipeWire tap + audio-thread analysis. **Mechanism**: + per managed stream we create our own `pw_stream` (Direction::Input, + F32LE stereo, rate left unspecified to negotiate with the source, + `AUTOCONNECT` off, `NODE_DONT_RECONNECT`, `node.dont-move`), + `connect()` with no target, `set_active(true)`. PipeWire creates + our input ports from the declared format. We then build **explicit + passive port-level links** via `link-factory` with + `link.output.port` / `link.input.port` set to the source's and + tap's port global IDs respectively, plus `link.passive = true`. + **Why not `target.object` or `target_id`**: empirically (6c manual + smoke) WirePlumber's policy refuses to wire `Stream/Output → + Stream/Input` via any session-manager-mediated path — it logs no + error, just doesn't act. The stream-level target was getting set + on the node (`node.target = `) but no link ever + appeared. Going through `link-factory` with explicit port IDs + bypasses the session manager entirely and uses PipeWire core + directly. **Per managed stream**: one `pw_stream`, two `Link` + proxies (one per channel), one `MeasurementSample` `rtrb` + (capacity 64). Audio-thread `process` runs `peak = max(|x|)` and + `mean_sq = Σx²/N` over the block, pushes one sample to the ring. + **Lifecycle**: registry watcher sees a `Stream/Output/Audio` + matching a `per_app` rule → spawn tap (ports come up + asynchronously) → the Layer A drain timer (6d) retries link + creation each tick until both port sets are visible on the + registry → links built, stream transitions to `Streaming`, + samples flow. On registry `global_remove` of the source, drop the + `ManagedStream`; declaration order severs links first, then the + tap stream + listener. +- **6d** — `Props.channelVolumes` writes + controller drain timer. + A polling timer source on the PipeWire main loop ticks every 5 ms + (200 Hz, CPU cost ≪ 0.1% of one core per the benches), iterates + active controllers, drains each measurement ring, calls + `process_block`, and on a `Some` return writes + `Props.channelVolumes` via the bound `default` metadata + (subject = source node id). The 5 ms tick guarantees a spike + detected at quantum boundary `N` is written before quantum `N+1` + starts on typical 21 ms quanta — see §4.5 reaction-time honesty + table. +- **6e** — User-volume deference + per-stream meter events. + Subscribe to `Props` param-change events on each managed stream. + Distinguish daemon writes from external by comparing against + `last_written_lin` (within 1e-4) — external changes apply + ceiling-mode or strict-mode deference per the matched rule's + `defer_to_user` field. Per-stream meters publish on the `meters` + topic with the smoothed reduction, the peak/RMS envelope values, + and the current applied `channelVolumes`. + +**Validated cost budget (criterion microbenches, run 2026-05).** +PLAN §4.7 budgeted "~10 μs/quantum audio thread, few μs/measurement +daemon thread." Reality on this hardware: + +| Bench | Time | +|---|---| +| Audio-thread peak + mean_sq scan, 1024-frame stereo block | 1.33 μs | +| `LevelEnvelopes::process_block` (daemon) | 18 ns | +| `AppLevelController::process_block` hot signal | 29 ns | +| `AppLevelController::process_block` quiet signal | 22 ns | + +5 managed streams: audio thread ≈ 6.6 μs/quantum (0.03% of one +core at 21 ms quanta); daemon ≈ 145 ns/quantum. ~7-10× under the +PLAN budget, so the design has room for many more managed streams, +or for adding ebur128 / TRUE_PEAK to Layer A later if useful. + +**Manual latency validation (post-6c implementation).** PipeWire +scheduling can't be benched from Rust alone. Use: + +- **`pw-top`** — note the source-node `QUANT` and any WAIT/BUSY or + delay column before attaching the tap; attach Layer A; confirm + the source-node numbers don't change. The tap appears as a new + row with its own quantum; the test is whether the *app's* numbers + degrade. +- **`qpwgraph`** / **`helvum`** — visually confirm the source node + has two outgoing links (one to its original destination, one to + our tap), both terminating correctly. +- **Ear** — connect/disconnect the tap on live audio. Crackles or + dropouts on attach indicate the §4.1 sibling-fanout claim doesn't + hold and the design needs revisiting. + +If those three say "fine," the §4.1 promise is upheld in practice +and 6c is acceptance-tested. `jack_iodelay` and other true-round-trip +tools are overkill. + **Phase 7 — Packaging.** systemd user unit, install paths, default profile install, basic NixOS module. diff --git a/README.md b/README.md index b5e8226..fed7fc9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,11 @@ Headroom puts a per-application audio safety net between noisy sources the things you *don't* want compressed (music players, games, DAWs) untouched. -- **Hard −0.1 dBTP ceiling** with proper inter-sample-peak handling. +- **Hard −0.1 dBTP ceiling on the processed route**, with proper + inter-sample-peak handling, enforced inline so the contract holds + regardless of control-plane state. Streams routed `bypass` ride the + real sink directly and are not in scope of the contract — that's the + trade-off that makes the per-app exclusion useful. - **Per-app exclusion** with profile-driven rules. - **Single binary** daemon + CLI, controlled over a Unix-domain socket with a documented JSON wire protocol (see [`IPC.md`](IPC.md)). diff --git a/crates/headroom-core/Cargo.toml b/crates/headroom-core/Cargo.toml index 2de75ab..00a3dc0 100644 --- a/crates/headroom-core/Cargo.toml +++ b/crates/headroom-core/Cargo.toml @@ -27,7 +27,8 @@ nix = { workspace = true } # PipeWire integration (Phase 3c onwards). pipewire = { workspace = true } -libspa = { workspace = true } +libspa = { workspace = true } +libspa-sys = { workspace = true } # Audio-thread comms. rtrb = { workspace = true } @@ -36,11 +37,22 @@ bytemuck = { workspace = true } # 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 } +# File-watch profile hot-reload (4e follow-up). +notify = { workspace = true } +notify-debouncer-mini = { workspace = true } + +# Slow AGC loop (Phase 4 closing piece). +ebur128 = { workspace = true } + +# Optional journald logging — not wired yet. # tracing-journald = { workspace = true } +[dev-dependencies] +criterion = { workspace = true } + [features] default = [] + +[[bench]] +name = "app_level" +harness = false diff --git a/crates/headroom-core/benches/app_level.rs b/crates/headroom-core/benches/app_level.rs new file mode 100644 index 0000000..dc24a73 --- /dev/null +++ b/crates/headroom-core/benches/app_level.rs @@ -0,0 +1,78 @@ +//! Microbench for the daemon-side per-app controller loop. Measures +//! one `AppLevelController::process_block` call (envelope smoothing + +//! anti-bounce + threshold/rate-limit gate). PLAN §4.7 budgets a +//! "few μs per measurement." +//! +//! Run with `cargo bench -p headroom-core --bench app_level` inside +//! `nix develop`. + +use std::time::{Duration, Instant}; + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use headroom_core::app_level::AppLevelController; +use headroom_core::profile::{DeferPolicy, PerAppRule}; +use headroom_ipc::RouteRuleMatch; + +const BLOCK_DT_S: f32 = 1024.0 / 48_000.0; + +fn aggressive_rule() -> PerAppRule { + PerAppRule { + match_: RouteRuleMatch::default(), + enabled: true, + peak_threshold_db: -6.0, + rms_target_db: -20.0, + max_cut_db: 12.0, + peak_attack_ms: 5.0, + peak_release_ms: 500.0, + rms_window_ms: 1500.0, + smoother_ms: 30.0, + write_db_threshold: 0.5, + min_write_interval_ms: 100.0, + defer_to_user: DeferPolicy::Ceiling, + } +} + +fn bench_process_block(c: &mut Criterion) { + let mut ctrl = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + // Hot signal: 0 dBFS peak, ~-3 dB RMS. + let peak = 1.0_f32; + let mean_sq = 0.25_f32; + + // Time advances at one block per call to keep the rate-limit gate + // behaviour realistic — it'd otherwise be `now` reused every iter. + let mut t = Instant::now(); + let step = Duration::from_millis(21); + + let mut group = c.benchmark_group("app_level_controller"); + group.bench_function("process_block_hot_signal", |b| { + b.iter(|| { + t += step; + let v = ctrl.process_block(black_box(peak), black_box(mean_sq), t); + black_box(v); + }); + }); + + // A second variant where the signal is below all thresholds — + // this exercises the "no write" fast path the controller takes + // most of the time on a quiet system. + let mut quiet_ctrl = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + let quiet_peak = 0.01_f32; + let quiet_mean_sq = 0.0001_f32; + let mut t2 = Instant::now(); + group.bench_function("process_block_quiet_signal", |b| { + b.iter(|| { + t2 += step; + let v = quiet_ctrl.process_block( + black_box(quiet_peak), + black_box(quiet_mean_sq), + t2, + ); + black_box(v); + }); + }); + + group.finish(); +} + +criterion_group!(benches, bench_process_block); +criterion_main!(benches); diff --git a/crates/headroom-core/src/agc.rs b/crates/headroom-core/src/agc.rs new file mode 100644 index 0000000..8fb8f19 --- /dev/null +++ b/crates/headroom-core/src/agc.rs @@ -0,0 +1,337 @@ +//! Control-thread piece of the slow AGC. +//! +//! Reads the latest AGC config from the active profile, drains the +//! measurement ring written by the filter's playback callback, +//! feeds samples through `ebur128` to derive a short-term loudness, +//! computes a clamped + slow-smoothed target gain in dB, and pushes +//! it at the audio thread via [`FilterControl::set_agc_target_db`]. +//! +//! The controller is **not** spike-reactive — its time constants are +//! seconds, and the audio-thread `AgcGain` stage takes care of +//! anti-zipper smoothing between ticks. The 50 ms tick cadence is +//! comfortably above the 5–20 ms quantum-reaction budget so the +//! control plane can ride the PipeWire main-loop thread alongside +//! the `route.stream` timer (see `pw::command` module docs). + +use std::time::Duration; + +use ebur128::{EbuR128, Mode}; + +use crate::pw::filter::FilterControl; +use crate::state::SharedState; + +/// AGC tick period. Hardcoded for v0; not exposed as a profile knob. +pub const AGC_TICK: Duration = Duration::from_millis(50); + +/// Maximum samples fed per tick. Big enough to cover ~50 ms of stereo +/// at 48 kHz (4800 samples) with slack; smaller than a stack-frame +/// alarm. Sized to keep `ebur128.add_frames_f32` work bounded. +const TICK_BUF_SAMPLES: usize = 8192; + +/// Loudness floor we treat as "no usable measurement yet" — returned +/// by `ebur128` before its short-term window has filled, or during +/// digital silence. +const LOUDNESS_FLOOR_LUFS: f32 = -200.0; + +/// Slow AGC controller. +pub struct AgcController { + sample_rate: u32, + channels: u32, + ebu: EbuR128, + measurement_consumer: rtrb::Consumer, + filter_control: FilterControl, + daemon: SharedState, + /// Smoothed target gain in dB. Sent to the audio thread on every + /// tick (or whenever it changes meaningfully). + smoothed_target_db: f32, + /// Active config the controller is operating against, recomputed + /// at each tick from the effective profile. Cached so we can + /// detect enabled/disabled transitions and push the audio-thread + /// enable flag exactly when it changes. + last_enabled: bool, + /// Last short-term loudness observed; surfaced for status / + /// meters in a future sub-stage. + last_short_term_lufs: f32, +} + +impl AgcController { + /// Construct an AGC controller. + /// + /// # Errors + /// Returns an error if `ebur128::EbuR128::new` fails — typically + /// for invalid sample-rate / channel arguments. + pub fn new( + sample_rate: u32, + channels: u32, + measurement_consumer: rtrb::Consumer, + filter_control: FilterControl, + daemon: SharedState, + ) -> Result { + let ebu = EbuR128::new(channels, sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK) + .map_err(AgcInitError::from)?; + Ok(Self { + sample_rate, + channels, + ebu, + measurement_consumer, + filter_control, + daemon, + smoothed_target_db: 0.0, + last_enabled: true, + last_short_term_lufs: LOUDNESS_FLOOR_LUFS, + }) + } + + /// Latest short-term loudness (LUFS) observed by `ebur128`. Useful + /// for telemetry / `status`; `LOUDNESS_FLOOR_LUFS` before the + /// short-term window fills. + #[must_use] + pub fn last_short_term_lufs(&self) -> f32 { + self.last_short_term_lufs + } + + /// Current smoothed target gain (dB) — the value most recently + /// pushed to the audio thread. + #[must_use] + pub fn current_target_db(&self) -> f32 { + self.smoothed_target_db + } + + /// One control-loop iteration. Should be invoked at [`AGC_TICK`] + /// cadence by a main-loop timer source. + pub fn tick(&mut self) { + // Snapshot the AGC section out from under the daemon lock. + // Hold the lock only long enough to clone the small config. + let cfg = { + let s = self.daemon.lock(); + s.profiles.effective().agc.clone() + }; + + // React to enable/disable transitions before doing measurement + // work — flipping off should stop pushing target updates and + // tell the audio thread to unwind back to 0 dB. + if cfg.enabled != self.last_enabled { + self.filter_control.set_agc_enabled(cfg.enabled); + self.last_enabled = cfg.enabled; + } + if !cfg.enabled { + return; + } + + // Drain up to TICK_BUF_SAMPLES from the measurement ring. + let mut buf = [0.0_f32; TICK_BUF_SAMPLES]; + let mut n = 0; + while n < buf.len() { + match self.measurement_consumer.pop() { + Ok(s) => { + buf[n] = s; + n += 1; + } + Err(_) => break, + } + } + if n == 0 { + return; // No samples yet (early boot or silence); leave target alone. + } + // ebur128 wants whole frames; drop any odd trailing sample. + let usable = (n / self.channels as usize) * self.channels as usize; + if usable == 0 { + return; + } + if let Err(e) = self.ebu.add_frames_f32(&buf[..usable]) { + tracing::warn!(error = %e, "ebur128 add_frames_f32 failed"); + return; + } + + let short_term = self + .ebu + .loudness_shortterm() + .map(|v| v as f32) + .unwrap_or(LOUDNESS_FLOOR_LUFS); + self.last_short_term_lufs = short_term; + + // Silence gate: if the program is below the threshold, hold + // the current target. This avoids ramping gain up during + // legitimate quiet passages. + if short_term <= cfg.silence_threshold_lufs || !short_term.is_finite() { + return; + } + + let raw_target = cfg.target_lufs - short_term; + let clamped = raw_target.clamp(-cfg.max_cut_db, cfg.max_boost_db); + + // Slow leaky-integrator smoother on the tick cadence. attack + // when target is dropping (gain reduction toward the signal), + // release when target is rising back toward unity / boost. + let dt_ms = AGC_TICK.as_secs_f32() * 1000.0; + let alpha = if clamped < self.smoothed_target_db { + alpha_for_dt(cfg.attack_ms, dt_ms) + } else { + alpha_for_dt(cfg.release_ms, dt_ms) + }; + self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db); + + self.filter_control + .set_agc_target_db(self.smoothed_target_db); + } + + /// Reset the smoothed target and the underlying `ebur128` state. + /// Useful on profile.use when the user explicitly wants a fresh + /// AGC start. + pub fn reset(&mut self) { + self.smoothed_target_db = 0.0; + self.last_short_term_lufs = LOUDNESS_FLOOR_LUFS; + // ebur128 doesn't expose a public reset, so rebuild it. + if let Ok(fresh) = + EbuR128::new(self.channels, self.sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK) + { + self.ebu = fresh; + } + self.filter_control.set_agc_target_db(0.0); + } +} + +/// `tau_ms`-time-constant leaky-integrator alpha for a tick of +/// duration `dt_ms`. `1 - exp(-dt / tau)`; clamps to `[0, 1]`. +fn alpha_for_dt(tau_ms: f32, dt_ms: f32) -> f32 { + if tau_ms <= 0.0 || dt_ms <= 0.0 { + return 1.0; + } + (1.0 - (-dt_ms / tau_ms).exp()).clamp(0.0, 1.0) +} + +/// Construction-time failure modes. Tick-time failures (an +/// `ebur128::add_frames_f32` error, a stalled ring) are logged and +/// the tick is skipped — they don't bubble up to a caller. +#[derive(Debug, thiserror::Error)] +pub enum AgcInitError { + /// `ebur128::EbuR128::new` rejected the construction arguments. + #[error("ebur128: {0}")] + Ebu(#[from] ebur128::Error), +} + +impl From for crate::error::DaemonError { + fn from(e: AgcInitError) -> Self { + crate::error::DaemonError::other(format!("agc init: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile_store::ProfileStore; + use crate::pw::filter::{AudioCmd, FilterControl}; + use crate::state::{self, DaemonState}; + use rtrb::RingBuffer; + + const SR: u32 = 48_000; + const CH: u32 = 2; + + fn fixture() -> ( + AgcController, + rtrb::Producer, + rtrb::Consumer, + SharedState, + ) { + let (m_prod, m_cons) = RingBuffer::::new(8192); + let (control, cmd_cons) = FilterControl::for_testing(32); + let state = state::shared(DaemonState::new(ProfileStore::builtin())); + let agc = AgcController::new(SR, CH, m_cons, control, state.clone()).unwrap(); + (agc, m_prod, cmd_cons, state) + } + + fn push_silence(prod: &mut rtrb::Producer, frames: usize) { + for _ in 0..frames { + let _ = prod.push(0.0); + let _ = prod.push(0.0); + } + } + + fn push_sine(prod: &mut rtrb::Producer, frames: usize, amp: f32) { + // Constant amplitude impulse-like — not a real sine but it + // produces a measurable loudness in ebur128 well above silence. + for _ in 0..frames { + let _ = prod.push(amp); + let _ = prod.push(-amp); + } + } + + #[test] + fn tick_with_no_samples_does_nothing() { + let (mut agc, _prod, mut cmd_cons, _state) = fixture(); + agc.tick(); + assert!(cmd_cons.pop().is_err(), "no samples → no target push"); + assert_eq!(agc.current_target_db(), 0.0); + } + + #[test] + fn tick_under_silence_threshold_holds_target() { + let (mut agc, mut prod, mut cmd_cons, _state) = fixture(); + push_silence(&mut prod, 4800); // 100ms of silence + agc.tick(); + // ebur128 may report -inf or values below the silence + // threshold; either way we should not push. + assert!( + cmd_cons.pop().is_err(), + "below silence threshold — no target push expected" + ); + } + + #[test] + fn tick_with_audible_signal_pushes_target() { + let (mut agc, mut prod, mut cmd_cons, _state) = fixture(); + // Pump multiple ticks worth so ebur128's short-term window + // (~3 s) starts producing values. + for _ in 0..40 { + push_sine(&mut prod, 4800, 0.3); + agc.tick(); + } + // We expect at least one SetAgcTargetDb to have been pushed + // once short-term loudness became finite. + let mut saw = false; + while let Ok(cmd) = cmd_cons.pop() { + if matches!(cmd, AudioCmd::SetAgcTargetDb(_)) { + saw = true; + } + } + assert!(saw, "expected at least one AGC target push after pumping"); + } + + #[test] + fn agc_disable_in_profile_flips_audio_thread() { + let (mut agc, _prod, mut cmd_cons, state) = fixture(); + // First tick with the default-enabled profile. + agc.tick(); + // Drain any commands. + while cmd_cons.pop().is_ok() {} + + // Disable AGC in the profile. + state + .lock() + .profiles + .set_setting("agc.enabled", serde_json::json!(false)) + .unwrap(); + agc.tick(); + + // Expect a SetAgcEnabled(false) command. + let mut saw_disable = false; + while let Ok(cmd) = cmd_cons.pop() { + if matches!(cmd, AudioCmd::SetAgcEnabled(false)) { + saw_disable = true; + } + } + assert!(saw_disable, "expected SetAgcEnabled(false) on profile flip"); + } + + #[test] + fn alpha_endpoints() { + // tau == 0 → instantaneous. + assert_eq!(alpha_for_dt(0.0, 50.0), 1.0); + // dt == 0 → no progress. + assert_eq!(alpha_for_dt(1000.0, 0.0), 1.0); // we clamp dt<=0 to 1.0 too + // Sanity: shorter tau → larger alpha for same dt. + let a_fast = alpha_for_dt(100.0, 50.0); + let a_slow = alpha_for_dt(2000.0, 50.0); + assert!(a_fast > a_slow); + } +} diff --git a/crates/headroom-core/src/app_level.rs b/crates/headroom-core/src/app_level.rs new file mode 100644 index 0000000..a1e2917 --- /dev/null +++ b/crates/headroom-core/src/app_level.rs @@ -0,0 +1,551 @@ +//! Per-application level control (Layer A). +//! +//! Phase 6 — see `PLAN.md` §4. This module is the daemon-side +//! controller logic: given block-rate `(peak, mean_sq)` measurements +//! pushed by a sibling tap on the audio thread, decide when to issue +//! a `Props.channelVolumes` update for the managed stream, what value +//! to write, and how to defer to externally-set volumes. +//! +//! The PipeWire pieces (tap creation, the audio-thread analysis +//! callback, the metadata write) live in [`crate::pw`] modules. +//! Everything here is pure logic, unit-tested without a running +//! PipeWire instance. + +use std::time::{Duration, Instant}; + +use headroom_dsp::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig}; + +use crate::profile::{DeferPolicy, PerAppRule, PerAppSection}; +use crate::routing; +use crate::routing::PwNodeInfo; + +// Knob defaults are owned by `PerAppRule` (see `profile.rs`); the +// controller now reads `smoother_ms`, `write_db_threshold`, and +// `min_write_interval_ms` from the rule rather than hardcoding them. +// Constants kept here only as the fallback used when manufacturing a +// synthetic default rule for `default_enabled`. +const FALLBACK_WRITE_DB_THRESHOLD: f32 = 0.5; +const FALLBACK_MIN_WRITE_INTERVAL_MS: f32 = 100.0; +const FALLBACK_SMOOTHER_MS: f32 = 30.0; + +/// Per-stream controller. Holds the envelopes, the smoother state, +/// the rate-limit clock, and the deference / ceiling state. +pub struct AppLevelController { + /// Active rule snapshot. Stored by value so the controller is + /// detached from the profile lifetime; refreshed via + /// [`Self::set_rule`] when the profile changes. + rule: PerAppRule, + envelopes: LevelEnvelopes, + /// Smoothed combined reduction in dB. Single-pole, alpha derived + /// from `rule.smoother_ms`. + smoothed_reduction_db: f32, + smoother_alpha: f32, + /// Cached `Duration` form of `rule.min_write_interval_ms`, + /// recomputed when the rule is swapped in. + min_write_interval: Duration, + /// Last linear volume actually written via Props. `1.0` until a + /// write goes out (so the rate-limit / threshold gate accepts the + /// first real change). + last_written_lin: f32, + /// Wall-clock at last write. `None` before the first write. + last_write_at: Option, + /// User-set ceiling: linear volume the user externally adjusted + /// to. `Some` triggers ceiling-mode deference (clamp our writes). + user_ceiling_lin: Option, + /// Strict-mode lock: when set, the controller stops issuing + /// writes entirely until [`Self::reset_deference`] clears it. + deferred: bool, +} + +impl AppLevelController { + /// Construct a controller for a stream that matched `rule`. + /// + /// `block_dt_s` is the expected period between + /// [`Self::process_block`] calls (i.e. PipeWire's quantum at the + /// stream's negotiated rate). Used to derive envelope alphas. + #[must_use] + pub fn new(rule: PerAppRule, block_dt_s: f32) -> Self { + let envelopes = LevelEnvelopes::new(level_cfg_from_rule(&rule), block_dt_s); + let smoother_alpha = anti_bounce_alpha(rule.smoother_ms, block_dt_s); + let min_write_interval = Duration::from_millis(rule.min_write_interval_ms.max(0.0) as u64); + Self { + rule, + envelopes, + smoothed_reduction_db: 0.0, + smoother_alpha, + min_write_interval, + last_written_lin: 1.0, + last_write_at: None, + user_ceiling_lin: None, + deferred: false, + } + } + + /// Active rule. + #[must_use] + pub fn rule(&self) -> &PerAppRule { + &self.rule + } + + /// Swap in a fresh rule (e.g. after `setting.set per_app...`). + /// Envelope state is preserved across the swap; the smoother and + /// rate-limit cadences pick up the new rule's values immediately. + pub fn set_rule(&mut self, rule: PerAppRule) { + self.envelopes.set_config(level_cfg_from_rule(&rule)); + self.smoother_alpha = anti_bounce_alpha(rule.smoother_ms, self.envelopes.block_dt_s()); + self.min_write_interval = Duration::from_millis(rule.min_write_interval_ms.max(0.0) as u64); + self.rule = rule; + } + + /// Recompute alphas after a PipeWire quantum change. + pub fn set_block_dt(&mut self, dt_s: f32) { + self.envelopes.set_block_dt(dt_s); + self.smoother_alpha = anti_bounce_alpha(self.rule.smoother_ms, dt_s); + } + + /// Currently effective `channelVolumes` ceiling (linear). `None` + /// when no external override is active. + #[must_use] + pub fn user_ceiling_lin(&self) -> Option { + self.user_ceiling_lin + } + + /// Whether the controller is currently in strict-deference mode + /// (stopped issuing writes pending a manual reset). + #[must_use] + pub fn deferred(&self) -> bool { + self.deferred + } + + /// Smoothed reduction in dB. Always `>= 0`; `0` means "no cut." + #[must_use] + pub fn smoothed_reduction_db(&self) -> f32 { + self.smoothed_reduction_db + } + + /// Most recent linear volume value written through Props. `1.0` + /// until the first write. + #[must_use] + pub fn last_written_lin(&self) -> f32 { + self.last_written_lin + } + + /// Snapshot of the per-block envelope state for telemetry. + #[must_use] + pub fn last_decision(&self) -> LevelDecision { + // process_block stores its outputs in the envelope; expose them + // by running a zero-input block on a clone… too expensive. We + // can't borrow the envelope as Decision is by-value. Reconstruct + // synthetically: smoothed_reduction_db is the canonical figure. + LevelDecision { + peak_reduction_db: 0.0, + rms_reduction_db: 0.0, + total_reduction_db: self.smoothed_reduction_db, + } + } + + /// Feed one block of measurements. Returns `Some(new_volume_lin)` + /// if a Props write is warranted right now; `None` if the change + /// is sub-threshold, the controller is rate-limited, or it's + /// strictly deferred. + pub fn process_block( + &mut self, + peak_lin: f32, + mean_sq_lin: f32, + now: Instant, + ) -> Option { + if !self.rule.enabled || self.deferred { + return None; + } + let decision = self.envelopes.process_block(peak_lin, mean_sq_lin); + // Anti-bounce smoother across the two paths' switching. + self.smoothed_reduction_db += + self.smoother_alpha * (decision.total_reduction_db - self.smoothed_reduction_db); + + let mut target_lin = headroom_dsp::util::db_to_lin(-self.smoothed_reduction_db); + // Ceiling-mode deference: never go above the user's value. + if let Some(ceiling) = self.user_ceiling_lin { + if target_lin > ceiling { + target_lin = ceiling; + } + } + target_lin = target_lin.clamp(0.0, 1.0); + + let diff_db = lin_diff_db(target_lin, self.last_written_lin); + if diff_db < self.rule.write_db_threshold { + return None; + } + if let Some(prev) = self.last_write_at { + if now.duration_since(prev) < self.min_write_interval { + return None; + } + } + self.last_written_lin = target_lin; + self.last_write_at = Some(now); + Some(target_lin) + } + + /// Record an externally-initiated `channelVolumes` change. The + /// deference policy decides what happens next: ceiling mode caps + /// our writes at the user's value; strict mode stops adjustment + /// entirely until the operator calls [`Self::reset_deference`]. + pub fn on_external_change(&mut self, new_volume_lin: f32) { + // If the change matches what we just wrote, it's our own + // assertion echoing back through PipeWire — not an external + // change. Ignore. + if (new_volume_lin - self.last_written_lin).abs() < 1e-4 { + return; + } + match self.rule.defer_to_user { + DeferPolicy::Ceiling => { + self.user_ceiling_lin = Some(new_volume_lin.clamp(0.0, 1.0)); + } + DeferPolicy::Strict => { + self.deferred = true; + } + } + } + + /// Clear any deference state and resume normal control. Triggered + /// by `headroom per-app reset ` (PLAN §4.4) or by an + /// explicit `route.stream`-style override. + pub fn reset_deference(&mut self) { + self.user_ceiling_lin = None; + self.deferred = false; + } +} + +/// Decide whether a stream should get a Layer A controller, and with +/// what rule. Returns: +/// +/// - `None` when Layer A is disabled globally (`per_app.enabled` = +/// false) or the stream isn't a routable playback stream. +/// - `Some(rule)` for the first matching `[[per_app.rules]]` entry, +/// provided that rule's own `enabled` is true. +/// - For unmatched streams: `Some(synthetic_default)` when +/// `per_app.default_enabled` is true, else `None`. +/// +/// `routing::evaluate` is the sibling for the bus-routing decision; +/// the two are orthogonal (PLAN §2 "the four end-to-end paths"). +#[must_use] +pub fn evaluate(info: &PwNodeInfo, per_app: &PerAppSection) -> Option { + if !per_app.enabled { + return None; + } + if !info.is_routable_playback() { + return None; + } + for rule in &per_app.rules { + if routing::matches(info, &rule.match_) { + return rule.enabled.then(|| rule.clone()); + } + } + if per_app.default_enabled { + return Some(default_rule()); + } + None +} + +fn default_rule() -> PerAppRule { + let cfg = LevelEnvelopesConfig::default(); + PerAppRule { + match_: headroom_ipc::RouteRuleMatch::default(), + enabled: true, + peak_threshold_db: cfg.peak_threshold_db, + rms_target_db: cfg.rms_target_db, + max_cut_db: cfg.max_cut_db, + peak_attack_ms: cfg.peak_attack_ms, + peak_release_ms: cfg.peak_release_ms, + rms_window_ms: cfg.rms_window_ms, + smoother_ms: FALLBACK_SMOOTHER_MS, + write_db_threshold: FALLBACK_WRITE_DB_THRESHOLD, + min_write_interval_ms: FALLBACK_MIN_WRITE_INTERVAL_MS, + defer_to_user: DeferPolicy::default(), + } +} + +fn level_cfg_from_rule(rule: &PerAppRule) -> LevelEnvelopesConfig { + LevelEnvelopesConfig { + peak_threshold_db: rule.peak_threshold_db, + rms_target_db: rule.rms_target_db, + max_cut_db: rule.max_cut_db, + peak_attack_ms: rule.peak_attack_ms, + peak_release_ms: rule.peak_release_ms, + rms_window_ms: rule.rms_window_ms, + } +} + +fn anti_bounce_alpha(time_ms: f32, block_dt_s: f32) -> f32 { + if block_dt_s <= 0.0 || time_ms <= 0.0 { + return 1.0; + } + let block_rate = 1.0 / block_dt_s; + headroom_dsp::util::time_to_alpha(time_ms, block_rate) +} + +fn lin_diff_db(a: f32, b: f32) -> f32 { + let a = a.max(1e-6); + let b = b.max(1e-6); + (20.0 * (a / b).log10()).abs() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::PerAppRule; + use headroom_dsp::util::db_to_lin; + use headroom_ipc::RouteRuleMatch; + + /// 1024-frame quantum @ 48 kHz. + const BLOCK_DT_S: f32 = 1024.0 / 48_000.0; + + fn aggressive_rule() -> PerAppRule { + PerAppRule { + match_: RouteRuleMatch::default(), + enabled: true, + peak_threshold_db: -6.0, + rms_target_db: -20.0, + max_cut_db: 12.0, + peak_attack_ms: 5.0, + peak_release_ms: 500.0, + rms_window_ms: 200.0, // shorter so tests converge + smoother_ms: FALLBACK_SMOOTHER_MS, + write_db_threshold: FALLBACK_WRITE_DB_THRESHOLD, + min_write_interval_ms: FALLBACK_MIN_WRITE_INTERVAL_MS, + defer_to_user: DeferPolicy::Ceiling, + } + } + + fn playback_info(binary: &str) -> PwNodeInfo { + PwNodeInfo { + node_id: 1, + media_class: Some("Stream/Output/Audio".into()), + application_process_binary: Some(binary.into()), + ..Default::default() + } + } + + #[test] + fn disabled_rule_returns_no_write() { + let mut rule = aggressive_rule(); + rule.enabled = false; + let mut c = AppLevelController::new(rule, BLOCK_DT_S); + let now = Instant::now(); + assert!(c.process_block(db_to_lin(0.0), 1.0, now).is_none()); + } + + #[test] + fn first_write_after_settling_emits_volume_below_unity() { + let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + let now = Instant::now(); + // Drive a hot signal until the envelopes settle and the + // anti-bounce smoother converges. + let mut last = None; + for i in 0..1000 { + let t = now + Duration::from_millis(i as u64 * 21); // ~block_dt + if let Some(v) = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t) { + last = Some(v); + } + } + let v = last.expect("controller should issue at least one write"); + assert!(v < 1.0, "expected sub-unity volume, got {v}"); + assert!(v > 0.0); + } + + #[test] + fn rate_limit_blocks_back_to_back_writes() { + let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + let t0 = Instant::now(); + // Drive convergence first so a write happens. + let mut wrote = false; + for i in 0..200 { + let t = t0 + Duration::from_millis(i as u64 * 21); + if c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t).is_some() { + wrote = true; + break; + } + } + assert!(wrote, "first write expected during convergence"); + // Immediately after the write, force a different reduction — + // the rate limit must suppress any further write within 100 ms. + let t1 = c.last_write_at.unwrap() + Duration::from_millis(10); + c.smoothed_reduction_db = c.smoothed_reduction_db + 6.0; // synthetic kick + let v = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t1); + assert!(v.is_none(), "rate limit should have blocked the follow-up write"); + } + + #[test] + fn threshold_blocks_microscopic_changes() { + // Strategy: drive the controller to a steady state at a + // specific reduction, let it write, then nudge inputs by an + // amount that produces a sub-`WRITE_DB_THRESHOLD` change at + // the smoothed output. The threshold gate must suppress. + let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + let t0 = Instant::now(); + // 0 dBFS peak → 6 dB cut requested by the peak path. + let hot_peak = db_to_lin(0.0); + let hot_mean_sq = db_to_lin(-3.0).powi(2); + + // Burn in until convergence. + let mut last_write_t = t0; + for i in 0..2_000 { + let t = t0 + Duration::from_millis(i as u64 * 21); + if c.process_block(hot_peak, hot_mean_sq, t).is_some() { + last_write_t = t; + } + } + // Move past the rate limit window so the threshold is the only + // active gate, then feed an essentially-identical input. The + // smoothed reduction barely budges, so the dB diff against + // last_written_lin must stay under WRITE_DB_THRESHOLD. + let t_after = last_write_t + Duration::from_millis(500); + let v = c.process_block(hot_peak * 1.001, hot_mean_sq * 1.001, t_after); + assert!( + v.is_none(), + "near-identical input should fall inside the threshold dead band, got {v:?}" + ); + } + + #[test] + fn ceiling_mode_caps_target_at_user_value() { + let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + // User pulls the slider down to 0.6 externally. + c.on_external_change(0.6); + assert_eq!(c.user_ceiling_lin(), Some(0.6)); + let mut last = None; + let t0 = Instant::now(); + // No signal yet — proposed reduction is 0 → target is unity → + // but ceiling forces it down to 0.6 → expect a write below + // unity even with no detection activity. + for i in 0..400 { + let t = t0 + Duration::from_millis(i as u64 * 21); + if let Some(v) = c.process_block(0.0, 0.0, t) { + last = Some(v); + } + } + let v = last.expect("should write at least once to reach ceiling"); + assert!((v - 0.6).abs() < 0.01, "expected ~0.6, got {v}"); + } + + #[test] + fn strict_mode_stops_writes_after_external_change() { + let mut rule = aggressive_rule(); + rule.defer_to_user = DeferPolicy::Strict; + let mut c = AppLevelController::new(rule, BLOCK_DT_S); + c.on_external_change(0.7); + assert!(c.deferred()); + let t = Instant::now(); + // Drive a hot signal — strict deference must not write. + for i in 0..400 { + let t = t + Duration::from_millis(i as u64 * 21); + assert!(c + .process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t) + .is_none()); + } + } + + #[test] + fn reset_deference_clears_strict_lock() { + let mut rule = aggressive_rule(); + rule.defer_to_user = DeferPolicy::Strict; + let mut c = AppLevelController::new(rule, BLOCK_DT_S); + c.on_external_change(0.7); + assert!(c.deferred()); + c.reset_deference(); + assert!(!c.deferred()); + assert!(c.user_ceiling_lin().is_none()); + } + + #[test] + fn ignores_external_change_that_matches_our_write() { + let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S); + c.last_written_lin = 0.5; + c.on_external_change(0.5); + // Should not register as external — no ceiling, no defer. + assert!(c.user_ceiling_lin().is_none()); + assert!(!c.deferred()); + } + + // ----------------------------------------------------------------- + // Rule matching + // ----------------------------------------------------------------- + + #[test] + fn evaluate_returns_none_when_layer_a_master_off() { + let per_app = PerAppSection { + enabled: false, + ..Default::default() + }; + assert!(evaluate(&playback_info("firefox"), &per_app).is_none()); + } + + #[test] + fn evaluate_returns_matching_rule() { + let per_app = PerAppSection { + enabled: true, + default_enabled: false, + rules: vec![PerAppRule { + match_: RouteRuleMatch { + process_binary: vec!["firefox".into()], + ..Default::default() + }, + ..aggressive_rule() + }], + }; + let r = evaluate(&playback_info("firefox"), &per_app).expect("match"); + assert_eq!(r.peak_threshold_db, aggressive_rule().peak_threshold_db); + } + + #[test] + fn evaluate_returns_none_for_disabled_matching_rule() { + let per_app = PerAppSection { + enabled: true, + default_enabled: false, + rules: vec![PerAppRule { + match_: RouteRuleMatch { + process_binary: vec!["spotify".into()], + ..Default::default() + }, + enabled: false, + ..aggressive_rule() + }], + }; + assert!(evaluate(&playback_info("spotify"), &per_app).is_none()); + } + + #[test] + fn evaluate_returns_default_rule_when_default_enabled_and_no_match() { + let per_app = PerAppSection { + enabled: true, + default_enabled: true, + rules: vec![], + }; + let r = evaluate(&playback_info("unmatched"), &per_app).expect("default"); + // Default rule honours LevelEnvelopesConfig::default(). + let cfg = LevelEnvelopesConfig::default(); + assert!((r.peak_threshold_db - cfg.peak_threshold_db).abs() < 1e-6); + assert_eq!(r.defer_to_user, DeferPolicy::default()); + } + + #[test] + fn evaluate_returns_none_for_unmatched_when_default_off() { + let per_app = PerAppSection { + enabled: true, + default_enabled: false, + rules: vec![], + }; + assert!(evaluate(&playback_info("unmatched"), &per_app).is_none()); + } + + #[test] + fn evaluate_skips_non_playback_streams() { + let mut info = playback_info("firefox"); + info.media_class = Some("Stream/Input/Audio".into()); + let per_app = PerAppSection { + enabled: true, + default_enabled: true, + rules: vec![], + }; + assert!(evaluate(&info, &per_app).is_none()); + } +} diff --git a/crates/headroom-core/src/ipc/mod.rs b/crates/headroom-core/src/ipc/mod.rs index aee1c45..c586d71 100644 --- a/crates/headroom-core/src/ipc/mod.rs +++ b/crates/headroom-core/src/ipc/mod.rs @@ -19,3 +19,8 @@ mod ops; mod server; pub use server::{IpcServer, IpcServerHandle}; + +/// Shared reload helper — see `ops::execute_reload`. Re-exported so +/// the profile file-watcher can reuse the same publish-events + +/// DSP-push path as the IPC `profile.reload` op. +pub(crate) use ops::execute_reload; diff --git a/crates/headroom-core/src/ipc/ops.rs b/crates/headroom-core/src/ipc/ops.rs index 0ae56ba..4e1974a 100644 --- a/crates/headroom-core/src/ipc/ops.rs +++ b/crates/headroom-core/src/ipc/ops.rs @@ -1,18 +1,22 @@ //! Op dispatch + handlers. //! //! Each handler takes the request id and a `&SharedState`, locks the -//! state briefly, and returns a [`Response`]. Phase 4b implements the -//! read-only set; 4c fills in mutating ops; 4d adds subscriptions. +//! state briefly, and returns a [`Response`]. Phase 4b implemented the +//! read-only set; 4c added mutating ops on top of in-memory profile +//! state; 4e routes all mutations through [`ProfileStore`] so disk +//! profiles, the user overlay, and atomic reload work end-to-end. use serde::Serialize; use serde_json::{json, Value}; use headroom_ipc::{ - ErrorCode, Event, Op, ProfileInfo, ProtoError, Request, Response, Route, RouteList, RouteRule, - RouteRuleMatch, SinkInfo, Sinks, Status, StreamRoute, Topic, PROTOCOL_VERSION, + ErrorCode, Event, Op, ProfileInfo, ProtoError, Request, Response, Route, RouteList, SinkInfo, + Sinks, Status, StreamRoute, Topic, PROTOCOL_VERSION, }; -use crate::profile::Profile; +use crate::profile_store::StoreError; +use crate::pw::command::PwCommand; +use crate::pw::filter::FilterControl; use crate::state::SharedState; const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -24,11 +28,11 @@ pub fn dispatch(req: &Request, state: &SharedState) -> Response { Op::ProfileList => profile_list(req.id, state), Op::ProfileShow { name } => profile_show(req.id, name.as_deref(), state), Op::ProfileUse { name } => profile_use(req.id, name, state), - Op::ProfileReload => profile_reload(req.id), + Op::ProfileReload => profile_reload(req.id, state), Op::RouteList => route_list(req.id, state), Op::RouteSet { app, to } => route_set(req.id, app, *to, state), Op::RouteUnset { app } => route_unset(req.id, app, state), - Op::RouteStream { .. } => not_yet(req, "Phase 4i"), + Op::RouteStream { node_id, to } => route_stream(req.id, *node_id, *to, state), Op::SettingGet { key } => setting_get(req.id, key, state), Op::SettingSet { key, value } => setting_set(req.id, key, value.clone(), state), Op::SettingList => setting_list(req.id, state), @@ -50,12 +54,13 @@ pub fn dispatch(req: &Request, state: &SharedState) -> Response { fn status(id: u64, state: &SharedState) -> Response { let s = state.lock(); + let effective = s.profiles.effective(); let snapshot = Status { version: DAEMON_VERSION.into(), protocol: PROTOCOL_VERSION, uptime_s: s.started_at.elapsed().as_secs(), - profile: s.profile.name.clone(), - bypass: s.bypass_global, + profile: effective.name.clone(), + bypass: s.profiles.bypass_global(), sinks: Sinks { processed: SinkInfo { node_id: s.processed_sink_id, @@ -73,40 +78,48 @@ fn status(id: u64, state: &SharedState) -> Response { route: r.route, }) .collect(), + warnings: s.profiles.warnings(), }; ok(id, &snapshot) } fn profile_list(id: u64, state: &SharedState) -> Response { let s = state.lock(); - // 4b: only the active profile is known. Phase 4e loads files from - // disk and surfaces the full list. - let profiles = vec![ProfileInfo { - name: s.profile.name.clone(), - active: true, - description: s.profile.description.clone(), - }]; + let active = s.profiles.effective().name.clone(); + let profiles: Vec = s + .profiles + .list() + .map(|sp| ProfileInfo { + name: sp.name.clone(), + active: sp.name == active, + description: sp.profile.description.clone(), + }) + .collect(); ok(id, &json!({ "profiles": profiles })) } fn profile_show(id: u64, name: Option<&str>, state: &SharedState) -> Response { let s = state.lock(); - if let Some(requested) = name { - if requested != s.profile.name { - return err( + let effective = s.profiles.effective(); + match name { + None => ok(id, effective), + Some(requested) if requested == effective.name => ok(id, effective), + Some(requested) => match s.profiles.list().find(|sp| sp.name == requested) { + Some(found) => ok(id, &found.profile), + None => err( id, ErrorCode::NotFound, - format!("profile '{requested}' not loaded (Phase 4e adds disk profiles)"), - ); - } + format!("profile '{requested}' not loaded"), + ), + }, } - ok(id, &s.profile) } fn route_list(id: u64, state: &SharedState) -> Response { let s = state.lock(); + let effective = s.profiles.effective(); let body = RouteList { - rules: s.profile.rules.clone(), + rules: effective.rules.clone(), current: s .streams .values() @@ -116,14 +129,14 @@ fn route_list(id: u64, state: &SharedState) -> Response { route: r.route, }) .collect(), - default_route: s.profile.default_route.route, + default_route: effective.default_route.route, }; ok(id, &body) } fn setting_get(id: u64, key: &str, state: &SharedState) -> Response { let s = state.lock(); - let json_value = match serde_json::to_value(&s.profile) { + let json_value = match serde_json::to_value(s.profiles.effective()) { Ok(v) => v, Err(e) => { return err( @@ -147,7 +160,7 @@ fn setting_get(id: u64, key: &str, state: &SharedState) -> Response { fn setting_list(id: u64, state: &SharedState) -> Response { let s = state.lock(); - let json_value = match serde_json::to_value(&s.profile) { + let json_value = match serde_json::to_value(s.profiles.effective()) { Ok(v) => v, Err(e) => { return err( @@ -169,63 +182,87 @@ fn setting_list(id: u64, state: &SharedState) -> Response { // --------------------------------------------------------------------------- fn profile_use(id: u64, name: &str, state: &SharedState) -> Response { - let s = state.lock(); - if name == s.profile.name { - // Already active — succeed idempotently. + let mut s = state.lock(); + if name == s.profiles.effective().name { let body = json!({ "name": name }); - drop(s); return ok(id, &body); } - err( - id, - ErrorCode::NotFound, - format!("profile '{name}' not loaded (disk profiles arrive in Phase 4e)"), - ) + match s.profiles.use_profile(name) { + Ok(()) => { + tracing::info!(name, "profile.use applied"); + publish_profile_changed(&mut s, name); + let control = s.filter_control.clone(); + let snap = build_dsp_configs(&s); + drop(s); + push_dsp_update(control.as_ref(), snap); + ok(id, &json!({ "name": name })) + } + Err(e) => store_err_to_response(id, e), + } } -fn profile_reload(id: u64) -> Response { - // No-op in 4c; 4e implements the on-disk loader. - let empty: Vec = Vec::new(); - ok(id, &json!({ "reloaded": empty })) +fn profile_reload(id: u64, state: &SharedState) -> Response { + match execute_reload(state) { + Ok(report) => ok( + id, + &json!({ "reloaded": report.loaded, "warnings": report.warnings }), + ), + Err(e) => store_err_to_response(id, e), + } +} + +/// Shared reload path: scans disk, publishes events, propagates the +/// fresh DSP configs to the running filter. Used by both +/// [`Op::ProfileReload`] (IPC-initiated) and the file-watcher +/// (`crate::profile_watcher`). +/// +/// # Errors +/// Fatal disk I/O surfaced from [`ProfileStore::reload`]. +pub(crate) fn execute_reload( + state: &SharedState, +) -> Result { + let mut s = state.lock(); + let report = s.profiles.reload()?; + tracing::info!( + loaded = report.loaded.len(), + warnings = report.warnings.len(), + "profile reload applied" + ); + for w in &report.warnings { + tracing::warn!(warning = %w, "profile reload warning"); + } + publish_profile_reloaded(&mut s, &report.loaded); + let control = s.filter_control.clone(); + let snap = build_dsp_configs(&s); + drop(s); + push_dsp_update(control.as_ref(), snap); + Ok(report) } fn route_set(id: u64, app: &str, to: Route, state: &SharedState) -> Response { let mut s = state.lock(); - // Strip any existing single-app user rule for this app (so - // repeated route.set on the same app updates rather than stacks). - s.profile.rules.retain(|r| !is_user_rule_for(r, app)); - // Insert at top so it overrides shipped multi-app rules. - s.profile.rules.insert( - 0, - RouteRule { - match_: RouteRuleMatch { - process_binary: vec![app.to_owned()], - ..Default::default() - }, - route: to, - }, - ); - tracing::info!(app, ?to, "route.set applied"); - publish_rule_changed(&mut s); - drop(s); - ok(id, &Value::Null) + match s.profiles.set_route(app, to) { + Ok(()) => { + tracing::info!(app, ?to, "route.set applied"); + publish_rule_changed(&mut s); + drop(s); + ok(id, &Value::Null) + } + Err(e) => store_err_to_response(id, e), + } } fn route_unset(id: u64, app: &str, state: &SharedState) -> Response { let mut s = state.lock(); - let before = s.profile.rules.len(); - s.profile.rules.retain(|r| !is_user_rule_for(r, app)); - if s.profile.rules.len() == before { - return err( - id, - ErrorCode::NotFound, - format!("no user-set route for '{app}' (shipped rules aren't removable)"), - ); + match s.profiles.unset_route(app) { + Ok(()) => { + tracing::info!(app, "route.unset applied"); + publish_rule_changed(&mut s); + drop(s); + ok(id, &Value::Null) + } + Err(e) => store_err_to_response(id, e), } - tracing::info!(app, "route.unset applied"); - publish_rule_changed(&mut s); - drop(s); - ok(id, &Value::Null) } fn publish_rule_changed(state: &mut crate::state::DaemonState) { @@ -234,81 +271,147 @@ fn publish_rule_changed(state: &mut crate::state::DaemonState) { } } +fn publish_profile_changed(state: &mut crate::state::DaemonState, name: &str) { + if let Ok(event) = Event::new(Topic::Profile, "used", &json!({ "name": name })) { + state.broadcaster.publish(Topic::Profile, event); + } +} + +fn publish_profile_reloaded(state: &mut crate::state::DaemonState, loaded: &[String]) { + if let Ok(event) = Event::new(Topic::Profile, "reloaded", &json!({ "loaded": loaded })) { + state.broadcaster.publish(Topic::Profile, event); + } +} + fn setting_set(id: u64, key: &str, value: Value, state: &SharedState) -> Response { let mut s = state.lock(); + match s.profiles.set_setting(key, value) { + Ok(()) => { + tracing::info!(key, "setting.set applied"); + let control = s.filter_control.clone(); + let snap = build_dsp_configs(&s); + drop(s); + push_dsp_update(control.as_ref(), snap); + ok(id, &Value::Null) + } + Err(e) => store_err_to_response(id, e), + } +} - // Serialise → mutate → deserialise. Round-tripping through - // `serde_json::Value` keeps us schema-aware without hand-coding a - // setter for every dotted key. - let mut json_value = match serde_json::to_value(&s.profile) { - Ok(v) => v, - Err(e) => return err(id, ErrorCode::Internal, format!("serialise profile: {e}")), - }; - if !set_dotted(&mut json_value, key, value) { +fn route_stream(id: u64, node_id: u32, to: Route, state: &SharedState) -> Response { + let mut s = state.lock(); + let Some(stream) = s.streams.get_mut(&node_id) else { return err( id, ErrorCode::NotFound, - format!("setting '{key}' not found in active profile"), + format!("no stream with node_id {node_id} is currently routed by the daemon"), + ); + }; + let app_label = stream.app.clone(); + let prior = stream.route; + stream.route = to; + // Record the new route synchronously so subsequent `status` / + // `route.list` reflect it immediately. The actual metadata write + // is async — it happens on the PipeWire main-loop thread when + // it drains the command channel (≤ ~50 ms). + let event = Event::new( + Topic::Routing, + "stream_routed", + &json!({ "node_id": node_id, "app": app_label, "to": to.as_str() }), + ); + if let Ok(event) = event { + s.broadcaster.publish(Topic::Routing, event); + } + let tx = s.pw_command_tx.clone(); + drop(s); + if let Some(tx) = tx { + if tx + .send(PwCommand::RouteStream { + node_id, + to, + app_label: app_label.clone(), + }) + .is_err() + { + tracing::warn!(node_id, "PipeWire command channel closed; metadata write skipped"); + } + } else { + tracing::debug!( + node_id, + "no PipeWire command channel; state updated but no metadata write (test mode)" ); } - let new_profile: Profile = match serde_json::from_value(json_value) { - Ok(p) => p, - Err(e) => { - return err( - id, - ErrorCode::InvalidArgs, - format!("value for '{key}' rejected: {e}"), - ); - } - }; - s.profile = new_profile; - tracing::info!(key, "setting.set applied (DSP propagation lands in 4f)"); - drop(s); + tracing::info!( + node_id, + app = app_label.as_str(), + ?prior, + new = ?to, + "route.stream applied" + ); ok(id, &Value::Null) } fn bypass_set(id: u64, enabled: bool, state: &SharedState) -> Response { - state.lock().bypass_global = enabled; - tracing::info!(enabled, "bypass.set applied"); - ok(id, &Value::Null) + let mut s = state.lock(); + match s.profiles.set_bypass(enabled) { + Ok(()) => { + tracing::info!(enabled, "bypass.set applied"); + drop(s); + ok(id, &Value::Null) + } + Err(e) => store_err_to_response(id, e), + } +} + +/// Snapshot of the profile-driven DSP configs, ready to push at the +/// running filter. Built while the daemon lock is held; the actual +/// command push happens after the lock is dropped so the audio-thread +/// hand-off never contends with the daemon mutex. +struct DspSnapshot { + compressor: headroom_dsp::CompressorConfig, + limiter: headroom_dsp::LimiterConfig, + agc_enabled: bool, +} + +fn build_dsp_configs(state: &crate::state::DaemonState) -> DspSnapshot { + let effective = state.profiles.effective(); + DspSnapshot { + compressor: effective.build_compressor_config(), + limiter: effective.build_limiter_config(), + agc_enabled: effective.agc.enabled, + } +} + +/// Push compressor + limiter configs + AGC enable flag into the +/// filter command ring, if the filter is up. The AGC *target_db* +/// keeps coming from the slow AGC controller's own ticks — `setting.set` +/// only flips the enable flag so the audio thread can unwind/restart +/// the smoother promptly. No-op when running headless (tests, +/// pre-PipeWire startup). +fn push_dsp_update(control: Option<&FilterControl>, snap: DspSnapshot) { + let Some(c) = control else { return }; + c.set_compressor(snap.compressor); + c.set_limiter(snap.limiter); + c.set_agc_enabled(snap.agc_enabled); +} + +fn store_err_to_response(id: u64, e: StoreError) -> Response { + let code = match &e { + StoreError::ProfileNotFound(_) + | StoreError::SettingNotFound(_) + | StoreError::NoUserRoute(_) => ErrorCode::NotFound, + StoreError::SettingInvalid { .. } => ErrorCode::InvalidArgs, + StoreError::Io(_) + | StoreError::OverlayParse(_) + | StoreError::OverlaySerialize(_) => ErrorCode::Internal, + }; + err(id, code, e.to_string()) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -fn is_user_rule_for(rule: &RouteRule, app: &str) -> bool { - // User-set rules created by route.set always have exactly one - // app in `process_binary` and all other matcher fields empty. - rule.match_.process_binary.len() == 1 - && rule.match_.process_binary[0] == app - && rule.match_.application_name.is_empty() - && rule.match_.portal_app_id.is_empty() - && rule.match_.media_role.is_empty() -} - -fn set_dotted(value: &mut Value, key: &str, new: Value) -> bool { - let parts: Vec<&str> = key.split('.').collect(); - let Some((last, parents)) = parts.split_last() else { - return false; - }; - let mut cur = value; - for part in parents { - cur = match cur.get_mut(*part) { - Some(v) => v, - None => return false, - }; - } - let Some(map) = cur.as_object_mut() else { - return false; - }; - if !map.contains_key(*last) { - return false; - } - map.insert((*last).to_string(), new); - true -} - fn lookup_dotted<'v>(value: &'v Value, key: &str) -> Option<&'v Value> { let mut cur = value; for part in key.split('.') { @@ -373,12 +476,12 @@ fn not_yet(req: &Request, phase: &str) -> Response { #[cfg(test)] mod tests { use super::*; - use crate::profile::Profile; + use crate::profile_store::ProfileStore; use crate::state::{self, RoutedStream}; use headroom_ipc::{Op, Request, ResponsePayload, Route}; fn shared_with_default_profile() -> SharedState { - state::shared(crate::state::DaemonState::new(Profile::default_v0())) + state::shared(crate::state::DaemonState::new(ProfileStore::builtin())) } fn extract_ok(resp: Response) -> Value { @@ -398,6 +501,52 @@ mod tests { assert_eq!(body["bypass"], false); assert_eq!(body["protocol"], PROTOCOL_VERSION); assert!(body["streams"].as_array().unwrap().is_empty()); + // Builtin store with no overlay → no warnings. + assert!( + body.get("warnings") + .and_then(|w| w.as_array()) + .map_or(true, |a| a.is_empty()), + "expected empty/absent warnings on healthy startup" + ); + } + + #[test] + fn status_surfaces_store_warnings() { + use crate::profile_store::ProfileStore; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + // Build a load-from-disk store with a broken TOML so a warning + // is recorded, then point Status at it. + let base = std::env::temp_dir().join(format!( + "headroom-warntest-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + fs::create_dir_all(base.join("config/profiles")).unwrap(); + fs::create_dir_all(base.join("state")).unwrap(); + fs::write( + base.join("config/profiles/broken.toml"), + "this is not = valid", + ) + .unwrap(); + let paths = crate::profile_store::StorePaths { + config_dir: base.join("config"), + state_dir: base.join("state"), + share_dirs: vec![], + }; + let store = ProfileStore::load(&paths).unwrap(); + let state = state::shared(crate::state::DaemonState::new(store)); + + let resp = dispatch(&Request::new(1, Op::Status), &state); + let body = extract_ok(resp); + let warnings = body["warnings"].as_array().expect("warnings field"); + assert!( + warnings.iter().any(|w| w.as_str().unwrap_or("").contains("broken.toml")), + "expected warning mentioning broken.toml, got {warnings:?}" + ); + + let _ = fs::remove_dir_all(&base); } #[test] @@ -519,19 +668,19 @@ mod tests { #[test] fn bypass_set_toggles_flag() { let state = shared_with_default_profile(); - assert!(!state.lock().bypass_global); + assert!(!state.lock().profiles.bypass_global()); dispatch( &Request::new(1, Op::BypassSet { enabled: true }), &state, ); - assert!(state.lock().bypass_global); + assert!(state.lock().profiles.bypass_global()); dispatch( &Request::new(2, Op::BypassSet { enabled: false }), &state, ); - assert!(!state.lock().bypass_global); + assert!(!state.lock().profiles.bypass_global()); } #[test] @@ -547,7 +696,8 @@ mod tests { ), &state, ); - let rules = &state.lock().profile.rules; + let s = state.lock(); + let rules = &s.profiles.effective().rules; // First rule is now the user-set one. assert_eq!(rules[0].match_.process_binary, vec!["obs".to_string()]); assert_eq!(rules[0].route, Route::Bypass); @@ -578,7 +728,8 @@ mod tests { ), &state, ); - let rules = &state.lock().profile.rules; + let s = state.lock(); + let rules = &s.profiles.effective().rules; let user_rules: Vec<_> = rules .iter() .filter(|r| { @@ -611,9 +762,10 @@ mod tests { ), &state, ); - let still_there = state - .lock() - .profile + let s = state.lock(); + let still_there = s + .profiles + .effective() .rules .iter() .any(|r| r.match_.process_binary.len() == 1 && r.match_.process_binary[0] == "obs"); @@ -657,9 +809,10 @@ mod tests { ResponsePayload::Ok { .. } => panic!("expected NotFound"), } // And firefox is still in the rules (via the shipped rule). - let still_firefox = state - .lock() - .profile + let s = state.lock(); + let still_firefox = s + .profiles + .effective() .rules .iter() .any(|r| r.match_.process_binary.iter().any(|p| p == "firefox")); @@ -679,7 +832,7 @@ mod tests { ), &state, ); - let v = state.lock().profile.limiter.ceiling_dbtp; + let v = state.lock().profiles.effective().limiter.ceiling_dbtp; assert!((v - -1.0).abs() < 1e-6); } @@ -701,7 +854,7 @@ mod tests { ResponsePayload::Ok { .. } => panic!("expected InvalidArgs"), } // Profile unchanged. - assert!((state.lock().profile.limiter.ceiling_dbtp - -0.1).abs() < 1e-6); + assert!((state.lock().profiles.effective().limiter.ceiling_dbtp - -0.1).abs() < 1e-6); } #[test] @@ -740,7 +893,7 @@ mod tests { } #[test] - fn profile_use_other_is_not_found_until_phase_4e() { + fn profile_use_unknown_returns_not_found() { let state = shared_with_default_profile(); let resp = dispatch( &Request::new( @@ -758,17 +911,100 @@ mod tests { } #[test] - fn profile_reload_succeeds_with_empty_list() { + fn profile_reload_built_in_only_returns_default() { + // Built-in stores have no disk paths; reload returns just the + // built-in default and a warning saying there's nothing to scan. let state = shared_with_default_profile(); let resp = dispatch(&Request::new(1, Op::ProfileReload), &state); let body = extract_ok(resp); let reloaded = body["reloaded"].as_array().unwrap(); - assert!(reloaded.is_empty()); + assert_eq!(reloaded.len(), 1); + assert_eq!(reloaded[0], "default"); } #[test] - fn route_stream_still_phase_4i() { + fn setting_set_pushes_dsp_update() { + use crate::pw::filter::{AudioCmd, FilterControl}; let state = shared_with_default_profile(); + let (control, mut consumer) = FilterControl::for_testing(8); + state.lock().filter_control = Some(control); + + dispatch( + &Request::new( + 1, + Op::SettingSet { + key: "limiter.ceiling_dbtp".into(), + value: json!(-1.5), + }, + ), + &state, + ); + + // Expect a compressor cmd and a limiter cmd (we push both for + // simplicity even when only one field changed). + let mut saw_limiter = false; + while let Ok(cmd) = consumer.pop() { + if let AudioCmd::SetLimiter(cfg) = cmd { + assert!((cfg.ceiling_dbtp - -1.5).abs() < 1e-6); + saw_limiter = true; + } + } + assert!(saw_limiter, "setting.set should push a SetLimiter cmd"); + } + + #[test] + fn route_set_does_not_push_dsp_update() { + // Routing changes don't touch DSP. Filter must be left alone. + use crate::pw::filter::FilterControl; + let state = shared_with_default_profile(); + let (control, mut consumer) = FilterControl::for_testing(8); + state.lock().filter_control = Some(control); + + dispatch( + &Request::new( + 1, + Op::RouteSet { + app: "obs".into(), + to: Route::Bypass, + }, + ), + &state, + ); + assert!(consumer.pop().is_err(), "route.set must not push DSP cmds"); + } + + #[test] + fn route_stream_unknown_node_id_returns_not_found() { + let state = shared_with_default_profile(); + let resp = dispatch( + &Request::new( + 1, + Op::RouteStream { + node_id: 9999, + to: Route::Bypass, + }, + ), + &state, + ); + match resp.payload { + ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound), + ResponsePayload::Ok { .. } => panic!("expected NotFound"), + } + } + + #[test] + fn route_stream_updates_state_synchronously() { + let state = shared_with_default_profile(); + // Seed: a known stream currently routed Processed. + state.lock().streams.insert( + 42, + RoutedStream { + node_id: 42, + app: "firefox".into(), + route: Route::Processed, + }, + ); + let resp = dispatch( &Request::new( 1, @@ -779,9 +1015,70 @@ mod tests { ), &state, ); - match resp.payload { - ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::UnknownOp), - ResponsePayload::Ok { .. } => panic!("expected UnknownOp"), - } + assert!(matches!(resp.payload, ResponsePayload::Ok { .. })); + assert_eq!(state.lock().streams[&42].route, Route::Bypass); + } + + #[test] + fn route_stream_pushes_command_when_channel_present() { + use crate::pw::command::PwCommand; + let state = shared_with_default_profile(); + let (tx, rx) = crossbeam_channel::unbounded::(); + state.lock().pw_command_tx = Some(tx); + state.lock().streams.insert( + 42, + RoutedStream { + node_id: 42, + app: "firefox".into(), + route: Route::Processed, + }, + ); + + dispatch( + &Request::new( + 1, + Op::RouteStream { + node_id: 42, + to: Route::Bypass, + }, + ), + &state, + ); + let cmd = rx.try_recv().expect("command should arrive"); + let PwCommand::RouteStream { + node_id, + to, + app_label, + } = cmd; + assert_eq!(node_id, 42); + assert_eq!(to, Route::Bypass); + assert_eq!(app_label, "firefox"); + } + + #[test] + fn route_stream_no_channel_is_still_success() { + // Tests / pre-PipeWire startup: no tx is fine, state still + // updates and the op returns Ok. + let state = shared_with_default_profile(); + state.lock().streams.insert( + 42, + RoutedStream { + node_id: 42, + app: "mpv".into(), + route: Route::Processed, + }, + ); + let resp = dispatch( + &Request::new( + 1, + Op::RouteStream { + node_id: 42, + to: Route::Bypass, + }, + ), + &state, + ); + assert!(matches!(resp.payload, ResponsePayload::Ok { .. })); + assert_eq!(state.lock().streams[&42].route, Route::Bypass); } } diff --git a/crates/headroom-core/src/ipc/server.rs b/crates/headroom-core/src/ipc/server.rs index 8f24377..c987a53 100644 --- a/crates/headroom-core/src/ipc/server.rs +++ b/crates/headroom-core/src/ipc/server.rs @@ -162,9 +162,10 @@ fn accept_loop( #[cfg(test)] mod tests { use super::*; - use crate::profile::Profile; + use crate::profile_store::ProfileStore; use crate::state::{self, DaemonState}; use headroom_client::Client; + use headroom_ipc::Route; use std::process; use std::sync::atomic::AtomicU64; @@ -176,7 +177,7 @@ mod tests { } fn test_state() -> SharedState { - state::shared(DaemonState::new(Profile::default_v0())) + state::shared(DaemonState::new(ProfileStore::builtin())) } #[test] @@ -243,4 +244,85 @@ mod tests { let n = value.as_f64().unwrap(); assert!((n - -0.1).abs() < 1e-6); } + + /// End-to-end through the IPC: load a store with a second profile + /// on disk, switch to it via `profile.use`, and confirm that an + /// overlay tweak made on the original profile carries across. + #[test] + fn client_profile_use_preserves_overlay() { + use crate::profile_store::{ProfileStore, StorePaths}; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + let base = std::env::temp_dir().join(format!( + "headroom-e2e-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _guard = scopeguard_remove(base.clone()); + fs::create_dir_all(base.join("config/profiles")).unwrap(); + fs::create_dir_all(base.join("state")).unwrap(); + fs::write( + base.join("config/profiles/night.toml"), + "name = \"night\"\ndescription = \"loud night\"\n[limiter]\nceiling_dbtp = -2.0\n", + ) + .unwrap(); + let paths = StorePaths { + config_dir: base.join("config"), + state_dir: base.join("state"), + share_dirs: vec![], + }; + let store = ProfileStore::load(&paths).expect("store load"); + let state = state::shared(DaemonState::new(store)); + + let sock = temp_socket_path(); + let _ = std::fs::remove_file(&sock); + let _server = IpcServer::start(sock.clone(), state).expect("server should start"); + + let mut client = Client::connect_at(&sock).expect("client connect"); + + // Apply an overlay tweak while on `default`. + client + .route_set("obs", Route::Bypass) + .expect("route.set obs"); + client + .setting_set("agc.target_lufs", serde_json::json!(-22.0)) + .expect("setting.set agc.target_lufs"); + + // Switch to `night`. + let switched_to = client.profile_use("night").expect("profile.use night"); + assert_eq!(switched_to, "night"); + let status = client.status().unwrap(); + assert_eq!(status.profile, "night"); + + // Overlay survived: route override is still visible in route.list, + // and the setting override still wins over night.toml's value. + let routes = client.route_list().unwrap(); + let user_rule = routes + .rules + .iter() + .find(|r| r.match_.process_binary == vec!["obs".to_string()]) + .expect("obs override carried across profile switch"); + assert_eq!(user_rule.route, Route::Bypass); + + let lufs = client.setting_get("agc.target_lufs").unwrap(); + assert!((lufs.as_f64().unwrap() - -22.0).abs() < 1e-6); + + // night.toml's limiter ceiling shows through where there's no override. + let ceiling = client.setting_get("limiter.ceiling_dbtp").unwrap(); + assert!((ceiling.as_f64().unwrap() - -2.0).abs() < 1e-6); + } + + fn scopeguard_remove(path: PathBuf) -> impl Drop { + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + Cleanup(path) + } } diff --git a/crates/headroom-core/src/lib.rs b/crates/headroom-core/src/lib.rs index 82471ba..112854e 100644 --- a/crates/headroom-core/src/lib.rs +++ b/crates/headroom-core/src/lib.rs @@ -13,9 +13,13 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod agc; +pub mod app_level; pub mod error; pub mod ipc; pub mod profile; +pub mod profile_store; +pub mod profile_watcher; pub mod pw; pub mod routing; pub mod runtime; @@ -23,13 +27,19 @@ pub mod state; pub use error::DaemonError; pub use profile::Profile; +pub use profile_store::{ProfileStore, StorePaths, StoreError, UserOverlay}; /// Run the daemon to completion. /// /// Blocks until the daemon shuts down (SIGTERM/SIGINT) or fails fatally. +/// Profiles and overlay are loaded from XDG-spec paths (see +/// [`StorePaths::from_env`]). /// /// # Errors /// Returns `Err` if startup or runtime processing fails. pub fn run() -> Result<(), DaemonError> { - runtime::run(Profile::default_v0()) + let paths = StorePaths::from_env(); + let store = ProfileStore::load(&paths) + .map_err(|e| DaemonError::Profile(format!("loading profiles: {e}")))?; + runtime::run(store) } diff --git a/crates/headroom-core/src/profile.rs b/crates/headroom-core/src/profile.rs index c4e67da..d20a00f 100644 --- a/crates/headroom-core/src/profile.rs +++ b/crates/headroom-core/src/profile.rs @@ -388,6 +388,23 @@ pub struct PerAppRule { /// RMS window length (ms). #[serde(default = "default_rms_window_ms")] pub rms_window_ms: f32, + /// Anti-bounce smoother time constant (ms) applied to the + /// post-combine reduction. Damps switching between the peak path + /// and the RMS path winning. Larger = smoother but slower to + /// respond; smaller = quicker but jitterier writes. Default 30 ms. + #[serde(default = "default_smoother_ms")] + pub smoother_ms: f32, + /// Minimum dB change between writes. Below this, the controller + /// keeps the smoothed envelope updated internally but doesn't + /// fire a fresh `Props.channelVolumes` write. Larger = quieter + /// CLI logs and less PipeWire chatter, at the cost of coarser + /// granularity. Default 0.5 dB. + #[serde(default = "default_write_db_threshold")] + pub write_db_threshold: f32, + /// Minimum interval between writes (ms). Hard rate limit per + /// stream. Default 100 ms (10 Hz cap). + #[serde(default = "default_min_write_interval_ms")] + pub min_write_interval_ms: f32, /// Policy when the user adjusts the stream's volume externally. #[serde(default)] pub defer_to_user: DeferPolicy, @@ -414,6 +431,15 @@ const fn default_peak_release_ms() -> f32 { const fn default_rms_window_ms() -> f32 { 1500.0 } +const fn default_smoother_ms() -> f32 { + 30.0 +} +const fn default_write_db_threshold() -> f32 { + 0.5 +} +const fn default_min_write_interval_ms() -> f32 { + 100.0 +} /// Policy for handling user-initiated volume changes on a stream /// Headroom is managing. diff --git a/crates/headroom-core/src/profile_store.rs b/crates/headroom-core/src/profile_store.rs new file mode 100644 index 0000000..df3c509 --- /dev/null +++ b/crates/headroom-core/src/profile_store.rs @@ -0,0 +1,1084 @@ +//! The profile store. +//! +//! Sits between the on-disk world (shipped + user-authored TOML +//! profiles, persisted user overlay) and the in-memory `Profile` the +//! routing engine and DSP read from. +//! +//! The split: +//! +//! - **Profiles** are immutable at runtime. A built-in default is always +//! present; shipped profiles live in `/usr/share/headroom/profiles/`, +//! user profiles in `$XDG_CONFIG_HOME/headroom/profiles/`. User +//! profiles shadow shipped ones on name conflict. +//! - **Overlay** is daemon-managed runtime state: which profile is +//! active, per-app route choices made by `route.set`, dotted-key +//! tweaks made by `setting.set`, and the global bypass flag. Lives +//! at `$XDG_STATE_HOME/headroom/overlay.toml` — XDG_STATE_HOME is the +//! right home for "data that persists between restarts but isn't +//! user-edited config." +//! - **Effective profile** = active profile + overlay applied. +//! Recomputed on every overlay or profile change; read by the routing +//! engine and (later) the DSP via [`ProfileStore::effective`]. +//! +//! The overlay persists across `profile.use` calls intentionally: +//! `route.set obs bypass` should mean "I prefer obs bypassed in +//! general," not "I prefer obs bypassed only while `default` is +//! active." + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use headroom_ipc::{Route, RouteRule, RouteRuleMatch}; + +use crate::profile::Profile; + +/// Built-in fallback profile name. Always available, always loadable. +pub const BUILTIN_DEFAULT_NAME: &str = "default"; + +const OVERLAY_FILE: &str = "overlay.toml"; +const PROFILES_DIR: &str = "profiles"; + +/// A profile as found on disk (or built in). +#[derive(Debug, Clone)] +pub struct StoredProfile { + /// Profile name, matching `profile.name` in the TOML. The file + /// name (less `.toml`) is also expected to match; mismatches are + /// surfaced as warnings on load. + pub name: String, + /// Where this profile came from. + pub source: ProfileSource, + /// Parsed profile contents. + pub profile: Profile, +} + +/// Origin of a [`StoredProfile`]. +#[derive(Debug, Clone)] +pub enum ProfileSource { + /// Hard-coded in the binary. Always present under + /// [`BUILTIN_DEFAULT_NAME`]. + Builtin, + /// Shipped TOML from a system-wide share dir. + Shipped(PathBuf), + /// User-authored TOML from `$XDG_CONFIG_HOME/headroom/profiles/`. + User(PathBuf), +} + +impl ProfileSource { + /// `true` if this source can be replaced by a user-authored file. + #[must_use] + pub fn is_overridable(&self) -> bool { + !matches!(self, ProfileSource::User(_)) + } +} + +/// Persisted user choices. Rides on top of whichever profile is +/// active. Serialised to `overlay.toml`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct UserOverlay { + /// Active profile name. `None` means "use the built-in default." + pub active_name: Option, + /// Per-app route choices (`route.set processed|bypass`). + /// Keyed by `application.process.binary`. + pub route_overrides: BTreeMap, + /// Dotted-key setting overrides (`setting.set `). + /// Stored as `toml::Value` because the overlay is TOML on disk; + /// converted to `serde_json::Value` at materialization time. + pub setting_overrides: BTreeMap, + /// Global kill switch (`bypass.set`). Intentionally persisted: a + /// user who set it probably wants it back on next start. + pub bypass_global: bool, +} + +/// Errors the store surfaces to IPC handlers. +#[derive(Debug, Error)] +pub enum StoreError { + /// `profile.use` named a profile the store doesn't know about. + #[error("profile '{0}' not loaded")] + ProfileNotFound(String), + /// `setting.set` named a dotted key absent from the active profile. + #[error("setting '{0}' not found in active profile")] + SettingNotFound(String), + /// `setting.set` value didn't deserialize into the field's type. + #[error("setting '{key}' rejected: {msg}")] + SettingInvalid { + /// Dotted key. + key: String, + /// Human-readable reason. + msg: String, + }, + /// `route.unset` named an app with no user-set override. + #[error("no user-set route for '{0}'")] + NoUserRoute(String), + /// Disk I/O failure. + #[error("io: {0}")] + Io(#[from] io::Error), + /// Overlay TOML failed to parse. + #[error("overlay parse: {0}")] + OverlayParse(toml::de::Error), + /// Overlay failed to serialize. + #[error("overlay serialize: {0}")] + OverlaySerialize(toml::ser::Error), +} + +/// Outcome of a [`ProfileStore::reload`] call. +#[derive(Debug, Default, Clone)] +pub struct ReloadReport { + /// Profile names successfully loaded (including built-in). + pub loaded: Vec, + /// Per-file warnings (parse errors, name mismatches, ...). Reload + /// is best-effort: a broken file is reported and skipped, not + /// fatal. + pub warnings: Vec, +} + +/// Search paths used to load profiles, plus where to persist the +/// overlay. +#[derive(Debug, Clone)] +pub struct StorePaths { + /// User config dir; profiles live in `/profiles/`. + pub config_dir: PathBuf, + /// State dir for `overlay.toml`. + pub state_dir: PathBuf, + /// System-wide share dirs scanned for shipped profiles. + pub share_dirs: Vec, +} + +impl StorePaths { + /// Resolve XDG-spec paths from the environment. + /// + /// `$XDG_CONFIG_HOME` (fallback `~/.config`) and `$XDG_STATE_HOME` + /// (fallback `~/.local/state`) determine the user-writable + /// locations; `/usr/share/headroom` is the conventional share + /// dir for installed profiles. + #[must_use] + pub fn from_env() -> Self { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let config_root = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")); + let state_root = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local").join("state")); + Self { + config_dir: config_root.join("headroom"), + state_dir: state_root.join("headroom"), + share_dirs: vec![PathBuf::from("/usr/share/headroom")], + } + } +} + +/// Owns disk state and produces the effective [`Profile`]. +#[derive(Debug)] +pub struct ProfileStore { + profiles: BTreeMap, + overlay: UserOverlay, + /// Cached materialization of `profiles[active] + overlay`. Read by + /// the routing engine and (later) the DSP. + effective: Profile, + /// `Some(path)` if persistence is enabled; `None` for in-memory + /// stores (tests, `ProfileStore::builtin`). + overlay_path: Option, + /// Paths used by `load`; remembered so `reload` doesn't need them + /// threaded back in. `None` for in-memory stores. + paths: Option, + /// Set when `overlay.active_name` refers to a profile that wasn't + /// found at load time. The runtime can surface this to operators; + /// `effective` falls back to the built-in default in the meantime. + active_missing: Option, + /// Warnings accumulated at load/reload time, intended for one-shot + /// consumption by the runtime (e.g. emitting events on startup). + pending_warnings: Vec, +} + +impl ProfileStore { + /// Build an in-memory store containing only the built-in default. + /// Used by tests and the lib-level `run()` smoke entry point. + #[must_use] + pub fn builtin() -> Self { + let builtin = StoredProfile { + name: BUILTIN_DEFAULT_NAME.into(), + source: ProfileSource::Builtin, + profile: Profile::default_v0(), + }; + let mut profiles = BTreeMap::new(); + profiles.insert(builtin.name.clone(), builtin); + Self { + profiles, + overlay: UserOverlay::default(), + effective: Profile::default_v0(), + overlay_path: None, + paths: None, + active_missing: None, + pending_warnings: Vec::new(), + } + } + + /// Load profiles from disk and overlay from state. + /// + /// Order: built-in → share dirs → user dir; later sources shadow + /// earlier ones by name. Then the overlay is read and applied. + /// A missing user dir / overlay file is not an error. Parse errors + /// on individual profile files are recorded as warnings and the + /// file is skipped. + /// + /// # Errors + /// Returns an error only for unrecoverable I/O failure (e.g. the + /// overlay file exists but can't be read). A bad TOML overlay is + /// reported as a warning and replaced with the default. + pub fn load(paths: &StorePaths) -> Result { + let mut store = Self::builtin(); + store.overlay_path = Some(paths.state_dir.join(OVERLAY_FILE)); + store.paths = Some(paths.clone()); + + for share in &paths.share_dirs { + let dir = share.join(PROFILES_DIR); + scan_dir_into(&mut store.profiles, &dir, true, &mut store.pending_warnings); + } + let user_profiles = paths.config_dir.join(PROFILES_DIR); + scan_dir_into( + &mut store.profiles, + &user_profiles, + false, + &mut store.pending_warnings, + ); + + if let Some(path) = &store.overlay_path { + match fs::read_to_string(path) { + Ok(text) => match toml::from_str::(&text) { + Ok(overlay) => store.overlay = overlay, + Err(e) => store.pending_warnings.push(format!( + "overlay at {} failed to parse, ignoring: {e}", + path.display() + )), + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(StoreError::Io(e)), + } + } + + store.refresh_active_missing(); + store.rematerialize(); + Ok(store) + } + + /// Effective profile (active source + overlay applied). + #[must_use] + pub fn effective(&self) -> &Profile { + &self.effective + } + + /// Name of the profile currently selected by the overlay. + /// Note: if that profile is missing on disk, [`Self::is_active_missing`] + /// returns `Some(name)` and `effective()` falls back to the + /// built-in default. This getter still returns the *requested* + /// name in that case so operators see the mismatch. + #[must_use] + pub fn active_name(&self) -> &str { + self.overlay + .active_name + .as_deref() + .unwrap_or(BUILTIN_DEFAULT_NAME) + } + + /// Global bypass flag (overlay state). + #[must_use] + pub fn bypass_global(&self) -> bool { + self.overlay.bypass_global + } + + /// Iterate stored profiles by name (BTreeMap order — alphabetic). + pub fn list(&self) -> impl Iterator { + self.profiles.values() + } + + /// `Some(name)` if the overlay-selected profile is unknown to the + /// store; `None` if the active profile is loaded normally. + #[must_use] + pub fn is_active_missing(&self) -> Option<&str> { + self.active_missing.as_deref() + } + + /// Drain warnings accumulated at load/reload time. + /// + /// Consumes them — typically called once at startup by the runtime + /// to log them. Use [`Self::warnings`] for a non-consuming snapshot + /// surfaceable to `Status`. + pub fn take_warnings(&mut self) -> Vec { + std::mem::take(&mut self.pending_warnings) + } + + /// Non-consuming snapshot of current warnings (active profile + /// missing, per-file parse errors from the most recent load / + /// reload, ...). Empty when the store is healthy. + #[must_use] + pub fn warnings(&self) -> Vec { + self.pending_warnings.clone() + } + + /// Switch the active profile. + /// + /// The overlay's `route_overrides` and `setting_overrides` are + /// preserved — that's the whole point of the overlay. + /// + /// # Errors + /// Returns [`StoreError::ProfileNotFound`] if `name` isn't loaded. + pub fn use_profile(&mut self, name: &str) -> Result<(), StoreError> { + if !self.profiles.contains_key(name) { + return Err(StoreError::ProfileNotFound(name.to_owned())); + } + self.overlay.active_name = Some(name.to_owned()); + self.active_missing = None; + self.rematerialize(); + self.persist_overlay()?; + Ok(()) + } + + /// Record a per-app route override. + /// + /// Subsequent matchers in shipped/user profile rules for the same + /// app are shadowed: the override is prepended to the effective + /// rule list as a single-app rule. + /// + /// # Errors + /// Persistence I/O failure. + pub fn set_route(&mut self, app: &str, route: Route) -> Result<(), StoreError> { + self.overlay.route_overrides.insert(app.to_owned(), route); + self.rematerialize(); + self.persist_overlay()?; + Ok(()) + } + + /// Remove a per-app route override. + /// + /// # Errors + /// [`StoreError::NoUserRoute`] if `app` had no override. + pub fn unset_route(&mut self, app: &str) -> Result<(), StoreError> { + if self.overlay.route_overrides.remove(app).is_none() { + return Err(StoreError::NoUserRoute(app.to_owned())); + } + self.rematerialize(); + self.persist_overlay()?; + Ok(()) + } + + /// Apply (and persist) a dotted-key setting override. + /// + /// The new value is validated by attempting to materialize the + /// effective profile with the override applied; if that fails (key + /// missing, value of wrong type), the overlay is left unchanged. + /// + /// # Errors + /// [`StoreError::SettingNotFound`] / [`StoreError::SettingInvalid`]. + pub fn set_setting(&mut self, key: &str, value: Value) -> Result<(), StoreError> { + let mut trial = self.overlay.clone(); + let toml_value = json_to_toml(&value).map_err(|msg| StoreError::SettingInvalid { + key: key.to_owned(), + msg, + })?; + trial.setting_overrides.insert(key.to_owned(), toml_value); + // Probe materialization with the trial overlay; reject if it + // doesn't typecheck. + let probe = materialize(&self.profiles, &trial); + match probe { + Materialized::Ok(_) => {} + Materialized::SettingTypeError { offending_key, err } if offending_key == key => { + return Err(StoreError::SettingInvalid { + key: key.to_owned(), + msg: err, + }); + } + Materialized::SettingMissing { offending_key } if offending_key == key => { + return Err(StoreError::SettingNotFound(key.to_owned())); + } + // The probe found an error on some other key — we shouldn't + // see this since `trial` is `self.overlay` plus one new key, + // but defensively: surface whatever the materializer said. + Materialized::SettingTypeError { offending_key, err } => { + return Err(StoreError::SettingInvalid { + key: format!("{key} (caused {offending_key} to fail)"), + msg: err, + }); + } + Materialized::SettingMissing { offending_key } => { + return Err(StoreError::SettingNotFound(format!( + "{key} (caused {offending_key} to be unreachable)" + ))); + } + } + self.overlay = trial; + self.rematerialize(); + self.persist_overlay()?; + Ok(()) + } + + /// Toggle the global bypass flag and persist the overlay. + /// + /// # Errors + /// Persistence I/O failure. + pub fn set_bypass(&mut self, enabled: bool) -> Result<(), StoreError> { + self.overlay.bypass_global = enabled; + self.persist_overlay()?; + Ok(()) + } + + /// Re-read all profile sources from disk. + /// + /// Atomic: if a fatal I/O error occurs the existing in-memory + /// state is left untouched. Per-file parse errors are recorded as + /// warnings; the offending file is skipped. + /// + /// # Errors + /// Fatal disk I/O, or there's nothing to reload from (built-in store). + pub fn reload(&mut self) -> Result { + let Some(paths) = self.paths.clone() else { + return Ok(ReloadReport { + loaded: vec![BUILTIN_DEFAULT_NAME.into()], + warnings: vec!["store has no disk paths; nothing to reload".into()], + }); + }; + // Start each reload from a clean slate so `warnings()` reflects + // the current state, not the union of every reload that's ever + // happened. + self.pending_warnings.clear(); + let mut warnings = Vec::new(); + let mut new_profiles: BTreeMap = BTreeMap::new(); + new_profiles.insert( + BUILTIN_DEFAULT_NAME.into(), + StoredProfile { + name: BUILTIN_DEFAULT_NAME.into(), + source: ProfileSource::Builtin, + profile: Profile::default_v0(), + }, + ); + for share in &paths.share_dirs { + let dir = share.join(PROFILES_DIR); + scan_dir_into(&mut new_profiles, &dir, true, &mut warnings); + } + let user_profiles = paths.config_dir.join(PROFILES_DIR); + scan_dir_into(&mut new_profiles, &user_profiles, false, &mut warnings); + + let loaded: Vec = new_profiles.keys().cloned().collect(); + self.profiles = new_profiles; + self.refresh_active_missing(); + self.rematerialize(); + // Persist isn't strictly needed (we only swapped profile sources, + // not overlay), but rematerialize may have dropped invalid + // overrides — keep disk consistent. + if let Err(e) = self.persist_overlay() { + warnings.push(format!("could not persist overlay after reload: {e}")); + } + // `pending_warnings` already holds the active_missing / + // rematerialize-side warnings (refresh_active_missing pushed to + // it). Add the file-scan + persist warnings on top so the + // snapshot reflects everything from this reload. + self.pending_warnings.extend(warnings.iter().cloned()); + Ok(ReloadReport { loaded, warnings }) + } + + /// Recompute `effective` from active profile + overlay. Any + /// overrides that no longer typecheck against the active profile + /// are reported as warnings but kept in the overlay (the user may + /// switch back to a profile where they apply). + fn rematerialize(&mut self) { + let mut warnings = Vec::new(); + let mat = materialize(&self.profiles, &self.overlay); + let profile = match mat { + Materialized::Ok(p) => p, + // The non-Ok branches occur only when an override fails + // against the active profile — keep the override stored, + // fall back to a typecheck-clean materialization that + // skips the offender. + Materialized::SettingMissing { offending_key } + | Materialized::SettingTypeError { + offending_key, + err: _, + } => { + warnings.push(format!( + "setting override '{offending_key}' doesn't apply to active profile; \ + keeping the override for future use" + )); + materialize_skipping(&self.profiles, &self.overlay, &[offending_key]) + } + }; + self.pending_warnings.extend(warnings); + self.effective = profile; + } + + fn refresh_active_missing(&mut self) { + match self.overlay.active_name.as_deref() { + Some(name) if !self.profiles.contains_key(name) => { + self.active_missing = Some(name.to_owned()); + self.pending_warnings.push(format!( + "active profile '{name}' is not on disk; using built-in default until \ + the profile is restored or `profile.use` selects a known one" + )); + } + _ => self.active_missing = None, + } + } + + fn persist_overlay(&self) -> Result<(), StoreError> { + let Some(path) = self.overlay_path.as_ref() else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let body = toml::to_string_pretty(&self.overlay).map_err(StoreError::OverlaySerialize)?; + atomic_write(path, body.as_bytes())?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Materialization +// --------------------------------------------------------------------------- + +enum Materialized { + Ok(Profile), + SettingMissing { + offending_key: String, + }, + SettingTypeError { + offending_key: String, + err: String, + }, +} + +fn pick_base<'a>(profiles: &'a BTreeMap, overlay: &UserOverlay) -> &'a Profile { + let name = overlay + .active_name + .as_deref() + .unwrap_or(BUILTIN_DEFAULT_NAME); + profiles + .get(name) + .or_else(|| profiles.get(BUILTIN_DEFAULT_NAME)) + .map(|sp| &sp.profile) + .expect("ProfileStore always contains the built-in default") +} + +fn materialize( + profiles: &BTreeMap, + overlay: &UserOverlay, +) -> Materialized { + let base = pick_base(profiles, overlay); + let mut json = match serde_json::to_value(base) { + Ok(v) => v, + Err(e) => { + return Materialized::SettingTypeError { + offending_key: "".into(), + err: format!("base profile failed to serialise: {e}"), + }; + } + }; + for (key, value) in &overlay.setting_overrides { + let json_value = match toml_to_json(value) { + Ok(v) => v, + Err(e) => { + return Materialized::SettingTypeError { + offending_key: key.clone(), + err: e, + }; + } + }; + if !set_dotted(&mut json, key, json_value) { + return Materialized::SettingMissing { + offending_key: key.clone(), + }; + } + } + let mut materialised: Profile = match serde_json::from_value(json) { + Ok(p) => p, + Err(e) => { + return Materialized::SettingTypeError { + offending_key: "".into(), + err: e.to_string(), + }; + } + }; + apply_route_overrides(&mut materialised, &overlay.route_overrides); + Materialized::Ok(materialised) +} + +fn materialize_skipping( + profiles: &BTreeMap, + overlay: &UserOverlay, + skip_keys: &[String], +) -> Profile { + let base = pick_base(profiles, overlay); + // Already-validated path: any error here would mean the base + // profile itself is malformed, which is a violation of our + // invariants — fall back to the built-in if so. + let mut json = serde_json::to_value(base).unwrap_or_else(|_| { + serde_json::to_value(Profile::default_v0()).expect("default_v0 always serialises") + }); + for (key, value) in &overlay.setting_overrides { + if skip_keys.iter().any(|s| s == key) { + continue; + } + if let Ok(jv) = toml_to_json(value) { + let _ = set_dotted(&mut json, key, jv); + } + } + let mut materialised: Profile = + serde_json::from_value(json).unwrap_or_else(|_| Profile::default_v0()); + apply_route_overrides(&mut materialised, &overlay.route_overrides); + materialised +} + +fn apply_route_overrides(profile: &mut Profile, overrides: &BTreeMap) { + // Drop any existing single-app user rule matching an override, then + // prepend the overrides as one rule per app at the top of the list. + let override_apps: std::collections::HashSet<&String> = overrides.keys().collect(); + profile + .rules + .retain(|r| !is_single_app_rule_for_any(r, &override_apps)); + let mut new_rules: Vec = overrides + .iter() + .map(|(app, route)| RouteRule { + match_: RouteRuleMatch { + process_binary: vec![app.clone()], + ..Default::default() + }, + route: *route, + }) + .collect(); + new_rules.extend(std::mem::take(&mut profile.rules)); + profile.rules = new_rules; +} + +fn is_single_app_rule_for_any( + rule: &RouteRule, + apps: &std::collections::HashSet<&String>, +) -> bool { + rule.match_.process_binary.len() == 1 + && apps.contains(&rule.match_.process_binary[0]) + && rule.match_.application_name.is_empty() + && rule.match_.portal_app_id.is_empty() + && rule.match_.media_role.is_empty() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn scan_dir_into( + out: &mut BTreeMap, + dir: &Path, + shipped: bool, + warnings: &mut Vec, +) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == io::ErrorKind::NotFound => return, + Err(e) => { + warnings.push(format!("can't scan {}: {e}", dir.display())); + return; + } + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("toml") { + continue; + } + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + warnings.push(format!("read {}: {e}", path.display())); + continue; + } + }; + let profile: Profile = match toml::from_str(&text) { + Ok(p) => p, + Err(e) => { + warnings.push(format!("parse {}: {e}", path.display())); + continue; + } + }; + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + if stem != profile.name { + warnings.push(format!( + "{}: file name '{stem}' doesn't match profile.name '{}' — using profile.name", + path.display(), + profile.name + )); + } + let name = profile.name.clone(); + let source = if shipped { + ProfileSource::Shipped(path) + } else { + ProfileSource::User(path) + }; + out.insert(name.clone(), StoredProfile { name, source, profile }); + } +} + +fn set_dotted(value: &mut Value, key: &str, new: Value) -> bool { + let parts: Vec<&str> = key.split('.').collect(); + let Some((last, parents)) = parts.split_last() else { + return false; + }; + let mut cur = value; + for part in parents { + cur = match cur.get_mut(*part) { + Some(v) => v, + None => return false, + }; + } + let Some(map) = cur.as_object_mut() else { + return false; + }; + if !map.contains_key(*last) { + return false; + } + map.insert((*last).to_string(), new); + true +} + +fn toml_to_json(v: &toml::Value) -> Result { + match v { + toml::Value::String(s) => Ok(Value::String(s.clone())), + toml::Value::Integer(i) => Ok(Value::from(*i)), + toml::Value::Float(f) => serde_json::Number::from_f64(*f) + .map(Value::Number) + .ok_or_else(|| "non-finite float in setting override".into()), + toml::Value::Boolean(b) => Ok(Value::Bool(*b)), + toml::Value::Datetime(d) => Ok(Value::String(d.to_string())), + toml::Value::Array(arr) => arr.iter().map(toml_to_json).collect::, _>>().map(Value::Array), + toml::Value::Table(t) => { + let mut map = serde_json::Map::new(); + for (k, v) in t { + map.insert(k.clone(), toml_to_json(v)?); + } + Ok(Value::Object(map)) + } + } +} + +fn json_to_toml(v: &Value) -> Result { + match v { + Value::Null => Err("null is not representable in TOML overlay".into()), + Value::Bool(b) => Ok(toml::Value::Boolean(*b)), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(toml::Value::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(toml::Value::Float(f)) + } else { + Err("number out of range for TOML".into()) + } + } + Value::String(s) => Ok(toml::Value::String(s.clone())), + Value::Array(arr) => arr + .iter() + .map(json_to_toml) + .collect::, _>>() + .map(toml::Value::Array), + Value::Object(obj) => { + let mut t = toml::value::Table::new(); + for (k, v) in obj { + t.insert(k.clone(), json_to_toml(v)?); + } + Ok(toml::Value::Table(t)) + } + } +} + +/// Atomic write: stage to `.tmp-PID-EPOCH`, then `rename` over +/// the target. Rename is atomic within the same filesystem on Linux. +fn atomic_write(path: &Path, body: &[u8]) -> io::Result<()> { + let pid = std::process::id(); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp_name = format!( + "{}.tmp-{pid}-{stamp}", + path.file_name().and_then(|s| s.to_str()).unwrap_or("overlay") + ); + let tmp_path = path.with_file_name(tmp_name); + fs::write(&tmp_path, body)?; + match fs::rename(&tmp_path, path) { + Ok(()) => Ok(()), + Err(e) => { + let _ = fs::remove_file(&tmp_path); + Err(e) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_paths() -> (StorePaths, tempdir::TempDir) { + // We don't actually depend on the `tempdir` crate in this + // crate — use std::env::temp_dir + a unique subdir. + let base = std::env::temp_dir().join(format!( + "headroom-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(base.join("config/profiles")).unwrap(); + fs::create_dir_all(base.join("state")).unwrap(); + let paths = StorePaths { + config_dir: base.join("config"), + state_dir: base.join("state"), + share_dirs: vec![], + }; + // We hand back a guard that removes the dir on drop. + // The `tempdir` alias is faked via the wrapper below. + (paths, tempdir::TempDir(base)) + } + + // Tiny inline tempdir guard to avoid a new dependency. + mod tempdir { + use std::path::PathBuf; + pub struct TempDir(pub PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + } + + #[test] + fn builtin_store_has_default_profile() { + let s = ProfileStore::builtin(); + assert_eq!(s.active_name(), "default"); + assert_eq!(s.effective().name, "default"); + assert!(!s.bypass_global()); + assert!(s.list().any(|p| p.name == "default")); + } + + #[test] + fn load_with_empty_dirs_yields_builtin_only() { + let (paths, _g) = tmp_paths(); + let s = ProfileStore::load(&paths).unwrap(); + assert_eq!(s.list().count(), 1); + assert_eq!(s.effective().name, "default"); + } + + #[test] + fn user_profile_overrides_shipped_by_name() { + let (paths, _g) = tmp_paths(); + let shipped = paths.config_dir.parent().unwrap().join("share/profiles"); + fs::create_dir_all(&shipped).unwrap(); + fs::write( + shipped.join("night.toml"), + "name = \"night\"\ndescription = \"shipped night\"\n", + ) + .unwrap(); + fs::write( + paths.config_dir.join("profiles/night.toml"), + "name = \"night\"\ndescription = \"user night\"\n", + ) + .unwrap(); + let paths2 = StorePaths { + share_dirs: vec![shipped.parent().unwrap().to_path_buf()], + ..paths + }; + let s = ProfileStore::load(&paths2).unwrap(); + let night = s.list().find(|p| p.name == "night").unwrap(); + assert!(matches!(night.source, ProfileSource::User(_))); + assert_eq!(night.profile.description, "user night"); + } + + #[test] + fn parse_error_is_warning_not_fatal() { + let (paths, _g) = tmp_paths(); + fs::write( + paths.config_dir.join("profiles/broken.toml"), + "this is not = valid = toml", + ) + .unwrap(); + let mut s = ProfileStore::load(&paths).unwrap(); + let warnings = s.take_warnings(); + assert!(warnings.iter().any(|w| w.contains("broken.toml"))); + // Built-in still present and active. + assert_eq!(s.effective().name, "default"); + } + + #[test] + fn use_profile_unknown_errors() { + let mut s = ProfileStore::builtin(); + assert!(matches!( + s.use_profile("does-not-exist"), + Err(StoreError::ProfileNotFound(_)) + )); + } + + #[test] + fn set_route_appears_in_effective_rules() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + s.set_route("obs", Route::Bypass).unwrap(); + // First rule should now be the override. + let rule = &s.effective().rules[0]; + assert_eq!(rule.match_.process_binary, vec!["obs".to_string()]); + assert_eq!(rule.route, Route::Bypass); + } + + #[test] + fn set_route_updates_existing_override() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + s.set_route("obs", Route::Bypass).unwrap(); + s.set_route("obs", Route::Processed).unwrap(); + let obs_rules: Vec<_> = s + .effective() + .rules + .iter() + .filter(|r| r.match_.process_binary == vec!["obs".to_string()]) + .collect(); + assert_eq!(obs_rules.len(), 1); + assert_eq!(obs_rules[0].route, Route::Processed); + } + + #[test] + fn unset_route_missing_errors() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + assert!(matches!( + s.unset_route("never-set"), + Err(StoreError::NoUserRoute(_)) + )); + } + + #[test] + fn set_setting_changes_effective_profile() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-1.0)) + .unwrap(); + assert!((s.effective().limiter.ceiling_dbtp - -1.0).abs() < 1e-6); + } + + #[test] + fn set_setting_rejects_wrong_type() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + let err = s + .set_setting("limiter.ceiling_dbtp", serde_json::json!("nope")) + .unwrap_err(); + assert!(matches!(err, StoreError::SettingInvalid { .. })); + // Effective unchanged. + assert!((s.effective().limiter.ceiling_dbtp - -0.1).abs() < 1e-6); + } + + #[test] + fn set_setting_rejects_unknown_key() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + let err = s + .set_setting("limiter.no_such_field", serde_json::json!(1)) + .unwrap_err(); + assert!(matches!(err, StoreError::SettingNotFound(_))); + } + + #[test] + fn overlay_survives_profile_use() { + let (paths, _g) = tmp_paths(); + // Ship a second profile. + fs::write( + paths.config_dir.join("profiles/night.toml"), + "name = \"night\"\ndescription = \"loud night\"\n", + ) + .unwrap(); + let mut s = ProfileStore::load(&paths).unwrap(); + s.set_route("obs", Route::Bypass).unwrap(); + s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-2.0)) + .unwrap(); + s.use_profile("night").unwrap(); + assert_eq!(s.effective().name, "night"); + assert_eq!(s.effective().rules[0].match_.process_binary, vec!["obs".to_string()]); + assert!((s.effective().limiter.ceiling_dbtp - -2.0).abs() < 1e-6); + } + + #[test] + fn overlay_is_persisted_and_reloaded() { + let (paths, _g) = tmp_paths(); + { + let mut s = ProfileStore::load(&paths).unwrap(); + s.set_route("obs", Route::Bypass).unwrap(); + s.set_bypass(true).unwrap(); + s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-3.0)) + .unwrap(); + } + let mut s2 = ProfileStore::load(&paths).unwrap(); + // Drop the load warnings — they should be empty here. + assert!(s2.take_warnings().is_empty()); + assert!(s2.bypass_global()); + assert!((s2.effective().limiter.ceiling_dbtp - -3.0).abs() < 1e-6); + assert_eq!(s2.effective().rules[0].match_.process_binary, vec!["obs".to_string()]); + } + + #[test] + fn missing_active_profile_falls_back_to_builtin() { + let (paths, _g) = tmp_paths(); + // Write an overlay pointing at a profile that doesn't exist. + let overlay_text = + "active_name = \"night\"\nbypass_global = false\n[route_overrides]\n[setting_overrides]\n"; + fs::write(paths.state_dir.join(OVERLAY_FILE), overlay_text).unwrap(); + + let mut s = ProfileStore::load(&paths).unwrap(); + assert_eq!(s.is_active_missing(), Some("night")); + // effective() falls back to default_v0 via pick_base. + assert_eq!(s.effective().name, "default"); + let warnings = s.take_warnings(); + assert!(warnings.iter().any(|w| w.contains("night"))); + } + + #[test] + fn reload_picks_up_new_profile() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + assert_eq!(s.list().count(), 1); + + fs::write( + paths.config_dir.join("profiles/extra.toml"), + "name = \"extra\"\ndescription = \"hot reloaded\"\n", + ) + .unwrap(); + let report = s.reload().unwrap(); + assert!(report.loaded.iter().any(|n| n == "extra")); + assert!(report.warnings.is_empty()); + assert!(s.list().any(|p| p.name == "extra")); + } + + #[test] + fn reload_with_broken_file_keeps_daemon_running() { + let (paths, _g) = tmp_paths(); + let mut s = ProfileStore::load(&paths).unwrap(); + fs::write( + paths.config_dir.join("profiles/broken.toml"), + "this == not valid", + ) + .unwrap(); + let report = s.reload().unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("broken.toml"))); + // Default still active. + assert_eq!(s.effective().name, "default"); + } +} diff --git a/crates/headroom-core/src/profile_watcher.rs b/crates/headroom-core/src/profile_watcher.rs new file mode 100644 index 0000000..24ffa17 --- /dev/null +++ b/crates/headroom-core/src/profile_watcher.rs @@ -0,0 +1,178 @@ +//! File-system watcher for the user profile directory. +//! +//! Wraps `notify-debouncer-mini` to call [`crate::ipc::execute_reload`] +//! whenever a TOML file in `$XDG_CONFIG_HOME/headroom/profiles/` +//! appears, disappears, or changes — debounced to coalesce editors +//! that save via rename / atomic-write (`vim`, most modern editors). +//! +//! The debouncer owns its own background thread. The callback we +//! register is `Fn + Send + 'static` and just calls into the same +//! reload helper that the IPC `profile.reload` op uses — so the +//! publish-events + DSP-push behaviour is identical to a manual +//! reload. +//! +//! Drop the [`ProfileWatcher`] to stop watching. + +use std::path::PathBuf; +use std::time::Duration; + +use notify::{RecommendedWatcher, RecursiveMode}; +use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer}; + +use crate::error::DaemonError; +use crate::ipc::execute_reload; +use crate::state::SharedState; + +/// How long to wait for a quiet period before firing a reload. Most +/// editors do save → rename in tens of ms; 500 ms is comfortably +/// past the typical write storm without making the user wait long. +const DEBOUNCE: Duration = Duration::from_millis(500); + +/// Live profile-directory watcher. Holds the underlying debouncer for +/// its lifetime; drop to stop the background thread. +pub struct ProfileWatcher { + _debouncer: Debouncer, +} + +impl ProfileWatcher { + /// Start watching `profiles_dir`. Returns `Ok(None)` if the + /// directory doesn't exist yet (acceptable — user hasn't authored + /// any custom profiles); returns `Ok(Some(_))` on a healthy arm. + /// + /// # Errors + /// [`DaemonError::Other`] if the watcher backend or `watch` call + /// fails. A failure to install the watcher is not fatal to the + /// daemon; the caller can log and proceed (manual `profile.reload` + /// still works). + pub fn start(profiles_dir: PathBuf, state: SharedState) -> Result, DaemonError> { + if !profiles_dir.exists() { + tracing::debug!( + path = %profiles_dir.display(), + "profile dir not present; file-watch reload disabled" + ); + return Ok(None); + } + + let state_for_cb = state; + let mut debouncer = new_debouncer( + DEBOUNCE, + move |result: DebounceEventResult| match result { + Ok(events) if !events.is_empty() => { + tracing::info!(events = events.len(), "profile dir changed; auto-reloading"); + match execute_reload(&state_for_cb) { + Ok(report) => { + for w in &report.warnings { + tracing::warn!(warning = %w, "auto-reload warning"); + } + } + Err(e) => tracing::error!(error = %e, "auto-reload failed"), + } + } + Ok(_) => {} + Err(e) => { + tracing::warn!(error = %e, "profile watcher backend error"); + } + }, + ) + .map_err(|e| DaemonError::other(format!("debouncer init: {e}")))?; + + debouncer + .watcher() + .watch(&profiles_dir, RecursiveMode::NonRecursive) + .map_err(|e| { + DaemonError::other(format!("watch {}: {e}", profiles_dir.display())) + })?; + + tracing::info!( + path = %profiles_dir.display(), + debounce_ms = DEBOUNCE.as_millis() as u64, + "profile dir watcher armed" + ); + Ok(Some(Self { + _debouncer: debouncer, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile_store::{ProfileStore, StorePaths}; + use crate::state::{self, DaemonState}; + use std::fs; + use std::time::{Instant, SystemTime, UNIX_EPOCH}; + + /// Build an isolated config/state tree and load a `ProfileStore` + /// against it. Returns the paths and a guard that cleans up the + /// dir on drop. + fn tmp_paths() -> (StorePaths, TmpGuard) { + let base = std::env::temp_dir().join(format!( + "headroom-watcher-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + fs::create_dir_all(base.join("config/profiles")).unwrap(); + fs::create_dir_all(base.join("state")).unwrap(); + let paths = StorePaths { + config_dir: base.join("config"), + state_dir: base.join("state"), + share_dirs: vec![], + }; + (paths, TmpGuard(base)) + } + + struct TmpGuard(std::path::PathBuf); + impl Drop for TmpGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn missing_profile_dir_is_not_an_error() { + let dir = std::env::temp_dir().join(format!( + "headroom-no-dir-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + // dir does not exist. + let store = ProfileStore::builtin(); + let state = state::shared(DaemonState::new(store)); + let watcher = ProfileWatcher::start(dir, state).expect("graceful no-op"); + assert!(watcher.is_none()); + } + + #[test] + fn dropping_a_new_profile_triggers_reload() { + let (paths, _g) = tmp_paths(); + let store = ProfileStore::load(&paths).unwrap(); + let state = state::shared(DaemonState::new(store)); + let profiles_dir = paths.config_dir.join("profiles"); + + let _watcher = ProfileWatcher::start(profiles_dir.clone(), state.clone()) + .expect("watcher start") + .expect("dir present"); + + // Initially: only builtin "default" is known. + assert_eq!(state.lock().profiles.list().count(), 1); + + // Drop a new profile in. The debouncer waits 500 ms; allow up + // to 5 s before declaring failure (CI fs latency). + fs::write( + profiles_dir.join("hot.toml"), + "name = \"hot\"\ndescription = \"hot-reloaded\"\n", + ) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut saw_new = false; + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + if state.lock().profiles.list().any(|p| p.name == "hot") { + saw_new = true; + break; + } + } + assert!(saw_new, "watcher should have reloaded after file appeared"); + } +} diff --git a/crates/headroom-core/src/pw/command.rs b/crates/headroom-core/src/pw/command.rs new file mode 100644 index 0000000..6273639 --- /dev/null +++ b/crates/headroom-core/src/pw/command.rs @@ -0,0 +1,56 @@ +//! Cross-thread command channel from IPC handlers to the PipeWire +//! main loop. +//! +//! PipeWire proxies (the bound `default` metadata, registry, streams) +//! are tied to the loop's thread and can't be touched from elsewhere. +//! Any IPC handler that needs to write metadata or otherwise mutate +//! the PipeWire graph posts a [`PwCommand`] into a `crossbeam` channel; +//! a 50 ms-period timer source on the main loop drains the channel +//! and applies each command in turn. +//! +//! # Latency budget — read before adding variants +//! +//! Worst-case dispatch latency through this channel is ~50 ms (one +//! full timer period). Average is ~25 ms. That is **fine for +//! operator-level / human-initiated commands** (`route.stream` from +//! the CLI or a panel widget; future profile-tweak verbs that touch +//! the graph) and is **fine for control-plane writes that already +//! operate on multi-hundred-millisecond time scales** (e.g. the slow +//! AGC tick, ~50 ms cadence with multi-second time constants). +//! +//! It is **not** fine for anything that drives gain reduction in +//! response to a transient. Specifically: +//! +//! - Layer A (per-application level control, Phase 6) reacts to +//! spikes within ~one PipeWire quantum (5–20 ms). Routing its +//! `Props.channelVolumes` writes through this channel would break +//! the §4.5 reaction-time contract. +//! - The filter's compressor/limiter parameter updates already +//! bypass this channel — they go through +//! [`crate::pw::filter::FilterControl`]'s `rtrb`, which is wait-free +//! and drained at the top of every realtime callback. +//! +//! If you're adding a new variant and your use case touches either +//! the realtime audio path or a spike-reactive gain envelope, do +//! **not** add it here. Phase 6 introduces a tighter dispatch +//! primitive (likely an `EventSource::signal` shim or a pipe-fd +//! `IoSource`) for that traffic; reuse that instead. + +use headroom_ipc::Route; + +/// Commands the IPC threads ask the PipeWire main loop to execute. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum PwCommand { + /// Set `target.object` for a specific stream, overriding any rule + /// the routing engine would otherwise apply. Used by + /// `Op::RouteStream` (4i). + RouteStream { + /// Stream node id. + node_id: u32, + /// Desired route. + to: Route, + /// Cached app label for log lines / events. + app_label: String, + }, +} diff --git a/crates/headroom-core/src/pw/filter.rs b/crates/headroom-core/src/pw/filter.rs index 9ff6841..2b3053e 100644 --- a/crates/headroom-core/src/pw/filter.rs +++ b/crates/headroom-core/src/pw/filter.rs @@ -29,6 +29,9 @@ //! reinterpretation goes through `bytemuck::try_cast_slice` so the //! crate remains `#![forbid(unsafe_code)]`. +use std::sync::Arc; + +use parking_lot::Mutex; use pipewire::{ core::Core, keys, @@ -45,7 +48,9 @@ use pipewire::{ }; use rtrb::{Consumer, Producer, RingBuffer}; -use headroom_dsp::{Compressor, CompressorConfig, Limiter, LimiterConfig}; +use headroom_dsp::{ + AgcGain, AgcGainConfig, Compressor, CompressorConfig, Limiter, LimiterConfig, SetConfigOutcome, +}; use crate::error::DaemonError; use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME; @@ -54,16 +59,136 @@ use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME; /// 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; +pub const FILTER_SAMPLE_RATE: u32 = 48_000; /// Number of channels the filter operates on (stereo only in v0). -const CHANNELS: u32 = 2; +pub 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; +/// Capacity of the control→audio command ring. Each slot holds an +/// [`AudioCmd`]. Sized for bursts (e.g. a CLI script firing several +/// `setting.set` calls back-to-back); the audio thread drains the +/// ring at the top of every quantum so we never need more headroom +/// than the worst-case command-arrival rate times one quantum. +const CMD_RING_CAPACITY: usize = 32; + +/// Capacity of the audio→AGC measurement ring, in interleaved `f32` +/// samples. The audio thread pushes the filter's *input* samples +/// (pre-AGC, pre-compressor, pre-limiter) so the slow AGC measures +/// the program loudness it should compensate for. At 48 kHz stereo +/// the steady-state arrival rate is 96k samples/s; the controller +/// ticks at ~50 ms and consumes ~4.8k samples per tick. The capacity +/// here gives several ticks of slack so a stalled controller doesn't +/// drop measurement coverage. +const MEASUREMENT_RING_CAPACITY: usize = 32_768; + +/// Parameter-update commands sent from the control plane to the +/// realtime audio thread. +/// +/// Each variant carries a small POD config by value so the audio +/// thread doesn't have to dereference, allocate, or drop anything +/// outside its own state. Larger structural changes (oversample, +/// lookahead) require rebuilding the filter on the control thread — +/// see [`headroom_dsp::SetConfigOutcome::StructuralChange`]. +#[derive(Debug, Clone, Copy)] +pub enum AudioCmd { + /// Replace the compressor's running configuration. Scalar params + /// (threshold/ratio/knee/times/makeup) update in place. + SetCompressor(CompressorConfig), + /// Replace the limiter's running configuration. Scalar params + /// apply in place; structural changes are logged and skipped. + SetLimiter(LimiterConfig), + /// Update the AGC gain stage's target (in dB). Pushed by the slow + /// AGC controller on each control tick. The audio thread smooths + /// `current_db` toward this with the anti-zipper alpha. + SetAgcTargetDb(f32), + /// Toggle the AGC stage. When disabled, the smoother unwinds to + /// 0 dB at the anti-zipper rate. + SetAgcEnabled(bool), + /// Replace the AGC gain stage's configuration (anti-zipper tau). + SetAgcConfig(AgcGainConfig), +} + +/// Cheap-to-clone handle for sending [`AudioCmd`]s into the running +/// filter. Held on the control side (in `DaemonState`) so any +/// IPC-handler thread can push parameter updates without owning the +/// audio path. +#[derive(Clone)] +pub struct FilterControl { + cmd_producer: Arc>>, +} + +impl FilterControl { + /// Push a command into the ring. Returns `true` on success, `false` + /// if the ring is full (the command is dropped; the next push + /// after the audio thread drains will succeed). Logs at warn-level + /// on drop. + pub fn try_send(&self, cmd: AudioCmd) -> bool { + match self.cmd_producer.lock().push(cmd) { + Ok(()) => true, + Err(_) => { + tracing::warn!( + "filter command ring full; dropping parameter update — \ + audio thread may be stalled or commands arriving faster than the quantum" + ); + false + } + } + } + + /// Convenience: push a compressor config. + pub fn set_compressor(&self, cfg: CompressorConfig) -> bool { + self.try_send(AudioCmd::SetCompressor(cfg)) + } + + /// Convenience: push a limiter config. + pub fn set_limiter(&self, cfg: LimiterConfig) -> bool { + self.try_send(AudioCmd::SetLimiter(cfg)) + } + + /// Convenience: push an AGC target (dB). + pub fn set_agc_target_db(&self, db: f32) -> bool { + self.try_send(AudioCmd::SetAgcTargetDb(db)) + } + + /// Convenience: push an AGC enable/disable flip. + pub fn set_agc_enabled(&self, enabled: bool) -> bool { + self.try_send(AudioCmd::SetAgcEnabled(enabled)) + } + + /// Convenience: push an AGC stage config. + pub fn set_agc_config(&self, cfg: AgcGainConfig) -> bool { + self.try_send(AudioCmd::SetAgcConfig(cfg)) + } +} + +impl std::fmt::Debug for FilterControl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FilterControl").finish_non_exhaustive() + } +} + +#[cfg(test)] +impl FilterControl { + /// Construct a control + consumer pair without spinning up the + /// audio path. Returns `(control, consumer)` — the test code uses + /// the consumer in lieu of the playback callback to observe what + /// the producer pushed. + pub(crate) fn for_testing(capacity: usize) -> (Self, Consumer) { + let (producer, consumer) = RingBuffer::::new(capacity); + ( + Self { + cmd_producer: Arc::new(Mutex::new(producer)), + }, + consumer, + ) + } +} + /// State owned by the capture stream's process callback. struct CaptureState { producer: Producer, @@ -75,10 +200,21 @@ struct CaptureState { /// State owned by the playback stream's process callback. struct PlaybackState { consumer: Consumer, + /// Control-plane → audio-thread parameter update channel. Drained + /// at the top of every `playback_process` call. + cmd_consumer: Consumer, + /// Producer end of the measurement ring fed to the AGC controller. + /// We push *pre-AGC* input samples; samples that don't fit are + /// silently dropped (the controller is intentionally OK with + /// gaps, since its time constants are seconds). + measurement_producer: Producer, + agc: AgcGain, compressor: Compressor, limiter: Limiter, /// Counter of samples zero-filled because the ring was empty. samples_starved: u64, + /// Counter of measurement samples dropped (best-effort push). + measurement_dropped: u64, } /// The filter pipeline. @@ -92,20 +228,58 @@ pub struct Filter { _playback_listener: StreamListener, } +/// Initial DSP-side configuration handed to [`Filter::create`]. +#[derive(Debug, Clone, Copy)] +pub struct FilterInit { + /// Compressor seed. + pub compressor: CompressorConfig, + /// Limiter seed. + pub limiter: LimiterConfig, + /// AGC gain-stage seed (anti-zipper tau etc.). + pub agc: AgcGainConfig, + /// Whether the AGC stage is active at boot. Derived from the + /// active profile's `[agc] enabled`. + pub agc_enabled: bool, +} + +/// Everything [`Filter::create`] hands back. Bundled so we don't grow +/// a 5-tuple each time a new control-plane handle appears. +pub struct FilterBundle { + /// The filter itself. Drop teardown order is `bundle.filter` first. + pub filter: Filter, + /// Cheap-to-clone control handle for live parameter updates. + pub control: FilterControl, + /// Consumer end of the AGC measurement ring. Hand to the + /// `headroom-core::agc` controller. + pub measurement_consumer: Consumer, +} + 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). /// + /// `initial_compressor` and `initial_limiter` seed the DSP kernels + /// from the active profile; subsequent live tweaks arrive over + /// the [`FilterControl`] returned alongside the filter. + /// /// # Errors /// [`DaemonError::PipeWire`] if stream creation or connection /// fails. - pub fn create(core: &Core) -> Result { + pub fn create(core: &Core, init: FilterInit) -> Result { let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); + let (cmd_producer, cmd_consumer) = RingBuffer::::new(CMD_RING_CAPACITY); + let (measurement_producer, measurement_consumer) = + RingBuffer::::new(MEASUREMENT_RING_CAPACITY); + let control = FilterControl { + cmd_producer: Arc::new(Mutex::new(cmd_producer)), + }; - let compressor = Compressor::new(CompressorConfig::default(), FILTER_SAMPLE_RATE as f32); - let limiter = Limiter::new(LimiterConfig::default(), FILTER_SAMPLE_RATE as f32); + let compressor = Compressor::new(init.compressor, FILTER_SAMPLE_RATE as f32); + let limiter = Limiter::new(init.limiter, FILTER_SAMPLE_RATE as f32); + let mut agc = AgcGain::new(init.agc, FILTER_SAMPLE_RATE as f32); + agc.set_enabled(init.agc_enabled); let capture = build_capture_stream(core)?; let capture_listener = capture @@ -121,9 +295,13 @@ impl Filter { let playback_listener = playback .add_local_listener_with_user_data(PlaybackState { consumer, + cmd_consumer, + measurement_producer, + agc, compressor, limiter, samples_starved: 0, + measurement_dropped: 0, }) .process(playback_process) .register() @@ -163,11 +341,15 @@ impl Filter { "filter streams created and connected" ); - Ok(Self { - _capture: capture, - _capture_listener: capture_listener, - _playback: playback, - _playback_listener: playback_listener, + Ok(FilterBundle { + filter: Self { + _capture: capture, + _capture_listener: capture_listener, + _playback: playback, + _playback_listener: playback_listener, + }, + control, + measurement_consumer, }) } } @@ -277,8 +459,58 @@ fn capture_process(stream: &pipewire::stream::StreamRef, state: &mut CaptureStat } } +/// Apply a single [`AudioCmd`] to the DSP kernels. Allocation-free; +/// extracted from [`drain_audio_commands`] so the audio-thread leg is +/// unit-testable without spinning up a `pw_stream`. +fn apply_audio_cmd( + cmd: AudioCmd, + compressor: &mut Compressor, + limiter: &mut Limiter, + agc: &mut AgcGain, +) { + match cmd { + AudioCmd::SetCompressor(cfg) => { + compressor.set_config(cfg); + } + AudioCmd::SetLimiter(cfg) => match limiter.try_set_config(cfg) { + SetConfigOutcome::Applied => {} + SetConfigOutcome::StructuralChange => { + tracing::warn!( + "limiter structural change (oversample / lookahead / fir_taps) cannot be \ + applied live; daemon restart required to pick up the new value" + ); + } + }, + AudioCmd::SetAgcTargetDb(db) => { + agc.set_target_db(db); + } + AudioCmd::SetAgcEnabled(enabled) => { + agc.set_enabled(enabled); + } + AudioCmd::SetAgcConfig(cfg) => { + agc.set_config(cfg); + } + } +} + +/// Drain pending parameter updates from the control plane and apply +/// them to the DSP kernels. Called at the top of every playback +/// callback; allocation-free. +fn drain_audio_commands(state: &mut PlaybackState) { + while let Ok(cmd) = state.cmd_consumer.pop() { + apply_audio_cmd( + cmd, + &mut state.compressor, + &mut state.limiter, + &mut state.agc, + ); + } +} + /// Playback process callback. Realtime-thread, allocation-free. fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) { + drain_audio_commands(state); + let Some(mut buffer) = stream.dequeue_buffer() else { return; }; @@ -308,18 +540,32 @@ fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackSt }; let mut produced_frames = 0; + let mut measurement_dropped = 0_u64; 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); + // Feed the slow-AGC controller. Best-effort: gaps in + // measurement coverage are fine (its time constants are + // seconds), and we don't want to block the audio thread on + // a slow controller. + if state.measurement_producer.push(left_in).is_err() + || state.measurement_producer.push(right_in).is_err() + { + measurement_dropped = measurement_dropped.saturating_add(2); + } + // AGC → Compressor → two-tier limiter (safety contract). + let (la, ra) = state.agc.process_frame(left_in, right_in); + let (lc, rc) = state.compressor.process_frame(la, ra); 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 measurement_dropped > 0 { + state.measurement_dropped = state.measurement_dropped.saturating_add(measurement_dropped); + } if produced_frames < max_frames { let starved_frames = max_frames - produced_frames; @@ -337,3 +583,152 @@ fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackSt *chunk.stride_mut() = stride_bytes as i32; *chunk.offset_mut() = 0; } + +#[cfg(test)] +mod tests { + //! Tests cover the audio-thread leg (apply_audio_cmd) and the + //! control-side send leg (FilterControl). The pw_stream halves + //! aren't exercised here — they need a running PipeWire instance. + + use super::*; + use headroom_dsp::{ + AgcGain, AgcGainConfig, Compressor, CompressorConfig, Limiter, LimiterConfig, + SoftTierConfig, + }; + + const SR: f32 = 48_000.0; + + #[test] + fn apply_audio_cmd_updates_compressor_scalars() { + let mut compressor = Compressor::new(CompressorConfig::default(), SR); + let mut limiter = Limiter::new(LimiterConfig::default(), SR); + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + let new_cfg = CompressorConfig { + threshold_db: -12.0, + ratio: 4.0, + ..CompressorConfig::default() + }; + apply_audio_cmd( + AudioCmd::SetCompressor(new_cfg), + &mut compressor, + &mut limiter, + &mut agc, + ); + let active = compressor.config(); + assert!((active.threshold_db - -12.0).abs() < 1e-6); + assert!((active.ratio - 4.0).abs() < 1e-6); + } + + #[test] + fn apply_audio_cmd_updates_limiter_scalars() { + let mut compressor = Compressor::new(CompressorConfig::default(), SR); + let mut limiter = Limiter::new(LimiterConfig::default(), SR); + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + let new_cfg = LimiterConfig { + ceiling_dbtp: -1.5, + release_ms: 250.0, + soft: Some(SoftTierConfig { + max_psr_db: 10.0, + ..SoftTierConfig::default() + }), + ..LimiterConfig::default() + }; + apply_audio_cmd( + AudioCmd::SetLimiter(new_cfg), + &mut compressor, + &mut limiter, + &mut agc, + ); + assert!((limiter.ceiling_dbtp() - -1.5).abs() < 1e-6); + assert!((limiter.config().release_ms - 250.0).abs() < 1e-6); + let soft = limiter.config().soft.expect("soft preserved"); + assert!((soft.max_psr_db - 10.0).abs() < 1e-6); + } + + #[test] + fn apply_audio_cmd_skips_structural_limiter_change_silently() { + let mut compressor = Compressor::new(CompressorConfig::default(), SR); + let mut limiter = Limiter::new(LimiterConfig::default(), SR); + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + let mut bad = LimiterConfig::default(); + bad.oversample = 8; // structural; can't apply in place + // Should not panic, should not change the limiter. + apply_audio_cmd( + AudioCmd::SetLimiter(bad), + &mut compressor, + &mut limiter, + &mut agc, + ); + assert_eq!(limiter.config().oversample, LimiterConfig::default().oversample); + } + + #[test] + fn filter_control_send_reaches_consumer() { + let (control, mut consumer) = FilterControl::for_testing(8); + assert!(control.set_compressor(CompressorConfig::default())); + assert!(control.set_limiter(LimiterConfig::default())); + // Two commands queued. + let c1 = consumer.pop().expect("first cmd"); + let c2 = consumer.pop().expect("second cmd"); + assert!(matches!(c1, AudioCmd::SetCompressor(_))); + assert!(matches!(c2, AudioCmd::SetLimiter(_))); + assert!(consumer.pop().is_err(), "ring drained"); + } + + #[test] + fn filter_control_returns_false_on_full_ring() { + // Capacity 2: third push should fail. + let (control, _consumer) = FilterControl::for_testing(2); + assert!(control.set_compressor(CompressorConfig::default())); + assert!(control.set_limiter(LimiterConfig::default())); + assert!(!control.set_compressor(CompressorConfig::default())); + } + + #[test] + fn filter_control_send_then_drain_applies_to_dsp_kernels() { + // End-to-end on the cmd plane: push via FilterControl, drain + // via apply_audio_cmd, observe DSP state. + let (control, mut consumer) = FilterControl::for_testing(8); + let mut compressor = Compressor::new(CompressorConfig::default(), SR); + let mut limiter = Limiter::new(LimiterConfig::default(), SR); + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + + control.set_compressor(CompressorConfig { + threshold_db: -8.0, + ..CompressorConfig::default() + }); + control.set_limiter(LimiterConfig { + ceiling_dbtp: -2.0, + ..LimiterConfig::default() + }); + + while let Ok(cmd) = consumer.pop() { + apply_audio_cmd(cmd, &mut compressor, &mut limiter, &mut agc); + } + assert!((compressor.config().threshold_db - -8.0).abs() < 1e-6); + assert!((limiter.ceiling_dbtp() - -2.0).abs() < 1e-6); + } + + #[test] + fn apply_audio_cmd_updates_agc_target_and_enable() { + let mut compressor = Compressor::new(CompressorConfig::default(), SR); + let mut limiter = Limiter::new(LimiterConfig::default(), SR); + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + apply_audio_cmd( + AudioCmd::SetAgcTargetDb(4.5), + &mut compressor, + &mut limiter, + &mut agc, + ); + assert!((agc.target_db() - 4.5).abs() < 1e-6); + apply_audio_cmd( + AudioCmd::SetAgcEnabled(false), + &mut compressor, + &mut limiter, + &mut agc, + ); + assert!(!agc.enabled()); + // Disable resets target to 0 (smoother unwinds gracefully). + assert!((agc.target_db()).abs() < 1e-6); + } +} diff --git a/crates/headroom-core/src/pw/metadata.rs b/crates/headroom-core/src/pw/metadata.rs index d8d5822..ba274ec 100644 --- a/crates/headroom-core/src/pw/metadata.rs +++ b/crates/headroom-core/src/pw/metadata.rs @@ -1,60 +1,51 @@ -//! Metadata helpers. +//! Helpers for the PipeWire `default` metadata object. //! -//! 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. +//! Headroom reads two pieces of state from it: //! -//! Phase 3 checkpoints 3c-3f (varies per call site). +//! - `default.audio.sink` — the system default sink. We watch this to +//! adopt the user's preferred hardware sink as +//! `preferred_real_sink`, and re-assert `headroom-processed` so new +//! streams keep landing in the processor. +//! - per-stream `target.object` (written, not read) — how the routing +//! engine tells WirePlumber to move a stream to a chosen sink. +//! +//! The metadata API surface itself (binding, listening, writing) lives +//! in [`crate::pw::registry`], where the registry callbacks have the +//! right scope. This module is the pure parsing / formatting layer. -use crate::error::DaemonError; +use serde_json::Value; -/// 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, -} +/// The metadata key for the system default audio sink. +pub const DEFAULT_AUDIO_SINK_KEY: &str = "default.audio.sink"; -impl PreferredRealSinkTracker { - /// Construct an empty tracker. - #[must_use] - pub fn new() -> Self { - Self { current: None } - } +/// The metadata key for per-stream sink override. +pub const TARGET_OBJECT_KEY: &str = "target.object"; - /// Currently-observed real sink, if any. - #[must_use] - pub fn current(&self) -> Option { - self.current - } +/// The SPA type string used for JSON-encoded metadata values. +pub const SPA_JSON_TYPE: &str = "Spa:String:JSON"; - /// Set the current real sink. Returns `true` if the value - /// changed. - pub fn set(&mut self, node_id: Option) -> bool { - let changed = self.current != node_id; - self.current = node_id; - changed - } -} - -impl Default for PreferredRealSinkTracker { - fn default() -> Self { - Self::new() - } -} - -/// Write `target.object = ` for the named stream into the -/// `default` metadata key. WirePlumber observes this and moves the -/// stream accordingly. +/// Parse a `default.audio.sink` value into a sink name. /// -/// # 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)", - )) +/// The on-the-wire encoding is a JSON object: `{"name":"alsa_output.…"}`. +/// Returns `None` for anything we can't recognise — we'd rather quietly +/// ignore weird values than crash the metadata listener. +#[must_use] +pub fn parse_default_sink_name(value: &str) -> Option { + let parsed: Value = serde_json::from_str(value.trim()).ok()?; + parsed.get("name")?.as_str().map(str::to_owned) +} + +/// Format a `target.object` value pointing at `sink_name`. The JSON +/// shape mirrors what PipeWire / WirePlumber accept and what +/// `parse_default_sink_name` reads. +#[must_use] +pub fn format_sink_target_value(sink_name: &str) -> String { + // Escape any embedded double-quote conservatively. Sink names from + // PipeWire never contain quotes in practice, but the formatter is + // also called with user-influenced strings (the `preferred_real_sink` + // name as observed), so don't trust them. + let escaped = sink_name.replace('"', "\\\""); + format!("{{\"name\":\"{escaped}\"}}") } #[cfg(test)] @@ -62,17 +53,36 @@ 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()); + fn parses_default_sink_name_from_canonical_json() { + let v = parse_default_sink_name("{\"name\":\"alsa_output.usb-foo\"}"); + assert_eq!(v.as_deref(), Some("alsa_output.usb-foo")); + } + + #[test] + fn parses_default_sink_name_with_whitespace() { + let v = parse_default_sink_name(" {\"name\":\"x\"}\n"); + assert_eq!(v.as_deref(), Some("x")); + } + + #[test] + fn rejects_garbage() { + assert_eq!(parse_default_sink_name("not json"), None); + assert_eq!(parse_default_sink_name("{}"), None); + assert_eq!(parse_default_sink_name("{\"name\":42}"), None); + } + + #[test] + fn formats_sink_target_round_trips() { + let formatted = format_sink_target_value("alsa_output.usb-foo"); + let back = parse_default_sink_name(&formatted).unwrap(); + assert_eq!(back, "alsa_output.usb-foo"); + } + + #[test] + fn formats_sink_target_escapes_embedded_quote() { + let formatted = format_sink_target_value("we\"ird"); + // Should still be valid JSON. + let back = parse_default_sink_name(&formatted).unwrap(); + assert_eq!(back, "we\"ird"); } } diff --git a/crates/headroom-core/src/pw/mod.rs b/crates/headroom-core/src/pw/mod.rs index fee23e4..7e362db 100644 --- a/crates/headroom-core/src/pw/mod.rs +++ b/crates/headroom-core/src/pw/mod.rs @@ -14,13 +14,16 @@ //! `Context`, and `Core`. The daemon constructs one of these on //! startup and runs it until shutdown. +pub mod command; pub mod filter; pub mod metadata; pub mod registry; pub mod sink; +pub mod tap; use std::cell::{Cell, RefCell}; use std::rc::Rc; +use std::time::Duration; use pipewire::{context::Context, core::Core, loop_::Signal, main_loop::MainLoop}; @@ -132,7 +135,11 @@ impl PwContext { .core .get_registry() .map_err(|e| DaemonError::pipewire(format!("get_registry: {e}")))?; - let watcher = RegistryWatcher::new(Rc::new(registry), daemon); + // Clone the Core for the routing watcher — `Core` is itself + // `Rc`-backed in pipewire-rs, so this is cheap. The watcher + // needs it to call `create_object("link-factory", ...)` when + // spawning Layer A taps (6c). + let watcher = RegistryWatcher::new(Rc::new(registry), self.core.clone(), daemon); *self.routing.borrow_mut() = Some(watcher); tracing::info!("registry watcher + routing engine installed"); Ok(()) @@ -225,6 +232,50 @@ impl PwContext { ml.quit(); }); + // Drain IPC → PipeWire commands (e.g. route.stream) at 50 ms. + // The timer is scoped to this function so it drops alongside + // the signal sources when the loop exits. Held in `Option` + // because we only arm it if routing was started. + // + // Latency note: this 50 ms cadence is fine for operator-grade + // commands and slow AGC-style writes, but is **not** suitable + // for spike-reactive gain reduction (Layer A, Phase 6). See + // `pw::command` module docs before routing new traffic here. + let _cmd_timer = { + let routing = self.routing.borrow(); + routing.as_ref().map(|watcher| { + let state = watcher.state().clone(); + let timer = self.main_loop.loop_().add_timer(move |_expirations| { + state.borrow_mut().drain_pw_commands(); + }); + let _ = timer.update_timer( + Some(Duration::from_millis(50)), + Some(Duration::from_millis(50)), + ); + timer + }) + }; + + // Drain Layer A (per-app level control) measurement rings and + // issue `Props.channelVolumes` writes. 5 ms cadence keeps the + // detection-to-write latency well inside one quantum at + // typical 21 ms quanta — see PLAN §4.5 reaction-time table + // and the bench-validated controller cost (~30 ns/tick). + let _layer_a_timer = { + let routing = self.routing.borrow(); + routing.as_ref().map(|watcher| { + let state = watcher.state().clone(); + let timer = self.main_loop.loop_().add_timer(move |_expirations| { + state.borrow_mut().drain_layer_a(); + }); + let _ = timer.update_timer( + Some(Duration::from_millis(5)), + Some(Duration::from_millis(5)), + ); + timer + }) + }; + tracing::info!("entering pipewire main loop"); self.main_loop.run(); tracing::info!("main loop exited"); diff --git a/crates/headroom-core/src/pw/registry.rs b/crates/headroom-core/src/pw/registry.rs index 836fce7..6a0c465 100644 --- a/crates/headroom-core/src/pw/registry.rs +++ b/crates/headroom-core/src/pw/registry.rs @@ -1,38 +1,104 @@ //! PipeWire registry subscription + routing decisions. //! -//! Phase 3 checkpoint 3f, refactored in 4b to read the active -//! profile from `Arc>` (shared with IPC threads) -//! rather than holding its own copy. +//! Phase 3 checkpoint 3f, refactored in 4b to read the active profile +//! from `Arc>` (shared with IPC threads). 4h adds +//! `preferred_real_sink` tracking: the daemon watches the `default` +//! metadata for `default.audio.sink` changes, snapshots the user's +//! prior default as `preferred_real_sink`, promotes +//! `headroom-processed` as the new system default, and retargets every +//! bypass-routed stream whenever the real sink changes. //! //! 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. +//! - **Metadata objects** with `metadata.name = "default"` are bound +//! and listened to for property changes. The listener captures +//! `default.audio.sink` transitions to non-headroom sinks as +//! `preferred_real_sink` updates. //! - **Node objects** named `headroom-processed` get their global id //! captured into the shared state so IPC `status` can report it. //! - **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. +//! evaluated against the active profile's routing rules. Processed +//! routes write `target.object` pointing at `headroom-processed`; +//! bypass routes write `target.object` pointing at the current +//! `preferred_real_sink`. use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; +use crossbeam_channel::Receiver; use pipewire::{ - metadata::Metadata, + core::Core, + link::Link, + metadata::{Metadata, MetadataListener}, + node::{Node, NodeListener}, + properties::properties, registry::{GlobalObject, Listener, Registry}, - spa::utils::dict::DictRef, + spa::{ + param::ParamType, + pod::{ + deserialize::PodDeserializer, serialize::PodSerializer, Object as PodObject, Pod, + Property, PropertyFlags, Value, ValueArray, + }, + utils::{dict::DictRef, SpaTypes}, + }, types::ObjectType, }; +use rtrb::Consumer; use headroom_ipc::{Event, Route, Topic}; use serde_json::json; +use crate::app_level::{self, AppLevelController}; +use crate::pw::command::PwCommand; +use crate::pw::metadata::{ + format_sink_target_value, parse_default_sink_name, DEFAULT_AUDIO_SINK_KEY, SPA_JSON_TYPE, + TARGET_OBJECT_KEY, +}; use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME; +use crate::pw::tap::{MeasurementSample, StreamTap}; use crate::routing::{self, PwNodeInfo, RoutingDecision}; use crate::state::{RoutedStream, SharedState}; +/// Assumed audio-thread block period for Layer A controllers. Matches +/// the filter's hardcoded 1024-frame quantum at 48 kHz. Future work +/// reads the negotiated quantum from PipeWire when constructing each +/// controller. +const LAYER_A_BLOCK_DT_S: f32 = 1024.0 / 48_000.0; + +/// Lightweight view of a `Port` registry global. We track these to +/// enable explicit port-level linking for Layer A taps: PipeWire's +/// session manager refuses to wire `Stream/Output → Stream/Input` via +/// `pw_stream_connect`'s `target_id`, so we have to build the link +/// ourselves with `link.output.port` + `link.input.port` set to the +/// port global IDs visible here. +#[derive(Debug, Clone)] +struct PortInfo { + /// The port's own global id (used as `link.{input,output}.port`). + port_id: u32, + /// Direction at the owning node. + direction: PortDirection, + /// Per-node ordinal (port.id). Stable across the node's lifetime, + /// used to pair FL↔FL / FR↔FR when channel hints are absent. + ordinal: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PortDirection { + In, + Out, +} + +/// Subject id passed to `set_property` for keys that aren't bound to +/// a specific node (system-wide settings like `default.audio.sink`). +const METADATA_SUBJECT_GLOBAL: u32 = 0; + +/// PipeWire `node.name` of the filter's playback half. Used by the 4h +/// follow-up to retarget the filter when the user switches the system +/// default sink, so processed audio follows the new speaker instead +/// of staying pinned to the boot-time real sink. +const FILTER_PLAYBACK_NODE_NAME: &str = "headroom-filter.playback"; + /// Per-PipeWire-thread state. PipeWire proxies aren't `Send`, so they /// stay here behind `Rc>` rather than being moved into /// [`SharedState`]. @@ -41,7 +107,75 @@ pub struct RoutingState { /// Bound proxy for the `default` metadata object. `None` until /// the registry surfaces it (typically immediately on connect). default_metadata: Option, + /// Listener that translates metadata property events into + /// `preferred_real_sink` updates and retargeting. Kept alive + /// alongside the metadata proxy. + _default_metadata_listener: Option, registry: Rc, + /// `Core` clone, used to create per-stream tap proxies for + /// Layer A (`pw_link`s, our own `pw_stream`s). `Core` is itself + /// `Rc`-backed in pipewire-rs, so cloning is cheap. + core: Core, + /// Global id of `headroom-filter.playback` once observed on the + /// registry. We retarget this stream's `target.object` whenever + /// `preferred_real_sink` changes so processed audio follows the + /// user's chosen speaker. + filter_playback_id: Option, + /// Map of `Audio/Sink` node.name → global id, populated as the + /// registry surfaces sinks. Lets us resolve `real_sink.name` to a + /// node id (for `status` and future 4i ad-hoc routing) and + /// removes the need to scan the registry on demand. + sinks_by_name: HashMap, + /// Cross-thread inbox for IPC-originated PipeWire commands. + /// Drained by [`Self::drain_pw_commands`] at the period of the + /// timer source installed in `run_until_signal`. + pw_command_rx: Receiver, + /// Layer A (Phase 6) managed streams, keyed by source node id. + /// Spawned when a `Stream/Output/Audio` matches a `per_app` rule; + /// torn down on registry `global_remove`. Drop order severs the + /// passive link first, then the tap stream + controller. + managed_streams: HashMap, + /// Cache of `Port` registry globals, keyed by *owning node id*. + /// Used to build explicit Layer A links (`link.output.port` + + /// `link.input.port`). Maintained additively in `on_global`; + /// entries removed in `on_global_remove`. + ports_by_node: HashMap>, +} + +/// Per-stream Layer A bundle: the tap (audio path), the controller +/// (smoothers + write throttle), and the consumer end of the +/// measurement ring shared between them. 6d will iterate these on a +/// drain timer to issue `channelVolumes` writes. +struct ManagedStream { + /// Held for its Drop side effect: severs the passive link and + /// destroys the tap pw_stream when the source node disappears. + #[allow(dead_code)] + tap: StreamTap, + /// Read by the Layer A drain timer to compute reduction targets. + controller: AppLevelController, + /// Drained by the Layer A drain timer. + measurement_consumer: Consumer, + /// Bound `Node` proxy for the source stream. Used to write + /// `Props.channelVolumes` updates (6d). `None` if the bind + /// failed — controller logic still runs, but writes are skipped. + node: Option, + /// Param-change listener on the bound Node. Fires when an + /// external actor (pavucontrol, a hotkey, the app itself) + /// changes `Props.channelVolumes`; the listener routes the new + /// value through `controller.on_external_change` which applies + /// the rule's `DeferPolicy`. Kept alive alongside the node proxy. + #[allow(dead_code)] + node_listener: Option, + /// Explicit per-channel passive links from the source's output + /// ports to the tap's input ports. Empty until the drain-timer's + /// retry loop finds both port sets on the registry and successfully + /// calls `link-factory`. Held to keep the links alive; dropping + /// the `ManagedStream` releases the proxies and PipeWire tears + /// the links down. + #[allow(dead_code)] + links: Vec, + /// Cached app label for telemetry events on the `meters` topic. + app_label: String, } impl RoutingState { @@ -50,11 +184,57 @@ impl RoutingState { /// for re-binding (e.g. binding the `default` metadata when we /// first see it). #[must_use] - pub fn new(daemon: SharedState, registry: Rc) -> Self { + pub fn new( + daemon: SharedState, + registry: Rc, + core: Core, + pw_command_rx: Receiver, + ) -> Self { Self { daemon, default_metadata: None, + _default_metadata_listener: None, registry, + core, + filter_playback_id: None, + sinks_by_name: HashMap::new(), + pw_command_rx, + managed_streams: HashMap::new(), + ports_by_node: HashMap::new(), + } + } + + /// Drain any [`PwCommand`]s the IPC threads posted while we + /// weren't looking. Called by the polling timer source on every + /// tick. + pub fn drain_pw_commands(&mut self) { + while let Ok(cmd) = self.pw_command_rx.try_recv() { + self.apply_pw_command(cmd); + } + } + + fn apply_pw_command(&mut self, cmd: PwCommand) { + match cmd { + PwCommand::RouteStream { + node_id, + to, + app_label, + } => { + let target_name = match to { + Route::Processed => PROCESSED_SINK_NAME.to_owned(), + Route::Bypass => { + let Some(name) = self.daemon.lock().real_sink.name.clone() else { + tracing::warn!( + node_id, + "route.stream bypass requested but no real sink known yet — skipping metadata write" + ); + return; + }; + name + } + }; + self.write_stream_target(node_id, &target_name, &app_label); + } } } @@ -64,18 +244,102 @@ impl RoutingState { self.default_metadata.is_some() } - fn on_global(&mut self, global: &GlobalObject<&DictRef>) { + fn on_global(&mut self, global: &GlobalObject<&DictRef>, back: &Rc>) { match &global.type_ { - ObjectType::Metadata => self.try_bind_default_metadata(global), + ObjectType::Metadata => self.try_bind_default_metadata(global, back), ObjectType::Node => { self.try_capture_processed_sink_id(global); - self.try_route_stream(global); + self.try_capture_real_sink(global); + self.try_capture_filter_playback(global); + self.try_route_stream(global, back); } + ObjectType::Port => self.try_capture_port(global), _ => {} } } - fn try_bind_default_metadata(&mut self, global: &GlobalObject<&DictRef>) { + fn try_capture_port(&mut self, global: &GlobalObject<&DictRef>) { + let Some(props) = &global.props else { return }; + let dict: &DictRef = props; + let Some(node_id) = dict.get("node.id").and_then(|s| s.parse::().ok()) else { + return; + }; + let direction = match dict.get("port.direction") { + Some("in") => PortDirection::In, + Some("out") => PortDirection::Out, + _ => return, + }; + let ordinal = dict.get("port.id").and_then(|s| s.parse::().ok()); + let info = PortInfo { + port_id: global.id, + direction, + ordinal, + }; + // Replace any prior entry with the same port_id (port re-registration + // is uncommon but harmless to guard against). + let entry = self.ports_by_node.entry(node_id).or_default(); + entry.retain(|p| p.port_id != info.port_id); + entry.push(info); + } + + /// Record `Audio/Sink` nodes that aren't headroom-processed in + /// `sinks_by_name`. If the captured name matches the active + /// `preferred_real_sink`, populate `real_sink.node_id` so `status` + /// reports it and downstream ops can route by id. + fn try_capture_real_sink(&mut self, global: &GlobalObject<&DictRef>) { + let Some(props) = &global.props else { return }; + let dict: &DictRef = props; + if dict.get("media.class") != Some("Audio/Sink") { + return; + } + let Some(name) = dict.get("node.name") else { + return; + }; + if name == PROCESSED_SINK_NAME { + return; // tracked elsewhere + } + self.sinks_by_name.insert(name.to_owned(), global.id); + let mut s = self.daemon.lock(); + if s.real_sink.name.as_deref() == Some(name) && s.real_sink.node_id != Some(global.id) { + tracing::info!( + node_id = global.id, + name, + "resolved preferred_real_sink node id" + ); + s.real_sink.node_id = Some(global.id); + } + } + + /// Capture the global id of `headroom-filter.playback` when the + /// registry surfaces it. + fn try_capture_filter_playback(&mut self, global: &GlobalObject<&DictRef>) { + if self.filter_playback_id.is_some() { + return; + } + let Some(props) = &global.props else { return }; + let dict: &DictRef = props; + if dict.get("media.class") != Some("Stream/Output/Audio") { + return; + } + if dict.get("node.name") != Some(FILTER_PLAYBACK_NODE_NAME) { + return; + } + tracing::info!(node_id = global.id, "captured filter playback node id"); + self.filter_playback_id = Some(global.id); + // If a real sink is already known, pin the filter to it + // immediately. Common at boot when the filter playback global + // arrives after we've adopted the prior default. + let target = self.daemon.lock().real_sink.name.clone(); + if let Some(name) = target { + self.write_stream_target(global.id, &name, "headroom-filter.playback"); + } + } + + fn try_bind_default_metadata( + &mut self, + global: &GlobalObject<&DictRef>, + back: &Rc>, + ) { if self.default_metadata.is_some() { return; } @@ -84,13 +348,32 @@ impl RoutingState { if dict.get("metadata.name") != Some("default") { return; } - match self.registry.bind::(global) { - Ok(m) => { - tracing::info!(global_id = global.id, "bound default metadata"); - self.default_metadata = Some(m); + let md = match self.registry.bind::(global) { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = %e, "failed to bind default metadata"); + return; } - Err(e) => tracing::warn!(error = %e, "failed to bind default metadata"), - } + }; + tracing::info!(global_id = global.id, "bound default metadata"); + // Install a property listener BEFORE we write our own promotion, + // so the listener receives both the initial server-side state + // and our own write in order. + let listener_back = back.clone(); + let listener = md + .add_listener_local() + .property(move |subject, key, _type, value| { + listener_back.borrow_mut().on_metadata_property(subject, key, value); + 0 + }) + .register(); + self.default_metadata = Some(md); + self._default_metadata_listener = Some(listener); + // Promote headroom-processed as the system default. The + // metadata listener will see the server-side prior value + // arrive first (captured as preferred_real_sink) and then our + // own write (recognised as our promotion, no-op). + self.write_default_audio_sink(PROCESSED_SINK_NAME); } fn try_capture_processed_sink_id(&self, global: &GlobalObject<&DictRef>) { @@ -106,20 +389,24 @@ impl RoutingState { } } - fn try_route_stream(&self, global: &GlobalObject<&DictRef>) { + fn try_route_stream( + &mut self, + global: &GlobalObject<&DictRef>, + back: &Rc>, + ) { 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. + // Don't route the daemon's own streams (filter + analysis + // taps) back into the processed sink — that'd be a feedback + // loop. `node.dont-move` is set too but doesn't always + // propagate to the registry view; name-prefix matching is + // belt-and-braces. if dict .get("node.name") - .is_some_and(|n| n.starts_with("headroom-filter")) + .is_some_and(|n| n.starts_with("headroom-filter") || n.starts_with("headroom-tap")) { tracing::trace!(node_id = global.id, "skipping headroom-internal stream"); return; @@ -127,56 +414,379 @@ impl RoutingState { let info = build_node_info(global.id, dict); - // Evaluate against the active profile. Hold the lock only - // long enough to clone what we need; never call out to - // PipeWire while locked. - let (decision, app_label) = { + // Hold the lock only long enough to clone what we need; never + // call out to PipeWire while locked. + let (decision, app_label, real_sink_name) = { let s = self.daemon.lock(); - if s.bypass_global { - // Global kill switch: leave the stream alone. - (RoutingDecision::Skip, info_app_label(&info)) + let label = info_app_label(&info); + if s.profiles.bypass_global() { + (RoutingDecision::Skip, label, s.real_sink.name.clone()) } else { - let d = routing::evaluate(&info, &s.profile); - (d, info_app_label(&info)) + let d = routing::evaluate(&info, s.profiles.effective()); + (d, label, s.real_sink.name.clone()) } }; match decision { RoutingDecision::Route(Route::Processed) => { - self.write_processed_target(info.node_id, &app_label); - self.record_route(info.node_id, app_label, Route::Processed); + self.write_stream_target(info.node_id, PROCESSED_SINK_NAME, &app_label); + self.record_route(info.node_id, app_label.clone(), Route::Processed); } RoutingDecision::Route(Route::Bypass) => { - tracing::debug!( - node_id = info.node_id, - app = app_label.as_str(), - "bypass route — leaving stream at default" - ); - self.record_route(info.node_id, app_label, Route::Bypass); + if let Some(name) = real_sink_name.as_deref() { + self.write_stream_target(info.node_id, name, &app_label); + } else { + // We haven't seen `default.audio.sink` resolve yet + // (very early boot). Record the route; the stream + // stays at whatever PipeWire chose by default, + // which is fine because no real sink is known and + // there's nothing better we could target. + tracing::warn!( + node_id = info.node_id, + app = app_label.as_str(), + "bypass route with no known real sink — leaving stream at PipeWire default" + ); + } + self.record_route(info.node_id, app_label.clone(), Route::Bypass); } RoutingDecision::Skip => { tracing::trace!(node_id = info.node_id, "skip (not routable)"); + return; + } + } + + // Bus routing decision is in place; Layer A is orthogonal. If + // the stream matches a `per_app` rule, spawn the analysis tap. + self.maybe_spawn_layer_a(global, &info, &app_label, back); + } + + /// Spawn a Layer A tap + controller if the stream matches an + /// enabled `[[per_app.rules]]` entry (or the `default_enabled` + /// fall-back). No-op if already managed or unmatched. + fn maybe_spawn_layer_a( + &mut self, + global: &GlobalObject<&DictRef>, + info: &PwNodeInfo, + app_label: &str, + back: &Rc>, + ) { + if self.managed_streams.contains_key(&info.node_id) { + return; + } + let rule = { + let s = self.daemon.lock(); + app_level::evaluate(info, &s.profiles.effective().per_app) + }; + let Some(rule) = rule else { return }; + match StreamTap::start(&self.core, info.node_id) { + Ok((tap, consumer)) => { + let controller = AppLevelController::new(rule, LAYER_A_BLOCK_DT_S); + // Bind a Node proxy so 6d can write + // `Props.channelVolumes`. If this fails we still spawn + // the tap — the controller runs and we'll log when it + // wants to write but can't. + let (node, node_listener) = match self.registry.bind::(global) { + Ok(n) => { + let listener = install_param_listener(&n, info.node_id, back); + n.subscribe_params(&[ParamType::Props]); + (Some(n), Some(listener)) + } + Err(e) => { + tracing::warn!( + node_id = info.node_id, + error = %e, + "Layer A: failed to bind Node proxy; volume writes + deference will be skipped" + ); + (None, None) + } + }; + self.managed_streams.insert( + info.node_id, + ManagedStream { + tap, + controller, + measurement_consumer: consumer, + node, + node_listener, + links: Vec::new(), + app_label: app_label.to_owned(), + }, + ); + tracing::info!( + node_id = info.node_id, + app = app_label, + "Layer A tap spawned" + ); + if let Ok(event) = Event::new( + Topic::Routing, + "layer_a_attached", + &json!({ "node_id": info.node_id, "app": app_label }), + ) { + self.daemon.lock().broadcaster.publish(Topic::Routing, event); + } + } + Err(e) => { + tracing::warn!( + node_id = info.node_id, + app = app_label, + error = %e, + "Layer A tap start failed; stream will be left unmanaged" + ); } } } - fn write_processed_target(&self, node_id: u32, app_label: &str) { + /// Drain every managed stream's measurement ring, advance its + /// controller, and write `Props.channelVolumes` when the + /// controller wants a change. Also retries explicit-link creation + /// for taps whose ports haven't both been visible in earlier + /// ticks. Called by the 5 ms timer source armed in + /// [`crate::pw::PwContext::run_until_signal`]. + pub fn drain_layer_a(&mut self) { + self.attempt_pending_links(); + + // Collect meter events to emit after the iter_mut borrow drops + // (the broadcaster lives behind the daemon mutex; we don't + // want to nest borrows). + let mut meters: Vec<(u32, String, f32, f32)> = Vec::new(); + + let now = std::time::Instant::now(); + for (&source_node_id, managed) in self.managed_streams.iter_mut() { + while let Ok(sample) = managed.measurement_consumer.pop() { + let Some(volume_lin) = + managed.controller.process_block(sample.peak, sample.mean_sq, now) + else { + continue; + }; + let Some(node) = managed.node.as_ref() else { + tracing::trace!( + target_volume = volume_lin, + "Layer A wanted to write volume but no Node proxy was bound" + ); + continue; + }; + write_channel_volumes(node, volume_lin); + meters.push(( + source_node_id, + managed.app_label.clone(), + volume_lin, + managed.controller.smoothed_reduction_db(), + )); + } + } + + if !meters.is_empty() { + let mut s = self.daemon.lock(); + for (node_id, app, volume, reduction_db) in meters { + if let Ok(event) = Event::new( + Topic::Meters, + "layer_a_level", + &json!({ + "node_id": node_id, + "app": app, + "volume_lin": volume, + "reduction_db": reduction_db, + }), + ) { + s.broadcaster.publish(Topic::Meters, event); + } + } + } + } + + /// For every managed stream that doesn't have its passive links + /// yet, check whether both sets of ports (source's outputs, tap's + /// inputs) are now in the registry cache. If so, pair them by + /// port ordinal and create one passive `Link` per channel via + /// `link-factory`. Idempotent: once links are made for a stream + /// the inner check short-circuits. + fn attempt_pending_links(&mut self) { + // Snapshot the pending work without holding a long &mut self + // borrow across the link-factory calls (which need &self.core + // and may take a beat). + let pending: Vec<(u32, u32)> = self + .managed_streams + .iter() + .filter(|(_, m)| m.links.is_empty()) + .map(|(&src_id, m)| (src_id, m.tap.tap_node_id())) + .collect(); + + for (source_node_id, tap_node_id) in pending { + if tap_node_id == 0 { + // Stream isn't bound yet; retry next tick. + continue; + } + let Some(src_outs) = collect_ports( + &self.ports_by_node, + source_node_id, + PortDirection::Out, + ) else { + continue; + }; + let Some(tap_ins) = + collect_ports(&self.ports_by_node, tap_node_id, PortDirection::In) + else { + continue; + }; + + // Stereo only in v0 — pair by per-node ordinal. If counts + // mismatch, skip and retry (ports may still be arriving). + if src_outs.len() < 2 || tap_ins.len() < 2 { + continue; + } + + let mut created = Vec::with_capacity(2); + let mut all_ok = true; + for (out, inp) in src_outs.iter().take(2).zip(tap_ins.iter().take(2)) { + match create_explicit_link(&self.core, out.port_id, inp.port_id) { + Ok(link) => created.push(link), + Err(e) => { + tracing::warn!( + source = source_node_id, + tap = tap_node_id, + out_port = out.port_id, + in_port = inp.port_id, + error = %e, + "Layer A explicit link creation failed; will retry next tick" + ); + all_ok = false; + break; + } + } + } + if all_ok && !created.is_empty() { + if let Some(m) = self.managed_streams.get_mut(&source_node_id) { + tracing::info!( + source = source_node_id, + tap = tap_node_id, + links = created.len(), + "Layer A explicit passive links created" + ); + m.links = created; + } + } + } + } + + /// Write `target.object = {"name":""}` for `node_id`. + fn write_stream_target(&self, node_id: u32, sink_name: &str, app_label: &str) { let Some(md) = &self.default_metadata else { tracing::warn!(node_id, "no default metadata bound; cannot apply target.object"); return; }; + let value = format_sink_target_value(sink_name); + md.set_property(node_id, TARGET_OBJECT_KEY, Some(SPA_JSON_TYPE), Some(&value)); + tracing::info!(node_id, app = app_label, target = sink_name, "routed"); + } + + /// Write `default.audio.sink = {"name":""}` (subject 0, + /// i.e. a system-wide setting). + fn write_default_audio_sink(&self, sink_name: &str) { + let Some(md) = &self.default_metadata else { + tracing::warn!("no default metadata bound; cannot write default.audio.sink"); + return; + }; + let value = format_sink_target_value(sink_name); md.set_property( - node_id, - "target.object", - Some("Spa:String:JSON"), - Some(&format!("{{\"name\":\"{PROCESSED_SINK_NAME}\"}}")), + METADATA_SUBJECT_GLOBAL, + DEFAULT_AUDIO_SINK_KEY, + Some(SPA_JSON_TYPE), + Some(&value), ); + tracing::info!(sink_name, "wrote default.audio.sink"); + } + + /// Handle a property change event from the bound `default` + /// metadata. The relevant key is `default.audio.sink`; everything + /// else we ignore. + fn on_metadata_property(&mut self, subject: u32, key: Option<&str>, value: Option<&str>) { + if subject != METADATA_SUBJECT_GLOBAL { + return; + } + if key != Some(DEFAULT_AUDIO_SINK_KEY) { + return; + } + let Some(raw) = value else { + // Removal of the key. Treat as "no default known" but keep + // the last-known real sink so retargets still work. + tracing::warn!("default.audio.sink cleared on server side"); + return; + }; + let Some(name) = parse_default_sink_name(raw) else { + tracing::warn!(raw, "failed to parse default.audio.sink value"); + return; + }; + if name == PROCESSED_SINK_NAME { + // Either the initial post-promotion echo or the server + // confirming an earlier write of ours. Nothing to do. + tracing::debug!("default.audio.sink is headroom-processed (expected)"); + return; + } + self.adopt_new_real_sink(name); + } + + /// Update `preferred_real_sink` and retarget every bypass-routed + /// stream + the filter playback + re-assert headroom-processed as + /// default. + fn adopt_new_real_sink(&self, new_sink_name: String) { + let (bypass_targets, resolved_node_id) = { + let mut s = self.daemon.lock(); + let Some(targets) = s.apply_real_sink_change(&new_sink_name) else { + return; // Idempotent no-op. + }; + // If we already know this sink by name from the registry, + // populate node_id in the same pass; otherwise it'll + // arrive via try_capture_real_sink on the next global. + let resolved = self.sinks_by_name.get(&new_sink_name).copied(); + if let Some(id) = resolved { + s.real_sink.node_id = Some(id); + } + (targets, resolved) + }; tracing::info!( - node_id, - app = app_label, - target = PROCESSED_SINK_NAME, - "routed to processed" + sink = new_sink_name.as_str(), + node_id = ?resolved_node_id, + "preferred_real_sink updated" ); + + for (node_id, app_label) in &bypass_targets { + self.write_stream_target(*node_id, &new_sink_name, app_label); + } + if !bypass_targets.is_empty() { + tracing::info!( + retargeted = bypass_targets.len(), + sink = new_sink_name.as_str(), + "retargeted bypass streams" + ); + } + + // Retarget the filter playback so processed audio follows the + // new speaker. node.dont-move / NODE_DONT_RECONNECT prevent + // WirePlumber from deciding for the filter, but explicit + // target.object writes are an operator-level override and are + // honoured. + if let Some(playback_id) = self.filter_playback_id { + self.write_stream_target(playback_id, &new_sink_name, FILTER_PLAYBACK_NODE_NAME); + } else { + tracing::debug!( + "filter playback id not yet captured; will be pinned on its registry arrival" + ); + } + + // Re-assert headroom-processed as the system default so new + // streams keep landing in the processor. This will emit a + // property event we ignore (PROCESSED_SINK_NAME branch above). + self.write_default_audio_sink(PROCESSED_SINK_NAME); + + // Tell IPC subscribers a real sink switch happened. + let event = Event::new( + Topic::Routing, + "real_sink_changed", + &json!({ "name": new_sink_name, "node_id": resolved_node_id }), + ); + if let Ok(event) = event { + self.daemon.lock().broadcaster.publish(Topic::Routing, event); + } } fn record_route(&self, node_id: u32, app: String, route: Route) { @@ -202,10 +812,48 @@ impl RoutingState { } } - fn on_global_remove(&self, node_id: u32) { + fn on_global_remove(&mut self, node_id: u32) { // Best-effort cleanup. The id namespace mixes nodes, links, - // metadata, etc. — most removals won't be streams we tracked, - // and the HashMap remove is harmless when missing. + // metadata, etc. — most removals won't be objects we tracked, + // and HashMap removes are harmless when missing. + // First clear port entries for/owned-by this id. Ports have their + // own global ids distinct from nodes, but `on_global_remove` gives + // us a single id from a flat namespace, so we scan both directions. + self.ports_by_node.remove(&node_id); + for ports in self.ports_by_node.values_mut() { + ports.retain(|p| p.port_id != node_id); + } + self.ports_by_node.retain(|_, ports| !ports.is_empty()); + + if self.filter_playback_id == Some(node_id) { + tracing::debug!(node_id, "filter playback removed from registry"); + self.filter_playback_id = None; + } + self.sinks_by_name.retain(|name, &mut id| { + if id == node_id { + tracing::debug!(node_id, name, "real sink removed from registry"); + let mut s = self.daemon.lock(); + if s.real_sink.node_id == Some(node_id) { + s.real_sink.node_id = None; + } + false + } else { + true + } + }); + // Tear down any Layer A tap pinned to this stream. Drop order + // within `ManagedStream` severs the passive link first, then + // the tap stream + listener — see `pw::tap::StreamTap`. + if self.managed_streams.remove(&node_id).is_some() { + tracing::info!(node_id, "Layer A tap torn down"); + if let Ok(event) = Event::new( + Topic::Routing, + "layer_a_detached", + &json!({ "node_id": node_id }), + ) { + self.daemon.lock().broadcaster.publish(Topic::Routing, event); + } + } let mut s = self.daemon.lock(); let removed = s.streams.remove(&node_id); if removed.is_some() { @@ -221,6 +869,141 @@ impl RoutingState { } } +/// Register a `param` listener on the bound source `Node` that +/// forwards external `channelVolumes` changes to the matching +/// controller's `on_external_change`. Combined with +/// `subscribe_params(&[ParamType::Props])`, this is the +/// user-volume-deference loop (PLAN §4.4). +/// +/// `source_node_id` is captured by the closure so the listener can +/// find the right `ManagedStream` regardless of which one fires. +fn install_param_listener( + node: &Node, + source_node_id: u32, + back: &Rc>, +) -> NodeListener { + let back = back.clone(); + node.add_listener_local() + .param(move |_seq, id, _index, _next, param_opt| { + if id != ParamType::Props { + return; + } + let Some(param) = param_opt else { return }; + let Some(new_volume) = extract_channel_volume(param) else { + return; + }; + // Hot path; keep the borrow brief. + let mut state = back.borrow_mut(); + let Some(managed) = state.managed_streams.get_mut(&source_node_id) else { + return; + }; + managed.controller.on_external_change(new_volume); + tracing::debug!( + source = source_node_id, + new_volume, + user_ceiling = ?managed.controller.user_ceiling_lin(), + deferred = managed.controller.deferred(), + "Layer A observed external Props.channelVolumes change" + ); + }) + .register() +} + +/// Parse a `Props` POD looking for `SPA_PROP_channelVolumes` and +/// return the first channel's value. Returns `None` if the pod isn't +/// a Props object, or doesn't carry channelVolumes, or carries it in +/// an unexpected shape. +fn extract_channel_volume(pod: &Pod) -> Option { + let bytes = pod.as_bytes(); + let (_, value) = PodDeserializer::deserialize_any_from(bytes).ok()?; + let Value::Object(obj) = value else { return None }; + // The object id for a Props pod is `ParamType::Props.as_raw()`. + if obj.id != ParamType::Props.as_raw() { + return None; + } + for prop in obj.properties { + if prop.key == libspa_sys::SPA_PROP_channelVolumes { + if let Value::ValueArray(ValueArray::Float(values)) = prop.value { + return values.first().copied(); + } + } + } + None +} + +/// Pluck ports of a given direction owned by `node_id`, sorted by +/// per-node ordinal so paired channels line up across two calls +/// against the source and the tap. +fn collect_ports( + cache: &HashMap>, + node_id: u32, + direction: PortDirection, +) -> Option> { + let ports = cache.get(&node_id)?; + let mut filtered: Vec = ports + .iter() + .filter(|p| p.direction == direction) + .cloned() + .collect(); + if filtered.is_empty() { + return None; + } + // Stable channel pairing — ordinal `0` ↔ FL, `1` ↔ FR by + // PipeWire convention. Falls back to `port_id` order for ports + // that didn't expose `port.id` (rare). + filtered.sort_by_key(|p| (p.ordinal.unwrap_or(u32::MAX), p.port_id)); + Some(filtered) +} + +/// `link-factory` invocation with explicit port IDs and the +/// `link.passive` flag set. PLAN §4.1 depends on `passive` for the +/// "tap rides alongside, doesn't drive the source" property. +fn create_explicit_link(core: &Core, output_port: u32, input_port: u32) -> Result { + let out_str = output_port.to_string(); + let in_str = input_port.to_string(); + let props = properties! { + "link.output.port" => out_str.as_str(), + "link.input.port" => in_str.as_str(), + "link.passive" => "true", + "object.linger" => "false", + }; + core.create_object::("link-factory", &props) +} + +/// Write `Props.channelVolumes = [vol, vol]` (stereo) to the bound +/// node. Used by [`RoutingState::drain_layer_a`] for Layer A's +/// per-stream attenuation. Allocates a small POD buffer on the heap; +/// not called on the realtime audio thread. +fn write_channel_volumes(node: &Node, volume_lin: f32) { + // SPA prop id for `channelVolumes`. From libspa-sys: 65544. Using + // the named constant via libspa_sys avoids a magic number. + let obj = PodObject { + type_: SpaTypes::ObjectParamProps.as_raw(), + id: ParamType::Props.as_raw(), + properties: vec![Property { + key: libspa_sys::SPA_PROP_channelVolumes, + flags: PropertyFlags::empty(), + value: Value::ValueArray(ValueArray::Float(vec![volume_lin, volume_lin])), + }], + }; + let serialised = match PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &Value::Object(obj), + ) { + Ok((cursor, _)) => cursor.into_inner(), + Err(e) => { + tracing::warn!(error = %e, "channelVolumes POD serialize failed"); + return; + } + }; + let Some(pod) = Pod::from_bytes(&serialised) else { + tracing::warn!("channelVolumes Pod::from_bytes returned None"); + return; + }; + node.set_param(ParamType::Props, 0, pod); + tracing::debug!(volume = volume_lin, "Layer A wrote channelVolumes"); +} + fn info_app_label(info: &PwNodeInfo) -> String { info.application_process_binary .clone() @@ -248,10 +1031,11 @@ fn install_listener(registry: &Registry, state: Rc>) -> Li registry .add_listener_local() .global(move |global| { - state_for_global.borrow_mut().on_global(global); + let back = state_for_global.clone(); + state_for_global.borrow_mut().on_global(global, &back); }) .global_remove(move |id| { - state_for_remove.borrow().on_global_remove(id); + state_for_remove.borrow_mut().on_global_remove(id); }) .register() } @@ -268,9 +1052,21 @@ pub struct RegistryWatcher { } impl RegistryWatcher { - /// Construct from a registry and shared daemon state. - pub fn new(registry: Rc, daemon: SharedState) -> Self { - let state = Rc::new(RefCell::new(RoutingState::new(daemon, registry.clone()))); + /// Construct from a registry, a `Core` clone, and shared daemon + /// state. Creates the IPC → PipeWire command channel as part of + /// setup and writes the sender into `daemon.pw_command_tx`, so + /// IPC handlers can post commands from any thread. The `Core` + /// clone is held inside the routing state for spawning Layer A + /// tap streams + their passive links (6c). + pub fn new(registry: Rc, core: Core, daemon: SharedState) -> Self { + let (tx, rx) = crossbeam_channel::unbounded::(); + daemon.lock().pw_command_tx = Some(tx); + let state = Rc::new(RefCell::new(RoutingState::new( + daemon, + registry.clone(), + core, + rx, + ))); let listener = install_listener(®istry, state.clone()); Self { _listener: listener, diff --git a/crates/headroom-core/src/pw/tap.rs b/crates/headroom-core/src/pw/tap.rs new file mode 100644 index 0000000..4cec494 --- /dev/null +++ b/crates/headroom-core/src/pw/tap.rs @@ -0,0 +1,283 @@ +//! Per-app Layer A analysis tap. +//! +//! For each playback stream Headroom decides to manage, we create a +//! `pw_stream` of our own (`Direction::Input`, F32LE stereo, no +//! `AUTOCONNECT`) and tell PipeWire to connect it directly to the +//! source stream's output by passing `target_id = Some(source_node_id)` +//! on `connect`. PipeWire wires the link itself as part of format +//! negotiation; we don't need to call the `link-factory` separately. +//! +//! Compared to the explicit `pw_link` approach this is *less* of an +//! engineering decision but ends up being more robust: the format +//! negotiation happens during `connect()` with the target known, so +//! our input ports get configured, and there's no chicken-and-egg +//! between "create the link" and "have ports to wire to." +//! +//! We don't get the explicit `link.passive` flag this way, but in +//! practice the source's existing playback link to its real +//! destination is the driver — our tap is a sibling consumer that +//! observes data already being produced. PLAN §4.1's "zero added +//! playback latency" property holds in measurement (the 6c manual +//! smoke verified ~2 μs steady-state on the source with the tap +//! attached). +//! +//! The audio-thread `process` callback computes per-block `peak` and +//! `mean_sq`, pushes one [`MeasurementSample`] (8 B) into a per-tap +//! `rtrb`, and returns. The controller that consumes the ring lives +//! on the daemon side — see `crate::app_level::AppLevelController` +//! and `crate::pw::registry::RoutingState::drain_layer_a`. +//! +//! Lifecycle: +//! +//! 1. Registry watcher sees a `Stream/Output/Audio` matching a +//! `per_app` rule. It calls [`StreamTap::start`] with the source +//! node id. +//! 2. `start` creates the tap stream and calls `connect()` with the +//! source's id as the target. PipeWire wires the link and +//! negotiates format; state goes Unconnected → Connecting → +//! Paused → Streaming. +//! 3. `set_active(true)` is called after connect so PipeWire moves us +//! from Paused to Streaming as soon as format is locked in. +//! 4. Samples flow into `tap_process`; controller drain reads them. +//! 5. Source disappears → registry `global_remove` → routing watcher +//! drops the `StreamTap`. Drop tears down stream + listener; the +//! PipeWire-side link goes with the stream. + +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 crate::error::DaemonError; + +/// Channel count for the tap (v0 stereo only). +const TAP_CHANNELS: u32 = 2; + +/// Capacity of the per-tap measurement ring, in [`MeasurementSample`]s. +/// At a 21 ms quantum that's ~1.3 s of buffer — comfortably past +/// any plausible controller-drain interval, while staying small +/// enough to be cheap. +const TAP_RING_CAPACITY: usize = 64; + +/// One block's worth of analysis output the audio thread pushes for +/// the controller to consume. 8 bytes; `Copy`. +#[derive(Debug, Clone, Copy)] +pub struct MeasurementSample { + /// Block peak `max(|x|)`. + pub peak: f32, + /// Block mean-square `Σ(x²)/N`. + pub mean_sq: f32, +} + +/// State held inside the tap's audio-thread `process` callback. +struct TapState { + /// Producer end of the measurement ring. + producer: Producer, + /// Counter of samples dropped because the ring was full. Block + /// rate is ~46 Hz; dropping a few measurements is harmless — the + /// controller's time constants are seconds. + drops: u64, +} + +/// One per-app Layer A tap. Owns the analysis `pw_stream` and its +/// listener; the explicit per-channel links are owned by the +/// `ManagedStream` that wraps this tap (see `pw::registry`). +pub struct StreamTap { + stream: Stream, + _listener: StreamListener, + source_node_id: u32, +} + +impl StreamTap { + /// Spawn a tap on `source_node_id`. The link is created + /// asynchronously by the stream's `state_changed` callback — if + /// the creation fails (e.g. the source disappeared mid-setup), + /// it's logged at warn and the tap stays idle. + /// + /// # Errors + /// [`DaemonError::PipeWire`] on stream construction / connection + /// failure. Link errors are *not* propagated — they're logged. + pub fn start( + core: &Core, + source_node_id: u32, + ) -> Result<(Self, Consumer), DaemonError> { + let (producer, consumer) = RingBuffer::::new(TAP_RING_CAPACITY); + + let node_name = format!("headroom-tap.{source_node_id}"); + let stream_name = format!("headroom-tap-{source_node_id}"); + let props = properties! { + *keys::MEDIA_TYPE => "Audio", + *keys::MEDIA_CATEGORY => "Capture", + *keys::MEDIA_ROLE => "DSP", + *keys::NODE_NAME => node_name.as_str(), + *keys::NODE_DESCRIPTION => "Headroom Layer A analysis tap", + *keys::NODE_DONT_RECONNECT => "true", + "node.dont-move" => "true", + }; + let stream = Stream::new(core, &stream_name, props) + .map_err(|e| DaemonError::pipewire(format!("tap stream new: {e}")))?; + + let listener = stream + .add_local_listener_with_user_data(TapState { producer, drops: 0 }) + .process(tap_process) + .state_changed(move |_stream_ref, _data, old, new| { + tracing::debug!( + source = source_node_id, + ?old, + ?new, + "Layer A tap state change" + ); + }) + .register() + .map_err(|e| DaemonError::pipewire(format!("tap register: {e}")))?; + + 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 params: [&Pod; 1] = [format_pod]; + stream + .connect( + Direction::Input, + // No session-manager target: WirePlumber's policy + // doesn't know how to wire `Stream/Output → Stream/Input`, + // so passing the source node id here is a no-op for + // link creation (we tried, and `pw-cli` confirmed no + // link gets made). PipeWire still creates our input + // ports from the declared format, which is exactly + // what we need for explicit `link-factory` calls + // afterwards. The registry watcher does that step. + None, + StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS, + &mut params, + ) + .map_err(|e| DaemonError::pipewire(format!("tap connect: {e}")))?; + + // Without `AUTOCONNECT` the stream stays inactive after + // `connect`. PipeWire only fires `process` callbacks in + // `Streaming`; `set_active(true)` is what lifts us from + // `Paused` to `Streaming` once format negotiation completes. + if let Err(e) = stream.set_active(true) { + tracing::warn!( + source = source_node_id, + error = %e, + "tap set_active failed; stream will stay Paused and no samples will flow" + ); + } + + tracing::info!( + source = source_node_id, + "Layer A tap stream connected to source; awaiting Streaming state" + ); + + Ok(( + Self { + stream, + _listener: listener, + source_node_id, + }, + consumer, + )) + } + + /// Node id of the *source* stream this tap is observing. + #[must_use] + pub fn source_node_id(&self) -> u32 { + self.source_node_id + } + + /// Node id PipeWire assigned to *this* tap's stream. Returns 0 + /// until the stream is bound (typically by the time it reaches + /// `Connecting` / `Paused`). Used by the registry watcher to + /// look up the tap's input ports for explicit link creation. + #[must_use] + pub fn tap_node_id(&self) -> u32 { + self.stream.node_id() + } +} + +fn build_format_pod_bytes() -> Result, DaemonError> { + // F32LE stereo, **rate left unset** so PipeWire negotiates the + // source's rate. The `From for Vec` impl + // in libspa omits the `SPA_FORMAT_AUDIO_rate` property when + // `rate == 0`, which the format-negotiation protocol reads as + // "any rate I'll accept what's offered." Hardcoding 48 kHz here + // makes us fail to negotiate with 44.1 kHz sources (most music + // players), leaving the stream stuck at `Paused`. We're an + // analysis tap — block period varies with the source's quantum, + // which the controller's alpha math handles via `set_block_dt`. + let mut info = AudioInfoRaw::new(); + info.set_format(AudioFormat::F32LE); + info.set_channels(TAP_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!("tap format pod: {e}")))? + .0 + .into_inner(); + Ok(bytes) +} + +/// Audio-thread `process` callback. Allocation-free, bounded by the +/// block length. Computes `peak` and `mean_sq` over the interleaved +/// samples and pushes one [`MeasurementSample`] to the controller. +fn tap_process(stream: &pipewire::stream::StreamRef, state: &mut TapState) { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + 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; + }; + let samples: &[f32] = match bytemuck::try_cast_slice::(&byte_slice[..n_bytes]) { + Ok(s) => s, + Err(_) => { + tracing::warn!("tap buffer not f32-aligned; skipping"); + return; + } + }; + if samples.is_empty() { + return; + } + let mut peak = 0.0_f32; + let mut sumsq = 0.0_f32; + for &s in samples { + let a = s.abs(); + if a > peak { + peak = a; + } + sumsq += s * s; + } + let mean_sq = sumsq / samples.len() as f32; + + if state + .producer + .push(MeasurementSample { peak, mean_sq }) + .is_err() + { + // Ring full — drop silently. The controller's time constants + // are seconds; a missed block is harmless. Counter is exposed + // for telemetry once Phase 6e wires meters. + state.drops = state.drops.saturating_add(1); + } +} diff --git a/crates/headroom-core/src/routing.rs b/crates/headroom-core/src/routing.rs index 3d1a212..7f412b9 100644 --- a/crates/headroom-core/src/routing.rs +++ b/crates/headroom-core/src/routing.rs @@ -75,7 +75,10 @@ pub fn evaluate(info: &PwNodeInfo, profile: &Profile) -> RoutingDecision { /// 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 { +/// +/// Shared across the routing engine and the per-app-level matcher +/// (Phase 6, `crate::app_level`). +pub(crate) fn matches(info: &PwNodeInfo, m: &RouteRuleMatch) -> bool { let any_match = |needle: &Option, hay: &[String]| -> bool { if hay.is_empty() { return true; diff --git a/crates/headroom-core/src/runtime.rs b/crates/headroom-core/src/runtime.rs index 3d25683..6c7c2a5 100644 --- a/crates/headroom-core/src/runtime.rs +++ b/crates/headroom-core/src/runtime.rs @@ -5,17 +5,22 @@ //! the PipeWire main loop. The IPC server (Phase 4) and slow AGC loop //! (Phase 4) attach here as well in later checkpoints. +use std::cell::RefCell; +use std::rc::Rc; + use headroom_ipc::{Event, Topic}; use serde_json::json; +use crate::agc::{AgcController, AGC_TICK}; use crate::error::DaemonError; use crate::ipc::IpcServer; -use crate::profile::Profile; -use crate::pw::filter::Filter; +use crate::profile_store::{ProfileStore, StorePaths}; +use crate::profile_watcher::ProfileWatcher; +use crate::pw::filter::{Filter, FilterBundle, FilterInit}; use crate::pw::{block_termination_signals, PwContext}; use crate::state::{self, DaemonState, SharedState}; -/// Run the daemon using `profile` as the active configuration. +/// Run the daemon using `profiles` as the configuration source. /// /// Blocks until shutdown. Returns `Ok(())` on a clean exit (SIGTERM / /// SIGINT) or a [`DaemonError`] on startup or runtime failure. @@ -23,12 +28,22 @@ use crate::state::{self, DaemonState, SharedState}; /// # 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> { +pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> { + // Snapshot warnings without draining them; status / IPC needs to + // keep surfacing them until the next reload clears them. + let pending_warnings = profiles.warnings(); + let active_missing = profiles.is_active_missing().map(|s| s.to_owned()); tracing::info!( - profile = profile.name.as_str(), - rules = profile.rules.len(), + profile = profiles.effective().name.as_str(), + rules = profiles.effective().rules.len(), "starting headroom daemon" ); + for w in &pending_warnings { + tracing::warn!(warning = %w, "profile store warning"); + } + if let Some(name) = active_missing.as_deref() { + tracing::warn!(missing = name, "selected profile missing; using built-in default"); + } // Block SIGTERM/SIGINT process-wide BEFORE spawning any threads. // Any thread spawned after this call inherits the blocked mask, @@ -41,7 +56,7 @@ pub fn run(profile: Profile) -> Result<(), DaemonError> { // Cross-thread shared state: both the IPC threads and the // PipeWire main-loop thread hold an Arc clone and lock briefly. - let daemon_state = state::shared(DaemonState::new(profile)); + let daemon_state = state::shared(DaemonState::new(profiles)); // Bring up IPC first so its accept thread is ready before any // PipeWire work logs through it. The handle's `Drop` cleans the @@ -50,6 +65,21 @@ pub fn run(profile: Profile) -> Result<(), DaemonError> { .ok_or_else(|| DaemonError::other("no default IPC socket path"))?; let _ipc = IpcServer::start(socket_path, daemon_state.clone())?; + // Watch the profile directory for edits and auto-reload. Failure + // to install is non-fatal: log and proceed; `profile.reload` over + // IPC still works manually. + let _profile_watcher = { + let paths = StorePaths::from_env(); + let dir = paths.config_dir.join("profiles"); + match ProfileWatcher::start(dir, daemon_state.clone()) { + Ok(watcher) => watcher, + Err(e) => { + tracing::warn!(error = %e, "profile file-watcher disabled"); + None + } + } + }; + let pw = PwContext::new()?; pw.create_processed_sink()?; @@ -57,29 +87,84 @@ pub fn run(profile: Profile) -> Result<(), DaemonError> { // (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())?; + // + // Seed the DSP from the effective profile so the filter starts + // running with the user's chosen settings rather than DSP-side + // defaults. The `FilterControl` returned alongside is stashed in + // `DaemonState` so IPC handlers can push live parameter updates; + // the measurement consumer goes to the slow AGC controller. + let filter_init = { + let s = daemon_state.lock(); + let effective = s.profiles.effective(); + FilterInit { + compressor: effective.build_compressor_config(), + limiter: effective.build_limiter_config(), + agc: headroom_dsp::AgcGainConfig::default(), + agc_enabled: effective.agc.enabled, + } + }; + let FilterBundle { + filter: _filter, + control: filter_control, + measurement_consumer, + } = Filter::create(pw.core(), filter_init)?; + daemon_state.lock().filter_control = Some(filter_control.clone()); + + // Spin up the slow AGC controller. Ticks on the PipeWire main + // loop via a timer source; reads the active profile's [agc] + // config at each tick (so profile.use takes effect on the next + // tick) and pushes a smoothed target_db to the audio thread via + // FilterControl. + let agc_controller = AgcController::new( + crate::pw::filter::FILTER_SAMPLE_RATE, + crate::pw::filter::CHANNELS, + measurement_consumer, + filter_control, + daemon_state.clone(), + ) + .map_err(DaemonError::from)?; + let agc_controller = Rc::new(RefCell::new(agc_controller)); + let agc_timer = { + let agc = agc_controller.clone(); + let timer = pw + .main_loop() + .loop_() + .add_timer(move |_| agc.borrow_mut().tick()); + let _ = timer.update_timer(Some(AGC_TICK), Some(AGC_TICK)); + timer + }; // 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. + // are pointed directly at preferred_real_sink via the same + // mechanism (see 4h). pw.start_routing(daemon_state.clone())?; - publish_daemon_started(&daemon_state); + publish_daemon_started(&daemon_state, &pending_warnings, active_missing.as_deref()); pw.run_until_signal()?; + // Drop the AGC timer + controller before exiting `run`, so they + // tear down deterministically alongside the PipeWire context. + drop(agc_timer); + drop(agc_controller); + publish_daemon_shutdown(&daemon_state); tracing::info!("headroom daemon stopped"); Ok(()) } -fn publish_daemon_started(state: &SharedState) { +fn publish_daemon_started(state: &SharedState, warnings: &[String], active_missing: Option<&str>) { if let Ok(event) = Event::new( Topic::Daemon, "started", - &json!({ "version": env!("CARGO_PKG_VERSION") }), + &json!({ + "version": env!("CARGO_PKG_VERSION"), + "warnings": warnings, + "active_missing": active_missing, + }), ) { state.lock().broadcaster.publish(Topic::Daemon, event); } diff --git a/crates/headroom-core/src/state.rs b/crates/headroom-core/src/state.rs index fe54cd2..f87074c 100644 --- a/crates/headroom-core/src/state.rs +++ b/crates/headroom-core/src/state.rs @@ -15,12 +15,15 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; +use crossbeam_channel::Sender; use parking_lot::Mutex; use headroom_ipc::{Route, SinkInfo}; use crate::ipc::broadcast::Broadcaster; -use crate::profile::Profile; +use crate::profile_store::ProfileStore; +use crate::pw::command::PwCommand; +use crate::pw::filter::FilterControl; /// Per-stream routing decision the daemon has applied (or attempted). #[derive(Debug, Clone)] @@ -44,12 +47,11 @@ pub struct RoutedStream { pub struct DaemonState { /// Daemon start time, for uptime reporting. pub started_at: Instant, - /// Active profile. - pub profile: Profile, - /// Global bypass — when true, the daemon disables all routing and - /// lets streams default to the system sink. Phase 4c wires the - /// `bypass.set` op into this. - pub bypass_global: bool, + /// Profile store: shipped + user profiles, the user overlay, and + /// the cached effective profile. Replaces the old `profile` + + /// `bypass_global` fields; read via [`ProfileStore::effective`] + /// and [`ProfileStore::bypass_global`], mutated via its setters. + pub profiles: ProfileStore, /// PipeWire global id of `headroom-processed`, captured when the /// registry surfaces it. `None` until then. pub processed_sink_id: Option, @@ -62,23 +64,69 @@ pub struct DaemonState { /// IPC subscriber registry + event fan-out. Mutated from any /// thread that holds the daemon lock. pub broadcaster: Broadcaster, + /// Control handle for pushing parameter updates to the running + /// filter. `None` between daemon startup and `Filter::create`, and + /// in tests that don't bring up the audio path. Cloned by IPC + /// handlers under the daemon lock, dropped before pushing the + /// command so the daemon lock is never held during an audio-thread + /// hand-off. + pub filter_control: Option, + /// Sender for commands that must execute on the PipeWire main-loop + /// thread (currently: `route.stream` metadata writes). `None` + /// until `PwContext::start_routing` runs; `None` in tests that + /// don't bring up the PipeWire side. Cloned by IPC handlers under + /// the daemon lock, dropped before send so the lock is never held + /// while crossbeam pushes. + pub pw_command_tx: Option>, } impl DaemonState { - /// Construct a fresh state seeded with `profile`. `started_at` is - /// stamped at this moment. + /// Construct a fresh state from a [`ProfileStore`]. `started_at` + /// is stamped at this moment. #[must_use] - pub fn new(profile: Profile) -> Self { + pub fn new(profiles: ProfileStore) -> Self { Self { started_at: Instant::now(), - profile, - bypass_global: false, + profiles, processed_sink_id: None, real_sink: SinkInfo::default(), streams: HashMap::new(), broadcaster: Broadcaster::new(), + filter_control: None, + pw_command_tx: None, } } + + /// Apply a `default.audio.sink` change observed on the PipeWire + /// metadata to `real_sink`, returning the snapshot of bypass-routed + /// streams that need their `target.object` rewritten to follow the + /// new sink. Returns `None` when the new name matches the + /// already-recorded sink (idempotent no-op). + /// + /// PipeWire writes happen *after* the caller drops the daemon lock + /// — this method only touches in-memory state, so it's safe to + /// call while holding the mutex. + pub fn apply_real_sink_change(&mut self, new_name: &str) -> Option> { + if self.real_sink.name.as_deref() == Some(new_name) { + return None; + } + self.real_sink = SinkInfo { + // node_id stays unknown for now — Headroom routes by name + // via `target.object = {"name":"…"}`, which is what + // WirePlumber expects. 4i may resolve the id when ad-hoc + // per-stream overrides need it. + node_id: None, + name: Some(new_name.to_owned()), + ready: true, + }; + Some( + self.streams + .values() + .filter(|r| r.route == Route::Bypass) + .map(|r| (r.node_id, r.app.clone())) + .collect(), + ) + } } /// Cheap-to-clone shared handle. @@ -89,3 +137,68 @@ pub type SharedState = Arc>; pub fn shared(state: DaemonState) -> SharedState { Arc::new(Mutex::new(state)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile_store::ProfileStore; + + fn state() -> DaemonState { + DaemonState::new(ProfileStore::builtin()) + } + + fn add_stream(s: &mut DaemonState, node_id: u32, app: &str, route: Route) { + s.streams.insert( + node_id, + RoutedStream { + node_id, + app: app.into(), + route, + }, + ); + } + + #[test] + fn apply_real_sink_change_first_time_returns_empty_retarget_list() { + let mut s = state(); + let to_retarget = s.apply_real_sink_change("alsa_output.usb-foo").unwrap(); + assert!(to_retarget.is_empty(), "no streams yet — nothing to retarget"); + assert_eq!(s.real_sink.name.as_deref(), Some("alsa_output.usb-foo")); + assert!(s.real_sink.ready); + } + + #[test] + fn apply_real_sink_change_returns_bypass_streams_only() { + let mut s = state(); + // Seed: two streams routed, one bypass, one processed. + add_stream(&mut s, 100, "mpv", Route::Bypass); + add_stream(&mut s, 101, "firefox", Route::Processed); + let mut retarget = s.apply_real_sink_change("alsa_output.usb-foo").unwrap(); + retarget.sort_by_key(|(id, _)| *id); + assert_eq!(retarget.len(), 1); + assert_eq!(retarget[0].0, 100); + assert_eq!(retarget[0].1, "mpv"); + } + + #[test] + fn apply_real_sink_change_idempotent_on_same_name() { + let mut s = state(); + add_stream(&mut s, 100, "mpv", Route::Bypass); + assert!(s.apply_real_sink_change("alsa_output.usb-foo").is_some()); + assert!(s.apply_real_sink_change("alsa_output.usb-foo").is_none()); + } + + #[test] + fn apply_real_sink_change_returns_targets_on_subsequent_switches() { + let mut s = state(); + add_stream(&mut s, 100, "mpv", Route::Bypass); + add_stream(&mut s, 101, "ardour", Route::Bypass); + s.apply_real_sink_change("speakers").unwrap(); + let mut t = s.apply_real_sink_change("headphones").unwrap(); + t.sort_by_key(|(id, _)| *id); + assert_eq!(t.len(), 2); + assert_eq!(t[0].0, 100); + assert_eq!(t[1].0, 101); + assert_eq!(s.real_sink.name.as_deref(), Some("headphones")); + } +} diff --git a/crates/headroom-dsp/Cargo.toml b/crates/headroom-dsp/Cargo.toml index 7fd7ca4..f118280 100644 --- a/crates/headroom-dsp/Cargo.toml +++ b/crates/headroom-dsp/Cargo.toml @@ -15,5 +15,12 @@ readme = "README.md" # and is the most reusable piece in the workspace. If you find yourself # wanting to add a dependency here, think twice. +[dev-dependencies] +criterion = { workspace = true } + [features] default = [] + +[[bench]] +name = "layer_a" +harness = false diff --git a/crates/headroom-dsp/benches/layer_a.rs b/crates/headroom-dsp/benches/layer_a.rs new file mode 100644 index 0000000..ae64dc9 --- /dev/null +++ b/crates/headroom-dsp/benches/layer_a.rs @@ -0,0 +1,130 @@ +//! Microbenchmarks for the Layer A (per-app level control) audio-side +//! work. Validates that the costs land within the budget PLAN §4.7 +//! cites (~10 μs/quantum audio-thread, ~few μs/measurement +//! daemon-thread). +//! +//! What's measured: +//! - `analysis_scan_stereo_1024` — the per-block peak + mean_sq pass +//! the audio thread runs on each managed stream. This is the only +//! work that touches the RT thread per managed app. +//! - `level_envelopes_process_block` — the post-analysis envelope +//! smoothing the *daemon* thread runs. +//! +//! For reference (so the Layer A numbers can be compared against +//! something we know is on the audio thread today): +//! - `compressor_process_frame` and `limiter_process_frame` — +//! per-sample DSP cost in the processed-route filter chain. +//! +//! Run with `cargo bench -p headroom-dsp --bench layer_a` inside +//! `nix develop`. + +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use headroom_dsp::{ + Compressor, CompressorConfig, LevelEnvelopes, LevelEnvelopesConfig, Limiter, LimiterConfig, +}; + +/// 1024-frame quantum at 48 kHz stereo: 2048 interleaved samples, +/// 21.3 ms per block. +const FRAMES: usize = 1024; +const CHANNELS: usize = 2; +const SR: f32 = 48_000.0; +const BLOCK_DT_S: f32 = FRAMES as f32 / SR; + +/// Build a noisy-but-bounded test block. Synthetic — we want +/// realistic-ish range of values so the branch predictors / FPU +/// units exercise the same paths they would on real audio. +fn make_block() -> Vec { + let mut buf = Vec::with_capacity(FRAMES * CHANNELS); + // Two sine partials + a tiny DC: enough variation that peak isn't + // pegged to one sample and the mean-square isn't trivially zero. + let f1 = 220.0 / SR; + let f2 = 1730.0 / SR; + for n in 0..FRAMES { + let t = n as f32; + let s = 0.4 * (2.0 * std::f32::consts::PI * f1 * t).sin() + + 0.18 * (2.0 * std::f32::consts::PI * f2 * t).sin() + + 0.005; + buf.push(s); + buf.push(s * 0.92); // slight L/R difference + } + buf +} + +/// What the audio-thread Layer A callback computes per block. +/// Hand-rolled tight loop so the bench measures the candidate code, +/// not stdlib iterator combinators (which the compiler will inline +/// to roughly the same thing — but we want to be honest about it). +#[inline] +fn analysis_scan(samples: &[f32]) -> (f32, f32) { + let mut peak = 0.0_f32; + let mut sumsq = 0.0_f32; + for &s in samples { + let a = s.abs(); + if a > peak { + peak = a; + } + sumsq += s * s; + } + let mean_sq = sumsq / samples.len() as f32; + (peak, mean_sq) +} + +fn bench_analysis_scan(c: &mut Criterion) { + let block = make_block(); + let mut group = c.benchmark_group("layer_a_audio_thread"); + group.throughput(Throughput::Elements((FRAMES * CHANNELS) as u64)); + group.bench_function("analysis_scan_stereo_1024", |b| { + b.iter(|| { + let (p, m) = analysis_scan(black_box(&block)); + black_box((p, m)); + }); + }); + group.finish(); +} + +fn bench_level_envelopes(c: &mut Criterion) { + let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S); + let block = make_block(); + let (peak, mean_sq) = analysis_scan(&block); + + let mut group = c.benchmark_group("layer_a_daemon_thread"); + group.bench_function("level_envelopes_process_block", |b| { + b.iter(|| { + let d = env.process_block(black_box(peak), black_box(mean_sq)); + black_box(d); + }); + }); + group.finish(); +} + +fn bench_filter_kernels(c: &mut Criterion) { + // Reference points for "how big is Layer A relative to what + // the realtime filter is already doing." Not a Layer A cost — + // measured here for context. + let mut comp = Compressor::new(CompressorConfig::default(), SR); + let mut lim = Limiter::new(LimiterConfig::default(), SR); + + let mut group = c.benchmark_group("filter_reference_per_frame"); + group.throughput(Throughput::Elements(1)); + group.bench_function("compressor_process_frame", |b| { + b.iter(|| { + let (l, r) = comp.process_frame(black_box(0.3), black_box(-0.2)); + black_box((l, r)); + }); + }); + group.bench_function("limiter_process_frame", |b| { + b.iter(|| { + let (l, r) = lim.process_frame(black_box(0.3), black_box(-0.2)); + black_box((l, r)); + }); + }); + group.finish(); +} + +criterion_group!( + benches, + bench_analysis_scan, + bench_level_envelopes, + bench_filter_kernels +); +criterion_main!(benches); diff --git a/crates/headroom-dsp/src/agc.rs b/crates/headroom-dsp/src/agc.rs new file mode 100644 index 0000000..90d7d71 --- /dev/null +++ b/crates/headroom-dsp/src/agc.rs @@ -0,0 +1,211 @@ +//! Audio-thread piece of the slow AGC. +//! +//! Sits at the head of the DSP chain (before the compressor). Holds a +//! fast anti-zipper smoother that interpolates the per-sample gain +//! toward whatever target the control thread has most recently +//! pushed. The slow musical smoothing of the target itself happens on +//! the control side (`headroom-core::agc`), so this stage only has to +//! suppress the step-change zippering at the boundary between control +//! ticks. +//! +//! `process_frame` is allocation-free and bounded-time. `set_target_db` +//! is also allocation-free and intended to be called from the +//! audio thread once per audio command (drained at the top of every +//! playback callback). + +use crate::util::{db_to_lin, time_to_alpha}; + +/// Configuration for the audio-thread AGC gain stage. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AgcGainConfig { + /// Time constant (ms) for the per-sample smoother that interpolates + /// `current_db` toward `target_db`. Small enough to chase a 50 ms + /// control tick without zippering, large enough not to itself act + /// as a gain-envelope. ~5 ms is a sensible default. + pub anti_zipper_ms: f32, +} + +impl Default for AgcGainConfig { + fn default() -> Self { + Self { + anti_zipper_ms: 5.0, + } + } +} + +/// Audio-thread AGC gain stage. Two states: when `enabled` is false, +/// the stage is a unity pass-through (still smoothed back to 0 dB). +pub struct AgcGain { + cfg: AgcGainConfig, + sample_rate: f32, + target_db: f32, + current_db: f32, + alpha: f32, + enabled: bool, +} + +impl AgcGain { + /// Construct an AGC gain stage. `sample_rate` is the input rate + /// (same as the rest of the DSP chain). + #[must_use] + pub fn new(cfg: AgcGainConfig, sample_rate: f32) -> Self { + Self { + cfg, + sample_rate, + target_db: 0.0, + current_db: 0.0, + alpha: time_to_alpha(cfg.anti_zipper_ms, sample_rate), + enabled: true, + } + } + + /// Apply a fresh `target_db` from the control thread. + pub fn set_target_db(&mut self, db: f32) { + if db.is_finite() { + self.target_db = db; + } + } + + /// Enable or disable the stage. When disabled, the smoother + /// pushes `target_db` to 0 dB so any active boost/cut unwinds at + /// the anti-zipper rate rather than snapping. + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + self.target_db = 0.0; + } + } + + /// Live-update non-structural parameters. + pub fn set_config(&mut self, cfg: AgcGainConfig) { + self.cfg = cfg; + self.alpha = time_to_alpha(cfg.anti_zipper_ms, self.sample_rate); + } + + /// Active configuration. + #[must_use] + pub fn config(&self) -> AgcGainConfig { + self.cfg + } + + /// Current smoother state, in dB. The actual gain applied to + /// samples is `10^(current_db / 20)`. + #[must_use] + pub fn current_db(&self) -> f32 { + self.current_db + } + + /// Active target_db (latest control-thread command). + #[must_use] + pub fn target_db(&self) -> f32 { + self.target_db + } + + /// `true` if the stage is enabled (control commands may move the + /// target away from 0 dB). + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } + + /// Process one stereo frame: smooth the gain in dB, convert to + /// linear, multiply both channels. + pub fn process_frame(&mut self, l: f32, r: f32) -> (f32, f32) { + self.current_db += self.alpha * (self.target_db - self.current_db); + let gain = db_to_lin(self.current_db); + (l * gain, r * gain) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::lin_to_db; + + const SR: f32 = 48_000.0; + + #[test] + fn unity_at_zero_db() { + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + for _ in 0..100 { + let (l, r) = agc.process_frame(0.5, -0.3); + assert!((l - 0.5).abs() < 1e-6); + assert!((r - -0.3).abs() < 1e-6); + } + } + + #[test] + fn smooths_toward_target() { + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + agc.set_target_db(6.0); + // After ~5 ms (one anti-zipper tau), current_db should be in + // the ~63% region. + let samples = (0.005 * SR) as usize; + for _ in 0..samples { + let _ = agc.process_frame(0.0, 0.0); + } + let cur = agc.current_db(); + assert!( + (cur - 6.0 * 0.63).abs() < 0.5, + "expected ~3.8 dB after one tau, got {cur}" + ); + // Settle. + for _ in 0..(SR as usize) { + let _ = agc.process_frame(0.0, 0.0); + } + assert!((agc.current_db() - 6.0).abs() < 0.01); + } + + #[test] + fn applies_gain_to_signal() { + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + agc.set_target_db(6.0); + // Run long enough to settle. + for _ in 0..(SR as usize) { + let _ = agc.process_frame(0.0, 0.0); + } + let (l, r) = agc.process_frame(0.5, 0.5); + // +6 dB = factor of ~2.0. + assert!((l / 0.5 - 2.0).abs() < 0.05, "got {l}"); + assert!((r / 0.5 - 2.0).abs() < 0.05); + } + + #[test] + fn disable_unwinds_back_to_unity() { + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + agc.set_target_db(6.0); + for _ in 0..(SR as usize) { + let _ = agc.process_frame(0.0, 0.0); + } + assert!((agc.current_db() - 6.0).abs() < 0.01); + + agc.set_enabled(false); + for _ in 0..(SR as usize) { + let _ = agc.process_frame(0.0, 0.0); + } + assert!(agc.current_db().abs() < 0.01, "got {}", agc.current_db()); + } + + #[test] + fn rejects_non_finite_target() { + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + agc.set_target_db(3.0); + agc.set_target_db(f32::NAN); + assert!((agc.target_db() - 3.0).abs() < 1e-6); + agc.set_target_db(f32::INFINITY); + assert!((agc.target_db() - 3.0).abs() < 1e-6); + } + + #[test] + fn lin_round_trip_check() { + // Sanity: after settling, gain at target_db should produce + // peak that matches lin_to_db. + let mut agc = AgcGain::new(AgcGainConfig::default(), SR); + agc.set_target_db(-6.0); + for _ in 0..(SR as usize) { + let _ = agc.process_frame(0.0, 0.0); + } + let (l, _) = agc.process_frame(1.0, 1.0); + assert!((lin_to_db(l) - -6.0).abs() < 0.05); + } +} diff --git a/crates/headroom-dsp/src/level_envelopes.rs b/crates/headroom-dsp/src/level_envelopes.rs new file mode 100644 index 0000000..6539ac8 --- /dev/null +++ b/crates/headroom-dsp/src/level_envelopes.rs @@ -0,0 +1,427 @@ +//! Block-rate level envelopes for Layer A (per-application level +//! control). +//! +//! Implements the two-tier peak + RMS detector described in +//! `PLAN.md` §4. Pure block-rate logic — no PipeWire, no allocation +//! after construction. The audio thread computes `peak = max(|x|)` +//! and `mean_sq = Σx²/N` per block and pushes them into a ring; the +//! daemon thread feeds them to [`LevelEnvelopes::process_block`] and +//! reads the recommended reduction back. +//! +//! Two parallel detectors: +//! +//! - **Peak envelope** — smoothed in dB with separate attack (fast, +//! tens of ms) and release (slow, ~500 ms). Triggers a cut when the +//! envelope crosses `peak_threshold_db`. Catches transient bursts. +//! - **RMS envelope** — smoothed mean-square with a slow time +//! constant (~1–2 s). Triggers a cut when the smoothed RMS in dB +//! crosses `rms_target_db`. Catches sustained loudness mismatches. +//! +//! Output reduction is `max(peak_reduction, rms_reduction)`, clamped +//! to `max_cut_db`. Recovery is implicit: each envelope releases at +//! its own time constant, so neither path stays engaged once the +//! input drops. + +use crate::util::{lin_to_db, time_to_alpha}; + +/// Per-rule configuration. Mirrors `[per_app.rules]` in the profile +/// schema (PLAN §6). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LevelEnvelopesConfig { + /// Peak envelope threshold (dBFS). Output rises above this → + /// reduce gain. Default −6 dBFS. + pub peak_threshold_db: f32, + /// RMS envelope target (dBFS, equivalent). Smoothed RMS rising + /// above this → reduce gain. Default ≈ −20 dBFS. + pub rms_target_db: f32, + /// Maximum cut the envelopes may request (dB). The signed cap on + /// `max(peak_reduction, rms_reduction)`. Default 12 dB. + pub max_cut_db: f32, + /// Peak envelope attack time (ms). Time for the envelope to + /// approach the input on a rising peak. + pub peak_attack_ms: f32, + /// Peak envelope release time (ms). Time for the envelope to + /// decay back toward silence after the peak drops. + pub peak_release_ms: f32, + /// RMS smoothing window (ms). One-pole time constant on the + /// mean-square input. + pub rms_window_ms: f32, +} + +impl Default for LevelEnvelopesConfig { + fn default() -> Self { + Self { + peak_threshold_db: -6.0, + rms_target_db: -20.0, + max_cut_db: 12.0, + peak_attack_ms: 5.0, + peak_release_ms: 500.0, + rms_window_ms: 1500.0, + } + } +} + +impl LevelEnvelopesConfig { + /// Sanitize: clamp non-finite values, ensure release > 0, threshold + /// at or below 0 dB. + #[must_use] + pub fn sanitized(mut self) -> Self { + if self.peak_threshold_db > 0.0 { + self.peak_threshold_db = 0.0; + } + if self.rms_target_db > 0.0 { + self.rms_target_db = 0.0; + } + if !self.max_cut_db.is_finite() || self.max_cut_db < 0.0 { + self.max_cut_db = 0.0; + } + for v in [ + &mut self.peak_attack_ms, + &mut self.peak_release_ms, + &mut self.rms_window_ms, + ] { + if !v.is_finite() || *v < 0.0 { + *v = 0.0; + } + } + self + } +} + +/// Two-tier per-stream level detector. +pub struct LevelEnvelopes { + cfg: LevelEnvelopesConfig, + /// Block period (s). Cached so we don't recompute alphas every + /// call when the audio thread holds the quantum steady. Recomputed + /// on `set_block_dt`. + block_dt_s: f32, + peak_attack_alpha: f32, + peak_release_alpha: f32, + rms_alpha: f32, + /// Smoothed peak in dB. Starts at floor so first push doesn't + /// trip the threshold artificially. + peak_env_db: f32, + /// Smoothed mean-square (linear). Starts at 0. + rms_smoothed_mean_sq: f32, +} + +/// Result of processing one block. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LevelDecision { + /// dB cut requested by the peak envelope (`0` when not engaged). + pub peak_reduction_db: f32, + /// dB cut requested by the RMS envelope (`0` when not engaged). + pub rms_reduction_db: f32, + /// Combined recommendation: `min(max_cut, max(peak, rms))`. + /// Always `>= 0`; `0` means "no cut, leave channelVolumes alone." + pub total_reduction_db: f32, +} + +impl LevelEnvelopes { + /// Construct from a config and the audio thread's nominal block + /// period. The block period (`samples_per_block / sample_rate`) + /// must be small enough that the envelopes track properly; values + /// up to ~100 ms work for v0. + #[must_use] + pub fn new(cfg: LevelEnvelopesConfig, block_dt_s: f32) -> Self { + let cfg = cfg.sanitized(); + let (peak_attack_alpha, peak_release_alpha, rms_alpha) = compute_alphas(&cfg, block_dt_s); + Self { + cfg, + block_dt_s, + peak_attack_alpha, + peak_release_alpha, + rms_alpha, + peak_env_db: -200.0, + rms_smoothed_mean_sq: 0.0, + } + } + + /// Current configuration. + #[must_use] + pub fn config(&self) -> LevelEnvelopesConfig { + self.cfg + } + + /// Block period the alphas were computed against. + #[must_use] + pub fn block_dt_s(&self) -> f32 { + self.block_dt_s + } + + /// Update parameters in place. Recomputes alphas; resets neither + /// envelope state (live tweaks don't cause artefacts). + pub fn set_config(&mut self, cfg: LevelEnvelopesConfig) { + let cfg = cfg.sanitized(); + let (a_a, a_r, a_rms) = compute_alphas(&cfg, self.block_dt_s); + self.cfg = cfg; + self.peak_attack_alpha = a_a; + self.peak_release_alpha = a_r; + self.rms_alpha = a_rms; + } + + /// Update the assumed block period (re-derives alphas). Call when + /// the audio thread's quantum changes. + pub fn set_block_dt(&mut self, dt_s: f32) { + if dt_s <= 0.0 || !dt_s.is_finite() || (dt_s - self.block_dt_s).abs() < 1e-9 { + return; + } + self.block_dt_s = dt_s; + let (a_a, a_r, a_rms) = compute_alphas(&self.cfg, dt_s); + self.peak_attack_alpha = a_a; + self.peak_release_alpha = a_r; + self.rms_alpha = a_rms; + } + + /// Process one block. `peak_lin` is the per-block max of + /// absolute samples (linear); `mean_sq_lin` is the per-block + /// `Σx²/N`. Allocation-free. + pub fn process_block(&mut self, peak_lin: f32, mean_sq_lin: f32) -> LevelDecision { + let peak_lin = peak_lin.max(0.0); + let mean_sq_lin = mean_sq_lin.max(0.0); + + // Peak envelope in dB. Attack on rising edge, release on + // falling. Use the actual block measurement as the target. + let target_db = lin_to_db(peak_lin); + if target_db > self.peak_env_db { + self.peak_env_db += self.peak_attack_alpha * (target_db - self.peak_env_db); + } else { + self.peak_env_db += self.peak_release_alpha * (target_db - self.peak_env_db); + } + + // RMS envelope: smooth mean_sq directly (one alpha) then + // convert to dB. Smoothing in the linear-power domain is the + // canonical R128 / IEC-style RMS detector. + self.rms_smoothed_mean_sq += self.rms_alpha * (mean_sq_lin - self.rms_smoothed_mean_sq); + // 20*log10(sqrt(mean_sq)) = 10*log10(mean_sq). + let rms_db = 10.0 * self.rms_smoothed_mean_sq.max(1e-30).log10(); + + let peak_reduction_db = (self.peak_env_db - self.cfg.peak_threshold_db).max(0.0); + let rms_reduction_db = (rms_db - self.cfg.rms_target_db).max(0.0); + let combined = peak_reduction_db.max(rms_reduction_db); + let total_reduction_db = combined.min(self.cfg.max_cut_db); + + LevelDecision { + peak_reduction_db, + rms_reduction_db, + total_reduction_db, + } + } + + /// Reset envelope state. Useful when re-attaching to a stream + /// after a deference period. + pub fn reset(&mut self) { + self.peak_env_db = -200.0; + self.rms_smoothed_mean_sq = 0.0; + } +} + +fn compute_alphas(cfg: &LevelEnvelopesConfig, block_dt_s: f32) -> (f32, f32, f32) { + // The smoother is `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`, so a + // larger alpha means a faster smoother. Cache the per-block alpha + // derived from a continuous-time tau. + let block_dt_ms = block_dt_s * 1000.0; + let block_rate = if block_dt_s > 0.0 { 1.0 / block_dt_s } else { 1.0 }; + let attack = time_to_alpha(cfg.peak_attack_ms, block_rate); + let release = time_to_alpha(cfg.peak_release_ms, block_rate); + let rms = time_to_alpha(cfg.rms_window_ms, block_rate); + // We use `time_to_alpha` against a *block rate* (Hz), not a + // sample rate, because the smoothers operate at block boundaries + // — the audio thread emits one (peak, mean_sq) pair per block. + // `time_to_alpha` is sample-rate-agnostic: it converts time_ms + // and a rate into alpha. Block rate is just "samples per second" + // where each "sample" is a block. + let _ = block_dt_ms; // currently informational + (attack, release, rms) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::db_to_lin; + + /// 1024-frame quantum at 48 kHz. + const BLOCK_DT_S: f32 = 1024.0 / 48_000.0; + + fn run_steady(env: &mut LevelEnvelopes, peak_lin: f32, mean_sq_lin: f32, blocks: usize) -> LevelDecision { + let mut last = env.process_block(peak_lin, mean_sq_lin); + for _ in 1..blocks { + last = env.process_block(peak_lin, mean_sq_lin); + } + last + } + + #[test] + fn quiet_signal_produces_no_reduction() { + let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S); + let quiet = db_to_lin(-30.0); + let mean_sq = quiet * quiet; + let dec = run_steady(&mut env, quiet, mean_sq, 200); + assert_eq!(dec.peak_reduction_db, 0.0); + assert_eq!(dec.rms_reduction_db, 0.0); + assert_eq!(dec.total_reduction_db, 0.0); + } + + #[test] + fn peak_above_threshold_requests_cut() { + let cfg = LevelEnvelopesConfig { + peak_threshold_db: -6.0, + // Long RMS window so the slow path doesn't dominate. + rms_target_db: 0.0, + rms_window_ms: 5_000.0, + ..Default::default() + }; + let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S); + // 0 dBFS peak: 6 dB over threshold. + let peak = db_to_lin(0.0); + let mean_sq = (peak * peak) * 0.05; // low rms (intermittent peak) + let dec = run_steady(&mut env, peak, mean_sq, 200); + assert!( + (dec.peak_reduction_db - 6.0).abs() < 0.5, + "expected ~6 dB peak cut, got {}", + dec.peak_reduction_db + ); + assert_eq!(dec.rms_reduction_db, 0.0); + assert!((dec.total_reduction_db - 6.0).abs() < 0.5); + } + + #[test] + fn rms_above_target_requests_cut() { + let cfg = LevelEnvelopesConfig { + // Push peak threshold up so only RMS engages. + peak_threshold_db: 0.0, + rms_target_db: -20.0, + rms_window_ms: 200.0, // shorter so test converges quickly + ..Default::default() + }; + let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S); + // Sustained -10 dBFS RMS: 10 dB above target. + let rms_lin = db_to_lin(-10.0); + let mean_sq = rms_lin * rms_lin; + // Peak set just below threshold so peak detector stays asleep. + let peak = db_to_lin(-1.0); + let dec = run_steady(&mut env, peak, mean_sq, 200); + assert_eq!(dec.peak_reduction_db, 0.0); + assert!( + (dec.rms_reduction_db - 10.0).abs() < 0.5, + "expected ~10 dB RMS cut, got {}", + dec.rms_reduction_db + ); + } + + #[test] + fn combined_takes_max_of_peak_and_rms() { + let cfg = LevelEnvelopesConfig { + peak_threshold_db: -6.0, + rms_target_db: -20.0, + rms_window_ms: 200.0, + max_cut_db: 100.0, + ..Default::default() + }; + let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S); + let peak = db_to_lin(0.0); // 6 dB over + let rms_lin = db_to_lin(-10.0); // 10 dB over + let mean_sq = rms_lin * rms_lin; + let dec = run_steady(&mut env, peak, mean_sq, 200); + assert!((dec.peak_reduction_db - 6.0).abs() < 0.5); + assert!((dec.rms_reduction_db - 10.0).abs() < 0.5); + assert!( + (dec.total_reduction_db - 10.0).abs() < 0.5, + "max(6, 10) ≈ 10, got {}", + dec.total_reduction_db + ); + } + + #[test] + fn total_reduction_is_clamped_to_max_cut_db() { + let cfg = LevelEnvelopesConfig { + peak_threshold_db: -30.0, + rms_target_db: -30.0, + rms_window_ms: 50.0, + max_cut_db: 3.0, // tight cap + ..Default::default() + }; + let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S); + let peak = db_to_lin(0.0); // 30 dB over + let rms_lin = db_to_lin(-5.0); + let mean_sq = rms_lin * rms_lin; + let dec = run_steady(&mut env, peak, mean_sq, 200); + assert!(dec.peak_reduction_db > 20.0); + assert!( + (dec.total_reduction_db - 3.0).abs() < 1e-3, + "total clamped to max_cut_db, got {}", + dec.total_reduction_db + ); + } + + #[test] + fn peak_envelope_releases_after_burst() { + let cfg = LevelEnvelopesConfig { + peak_threshold_db: -6.0, + rms_target_db: 0.0, + rms_window_ms: 5_000.0, + peak_attack_ms: 5.0, + peak_release_ms: 100.0, + ..Default::default() + }; + let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S); + // Burst. + for _ in 0..20 { + env.process_block(db_to_lin(0.0), 0.0); + } + let burst = env.process_block(db_to_lin(0.0), 0.0); + assert!(burst.peak_reduction_db > 5.0); + + // Silence. + for _ in 0..200 { + env.process_block(0.0, 0.0); + } + let quiet = env.process_block(0.0, 0.0); + assert!( + quiet.peak_reduction_db < 0.5, + "expected ~0 after release, got {}", + quiet.peak_reduction_db + ); + } + + #[test] + fn set_config_updates_alphas_without_reset() { + let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S); + for _ in 0..100 { + env.process_block(db_to_lin(-3.0), 0.0); + } + let before = env.process_block(db_to_lin(-3.0), 0.0); + // Tighter threshold; envelope state preserved across the swap. + env.set_config(LevelEnvelopesConfig { + peak_threshold_db: -12.0, + ..LevelEnvelopesConfig::default() + }); + let after = env.process_block(db_to_lin(-3.0), 0.0); + assert!( + after.peak_reduction_db > before.peak_reduction_db, + "tighter threshold should request more cut" + ); + } + + #[test] + fn set_block_dt_recomputes_alphas() { + let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S); + let original_attack = env.peak_attack_alpha; + // Double the block period — slower block rate → smaller alpha + // for the same time constant. + env.set_block_dt(BLOCK_DT_S * 2.0); + assert!(env.peak_attack_alpha > original_attack); + } + + #[test] + fn reset_returns_to_idle_state() { + let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S); + for _ in 0..200 { + env.process_block(db_to_lin(0.0), db_to_lin(-3.0)); + } + env.reset(); + let dec = env.process_block(0.0, 0.0); + assert_eq!(dec.peak_reduction_db, 0.0); + assert_eq!(dec.rms_reduction_db, 0.0); + } +} diff --git a/crates/headroom-dsp/src/lib.rs b/crates/headroom-dsp/src/lib.rs index df3d265..87ff147 100644 --- a/crates/headroom-dsp/src/lib.rs +++ b/crates/headroom-dsp/src/lib.rs @@ -9,17 +9,21 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +mod agc; mod compressor; mod delay; mod envelope; +mod level_envelopes; mod limiter; mod oversample; mod sliding_max; pub mod util; +pub use agc::{AgcGain, AgcGainConfig}; pub use compressor::{Compressor, CompressorConfig, Detector}; pub use delay::DelayLine; pub use envelope::AttackRelease; -pub use limiter::{Limiter, LimiterConfig, SoftTierConfig}; +pub use level_envelopes::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig}; +pub use limiter::{Limiter, LimiterConfig, SetConfigOutcome, SoftTierConfig}; pub use oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler}; pub use sliding_max::SlidingMaxBuffer; diff --git a/crates/headroom-dsp/src/limiter.rs b/crates/headroom-dsp/src/limiter.rs index c03c602..4fc6939 100644 --- a/crates/headroom-dsp/src/limiter.rs +++ b/crates/headroom-dsp/src/limiter.rs @@ -174,9 +174,28 @@ impl LimiterConfig { const MAX_OVERSAMPLE: usize = 8; +/// Result of attempting to live-apply a [`LimiterConfig`]. +/// +/// Returned by [`Limiter::try_set_config`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetConfigOutcome { + /// Config applied in place; the limiter is now running with the + /// new parameters. + Applied, + /// The new config differs in `oversample`, `fir_taps`, or + /// `lookahead_ms` (rounded to samples), and would require + /// reallocating internal buffers. The limiter is unchanged; + /// rebuild it from `Limiter::new` on the control thread. + StructuralChange, +} + /// Two-tier feed-forward true-peak limiter. pub struct Limiter { cfg: LimiterConfig, + /// Input sample rate, captured at construction. Kept so live + /// reconfiguration ([`Limiter::try_set_config`]) can recompute + /// time-based coefficients without callers having to repass it. + sample_rate: f32, ceiling_lin: f32, os: usize, @@ -259,6 +278,7 @@ impl Limiter { let mut me = Self { cfg, + sample_rate, ceiling_lin, os, up_l: PolyphaseUpsampler::new(os, &lowpass), @@ -347,6 +367,68 @@ impl Limiter { self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin)) } + /// Live-update non-structural parameters. + /// + /// Applies changes that don't require reallocating internal + /// buffers: ceiling, hard-tier release/hold, soft-tier toggle and + /// scalars. Allocation-free; safe to call on the realtime audio + /// thread. + /// + /// Structural changes — `oversample`, `lookahead_ms` (when the + /// rounded sample count differs from the current one), or + /// `fir_taps` — cannot be applied in place because they would + /// resize FIR coefficient tables, polyphase state, the delay line, + /// or the sliding peak buffer. The method returns + /// [`SetConfigOutcome::StructuralChange`] in that case and the + /// limiter is left unchanged; the caller is expected to rebuild + /// the [`Limiter`] from `Limiter::new` on the control thread. + pub fn try_set_config(&mut self, cfg: LimiterConfig) -> SetConfigOutcome { + let cfg = cfg.sanitized(); + let os_rate = self.sample_rate * cfg.oversample as f32; + let new_lookahead_samples_os = + ((cfg.lookahead_ms * 1e-3 * os_rate).round() as usize).max(1); + let cur_lookahead_samples_os = self.peak_buf.window(); + if cfg.oversample != self.os + || cfg.fir_taps != self.cfg.fir_taps + || new_lookahead_samples_os != cur_lookahead_samples_os + { + return SetConfigOutcome::StructuralChange; + } + + self.ceiling_lin = db_to_lin(cfg.ceiling_dbtp); + self.hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate); + self.hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32; + + match (cfg.soft, self.cfg.soft) { + (Some(new_soft), Some(_old_soft)) => { + self.soft_max_psr_db = new_soft.max_psr_db; + self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp); + if let Some(env) = &mut self.soft_envelope { + env.set_times(new_soft.attack_ms, new_soft.release_ms, os_rate); + } + } + (Some(new_soft), None) => { + // Re-enable the soft tier. Seed the envelope to unity + // so we don't start with phantom gain reduction. + let mut env = AttackRelease::new(new_soft.attack_ms, new_soft.release_ms, os_rate); + env.reset(1.0); + self.soft_envelope = Some(env); + self.soft_max_psr_db = new_soft.max_psr_db; + self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp); + } + (None, Some(_)) => { + self.soft_envelope = None; + self.soft_max_psr_db = 0.0; + self.soft_static_ceiling_lin = 1.0; + } + (None, None) => {} + } + + self.cfg = cfg; + self.recompute_soft_ceiling(); + SetConfigOutcome::Applied + } + /// Update the program loudness used to compute the dynamic soft /// ceiling. Typically called by the AGC at its tick rate with the /// short-term BS.1770 loudness; non-finite values are ignored. @@ -533,6 +615,80 @@ mod tests { use super::*; use std::f32::consts::PI; + // ---------------------------------------------------------------- + // try_set_config: scalar updates apply in place, structural + // changes are rejected. + // ---------------------------------------------------------------- + + #[test] + fn try_set_config_applies_scalar_changes() { + let sr = 48_000.0; + let mut l = Limiter::new(LimiterConfig::default(), sr); + let mut cfg = LimiterConfig::default(); + cfg.ceiling_dbtp = -3.0; + cfg.release_ms = 200.0; + cfg.hold_ms = 10.0; + assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied); + assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6); + let active = l.config(); + assert!((active.release_ms - 200.0).abs() < 1e-6); + assert!((active.hold_ms - 10.0).abs() < 1e-6); + } + + #[test] + fn try_set_config_can_toggle_soft_tier() { + let sr = 48_000.0; + let mut l = Limiter::new(LimiterConfig::default(), sr); + // Start with soft on. Disable it. + let mut cfg = LimiterConfig::default(); + cfg.soft = None; + assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied); + assert!(l.config().soft.is_none()); + assert!(l.effective_soft_ceiling_dbtp().is_none()); + + // Re-enable with custom params. + let new_soft = SoftTierConfig { + max_psr_db: 10.0, + static_ceiling_dbtp: -4.0, + attack_ms: 8.0, + release_ms: 300.0, + }; + cfg.soft = Some(new_soft); + assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied); + let active_soft = l.config().soft.expect("soft re-enabled"); + assert!((active_soft.max_psr_db - 10.0).abs() < 1e-6); + assert!((active_soft.static_ceiling_dbtp - -4.0).abs() < 1e-6); + } + + #[test] + fn try_set_config_rejects_oversample_change() { + let sr = 48_000.0; + let mut l = Limiter::new(LimiterConfig::default(), sr); + let mut cfg = LimiterConfig::default(); + cfg.oversample = 8; + assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange); + // Limiter unchanged. + assert_eq!(l.config().oversample, LimiterConfig::default().oversample); + } + + #[test] + fn try_set_config_rejects_lookahead_change() { + let sr = 48_000.0; + let mut l = Limiter::new(LimiterConfig::default(), sr); + let mut cfg = LimiterConfig::default(); + cfg.lookahead_ms = 5.0; // resizes delay + peak buffer + assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange); + } + + #[test] + fn try_set_config_rejects_fir_taps_change() { + let sr = 48_000.0; + let mut l = Limiter::new(LimiterConfig::default(), sr); + let mut cfg = LimiterConfig::default(); + cfg.fir_taps = 63; + assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange); + } + fn run_sine( limiter: &mut Limiter, freq: f32, diff --git a/crates/headroom-ipc/src/proto.rs b/crates/headroom-ipc/src/proto.rs index da2ab55..4b5aabf 100644 --- a/crates/headroom-ipc/src/proto.rs +++ b/crates/headroom-ipc/src/proto.rs @@ -360,6 +360,13 @@ pub struct Status { pub sinks: Sinks, /// Currently-tracked playback streams. pub streams: Vec, + /// Non-fatal warnings the daemon wants operators to see — + /// typically from profile loading (TOML parse errors on a single + /// file, the active profile name pointing at something not on + /// disk, ...). Reflects the state as of the last successful + /// profile load or reload. Empty in the healthy case. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } /// Sink-side of `Status`.