stage 6: per-app

This commit is contained in:
atagen 2026-05-20 23:47:19 +10:00
parent 9edd809416
commit fcf421b94c
31 changed files with 6360 additions and 344 deletions

429
Cargo.lock generated
View file

@ -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"

View file

@ -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"

227
PLAN.md
View file

@ -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:
- **4a4d** — 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 <node-id> 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 = <source-id>`) 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.

View file

@ -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)).

View file

@ -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

View file

@ -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);

View file

@ -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 520 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<f32>,
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<f32>,
filter_control: FilterControl,
daemon: SharedState,
) -> Result<Self, AgcInitError> {
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<AgcInitError> 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<f32>,
rtrb::Consumer<AudioCmd>,
SharedState,
) {
let (m_prod, m_cons) = RingBuffer::<f32>::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<f32>, frames: usize) {
for _ in 0..frames {
let _ = prod.push(0.0);
let _ = prod.push(0.0);
}
}
fn push_sine(prod: &mut rtrb::Producer<f32>, 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);
}
}

View file

@ -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<Instant>,
/// User-set ceiling: linear volume the user externally adjusted
/// to. `Some` triggers ceiling-mode deference (clamp our writes).
user_ceiling_lin: Option<f32>,
/// 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<f32> {
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<f32> {
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 <app>` (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<PerAppRule> {
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());
}
}

View file

@ -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;

View file

@ -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<ProfileInfo> = 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<String> = 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<crate::profile_store::ReloadReport, StoreError> {
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::<PwCommand>();
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);
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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<RecommendedWatcher>,
}
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<Option<Self>, 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");
}
}

View file

@ -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 (520 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,
},
}

View file

@ -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<Mutex<Producer<AudioCmd>>>,
}
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<AudioCmd>) {
let (producer, consumer) = RingBuffer::<AudioCmd>::new(capacity);
(
Self {
cmd_producer: Arc::new(Mutex::new(producer)),
},
consumer,
)
}
}
/// State owned by the capture stream's process callback.
struct CaptureState {
producer: Producer<f32>,
@ -75,10 +200,21 @@ struct CaptureState {
/// State owned by the playback stream's process callback.
struct PlaybackState {
consumer: Consumer<f32>,
/// Control-plane → audio-thread parameter update channel. Drained
/// at the top of every `playback_process` call.
cmd_consumer: Consumer<AudioCmd>,
/// 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<f32>,
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<PlaybackState>,
}
/// 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<f32>,
}
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<Self, DaemonError> {
pub fn create(core: &Core, init: FilterInit) -> Result<FilterBundle, DaemonError> {
let (producer, consumer) = RingBuffer::<f32>::new(RING_CAPACITY);
let (cmd_producer, cmd_consumer) = RingBuffer::<AudioCmd>::new(CMD_RING_CAPACITY);
let (measurement_producer, measurement_consumer) =
RingBuffer::<f32>::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);
}
}

View file

@ -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<u32>,
}
/// 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<u32> {
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<u32>) -> bool {
let changed = self.current != node_id;
self.current = node_id;
changed
}
}
impl Default for PreferredRealSinkTracker {
fn default() -> Self {
Self::new()
}
}
/// Write `target.object = <serial>` for the named stream into the
/// `default` metadata key. WirePlumber observes this and moves the
/// stream accordingly.
/// 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<String> {
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");
}
}

View file

@ -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");

File diff suppressed because it is too large Load diff

View file

@ -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<MeasurementSample>,
/// 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<TapState>,
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<MeasurementSample>), DaemonError> {
let (producer, consumer) = RingBuffer::<MeasurementSample>::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<Vec<u8>, DaemonError> {
// F32LE stereo, **rate left unset** so PipeWire negotiates the
// source's rate. The `From<AudioInfoRaw> for Vec<Property>` 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::<u8, f32>(&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);
}
}

View file

@ -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<String>, hay: &[String]| -> bool {
if hay.is_empty() {
return true;

View file

@ -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);
}

View file

@ -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<u32>,
@ -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<FilterControl>,
/// 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<Sender<PwCommand>>,
}
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<Vec<(u32, String)>> {
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<Mutex<DaemonState>>;
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"));
}
}

View file

@ -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

View file

@ -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<f32> {
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);

View file

@ -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);
}
}

View file

@ -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 (~12 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);
}
}

View file

@ -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;

View file

@ -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,

View file

@ -360,6 +360,13 @@ pub struct Status {
pub sinks: Sinks,
/// Currently-tracked playback streams.
pub streams: Vec<StreamRoute>,
/// 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<String>,
}
/// Sink-side of `Status`.