Compare commits
21 commits
fcf421b94c
...
716290c3bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716290c3bf | ||
|
|
4c39ecd5d2 | ||
|
|
ab02df23fe | ||
|
|
86d00c43d1 | ||
|
|
4a80a16d79 | ||
|
|
ec49206660 | ||
|
|
5c769a1226 | ||
|
|
5143c07c99 | ||
|
|
04a005e1cd | ||
|
|
0e718abe27 | ||
|
|
e0c23ec459 | ||
|
|
3427ec56fc | ||
|
|
03edb17180 | ||
|
|
244367ccb9 | ||
|
|
c65c75bb9f | ||
|
|
d52cd6db3b | ||
|
|
9220143db7 | ||
|
|
8af6dff98d | ||
|
|
df8af6c4d2 | ||
|
|
e528a98417 | ||
|
|
79e4baedd0 |
31 changed files with 3965 additions and 322 deletions
388
Cargo.lock
generated
388
Cargo.lock
generated
|
|
@ -11,6 +11,12 @@ 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"
|
||||
|
|
@ -83,6 +89,12 @@ 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"
|
||||
|
|
@ -128,12 +140,27 @@ 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"
|
||||
|
|
@ -253,6 +280,20 @@ 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"
|
||||
|
|
@ -320,12 +361,72 @@ 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"
|
||||
|
|
@ -391,6 +492,18 @@ 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"
|
||||
|
|
@ -505,6 +618,17 @@ 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"
|
||||
|
|
@ -515,10 +639,14 @@ 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",
|
||||
|
|
@ -539,6 +667,7 @@ dependencies = [
|
|||
name = "headroom-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"assert_no_alloc",
|
||||
"bytemuck",
|
||||
"criterion",
|
||||
"crossbeam-channel",
|
||||
|
|
@ -591,6 +720,12 @@ 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"
|
||||
|
|
@ -598,7 +733,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -621,6 +765,19 @@ 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"
|
||||
|
|
@ -656,6 +813,15 @@ 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"
|
||||
|
|
@ -738,6 +904,12 @@ 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"
|
||||
|
|
@ -753,6 +925,15 @@ 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"
|
||||
|
|
@ -786,6 +967,18 @@ 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"
|
||||
|
|
@ -821,7 +1014,7 @@ dependencies = [
|
|||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
|
@ -896,6 +1089,12 @@ 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"
|
||||
|
|
@ -954,6 +1153,27 @@ 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"
|
||||
|
|
@ -1004,6 +1224,31 @@ 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"
|
||||
|
|
@ -1096,6 +1341,17 @@ 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"
|
||||
|
|
@ -1118,12 +1374,40 @@ 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"
|
||||
|
|
@ -1327,6 +1611,17 @@ 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"
|
||||
|
|
@ -1410,7 +1705,16 @@ version = "0.48.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1428,13 +1732,29 @@ version = "0.48.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1443,42 +1763,90 @@ 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,6 +37,12 @@ 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,6 +233,23 @@ 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
|
||||
|
|
@ -831,7 +848,10 @@ builds and any CI go through `nix build`.
|
|||
|
||||
## 11. Phased implementation
|
||||
|
||||
The phases are roughly token-of-work units, not calendar weeks.
|
||||
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.
|
||||
|
||||
**Phase 0 — scaffolding.** Flake, workspace, crate skeletons, README,
|
||||
PLAN/IPC docs. *(done as part of this commit)*
|
||||
|
|
@ -891,33 +911,31 @@ 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 phase that consumes them lands.
|
||||
lost. Pick up by name when the trigger that gates them fires.
|
||||
|
||||
- **Ephemeral overlay mutations.** *(4e follow-up.)* All `route.set`
|
||||
/ `setting.set` changes are persisted to `overlay.toml`. A
|
||||
`--ephemeral` flag (or `--volatile`) on the CLI for one-shot tweaks
|
||||
that don't outlive the daemon was considered and dropped from v0
|
||||
for simplicity. Revisit if real users ask for it; the store-level
|
||||
change is a flag on the setter methods.
|
||||
- **Filter playback BUSY spikes (periodic, ~10 s cadence).** *(6c
|
||||
manual smoke finding, 2026-05.)* On a quiet system with AGC and
|
||||
per-app both off, the filter's `playback_process` BUSY
|
||||
occasionally spikes from its ~240 μs steady-state to ~2.0 ms,
|
||||
correlating with output-sink WAIT spikes of similar size. No
|
||||
audible impact (sub-quantum at 21 ms). The ~10 s cadence rules
|
||||
out sliding-max worst-case (which would be input-pattern-driven,
|
||||
not periodic) and Layer A (the spikes persist with `per_app.enabled
|
||||
= false`). Suspects with 10 s clocks somewhere: WirePlumber session
|
||||
policy heartbeat, PipeWire internal graph re-eval, or system-level
|
||||
scheduling (CPU governor, kernel housekeeping). Diagnostic for
|
||||
Phase 8: timestamp the playback callback, log when its measured
|
||||
duration crosses ~1 ms; correlate with `journalctl`,
|
||||
`wireplumber --verbose`, and `pw-dump` snapshots taken around the
|
||||
spike. If we can't attribute it to PipeWire-side reschedule and
|
||||
it's something we can fix in our callback, the candidate
|
||||
workaround is to break the limiter's per-block work into smaller
|
||||
chunks (cap allocations / pops / branches per call) for more
|
||||
predictable timing.
|
||||
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.
|
||||
- **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
|
||||
|
|
@ -1042,18 +1060,68 @@ If those three say "fine," the §4.1 promise is upheld in practice
|
|||
and 6c is acceptance-tested. `jack_iodelay` and other true-round-trip
|
||||
tools are overkill.
|
||||
|
||||
**Phase 7 — Packaging.** systemd user unit, install paths, default
|
||||
profile install, basic NixOS module.
|
||||
**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 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.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
|
@ -1071,8 +1139,17 @@ multi-rate hardware, allocation-tracer sweep with
|
|||
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 routed directly to the real sink (bypass behaviour)
|
||||
regardless of profile rule. Documented behaviour.
|
||||
`>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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
94
README.md
94
README.md
|
|
@ -13,24 +13,114 @@ 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
|
||||
|
||||
Pre-alpha. Wire protocol and crate scaffolding are in; daemon and
|
||||
filter are under construction.
|
||||
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.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
nix develop # toolchain + pipewire dev libs + helpers
|
||||
cargo build # iterate
|
||||
cargo test --workspace
|
||||
nix build # final packaged headroom binary
|
||||
```
|
||||
|
||||
|
|
|
|||
39
contrib/systemd/headroom.service
Normal file
39
contrib/systemd/headroom.service
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[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,7 +18,11 @@ 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,12 +6,24 @@
|
|||
|
||||
#![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)]
|
||||
|
|
@ -66,12 +78,19 @@ enum Cmd {
|
|||
/// Reload profile files from disk.
|
||||
Reload,
|
||||
|
||||
/// Subscribe to event topics and print as line-delimited JSON.
|
||||
/// 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).
|
||||
Monitor {
|
||||
/// Topics to subscribe to (comma-separated).
|
||||
/// Defaults to `meters` if none given.
|
||||
/// Topics to subscribe to (comma-separated). Only honoured with
|
||||
/// `--json`; the TUI always subscribes to all four event topics.
|
||||
#[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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -170,13 +189,28 @@ fn init_tracing() {
|
|||
|
||||
fn run() -> Result<(), CliError> {
|
||||
let cli = Cli::parse();
|
||||
init_tracing();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -247,18 +281,23 @@ fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
|
|||
let reloaded = client.profile_reload()?;
|
||||
println!("reloaded: {reloaded:?}");
|
||||
}
|
||||
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)?,
|
||||
);
|
||||
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`")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -276,6 +315,9 @@ enum CliError {
|
|||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("tui: {0}")]
|
||||
Tui(tui::TuiError),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
|
|
|||
810
crates/headroom-cli/src/tui.rs
Normal file
810
crates/headroom-cli/src/tui.rs
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
//! `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,7 +12,6 @@ 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 }
|
||||
|
|
@ -44,11 +43,20 @@ 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,7 +16,9 @@
|
|||
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;
|
||||
|
||||
|
|
@ -30,8 +32,10 @@ const TICK_BUF_SAMPLES: usize = 8192;
|
|||
|
||||
/// Loudness floor we treat as "no usable measurement yet" — returned
|
||||
/// by `ebur128` before its short-term window has filled, or during
|
||||
/// digital silence.
|
||||
const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
|
||||
/// 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;
|
||||
|
||||
/// Slow AGC controller.
|
||||
pub struct AgcController {
|
||||
|
|
@ -50,8 +54,23 @@ pub struct AgcController {
|
|||
/// enable flag exactly when it changes.
|
||||
last_enabled: bool,
|
||||
/// Last short-term loudness observed; surfaced for status /
|
||||
/// meters in a future sub-stage.
|
||||
/// `meters` topic.
|
||||
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 {
|
||||
|
|
@ -66,9 +85,19 @@ impl AgcController {
|
|||
measurement_consumer: rtrb::Consumer<f32>,
|
||||
filter_control: FilterControl,
|
||||
daemon: SharedState,
|
||||
bus_metrics: SharedBusMetrics,
|
||||
timing: crate::meters::SharedPlaybackTiming,
|
||||
) -> Result<Self, AgcInitError> {
|
||||
let ebu = EbuR128::new(channels, sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK)
|
||||
.map_err(AgcInitError::from)?;
|
||||
// `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)?;
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
|
|
@ -79,6 +108,11 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -99,26 +133,107 @@ 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 the AGC section out from under the daemon lock.
|
||||
// Hold the lock only long enough to clone the small config.
|
||||
let cfg = {
|
||||
// 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) = {
|
||||
let s = self.daemon.lock();
|
||||
s.profiles.effective().agc.clone()
|
||||
let p = s.profiles.effective();
|
||||
(p.agc.clone(), p.meters.publish_hz)
|
||||
};
|
||||
|
||||
// React to enable/disable transitions before doing measurement
|
||||
// work — flipping off should stop pushing target updates and
|
||||
// tell the audio thread to unwind back to 0 dB.
|
||||
if cfg.enabled != self.last_enabled {
|
||||
self.filter_control.set_agc_enabled(cfg.enabled);
|
||||
self.last_enabled = cfg.enabled;
|
||||
}
|
||||
if !cfg.enabled {
|
||||
return;
|
||||
|
||||
// Drain 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);
|
||||
}
|
||||
|
||||
// Drain up to TICK_BUF_SAMPLES from the measurement ring.
|
||||
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 {
|
||||
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) {
|
||||
let mut buf = [0.0_f32; TICK_BUF_SAMPLES];
|
||||
let mut n = 0;
|
||||
while n < buf.len() {
|
||||
|
|
@ -131,7 +246,7 @@ impl AgcController {
|
|||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return; // No samples yet (early boot or silence); leave target alone.
|
||||
return;
|
||||
}
|
||||
// ebur128 wants whole frames; drop any odd trailing sample.
|
||||
let usable = (n / self.channels as usize) * self.channels as usize;
|
||||
|
|
@ -140,39 +255,61 @@ 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 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)
|
||||
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,
|
||||
};
|
||||
self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db);
|
||||
|
||||
self.filter_control
|
||||
.set_agc_target_db(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
|
||||
}
|
||||
|
||||
/// Reset the smoothed target and the underlying `ebur128` state.
|
||||
|
|
@ -181,14 +318,50 @@ 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.
|
||||
if let Ok(fresh) =
|
||||
EbuR128::new(self.channels, self.sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK)
|
||||
{
|
||||
// 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,
|
||||
) {
|
||||
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
|
||||
|
|
@ -219,6 +392,7 @@ 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};
|
||||
|
|
@ -232,12 +406,24 @@ 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 agc = AgcController::new(SR, CH, m_cons, control, state.clone()).unwrap();
|
||||
(agc, m_prod, cmd_cons, state)
|
||||
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)
|
||||
}
|
||||
|
||||
fn push_silence(prod: &mut rtrb::Producer<f32>, frames: usize) {
|
||||
|
|
@ -258,7 +444,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tick_with_no_samples_does_nothing() {
|
||||
let (mut agc, _prod, mut cmd_cons, _state) = fixture();
|
||||
let (mut agc, _prod, mut cmd_cons, _state, _bus) = fixture();
|
||||
agc.tick();
|
||||
assert!(cmd_cons.pop().is_err(), "no samples → no target push");
|
||||
assert_eq!(agc.current_target_db(), 0.0);
|
||||
|
|
@ -266,7 +452,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tick_under_silence_threshold_holds_target() {
|
||||
let (mut agc, mut prod, mut cmd_cons, _state) = fixture();
|
||||
let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
|
||||
push_silence(&mut prod, 4800); // 100ms of silence
|
||||
agc.tick();
|
||||
// ebur128 may report -inf or values below the silence
|
||||
|
|
@ -279,7 +465,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tick_with_audible_signal_pushes_target() {
|
||||
let (mut agc, mut prod, mut cmd_cons, _state) = fixture();
|
||||
let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
|
||||
// Pump multiple ticks worth so ebur128's short-term window
|
||||
// (~3 s) starts producing values.
|
||||
for _ in 0..40 {
|
||||
|
|
@ -299,7 +485,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn agc_disable_in_profile_flips_audio_thread() {
|
||||
let (mut agc, _prod, mut cmd_cons, state) = fixture();
|
||||
let (mut agc, _prod, mut cmd_cons, state, _bus) = 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 = c.smoothed_reduction_db + 6.0; // synthetic kick
|
||||
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,6 +66,11 @@ 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(),
|
||||
},
|
||||
|
|
@ -193,6 +198,7 @@ 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 }))
|
||||
|
|
@ -234,6 +240,7 @@ 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)
|
||||
|
|
@ -245,6 +252,7 @@ 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)
|
||||
}
|
||||
|
|
@ -258,6 +266,7 @@ 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)
|
||||
}
|
||||
|
|
@ -271,6 +280,25 @@ 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);
|
||||
|
|
@ -356,7 +384,22 @@ 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),
|
||||
|
|
@ -505,7 +548,7 @@ mod tests {
|
|||
assert!(
|
||||
body.get("warnings")
|
||||
.and_then(|w| w.as_array())
|
||||
.map_or(true, |a| a.is_empty()),
|
||||
.is_none_or(|a| a.is_empty()),
|
||||
"expected empty/absent warnings on healthy startup"
|
||||
);
|
||||
}
|
||||
|
|
@ -1049,7 +1092,10 @@ mod tests {
|
|||
node_id,
|
||||
to,
|
||||
app_label,
|
||||
} = cmd;
|
||||
} = cmd
|
||||
else {
|
||||
panic!("expected RouteStream, got {cmd:?}");
|
||||
};
|
||||
assert_eq!(node_id, 42);
|
||||
assert_eq!(to, Route::Bypass);
|
||||
assert_eq!(app_label, "firefox");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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;
|
||||
|
|
|
|||
192
crates/headroom-core/src/meters.rs
Normal file
192
crates/headroom-core/src/meters.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! 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,6 +150,7 @@ 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,37 +637,56 @@ fn materialize_skipping(
|
|||
}
|
||||
|
||||
fn apply_route_overrides(profile: &mut Profile, overrides: &BTreeMap<String, Route>) {
|
||||
// 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 {
|
||||
// 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 {
|
||||
match_: RouteRuleMatch {
|
||||
process_binary: vec![app.clone()],
|
||||
..Default::default()
|
||||
},
|
||||
route: *route,
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
new_rules.push(RouteRule {
|
||||
match_: RouteRuleMatch {
|
||||
application_name: vec![app.clone()],
|
||||
..Default::default()
|
||||
},
|
||||
route: *route,
|
||||
});
|
||||
}
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -940,6 +959,96 @@ 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,4 +53,24 @@ 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,13 +53,26 @@ 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.
|
||||
pub const FILTER_SAMPLE_RATE: u32 = 48_000;
|
||||
/// 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;
|
||||
|
||||
/// Number of channels the filter operates on (stereo only in v0).
|
||||
pub const CHANNELS: u32 = 2;
|
||||
|
|
@ -215,6 +228,16 @@ 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.
|
||||
|
|
@ -252,6 +275,18 @@ 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 {
|
||||
|
|
@ -267,7 +302,11 @@ impl Filter {
|
|||
/// # Errors
|
||||
/// [`DaemonError::PipeWire`] if stream creation or connection
|
||||
/// fails.
|
||||
pub fn create(core: &Core, init: FilterInit) -> Result<FilterBundle, DaemonError> {
|
||||
pub fn create(
|
||||
core: &Core,
|
||||
init: FilterInit,
|
||||
sample_rate: u32,
|
||||
) -> 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) =
|
||||
|
|
@ -275,10 +314,18 @@ 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();
|
||||
|
||||
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);
|
||||
// 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);
|
||||
agc.set_enabled(init.agc_enabled);
|
||||
|
||||
let capture = build_capture_stream(core)?;
|
||||
|
|
@ -302,15 +349,17 @@ 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 FILTER_SAMPLE_RATE) and the
|
||||
// interpretation (F32LE stereo at `sample_rate`) and the
|
||||
// POD bytes live on this stack for the duration of both calls.
|
||||
let format_bytes = build_format_pod_bytes()?;
|
||||
let format_bytes = build_format_pod_bytes(sample_rate)?;
|
||||
let format_pod =
|
||||
Pod::from_bytes(&format_bytes).ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?;
|
||||
|
||||
|
|
@ -335,7 +384,7 @@ impl Filter {
|
|||
.map_err(|e| DaemonError::pipewire(format!("playback connect: {e}")))?;
|
||||
|
||||
tracing::info!(
|
||||
sample_rate = FILTER_SAMPLE_RATE,
|
||||
sample_rate,
|
||||
channels = CHANNELS,
|
||||
ring_capacity = RING_CAPACITY,
|
||||
"filter streams created and connected"
|
||||
|
|
@ -350,6 +399,9 @@ impl Filter {
|
|||
},
|
||||
control,
|
||||
measurement_consumer,
|
||||
bus_metrics,
|
||||
timing,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -395,12 +447,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
|
||||
/// [`FILTER_SAMPLE_RATE`]) into a SPA POD byte buffer.
|
||||
fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
|
||||
/// 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> {
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(FILTER_SAMPLE_RATE);
|
||||
info.set_rate(sample_rate);
|
||||
info.set_channels(CHANNELS);
|
||||
|
||||
let obj = Object {
|
||||
|
|
@ -415,8 +467,14 @@ fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
|
|||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Capture process callback. Realtime-thread, allocation-free.
|
||||
/// Capture process callback. Realtime-thread, allocation-free —
|
||||
/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds
|
||||
/// so any inadvertent allocation aborts immediately.
|
||||
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.
|
||||
};
|
||||
|
|
@ -507,8 +565,19 @@ fn drain_audio_commands(state: &mut PlaybackState) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Playback process callback. Realtime-thread, allocation-free.
|
||||
/// 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.
|
||||
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 {
|
||||
|
|
@ -577,6 +646,21 @@ fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackSt
|
|||
.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;
|
||||
|
|
@ -650,8 +734,11 @@ 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 mut bad = LimiterConfig::default();
|
||||
bad.oversample = 8; // structural; can't apply in place
|
||||
let bad = LimiterConfig {
|
||||
// structural; can't apply in place
|
||||
oversample: 8,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
// Should not panic, should not change the limiter.
|
||||
apply_audio_cmd(
|
||||
AudioCmd::SetLimiter(bad),
|
||||
|
|
|
|||
|
|
@ -157,6 +157,15 @@ 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,7 +234,12 @@ 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,6 +34,15 @@ 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 {
|
||||
|
|
@ -57,13 +66,34 @@ pub enum RoutingDecision {
|
|||
/// Evaluate a stream against the profile's routing rules.
|
||||
///
|
||||
/// Returns [`RoutingDecision::Skip`] if the stream isn't a routable
|
||||
/// playback stream. Otherwise returns the first-match route, or the
|
||||
/// profile's `default_route` if no rule matches.
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn evaluate(info: &PwNodeInfo, profile: &Profile) -> RoutingDecision {
|
||||
pub fn evaluate(info: &PwNodeInfo, profile: &Profile, bypass_global: bool) -> 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);
|
||||
|
|
@ -113,7 +143,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), RoutingDecision::Skip);
|
||||
assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -121,7 +151,72 @@ mod tests {
|
|||
let mut info = playback("firefox");
|
||||
info.dont_move = true;
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -129,7 +224,7 @@ mod tests {
|
|||
let info = playback("mpv");
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -139,7 +234,7 @@ mod tests {
|
|||
let info = playback("firefox");
|
||||
let profile = Profile::default_v0();
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
|
@ -150,7 +245,7 @@ mod tests {
|
|||
let profile = Profile::default_v0();
|
||||
// default_v0 has `default_route = Processed`.
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
|
@ -177,7 +272,7 @@ mod tests {
|
|||
});
|
||||
let info = playback("firefox");
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -192,7 +287,7 @@ mod tests {
|
|||
});
|
||||
let info = playback("firefox");
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -213,7 +308,7 @@ mod tests {
|
|||
// process_binary matches but media_role doesn't (None on info).
|
||||
let info = playback("firefox");
|
||||
assert_ne!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
|
||||
|
|
@ -221,7 +316,7 @@ mod tests {
|
|||
let mut info2 = playback("firefox");
|
||||
info2.media_role = Some("Communication".into());
|
||||
assert_eq!(
|
||||
evaluate(&info2, &profile),
|
||||
evaluate(&info2, &profile, false),
|
||||
RoutingDecision::Route(Route::Bypass)
|
||||
);
|
||||
}
|
||||
|
|
@ -240,7 +335,7 @@ mod tests {
|
|||
let mut info = playback("DiscordWrapper");
|
||||
info.portal_app_id = Some("com.discordapp.Discord".into());
|
||||
assert_eq!(
|
||||
evaluate(&info, &profile),
|
||||
evaluate(&info, &profile, false),
|
||||
RoutingDecision::Route(Route::Processed)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,24 +103,48 @@ 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,
|
||||
} = Filter::create(pw.core(), filter_init)?;
|
||||
daemon_state.lock().filter_control = Some(filter_control.clone());
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// FilterControl. Also publishes `meters` topic ticks at
|
||||
// `profile.meters.publish_hz` (capped at 20 Hz, the AGC tick
|
||||
// rate) — 4g.
|
||||
let agc_controller = AgcController::new(
|
||||
crate::pw::filter::FILTER_SAMPLE_RATE,
|
||||
filter_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));
|
||||
|
|
@ -141,6 +165,25 @@ 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,6 +55,13 @@ 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,
|
||||
|
|
@ -89,6 +96,7 @@ 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(),
|
||||
|
|
@ -111,13 +119,14 @@ impl DaemonState {
|
|||
return None;
|
||||
}
|
||||
self.real_sink = SinkInfo {
|
||||
// node_id stays unknown for now — Headroom routes by name
|
||||
// via `target.object = {"name":"…"}`, which is what
|
||||
// WirePlumber expects. 4i may resolve the id when ad-hoc
|
||||
// per-stream overrides need it.
|
||||
// node_id + 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: None,
|
||||
name: Some(new_name.to_owned()),
|
||||
ready: true,
|
||||
sample_rate: None,
|
||||
};
|
||||
Some(
|
||||
self.streams
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ 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).
|
||||
|
|
@ -40,6 +48,7 @@ pub struct CompressorConfig {
|
|||
impl Default for CompressorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
threshold_db: -24.0,
|
||||
ratio: 2.5,
|
||||
knee_db: 6.0,
|
||||
|
|
@ -108,11 +117,23 @@ impl Compressor {
|
|||
self.last_gr_db
|
||||
}
|
||||
|
||||
/// Update parameters. Recomputes alphas. Envelope state is kept,
|
||||
/// so live tweaks don't pop.
|
||||
/// 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).
|
||||
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);
|
||||
|
|
@ -120,6 +141,13 @@ 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 => {
|
||||
|
|
@ -256,6 +284,79 @@ 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,10 +140,22 @@ 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.
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
if self.ceiling_dbtp > 0.0 {
|
||||
|
|
@ -162,6 +174,27 @@ 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 {
|
||||
|
|
@ -615,6 +648,40 @@ 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.
|
||||
|
|
@ -624,10 +691,12 @@ mod tests {
|
|||
fn try_set_config_applies_scalar_changes() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.ceiling_dbtp = -3.0;
|
||||
cfg.release_ms = 200.0;
|
||||
cfg.hold_ms = 10.0;
|
||||
let cfg = LimiterConfig {
|
||||
ceiling_dbtp: -3.0,
|
||||
release_ms: 200.0,
|
||||
hold_ms: 10.0,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
|
||||
assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);
|
||||
let active = l.config();
|
||||
|
|
@ -640,8 +709,10 @@ 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::default();
|
||||
cfg.soft = None;
|
||||
let mut cfg = LimiterConfig {
|
||||
soft: None,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
|
||||
assert!(l.config().soft.is_none());
|
||||
assert!(l.effective_soft_ceiling_dbtp().is_none());
|
||||
|
|
@ -664,8 +735,10 @@ mod tests {
|
|||
fn try_set_config_rejects_oversample_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.oversample = 8;
|
||||
let cfg = LimiterConfig {
|
||||
oversample: 8,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
// Limiter unchanged.
|
||||
assert_eq!(l.config().oversample, LimiterConfig::default().oversample);
|
||||
|
|
@ -675,8 +748,11 @@ mod tests {
|
|||
fn try_set_config_rejects_lookahead_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.lookahead_ms = 5.0; // resizes delay + peak buffer
|
||||
let cfg = LimiterConfig {
|
||||
// resizes delay + peak buffer
|
||||
lookahead_ms: 5.0,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
}
|
||||
|
||||
|
|
@ -684,8 +760,10 @@ mod tests {
|
|||
fn try_set_config_rejects_fir_taps_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.fir_taps = 63;
|
||||
let cfg = LimiterConfig {
|
||||
fir_taps: 63,
|
||||
..LimiterConfig::default()
|
||||
};
|
||||
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, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
|
||||
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
|
||||
SinkInfo, Sinks, Status, StreamRoute, Topic,
|
||||
DaemonEvent, Event, HelloData, LayerALevel, 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,6 +391,13 @@ 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.
|
||||
|
|
@ -525,10 +532,49 @@ 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,97 +11,118 @@
|
|||
};
|
||||
|
||||
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
|
||||
];
|
||||
|
||||
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
|
||||
# Native libs the audio crates link against.
|
||||
nativeAudioBuildInputs = with pkgs; [
|
||||
pipewire
|
||||
pipewire.dev
|
||||
];
|
||||
|
||||
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
|
||||
socat # poke the IPC socket
|
||||
jq # pretty-print JSON
|
||||
pipewire # for pw-cli, pw-cat, etc.
|
||||
wireplumber
|
||||
]);
|
||||
nativeBuildTools = with pkgs; [
|
||||
pkg-config
|
||||
clang
|
||||
];
|
||||
|
||||
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);
|
||||
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";
|
||||
|
||||
# `nix build` — the final packaged daemon + CLI.
|
||||
packages = rec {
|
||||
default = headroom;
|
||||
nativeBuildInputs = nativeBuildTools ++ [
|
||||
rustToolchain
|
||||
pkgs.rust-analyzer
|
||||
];
|
||||
|
||||
headroom = rustPlatform.buildRustPackage ({
|
||||
pname = "headroom";
|
||||
version = (builtins.fromTOML (builtins.readFile ./crates/headroom-cli/Cargo.toml)).package.version;
|
||||
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
|
||||
socat # poke the IPC socket
|
||||
jq # pretty-print JSON
|
||||
pipewire # for pw-cli, pw-cat, etc.
|
||||
wireplumber
|
||||
]);
|
||||
|
||||
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";
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
# Reserved for the eventual user-service module.
|
||||
# nixosModules.default = import ./nix/module.nix;
|
||||
# `nix build` — the final packaged daemon + CLI.
|
||||
packages = rec {
|
||||
default = headroom;
|
||||
|
||||
formatter = pkgs.nixpkgs-fmt;
|
||||
});
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
119
nix/home-module.nix
Normal file
119
nix/home-module.nix
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# 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" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
61
nix/nixos-module.nix
Normal file
61
nix/nixos-module.nix
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# 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