stage 6: per-app
This commit is contained in:
parent
9edd809416
commit
fcf421b94c
31 changed files with 6360 additions and 344 deletions
429
Cargo.lock
generated
429
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
227
PLAN.md
|
|
@ -13,10 +13,16 @@ conversational sketch.
|
|||
|
||||
### Goals
|
||||
|
||||
- **Hard safety net.** Output is guaranteed to stay below a configurable
|
||||
ceiling (default **−0.1 dBTP**) with proper inter-sample peak handling.
|
||||
This guarantee survives daemon misbehaviour, profile reloads, and bad
|
||||
routing decisions — it is enforced inline in the audio path.
|
||||
- **Hard safety net on the processed route.** Audio routed through
|
||||
`headroom-processed` is guaranteed to leave the filter below a
|
||||
configurable ceiling (default **−0.1 dBTP**) with proper inter-sample
|
||||
peak handling. The guarantee is enforced inline in the filter,
|
||||
downstream of every control-plane code path, and survives daemon
|
||||
misbehaviour, profile reloads, and bad routing decisions. Streams
|
||||
routed `bypass` ride the real sink directly and are **not** subject
|
||||
to this contract (see §2 path ①); the contract also does not extend
|
||||
to whatever resampling or post-processing the downstream device
|
||||
path applies after the filter's output.
|
||||
- **Per-application exclusion.** Music players, games, and DAWs route
|
||||
around the processor; browsers, voice chat, and "everything else" go
|
||||
through it. Rules are app-level and live in profiles.
|
||||
|
|
@ -472,10 +478,13 @@ is the irreducible cost of "no lookahead allowed." For absolute
|
|||
spike prevention you need lookahead, which means latency, which
|
||||
contradicts the constraint of this layer.
|
||||
|
||||
The bus-level Layer C limiter (§3.1) catches anything that would
|
||||
exceed the absolute ceiling regardless of whether Layer A has caught
|
||||
up. Layer A reduces *workload* on Layer C by pre-attenuating noisy
|
||||
apps; it doesn't replace it.
|
||||
On the processed route the bus-level Layer C limiter (§3.1) catches
|
||||
anything that would exceed the ceiling regardless of whether Layer A
|
||||
has caught up; on bypass routes Layer A is the only thing watching, so
|
||||
isolated one-block transients reach the real sink. Layer A reduces
|
||||
*workload* on Layer C where Layer C is in the path, and is a
|
||||
best-effort comfort filter where it isn't; it doesn't replace the
|
||||
limiter.
|
||||
|
||||
### 4.6 Layered budget summary
|
||||
|
||||
|
|
@ -593,9 +602,24 @@ updates arrive over an `rtrb` SPSC queue from the control thread.
|
|||
|
||||
## 6. Profiles
|
||||
|
||||
Location: `$XDG_CONFIG_HOME/headroom/profiles/*.toml` (overriding
|
||||
shipped defaults in `/usr/share/headroom/profiles/` if installed
|
||||
system-wide). Hot-reloaded via `notify-debouncer-mini`.
|
||||
Profile files live in `$XDG_CONFIG_HOME/headroom/profiles/*.toml`,
|
||||
shadowing shipped defaults in `/usr/share/headroom/profiles/` by
|
||||
name. Profile files are user-authored configuration — they're the
|
||||
thing you open in `$EDITOR`. File-watcher hot-reload via
|
||||
`notify-debouncer-mini` is planned; in the meantime `profile.reload`
|
||||
re-scans on demand.
|
||||
|
||||
Daemon-managed user state — active profile name, per-app route
|
||||
overrides made via `route.set`, dotted-key tweaks made via
|
||||
`setting.set`, the global bypass flag — is *not* mixed in with the
|
||||
profile TOMLs. It lives in a single `overlay.toml` at
|
||||
`$XDG_STATE_HOME/headroom/overlay.toml`, written atomically by the
|
||||
daemon (stage to `overlay.toml.tmp-…`, then rename). The overlay
|
||||
rides on top of whichever profile is active, so `route.set obs
|
||||
bypass` persists across `profile.use night` — that's a user
|
||||
preference, not a tweak of `default`. If the overlay names an active
|
||||
profile that's not on disk, the daemon falls back to the built-in
|
||||
default and surfaces a warning; it does not refuse to start.
|
||||
|
||||
Each profile is a complete listening scenario. Schema (`headroom-core::profile`):
|
||||
|
||||
|
|
@ -664,6 +688,10 @@ max_cut_db = 12.0 # never cut more than this
|
|||
peak_attack_ms = 5.0
|
||||
peak_release_ms = 500.0
|
||||
rms_window_ms = 1500.0
|
||||
# Controller-side knobs (all optional; defaults shown).
|
||||
smoother_ms = 30.0 # anti-bounce smoother on max(peak,rms)
|
||||
write_db_threshold = 0.5 # dB diff below which we don't fire a write
|
||||
min_write_interval_ms = 100.0 # min ms between writes per stream (10 Hz cap)
|
||||
defer_to_user = "ceiling" # "ceiling" | "strict"
|
||||
|
||||
[[per_app.rules]]
|
||||
|
|
@ -826,6 +854,88 @@ routing engine. Hardcoded profile, no IPC server yet.
|
|||
IPC schema. Profile loading + hot-reload. Slow AGC loop ticking on
|
||||
real loudness measurements.
|
||||
|
||||
Sub-stages used in commits / TODOs:
|
||||
|
||||
- **4a–4d** — Unix socket server, op dispatch, mutating ops, event
|
||||
broadcaster.
|
||||
- **4e** — `ProfileStore`: shipped + user profiles, atomic reload,
|
||||
user overlay at `$XDG_STATE_HOME/headroom/overlay.toml`. `profile.use`,
|
||||
`profile.reload`, `setting.set`, `route.set` all dispatch through it.
|
||||
- **4f** — DSP parameter propagation: `setting.set` reaches the running
|
||||
filter via the `rtrb` control queue, so live profile/setting edits
|
||||
take effect without restart.
|
||||
- **4h** — `preferred_real_sink` tracking: subscribe to
|
||||
`default.audio.sink`, snapshot the prior default, promote
|
||||
`headroom-processed`, retarget every bypassed stream on
|
||||
default-sink change, on hotplug, and on Bluetooth handoff. Also
|
||||
pins the filter's playback to the tracked real sink so processed
|
||||
audio follows when the user switches default, and resolves the
|
||||
real sink's node id from the registry for `status` reporting.
|
||||
- **4i** — `route.stream <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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
crates/headroom-core/benches/app_level.rs
Normal file
78
crates/headroom-core/benches/app_level.rs
Normal 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);
|
||||
337
crates/headroom-core/src/agc.rs
Normal file
337
crates/headroom-core/src/agc.rs
Normal 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 5–20 ms quantum-reaction budget so the
|
||||
//! control plane can ride the PipeWire main-loop thread alongside
|
||||
//! the `route.stream` timer (see `pw::command` module docs).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use ebur128::{EbuR128, Mode};
|
||||
|
||||
use crate::pw::filter::FilterControl;
|
||||
use crate::state::SharedState;
|
||||
|
||||
/// AGC tick period. Hardcoded for v0; not exposed as a profile knob.
|
||||
pub const AGC_TICK: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Maximum samples fed per tick. Big enough to cover ~50 ms of stereo
|
||||
/// at 48 kHz (4800 samples) with slack; smaller than a stack-frame
|
||||
/// alarm. Sized to keep `ebur128.add_frames_f32` work bounded.
|
||||
const TICK_BUF_SAMPLES: usize = 8192;
|
||||
|
||||
/// Loudness floor we treat as "no usable measurement yet" — returned
|
||||
/// by `ebur128` before its short-term window has filled, or during
|
||||
/// digital silence.
|
||||
const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
|
||||
|
||||
/// Slow AGC controller.
|
||||
pub struct AgcController {
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
ebu: EbuR128,
|
||||
measurement_consumer: rtrb::Consumer<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);
|
||||
}
|
||||
}
|
||||
551
crates/headroom-core/src/app_level.rs
Normal file
551
crates/headroom-core/src/app_level.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1084
crates/headroom-core/src/profile_store.rs
Normal file
1084
crates/headroom-core/src/profile_store.rs
Normal file
File diff suppressed because it is too large
Load diff
178
crates/headroom-core/src/profile_watcher.rs
Normal file
178
crates/headroom-core/src/profile_watcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
56
crates/headroom-core/src/pw/command.rs
Normal file
56
crates/headroom-core/src/pw/command.rs
Normal 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 (5–20 ms). Routing its
|
||||
//! `Props.channelVolumes` writes through this channel would break
|
||||
//! the §4.5 reaction-time contract.
|
||||
//! - The filter's compressor/limiter parameter updates already
|
||||
//! bypass this channel — they go through
|
||||
//! [`crate::pw::filter::FilterControl`]'s `rtrb`, which is wait-free
|
||||
//! and drained at the top of every realtime callback.
|
||||
//!
|
||||
//! If you're adding a new variant and your use case touches either
|
||||
//! the realtime audio path or a spike-reactive gain envelope, do
|
||||
//! **not** add it here. Phase 6 introduces a tighter dispatch
|
||||
//! primitive (likely an `EventSource::signal` shim or a pipe-fd
|
||||
//! `IoSource`) for that traffic; reuse that instead.
|
||||
|
||||
use headroom_ipc::Route;
|
||||
|
||||
/// Commands the IPC threads ask the PipeWire main loop to execute.
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum PwCommand {
|
||||
/// Set `target.object` for a specific stream, overriding any rule
|
||||
/// the routing engine would otherwise apply. Used by
|
||||
/// `Op::RouteStream` (4i).
|
||||
RouteStream {
|
||||
/// Stream node id.
|
||||
node_id: u32,
|
||||
/// Desired route.
|
||||
to: Route,
|
||||
/// Cached app label for log lines / events.
|
||||
app_label: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
283
crates/headroom-core/src/pw/tap.rs
Normal file
283
crates/headroom-core/src/pw/tap.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
crates/headroom-dsp/benches/layer_a.rs
Normal file
130
crates/headroom-dsp/benches/layer_a.rs
Normal 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);
|
||||
211
crates/headroom-dsp/src/agc.rs
Normal file
211
crates/headroom-dsp/src/agc.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
427
crates/headroom-dsp/src/level_envelopes.rs
Normal file
427
crates/headroom-dsp/src/level_envelopes.rs
Normal 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 (~1–2 s). Triggers a cut when the smoothed RMS in dB
|
||||
//! crosses `rms_target_db`. Catches sustained loudness mismatches.
|
||||
//!
|
||||
//! Output reduction is `max(peak_reduction, rms_reduction)`, clamped
|
||||
//! to `max_cut_db`. Recovery is implicit: each envelope releases at
|
||||
//! its own time constant, so neither path stays engaged once the
|
||||
//! input drops.
|
||||
|
||||
use crate::util::{lin_to_db, time_to_alpha};
|
||||
|
||||
/// Per-rule configuration. Mirrors `[per_app.rules]` in the profile
|
||||
/// schema (PLAN §6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LevelEnvelopesConfig {
|
||||
/// Peak envelope threshold (dBFS). Output rises above this →
|
||||
/// reduce gain. Default −6 dBFS.
|
||||
pub peak_threshold_db: f32,
|
||||
/// RMS envelope target (dBFS, equivalent). Smoothed RMS rising
|
||||
/// above this → reduce gain. Default ≈ −20 dBFS.
|
||||
pub rms_target_db: f32,
|
||||
/// Maximum cut the envelopes may request (dB). The signed cap on
|
||||
/// `max(peak_reduction, rms_reduction)`. Default 12 dB.
|
||||
pub max_cut_db: f32,
|
||||
/// Peak envelope attack time (ms). Time for the envelope to
|
||||
/// approach the input on a rising peak.
|
||||
pub peak_attack_ms: f32,
|
||||
/// Peak envelope release time (ms). Time for the envelope to
|
||||
/// decay back toward silence after the peak drops.
|
||||
pub peak_release_ms: f32,
|
||||
/// RMS smoothing window (ms). One-pole time constant on the
|
||||
/// mean-square input.
|
||||
pub rms_window_ms: f32,
|
||||
}
|
||||
|
||||
impl Default for LevelEnvelopesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
peak_threshold_db: -6.0,
|
||||
rms_target_db: -20.0,
|
||||
max_cut_db: 12.0,
|
||||
peak_attack_ms: 5.0,
|
||||
peak_release_ms: 500.0,
|
||||
rms_window_ms: 1500.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LevelEnvelopesConfig {
|
||||
/// Sanitize: clamp non-finite values, ensure release > 0, threshold
|
||||
/// at or below 0 dB.
|
||||
#[must_use]
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
if self.peak_threshold_db > 0.0 {
|
||||
self.peak_threshold_db = 0.0;
|
||||
}
|
||||
if self.rms_target_db > 0.0 {
|
||||
self.rms_target_db = 0.0;
|
||||
}
|
||||
if !self.max_cut_db.is_finite() || self.max_cut_db < 0.0 {
|
||||
self.max_cut_db = 0.0;
|
||||
}
|
||||
for v in [
|
||||
&mut self.peak_attack_ms,
|
||||
&mut self.peak_release_ms,
|
||||
&mut self.rms_window_ms,
|
||||
] {
|
||||
if !v.is_finite() || *v < 0.0 {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-tier per-stream level detector.
|
||||
pub struct LevelEnvelopes {
|
||||
cfg: LevelEnvelopesConfig,
|
||||
/// Block period (s). Cached so we don't recompute alphas every
|
||||
/// call when the audio thread holds the quantum steady. Recomputed
|
||||
/// on `set_block_dt`.
|
||||
block_dt_s: f32,
|
||||
peak_attack_alpha: f32,
|
||||
peak_release_alpha: f32,
|
||||
rms_alpha: f32,
|
||||
/// Smoothed peak in dB. Starts at floor so first push doesn't
|
||||
/// trip the threshold artificially.
|
||||
peak_env_db: f32,
|
||||
/// Smoothed mean-square (linear). Starts at 0.
|
||||
rms_smoothed_mean_sq: f32,
|
||||
}
|
||||
|
||||
/// Result of processing one block.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LevelDecision {
|
||||
/// dB cut requested by the peak envelope (`0` when not engaged).
|
||||
pub peak_reduction_db: f32,
|
||||
/// dB cut requested by the RMS envelope (`0` when not engaged).
|
||||
pub rms_reduction_db: f32,
|
||||
/// Combined recommendation: `min(max_cut, max(peak, rms))`.
|
||||
/// Always `>= 0`; `0` means "no cut, leave channelVolumes alone."
|
||||
pub total_reduction_db: f32,
|
||||
}
|
||||
|
||||
impl LevelEnvelopes {
|
||||
/// Construct from a config and the audio thread's nominal block
|
||||
/// period. The block period (`samples_per_block / sample_rate`)
|
||||
/// must be small enough that the envelopes track properly; values
|
||||
/// up to ~100 ms work for v0.
|
||||
#[must_use]
|
||||
pub fn new(cfg: LevelEnvelopesConfig, block_dt_s: f32) -> Self {
|
||||
let cfg = cfg.sanitized();
|
||||
let (peak_attack_alpha, peak_release_alpha, rms_alpha) = compute_alphas(&cfg, block_dt_s);
|
||||
Self {
|
||||
cfg,
|
||||
block_dt_s,
|
||||
peak_attack_alpha,
|
||||
peak_release_alpha,
|
||||
rms_alpha,
|
||||
peak_env_db: -200.0,
|
||||
rms_smoothed_mean_sq: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current configuration.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> LevelEnvelopesConfig {
|
||||
self.cfg
|
||||
}
|
||||
|
||||
/// Block period the alphas were computed against.
|
||||
#[must_use]
|
||||
pub fn block_dt_s(&self) -> f32 {
|
||||
self.block_dt_s
|
||||
}
|
||||
|
||||
/// Update parameters in place. Recomputes alphas; resets neither
|
||||
/// envelope state (live tweaks don't cause artefacts).
|
||||
pub fn set_config(&mut self, cfg: LevelEnvelopesConfig) {
|
||||
let cfg = cfg.sanitized();
|
||||
let (a_a, a_r, a_rms) = compute_alphas(&cfg, self.block_dt_s);
|
||||
self.cfg = cfg;
|
||||
self.peak_attack_alpha = a_a;
|
||||
self.peak_release_alpha = a_r;
|
||||
self.rms_alpha = a_rms;
|
||||
}
|
||||
|
||||
/// Update the assumed block period (re-derives alphas). Call when
|
||||
/// the audio thread's quantum changes.
|
||||
pub fn set_block_dt(&mut self, dt_s: f32) {
|
||||
if dt_s <= 0.0 || !dt_s.is_finite() || (dt_s - self.block_dt_s).abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
self.block_dt_s = dt_s;
|
||||
let (a_a, a_r, a_rms) = compute_alphas(&self.cfg, dt_s);
|
||||
self.peak_attack_alpha = a_a;
|
||||
self.peak_release_alpha = a_r;
|
||||
self.rms_alpha = a_rms;
|
||||
}
|
||||
|
||||
/// Process one block. `peak_lin` is the per-block max of
|
||||
/// absolute samples (linear); `mean_sq_lin` is the per-block
|
||||
/// `Σx²/N`. Allocation-free.
|
||||
pub fn process_block(&mut self, peak_lin: f32, mean_sq_lin: f32) -> LevelDecision {
|
||||
let peak_lin = peak_lin.max(0.0);
|
||||
let mean_sq_lin = mean_sq_lin.max(0.0);
|
||||
|
||||
// Peak envelope in dB. Attack on rising edge, release on
|
||||
// falling. Use the actual block measurement as the target.
|
||||
let target_db = lin_to_db(peak_lin);
|
||||
if target_db > self.peak_env_db {
|
||||
self.peak_env_db += self.peak_attack_alpha * (target_db - self.peak_env_db);
|
||||
} else {
|
||||
self.peak_env_db += self.peak_release_alpha * (target_db - self.peak_env_db);
|
||||
}
|
||||
|
||||
// RMS envelope: smooth mean_sq directly (one alpha) then
|
||||
// convert to dB. Smoothing in the linear-power domain is the
|
||||
// canonical R128 / IEC-style RMS detector.
|
||||
self.rms_smoothed_mean_sq += self.rms_alpha * (mean_sq_lin - self.rms_smoothed_mean_sq);
|
||||
// 20*log10(sqrt(mean_sq)) = 10*log10(mean_sq).
|
||||
let rms_db = 10.0 * self.rms_smoothed_mean_sq.max(1e-30).log10();
|
||||
|
||||
let peak_reduction_db = (self.peak_env_db - self.cfg.peak_threshold_db).max(0.0);
|
||||
let rms_reduction_db = (rms_db - self.cfg.rms_target_db).max(0.0);
|
||||
let combined = peak_reduction_db.max(rms_reduction_db);
|
||||
let total_reduction_db = combined.min(self.cfg.max_cut_db);
|
||||
|
||||
LevelDecision {
|
||||
peak_reduction_db,
|
||||
rms_reduction_db,
|
||||
total_reduction_db,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset envelope state. Useful when re-attaching to a stream
|
||||
/// after a deference period.
|
||||
pub fn reset(&mut self) {
|
||||
self.peak_env_db = -200.0;
|
||||
self.rms_smoothed_mean_sq = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_alphas(cfg: &LevelEnvelopesConfig, block_dt_s: f32) -> (f32, f32, f32) {
|
||||
// The smoother is `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`, so a
|
||||
// larger alpha means a faster smoother. Cache the per-block alpha
|
||||
// derived from a continuous-time tau.
|
||||
let block_dt_ms = block_dt_s * 1000.0;
|
||||
let block_rate = if block_dt_s > 0.0 { 1.0 / block_dt_s } else { 1.0 };
|
||||
let attack = time_to_alpha(cfg.peak_attack_ms, block_rate);
|
||||
let release = time_to_alpha(cfg.peak_release_ms, block_rate);
|
||||
let rms = time_to_alpha(cfg.rms_window_ms, block_rate);
|
||||
// We use `time_to_alpha` against a *block rate* (Hz), not a
|
||||
// sample rate, because the smoothers operate at block boundaries
|
||||
// — the audio thread emits one (peak, mean_sq) pair per block.
|
||||
// `time_to_alpha` is sample-rate-agnostic: it converts time_ms
|
||||
// and a rate into alpha. Block rate is just "samples per second"
|
||||
// where each "sample" is a block.
|
||||
let _ = block_dt_ms; // currently informational
|
||||
(attack, release, rms)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::db_to_lin;
|
||||
|
||||
/// 1024-frame quantum at 48 kHz.
|
||||
const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;
|
||||
|
||||
fn run_steady(env: &mut LevelEnvelopes, peak_lin: f32, mean_sq_lin: f32, blocks: usize) -> LevelDecision {
|
||||
let mut last = env.process_block(peak_lin, mean_sq_lin);
|
||||
for _ in 1..blocks {
|
||||
last = env.process_block(peak_lin, mean_sq_lin);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_signal_produces_no_reduction() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
let quiet = db_to_lin(-30.0);
|
||||
let mean_sq = quiet * quiet;
|
||||
let dec = run_steady(&mut env, quiet, mean_sq, 200);
|
||||
assert_eq!(dec.peak_reduction_db, 0.0);
|
||||
assert_eq!(dec.rms_reduction_db, 0.0);
|
||||
assert_eq!(dec.total_reduction_db, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_above_threshold_requests_cut() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -6.0,
|
||||
// Long RMS window so the slow path doesn't dominate.
|
||||
rms_target_db: 0.0,
|
||||
rms_window_ms: 5_000.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
// 0 dBFS peak: 6 dB over threshold.
|
||||
let peak = db_to_lin(0.0);
|
||||
let mean_sq = (peak * peak) * 0.05; // low rms (intermittent peak)
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert!(
|
||||
(dec.peak_reduction_db - 6.0).abs() < 0.5,
|
||||
"expected ~6 dB peak cut, got {}",
|
||||
dec.peak_reduction_db
|
||||
);
|
||||
assert_eq!(dec.rms_reduction_db, 0.0);
|
||||
assert!((dec.total_reduction_db - 6.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_above_target_requests_cut() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
// Push peak threshold up so only RMS engages.
|
||||
peak_threshold_db: 0.0,
|
||||
rms_target_db: -20.0,
|
||||
rms_window_ms: 200.0, // shorter so test converges quickly
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
// Sustained -10 dBFS RMS: 10 dB above target.
|
||||
let rms_lin = db_to_lin(-10.0);
|
||||
let mean_sq = rms_lin * rms_lin;
|
||||
// Peak set just below threshold so peak detector stays asleep.
|
||||
let peak = db_to_lin(-1.0);
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert_eq!(dec.peak_reduction_db, 0.0);
|
||||
assert!(
|
||||
(dec.rms_reduction_db - 10.0).abs() < 0.5,
|
||||
"expected ~10 dB RMS cut, got {}",
|
||||
dec.rms_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_takes_max_of_peak_and_rms() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -6.0,
|
||||
rms_target_db: -20.0,
|
||||
rms_window_ms: 200.0,
|
||||
max_cut_db: 100.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
let peak = db_to_lin(0.0); // 6 dB over
|
||||
let rms_lin = db_to_lin(-10.0); // 10 dB over
|
||||
let mean_sq = rms_lin * rms_lin;
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert!((dec.peak_reduction_db - 6.0).abs() < 0.5);
|
||||
assert!((dec.rms_reduction_db - 10.0).abs() < 0.5);
|
||||
assert!(
|
||||
(dec.total_reduction_db - 10.0).abs() < 0.5,
|
||||
"max(6, 10) ≈ 10, got {}",
|
||||
dec.total_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_reduction_is_clamped_to_max_cut_db() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -30.0,
|
||||
rms_target_db: -30.0,
|
||||
rms_window_ms: 50.0,
|
||||
max_cut_db: 3.0, // tight cap
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
let peak = db_to_lin(0.0); // 30 dB over
|
||||
let rms_lin = db_to_lin(-5.0);
|
||||
let mean_sq = rms_lin * rms_lin;
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert!(dec.peak_reduction_db > 20.0);
|
||||
assert!(
|
||||
(dec.total_reduction_db - 3.0).abs() < 1e-3,
|
||||
"total clamped to max_cut_db, got {}",
|
||||
dec.total_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_envelope_releases_after_burst() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -6.0,
|
||||
rms_target_db: 0.0,
|
||||
rms_window_ms: 5_000.0,
|
||||
peak_attack_ms: 5.0,
|
||||
peak_release_ms: 100.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
// Burst.
|
||||
for _ in 0..20 {
|
||||
env.process_block(db_to_lin(0.0), 0.0);
|
||||
}
|
||||
let burst = env.process_block(db_to_lin(0.0), 0.0);
|
||||
assert!(burst.peak_reduction_db > 5.0);
|
||||
|
||||
// Silence.
|
||||
for _ in 0..200 {
|
||||
env.process_block(0.0, 0.0);
|
||||
}
|
||||
let quiet = env.process_block(0.0, 0.0);
|
||||
assert!(
|
||||
quiet.peak_reduction_db < 0.5,
|
||||
"expected ~0 after release, got {}",
|
||||
quiet.peak_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_updates_alphas_without_reset() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
for _ in 0..100 {
|
||||
env.process_block(db_to_lin(-3.0), 0.0);
|
||||
}
|
||||
let before = env.process_block(db_to_lin(-3.0), 0.0);
|
||||
// Tighter threshold; envelope state preserved across the swap.
|
||||
env.set_config(LevelEnvelopesConfig {
|
||||
peak_threshold_db: -12.0,
|
||||
..LevelEnvelopesConfig::default()
|
||||
});
|
||||
let after = env.process_block(db_to_lin(-3.0), 0.0);
|
||||
assert!(
|
||||
after.peak_reduction_db > before.peak_reduction_db,
|
||||
"tighter threshold should request more cut"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_block_dt_recomputes_alphas() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
let original_attack = env.peak_attack_alpha;
|
||||
// Double the block period — slower block rate → smaller alpha
|
||||
// for the same time constant.
|
||||
env.set_block_dt(BLOCK_DT_S * 2.0);
|
||||
assert!(env.peak_attack_alpha > original_attack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_idle_state() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
for _ in 0..200 {
|
||||
env.process_block(db_to_lin(0.0), db_to_lin(-3.0));
|
||||
}
|
||||
env.reset();
|
||||
let dec = env.process_block(0.0, 0.0);
|
||||
assert_eq!(dec.peak_reduction_db, 0.0);
|
||||
assert_eq!(dec.rms_reduction_db, 0.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue