Compare commits
No commits in common. "716290c3bfcb114f744ab5db17921c192e2a014a" and "fcf421b94cf15f9f92e37215e150fd85e248392e" have entirely different histories.
716290c3bf
...
fcf421b94c
31 changed files with 321 additions and 3964 deletions
388
Cargo.lock
generated
388
Cargo.lock
generated
|
|
@ -11,12 +11,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
|
|
@ -89,12 +83,6 @@ version = "1.0.102"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "assert_no_alloc"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55ca83137a482d61d916ceb1eba52a684f98004f18e0cafea230fe5579c178a3"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
|
|
@ -140,27 +128,12 @@ version = "1.25.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
|
|
@ -280,20 +253,6 @@ version = "1.0.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
|
|
@ -361,72 +320,12 @@ version = "0.8.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"crossterm_winapi",
|
||||
"mio 1.2.0",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_frame"
|
||||
version = "0.11.0"
|
||||
|
|
@ -492,18 +391,6 @@ version = "0.1.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
|
|
@ -618,17 +505,6 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
|
|
@ -639,14 +515,10 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|||
name = "headroom-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"assert_no_alloc",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"crossterm",
|
||||
"headroom-client",
|
||||
"headroom-core",
|
||||
"headroom-ipc",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
|
|
@ -667,7 +539,6 @@ dependencies = [
|
|||
name = "headroom-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"assert_no_alloc",
|
||||
"bytemuck",
|
||||
"criterion",
|
||||
"crossbeam-channel",
|
||||
|
|
@ -720,12 +591,6 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
|
|
@ -733,16 +598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -765,19 +621,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
|
|
@ -813,15 +656,6 @@ dependencies = [
|
|||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -904,12 +738,6 @@ dependencies = [
|
|||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
|
|
@ -925,15 +753,6 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
|
|
@ -967,18 +786,6 @@ dependencies = [
|
|||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.27.1"
|
||||
|
|
@ -1014,7 +821,7 @@ dependencies = [
|
|||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"mio",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
|
@ -1089,12 +896,6 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -1153,27 +954,6 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"itertools 0.13.0",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -1224,31 +1004,6 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
|
|
@ -1341,17 +1096,6 @@ dependencies = [
|
|||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio 1.2.0",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
|
|
@ -1374,40 +1118,12 @@ version = "1.15.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
|
@ -1611,17 +1327,6 @@ version = "1.13.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
|
|
@ -1705,16 +1410,7 @@ version = "0.48.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1732,29 +1428,13 @@ version = "0.48.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
"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]]
|
||||
|
|
@ -1763,90 +1443,42 @@ version = "0.48.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[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_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[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_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[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_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
|
|
|
|||
|
|
@ -37,12 +37,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
|||
# CLI
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
# TUI (monitor). Pinned to versions whose transitive deps still build
|
||||
# on the project's pinned rustc 1.86 (newer ratatui pulls
|
||||
# `instability` 0.3.12 + `darling` 0.23 which need 1.88+).
|
||||
ratatui = "=0.28.1"
|
||||
crossterm = "=0.28.1"
|
||||
|
||||
# Concurrency / control plane
|
||||
crossbeam-channel = "0.5"
|
||||
parking_lot = "0.12"
|
||||
|
|
|
|||
137
PLAN.md
137
PLAN.md
|
|
@ -233,23 +233,6 @@ downsampling) guarantee the contract numerically — the envelope can
|
|||
misbehave and the contract still holds. Never bypassed, never
|
||||
disabled.
|
||||
|
||||
**Contract scope (caveat).** The ≤ −0.1 dBTP guarantee holds at the
|
||||
*filter's output*, not at the speaker. The bus filter is hardcoded
|
||||
F32 stereo @ 48 kHz (`headroom-dsp::limiter`'s 4× oversampler is
|
||||
sized for 48 k); when the real sink negotiates a different rate
|
||||
(44.1 kHz, 96 kHz, 192 kHz), PipeWire inserts a downstream
|
||||
resampler between `filter.playback` and the sink. Polynomial /
|
||||
windowed-sinc resamplers can elevate inter-sample peaks slightly
|
||||
through their own reconstruction, so the limiter's true-peak
|
||||
guarantee leaks across that resampling stage. In practice the
|
||||
elevation is small (a few tenths of a dB worst case for a clean
|
||||
band-limited resampler), and the contract still holds at the bus
|
||||
output where headroom is in control. **For the contract to hold
|
||||
end-to-end the filter would need to match the real sink's rate
|
||||
and rebuild its DSP coefficients on rate-change** — that's the
|
||||
v1 work tracked as PLAN §11 "filter rate matching" (deferred from
|
||||
8d, gated on a multi-rate hardware test bench).
|
||||
|
||||
**Soft tier — the comfort cap.** Targets a *dynamic* ceiling computed
|
||||
as `program_lufs + max_psr_db`. Smooth attack/release envelope so the
|
||||
gain reduction sounds like volume riding, not a slap. Pulls transients
|
||||
|
|
@ -848,10 +831,7 @@ builds and any CI go through `nix build`.
|
|||
|
||||
## 11. Phased implementation
|
||||
|
||||
The phases are roughly token-of-work units, not calendar weeks. **All
|
||||
planned phases (0–8) are done as of 2026-05-21**; this section is
|
||||
preserved as historical context + a reading guide to the commit log.
|
||||
See [[headroom-project]] in team memory for the per-commit ledger.
|
||||
The phases are roughly token-of-work units, not calendar weeks.
|
||||
|
||||
**Phase 0 — scaffolding.** Flake, workspace, crate skeletons, README,
|
||||
PLAN/IPC docs. *(done as part of this commit)*
|
||||
|
|
@ -911,31 +891,33 @@ Sub-stages used in commits / TODOs:
|
|||
### 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 trigger that gates them fires.
|
||||
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. **Dormant** — no user has
|
||||
asked through Phase 8.
|
||||
- **Filter rate matching to the real sink.** *(F5 follow-up.)* §3.1
|
||||
documents the contract leak when the real sink runs at a
|
||||
non-48 kHz native rate. Closing it requires dynamic
|
||||
`FILTER_SAMPLE_RATE`, kernel rebuild on real-sink change
|
||||
(compressor + limiter coefficients are rate-dependent), and
|
||||
Layer A's `LAYER_A_BLOCK_DT_S` constant becoming dynamic too.
|
||||
Gated on a multi-rate hardware test bench — no point shipping
|
||||
the refactor without something to validate it against. **v1 scope.**
|
||||
- ~~**Filter playback BUSY spikes (periodic, ~10 s cadence).**~~
|
||||
**Closed in 8e (`d52cd6d`).** The instrumentation added by 8e
|
||||
did not reproduce the ~8×-baseline outlier pattern in a ~3 min
|
||||
release-build capture; steady state was ~2.2 ms / call at this
|
||||
hardware's quantum with max growing only to 1.3× baseline.
|
||||
`PlaybackTiming` stays so future regressions surface at WARN.
|
||||
Original observation may have been a transient WP/PW housekeeping
|
||||
artefact under a different config; no actionable code change.
|
||||
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
|
||||
|
|
@ -1060,68 +1042,18 @@ 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.** *Done — `c65c75b`.* `contrib/systemd/headroom.service`
|
||||
(user-scope, Type=simple, After=pipewire.service, Restart=on-failure,
|
||||
journald, LimitRTPRIO=20). The package's `postInstall` substitutes
|
||||
the unit's `@bindir@` placeholder with an absolute store path and
|
||||
copies `profiles/*.toml` to `share/headroom/profiles/`. Two Nix
|
||||
modules: `nixosModules.default` (`programs.headroom.enable` —
|
||||
binary on global PATH + `systemd.packages` for `systemctl --user`
|
||||
discovery + hard assertion on `services.pipewire.enable`) and
|
||||
`homeModules.default` (`services.headroom.enable` — symlinks
|
||||
shipped profiles into `$XDG_CONFIG_HOME/headroom/profiles/`,
|
||||
`extraProfiles` attrset for per-user overrides, writes the systemd
|
||||
user unit). README rewritten with install + usage sections.
|
||||
**Phase 7 — Packaging.** systemd user unit, install paths, default
|
||||
profile install, basic NixOS module.
|
||||
|
||||
**Phase 8 — Hardening.** *Done — `9220143` + `d52cd6d` + verification.*
|
||||
- **8a — `assert_no_alloc` on audio-thread callbacks (`9220143`).**
|
||||
`#[global_allocator] AllocDisabler` in `headroom-cli/src/main.rs`
|
||||
behind `cfg(debug_assertions)` (release strips it via the crate's
|
||||
default `disable_release`). The three RT callbacks
|
||||
(`capture_process`, `playback_process`, `tap_process`) wrap their
|
||||
body in `assert_no_alloc(|| inner(...))`. Verified by a deliberate
|
||||
`Vec::with_capacity` injection → SIGABRT on first audio callback;
|
||||
reverted before commit. Audio thread proven alloc-free under
|
||||
multi-thousand-callback live load.
|
||||
- **8b — live profile-reload under signal flow (verification only).**
|
||||
Edit `$XDG_CONFIG_HOME/headroom/profiles/<active>.toml` while a
|
||||
sine plays: notify-debouncer-mini fires, `ProfileStore::reload`
|
||||
runs, `setting.set` propagates via `FilterControl`'s rtrb to the
|
||||
audio thread. Compressor GR went 0 → −9.3 dB ≈ 1 s after edit
|
||||
and back to 0 after restore; 180 meter ticks over 9 s with max
|
||||
inter-tick gap = exact 50.0 ms (the AGC period). No glitches.
|
||||
- **8c — sink hotplug / default-sink change (verification only).**
|
||||
`wpctl set-default <other-sink>` while daemon runs:
|
||||
`on_metadata_property` fires, `adopt_new_real_sink` runs,
|
||||
filter.playback re-pinned via 4k explicit-link enforcement,
|
||||
`routing/real_sink_changed` emitted on the wire. Bounces back
|
||||
cleanly.
|
||||
- **8d — multi-rate hardware (partial / deferred).** Filter is
|
||||
hardcoded F32 stereo @ 48 kHz; PipeWire's link layer inserts a
|
||||
resampler at the filter.playback → real-sink edge when rates
|
||||
differ; bus DSP stays at 48 kHz internally. Architecture is
|
||||
sound; real-hardware validation (USB DAC at 96k etc.) deferred
|
||||
until available.
|
||||
- **8e — playback callback timing instrumentation (`d52cd6d`).**
|
||||
Lock-free `PlaybackTiming` atomics in `meters.rs`; AGC controller
|
||||
drains once per second and logs at WARN above
|
||||
`SPIKE_THRESHOLD_US = 5000`. The original ~10 s-cadence ~8×
|
||||
spike pattern from §11 follow-ups *did not reproduce* in a ~3 min
|
||||
release-build capture; steady state 2.2 ms / call at ~4 Hz,
|
||||
max climbed to only 1.3× baseline. Instrumentation kept so
|
||||
future regressions surface.
|
||||
**Phase 8 — Hardening.** Latency budget verification on real hardware,
|
||||
Bluetooth-handoff edge case, profile-reload while audio is flowing,
|
||||
multi-rate hardware, allocation-tracer sweep with
|
||||
`assert_no_alloc` in debug.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks & open questions
|
||||
|
||||
These are the original v0 design risks — still useful as a checklist
|
||||
for new contributors. Phase 4k/4l/8c have exercised the routing /
|
||||
hotplug / default-sink branches; the bullets below are unchanged
|
||||
since several of them remain live concerns for non-NixOS distros
|
||||
and multi-rate hardware. See [[headroom-project]] in team memory
|
||||
for current status per risk.
|
||||
|
||||
- **WirePlumber re-linking on device hotplug.** When a Bluetooth
|
||||
headset connects, WP re-evaluates linking. Headroom must re-pin its
|
||||
routed streams. Tractable; the registry events surface this.
|
||||
|
|
@ -1139,17 +1071,8 @@ for current status per risk.
|
|||
filter should source its rate from the real sink and convert on the
|
||||
capture side only.
|
||||
- **Surround content downmix vs. passthrough.** v0 punts: anything
|
||||
`>2ch` is force-bypassed regardless of profile rule. The bus
|
||||
filter is F32 stereo by construction and pulling a 5.1+ stream
|
||||
into it would either drop the centre/LFE/surround channels (with
|
||||
explicit links pairing only the first two ports) or run our DSP
|
||||
on a downmix that wasn't asked for. The check fires in
|
||||
`routing::evaluate` based on `PwNodeInfo.audio_channels` (parsed
|
||||
from the stream's `audio.channels` property). The explicit-link
|
||||
pairing in `apply_pending_routes` was generalised from `take(2)`
|
||||
to `take(min(src, dst))` so wide bypass to a wide real sink links
|
||||
all channels; narrower sinks let PipeWire's source-side adapter
|
||||
handle downmix as usual.
|
||||
>2ch is routed directly to the real sink (bypass behaviour)
|
||||
regardless of profile rule. Documented behaviour.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
94
README.md
94
README.md
|
|
@ -13,114 +13,24 @@ untouched.
|
|||
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.
|
||||
- **Layer A per-app level control** (peak + RMS detector → smoothed
|
||||
`channelVolumes` writes) for taming individual streams without
|
||||
touching the bus path. Zero added signal-path latency; safe to use
|
||||
on bypass-routed streams.
|
||||
- **Single binary** daemon + CLI, controlled over a Unix-domain socket
|
||||
with a documented JSON wire protocol (see [`IPC.md`](IPC.md)).
|
||||
- **First-party Rust crate** (`headroom-client`) for programmatic use;
|
||||
third-party clients (Qt panels, status bars, …) target the wire
|
||||
protocol directly.
|
||||
- **Live profile reload** — edit a TOML file in
|
||||
`$XDG_CONFIG_HOME/headroom/profiles/` and the daemon picks up
|
||||
changes within ~500 ms; the audio thread doesn't glitch.
|
||||
|
||||
See [`PLAN.md`](PLAN.md) for the full design and roadmap.
|
||||
|
||||
## Status
|
||||
|
||||
Alpha. The signal chain (AGC, compressor, two-tier limiter, Layer A
|
||||
per-app), the routing engine (explicit-link enforcement, sink hotplug,
|
||||
sticky default sink), the IPC server with topic subscriptions, the
|
||||
`headroom monitor` TUI, and live profile reload all work end-to-end.
|
||||
Packaging exposes a systemd user unit and Nix modules. What's missing
|
||||
is real-world soak time on multi-rate / Bluetooth setups and other
|
||||
distros' init systems.
|
||||
|
||||
## Installing
|
||||
|
||||
### Nix (flake)
|
||||
|
||||
This repo is a flake; the daemon plus its systemd user unit and the
|
||||
canonical profiles are exposed as a package.
|
||||
|
||||
```sh
|
||||
nix run github:amaanq/headroom -- daemon # one-shot run
|
||||
nix profile install github:amaanq/headroom # add to $PATH
|
||||
```
|
||||
|
||||
For **Home Manager**, add the flake as an input and enable the module:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.headroom.url = "github:amaanq/headroom";
|
||||
|
||||
# In your Home Manager configuration:
|
||||
imports = [ inputs.headroom.homeModules.default ];
|
||||
services.headroom.enable = true;
|
||||
}
|
||||
```
|
||||
|
||||
The module symlinks the shipped profiles into
|
||||
`$XDG_CONFIG_HOME/headroom/profiles/`, drops the systemd user unit
|
||||
into the user's services dir, and the unit starts after PipeWire and
|
||||
WirePlumber come up. `services.headroom.extraProfiles` lets you add
|
||||
your own.
|
||||
|
||||
For **NixOS** (system-wide binary install + systemd-user discovery):
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.headroom.url = "github:amaanq/headroom";
|
||||
|
||||
# In your NixOS configuration:
|
||||
imports = [ inputs.headroom.nixosModules.default ];
|
||||
programs.headroom.enable = true;
|
||||
}
|
||||
```
|
||||
|
||||
Then any user can `systemctl --user enable --now headroom`.
|
||||
|
||||
### Other distros (manual)
|
||||
|
||||
```sh
|
||||
cargo install --path crates/headroom-cli # or: cargo build --release
|
||||
# Profiles
|
||||
mkdir -p ~/.config/headroom/profiles
|
||||
cp profiles/*.toml ~/.config/headroom/profiles/
|
||||
# systemd user unit (edit the ExecStart path to point at your binary)
|
||||
install -Dm644 contrib/systemd/headroom.service \
|
||||
~/.config/systemd/user/headroom.service
|
||||
sed -i "s|@bindir@|$(dirname "$(command -v headroom)")|" \
|
||||
~/.config/systemd/user/headroom.service
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now headroom
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once the daemon is running:
|
||||
|
||||
```sh
|
||||
headroom status # JSON snapshot — sinks, streams, active profile
|
||||
headroom profile list # available profiles
|
||||
headroom profile use night # activate one
|
||||
headroom monitor # full-screen TUI (bus gauges + per-stream)
|
||||
headroom monitor --json meters # line-delimited JSON, for scripting
|
||||
headroom route set firefox processed
|
||||
headroom set compressor.threshold_db -28
|
||||
headroom bypass on # kill switch — straight to the real sink
|
||||
```
|
||||
|
||||
See `headroom --help` for the full surface.
|
||||
Pre-alpha. Wire protocol and crate scaffolding are in; daemon and
|
||||
filter are under construction.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
nix develop # toolchain + pipewire dev libs + helpers
|
||||
cargo build # iterate
|
||||
cargo test --workspace
|
||||
nix build # final packaged headroom binary
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
[Unit]
|
||||
Description=Headroom audio daemon (PipeWire AGC + compressor + true-peak limiter)
|
||||
Documentation=https://github.com/amaanq/headroom
|
||||
# PipeWire is a hard dependency: headroom registers a virtual sink and
|
||||
# wires explicit links via PW's link-factory, so we can't start before
|
||||
# pw-mainloop is up. ConditionUser ensures this only ever runs as a
|
||||
# user-scope unit, never accidentally as the system instance.
|
||||
After=pipewire.service pipewire-pulse.service wireplumber.service
|
||||
Requires=pipewire.service
|
||||
Wants=wireplumber.service
|
||||
ConditionUser=!@system
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=@bindir@/headroom daemon
|
||||
# Restart on failure but not too aggressively — a tight crash loop
|
||||
# would just produce a lot of stderr noise and clobber the user's
|
||||
# routing repeatedly.
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
# Headroom doesn't fork; SIGTERM is the clean shutdown path. The
|
||||
# default KillMode=control-group is correct for a single-process
|
||||
# daemon; explicit here for clarity.
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=5s
|
||||
# Surface stdout/stderr to journald so `journalctl --user -u headroom`
|
||||
# shows daemon logs with the expected RUST_LOG filtering.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=headroom
|
||||
# Realtime hint — pipewire grants RT scheduling via pw_thread_loop,
|
||||
# but the daemon main thread benefits from a slight scheduling boost
|
||||
# too. LimitRTPRIO matches the pipewire user unit's grant.
|
||||
LimitRTPRIO=20
|
||||
LimitRTTIME=200000
|
||||
LimitNICE=-11
|
||||
|
||||
[Install]
|
||||
WantedBy=pipewire.service
|
||||
|
|
@ -18,11 +18,7 @@ headroom-client = { workspace = true }
|
|||
headroom-core = { workspace = true }
|
||||
headroom-ipc = { workspace = true }
|
||||
|
||||
assert_no_alloc = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -6,24 +6,12 @@
|
|||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod tui;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use headroom_client::{Client, ClientError, Route, Topic};
|
||||
|
||||
// Wrap the system allocator so audio-thread `assert_no_alloc` blocks
|
||||
// in headroom-core can detect any allocation. Debug-only — in
|
||||
// release builds `assert_no_alloc`'s default `disable_release`
|
||||
// feature strips both `AllocDisabler` and the `assert_no_alloc(||
|
||||
// ...)` wrappers to no-ops, so there's zero overhead in production
|
||||
// (and the symbol doesn't even exist to reference here).
|
||||
#[cfg(debug_assertions)]
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
|
||||
|
||||
/// Headroom CLI.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
|
|
@ -78,19 +66,12 @@ enum Cmd {
|
|||
/// Reload profile files from disk.
|
||||
Reload,
|
||||
|
||||
/// Live monitor. Defaults to a full-screen TUI; `--json` falls back
|
||||
/// to the line-delimited JSON stream that previous versions
|
||||
/// produced (useful for scripting and tests).
|
||||
/// Subscribe to event topics and print as line-delimited JSON.
|
||||
Monitor {
|
||||
/// Topics to subscribe to (comma-separated). Only honoured with
|
||||
/// `--json`; the TUI always subscribes to all four event topics.
|
||||
/// Topics to subscribe to (comma-separated).
|
||||
/// Defaults to `meters` if none given.
|
||||
#[arg(value_delimiter = ',', default_value = "meters")]
|
||||
topics: Vec<MonitorTopic>,
|
||||
|
||||
/// Emit one JSON event per line on stdout instead of drawing
|
||||
/// the TUI.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -189,28 +170,13 @@ fn init_tracing() {
|
|||
|
||||
fn run() -> Result<(), CliError> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// TUI takes over the terminal; don't let `tracing` scribble on top
|
||||
// of it. The JSON-mode monitor also benefits from a quieter stderr.
|
||||
let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. });
|
||||
if !tui_mode {
|
||||
init_tracing();
|
||||
}
|
||||
init_tracing();
|
||||
|
||||
match cli.cmd {
|
||||
Cmd::Daemon => {
|
||||
headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Monitor { json: false, .. } => {
|
||||
// Connect on the main thread so the initial `status` /
|
||||
// `route.list` round-trips happen before we enter raw mode.
|
||||
let client = match cli.socket.as_deref() {
|
||||
Some(p) => Client::connect_at(p)?,
|
||||
None => Client::connect()?,
|
||||
};
|
||||
tui::run(client).map_err(CliError::Tui)
|
||||
}
|
||||
cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
|
||||
}
|
||||
}
|
||||
|
|
@ -281,23 +247,18 @@ fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
|
|||
let reloaded = client.profile_reload()?;
|
||||
println!("reloaded: {reloaded:?}");
|
||||
}
|
||||
Cmd::Monitor { topics, json } => {
|
||||
if json {
|
||||
let pw_topics: Vec<Topic> =
|
||||
topics.iter().copied().map(Topic::from).collect();
|
||||
client.subscribe(&pw_topics)?;
|
||||
loop {
|
||||
let ev = client.next_event()?;
|
||||
println!(
|
||||
"{} {}/{} {}",
|
||||
chrono_like_now(),
|
||||
ev.topic,
|
||||
ev.event,
|
||||
serde_json::to_string(&ev.data)?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
unreachable!("TUI monitor is dispatched before `with_client`")
|
||||
Cmd::Monitor { topics } => {
|
||||
let pw_topics: Vec<Topic> = topics.iter().copied().map(Topic::from).collect();
|
||||
client.subscribe(&pw_topics)?;
|
||||
loop {
|
||||
let ev = client.next_event()?;
|
||||
println!(
|
||||
"{} {}/{} {}",
|
||||
chrono_like_now(),
|
||||
ev.topic,
|
||||
ev.event,
|
||||
serde_json::to_string(&ev.data)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -315,9 +276,6 @@ enum CliError {
|
|||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("tui: {0}")]
|
||||
Tui(tui::TuiError),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,810 +0,0 @@
|
|||
//! `headroom monitor` TUI. Subscribes to `meters`, `routing`,
|
||||
//! `profile`, and `daemon`, renders bus DSP gauges + loudness +
|
||||
//! per-stream routing + status header.
|
||||
//!
|
||||
//! Architecture: the main thread owns the terminal and the draw loop.
|
||||
//! A reader thread owns the `Client` and forwards each subscription
|
||||
//! event over a crossbeam channel. On quit the main thread restores
|
||||
//! the terminal and exits; the reader thread is reaped by the OS.
|
||||
//! (A CLI binary doesn't need a graceful reader shutdown — the kernel
|
||||
//! tears the UnixStream down on process exit.)
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossbeam_channel::{select, tick, unbounded, Receiver};
|
||||
use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||
use headroom_client::{Client, ClientError};
|
||||
use headroom_ipc::{
|
||||
DaemonEvent, Event, LayerALevel, MeterTick, ProfileEvent, Route, RoutingEvent, Status,
|
||||
StreamRoute, Topic,
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
/// Errors specific to the TUI subcommand.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TuiError {
|
||||
#[error("client: {0}")]
|
||||
Client(#[from] ClientError),
|
||||
|
||||
#[error("terminal: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Entry point — owns the connected client through initial RPCs, then
|
||||
/// hands it off to the reader thread and enters the draw loop.
|
||||
pub fn run(mut client: Client) -> Result<(), TuiError> {
|
||||
// Subscribe + initial state, all on the main thread before the
|
||||
// terminal goes into raw mode. Any error here bubbles cleanly.
|
||||
let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
|
||||
client.subscribe(&topics)?;
|
||||
let status = client.status()?;
|
||||
let route_list = client.route_list()?;
|
||||
|
||||
// Spawn reader.
|
||||
let (tx, rx) = unbounded::<Msg>();
|
||||
let reader_handle = thread::Builder::new()
|
||||
.name("headroom-monitor-rx".into())
|
||||
.spawn(move || reader_loop(client, tx))
|
||||
.map_err(TuiError::Io)?;
|
||||
|
||||
// Terminal up.
|
||||
let mut terminal = ratatui::init();
|
||||
let outcome = draw_loop(&mut terminal, status, route_list, rx);
|
||||
ratatui::restore();
|
||||
|
||||
// Detach the reader: process exit (or the dropped channel) will
|
||||
// tear the connection down. We don't need its result.
|
||||
drop(reader_handle);
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Msg {
|
||||
Event(Event),
|
||||
Disconnected(String),
|
||||
}
|
||||
|
||||
fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
|
||||
loop {
|
||||
match client.next_event() {
|
||||
Ok(ev) => {
|
||||
if tx.send(Msg::Event(ev)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Msg::Disconnected(e.to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct UiState {
|
||||
daemon_version: String,
|
||||
profile: String,
|
||||
bypass: bool,
|
||||
/// Daemon uptime as of connect, plus our local elapsed.
|
||||
base_uptime_s: u64,
|
||||
connected_at: Instant,
|
||||
default_route: Route,
|
||||
streams: BTreeMap<u32, StreamRoute>,
|
||||
/// Per-stream Layer A state. Presence = tap attached; the inner
|
||||
/// `Option<f32>` is the latest smoothed reduction in dB (None
|
||||
/// until the first `meters/layer_a_level` event arrives).
|
||||
layer_a: BTreeMap<u32, Option<f32>>,
|
||||
meters: Option<MeterTick>,
|
||||
/// Wall-clock instant the last meter tick arrived. Used to show
|
||||
/// staleness if the audio thread stops feeding the AGC.
|
||||
last_meter_at: Option<Instant>,
|
||||
overflow_total: u64,
|
||||
last_error: Option<String>,
|
||||
disconnected: Option<String>,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
|
||||
let mut streams = BTreeMap::new();
|
||||
for s in route_list.current {
|
||||
streams.insert(s.node_id, s);
|
||||
}
|
||||
// Streams reported on `status` superset; merge.
|
||||
for s in status.streams.iter() {
|
||||
streams.entry(s.node_id).or_insert_with(|| s.clone());
|
||||
}
|
||||
Self {
|
||||
daemon_version: status.version,
|
||||
profile: status.profile,
|
||||
bypass: status.bypass,
|
||||
base_uptime_s: status.uptime_s,
|
||||
connected_at: Instant::now(),
|
||||
default_route: route_list.default_route,
|
||||
streams,
|
||||
layer_a: BTreeMap::new(),
|
||||
meters: None,
|
||||
last_meter_at: None,
|
||||
overflow_total: 0,
|
||||
last_error: None,
|
||||
disconnected: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn uptime_s(&self) -> u64 {
|
||||
self.base_uptime_s
|
||||
.saturating_add(self.connected_at.elapsed().as_secs())
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, ev: Event) {
|
||||
match ev.topic {
|
||||
Topic::Meters if ev.event == "tick" => {
|
||||
if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
|
||||
self.meters = Some(m);
|
||||
self.last_meter_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Topic::Meters if ev.event == "layer_a_level" => {
|
||||
if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
|
||||
self.layer_a.insert(l.node_id, Some(l.reduction_db));
|
||||
}
|
||||
}
|
||||
Topic::Routing => {
|
||||
if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
|
||||
match re {
|
||||
RoutingEvent::StreamRouted { node_id, app, to } => {
|
||||
self.streams.insert(
|
||||
node_id,
|
||||
StreamRoute {
|
||||
node_id,
|
||||
app,
|
||||
route: to,
|
||||
},
|
||||
);
|
||||
}
|
||||
RoutingEvent::StreamRemoved { node_id } => {
|
||||
self.streams.remove(&node_id);
|
||||
self.layer_a.remove(&node_id);
|
||||
}
|
||||
RoutingEvent::LayerAAttached { node_id, .. } => {
|
||||
// Mark managed; reduction unknown until the
|
||||
// first `layer_a_level` event lands.
|
||||
self.layer_a.entry(node_id).or_insert(None);
|
||||
}
|
||||
RoutingEvent::LayerADetached { node_id } => {
|
||||
self.layer_a.remove(&node_id);
|
||||
}
|
||||
RoutingEvent::RuleChanged => { /* TUI doesn't display rules */ }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::Profile => {
|
||||
if let Ok(ProfileEvent::Changed { name, .. }) =
|
||||
serde_json::from_value::<ProfileEvent>(profile_payload(&ev))
|
||||
{
|
||||
self.profile = name;
|
||||
}
|
||||
}
|
||||
Topic::Daemon => {
|
||||
if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
|
||||
match de {
|
||||
DaemonEvent::Overflow {
|
||||
lost, total_lost, ..
|
||||
} => {
|
||||
self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
|
||||
}
|
||||
DaemonEvent::Error { code, message } => {
|
||||
self.last_error = Some(format!("{code}: {message}"));
|
||||
}
|
||||
DaemonEvent::Shutdown => {
|
||||
self.disconnected = Some("daemon shutdown".into());
|
||||
}
|
||||
DaemonEvent::Started { version } => {
|
||||
self.daemon_version = version;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire frame carries `{event, topic, data}` — the typed enum lives
|
||||
/// inside `data` but is `#[serde(tag = "event")]`, so we re-inject the
|
||||
/// event name to make serde happy. Same dance for the other topics.
|
||||
fn routing_payload(ev: &Event) -> serde_json::Value {
|
||||
inject_event(&ev.event, &ev.data)
|
||||
}
|
||||
fn profile_payload(ev: &Event) -> serde_json::Value {
|
||||
inject_event(&ev.event, &ev.data)
|
||||
}
|
||||
fn daemon_payload(ev: &Event) -> serde_json::Value {
|
||||
inject_event(&ev.event, &ev.data)
|
||||
}
|
||||
|
||||
fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
|
||||
let mut obj = match data {
|
||||
serde_json::Value::Object(m) => m.clone(),
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
obj.insert("event".into(), serde_json::Value::String(event.to_string()));
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draw loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_loop<B: ratatui::backend::Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
status: Status,
|
||||
route_list: headroom_ipc::RouteList,
|
||||
rx: Receiver<Msg>,
|
||||
) -> Result<(), TuiError> {
|
||||
let mut state = UiState::new(status, route_list);
|
||||
// 10 Hz redraw floor so uptime + staleness counters tick even when
|
||||
// there are no events flowing.
|
||||
let ticker = tick(Duration::from_millis(100));
|
||||
let input_rx = spawn_input_thread();
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &state))?;
|
||||
|
||||
select! {
|
||||
recv(rx) -> msg => match msg {
|
||||
Ok(Msg::Event(ev)) => state.apply_event(ev),
|
||||
Ok(Msg::Disconnected(reason)) => {
|
||||
state.disconnected = Some(reason);
|
||||
// Final paint, then linger briefly so the user sees
|
||||
// the disconnected banner.
|
||||
terminal.draw(|f| draw(f, &state))?;
|
||||
thread::sleep(Duration::from_millis(800));
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
recv(input_rx) -> msg => match msg {
|
||||
Ok(InputMsg::Quit) => return Ok(()),
|
||||
Ok(InputMsg::Other) => {}
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
recv(ticker) -> _ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum InputMsg {
|
||||
Quit,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn spawn_input_thread() -> Receiver<InputMsg> {
|
||||
let (tx, rx) = unbounded::<InputMsg>();
|
||||
thread::Builder::new()
|
||||
.name("headroom-monitor-input".into())
|
||||
.spawn(move || loop {
|
||||
// Block on the next terminal event; crossterm's read() is
|
||||
// a blocking syscall against stdin.
|
||||
let Ok(ev) = event::read() else { return };
|
||||
let msg = match ev {
|
||||
CtEvent::Key(k) if is_quit(&k) => InputMsg::Quit,
|
||||
CtEvent::Key(_) | CtEvent::Resize(_, _) => InputMsg::Other,
|
||||
_ => continue,
|
||||
};
|
||||
if tx.send(msg).is_err() {
|
||||
return;
|
||||
}
|
||||
})
|
||||
.expect("spawn input thread");
|
||||
rx
|
||||
}
|
||||
|
||||
fn is_quit(k: &KeyEvent) -> bool {
|
||||
matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
|
||||
|| (k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw(f: &mut Frame, state: &UiState) {
|
||||
let area = f.area();
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled(
|
||||
" headroom monitor ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.title_top(Line::from(header_status(state)).right_aligned())
|
||||
.title_bottom(Line::from(footer_text(state)).right_aligned());
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(6), // bus gauges
|
||||
Constraint::Length(5), // loudness
|
||||
Constraint::Min(4), // streams table
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
draw_bus(f, chunks[0], state);
|
||||
draw_loudness(f, chunks[1], state);
|
||||
draw_streams(f, chunks[2], state);
|
||||
}
|
||||
|
||||
fn header_status(state: &UiState) -> Vec<Span<'static>> {
|
||||
let bypass_span = if state.bypass {
|
||||
Span::styled(
|
||||
" BYPASS ",
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow),
|
||||
)
|
||||
} else {
|
||||
Span::styled(" processed ", Style::default().fg(Color::Green))
|
||||
};
|
||||
vec![
|
||||
Span::raw(" profile: "),
|
||||
Span::styled(state.profile.clone(), Style::default().bold()),
|
||||
Span::raw(" "),
|
||||
bypass_span,
|
||||
Span::raw(format!(
|
||||
" v{} uptime {} ",
|
||||
state.daemon_version,
|
||||
fmt_uptime(state.uptime_s())
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
|
||||
let mut parts: Vec<Span> = vec![
|
||||
Span::raw(" q/Esc/Ctrl-C quit "),
|
||||
Span::styled("·", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" subscribed: meters routing profile daemon "),
|
||||
];
|
||||
if state.overflow_total > 0 {
|
||||
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
|
||||
parts.push(Span::styled(
|
||||
format!(" dropped: {} ", state.overflow_total),
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
if let Some(err) = &state.last_error {
|
||||
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
|
||||
parts.push(Span::styled(
|
||||
format!(" daemon error: {err} "),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
}
|
||||
if let Some(reason) = &state.disconnected {
|
||||
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
|
||||
parts.push(Span::styled(
|
||||
format!(" disconnected: {reason} "),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" bus dsp ");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let m = state.meters;
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[0],
|
||||
GaugeRow {
|
||||
label: "AGC target",
|
||||
value: m.map(|t| t.agc_gain_db),
|
||||
min: -12.0,
|
||||
max: 12.0,
|
||||
unit: "dB",
|
||||
color: Color::Cyan,
|
||||
},
|
||||
);
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[1],
|
||||
GaugeRow {
|
||||
label: "Compressor GR",
|
||||
value: m.map(|t| t.compressor_gr_db),
|
||||
min: -24.0,
|
||||
max: 0.0,
|
||||
unit: "dB",
|
||||
color: Color::Magenta,
|
||||
},
|
||||
);
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[2],
|
||||
GaugeRow {
|
||||
label: "Limiter GR",
|
||||
value: m.map(|t| t.limiter_gr_db),
|
||||
min: -24.0,
|
||||
max: 0.0,
|
||||
unit: "dB",
|
||||
color: Color::Red,
|
||||
},
|
||||
);
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[3],
|
||||
GaugeRow {
|
||||
label: "True peak",
|
||||
value: m.map(|t| t.true_peak_dbtp),
|
||||
min: -60.0,
|
||||
max: 3.0,
|
||||
unit: "dBTP",
|
||||
color: Color::Green,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct GaugeRow<'a> {
|
||||
label: &'a str,
|
||||
value: Option<f32>,
|
||||
min: f32,
|
||||
max: f32,
|
||||
unit: &'a str,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
/// One labeled gauge row: `LABEL VALUE [████░░░░] min..max`.
|
||||
fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
|
||||
let GaugeRow {
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
unit,
|
||||
color,
|
||||
} = row;
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(16),
|
||||
Constraint::Length(14),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(14),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
|
||||
|
||||
let value_str = value
|
||||
.map(|v| format!("{v:+7.2} {unit}"))
|
||||
.unwrap_or_else(|| " -- ".to_string());
|
||||
f.render_widget(
|
||||
Paragraph::new(value_str).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
);
|
||||
|
||||
let pct = match value {
|
||||
Some(v) => {
|
||||
let clamped = v.clamp(min, max);
|
||||
((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(color))
|
||||
.ratio(pct)
|
||||
.label("");
|
||||
f.render_widget(gauge, cols[2]);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
|
||||
cols[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" loudness (BS.1770) ");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let staleness = state
|
||||
.last_meter_at
|
||||
.map(|t| t.elapsed())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
let stale = staleness > Duration::from_millis(500);
|
||||
|
||||
let (mom, st, intg) = match state.meters {
|
||||
Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
lufs_line("Momentary (400 ms)", mom, stale),
|
||||
lufs_line("Short-term (3 s)", st, stale),
|
||||
lufs_line("Integrated (gated)", intg, stale),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
|
||||
let val = match v {
|
||||
Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
|
||||
format!("{x:+7.2} LUFS")
|
||||
}
|
||||
Some(_) => " -- LUFS".to_string(),
|
||||
None => " -- LUFS".to_string(),
|
||||
};
|
||||
let style = if stale {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::raw(format!(" {label:<24}")),
|
||||
Span::styled(val, style),
|
||||
])
|
||||
}
|
||||
|
||||
fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
|
||||
let title = format!(
|
||||
" streams ({}) — default: {} ",
|
||||
state.streams.len(),
|
||||
state.default_route
|
||||
);
|
||||
let block = Block::default().borders(Borders::ALL).title(title);
|
||||
|
||||
let header = Row::new(vec!["node", "app", "route", "layer A"])
|
||||
.style(Style::default().add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.streams
|
||||
.values()
|
||||
.map(|s| {
|
||||
let route_cell = match s.route {
|
||||
Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
|
||||
Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
|
||||
};
|
||||
let la_cell = match state.layer_a.get(&s.node_id) {
|
||||
Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
|
||||
.style(Style::default().fg(Color::Magenta)),
|
||||
Some(None) => Cell::from("attached")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
None => Cell::from("—").style(Style::default().fg(Color::DarkGray)),
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(s.node_id.to_string()),
|
||||
Cell::from(s.app.clone()),
|
||||
route_cell,
|
||||
la_cell,
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(8),
|
||||
Constraint::Min(20),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
];
|
||||
let table = Table::new(rows, widths).header(header).block(block);
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn fmt_uptime(s: u64) -> String {
|
||||
let h = s / 3600;
|
||||
let m = (s % 3600) / 60;
|
||||
let sec = s % 60;
|
||||
if h > 0 {
|
||||
format!("{h}h{m:02}m{sec:02}s")
|
||||
} else if m > 0 {
|
||||
format!("{m}m{sec:02}s")
|
||||
} else {
|
||||
format!("{sec}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use headroom_ipc::{Sinks, Status};
|
||||
|
||||
fn empty_state() -> UiState {
|
||||
let status = Status {
|
||||
version: "test".into(),
|
||||
protocol: 1,
|
||||
uptime_s: 0,
|
||||
profile: "default".into(),
|
||||
bypass: false,
|
||||
sinks: Sinks::default(),
|
||||
streams: vec![],
|
||||
warnings: vec![],
|
||||
};
|
||||
let route_list = headroom_ipc::RouteList {
|
||||
rules: vec![],
|
||||
current: vec![],
|
||||
default_route: Route::Processed,
|
||||
};
|
||||
UiState::new(status, route_list)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_tick_event_updates_state() {
|
||||
let mut state = empty_state();
|
||||
let tick = MeterTick {
|
||||
momentary_lufs: -19.3,
|
||||
shortterm_lufs: -20.1,
|
||||
integrated_lufs: -19.8,
|
||||
true_peak_dbtp: -1.4,
|
||||
gain_reduction_db: -2.1,
|
||||
compressor_gr_db: -0.8,
|
||||
limiter_gr_db: -1.3,
|
||||
agc_gain_db: 0.5,
|
||||
};
|
||||
let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
|
||||
state.apply_event(ev);
|
||||
let got = state.meters.expect("meters set");
|
||||
assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
|
||||
assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
|
||||
assert!(state.last_meter_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_removed_prunes_state() {
|
||||
let mut state = empty_state();
|
||||
// Insert via stream_routed first.
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"stream_routed",
|
||||
&serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"layer_a_attached",
|
||||
&serde_json::json!({ "node_id": 7, "app": "x" }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(state.streams.contains_key(&7));
|
||||
assert!(state.layer_a.contains_key(&7));
|
||||
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"stream_removed",
|
||||
&serde_json::json!({ "node_id": 7 }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(!state.streams.contains_key(&7));
|
||||
assert!(!state.layer_a.contains_key(&7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_a_level_updates_reduction() {
|
||||
let mut state = empty_state();
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"layer_a_attached",
|
||||
&serde_json::json!({ "node_id": 11, "app": "loud-app" }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(state.layer_a.get(&11), Some(&None));
|
||||
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Meters,
|
||||
"layer_a_level",
|
||||
&serde_json::json!({
|
||||
"node_id": 11,
|
||||
"app": "loud-app",
|
||||
"volume_lin": 0.256_f32,
|
||||
"reduction_db": -11.8_f32,
|
||||
}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let r = state.layer_a.get(&11).copied().flatten().unwrap();
|
||||
assert!((r - -11.8).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_event_inserts_stream() {
|
||||
let mut state = empty_state();
|
||||
let ev = Event::new(
|
||||
Topic::Routing,
|
||||
"stream_routed",
|
||||
&serde_json::json!({
|
||||
"node_id": 42,
|
||||
"app": "firefox",
|
||||
"to": "bypass",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.apply_event(ev);
|
||||
let s = state.streams.get(&42).expect("stream tracked");
|
||||
assert_eq!(s.app, "firefox");
|
||||
assert_eq!(s.route, Route::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_changed_updates_active() {
|
||||
let mut state = empty_state();
|
||||
let ev = Event::new(
|
||||
Topic::Profile,
|
||||
"changed",
|
||||
&serde_json::json!({
|
||||
"name": "night",
|
||||
"previous": "default",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.apply_event(ev);
|
||||
assert_eq!(state.profile, "night");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_overflow_accumulates() {
|
||||
let mut state = empty_state();
|
||||
let ev = Event::new(
|
||||
Topic::Daemon,
|
||||
"overflow",
|
||||
&serde_json::json!({
|
||||
"lost_topic": "meters",
|
||||
"lost": 3u32,
|
||||
"total_lost": 5u64,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.apply_event(ev);
|
||||
assert_eq!(state.overflow_total, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_uptime_buckets() {
|
||||
assert_eq!(fmt_uptime(5), "5s");
|
||||
assert_eq!(fmt_uptime(75), "1m15s");
|
||||
assert_eq!(fmt_uptime(3725), "1h02m05s");
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ authors.workspace = true
|
|||
[dependencies]
|
||||
headroom-dsp = { workspace = true }
|
||||
headroom-ipc = { workspace = true }
|
||||
headroom-client = { workspace = true } # test-only: integration tests
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -43,20 +44,11 @@ notify-debouncer-mini = { workspace = true }
|
|||
# Slow AGC loop (Phase 4 closing piece).
|
||||
ebur128 = { workspace = true }
|
||||
|
||||
# Audio-thread allocation guard. In debug builds the `AllocDisabler`
|
||||
# global allocator panics if anything inside an `assert_no_alloc!`
|
||||
# block tries to allocate; in release builds the macro is a no-op
|
||||
# (zero overhead). Wraps each audio-thread `process` callback.
|
||||
assert_no_alloc = { workspace = true }
|
||||
|
||||
# Optional journald logging — not wired yet.
|
||||
# tracing-journald = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
# Only used in `ipc::server::tests` to round-trip a real client
|
||||
# against the spawned IPC server.
|
||||
headroom-client = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
|
|
|||
|
|
@ -16,9 +16,7 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use ebur128::{EbuR128, Mode};
|
||||
use headroom_ipc::{Event, MeterTick, Topic};
|
||||
|
||||
use crate::meters::SharedBusMetrics;
|
||||
use crate::pw::filter::FilterControl;
|
||||
use crate::state::SharedState;
|
||||
|
||||
|
|
@ -32,10 +30,8 @@ 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. Published as-is in `MeterTick.*_lufs` fields, so
|
||||
/// clients can use this constant to recognise "no measurement" without
|
||||
/// hard-coding the number.
|
||||
pub const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
|
||||
/// digital silence.
|
||||
const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
|
||||
|
||||
/// Slow AGC controller.
|
||||
pub struct AgcController {
|
||||
|
|
@ -54,23 +50,8 @@ pub struct AgcController {
|
|||
/// enable flag exactly when it changes.
|
||||
last_enabled: bool,
|
||||
/// Last short-term loudness observed; surfaced for status /
|
||||
/// `meters` topic.
|
||||
/// meters in a future sub-stage.
|
||||
last_short_term_lufs: f32,
|
||||
/// Bus-level DSP snapshot written by the filter's playback
|
||||
/// callback. Used to fill the `MeterTick` payload published on
|
||||
/// `Topic::Meters`.
|
||||
bus_metrics: SharedBusMetrics,
|
||||
/// Tick counter for `publish_hz` throttling. Wraps freely.
|
||||
meter_tick_counter: u32,
|
||||
/// Playback callback timing stats. Sampled and logged once per
|
||||
/// second to surface BUSY-spike behaviour and general callback
|
||||
/// health.
|
||||
timing: crate::meters::SharedPlaybackTiming,
|
||||
/// Last `spike_count` value we observed, used to detect *new*
|
||||
/// spikes since the previous log.
|
||||
last_logged_spike_count: u64,
|
||||
/// Tick counter for the once-per-second timing log throttle.
|
||||
timing_log_counter: u32,
|
||||
}
|
||||
|
||||
impl AgcController {
|
||||
|
|
@ -85,19 +66,9 @@ impl AgcController {
|
|||
measurement_consumer: rtrb::Consumer<f32>,
|
||||
filter_control: FilterControl,
|
||||
daemon: SharedState,
|
||||
bus_metrics: SharedBusMetrics,
|
||||
timing: crate::meters::SharedPlaybackTiming,
|
||||
) -> Result<Self, AgcInitError> {
|
||||
// `Mode::I` (integrated, gated) costs a histogram walk per
|
||||
// `loudness_global()` call — bounded, fine at 20 Hz meter
|
||||
// cadence. Added so the `meters` topic can surface integrated
|
||||
// LUFS without a second ebur128 instance.
|
||||
let ebu = EbuR128::new(
|
||||
channels,
|
||||
sample_rate,
|
||||
Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK,
|
||||
)
|
||||
.map_err(AgcInitError::from)?;
|
||||
let ebu = EbuR128::new(channels, sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK)
|
||||
.map_err(AgcInitError::from)?;
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
|
|
@ -108,11 +79,6 @@ impl AgcController {
|
|||
smoothed_target_db: 0.0,
|
||||
last_enabled: true,
|
||||
last_short_term_lufs: LOUDNESS_FLOOR_LUFS,
|
||||
bus_metrics,
|
||||
meter_tick_counter: 0,
|
||||
timing,
|
||||
last_logged_spike_count: 0,
|
||||
timing_log_counter: 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -133,107 +99,26 @@ impl AgcController {
|
|||
|
||||
/// One control-loop iteration. Should be invoked at [`AGC_TICK`]
|
||||
/// cadence by a main-loop timer source.
|
||||
///
|
||||
/// Three things happen here:
|
||||
///
|
||||
/// 1. AGC enable/disable transition is observed and pushed to
|
||||
/// the audio thread.
|
||||
/// 2. The measurement ring is drained into `ebur128` and the
|
||||
/// short-term loudness is cached. This runs **regardless of
|
||||
/// AGC enabled** so the `meters` topic can keep surfacing LUFS
|
||||
/// when the user has only enabled the compressor / limiter.
|
||||
/// 3. If AGC is enabled, a smoothed target gain is computed and
|
||||
/// pushed to the audio thread.
|
||||
/// 4. Bus-level meters are published on `Topic::Meters` honouring
|
||||
/// `profile.meters.publish_hz`.
|
||||
pub fn tick(&mut self) {
|
||||
// Snapshot what we need out from under the daemon lock. Hold
|
||||
// the lock only long enough to clone the small config.
|
||||
let (cfg, publish_hz) = {
|
||||
// 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();
|
||||
let p = s.profiles.effective();
|
||||
(p.agc.clone(), p.meters.publish_hz)
|
||||
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;
|
||||
}
|
||||
|
||||
// Drain the measurement ring + feed ebur128 unconditionally.
|
||||
self.consume_measurements();
|
||||
let short_term = finite_or_floor(
|
||||
self.ebu.loudness_shortterm().map(|v| v as f32).ok(),
|
||||
);
|
||||
self.last_short_term_lufs = short_term;
|
||||
|
||||
if cfg.enabled
|
||||
&& short_term > cfg.silence_threshold_lufs
|
||||
&& short_term.is_finite()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
self.publish_meters(publish_hz);
|
||||
self.log_playback_timing();
|
||||
}
|
||||
|
||||
/// Throttled log of the playback callback's rolling timing stats.
|
||||
/// Fires roughly once per second at the AGC's 20 Hz tick rate.
|
||||
/// Cheap (lock-free atomic loads); useful for surfacing BUSY
|
||||
/// spikes without per-call log noise.
|
||||
fn log_playback_timing(&mut self) {
|
||||
// 20 Hz tick → log every 20 ticks for ~1 Hz cadence.
|
||||
self.timing_log_counter = self.timing_log_counter.wrapping_add(1);
|
||||
if self.timing_log_counter % 20 != 0 {
|
||||
if !cfg.enabled {
|
||||
return;
|
||||
}
|
||||
let snap = self.timing.snapshot();
|
||||
if snap.call_count == 0 {
|
||||
return;
|
||||
}
|
||||
let avg_us = snap.sum_us / snap.call_count.max(1);
|
||||
let new_spikes = snap.spike_count.saturating_sub(self.last_logged_spike_count);
|
||||
self.last_logged_spike_count = snap.spike_count;
|
||||
if new_spikes > 0 {
|
||||
tracing::warn!(
|
||||
avg_us,
|
||||
max_us = snap.max_us,
|
||||
new_spikes,
|
||||
total_spikes = snap.spike_count,
|
||||
last_spike_us = snap.last_spike_us,
|
||||
last_spike_at_call = snap.last_spike_at_call,
|
||||
call_count = snap.call_count,
|
||||
"playback callback BUSY spike(s) since last log"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
avg_us,
|
||||
max_us = snap.max_us,
|
||||
call_count = snap.call_count,
|
||||
"playback callback timing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain up to [`TICK_BUF_SAMPLES`] from the measurement ring and
|
||||
/// feed them through `ebur128`.
|
||||
fn consume_measurements(&mut self) {
|
||||
// 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() {
|
||||
|
|
@ -246,7 +131,7 @@ impl AgcController {
|
|||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return;
|
||||
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;
|
||||
|
|
@ -255,61 +140,39 @@ impl AgcController {
|
|||
}
|
||||
if let Err(e) = self.ebu.add_frames_f32(&buf[..usable]) {
|
||||
tracing::warn!(error = %e, "ebur128 add_frames_f32 failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a `MeterTick` event on `Topic::Meters` if this tick
|
||||
/// falls on the `publish_hz` cadence.
|
||||
fn publish_meters(&mut self, publish_hz: f32) {
|
||||
if !self.should_publish(publish_hz) {
|
||||
return;
|
||||
}
|
||||
let bus = *self.bus_metrics.lock();
|
||||
// `ebur128` returns `-inf` (not `Err`) for "no useful
|
||||
// measurement yet" — typically early-boot or while the input
|
||||
// is pure silence. `-inf` can't survive JSON serialisation
|
||||
// (serde_json renders non-finite f32 as null), so floor here.
|
||||
let momentary = finite_or_floor(
|
||||
self.ebu.loudness_momentary().map(|v| v as f32).ok(),
|
||||
);
|
||||
let integrated = finite_or_floor(
|
||||
self.ebu.loudness_global().map(|v| v as f32).ok(),
|
||||
);
|
||||
|
||||
let tick = MeterTick {
|
||||
momentary_lufs: momentary,
|
||||
shortterm_lufs: self.last_short_term_lufs,
|
||||
integrated_lufs: integrated,
|
||||
true_peak_dbtp: bus.true_peak_dbtp,
|
||||
// Total path GR is additive in log domain. Both values
|
||||
// are ≤ 0 dB when reducing.
|
||||
gain_reduction_db: bus.compressor_gr_db + bus.limiter_total_gr_db,
|
||||
compressor_gr_db: bus.compressor_gr_db,
|
||||
limiter_gr_db: bus.limiter_total_gr_db,
|
||||
agc_gain_db: self.smoothed_target_db,
|
||||
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);
|
||||
|
||||
if let Ok(event) = Event::new(Topic::Meters, "tick", &tick) {
|
||||
self.daemon.lock().broadcaster.publish(Topic::Meters, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick-rate gate for the `meters` publish loop. Caps at
|
||||
/// [`AGC_TICK`]'s native rate (20 Hz) — `publish_hz` above that is
|
||||
/// silently clamped.
|
||||
fn should_publish(&mut self, publish_hz: f32) -> bool {
|
||||
if publish_hz <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let agc_hz = 1000.0 / AGC_TICK.as_millis() as f32;
|
||||
if publish_hz >= agc_hz {
|
||||
self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1);
|
||||
return true;
|
||||
}
|
||||
let skip = (agc_hz / publish_hz).round().max(1.0) as u32;
|
||||
let now = self.meter_tick_counter;
|
||||
self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1);
|
||||
now % skip == 0
|
||||
self.filter_control
|
||||
.set_agc_target_db(self.smoothed_target_db);
|
||||
}
|
||||
|
||||
/// Reset the smoothed target and the underlying `ebur128` state.
|
||||
|
|
@ -318,50 +181,14 @@ impl AgcController {
|
|||
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. Keep
|
||||
// the same mode set used in `new()` so meter publishing stays
|
||||
// consistent.
|
||||
if let Ok(fresh) = EbuR128::new(
|
||||
self.channels,
|
||||
self.sample_rate,
|
||||
Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK,
|
||||
) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// Rebind the controller to a freshly-built filter (Phase C of
|
||||
/// the filter rate-matching work). The old `measurement_consumer`
|
||||
/// and `filter_control` point at rtrbs whose producers were just
|
||||
/// dropped — every send on them would now fail — so we swap in
|
||||
/// the new bundle's handles and rebuild `ebur128` at the new
|
||||
/// sample rate. Resets the smoother + the LUFS sentinel so the
|
||||
/// controller starts clean on the new audio path; the brief
|
||||
/// post-rebuild silence (~50–100 ms of dropped audio) is
|
||||
/// inaudible compared to the rate-change event itself.
|
||||
pub fn rebind(
|
||||
&mut self,
|
||||
measurement_consumer: rtrb::Consumer<f32>,
|
||||
filter_control: FilterControl,
|
||||
sample_rate: u32,
|
||||
) {
|
||||
self.measurement_consumer = measurement_consumer;
|
||||
self.filter_control = filter_control;
|
||||
self.sample_rate = sample_rate;
|
||||
self.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Coerce a possibly-non-finite LUFS measurement into a finite value
|
||||
/// suitable for serialisation. `-inf` (the `ebur128` "no usable
|
||||
/// reading" sentinel) and `NaN` both collapse to
|
||||
/// [`LOUDNESS_FLOOR_LUFS`].
|
||||
fn finite_or_floor(v: Option<f32>) -> f32 {
|
||||
match v {
|
||||
Some(x) if x.is_finite() => x,
|
||||
_ => LOUDNESS_FLOOR_LUFS,
|
||||
}
|
||||
}
|
||||
|
||||
/// `tau_ms`-time-constant leaky-integrator alpha for a tick of
|
||||
|
|
@ -392,7 +219,6 @@ impl From<AgcInitError> for crate::error::DaemonError {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::meters;
|
||||
use crate::profile_store::ProfileStore;
|
||||
use crate::pw::filter::{AudioCmd, FilterControl};
|
||||
use crate::state::{self, DaemonState};
|
||||
|
|
@ -406,24 +232,12 @@ mod tests {
|
|||
rtrb::Producer<f32>,
|
||||
rtrb::Consumer<AudioCmd>,
|
||||
SharedState,
|
||||
SharedBusMetrics,
|
||||
) {
|
||||
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 bus = meters::shared();
|
||||
let timing = meters::shared_timing();
|
||||
let agc = AgcController::new(
|
||||
SR,
|
||||
CH,
|
||||
m_cons,
|
||||
control,
|
||||
state.clone(),
|
||||
bus.clone(),
|
||||
timing,
|
||||
)
|
||||
.unwrap();
|
||||
(agc, m_prod, cmd_cons, state, bus)
|
||||
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) {
|
||||
|
|
@ -444,7 +258,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tick_with_no_samples_does_nothing() {
|
||||
let (mut agc, _prod, mut cmd_cons, _state, _bus) = fixture();
|
||||
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);
|
||||
|
|
@ -452,7 +266,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tick_under_silence_threshold_holds_target() {
|
||||
let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
|
||||
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
|
||||
|
|
@ -465,7 +279,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tick_with_audible_signal_pushes_target() {
|
||||
let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
|
||||
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 {
|
||||
|
|
@ -485,7 +299,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn agc_disable_in_profile_flips_audio_thread() {
|
||||
let (mut agc, _prod, mut cmd_cons, state, _bus) = fixture();
|
||||
let (mut agc, _prod, mut cmd_cons, state) = fixture();
|
||||
// First tick with the default-enabled profile.
|
||||
agc.tick();
|
||||
// Drain any commands.
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ mod tests {
|
|||
// 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 += 6.0; // synthetic kick
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,11 +66,6 @@ fn status(id: u64, state: &SharedState) -> Response {
|
|||
node_id: s.processed_sink_id,
|
||||
name: Some(crate::pw::sink::NODE_NAME.to_owned()),
|
||||
ready: s.processed_sink_id.is_some(),
|
||||
// The processed sink advertises whatever rate the
|
||||
// filter is currently running at (rate-matched to
|
||||
// the real sink). `None` only during very early
|
||||
// boot before `Filter::create` lands.
|
||||
sample_rate: s.filter_sample_rate,
|
||||
},
|
||||
real: s.real_sink.clone(),
|
||||
},
|
||||
|
|
@ -198,7 +193,6 @@ fn profile_use(id: u64, name: &str, state: &SharedState) -> Response {
|
|||
publish_profile_changed(&mut s, name);
|
||||
let control = s.filter_control.clone();
|
||||
let snap = build_dsp_configs(&s);
|
||||
post_reevaluate(&s);
|
||||
drop(s);
|
||||
push_dsp_update(control.as_ref(), snap);
|
||||
ok(id, &json!({ "name": name }))
|
||||
|
|
@ -240,7 +234,6 @@ pub(crate) fn execute_reload(
|
|||
publish_profile_reloaded(&mut s, &report.loaded);
|
||||
let control = s.filter_control.clone();
|
||||
let snap = build_dsp_configs(&s);
|
||||
post_reevaluate(&s);
|
||||
drop(s);
|
||||
push_dsp_update(control.as_ref(), snap);
|
||||
Ok(report)
|
||||
|
|
@ -252,7 +245,6 @@ fn route_set(id: u64, app: &str, to: Route, state: &SharedState) -> Response {
|
|||
Ok(()) => {
|
||||
tracing::info!(app, ?to, "route.set applied");
|
||||
publish_rule_changed(&mut s);
|
||||
post_reevaluate(&s);
|
||||
drop(s);
|
||||
ok(id, &Value::Null)
|
||||
}
|
||||
|
|
@ -266,7 +258,6 @@ fn route_unset(id: u64, app: &str, state: &SharedState) -> Response {
|
|||
Ok(()) => {
|
||||
tracing::info!(app, "route.unset applied");
|
||||
publish_rule_changed(&mut s);
|
||||
post_reevaluate(&s);
|
||||
drop(s);
|
||||
ok(id, &Value::Null)
|
||||
}
|
||||
|
|
@ -280,25 +271,6 @@ fn publish_rule_changed(state: &mut crate::state::DaemonState) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ask the PipeWire main loop to re-run `routing::evaluate` against
|
||||
/// every known stream. Called after any IPC mutation that changes
|
||||
/// the inputs to that decision: active profile, profile contents
|
||||
/// reloaded from disk, or a `route.set` / `route.unset` overlay
|
||||
/// edit. Without this, the new policy only applies to *future*
|
||||
/// streams; everything already routed keeps its old links until the
|
||||
/// app reconnects. A stale or duplicate post is harmless — the
|
||||
/// handler reads current state at apply time and is idempotent
|
||||
/// when nothing changed.
|
||||
fn post_reevaluate(state: &crate::state::DaemonState) {
|
||||
let Some(tx) = state.pw_command_tx.as_ref() else {
|
||||
tracing::debug!("no PipeWire command channel; reevaluation skipped (test mode)");
|
||||
return;
|
||||
};
|
||||
if tx.send(PwCommand::ReevaluateAll).is_err() {
|
||||
tracing::warn!("PipeWire command channel closed; reevaluation lost");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -384,22 +356,7 @@ fn bypass_set(id: u64, enabled: bool, state: &SharedState) -> Response {
|
|||
match s.profiles.set_bypass(enabled) {
|
||||
Ok(()) => {
|
||||
tracing::info!(enabled, "bypass.set applied");
|
||||
let tx = s.pw_command_tx.clone();
|
||||
drop(s);
|
||||
// Make bypass an actual graph operation, not just a
|
||||
// metadata flag. The registry thread re-runs
|
||||
// `routing::evaluate` against every known stream (which
|
||||
// now returns Route::Bypass under bypass_global=true),
|
||||
// tears down the explicit links to the processed sink,
|
||||
// and rebuilds them to the real sink. The
|
||||
// `reassert_default_processed` path is also gated on
|
||||
// bypass, so WP's choice of system default sticks for
|
||||
// any apps that route to "default."
|
||||
if let Some(tx) = tx {
|
||||
if tx.send(PwCommand::ReevaluateAll).is_err() {
|
||||
tracing::warn!("PipeWire command channel closed; bypass toggle had no graph effect");
|
||||
}
|
||||
}
|
||||
ok(id, &Value::Null)
|
||||
}
|
||||
Err(e) => store_err_to_response(id, e),
|
||||
|
|
@ -548,7 +505,7 @@ mod tests {
|
|||
assert!(
|
||||
body.get("warnings")
|
||||
.and_then(|w| w.as_array())
|
||||
.is_none_or(|a| a.is_empty()),
|
||||
.map_or(true, |a| a.is_empty()),
|
||||
"expected empty/absent warnings on healthy startup"
|
||||
);
|
||||
}
|
||||
|
|
@ -1092,10 +1049,7 @@ mod tests {
|
|||
node_id,
|
||||
to,
|
||||
app_label,
|
||||
} = cmd
|
||||
else {
|
||||
panic!("expected RouteStream, got {cmd:?}");
|
||||
};
|
||||
} = cmd;
|
||||
assert_eq!(node_id, 42);
|
||||
assert_eq!(to, Route::Bypass);
|
||||
assert_eq!(app_label, "firefox");
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ pub mod agc;
|
|||
pub mod app_level;
|
||||
pub mod error;
|
||||
pub mod ipc;
|
||||
pub mod meters;
|
||||
pub mod profile;
|
||||
pub mod profile_store;
|
||||
pub mod profile_watcher;
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
//! Bus-level meter snapshot shared between the audio thread and the
|
||||
//! AGC controller.
|
||||
//!
|
||||
//! Phase 4g.
|
||||
//!
|
||||
//! The audio thread writes [`BusMetrics`] after each
|
||||
//! `playback_process` call using `try_lock` — it must never block on
|
||||
//! the lock. The AGC controller reads on its 50 ms tick, combines
|
||||
//! with `ebur128` readings (momentary / short-term / integrated
|
||||
//! LUFS) and the current AGC gain target, and publishes a
|
||||
//! [`headroom_ipc::MeterTick`] on `Topic::Meters` for any IPC client
|
||||
//! that's subscribed.
|
||||
//!
|
||||
//! Per-app meter events (Phase 6e) are a separate stream emitted
|
||||
//! directly from the registry watcher. The two coexist on the same
|
||||
//! topic; clients see both kinds and key off the event payload shape
|
||||
//! to tell them apart.
|
||||
//!
|
||||
//! Wait-free on the audio side: a missed write (lock contended for
|
||||
//! the few nanoseconds the reader holds it) is harmless — the next
|
||||
//! quantum overwrites the slot. Dropped meter samples don't degrade
|
||||
//! the AGC; the controller reads the freshest available snapshot.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
/// Snapshot of bus-level DSP metrics, written by the audio thread
|
||||
/// after the AGC → Compressor → Limiter chain runs.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct BusMetrics {
|
||||
/// Compressor gain reduction in dB (negative when reducing).
|
||||
pub compressor_gr_db: f32,
|
||||
/// Limiter total gain reduction in dB (the min of soft and hard
|
||||
/// gain, in dB).
|
||||
pub limiter_total_gr_db: f32,
|
||||
/// Limiter soft-tier gain reduction in dB.
|
||||
pub limiter_soft_gr_db: f32,
|
||||
/// Limiter hard-tier gain reduction in dB. Non-zero only when
|
||||
/// the soft tier wasn't enough — that's the alarm condition.
|
||||
pub limiter_hard_gr_db: f32,
|
||||
/// True peak in dBTP observed by the limiter's per-quantum peak
|
||||
/// detector. Bounded above by the hard ceiling on the *output*;
|
||||
/// this field is the peak the limiter *saw on its input*, which
|
||||
/// is informative for tuning soft-tier headroom.
|
||||
pub true_peak_dbtp: f32,
|
||||
}
|
||||
|
||||
/// Cheap-to-clone shared handle. Audio thread + AGC controller each
|
||||
/// hold a clone.
|
||||
pub type SharedBusMetrics = Arc<Mutex<BusMetrics>>;
|
||||
|
||||
/// Construct an empty shared metrics handle.
|
||||
#[must_use]
|
||||
pub fn shared() -> SharedBusMetrics {
|
||||
Arc::new(Mutex::new(BusMetrics::default()))
|
||||
}
|
||||
|
||||
/// Rolling timing stats for the bus filter's `playback_process`
|
||||
/// callback. Updated from the audio thread via lock-free atomics,
|
||||
/// read (and reset) by the AGC controller's slow tick. Used to
|
||||
/// detect the ~10 s-cadence BUSY spikes mentioned in PLAN §11
|
||||
/// follow-ups, and (longer-term) as a general health signal — if
|
||||
/// `playback_us_max` creeps up over the run, something downstream
|
||||
/// is unhappy.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PlaybackTiming {
|
||||
/// Number of playback_process invocations.
|
||||
pub call_count: AtomicU64,
|
||||
/// Cumulative duration in microseconds.
|
||||
pub sum_us: AtomicU64,
|
||||
/// Max duration observed in microseconds across all calls.
|
||||
pub max_us: AtomicU64,
|
||||
/// Number of calls whose duration exceeded the spike threshold.
|
||||
pub spike_count: AtomicU64,
|
||||
/// Duration of the most recent spike in microseconds.
|
||||
pub last_spike_us: AtomicU64,
|
||||
/// `call_count` snapshot when the most recent spike fired (so a
|
||||
/// reader can detect "no new spike since last read" by comparing
|
||||
/// against its previous snapshot).
|
||||
pub last_spike_at_call: AtomicU64,
|
||||
}
|
||||
|
||||
impl PlaybackTiming {
|
||||
/// Threshold above which a call is counted as a "spike".
|
||||
///
|
||||
/// The steady-state cost of the playback callback scales with
|
||||
/// the PipeWire quantum: on a 1024-frame quantum it runs in
|
||||
/// ~240 μs (PLAN §4.7); on the 8192-frame quantum the Mbox
|
||||
/// negotiates here it sits around ~2.2 ms in release builds.
|
||||
/// 5 ms is comfortably above both regimes and only fires on
|
||||
/// real outliers (the ~10 s-cadence "BUSY" spike PLAN §11
|
||||
/// chases would have to be ~2× steady-state at any quantum to
|
||||
/// trip this).
|
||||
pub const SPIKE_THRESHOLD_US: u64 = 5_000;
|
||||
|
||||
/// Record one observation. Wait-free.
|
||||
#[inline]
|
||||
pub fn record(&self, dur_us: u64) {
|
||||
self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.sum_us.fetch_add(dur_us, Ordering::Relaxed);
|
||||
let mut cur_max = self.max_us.load(Ordering::Relaxed);
|
||||
while dur_us > cur_max {
|
||||
match self.max_us.compare_exchange_weak(
|
||||
cur_max,
|
||||
dur_us,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(v) => cur_max = v,
|
||||
}
|
||||
}
|
||||
if dur_us > Self::SPIKE_THRESHOLD_US {
|
||||
let count = self.call_count.load(Ordering::Relaxed);
|
||||
self.spike_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_spike_us.store(dur_us, Ordering::Relaxed);
|
||||
self.last_spike_at_call.store(count, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a snapshot of current counters. Doesn't reset.
|
||||
pub fn snapshot(&self) -> PlaybackTimingSnapshot {
|
||||
PlaybackTimingSnapshot {
|
||||
call_count: self.call_count.load(Ordering::Relaxed),
|
||||
sum_us: self.sum_us.load(Ordering::Relaxed),
|
||||
max_us: self.max_us.load(Ordering::Relaxed),
|
||||
spike_count: self.spike_count.load(Ordering::Relaxed),
|
||||
last_spike_us: self.last_spike_us.load(Ordering::Relaxed),
|
||||
last_spike_at_call: self.last_spike_at_call.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-old-data snapshot of [`PlaybackTiming`] for the controller's
|
||||
/// per-tick logging.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct PlaybackTimingSnapshot {
|
||||
/// Cumulative call count.
|
||||
pub call_count: u64,
|
||||
/// Cumulative duration in microseconds.
|
||||
pub sum_us: u64,
|
||||
/// Max single-call duration in microseconds observed so far.
|
||||
pub max_us: u64,
|
||||
/// Cumulative count of calls above the spike threshold.
|
||||
pub spike_count: u64,
|
||||
/// Duration of the most recent spike in microseconds.
|
||||
pub last_spike_us: u64,
|
||||
/// `call_count` when the most recent spike fired.
|
||||
pub last_spike_at_call: u64,
|
||||
}
|
||||
|
||||
/// Cheap-to-clone shared handle for [`PlaybackTiming`].
|
||||
pub type SharedPlaybackTiming = Arc<PlaybackTiming>;
|
||||
|
||||
/// Construct an empty shared timing handle.
|
||||
#[must_use]
|
||||
pub fn shared_timing() -> SharedPlaybackTiming {
|
||||
Arc::new(PlaybackTiming::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_all_zero() {
|
||||
let m = BusMetrics::default();
|
||||
assert_eq!(m.compressor_gr_db, 0.0);
|
||||
assert_eq!(m.limiter_total_gr_db, 0.0);
|
||||
assert_eq!(m.limiter_soft_gr_db, 0.0);
|
||||
assert_eq!(m.limiter_hard_gr_db, 0.0);
|
||||
assert_eq!(m.true_peak_dbtp, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_is_cheap_to_clone() {
|
||||
let a = shared();
|
||||
let b = a.clone();
|
||||
*a.lock() = BusMetrics {
|
||||
compressor_gr_db: -3.0,
|
||||
limiter_total_gr_db: -1.0,
|
||||
limiter_soft_gr_db: -1.0,
|
||||
limiter_hard_gr_db: 0.0,
|
||||
true_peak_dbtp: -0.5,
|
||||
};
|
||||
let snap = *b.lock();
|
||||
assert!((snap.compressor_gr_db - -3.0).abs() < 1e-6);
|
||||
assert!((snap.true_peak_dbtp - -0.5).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
|
@ -150,7 +150,6 @@ impl Profile {
|
|||
MakeupGain::Db(v) => Some(v),
|
||||
};
|
||||
CompressorConfig {
|
||||
enabled: self.compressor.enabled,
|
||||
threshold_db: self.compressor.threshold_db,
|
||||
ratio: self.compressor.ratio,
|
||||
knee_db: self.compressor.knee_db,
|
||||
|
|
|
|||
|
|
@ -637,56 +637,37 @@ fn materialize_skipping(
|
|||
}
|
||||
|
||||
fn apply_route_overrides(profile: &mut Profile, overrides: &BTreeMap<String, Route>) {
|
||||
// Prepend two rules per overlay entry so the match catches
|
||||
// whichever identity field the stream actually advertises.
|
||||
//
|
||||
// Why two rules and not one with both fields set? The matcher
|
||||
// ANDs across non-empty fields, so a rule with both
|
||||
// `process_binary` *and* `application_name` populated would
|
||||
// only match a stream that has *both* properties set to the
|
||||
// same string. Many CLI tools (pw-cat being the canonical
|
||||
// case, plus various Electron / Flatpak wrappers) only set
|
||||
// `application.name` and leave `application.process.binary`
|
||||
// unset — they'd miss the AND-shape rule despite the user's
|
||||
// clear intent.
|
||||
//
|
||||
// Two single-field rules with the same route effectively form
|
||||
// an OR across the identity fields. PipeWire iterates rules in
|
||||
// order and returns on first match, so emitting both is cheap
|
||||
// (constant per override) and correct in either case.
|
||||
//
|
||||
// No retain pre-pass: `materialize` is stateless (it
|
||||
// serializes the base profile fresh from `pick_base` each
|
||||
// call), so overlay rules can't accumulate across consecutive
|
||||
// `set_route` calls. A retain pre-pass would only deduplicate
|
||||
// rules whose *base profile* TOML coincidentally has the same
|
||||
// shape — silently removing a user-authored rule that was
|
||||
// never an overlay artefact. The prepended order means
|
||||
// overlay rules win first-match iteration over any genuinely
|
||||
// duplicate base-profile rule anyway, so no correctness gain;
|
||||
// dropping the retain closes the data-loss surface Codex
|
||||
// flagged in its audit of the route.set match-by-name change.
|
||||
let mut new_rules: Vec<RouteRule> = Vec::with_capacity(overrides.len() * 2);
|
||||
for (app, route) in overrides {
|
||||
new_rules.push(RouteRule {
|
||||
// Drop any existing single-app user rule matching an override, then
|
||||
// prepend the overrides as one rule per app at the top of the list.
|
||||
let override_apps: std::collections::HashSet<&String> = overrides.keys().collect();
|
||||
profile
|
||||
.rules
|
||||
.retain(|r| !is_single_app_rule_for_any(r, &override_apps));
|
||||
let mut new_rules: Vec<RouteRule> = overrides
|
||||
.iter()
|
||||
.map(|(app, route)| RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec![app.clone()],
|
||||
..Default::default()
|
||||
},
|
||||
route: *route,
|
||||
});
|
||||
new_rules.push(RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
application_name: vec![app.clone()],
|
||||
..Default::default()
|
||||
},
|
||||
route: *route,
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
new_rules.extend(std::mem::take(&mut profile.rules));
|
||||
profile.rules = new_rules;
|
||||
}
|
||||
|
||||
fn is_single_app_rule_for_any(
|
||||
rule: &RouteRule,
|
||||
apps: &std::collections::HashSet<&String>,
|
||||
) -> bool {
|
||||
rule.match_.process_binary.len() == 1
|
||||
&& apps.contains(&rule.match_.process_binary[0])
|
||||
&& rule.match_.application_name.is_empty()
|
||||
&& rule.match_.portal_app_id.is_empty()
|
||||
&& rule.match_.media_role.is_empty()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -959,96 +940,6 @@ mod tests {
|
|||
assert_eq!(rule.route, Route::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_route_emits_both_process_binary_and_application_name_rules() {
|
||||
// The route.set CLI verb accepts a single app identifier
|
||||
// but streams can advertise themselves via either
|
||||
// `application.process.binary` or `application.name`
|
||||
// (or neither — those go through default_route). The
|
||||
// overlay materialises BOTH single-field rules so each
|
||||
// possible identity field is covered.
|
||||
let (paths, _g) = tmp_paths();
|
||||
let mut s = ProfileStore::load(&paths).unwrap();
|
||||
s.set_route("pw-cat", Route::Bypass).unwrap();
|
||||
let rules = &s.effective().rules;
|
||||
let proc_rule = rules
|
||||
.iter()
|
||||
.find(|r| r.match_.process_binary == vec!["pw-cat".to_string()])
|
||||
.expect("process_binary rule");
|
||||
assert_eq!(proc_rule.route, Route::Bypass);
|
||||
assert!(proc_rule.match_.application_name.is_empty());
|
||||
let name_rule = rules
|
||||
.iter()
|
||||
.find(|r| r.match_.application_name == vec!["pw-cat".to_string()])
|
||||
.expect("application_name rule");
|
||||
assert_eq!(name_rule.route, Route::Bypass);
|
||||
assert!(name_rule.match_.process_binary.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_route_then_unset_leaves_no_residual_rules() {
|
||||
// Both the process_binary and application_name variants
|
||||
// of a single-app override must clear on unset; otherwise
|
||||
// a re-add would stack rules and the matcher would carry
|
||||
// dead entries indefinitely.
|
||||
let (paths, _g) = tmp_paths();
|
||||
let mut s = ProfileStore::load(&paths).unwrap();
|
||||
s.set_route("pw-cat", Route::Bypass).unwrap();
|
||||
s.unset_route("pw-cat").unwrap();
|
||||
let residual: Vec<_> = s
|
||||
.effective()
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.match_.process_binary == vec!["pw-cat".to_string()]
|
||||
|| r.match_.application_name == vec!["pw-cat".to_string()]
|
||||
})
|
||||
.collect();
|
||||
assert!(residual.is_empty(), "leftover override rules: {residual:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_rule_with_overlay_shape_survives_set_route_for_same_app() {
|
||||
// Regression for Codex audit Q5: an earlier retain pre-pass in
|
||||
// `apply_route_overrides` would silently drop any base-profile
|
||||
// rule whose single-field shape coincided with the overlay's
|
||||
// emit pattern. The fix is to delete the retain entirely —
|
||||
// prepending already makes the overlay win first-match
|
||||
// iteration, and removing the retain closes the data-loss
|
||||
// surface. This test pins the surviving-rule behaviour so a
|
||||
// future refactor can't quietly reintroduce the prune.
|
||||
let (paths, _g) = tmp_paths();
|
||||
fs::write(
|
||||
paths.config_dir.join("profiles/custom.toml"),
|
||||
r#"
|
||||
name = "custom"
|
||||
description = "user custom"
|
||||
default_route = { route = "processed" }
|
||||
[[rules]]
|
||||
match = { process_binary = ["obs"] }
|
||||
route = "processed"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut s = ProfileStore::load(&paths).unwrap();
|
||||
s.use_profile("custom").unwrap();
|
||||
// Sanity: user rule is loaded once.
|
||||
assert_eq!(s.effective().rules.len(), 1);
|
||||
|
||||
s.set_route("obs", Route::Bypass).unwrap();
|
||||
|
||||
let rules = &s.effective().rules;
|
||||
// Two overlay rules (process_binary + application_name) plus
|
||||
// the preserved user rule.
|
||||
assert_eq!(rules.len(), 3, "rules: {rules:#?}");
|
||||
assert_eq!(rules[0].route, Route::Bypass);
|
||||
assert_eq!(rules[0].match_.process_binary, vec!["obs".to_string()]);
|
||||
assert_eq!(rules[1].route, Route::Bypass);
|
||||
assert_eq!(rules[1].match_.application_name, vec!["obs".to_string()]);
|
||||
assert_eq!(rules[2].route, Route::Processed);
|
||||
assert_eq!(rules[2].match_.process_binary, vec!["obs".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_route_updates_existing_override() {
|
||||
let (paths, _g) = tmp_paths();
|
||||
|
|
|
|||
|
|
@ -53,24 +53,4 @@ pub enum PwCommand {
|
|||
/// Cached app label for log lines / events.
|
||||
app_label: String,
|
||||
},
|
||||
/// Re-run `routing::evaluate` against every known stream and
|
||||
/// enqueue routes where the decision changed since last time.
|
||||
/// Posted by IPC handlers that mutate routing inputs — global
|
||||
/// bypass toggle (F1), profile.use / profile.reload / route.set
|
||||
/// / route.unset (F2). The handler reads current state (bypass,
|
||||
/// effective profile, real sink) at apply time, not at post
|
||||
/// time, so a stale command is harmless.
|
||||
ReevaluateAll,
|
||||
/// Rebuild the bus filter at a new sample rate. Posted when
|
||||
/// the real sink's Format-param listener detects a rate that
|
||||
/// doesn't match what the filter is currently running at —
|
||||
/// either at cold boot (ALSA sinks only publish their rate
|
||||
/// via Format, not in their props dict, so the initial filter
|
||||
/// is created at the fallback rate before the Format event
|
||||
/// fires) or on a sink hot-swap that changed the rate.
|
||||
/// Causes a ~50–100 ms audio dropout during the swap.
|
||||
RebuildFilter {
|
||||
/// New filter sample rate in Hz.
|
||||
sample_rate: u32,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,26 +53,13 @@ use headroom_dsp::{
|
|||
};
|
||||
|
||||
use crate::error::DaemonError;
|
||||
use crate::meters::{BusMetrics, SharedBusMetrics, SharedPlaybackTiming};
|
||||
use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
|
||||
|
||||
/// Sample rate the filter operates at. The DSP kernels are
|
||||
/// constructed for this rate; if PipeWire negotiates a different
|
||||
/// rate the filter logs a warning and the DSP may sound slightly off
|
||||
/// in time-based parameters until Phase 4 wires rate updates.
|
||||
/// Sample rate the filter uses when no real sink is yet known
|
||||
/// (cold boot, or `default.audio.sink` hasn't resolved). The
|
||||
/// runtime overrides this via [`Filter::create`]'s `sample_rate`
|
||||
/// argument once a real-sink rate is captured from the registry.
|
||||
/// 48 kHz matches the PipeWire graph default; nothing else is
|
||||
/// load-bearing at this number.
|
||||
pub const DEFAULT_SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// Backward-compatibility alias for the old const name. Internal
|
||||
/// callers should take the rate as a parameter; this exists so
|
||||
/// out-of-tree code (`headroom-core` doc readers, downstream
|
||||
/// experiments) doesn't break on the rename.
|
||||
pub const FILTER_SAMPLE_RATE: u32 = DEFAULT_SAMPLE_RATE;
|
||||
pub const FILTER_SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// Number of channels the filter operates on (stereo only in v0).
|
||||
pub const CHANNELS: u32 = 2;
|
||||
|
|
@ -228,16 +215,6 @@ struct PlaybackState {
|
|||
samples_starved: u64,
|
||||
/// Counter of measurement samples dropped (best-effort push).
|
||||
measurement_dropped: u64,
|
||||
/// Bus-level meter snapshot shared with the AGC controller for
|
||||
/// meter publication. Audio thread does `try_lock` and skips on
|
||||
/// contention (which is vanishingly rare — the reader holds the
|
||||
/// lock for nanoseconds).
|
||||
bus_metrics: SharedBusMetrics,
|
||||
/// Lock-free rolling timing stats for the playback callback.
|
||||
/// Used by 8e to investigate the ~10 s-cadence BUSY spikes
|
||||
/// noted in PLAN §11 follow-ups, and as a general health
|
||||
/// signal going forward.
|
||||
timing: SharedPlaybackTiming,
|
||||
}
|
||||
|
||||
/// The filter pipeline.
|
||||
|
|
@ -275,18 +252,6 @@ pub struct FilterBundle {
|
|||
/// Consumer end of the AGC measurement ring. Hand to the
|
||||
/// `headroom-core::agc` controller.
|
||||
pub measurement_consumer: Consumer<f32>,
|
||||
/// Bus-level meter snapshot. The audio thread keeps it fresh on
|
||||
/// every `playback_process` call; the AGC controller reads it on
|
||||
/// each tick and publishes a `MeterTick` event.
|
||||
pub bus_metrics: SharedBusMetrics,
|
||||
/// Playback callback timing stats. Updated lock-free from the
|
||||
/// audio thread; sampled by the AGC controller's slow tick.
|
||||
pub timing: SharedPlaybackTiming,
|
||||
/// The sample rate the filter is running at — read from the
|
||||
/// real sink at construction time, or [`DEFAULT_SAMPLE_RATE`]
|
||||
/// if no real sink was known yet. Callers (runtime,
|
||||
/// AgcController) need it to size their own state.
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
|
|
@ -302,11 +267,7 @@ impl Filter {
|
|||
/// # Errors
|
||||
/// [`DaemonError::PipeWire`] if stream creation or connection
|
||||
/// fails.
|
||||
pub fn create(
|
||||
core: &Core,
|
||||
init: FilterInit,
|
||||
sample_rate: u32,
|
||||
) -> Result<FilterBundle, 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) =
|
||||
|
|
@ -314,18 +275,10 @@ impl Filter {
|
|||
let control = FilterControl {
|
||||
cmd_producer: Arc::new(Mutex::new(cmd_producer)),
|
||||
};
|
||||
let bus_metrics = crate::meters::shared();
|
||||
let timing = crate::meters::shared_timing();
|
||||
|
||||
// The limiter's `sanitized()` caps the *internal* (post-
|
||||
// oversample) rate, so a 96 kHz base + the default 4×
|
||||
// oversample auto-drops to 2× → 192 kHz internal rather
|
||||
// than 384 kHz. Keeps the FIR cost bounded as we follow
|
||||
// higher real-sink rates.
|
||||
let limiter_cfg = init.limiter.sanitize_for_rate(sample_rate as f32);
|
||||
let compressor = Compressor::new(init.compressor, sample_rate as f32);
|
||||
let limiter = Limiter::new(limiter_cfg, sample_rate as f32);
|
||||
let mut agc = AgcGain::new(init.agc, 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)?;
|
||||
|
|
@ -349,17 +302,15 @@ impl Filter {
|
|||
limiter,
|
||||
samples_starved: 0,
|
||||
measurement_dropped: 0,
|
||||
bus_metrics: bus_metrics.clone(),
|
||||
timing: timing.clone(),
|
||||
})
|
||||
.process(playback_process)
|
||||
.register()
|
||||
.map_err(|e| DaemonError::pipewire(format!("playback register: {e}")))?;
|
||||
|
||||
// One format POD, two connects. Both streams want the same
|
||||
// interpretation (F32LE stereo at `sample_rate`) and the
|
||||
// interpretation (F32LE stereo at FILTER_SAMPLE_RATE) and the
|
||||
// POD bytes live on this stack for the duration of both calls.
|
||||
let format_bytes = build_format_pod_bytes(sample_rate)?;
|
||||
let format_bytes = build_format_pod_bytes()?;
|
||||
let format_pod =
|
||||
Pod::from_bytes(&format_bytes).ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?;
|
||||
|
||||
|
|
@ -384,7 +335,7 @@ impl Filter {
|
|||
.map_err(|e| DaemonError::pipewire(format!("playback connect: {e}")))?;
|
||||
|
||||
tracing::info!(
|
||||
sample_rate,
|
||||
sample_rate = FILTER_SAMPLE_RATE,
|
||||
channels = CHANNELS,
|
||||
ring_capacity = RING_CAPACITY,
|
||||
"filter streams created and connected"
|
||||
|
|
@ -399,9 +350,6 @@ impl Filter {
|
|||
},
|
||||
control,
|
||||
measurement_consumer,
|
||||
bus_metrics,
|
||||
timing,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -447,12 +395,12 @@ fn build_playback_stream(core: &Core) -> Result<Stream, DaemonError> {
|
|||
.map_err(|e| DaemonError::pipewire(format!("playback Stream::new: {e}")))
|
||||
}
|
||||
|
||||
/// Serialize our preferred audio format (F32LE stereo at the
|
||||
/// runtime-supplied `sample_rate`) into a SPA POD byte buffer.
|
||||
fn build_format_pod_bytes(sample_rate: u32) -> Result<Vec<u8>, DaemonError> {
|
||||
/// Serialize our preferred audio format (F32LE stereo at
|
||||
/// [`FILTER_SAMPLE_RATE`]) into a SPA POD byte buffer.
|
||||
fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(sample_rate);
|
||||
info.set_rate(FILTER_SAMPLE_RATE);
|
||||
info.set_channels(CHANNELS);
|
||||
|
||||
let obj = Object {
|
||||
|
|
@ -467,14 +415,8 @@ fn build_format_pod_bytes(sample_rate: u32) -> Result<Vec<u8>, DaemonError> {
|
|||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Capture process callback. Realtime-thread, allocation-free —
|
||||
/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds
|
||||
/// so any inadvertent allocation aborts immediately.
|
||||
/// Capture process callback. Realtime-thread, allocation-free.
|
||||
fn capture_process(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) {
|
||||
assert_no_alloc::assert_no_alloc(|| capture_process_inner(stream, state));
|
||||
}
|
||||
|
||||
fn capture_process_inner(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return; // Out of buffers; pipewire is queueing for us.
|
||||
};
|
||||
|
|
@ -565,19 +507,8 @@ fn drain_audio_commands(state: &mut PlaybackState) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Playback process callback. Realtime-thread, allocation-free —
|
||||
/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds.
|
||||
/// Wraps the inner with an Instant timer; the duration is recorded
|
||||
/// into [`PlaybackTiming`] (lock-free atomics, no allocation), and
|
||||
/// the AGC controller drains the stats on its 50 ms tick.
|
||||
/// Playback process callback. Realtime-thread, allocation-free.
|
||||
fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) {
|
||||
let start = std::time::Instant::now();
|
||||
assert_no_alloc::assert_no_alloc(|| playback_process_inner(stream, state));
|
||||
let dur_us = start.elapsed().as_micros() as u64;
|
||||
state.timing.record(dur_us);
|
||||
}
|
||||
|
||||
fn playback_process_inner(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) {
|
||||
drain_audio_commands(state);
|
||||
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
|
|
@ -646,21 +577,6 @@ fn playback_process_inner(stream: &pipewire::stream::StreamRef, state: &mut Play
|
|||
.saturating_add((starved_frames * CHANNELS as usize) as u64);
|
||||
}
|
||||
|
||||
// Snapshot bus-level meter state for the AGC controller. `try_lock`
|
||||
// so we never block on a daemon-thread reader; a contended quantum
|
||||
// simply drops this update — the next one along will land.
|
||||
if produced_frames > 0 {
|
||||
if let Some(mut metrics) = state.bus_metrics.try_lock() {
|
||||
*metrics = BusMetrics {
|
||||
compressor_gr_db: state.compressor.gain_reduction_db(),
|
||||
limiter_total_gr_db: state.limiter.gain_reduction_db(),
|
||||
limiter_soft_gr_db: state.limiter.soft_gain_reduction_db(),
|
||||
limiter_hard_gr_db: state.limiter.hard_gain_reduction_db(),
|
||||
true_peak_dbtp: state.limiter.true_peak_dbtp(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tell PipeWire how much we wrote.
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.size_mut() = (max_frames * stride_bytes) as u32;
|
||||
|
|
@ -734,11 +650,8 @@ mod tests {
|
|||
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 bad = LimiterConfig {
|
||||
// structural; can't apply in place
|
||||
oversample: 8,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -157,15 +157,6 @@ impl PwContext {
|
|||
&self.core
|
||||
}
|
||||
|
||||
/// Borrow the routing state's `Rc<RefCell<RoutingState>>`, if
|
||||
/// the routing engine has been started. Lets `runtime` install
|
||||
/// the filter-rebuild handles after `start_routing` without
|
||||
/// having to thread them through that method's signature.
|
||||
#[must_use]
|
||||
pub fn routing_state(&self) -> Option<Rc<RefCell<crate::pw::registry::RoutingState>>> {
|
||||
self.routing.borrow().as_ref().map(|w| w.state().clone())
|
||||
}
|
||||
|
||||
/// Create `headroom-processed` and do a roundtrip to confirm it
|
||||
/// landed on the server.
|
||||
///
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -234,12 +234,7 @@ fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
|
|||
/// 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.
|
||||
/// Guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds.
|
||||
fn tap_process(stream: &pipewire::stream::StreamRef, state: &mut TapState) {
|
||||
assert_no_alloc::assert_no_alloc(|| tap_process_inner(stream, state));
|
||||
}
|
||||
|
||||
fn tap_process_inner(stream: &pipewire::stream::StreamRef, state: &mut TapState) {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,15 +34,6 @@ pub struct PwNodeInfo {
|
|||
/// `node.dont-move` — if set true, the stream opted out of being
|
||||
/// rerouted. Honoured by skipping routing entirely.
|
||||
pub dont_move: bool,
|
||||
/// `audio.channels` — the stream's declared channel count.
|
||||
/// `None` if the property is absent (older PipeWire / odd
|
||||
/// clients). Used to force `>2ch` streams onto the bypass
|
||||
/// path: the bus filter is stereo-only by construction, so
|
||||
/// pulling a 5.1 stream into the processed sink would either
|
||||
/// drop four channels (if we used explicit links naively) or
|
||||
/// produce a downmix that wasn't asked for. Either way the
|
||||
/// safer default is "leave surround alone."
|
||||
pub audio_channels: Option<u32>,
|
||||
}
|
||||
|
||||
impl PwNodeInfo {
|
||||
|
|
@ -66,34 +57,13 @@ pub enum RoutingDecision {
|
|||
/// Evaluate a stream against the profile's routing rules.
|
||||
///
|
||||
/// Returns [`RoutingDecision::Skip`] if the stream isn't a routable
|
||||
/// playback stream. When `bypass_global` is true, every routable
|
||||
/// stream gets [`Route::Bypass`] regardless of rule match — the
|
||||
/// global kill switch overrides everything. Otherwise returns the
|
||||
/// first-match route, or the profile's `default_route` if no rule
|
||||
/// matches.
|
||||
/// playback stream. Otherwise returns the first-match route, or the
|
||||
/// profile's `default_route` if no rule matches.
|
||||
#[must_use]
|
||||
pub fn evaluate(info: &PwNodeInfo, profile: &Profile, bypass_global: bool) -> RoutingDecision {
|
||||
pub fn evaluate(info: &PwNodeInfo, profile: &Profile) -> RoutingDecision {
|
||||
if !info.is_routable_playback() {
|
||||
return RoutingDecision::Skip;
|
||||
}
|
||||
// Global bypass: nothing reaches the processed sink. Implemented
|
||||
// as a real graph operation (4k explicit links to the real sink)
|
||||
// rather than just a metadata write — see PwCommand::ReevaluateAll
|
||||
// and `set_global_bypass` in the registry.
|
||||
if bypass_global {
|
||||
return RoutingDecision::Route(Route::Bypass);
|
||||
}
|
||||
// Force-bypass anything wider than stereo. PLAN §3's surround
|
||||
// contract: the bus filter is F32 stereo by construction, so
|
||||
// pulling a 5.1+ stream into `headroom-processed` either drops
|
||||
// channels (with explicit links) or produces an unrequested
|
||||
// downmix (if WP's adapter gets involved). Routing it straight
|
||||
// to the real sink preserves the user's intended layout. If
|
||||
// the real sink isn't 5.1-capable PipeWire's source-side
|
||||
// adapter handles the downmix — that's its job, not ours.
|
||||
if matches!(info.audio_channels, Some(ch) if ch > 2) {
|
||||
return RoutingDecision::Route(Route::Bypass);
|
||||
}
|
||||
for rule in &profile.rules {
|
||||
if matches(info, &rule.match_) {
|
||||
return RoutingDecision::Route(rule.route);
|
||||
|
|
@ -143,7 +113,7 @@ mod tests {
|
|||
let mut info = playback("firefox");
|
||||
info.media_class = Some("Stream/Input/Audio".into());
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
|
||||
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -151,72 +121,7 @@ mod tests {
|
|||
let mut info = playback("firefox");
|
||||
info.dont_move = true;
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surround_streams_force_bypass_regardless_of_rule_match() {
|
||||
// The default profile routes `firefox` to processed. A 5.1
|
||||
// firefox stream (rare but valid — some browser content
|
||||
// declares surround) must still bypass: the bus filter is
|
||||
// stereo-only and the explicit-link path would otherwise
|
||||
// drop FC/LFE/SL/SR. PLAN §3 surround contract.
|
||||
let mut info = playback("firefox");
|
||||
info.audio_channels = Some(6);
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_and_mono_streams_follow_normal_rules() {
|
||||
// Sanity: the surround forcer only kicks in for >2ch.
|
||||
let profile = Profile::default_v0();
|
||||
for ch in [None, Some(1), Some(2)] {
|
||||
let mut info = playback("firefox");
|
||||
info.audio_channels = ch;
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Processed),
|
||||
"channels={ch:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_name_only_rule_matches_stream_with_no_process_binary() {
|
||||
// The shape `route set` emits when expanded into an
|
||||
// `application_name`-keyed override. Verifies that a
|
||||
// stream missing `application.process.binary` (typical
|
||||
// of pw-cat, many CLI tools, some Flatpak wrappers) is
|
||||
// still matched by the user's intent.
|
||||
use headroom_ipc::{RouteRule, RouteRuleMatch};
|
||||
let mut profile = Profile::default_v0();
|
||||
// Override at the top of the rule list.
|
||||
profile.rules.insert(
|
||||
0,
|
||||
RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
application_name: vec!["pw-cat".into()],
|
||||
..Default::default()
|
||||
},
|
||||
route: Route::Bypass,
|
||||
},
|
||||
);
|
||||
// Stream advertises only application.name = "pw-cat".
|
||||
let info = PwNodeInfo {
|
||||
node_id: 9,
|
||||
media_class: Some("Stream/Output/Audio".into()),
|
||||
application_process_binary: None,
|
||||
application_name: Some("pw-cat".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -224,7 +129,7 @@ mod tests {
|
|||
let info = playback("mpv");
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -234,7 +139,7 @@ mod tests {
|
|||
let info = playback("firefox");
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
|
@ -245,7 +150,7 @@ mod tests {
|
|||
let profile = Profile::default_v0();
|
||||
// default_v0 has `default_route = Processed`.
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
|
@ -272,7 +177,7 @@ mod tests {
|
|||
});
|
||||
let info = playback("firefox");
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -287,7 +192,7 @@ mod tests {
|
|||
});
|
||||
let info = playback("firefox");
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -308,7 +213,7 @@ mod tests {
|
|||
// process_binary matches but media_role doesn't (None on info).
|
||||
let info = playback("firefox");
|
||||
assert_ne!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
|
||||
|
|
@ -316,7 +221,7 @@ mod tests {
|
|||
let mut info2 = playback("firefox");
|
||||
info2.media_role = Some("Communication".into());
|
||||
assert_eq!(
|
||||
evaluate(&info2, &profile, false),
|
||||
evaluate(&info2, &profile),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -335,7 +240,7 @@ mod tests {
|
|||
let mut info = playback("DiscordWrapper");
|
||||
info.portal_app_id = Some("com.discordapp.Discord".into());
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile, false),
|
||||
evaluate(&info, &profile),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,48 +103,24 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> {
|
|||
agc_enabled: effective.agc.enabled,
|
||||
}
|
||||
};
|
||||
// Read the real sink's native rate (captured during the brief
|
||||
// window the registry watcher has been running) so the filter
|
||||
// can match it and skip the output-edge resample for content
|
||||
// at that rate. Falls back to PipeWire's 48 kHz default if the
|
||||
// real sink hasn't surfaced yet — Phase C will rebuild the
|
||||
// filter when the rate later resolves to something else.
|
||||
let initial_rate = daemon_state
|
||||
.lock()
|
||||
.real_sink
|
||||
.sample_rate
|
||||
.unwrap_or(crate::pw::filter::DEFAULT_SAMPLE_RATE);
|
||||
tracing::info!(initial_rate, "creating filter at real-sink-matched rate");
|
||||
|
||||
let FilterBundle {
|
||||
filter,
|
||||
filter: _filter,
|
||||
control: filter_control,
|
||||
measurement_consumer,
|
||||
bus_metrics,
|
||||
timing,
|
||||
sample_rate: filter_rate,
|
||||
} = Filter::create(pw.core(), filter_init, initial_rate)?;
|
||||
{
|
||||
let mut s = daemon_state.lock();
|
||||
s.filter_control = Some(filter_control.clone());
|
||||
s.filter_sample_rate = Some(filter_rate);
|
||||
}
|
||||
} = 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. Also publishes `meters` topic ticks at
|
||||
// `profile.meters.publish_hz` (capped at 20 Hz, the AGC tick
|
||||
// rate) — 4g.
|
||||
// FilterControl.
|
||||
let agc_controller = AgcController::new(
|
||||
filter_rate,
|
||||
crate::pw::filter::FILTER_SAMPLE_RATE,
|
||||
crate::pw::filter::CHANNELS,
|
||||
measurement_consumer,
|
||||
filter_control,
|
||||
daemon_state.clone(),
|
||||
bus_metrics,
|
||||
timing,
|
||||
)
|
||||
.map_err(DaemonError::from)?;
|
||||
let agc_controller = Rc::new(RefCell::new(agc_controller));
|
||||
|
|
@ -165,25 +141,6 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> {
|
|||
// mechanism (see 4h).
|
||||
pw.start_routing(daemon_state.clone())?;
|
||||
|
||||
// Hand the filter + an AGC handle to the routing state so the
|
||||
// Format-param listener (registered when the real sink resolves
|
||||
// its negotiated audio.rate) can ask the registry thread to
|
||||
// rebuild the filter at a new rate via
|
||||
// `PwCommand::RebuildFilter`. Filter ownership moves here:
|
||||
// RoutingState now drops it on daemon shutdown via PwContext's
|
||||
// drop order. The Filter is `Some(filter)` here unconditionally
|
||||
// — `install_filter_rebuild_handles` overwrites whatever's in
|
||||
// the slot.
|
||||
if let Some(routing_state) = pw.routing_state() {
|
||||
routing_state
|
||||
.borrow_mut()
|
||||
.install_filter_rebuild_handles(filter, agc_controller.clone());
|
||||
} else {
|
||||
// start_routing succeeded above so this branch shouldn't
|
||||
// fire; keep the filter alive defensively if it ever does.
|
||||
tracing::warn!("routing_state unavailable post-start_routing; keeping filter local");
|
||||
}
|
||||
|
||||
publish_daemon_started(&daemon_state, &pending_warnings, active_missing.as_deref());
|
||||
|
||||
pw.run_until_signal()?;
|
||||
|
|
|
|||
|
|
@ -55,13 +55,6 @@ pub struct DaemonState {
|
|||
/// PipeWire global id of `headroom-processed`, captured when the
|
||||
/// registry surfaces it. `None` until then.
|
||||
pub processed_sink_id: Option<u32>,
|
||||
/// Sample rate the filter is currently running at, in Hz.
|
||||
/// `None` until `Filter::create` has been called (very early
|
||||
/// boot only). Matches the real sink's native rate at the time
|
||||
/// the filter was last (re)built. Used to populate the
|
||||
/// processed sink's `sample_rate` field in `status` and to
|
||||
/// drive Layer A's block-period.
|
||||
pub filter_sample_rate: Option<u32>,
|
||||
/// Snapshot of the user's preferred hardware sink. Phase 4h
|
||||
/// keeps this fresh from `default.audio.sink`.
|
||||
pub real_sink: SinkInfo,
|
||||
|
|
@ -96,7 +89,6 @@ impl DaemonState {
|
|||
started_at: Instant::now(),
|
||||
profiles,
|
||||
processed_sink_id: None,
|
||||
filter_sample_rate: None,
|
||||
real_sink: SinkInfo::default(),
|
||||
streams: HashMap::new(),
|
||||
broadcaster: Broadcaster::new(),
|
||||
|
|
@ -119,14 +111,13 @@ impl DaemonState {
|
|||
return None;
|
||||
}
|
||||
self.real_sink = SinkInfo {
|
||||
// node_id + sample_rate stay unknown for now —
|
||||
// registry's `try_capture_real_sink` resolves both
|
||||
// when it sees the matching `Audio/Sink` global. The
|
||||
// 4i routing path operates on name alone.
|
||||
// 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,
|
||||
sample_rate: None,
|
||||
};
|
||||
Some(
|
||||
self.streams
|
||||
|
|
|
|||
|
|
@ -19,14 +19,6 @@ pub enum Detector {
|
|||
/// Compressor parameters.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CompressorConfig {
|
||||
/// Master enable. When `false`, [`Compressor::process_frame`]
|
||||
/// returns the input unchanged and reports zero gain reduction.
|
||||
/// The compressor's envelope state is *not* reset while disabled,
|
||||
/// so a stale envelope can briefly affect the first few samples
|
||||
/// after re-enabling — but with typical release time constants
|
||||
/// (tens to hundreds of ms) any residual transient is below the
|
||||
/// audibility threshold.
|
||||
pub enabled: bool,
|
||||
/// Threshold in dBFS. Inputs above this start compressing.
|
||||
pub threshold_db: f32,
|
||||
/// Compression ratio (>= 1.0).
|
||||
|
|
@ -48,7 +40,6 @@ pub struct CompressorConfig {
|
|||
impl Default for CompressorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
threshold_db: -24.0,
|
||||
ratio: 2.5,
|
||||
knee_db: 6.0,
|
||||
|
|
@ -117,23 +108,11 @@ impl Compressor {
|
|||
self.last_gr_db
|
||||
}
|
||||
|
||||
/// Update parameters. Recomputes alphas. Envelope state is kept
|
||||
/// across same-enabled transitions so live tweaks don't pop, but
|
||||
/// reset on a `disabled → enabled` transition so a stale
|
||||
/// envelope from before the disable doesn't bleed out at the
|
||||
/// release time-constant when processing resumes (otherwise
|
||||
/// switching from a `transparent` profile back to a compressing
|
||||
/// one would briefly duck on the first ~100 ms of audio for no
|
||||
/// reason).
|
||||
/// Update parameters. Recomputes alphas. Envelope state is kept,
|
||||
/// so live tweaks don't pop.
|
||||
pub fn set_config(&mut self, cfg: CompressorConfig) {
|
||||
let cfg = cfg.sanitized();
|
||||
let was_disabled = !self.cfg.enabled;
|
||||
self.cfg = cfg;
|
||||
if was_disabled && self.cfg.enabled {
|
||||
self.envelope_db = -200.0;
|
||||
self.rms_state = 0.0;
|
||||
self.last_gr_db = 0.0;
|
||||
}
|
||||
self.attack_alpha = time_to_alpha(cfg.attack_ms, self.sample_rate);
|
||||
self.release_alpha = time_to_alpha(cfg.release_ms, self.sample_rate);
|
||||
self.rms_alpha = time_to_alpha(cfg.rms_window_ms, self.sample_rate);
|
||||
|
|
@ -141,13 +120,6 @@ impl Compressor {
|
|||
|
||||
/// Process one stereo frame.
|
||||
pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||
if !self.cfg.enabled {
|
||||
// Pass through untouched and report no reduction, so the
|
||||
// bus meters reflect "compressor off" rather than the
|
||||
// last value before disable.
|
||||
self.last_gr_db = 0.0;
|
||||
return (left, right);
|
||||
}
|
||||
let det_lin = match self.cfg.detector {
|
||||
Detector::Peak => left.abs().max(right.abs()),
|
||||
Detector::Rms => {
|
||||
|
|
@ -284,79 +256,6 @@ mod tests {
|
|||
assert_eq!(cfg.ratio, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_compressor_passes_signal_through_unchanged() {
|
||||
// Same hot input that would compress hard in the enabled
|
||||
// test above. With `enabled: false`, output equals input
|
||||
// exactly (no makeup gain, no reduction), and the reporter
|
||||
// shows zero GR — so the `transparent` and `bypass-all`
|
||||
// profiles actually do what their name claims.
|
||||
let cfg = CompressorConfig {
|
||||
enabled: false,
|
||||
threshold_db: -20.0,
|
||||
ratio: 4.0,
|
||||
makeup_db: Some(12.0),
|
||||
..CompressorConfig::default()
|
||||
};
|
||||
let mut c = Compressor::new(cfg, 48_000.0);
|
||||
for _ in 0..1_000 {
|
||||
let (l, r) = c.process_frame(0.5, 0.5);
|
||||
assert_eq!(l, 0.5);
|
||||
assert_eq!(r, 0.5);
|
||||
}
|
||||
assert_eq!(c.gain_reduction_db(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_transition_resets_stale_envelope() {
|
||||
// Run a loud signal through an enabled compressor to wind
|
||||
// the envelope up, then disable + re-enable via set_config.
|
||||
// The first sample after re-enable must NOT see the stale
|
||||
// envelope (which would otherwise duck the signal until
|
||||
// release_ms wound it down). Concretely: with a quiet input
|
||||
// after re-enable, the envelope should be at the floor, so
|
||||
// GR is zero — same as a freshly-constructed compressor.
|
||||
let loud_cfg = CompressorConfig {
|
||||
enabled: true,
|
||||
threshold_db: -20.0,
|
||||
ratio: 4.0,
|
||||
attack_ms: 0.1,
|
||||
release_ms: 1000.0, // slow release so stale state would otherwise stick
|
||||
knee_db: 0.0,
|
||||
makeup_db: Some(0.0),
|
||||
..CompressorConfig::default()
|
||||
};
|
||||
let mut c = Compressor::new(loud_cfg, 48_000.0);
|
||||
// Drive hot signal to wind envelope up.
|
||||
for _ in 0..2_000 {
|
||||
c.process_frame(0.5, 0.5);
|
||||
}
|
||||
assert!(
|
||||
c.gain_reduction_db() < -5.0,
|
||||
"precondition: envelope should be wound up; gr={}",
|
||||
c.gain_reduction_db()
|
||||
);
|
||||
|
||||
// Disable, then re-enable — should reset.
|
||||
let mut disabled_cfg = loud_cfg;
|
||||
disabled_cfg.enabled = false;
|
||||
c.set_config(disabled_cfg);
|
||||
c.set_config(loud_cfg);
|
||||
|
||||
// Now drive a quiet signal. With reset envelope, GR should
|
||||
// ride near zero; without reset, the stale envelope would
|
||||
// bleed gain reduction out over ~release_ms.
|
||||
let (l, r) = c.process_frame(0.001, 0.001);
|
||||
assert!(
|
||||
c.gain_reduction_db().abs() < 0.01,
|
||||
"envelope didn't reset across enable transition; gr={}",
|
||||
c.gain_reduction_db()
|
||||
);
|
||||
// Output should be quiet (within makeup-applied scale).
|
||||
assert!(l.abs() < 0.01);
|
||||
assert!(r.abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_curve_at_threshold_with_soft_knee() {
|
||||
// At exactly threshold, soft knee contributes exactly half the
|
||||
|
|
|
|||
|
|
@ -140,22 +140,10 @@ impl Default for LimiterConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Internal-rate cap (Hz). The limiter's true-peak detector
|
||||
/// upsamples to `sample_rate × oversample`. Above ~192 kHz the
|
||||
/// FIR cost rises linearly with effectively no gain — at base
|
||||
/// rates ≥ 96 kHz the signal already has plenty of bandwidth
|
||||
/// for inter-sample-peak detection. We cap the *effective*
|
||||
/// internal rate here and drop the oversample factor on high
|
||||
/// base rates accordingly.
|
||||
pub const MAX_INTERNAL_RATE_HZ: f32 = 192_000.0;
|
||||
|
||||
impl LimiterConfig {
|
||||
/// Sanitize a user-supplied configuration: clamp ceiling,
|
||||
/// oversample factor, ensure odd FIR length, sanitize the soft
|
||||
/// tier if present. Rate-agnostic — callers that know the
|
||||
/// audio thread's sample rate should prefer
|
||||
/// [`Self::sanitize_for_rate`] so the oversample factor scales
|
||||
/// down on high-rate inputs.
|
||||
/// tier if present.
|
||||
#[must_use]
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
if self.ceiling_dbtp > 0.0 {
|
||||
|
|
@ -174,27 +162,6 @@ impl LimiterConfig {
|
|||
self
|
||||
}
|
||||
|
||||
/// Sanitize and additionally cap the oversample factor so the
|
||||
/// post-upsample internal rate stays ≤ [`MAX_INTERNAL_RATE_HZ`].
|
||||
/// Examples at the default `oversample = 4`:
|
||||
/// 44.1 kHz → 4× → 176.4 kHz (under cap, untouched)
|
||||
/// 48 kHz → 4× → 192 kHz (at cap, untouched)
|
||||
/// 96 kHz → 2× → 192 kHz (cap engaged, dropped from 4)
|
||||
/// 192 kHz → 1× → 192 kHz (cap engaged, no oversampling)
|
||||
/// Always returns at least `oversample = 1`.
|
||||
#[must_use]
|
||||
pub fn sanitize_for_rate(self, sample_rate: f32) -> Self {
|
||||
let mut s = self.sanitized();
|
||||
if sample_rate > 0.0 {
|
||||
let max_os =
|
||||
(MAX_INTERNAL_RATE_HZ / sample_rate).floor().max(1.0) as usize;
|
||||
if s.oversample > max_os {
|
||||
s.oversample = max_os;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Convenience: brickwall only (no soft tier).
|
||||
#[must_use]
|
||||
pub fn brickwall_only() -> Self {
|
||||
|
|
@ -648,40 +615,6 @@ mod tests {
|
|||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// sanitize_for_rate: oversample factor scales down so the
|
||||
// internal (post-upsample) rate stays bounded.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sanitize_for_rate_caps_oversample_at_internal_192k() {
|
||||
// Default config has oversample = 4.
|
||||
let default = LimiterConfig::default();
|
||||
assert_eq!(default.oversample, 4);
|
||||
|
||||
// At 48 kHz: 4× = 192 kHz, at the cap, untouched.
|
||||
assert_eq!(default.sanitize_for_rate(48_000.0).oversample, 4);
|
||||
// At 44.1 kHz: 4× = 176.4 kHz, under the cap.
|
||||
assert_eq!(default.sanitize_for_rate(44_100.0).oversample, 4);
|
||||
// At 96 kHz: 4× = 384 kHz, exceeds; drop to 2× = 192 kHz.
|
||||
assert_eq!(default.sanitize_for_rate(96_000.0).oversample, 2);
|
||||
// At 192 kHz: cap forces oversample = 1.
|
||||
assert_eq!(default.sanitize_for_rate(192_000.0).oversample, 1);
|
||||
// Pathological rate above the cap still leaves at least 1.
|
||||
assert_eq!(default.sanitize_for_rate(384_000.0).oversample, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_for_rate_preserves_user_lower_oversample() {
|
||||
// User who explicitly set oversample = 2 at 48 kHz should
|
||||
// keep it; the rate cap doesn't push the value *up*.
|
||||
let cfg = LimiterConfig {
|
||||
oversample: 2,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
assert_eq!(cfg.sanitize_for_rate(48_000.0).oversample, 2);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// try_set_config: scalar updates apply in place, structural
|
||||
// changes are rejected.
|
||||
|
|
@ -691,12 +624,10 @@ mod tests {
|
|||
fn try_set_config_applies_scalar_changes() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let cfg = LimiterConfig {
|
||||
ceiling_dbtp: -3.0,
|
||||
release_ms: 200.0,
|
||||
hold_ms: 10.0,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
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();
|
||||
|
|
@ -709,10 +640,8 @@ mod tests {
|
|||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
// Start with soft on. Disable it.
|
||||
let mut cfg = LimiterConfig {
|
||||
soft: None,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
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());
|
||||
|
|
@ -735,10 +664,8 @@ mod tests {
|
|||
fn try_set_config_rejects_oversample_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let cfg = LimiterConfig {
|
||||
oversample: 8,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
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);
|
||||
|
|
@ -748,11 +675,8 @@ mod tests {
|
|||
fn try_set_config_rejects_lookahead_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let cfg = LimiterConfig {
|
||||
// resizes delay + peak buffer
|
||||
lookahead_ms: 5.0,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.lookahead_ms = 5.0; // resizes delay + peak buffer
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
}
|
||||
|
||||
|
|
@ -760,10 +684,8 @@ mod tests {
|
|||
fn try_set_config_rejects_fir_taps_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let cfg = LimiterConfig {
|
||||
fir_taps: 63,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.fir_taps = 63;
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ mod proto;
|
|||
pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};
|
||||
pub use error::{Error, ErrorCode, ProtoError};
|
||||
pub use proto::{
|
||||
DaemonEvent, Event, HelloData, LayerALevel, MeterTick, Op, ProfileEvent, ProfileInfo, Request,
|
||||
Response, ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent,
|
||||
ServerFrame, SinkInfo, Sinks, Status, StreamRoute, Topic,
|
||||
DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
|
||||
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
|
||||
SinkInfo, Sinks, Status, StreamRoute, Topic,
|
||||
};
|
||||
|
||||
/// Wire-protocol version. Bumped only on incompatible changes.
|
||||
|
|
|
|||
|
|
@ -391,13 +391,6 @@ pub struct SinkInfo {
|
|||
/// True if the sink is currently linked and accepting audio.
|
||||
#[serde(default)]
|
||||
pub ready: bool,
|
||||
/// Sink's native sample rate (Hz), when known. The filter
|
||||
/// matches the *real* sink's rate to skip the output-edge
|
||||
/// resample; the processed sink advertises whatever rate the
|
||||
/// filter is currently running at. Older clients that don't
|
||||
/// understand the field treat it as absent (serde `default`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sample_rate: Option<u32>,
|
||||
}
|
||||
|
||||
/// One playback stream and where it's routed.
|
||||
|
|
@ -532,49 +525,10 @@ pub enum RoutingEvent {
|
|||
/// Route assigned.
|
||||
to: Route,
|
||||
},
|
||||
/// A stream tracked by the routing engine went away (its
|
||||
/// PipeWire node disappeared). Clients should drop any state
|
||||
/// indexed by `node_id`.
|
||||
StreamRemoved {
|
||||
/// Node id of the departed stream.
|
||||
node_id: u32,
|
||||
},
|
||||
/// A Layer A (per-app level control) tap was attached to a
|
||||
/// stream — the daemon will start managing its
|
||||
/// `Props.channelVolumes` and publishing `meters/layer_a_level`
|
||||
/// events for it.
|
||||
LayerAAttached {
|
||||
/// Node id of the managed stream.
|
||||
node_id: u32,
|
||||
/// Application identifier.
|
||||
app: String,
|
||||
},
|
||||
/// A Layer A tap was torn down (typically because the stream
|
||||
/// went away). Clients should drop Layer A state for `node_id`.
|
||||
LayerADetached {
|
||||
/// Node id whose tap was torn down.
|
||||
node_id: u32,
|
||||
},
|
||||
/// A persistent rule was added, replaced, or removed.
|
||||
RuleChanged,
|
||||
}
|
||||
|
||||
/// `meters/layer_a_level` payload — published when the per-app
|
||||
/// (Layer A) level controller writes a new `channelVolumes` value to
|
||||
/// a managed stream.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LayerALevel {
|
||||
/// Source PipeWire node id.
|
||||
pub node_id: u32,
|
||||
/// Application identifier.
|
||||
pub app: String,
|
||||
/// Linear volume that was written (1.0 = unity).
|
||||
pub volume_lin: f32,
|
||||
/// Smoothed gain reduction the controller currently asserts, in
|
||||
/// dB. ≤ 0 dB when reducing.
|
||||
pub reduction_db: f32,
|
||||
}
|
||||
|
||||
/// `daemon` topic events.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
|
|
|
|||
183
flake.nix
183
flake.nix
|
|
@ -11,118 +11,97 @@
|
|||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ]
|
||||
(system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
|
||||
# Native libs the audio crates link against.
|
||||
nativeAudioBuildInputs = with pkgs; [
|
||||
pipewire
|
||||
pipewire.dev
|
||||
# Native libs the audio crates link against.
|
||||
nativeAudioBuildInputs = with pkgs; [
|
||||
pipewire
|
||||
pipewire.dev
|
||||
];
|
||||
|
||||
nativeBuildTools = with pkgs; [
|
||||
pkg-config
|
||||
clang
|
||||
];
|
||||
|
||||
commonEnv = {
|
||||
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
|
||||
PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig";
|
||||
};
|
||||
in
|
||||
{
|
||||
# `nix develop` — full dev environment.
|
||||
devShells.default = pkgs.mkShell ({
|
||||
name = "headroom-dev";
|
||||
|
||||
nativeBuildInputs = nativeBuildTools ++ [
|
||||
rustToolchain
|
||||
pkgs.rust-analyzer
|
||||
];
|
||||
|
||||
nativeBuildTools = with pkgs; [
|
||||
pkg-config
|
||||
clang
|
||||
];
|
||||
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
|
||||
socat # poke the IPC socket
|
||||
jq # pretty-print JSON
|
||||
pipewire # for pw-cli, pw-cat, etc.
|
||||
wireplumber
|
||||
]);
|
||||
|
||||
commonEnv = {
|
||||
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
|
||||
PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig";
|
||||
};
|
||||
in
|
||||
{
|
||||
# `nix develop` — full dev environment.
|
||||
devShells.default = pkgs.mkShell ({
|
||||
name = "headroom-dev";
|
||||
shellHook = ''
|
||||
echo "headroom dev shell — rustc $(rustc --version | cut -d' ' -f2)"
|
||||
echo " cargo build / cargo test for iteration."
|
||||
echo " nix build .#headroom for the packaged binary."
|
||||
export RUST_BACKTRACE=1
|
||||
export RUST_LOG=headroom=debug,info
|
||||
'';
|
||||
} // commonEnv);
|
||||
|
||||
nativeBuildInputs = nativeBuildTools ++ [
|
||||
rustToolchain
|
||||
pkgs.rust-analyzer
|
||||
];
|
||||
# `nix build` — the final packaged daemon + CLI.
|
||||
packages = rec {
|
||||
default = headroom;
|
||||
|
||||
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
|
||||
socat # poke the IPC socket
|
||||
jq # pretty-print JSON
|
||||
pipewire # for pw-cli, pw-cat, etc.
|
||||
wireplumber
|
||||
]);
|
||||
headroom = rustPlatform.buildRustPackage ({
|
||||
pname = "headroom";
|
||||
version = (builtins.fromTOML (builtins.readFile ./crates/headroom-cli/Cargo.toml)).package.version;
|
||||
|
||||
shellHook = ''
|
||||
echo "headroom dev shell — rustc $(rustc --version | cut -d' ' -f2)"
|
||||
echo " cargo build / cargo test for iteration."
|
||||
echo " nix build .#headroom for the packaged binary."
|
||||
export RUST_BACKTRACE=1
|
||||
export RUST_LOG=headroom=debug,info
|
||||
'';
|
||||
src = ./.;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
# allowBuiltinFetchGit = true;
|
||||
};
|
||||
|
||||
nativeBuildInputs = nativeBuildTools;
|
||||
buildInputs = nativeAudioBuildInputs;
|
||||
|
||||
# We ship two binaries from the workspace: `headroom` (cli + daemon).
|
||||
cargoBuildFlags = [ "-p" "headroom-cli" ];
|
||||
doCheck = true;
|
||||
cargoTestFlags = [ "--workspace" ];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AGC + compressor + true-peak limiter daemon for PipeWire";
|
||||
license = licenses.gpl3Plus;
|
||||
platforms = platforms.linux;
|
||||
mainProgram = "headroom";
|
||||
};
|
||||
} // commonEnv);
|
||||
};
|
||||
|
||||
# `nix build` — the final packaged daemon + CLI.
|
||||
packages = rec {
|
||||
default = headroom;
|
||||
# Reserved for the eventual user-service module.
|
||||
# nixosModules.default = import ./nix/module.nix;
|
||||
|
||||
headroom = rustPlatform.buildRustPackage ({
|
||||
pname = "headroom";
|
||||
# Pull from the workspace Cargo.toml — the per-crate
|
||||
# manifests use `version.workspace = true` which evaluates
|
||||
# to a table here, not a string.
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
|
||||
|
||||
src = ./.;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
# allowBuiltinFetchGit = true;
|
||||
};
|
||||
|
||||
nativeBuildInputs = nativeBuildTools;
|
||||
buildInputs = nativeAudioBuildInputs;
|
||||
|
||||
# We ship one binary from the workspace: `headroom` (cli + daemon).
|
||||
cargoBuildFlags = [ "-p" "headroom-cli" ];
|
||||
doCheck = true;
|
||||
cargoTestFlags = [ "--workspace" ];
|
||||
|
||||
# Install the systemd user unit (templated with @bindir@
|
||||
# so the unit refers to the absolute path of the binary in
|
||||
# this derivation, never to whatever happens to be on
|
||||
# PATH) and ship the canonical profiles under
|
||||
# share/headroom/profiles so users / modules can copy
|
||||
# them into XDG_CONFIG_HOME on first run.
|
||||
postInstall = ''
|
||||
install -Dm644 contrib/systemd/headroom.service \
|
||||
"$out/lib/systemd/user/headroom.service"
|
||||
substituteInPlace "$out/lib/systemd/user/headroom.service" \
|
||||
--replace-fail '@bindir@' "$out/bin"
|
||||
|
||||
mkdir -p "$out/share/headroom/profiles"
|
||||
cp -r profiles/. "$out/share/headroom/profiles/"
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AGC + compressor + true-peak limiter daemon for PipeWire";
|
||||
license = licenses.gpl3Plus;
|
||||
platforms = platforms.linux;
|
||||
mainProgram = "headroom";
|
||||
};
|
||||
} // commonEnv);
|
||||
};
|
||||
|
||||
formatter = pkgs.nixpkgs-fmt;
|
||||
}) // {
|
||||
# System-independent outputs — modules.
|
||||
nixosModules.default = import ./nix/nixos-module.nix self;
|
||||
homeModules.default = import ./nix/home-module.nix self;
|
||||
};
|
||||
formatter = pkgs.nixpkgs-fmt;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
# Home Manager module — installs the headroom binary, the systemd
|
||||
# user service, and (optionally) a default set of profiles into the
|
||||
# user's XDG_CONFIG_HOME.
|
||||
#
|
||||
# Headroom is a per-user daemon that talks to PipeWire over the user
|
||||
# session, so the Home Manager scope is its natural install point. A
|
||||
# separate NixOS module (./nixos-module.nix) covers the case where the
|
||||
# user wants `headroom` on every account's PATH or wants to enable the
|
||||
# service at the system level via systemd-user; that module simply
|
||||
# delegates the heavy lifting to `services.headroom` (this file) when
|
||||
# Home Manager is in use.
|
||||
self:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
inherit (lib) mkEnableOption mkOption mkIf types literalExpression;
|
||||
|
||||
cfg = config.services.headroom;
|
||||
|
||||
package = cfg.package;
|
||||
|
||||
# Profiles shipped by the package, suitable for symlinking into the
|
||||
# user's XDG_CONFIG_HOME so they show up in `headroom profile list`
|
||||
# without the user having to copy them by hand.
|
||||
shippedProfilesDir = "${package}/share/headroom/profiles";
|
||||
in
|
||||
{
|
||||
options.services.headroom = {
|
||||
enable = mkEnableOption "Headroom — PipeWire AGC + compressor + true-peak limiter daemon";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.headroom;
|
||||
defaultText = literalExpression "headroom.packages.\${pkgs.system}.headroom";
|
||||
description = ''
|
||||
The headroom package to install. Override to pin a local
|
||||
build (e.g. `path:/home/me/code/headroom`) when iterating.
|
||||
'';
|
||||
};
|
||||
|
||||
installDefaultProfiles = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Symlink the profiles shipped with the package into
|
||||
`$XDG_CONFIG_HOME/headroom/profiles/`. Disable if you
|
||||
maintain your own profile set and don't want the shipped
|
||||
ones cluttering `headroom profile list`.
|
||||
'';
|
||||
};
|
||||
|
||||
extraProfiles = mkOption {
|
||||
type = types.attrsOf types.path;
|
||||
default = { };
|
||||
example = literalExpression ''
|
||||
{
|
||||
"studio.toml" = ./profiles/studio.toml;
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Additional profile TOML files to drop into the user's
|
||||
profile directory, keyed by filename. Overrides any
|
||||
identically-named shipped profile.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
home.packages = [ package ];
|
||||
|
||||
# Symlink shipped profiles + any user-provided extras into the
|
||||
# user's XDG_CONFIG_HOME. The daemon's profile watcher
|
||||
# (notify-debouncer-mini) treats symlinks identically to
|
||||
# regular files, so this is transparent.
|
||||
xdg.configFile = lib.mkMerge [
|
||||
(mkIf cfg.installDefaultProfiles (
|
||||
lib.mapAttrs'
|
||||
(name: _: lib.nameValuePair "headroom/profiles/${name}" {
|
||||
source = "${shippedProfilesDir}/${name}";
|
||||
})
|
||||
(builtins.readDir shippedProfilesDir)
|
||||
))
|
||||
(lib.mapAttrs'
|
||||
(name: path: lib.nameValuePair "headroom/profiles/${name}" {
|
||||
source = path;
|
||||
})
|
||||
cfg.extraProfiles)
|
||||
];
|
||||
|
||||
# systemd user unit. The unit shipped by the package already
|
||||
# carries the right ExecStart with an absolute path baked in,
|
||||
# so we just symlink it into the user's services directory and
|
||||
# let Home Manager start it via its systemd-user machinery.
|
||||
systemd.user.services.headroom = {
|
||||
Unit = {
|
||||
Description = "Headroom audio daemon (PipeWire AGC + compressor + true-peak limiter)";
|
||||
Documentation = "https://github.com/amaanq/headroom";
|
||||
After = [ "pipewire.service" "pipewire-pulse.service" "wireplumber.service" ];
|
||||
Requires = [ "pipewire.service" ];
|
||||
Wants = [ "wireplumber.service" ];
|
||||
};
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart = "${package}/bin/headroom daemon";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "2s";
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
SyslogIdentifier = "headroom";
|
||||
LimitRTPRIO = 20;
|
||||
LimitRTTIME = 200000;
|
||||
LimitNICE = -11;
|
||||
};
|
||||
Install = {
|
||||
WantedBy = [ "pipewire.service" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
# NixOS module — system-wide install. Headroom itself is a user-scope
|
||||
# daemon (it talks to the user's PipeWire session), so this module's
|
||||
# job is narrow:
|
||||
#
|
||||
# 1. Make the `headroom` binary available on every login's PATH.
|
||||
# 2. Drop the systemd user unit into the system-wide location so a
|
||||
# user can `systemctl --user enable --now headroom` without first
|
||||
# having to use Home Manager.
|
||||
# 3. Ensure the standard audio stack (PipeWire + WirePlumber) is
|
||||
# enabled, since headroom can't function without them.
|
||||
#
|
||||
# For per-user defaults — activeProfile, shipped-profile install,
|
||||
# RT-priority tuning — use the Home Manager module
|
||||
# (`homeModules.default`) instead. The two compose.
|
||||
self:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
inherit (lib) mkEnableOption mkOption mkIf types literalExpression;
|
||||
|
||||
cfg = config.programs.headroom;
|
||||
in
|
||||
{
|
||||
options.programs.headroom = {
|
||||
enable = mkEnableOption "Headroom — PipeWire AGC + compressor + true-peak limiter daemon";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.headroom;
|
||||
defaultText = literalExpression "headroom.packages.\${pkgs.system}.headroom";
|
||||
description = ''
|
||||
The headroom package to install system-wide.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
# Binary + manpages (when we have them) on the global PATH.
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
# Make the shipped systemd user unit discoverable by `systemctl
|
||||
# --user`. Setting `packages` here is the canonical NixOS way to
|
||||
# install user-scope unit files from a package — it materialises
|
||||
# `/etc/systemd/user/headroom.service` pointing at the package's
|
||||
# `lib/systemd/user/headroom.service`.
|
||||
systemd.packages = [ cfg.package ];
|
||||
|
||||
# Headroom requires PipeWire; refuse to evaluate the module if
|
||||
# the user enabled headroom but not pipewire, with a pointer
|
||||
# rather than a confusing runtime failure.
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.services.pipewire.enable;
|
||||
message = ''
|
||||
programs.headroom.enable requires services.pipewire.enable = true;
|
||||
headroom is a PipeWire-only daemon.
|
||||
'';
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue