Compare commits

..

No commits in common. "716290c3bfcb114f744ab5db17921c192e2a014a" and "fcf421b94cf15f9f92e37215e150fd85e248392e" have entirely different histories.

31 changed files with 321 additions and 3964 deletions

388
Cargo.lock generated
View file

@ -11,12 +11,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anes"
version = "0.1.6"
@ -89,12 +83,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "assert_no_alloc"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ca83137a482d61d916ceb1eba52a684f98004f18e0cafea230fe5579c178a3"
[[package]]
name = "autocfg"
version = "1.5.0"
@ -140,27 +128,12 @@ version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.62"
@ -280,20 +253,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "convert_case"
version = "0.6.0"
@ -361,72 +320,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.1",
"crossterm_winapi",
"mio 1.2.0",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "darling"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dasp_frame"
version = "0.11.0"
@ -492,18 +391,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@ -618,17 +505,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@ -639,14 +515,10 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
name = "headroom-cli"
version = "0.1.0"
dependencies = [
"assert_no_alloc",
"clap",
"crossbeam-channel",
"crossterm",
"headroom-client",
"headroom-core",
"headroom-ipc",
"ratatui",
"serde_json",
"thiserror 2.0.18",
"tracing",
@ -667,7 +539,6 @@ dependencies = [
name = "headroom-core"
version = "0.1.0"
dependencies = [
"assert_no_alloc",
"bytemuck",
"criterion",
"crossbeam-channel",
@ -720,12 +591,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.14.0"
@ -733,16 +598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
"hashbrown",
]
[[package]]
@ -765,19 +621,6 @@ dependencies = [
"libc",
]
[[package]]
name = "instability"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
@ -813,15 +656,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -904,12 +738,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -925,15 +753,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "matchers"
version = "0.2.0"
@ -967,18 +786,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.27.1"
@ -1014,7 +821,7 @@ dependencies = [
"kqueue",
"libc",
"log",
"mio 0.8.11",
"mio",
"walkdir",
"windows-sys 0.48.0",
]
@ -1089,12 +896,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -1153,27 +954,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
dependencies = [
"bitflags 2.11.1",
"cassowary",
"compact_str",
"crossterm",
"instability",
"itertools 0.13.0",
"lru",
"paste",
"strum",
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1224,31 +1004,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@ -1341,17 +1096,6 @@ dependencies = [
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 1.2.0",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -1374,40 +1118,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
@ -1611,17 +1327,6 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
@ -1705,16 +1410,7 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@ -1732,29 +1428,13 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@ -1763,90 +1443,42 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"

View file

@ -37,12 +37,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# CLI
clap = { version = "4.5", features = ["derive"] }
# TUI (monitor). Pinned to versions whose transitive deps still build
# on the project's pinned rustc 1.86 (newer ratatui pulls
# `instability` 0.3.12 + `darling` 0.23 which need 1.88+).
ratatui = "=0.28.1"
crossterm = "=0.28.1"
# Concurrency / control plane
crossbeam-channel = "0.5"
parking_lot = "0.12"

137
PLAN.md
View file

@ -233,23 +233,6 @@ downsampling) guarantee the contract numerically — the envelope can
misbehave and the contract still holds. Never bypassed, never
disabled.
**Contract scope (caveat).** The ≤ 0.1 dBTP guarantee holds at the
*filter's output*, not at the speaker. The bus filter is hardcoded
F32 stereo @ 48 kHz (`headroom-dsp::limiter`'s 4× oversampler is
sized for 48 k); when the real sink negotiates a different rate
(44.1 kHz, 96 kHz, 192 kHz), PipeWire inserts a downstream
resampler between `filter.playback` and the sink. Polynomial /
windowed-sinc resamplers can elevate inter-sample peaks slightly
through their own reconstruction, so the limiter's true-peak
guarantee leaks across that resampling stage. In practice the
elevation is small (a few tenths of a dB worst case for a clean
band-limited resampler), and the contract still holds at the bus
output where headroom is in control. **For the contract to hold
end-to-end the filter would need to match the real sink's rate
and rebuild its DSP coefficients on rate-change** — that's the
v1 work tracked as PLAN §11 "filter rate matching" (deferred from
8d, gated on a multi-rate hardware test bench).
**Soft tier — the comfort cap.** Targets a *dynamic* ceiling computed
as `program_lufs + max_psr_db`. Smooth attack/release envelope so the
gain reduction sounds like volume riding, not a slap. Pulls transients
@ -848,10 +831,7 @@ builds and any CI go through `nix build`.
## 11. Phased implementation
The phases are roughly token-of-work units, not calendar weeks. **All
planned phases (08) are done as of 2026-05-21**; this section is
preserved as historical context + a reading guide to the commit log.
See [[headroom-project]] in team memory for the per-commit ledger.
The phases are roughly token-of-work units, not calendar weeks.
**Phase 0 — scaffolding.** Flake, workspace, crate skeletons, README,
PLAN/IPC docs. *(done as part of this commit)*
@ -911,31 +891,33 @@ Sub-stages used in commits / TODOs:
### Tracked follow-ups (carried past their sub-stage)
Items deliberately deferred from earlier sub-stages so they don't get
lost. Pick up by name when the trigger that gates them fires.
lost. Pick up by name when the phase that consumes them lands.
- **Ephemeral overlay mutations.** *(4e follow-up.)* All `route.set`
/ `setting.set` changes are persisted to `overlay.toml`. A
`--ephemeral` flag (or `--volatile`) on the CLI for one-shot tweaks
that don't outlive the daemon was considered and dropped from v0
for simplicity. Revisit if real users ask for it; the store-level
change is a flag on the setter methods. **Dormant** — no user has
asked through Phase 8.
- **Filter rate matching to the real sink.** *(F5 follow-up.)* §3.1
documents the contract leak when the real sink runs at a
non-48 kHz native rate. Closing it requires dynamic
`FILTER_SAMPLE_RATE`, kernel rebuild on real-sink change
(compressor + limiter coefficients are rate-dependent), and
Layer A's `LAYER_A_BLOCK_DT_S` constant becoming dynamic too.
Gated on a multi-rate hardware test bench — no point shipping
the refactor without something to validate it against. **v1 scope.**
- ~~**Filter playback BUSY spikes (periodic, ~10 s cadence).**~~
**Closed in 8e (`d52cd6d`).** The instrumentation added by 8e
did not reproduce the ~8×-baseline outlier pattern in a ~3 min
release-build capture; steady state was ~2.2 ms / call at this
hardware's quantum with max growing only to 1.3× baseline.
`PlaybackTiming` stays so future regressions surface at WARN.
Original observation may have been a transient WP/PW housekeeping
artefact under a different config; no actionable code change.
change is a flag on the setter methods.
- **Filter playback BUSY spikes (periodic, ~10 s cadence).** *(6c
manual smoke finding, 2026-05.)* On a quiet system with AGC and
per-app both off, the filter's `playback_process` BUSY
occasionally spikes from its ~240 μs steady-state to ~2.0 ms,
correlating with output-sink WAIT spikes of similar size. No
audible impact (sub-quantum at 21 ms). The ~10 s cadence rules
out sliding-max worst-case (which would be input-pattern-driven,
not periodic) and Layer A (the spikes persist with `per_app.enabled
= false`). Suspects with 10 s clocks somewhere: WirePlumber session
policy heartbeat, PipeWire internal graph re-eval, or system-level
scheduling (CPU governor, kernel housekeeping). Diagnostic for
Phase 8: timestamp the playback callback, log when its measured
duration crosses ~1 ms; correlate with `journalctl`,
`wireplumber --verbose`, and `pw-dump` snapshots taken around the
spike. If we can't attribute it to PipeWire-side reschedule and
it's something we can fix in our callback, the candidate
workaround is to break the limiter's per-block work into smaller
chunks (cap allocations / pops / branches per call) for more
predictable timing.
- **Sub-millisecond dispatch primitive for spike-reactive writes.**
*(Phase 6 optimisation, downgraded from prerequisite.)* The 4i
`PwCommand` channel uses a 50 ms polling timer, fine for
@ -1060,68 +1042,18 @@ If those three say "fine," the §4.1 promise is upheld in practice
and 6c is acceptance-tested. `jack_iodelay` and other true-round-trip
tools are overkill.
**Phase 7 — Packaging.** *Done — `c65c75b`.* `contrib/systemd/headroom.service`
(user-scope, Type=simple, After=pipewire.service, Restart=on-failure,
journald, LimitRTPRIO=20). The package's `postInstall` substitutes
the unit's `@bindir@` placeholder with an absolute store path and
copies `profiles/*.toml` to `share/headroom/profiles/`. Two Nix
modules: `nixosModules.default` (`programs.headroom.enable`
binary on global PATH + `systemd.packages` for `systemctl --user`
discovery + hard assertion on `services.pipewire.enable`) and
`homeModules.default` (`services.headroom.enable` — symlinks
shipped profiles into `$XDG_CONFIG_HOME/headroom/profiles/`,
`extraProfiles` attrset for per-user overrides, writes the systemd
user unit). README rewritten with install + usage sections.
**Phase 7 — Packaging.** systemd user unit, install paths, default
profile install, basic NixOS module.
**Phase 8 — Hardening.** *Done — `9220143` + `d52cd6d` + verification.*
- **8a — `assert_no_alloc` on audio-thread callbacks (`9220143`).**
`#[global_allocator] AllocDisabler` in `headroom-cli/src/main.rs`
behind `cfg(debug_assertions)` (release strips it via the crate's
default `disable_release`). The three RT callbacks
(`capture_process`, `playback_process`, `tap_process`) wrap their
body in `assert_no_alloc(|| inner(...))`. Verified by a deliberate
`Vec::with_capacity` injection → SIGABRT on first audio callback;
reverted before commit. Audio thread proven alloc-free under
multi-thousand-callback live load.
- **8b — live profile-reload under signal flow (verification only).**
Edit `$XDG_CONFIG_HOME/headroom/profiles/<active>.toml` while a
sine plays: notify-debouncer-mini fires, `ProfileStore::reload`
runs, `setting.set` propagates via `FilterControl`'s rtrb to the
audio thread. Compressor GR went 0 → 9.3 dB ≈ 1 s after edit
and back to 0 after restore; 180 meter ticks over 9 s with max
inter-tick gap = exact 50.0 ms (the AGC period). No glitches.
- **8c — sink hotplug / default-sink change (verification only).**
`wpctl set-default <other-sink>` while daemon runs:
`on_metadata_property` fires, `adopt_new_real_sink` runs,
filter.playback re-pinned via 4k explicit-link enforcement,
`routing/real_sink_changed` emitted on the wire. Bounces back
cleanly.
- **8d — multi-rate hardware (partial / deferred).** Filter is
hardcoded F32 stereo @ 48 kHz; PipeWire's link layer inserts a
resampler at the filter.playback → real-sink edge when rates
differ; bus DSP stays at 48 kHz internally. Architecture is
sound; real-hardware validation (USB DAC at 96k etc.) deferred
until available.
- **8e — playback callback timing instrumentation (`d52cd6d`).**
Lock-free `PlaybackTiming` atomics in `meters.rs`; AGC controller
drains once per second and logs at WARN above
`SPIKE_THRESHOLD_US = 5000`. The original ~10 s-cadence ~8×
spike pattern from §11 follow-ups *did not reproduce* in a ~3 min
release-build capture; steady state 2.2 ms / call at ~4 Hz,
max climbed to only 1.3× baseline. Instrumentation kept so
future regressions surface.
**Phase 8 — Hardening.** Latency budget verification on real hardware,
Bluetooth-handoff edge case, profile-reload while audio is flowing,
multi-rate hardware, allocation-tracer sweep with
`assert_no_alloc` in debug.
---
## 12. Risks & open questions
These are the original v0 design risks — still useful as a checklist
for new contributors. Phase 4k/4l/8c have exercised the routing /
hotplug / default-sink branches; the bullets below are unchanged
since several of them remain live concerns for non-NixOS distros
and multi-rate hardware. See [[headroom-project]] in team memory
for current status per risk.
- **WirePlumber re-linking on device hotplug.** When a Bluetooth
headset connects, WP re-evaluates linking. Headroom must re-pin its
routed streams. Tractable; the registry events surface this.
@ -1139,17 +1071,8 @@ for current status per risk.
filter should source its rate from the real sink and convert on the
capture side only.
- **Surround content downmix vs. passthrough.** v0 punts: anything
`>2ch` is force-bypassed regardless of profile rule. The bus
filter is F32 stereo by construction and pulling a 5.1+ stream
into it would either drop the centre/LFE/surround channels (with
explicit links pairing only the first two ports) or run our DSP
on a downmix that wasn't asked for. The check fires in
`routing::evaluate` based on `PwNodeInfo.audio_channels` (parsed
from the stream's `audio.channels` property). The explicit-link
pairing in `apply_pending_routes` was generalised from `take(2)`
to `take(min(src, dst))` so wide bypass to a wide real sink links
all channels; narrower sinks let PipeWire's source-side adapter
handle downmix as usual.
>2ch is routed directly to the real sink (bypass behaviour)
regardless of profile rule. Documented behaviour.
---

View file

@ -13,114 +13,24 @@ untouched.
real sink directly and are not in scope of the contract — that's the
trade-off that makes the per-app exclusion useful.
- **Per-app exclusion** with profile-driven rules.
- **Layer A per-app level control** (peak + RMS detector → smoothed
`channelVolumes` writes) for taming individual streams without
touching the bus path. Zero added signal-path latency; safe to use
on bypass-routed streams.
- **Single binary** daemon + CLI, controlled over a Unix-domain socket
with a documented JSON wire protocol (see [`IPC.md`](IPC.md)).
- **First-party Rust crate** (`headroom-client`) for programmatic use;
third-party clients (Qt panels, status bars, …) target the wire
protocol directly.
- **Live profile reload** — edit a TOML file in
`$XDG_CONFIG_HOME/headroom/profiles/` and the daemon picks up
changes within ~500 ms; the audio thread doesn't glitch.
See [`PLAN.md`](PLAN.md) for the full design and roadmap.
## Status
Alpha. The signal chain (AGC, compressor, two-tier limiter, Layer A
per-app), the routing engine (explicit-link enforcement, sink hotplug,
sticky default sink), the IPC server with topic subscriptions, the
`headroom monitor` TUI, and live profile reload all work end-to-end.
Packaging exposes a systemd user unit and Nix modules. What's missing
is real-world soak time on multi-rate / Bluetooth setups and other
distros' init systems.
## Installing
### Nix (flake)
This repo is a flake; the daemon plus its systemd user unit and the
canonical profiles are exposed as a package.
```sh
nix run github:amaanq/headroom -- daemon # one-shot run
nix profile install github:amaanq/headroom # add to $PATH
```
For **Home Manager**, add the flake as an input and enable the module:
```nix
{
inputs.headroom.url = "github:amaanq/headroom";
# In your Home Manager configuration:
imports = [ inputs.headroom.homeModules.default ];
services.headroom.enable = true;
}
```
The module symlinks the shipped profiles into
`$XDG_CONFIG_HOME/headroom/profiles/`, drops the systemd user unit
into the user's services dir, and the unit starts after PipeWire and
WirePlumber come up. `services.headroom.extraProfiles` lets you add
your own.
For **NixOS** (system-wide binary install + systemd-user discovery):
```nix
{
inputs.headroom.url = "github:amaanq/headroom";
# In your NixOS configuration:
imports = [ inputs.headroom.nixosModules.default ];
programs.headroom.enable = true;
}
```
Then any user can `systemctl --user enable --now headroom`.
### Other distros (manual)
```sh
cargo install --path crates/headroom-cli # or: cargo build --release
# Profiles
mkdir -p ~/.config/headroom/profiles
cp profiles/*.toml ~/.config/headroom/profiles/
# systemd user unit (edit the ExecStart path to point at your binary)
install -Dm644 contrib/systemd/headroom.service \
~/.config/systemd/user/headroom.service
sed -i "s|@bindir@|$(dirname "$(command -v headroom)")|" \
~/.config/systemd/user/headroom.service
systemctl --user daemon-reload
systemctl --user enable --now headroom
```
## Usage
Once the daemon is running:
```sh
headroom status # JSON snapshot — sinks, streams, active profile
headroom profile list # available profiles
headroom profile use night # activate one
headroom monitor # full-screen TUI (bus gauges + per-stream)
headroom monitor --json meters # line-delimited JSON, for scripting
headroom route set firefox processed
headroom set compressor.threshold_db -28
headroom bypass on # kill switch — straight to the real sink
```
See `headroom --help` for the full surface.
Pre-alpha. Wire protocol and crate scaffolding are in; daemon and
filter are under construction.
## Building
```sh
nix develop # toolchain + pipewire dev libs + helpers
cargo build # iterate
cargo test --workspace
nix build # final packaged headroom binary
```

View file

@ -1,39 +0,0 @@
[Unit]
Description=Headroom audio daemon (PipeWire AGC + compressor + true-peak limiter)
Documentation=https://github.com/amaanq/headroom
# PipeWire is a hard dependency: headroom registers a virtual sink and
# wires explicit links via PW's link-factory, so we can't start before
# pw-mainloop is up. ConditionUser ensures this only ever runs as a
# user-scope unit, never accidentally as the system instance.
After=pipewire.service pipewire-pulse.service wireplumber.service
Requires=pipewire.service
Wants=wireplumber.service
ConditionUser=!@system
[Service]
Type=simple
ExecStart=@bindir@/headroom daemon
# Restart on failure but not too aggressively — a tight crash loop
# would just produce a lot of stderr noise and clobber the user's
# routing repeatedly.
Restart=on-failure
RestartSec=2s
# Headroom doesn't fork; SIGTERM is the clean shutdown path. The
# default KillMode=control-group is correct for a single-process
# daemon; explicit here for clarity.
KillMode=control-group
TimeoutStopSec=5s
# Surface stdout/stderr to journald so `journalctl --user -u headroom`
# shows daemon logs with the expected RUST_LOG filtering.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=headroom
# Realtime hint — pipewire grants RT scheduling via pw_thread_loop,
# but the daemon main thread benefits from a slight scheduling boost
# too. LimitRTPRIO matches the pipewire user unit's grant.
LimitRTPRIO=20
LimitRTTIME=200000
LimitNICE=-11
[Install]
WantedBy=pipewire.service

View file

@ -18,11 +18,7 @@ headroom-client = { workspace = true }
headroom-core = { workspace = true }
headroom-ipc = { workspace = true }
assert_no_alloc = { workspace = true }
clap = { workspace = true }
crossbeam-channel = { workspace = true }
crossterm = { workspace = true }
ratatui = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View file

@ -6,24 +6,12 @@
#![forbid(unsafe_code)]
mod tui;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use headroom_client::{Client, ClientError, Route, Topic};
// Wrap the system allocator so audio-thread `assert_no_alloc` blocks
// in headroom-core can detect any allocation. Debug-only — in
// release builds `assert_no_alloc`'s default `disable_release`
// feature strips both `AllocDisabler` and the `assert_no_alloc(||
// ...)` wrappers to no-ops, so there's zero overhead in production
// (and the symbol doesn't even exist to reference here).
#[cfg(debug_assertions)]
#[global_allocator]
static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
/// Headroom CLI.
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
@ -78,19 +66,12 @@ enum Cmd {
/// Reload profile files from disk.
Reload,
/// Live monitor. Defaults to a full-screen TUI; `--json` falls back
/// to the line-delimited JSON stream that previous versions
/// produced (useful for scripting and tests).
/// Subscribe to event topics and print as line-delimited JSON.
Monitor {
/// Topics to subscribe to (comma-separated). Only honoured with
/// `--json`; the TUI always subscribes to all four event topics.
/// Topics to subscribe to (comma-separated).
/// Defaults to `meters` if none given.
#[arg(value_delimiter = ',', default_value = "meters")]
topics: Vec<MonitorTopic>,
/// Emit one JSON event per line on stdout instead of drawing
/// the TUI.
#[arg(long)]
json: bool,
},
}
@ -189,28 +170,13 @@ fn init_tracing() {
fn run() -> Result<(), CliError> {
let cli = Cli::parse();
// TUI takes over the terminal; don't let `tracing` scribble on top
// of it. The JSON-mode monitor also benefits from a quieter stderr.
let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. });
if !tui_mode {
init_tracing();
}
init_tracing();
match cli.cmd {
Cmd::Daemon => {
headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?;
Ok(())
}
Cmd::Monitor { json: false, .. } => {
// Connect on the main thread so the initial `status` /
// `route.list` round-trips happen before we enter raw mode.
let client = match cli.socket.as_deref() {
Some(p) => Client::connect_at(p)?,
None => Client::connect()?,
};
tui::run(client).map_err(CliError::Tui)
}
cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
}
}
@ -281,23 +247,18 @@ fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
let reloaded = client.profile_reload()?;
println!("reloaded: {reloaded:?}");
}
Cmd::Monitor { topics, json } => {
if json {
let pw_topics: Vec<Topic> =
topics.iter().copied().map(Topic::from).collect();
client.subscribe(&pw_topics)?;
loop {
let ev = client.next_event()?;
println!(
"{} {}/{} {}",
chrono_like_now(),
ev.topic,
ev.event,
serde_json::to_string(&ev.data)?,
);
}
} else {
unreachable!("TUI monitor is dispatched before `with_client`")
Cmd::Monitor { topics } => {
let pw_topics: Vec<Topic> = topics.iter().copied().map(Topic::from).collect();
client.subscribe(&pw_topics)?;
loop {
let ev = client.next_event()?;
println!(
"{} {}/{} {}",
chrono_like_now(),
ev.topic,
ev.event,
serde_json::to_string(&ev.data)?,
);
}
}
}
@ -315,9 +276,6 @@ enum CliError {
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("tui: {0}")]
Tui(tui::TuiError),
#[error("{0}")]
Other(String),
}

View file

@ -1,810 +0,0 @@
//! `headroom monitor` TUI. Subscribes to `meters`, `routing`,
//! `profile`, and `daemon`, renders bus DSP gauges + loudness +
//! per-stream routing + status header.
//!
//! Architecture: the main thread owns the terminal and the draw loop.
//! A reader thread owns the `Client` and forwards each subscription
//! event over a crossbeam channel. On quit the main thread restores
//! the terminal and exits; the reader thread is reaped by the OS.
//! (A CLI binary doesn't need a graceful reader shutdown — the kernel
//! tears the UnixStream down on process exit.)
use std::collections::BTreeMap;
use std::io;
use std::thread;
use std::time::{Duration, Instant};
use crossbeam_channel::{select, tick, unbounded, Receiver};
use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use headroom_client::{Client, ClientError};
use headroom_ipc::{
DaemonEvent, Event, LayerALevel, MeterTick, ProfileEvent, Route, RoutingEvent, Status,
StreamRoute, Topic,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table},
Frame, Terminal,
};
/// Errors specific to the TUI subcommand.
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("client: {0}")]
Client(#[from] ClientError),
#[error("terminal: {0}")]
Io(#[from] io::Error),
}
/// Entry point — owns the connected client through initial RPCs, then
/// hands it off to the reader thread and enters the draw loop.
pub fn run(mut client: Client) -> Result<(), TuiError> {
// Subscribe + initial state, all on the main thread before the
// terminal goes into raw mode. Any error here bubbles cleanly.
let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
client.subscribe(&topics)?;
let status = client.status()?;
let route_list = client.route_list()?;
// Spawn reader.
let (tx, rx) = unbounded::<Msg>();
let reader_handle = thread::Builder::new()
.name("headroom-monitor-rx".into())
.spawn(move || reader_loop(client, tx))
.map_err(TuiError::Io)?;
// Terminal up.
let mut terminal = ratatui::init();
let outcome = draw_loop(&mut terminal, status, route_list, rx);
ratatui::restore();
// Detach the reader: process exit (or the dropped channel) will
// tear the connection down. We don't need its result.
drop(reader_handle);
outcome
}
// ---------------------------------------------------------------------------
// Reader thread
// ---------------------------------------------------------------------------
enum Msg {
Event(Event),
Disconnected(String),
}
fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
loop {
match client.next_event() {
Ok(ev) => {
if tx.send(Msg::Event(ev)).is_err() {
return;
}
}
Err(e) => {
let _ = tx.send(Msg::Disconnected(e.to_string()));
return;
}
}
}
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
struct UiState {
daemon_version: String,
profile: String,
bypass: bool,
/// Daemon uptime as of connect, plus our local elapsed.
base_uptime_s: u64,
connected_at: Instant,
default_route: Route,
streams: BTreeMap<u32, StreamRoute>,
/// Per-stream Layer A state. Presence = tap attached; the inner
/// `Option<f32>` is the latest smoothed reduction in dB (None
/// until the first `meters/layer_a_level` event arrives).
layer_a: BTreeMap<u32, Option<f32>>,
meters: Option<MeterTick>,
/// Wall-clock instant the last meter tick arrived. Used to show
/// staleness if the audio thread stops feeding the AGC.
last_meter_at: Option<Instant>,
overflow_total: u64,
last_error: Option<String>,
disconnected: Option<String>,
}
impl UiState {
fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
let mut streams = BTreeMap::new();
for s in route_list.current {
streams.insert(s.node_id, s);
}
// Streams reported on `status` superset; merge.
for s in status.streams.iter() {
streams.entry(s.node_id).or_insert_with(|| s.clone());
}
Self {
daemon_version: status.version,
profile: status.profile,
bypass: status.bypass,
base_uptime_s: status.uptime_s,
connected_at: Instant::now(),
default_route: route_list.default_route,
streams,
layer_a: BTreeMap::new(),
meters: None,
last_meter_at: None,
overflow_total: 0,
last_error: None,
disconnected: None,
}
}
fn uptime_s(&self) -> u64 {
self.base_uptime_s
.saturating_add(self.connected_at.elapsed().as_secs())
}
fn apply_event(&mut self, ev: Event) {
match ev.topic {
Topic::Meters if ev.event == "tick" => {
if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
self.meters = Some(m);
self.last_meter_at = Some(Instant::now());
}
}
Topic::Meters if ev.event == "layer_a_level" => {
if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
self.layer_a.insert(l.node_id, Some(l.reduction_db));
}
}
Topic::Routing => {
if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
match re {
RoutingEvent::StreamRouted { node_id, app, to } => {
self.streams.insert(
node_id,
StreamRoute {
node_id,
app,
route: to,
},
);
}
RoutingEvent::StreamRemoved { node_id } => {
self.streams.remove(&node_id);
self.layer_a.remove(&node_id);
}
RoutingEvent::LayerAAttached { node_id, .. } => {
// Mark managed; reduction unknown until the
// first `layer_a_level` event lands.
self.layer_a.entry(node_id).or_insert(None);
}
RoutingEvent::LayerADetached { node_id } => {
self.layer_a.remove(&node_id);
}
RoutingEvent::RuleChanged => { /* TUI doesn't display rules */ }
_ => {}
}
}
}
Topic::Profile => {
if let Ok(ProfileEvent::Changed { name, .. }) =
serde_json::from_value::<ProfileEvent>(profile_payload(&ev))
{
self.profile = name;
}
}
Topic::Daemon => {
if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
match de {
DaemonEvent::Overflow {
lost, total_lost, ..
} => {
self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
}
DaemonEvent::Error { code, message } => {
self.last_error = Some(format!("{code}: {message}"));
}
DaemonEvent::Shutdown => {
self.disconnected = Some("daemon shutdown".into());
}
DaemonEvent::Started { version } => {
self.daemon_version = version;
}
_ => {}
}
}
}
_ => {}
}
}
}
/// The wire frame carries `{event, topic, data}` — the typed enum lives
/// inside `data` but is `#[serde(tag = "event")]`, so we re-inject the
/// event name to make serde happy. Same dance for the other topics.
fn routing_payload(ev: &Event) -> serde_json::Value {
inject_event(&ev.event, &ev.data)
}
fn profile_payload(ev: &Event) -> serde_json::Value {
inject_event(&ev.event, &ev.data)
}
fn daemon_payload(ev: &Event) -> serde_json::Value {
inject_event(&ev.event, &ev.data)
}
fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
let mut obj = match data {
serde_json::Value::Object(m) => m.clone(),
_ => serde_json::Map::new(),
};
obj.insert("event".into(), serde_json::Value::String(event.to_string()));
serde_json::Value::Object(obj)
}
// ---------------------------------------------------------------------------
// Draw loop
// ---------------------------------------------------------------------------
fn draw_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
status: Status,
route_list: headroom_ipc::RouteList,
rx: Receiver<Msg>,
) -> Result<(), TuiError> {
let mut state = UiState::new(status, route_list);
// 10 Hz redraw floor so uptime + staleness counters tick even when
// there are no events flowing.
let ticker = tick(Duration::from_millis(100));
let input_rx = spawn_input_thread();
loop {
terminal.draw(|f| draw(f, &state))?;
select! {
recv(rx) -> msg => match msg {
Ok(Msg::Event(ev)) => state.apply_event(ev),
Ok(Msg::Disconnected(reason)) => {
state.disconnected = Some(reason);
// Final paint, then linger briefly so the user sees
// the disconnected banner.
terminal.draw(|f| draw(f, &state))?;
thread::sleep(Duration::from_millis(800));
return Ok(());
}
Err(_) => return Ok(()),
},
recv(input_rx) -> msg => match msg {
Ok(InputMsg::Quit) => return Ok(()),
Ok(InputMsg::Other) => {}
Err(_) => return Ok(()),
},
recv(ticker) -> _ => {}
}
}
}
// ---------------------------------------------------------------------------
// Input thread
// ---------------------------------------------------------------------------
enum InputMsg {
Quit,
Other,
}
fn spawn_input_thread() -> Receiver<InputMsg> {
let (tx, rx) = unbounded::<InputMsg>();
thread::Builder::new()
.name("headroom-monitor-input".into())
.spawn(move || loop {
// Block on the next terminal event; crossterm's read() is
// a blocking syscall against stdin.
let Ok(ev) = event::read() else { return };
let msg = match ev {
CtEvent::Key(k) if is_quit(&k) => InputMsg::Quit,
CtEvent::Key(_) | CtEvent::Resize(_, _) => InputMsg::Other,
_ => continue,
};
if tx.send(msg).is_err() {
return;
}
})
.expect("spawn input thread");
rx
}
fn is_quit(k: &KeyEvent) -> bool {
matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
|| (k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
}
// ---------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------
fn draw(f: &mut Frame, state: &UiState) {
let area = f.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(Span::styled(
" headroom monitor ",
Style::default().add_modifier(Modifier::BOLD),
))
.title_top(Line::from(header_status(state)).right_aligned())
.title_bottom(Line::from(footer_text(state)).right_aligned());
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), // bus gauges
Constraint::Length(5), // loudness
Constraint::Min(4), // streams table
])
.split(inner);
draw_bus(f, chunks[0], state);
draw_loudness(f, chunks[1], state);
draw_streams(f, chunks[2], state);
}
fn header_status(state: &UiState) -> Vec<Span<'static>> {
let bypass_span = if state.bypass {
Span::styled(
" BYPASS ",
Style::default().fg(Color::Black).bg(Color::Yellow),
)
} else {
Span::styled(" processed ", Style::default().fg(Color::Green))
};
vec![
Span::raw(" profile: "),
Span::styled(state.profile.clone(), Style::default().bold()),
Span::raw(" "),
bypass_span,
Span::raw(format!(
" v{} uptime {} ",
state.daemon_version,
fmt_uptime(state.uptime_s())
)),
]
}
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
let mut parts: Vec<Span> = vec![
Span::raw(" q/Esc/Ctrl-C quit "),
Span::styled("·", Style::default().fg(Color::DarkGray)),
Span::raw(" subscribed: meters routing profile daemon "),
];
if state.overflow_total > 0 {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!(" dropped: {} ", state.overflow_total),
Style::default().fg(Color::Yellow),
));
}
if let Some(err) = &state.last_error {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!(" daemon error: {err} "),
Style::default().fg(Color::Red),
));
}
if let Some(reason) = &state.disconnected {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!(" disconnected: {reason} "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
parts
}
fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" bus dsp ");
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let m = state.meters;
draw_gauge_row(
f,
rows[0],
GaugeRow {
label: "AGC target",
value: m.map(|t| t.agc_gain_db),
min: -12.0,
max: 12.0,
unit: "dB",
color: Color::Cyan,
},
);
draw_gauge_row(
f,
rows[1],
GaugeRow {
label: "Compressor GR",
value: m.map(|t| t.compressor_gr_db),
min: -24.0,
max: 0.0,
unit: "dB",
color: Color::Magenta,
},
);
draw_gauge_row(
f,
rows[2],
GaugeRow {
label: "Limiter GR",
value: m.map(|t| t.limiter_gr_db),
min: -24.0,
max: 0.0,
unit: "dB",
color: Color::Red,
},
);
draw_gauge_row(
f,
rows[3],
GaugeRow {
label: "True peak",
value: m.map(|t| t.true_peak_dbtp),
min: -60.0,
max: 3.0,
unit: "dBTP",
color: Color::Green,
},
);
}
struct GaugeRow<'a> {
label: &'a str,
value: Option<f32>,
min: f32,
max: f32,
unit: &'a str,
color: Color,
}
/// One labeled gauge row: `LABEL VALUE [████░░░░] min..max`.
fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
let GaugeRow {
label,
value,
min,
max,
unit,
color,
} = row;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(16),
Constraint::Length(14),
Constraint::Min(8),
Constraint::Length(14),
])
.split(area);
f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
let value_str = value
.map(|v| format!("{v:+7.2} {unit}"))
.unwrap_or_else(|| " -- ".to_string());
f.render_widget(
Paragraph::new(value_str).alignment(Alignment::Right),
cols[1],
);
let pct = match value {
Some(v) => {
let clamped = v.clamp(min, max);
((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
}
None => 0.0,
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(color))
.ratio(pct)
.label("");
f.render_widget(gauge, cols[2]);
f.render_widget(
Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
cols[3],
);
}
fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" loudness (BS.1770) ");
let inner = block.inner(area);
f.render_widget(block, area);
let staleness = state
.last_meter_at
.map(|t| t.elapsed())
.unwrap_or(Duration::ZERO);
let stale = staleness > Duration::from_millis(500);
let (mom, st, intg) = match state.meters {
Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
None => (None, None, None),
};
let lines = vec![
lufs_line("Momentary (400 ms)", mom, stale),
lufs_line("Short-term (3 s)", st, stale),
lufs_line("Integrated (gated)", intg, stale),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
let val = match v {
Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
format!("{x:+7.2} LUFS")
}
Some(_) => " -- LUFS".to_string(),
None => " -- LUFS".to_string(),
};
let style = if stale {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
Line::from(vec![
Span::raw(format!(" {label:<24}")),
Span::styled(val, style),
])
}
fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
let title = format!(
" streams ({}) — default: {} ",
state.streams.len(),
state.default_route
);
let block = Block::default().borders(Borders::ALL).title(title);
let header = Row::new(vec!["node", "app", "route", "layer A"])
.style(Style::default().add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state
.streams
.values()
.map(|s| {
let route_cell = match s.route {
Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
};
let la_cell = match state.layer_a.get(&s.node_id) {
Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
.style(Style::default().fg(Color::Magenta)),
Some(None) => Cell::from("attached")
.style(Style::default().fg(Color::DarkGray)),
None => Cell::from("").style(Style::default().fg(Color::DarkGray)),
};
Row::new(vec![
Cell::from(s.node_id.to_string()),
Cell::from(s.app.clone()),
route_cell,
la_cell,
])
})
.collect();
let widths = [
Constraint::Length(8),
Constraint::Min(20),
Constraint::Length(12),
Constraint::Length(10),
];
let table = Table::new(rows, widths).header(header).block(block);
f.render_widget(table, area);
}
fn fmt_uptime(s: u64) -> String {
let h = s / 3600;
let m = (s % 3600) / 60;
let sec = s % 60;
if h > 0 {
format!("{h}h{m:02}m{sec:02}s")
} else if m > 0 {
format!("{m}m{sec:02}s")
} else {
format!("{sec}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
use headroom_ipc::{Sinks, Status};
fn empty_state() -> UiState {
let status = Status {
version: "test".into(),
protocol: 1,
uptime_s: 0,
profile: "default".into(),
bypass: false,
sinks: Sinks::default(),
streams: vec![],
warnings: vec![],
};
let route_list = headroom_ipc::RouteList {
rules: vec![],
current: vec![],
default_route: Route::Processed,
};
UiState::new(status, route_list)
}
#[test]
fn meter_tick_event_updates_state() {
let mut state = empty_state();
let tick = MeterTick {
momentary_lufs: -19.3,
shortterm_lufs: -20.1,
integrated_lufs: -19.8,
true_peak_dbtp: -1.4,
gain_reduction_db: -2.1,
compressor_gr_db: -0.8,
limiter_gr_db: -1.3,
agc_gain_db: 0.5,
};
let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
state.apply_event(ev);
let got = state.meters.expect("meters set");
assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
assert!(state.last_meter_at.is_some());
}
#[test]
fn stream_removed_prunes_state() {
let mut state = empty_state();
// Insert via stream_routed first.
state.apply_event(
Event::new(
Topic::Routing,
"stream_routed",
&serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
)
.unwrap(),
);
state.apply_event(
Event::new(
Topic::Routing,
"layer_a_attached",
&serde_json::json!({ "node_id": 7, "app": "x" }),
)
.unwrap(),
);
assert!(state.streams.contains_key(&7));
assert!(state.layer_a.contains_key(&7));
state.apply_event(
Event::new(
Topic::Routing,
"stream_removed",
&serde_json::json!({ "node_id": 7 }),
)
.unwrap(),
);
assert!(!state.streams.contains_key(&7));
assert!(!state.layer_a.contains_key(&7));
}
#[test]
fn layer_a_level_updates_reduction() {
let mut state = empty_state();
state.apply_event(
Event::new(
Topic::Routing,
"layer_a_attached",
&serde_json::json!({ "node_id": 11, "app": "loud-app" }),
)
.unwrap(),
);
assert_eq!(state.layer_a.get(&11), Some(&None));
state.apply_event(
Event::new(
Topic::Meters,
"layer_a_level",
&serde_json::json!({
"node_id": 11,
"app": "loud-app",
"volume_lin": 0.256_f32,
"reduction_db": -11.8_f32,
}),
)
.unwrap(),
);
let r = state.layer_a.get(&11).copied().flatten().unwrap();
assert!((r - -11.8).abs() < 1e-4);
}
#[test]
fn routing_event_inserts_stream() {
let mut state = empty_state();
let ev = Event::new(
Topic::Routing,
"stream_routed",
&serde_json::json!({
"node_id": 42,
"app": "firefox",
"to": "bypass",
}),
)
.unwrap();
state.apply_event(ev);
let s = state.streams.get(&42).expect("stream tracked");
assert_eq!(s.app, "firefox");
assert_eq!(s.route, Route::Bypass);
}
#[test]
fn profile_changed_updates_active() {
let mut state = empty_state();
let ev = Event::new(
Topic::Profile,
"changed",
&serde_json::json!({
"name": "night",
"previous": "default",
}),
)
.unwrap();
state.apply_event(ev);
assert_eq!(state.profile, "night");
}
#[test]
fn daemon_overflow_accumulates() {
let mut state = empty_state();
let ev = Event::new(
Topic::Daemon,
"overflow",
&serde_json::json!({
"lost_topic": "meters",
"lost": 3u32,
"total_lost": 5u64,
}),
)
.unwrap();
state.apply_event(ev);
assert_eq!(state.overflow_total, 5);
}
#[test]
fn fmt_uptime_buckets() {
assert_eq!(fmt_uptime(5), "5s");
assert_eq!(fmt_uptime(75), "1m15s");
assert_eq!(fmt_uptime(3725), "1h02m05s");
}
}

View file

@ -12,6 +12,7 @@ authors.workspace = true
[dependencies]
headroom-dsp = { workspace = true }
headroom-ipc = { workspace = true }
headroom-client = { workspace = true } # test-only: integration tests
serde = { workspace = true }
serde_json = { workspace = true }
@ -43,20 +44,11 @@ notify-debouncer-mini = { workspace = true }
# Slow AGC loop (Phase 4 closing piece).
ebur128 = { workspace = true }
# Audio-thread allocation guard. In debug builds the `AllocDisabler`
# global allocator panics if anything inside an `assert_no_alloc!`
# block tries to allocate; in release builds the macro is a no-op
# (zero overhead). Wraps each audio-thread `process` callback.
assert_no_alloc = { workspace = true }
# Optional journald logging — not wired yet.
# tracing-journald = { workspace = true }
[dev-dependencies]
criterion = { workspace = true }
# Only used in `ipc::server::tests` to round-trip a real client
# against the spawned IPC server.
headroom-client = { workspace = true }
[features]
default = []

View file

@ -16,9 +16,7 @@
use std::time::Duration;
use ebur128::{EbuR128, Mode};
use headroom_ipc::{Event, MeterTick, Topic};
use crate::meters::SharedBusMetrics;
use crate::pw::filter::FilterControl;
use crate::state::SharedState;
@ -32,10 +30,8 @@ const TICK_BUF_SAMPLES: usize = 8192;
/// Loudness floor we treat as "no usable measurement yet" — returned
/// by `ebur128` before its short-term window has filled, or during
/// digital silence. Published as-is in `MeterTick.*_lufs` fields, so
/// clients can use this constant to recognise "no measurement" without
/// hard-coding the number.
pub const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
/// digital silence.
const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
/// Slow AGC controller.
pub struct AgcController {
@ -54,23 +50,8 @@ pub struct AgcController {
/// enable flag exactly when it changes.
last_enabled: bool,
/// Last short-term loudness observed; surfaced for status /
/// `meters` topic.
/// meters in a future sub-stage.
last_short_term_lufs: f32,
/// Bus-level DSP snapshot written by the filter's playback
/// callback. Used to fill the `MeterTick` payload published on
/// `Topic::Meters`.
bus_metrics: SharedBusMetrics,
/// Tick counter for `publish_hz` throttling. Wraps freely.
meter_tick_counter: u32,
/// Playback callback timing stats. Sampled and logged once per
/// second to surface BUSY-spike behaviour and general callback
/// health.
timing: crate::meters::SharedPlaybackTiming,
/// Last `spike_count` value we observed, used to detect *new*
/// spikes since the previous log.
last_logged_spike_count: u64,
/// Tick counter for the once-per-second timing log throttle.
timing_log_counter: u32,
}
impl AgcController {
@ -85,19 +66,9 @@ impl AgcController {
measurement_consumer: rtrb::Consumer<f32>,
filter_control: FilterControl,
daemon: SharedState,
bus_metrics: SharedBusMetrics,
timing: crate::meters::SharedPlaybackTiming,
) -> Result<Self, AgcInitError> {
// `Mode::I` (integrated, gated) costs a histogram walk per
// `loudness_global()` call — bounded, fine at 20 Hz meter
// cadence. Added so the `meters` topic can surface integrated
// LUFS without a second ebur128 instance.
let ebu = EbuR128::new(
channels,
sample_rate,
Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK,
)
.map_err(AgcInitError::from)?;
let ebu = EbuR128::new(channels, sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK)
.map_err(AgcInitError::from)?;
Ok(Self {
sample_rate,
channels,
@ -108,11 +79,6 @@ impl AgcController {
smoothed_target_db: 0.0,
last_enabled: true,
last_short_term_lufs: LOUDNESS_FLOOR_LUFS,
bus_metrics,
meter_tick_counter: 0,
timing,
last_logged_spike_count: 0,
timing_log_counter: 0,
})
}
@ -133,107 +99,26 @@ impl AgcController {
/// One control-loop iteration. Should be invoked at [`AGC_TICK`]
/// cadence by a main-loop timer source.
///
/// Three things happen here:
///
/// 1. AGC enable/disable transition is observed and pushed to
/// the audio thread.
/// 2. The measurement ring is drained into `ebur128` and the
/// short-term loudness is cached. This runs **regardless of
/// AGC enabled** so the `meters` topic can keep surfacing LUFS
/// when the user has only enabled the compressor / limiter.
/// 3. If AGC is enabled, a smoothed target gain is computed and
/// pushed to the audio thread.
/// 4. Bus-level meters are published on `Topic::Meters` honouring
/// `profile.meters.publish_hz`.
pub fn tick(&mut self) {
// Snapshot what we need out from under the daemon lock. Hold
// the lock only long enough to clone the small config.
let (cfg, publish_hz) = {
// Snapshot the AGC section out from under the daemon lock.
// Hold the lock only long enough to clone the small config.
let cfg = {
let s = self.daemon.lock();
let p = s.profiles.effective();
(p.agc.clone(), p.meters.publish_hz)
s.profiles.effective().agc.clone()
};
// React to enable/disable transitions before doing measurement
// work — flipping off should stop pushing target updates and
// tell the audio thread to unwind back to 0 dB.
if cfg.enabled != self.last_enabled {
self.filter_control.set_agc_enabled(cfg.enabled);
self.last_enabled = cfg.enabled;
}
// Drain the measurement ring + feed ebur128 unconditionally.
self.consume_measurements();
let short_term = finite_or_floor(
self.ebu.loudness_shortterm().map(|v| v as f32).ok(),
);
self.last_short_term_lufs = short_term;
if cfg.enabled
&& short_term > cfg.silence_threshold_lufs
&& short_term.is_finite()
{
let raw_target = cfg.target_lufs - short_term;
let clamped = raw_target.clamp(-cfg.max_cut_db, cfg.max_boost_db);
// Slow leaky-integrator smoother on the tick cadence.
// attack when target is dropping (gain reduction toward
// the signal), release when target is rising back toward
// unity / boost.
let dt_ms = AGC_TICK.as_secs_f32() * 1000.0;
let alpha = if clamped < self.smoothed_target_db {
alpha_for_dt(cfg.attack_ms, dt_ms)
} else {
alpha_for_dt(cfg.release_ms, dt_ms)
};
self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db);
self.filter_control
.set_agc_target_db(self.smoothed_target_db);
}
self.publish_meters(publish_hz);
self.log_playback_timing();
}
/// Throttled log of the playback callback's rolling timing stats.
/// Fires roughly once per second at the AGC's 20 Hz tick rate.
/// Cheap (lock-free atomic loads); useful for surfacing BUSY
/// spikes without per-call log noise.
fn log_playback_timing(&mut self) {
// 20 Hz tick → log every 20 ticks for ~1 Hz cadence.
self.timing_log_counter = self.timing_log_counter.wrapping_add(1);
if self.timing_log_counter % 20 != 0 {
if !cfg.enabled {
return;
}
let snap = self.timing.snapshot();
if snap.call_count == 0 {
return;
}
let avg_us = snap.sum_us / snap.call_count.max(1);
let new_spikes = snap.spike_count.saturating_sub(self.last_logged_spike_count);
self.last_logged_spike_count = snap.spike_count;
if new_spikes > 0 {
tracing::warn!(
avg_us,
max_us = snap.max_us,
new_spikes,
total_spikes = snap.spike_count,
last_spike_us = snap.last_spike_us,
last_spike_at_call = snap.last_spike_at_call,
call_count = snap.call_count,
"playback callback BUSY spike(s) since last log"
);
} else {
tracing::debug!(
avg_us,
max_us = snap.max_us,
call_count = snap.call_count,
"playback callback timing"
);
}
}
/// Drain up to [`TICK_BUF_SAMPLES`] from the measurement ring and
/// feed them through `ebur128`.
fn consume_measurements(&mut self) {
// Drain up to TICK_BUF_SAMPLES from the measurement ring.
let mut buf = [0.0_f32; TICK_BUF_SAMPLES];
let mut n = 0;
while n < buf.len() {
@ -246,7 +131,7 @@ impl AgcController {
}
}
if n == 0 {
return;
return; // No samples yet (early boot or silence); leave target alone.
}
// ebur128 wants whole frames; drop any odd trailing sample.
let usable = (n / self.channels as usize) * self.channels as usize;
@ -255,61 +140,39 @@ impl AgcController {
}
if let Err(e) = self.ebu.add_frames_f32(&buf[..usable]) {
tracing::warn!(error = %e, "ebur128 add_frames_f32 failed");
}
}
/// Publish a `MeterTick` event on `Topic::Meters` if this tick
/// falls on the `publish_hz` cadence.
fn publish_meters(&mut self, publish_hz: f32) {
if !self.should_publish(publish_hz) {
return;
}
let bus = *self.bus_metrics.lock();
// `ebur128` returns `-inf` (not `Err`) for "no useful
// measurement yet" — typically early-boot or while the input
// is pure silence. `-inf` can't survive JSON serialisation
// (serde_json renders non-finite f32 as null), so floor here.
let momentary = finite_or_floor(
self.ebu.loudness_momentary().map(|v| v as f32).ok(),
);
let integrated = finite_or_floor(
self.ebu.loudness_global().map(|v| v as f32).ok(),
);
let tick = MeterTick {
momentary_lufs: momentary,
shortterm_lufs: self.last_short_term_lufs,
integrated_lufs: integrated,
true_peak_dbtp: bus.true_peak_dbtp,
// Total path GR is additive in log domain. Both values
// are ≤ 0 dB when reducing.
gain_reduction_db: bus.compressor_gr_db + bus.limiter_total_gr_db,
compressor_gr_db: bus.compressor_gr_db,
limiter_gr_db: bus.limiter_total_gr_db,
agc_gain_db: self.smoothed_target_db,
let short_term = self
.ebu
.loudness_shortterm()
.map(|v| v as f32)
.unwrap_or(LOUDNESS_FLOOR_LUFS);
self.last_short_term_lufs = short_term;
// Silence gate: if the program is below the threshold, hold
// the current target. This avoids ramping gain up during
// legitimate quiet passages.
if short_term <= cfg.silence_threshold_lufs || !short_term.is_finite() {
return;
}
let raw_target = cfg.target_lufs - short_term;
let clamped = raw_target.clamp(-cfg.max_cut_db, cfg.max_boost_db);
// Slow leaky-integrator smoother on the tick cadence. attack
// when target is dropping (gain reduction toward the signal),
// release when target is rising back toward unity / boost.
let dt_ms = AGC_TICK.as_secs_f32() * 1000.0;
let alpha = if clamped < self.smoothed_target_db {
alpha_for_dt(cfg.attack_ms, dt_ms)
} else {
alpha_for_dt(cfg.release_ms, dt_ms)
};
self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db);
if let Ok(event) = Event::new(Topic::Meters, "tick", &tick) {
self.daemon.lock().broadcaster.publish(Topic::Meters, event);
}
}
/// Tick-rate gate for the `meters` publish loop. Caps at
/// [`AGC_TICK`]'s native rate (20 Hz) — `publish_hz` above that is
/// silently clamped.
fn should_publish(&mut self, publish_hz: f32) -> bool {
if publish_hz <= 0.0 {
return false;
}
let agc_hz = 1000.0 / AGC_TICK.as_millis() as f32;
if publish_hz >= agc_hz {
self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1);
return true;
}
let skip = (agc_hz / publish_hz).round().max(1.0) as u32;
let now = self.meter_tick_counter;
self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1);
now % skip == 0
self.filter_control
.set_agc_target_db(self.smoothed_target_db);
}
/// Reset the smoothed target and the underlying `ebur128` state.
@ -318,50 +181,14 @@ impl AgcController {
pub fn reset(&mut self) {
self.smoothed_target_db = 0.0;
self.last_short_term_lufs = LOUDNESS_FLOOR_LUFS;
// ebur128 doesn't expose a public reset, so rebuild it. Keep
// the same mode set used in `new()` so meter publishing stays
// consistent.
if let Ok(fresh) = EbuR128::new(
self.channels,
self.sample_rate,
Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK,
) {
// ebur128 doesn't expose a public reset, so rebuild it.
if let Ok(fresh) =
EbuR128::new(self.channels, self.sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK)
{
self.ebu = fresh;
}
self.filter_control.set_agc_target_db(0.0);
}
/// Rebind the controller to a freshly-built filter (Phase C of
/// the filter rate-matching work). The old `measurement_consumer`
/// and `filter_control` point at rtrbs whose producers were just
/// dropped — every send on them would now fail — so we swap in
/// the new bundle's handles and rebuild `ebur128` at the new
/// sample rate. Resets the smoother + the LUFS sentinel so the
/// controller starts clean on the new audio path; the brief
/// post-rebuild silence (~50100 ms of dropped audio) is
/// inaudible compared to the rate-change event itself.
pub fn rebind(
&mut self,
measurement_consumer: rtrb::Consumer<f32>,
filter_control: FilterControl,
sample_rate: u32,
) {
self.measurement_consumer = measurement_consumer;
self.filter_control = filter_control;
self.sample_rate = sample_rate;
self.reset();
}
}
/// Coerce a possibly-non-finite LUFS measurement into a finite value
/// suitable for serialisation. `-inf` (the `ebur128` "no usable
/// reading" sentinel) and `NaN` both collapse to
/// [`LOUDNESS_FLOOR_LUFS`].
fn finite_or_floor(v: Option<f32>) -> f32 {
match v {
Some(x) if x.is_finite() => x,
_ => LOUDNESS_FLOOR_LUFS,
}
}
/// `tau_ms`-time-constant leaky-integrator alpha for a tick of
@ -392,7 +219,6 @@ impl From<AgcInitError> for crate::error::DaemonError {
#[cfg(test)]
mod tests {
use super::*;
use crate::meters;
use crate::profile_store::ProfileStore;
use crate::pw::filter::{AudioCmd, FilterControl};
use crate::state::{self, DaemonState};
@ -406,24 +232,12 @@ mod tests {
rtrb::Producer<f32>,
rtrb::Consumer<AudioCmd>,
SharedState,
SharedBusMetrics,
) {
let (m_prod, m_cons) = RingBuffer::<f32>::new(8192);
let (control, cmd_cons) = FilterControl::for_testing(32);
let state = state::shared(DaemonState::new(ProfileStore::builtin()));
let bus = meters::shared();
let timing = meters::shared_timing();
let agc = AgcController::new(
SR,
CH,
m_cons,
control,
state.clone(),
bus.clone(),
timing,
)
.unwrap();
(agc, m_prod, cmd_cons, state, bus)
let agc = AgcController::new(SR, CH, m_cons, control, state.clone()).unwrap();
(agc, m_prod, cmd_cons, state)
}
fn push_silence(prod: &mut rtrb::Producer<f32>, frames: usize) {
@ -444,7 +258,7 @@ mod tests {
#[test]
fn tick_with_no_samples_does_nothing() {
let (mut agc, _prod, mut cmd_cons, _state, _bus) = fixture();
let (mut agc, _prod, mut cmd_cons, _state) = fixture();
agc.tick();
assert!(cmd_cons.pop().is_err(), "no samples → no target push");
assert_eq!(agc.current_target_db(), 0.0);
@ -452,7 +266,7 @@ mod tests {
#[test]
fn tick_under_silence_threshold_holds_target() {
let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
let (mut agc, mut prod, mut cmd_cons, _state) = fixture();
push_silence(&mut prod, 4800); // 100ms of silence
agc.tick();
// ebur128 may report -inf or values below the silence
@ -465,7 +279,7 @@ mod tests {
#[test]
fn tick_with_audible_signal_pushes_target() {
let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
let (mut agc, mut prod, mut cmd_cons, _state) = fixture();
// Pump multiple ticks worth so ebur128's short-term window
// (~3 s) starts producing values.
for _ in 0..40 {
@ -485,7 +299,7 @@ mod tests {
#[test]
fn agc_disable_in_profile_flips_audio_thread() {
let (mut agc, _prod, mut cmd_cons, state, _bus) = fixture();
let (mut agc, _prod, mut cmd_cons, state) = fixture();
// First tick with the default-enabled profile.
agc.tick();
// Drain any commands.

View file

@ -369,7 +369,7 @@ mod tests {
// Immediately after the write, force a different reduction —
// the rate limit must suppress any further write within 100 ms.
let t1 = c.last_write_at.unwrap() + Duration::from_millis(10);
c.smoothed_reduction_db += 6.0; // synthetic kick
c.smoothed_reduction_db = c.smoothed_reduction_db + 6.0; // synthetic kick
let v = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t1);
assert!(v.is_none(), "rate limit should have blocked the follow-up write");
}

View file

@ -66,11 +66,6 @@ fn status(id: u64, state: &SharedState) -> Response {
node_id: s.processed_sink_id,
name: Some(crate::pw::sink::NODE_NAME.to_owned()),
ready: s.processed_sink_id.is_some(),
// The processed sink advertises whatever rate the
// filter is currently running at (rate-matched to
// the real sink). `None` only during very early
// boot before `Filter::create` lands.
sample_rate: s.filter_sample_rate,
},
real: s.real_sink.clone(),
},
@ -198,7 +193,6 @@ fn profile_use(id: u64, name: &str, state: &SharedState) -> Response {
publish_profile_changed(&mut s, name);
let control = s.filter_control.clone();
let snap = build_dsp_configs(&s);
post_reevaluate(&s);
drop(s);
push_dsp_update(control.as_ref(), snap);
ok(id, &json!({ "name": name }))
@ -240,7 +234,6 @@ pub(crate) fn execute_reload(
publish_profile_reloaded(&mut s, &report.loaded);
let control = s.filter_control.clone();
let snap = build_dsp_configs(&s);
post_reevaluate(&s);
drop(s);
push_dsp_update(control.as_ref(), snap);
Ok(report)
@ -252,7 +245,6 @@ fn route_set(id: u64, app: &str, to: Route, state: &SharedState) -> Response {
Ok(()) => {
tracing::info!(app, ?to, "route.set applied");
publish_rule_changed(&mut s);
post_reevaluate(&s);
drop(s);
ok(id, &Value::Null)
}
@ -266,7 +258,6 @@ fn route_unset(id: u64, app: &str, state: &SharedState) -> Response {
Ok(()) => {
tracing::info!(app, "route.unset applied");
publish_rule_changed(&mut s);
post_reevaluate(&s);
drop(s);
ok(id, &Value::Null)
}
@ -280,25 +271,6 @@ fn publish_rule_changed(state: &mut crate::state::DaemonState) {
}
}
/// Ask the PipeWire main loop to re-run `routing::evaluate` against
/// every known stream. Called after any IPC mutation that changes
/// the inputs to that decision: active profile, profile contents
/// reloaded from disk, or a `route.set` / `route.unset` overlay
/// edit. Without this, the new policy only applies to *future*
/// streams; everything already routed keeps its old links until the
/// app reconnects. A stale or duplicate post is harmless — the
/// handler reads current state at apply time and is idempotent
/// when nothing changed.
fn post_reevaluate(state: &crate::state::DaemonState) {
let Some(tx) = state.pw_command_tx.as_ref() else {
tracing::debug!("no PipeWire command channel; reevaluation skipped (test mode)");
return;
};
if tx.send(PwCommand::ReevaluateAll).is_err() {
tracing::warn!("PipeWire command channel closed; reevaluation lost");
}
}
fn publish_profile_changed(state: &mut crate::state::DaemonState, name: &str) {
if let Ok(event) = Event::new(Topic::Profile, "used", &json!({ "name": name })) {
state.broadcaster.publish(Topic::Profile, event);
@ -384,22 +356,7 @@ fn bypass_set(id: u64, enabled: bool, state: &SharedState) -> Response {
match s.profiles.set_bypass(enabled) {
Ok(()) => {
tracing::info!(enabled, "bypass.set applied");
let tx = s.pw_command_tx.clone();
drop(s);
// Make bypass an actual graph operation, not just a
// metadata flag. The registry thread re-runs
// `routing::evaluate` against every known stream (which
// now returns Route::Bypass under bypass_global=true),
// tears down the explicit links to the processed sink,
// and rebuilds them to the real sink. The
// `reassert_default_processed` path is also gated on
// bypass, so WP's choice of system default sticks for
// any apps that route to "default."
if let Some(tx) = tx {
if tx.send(PwCommand::ReevaluateAll).is_err() {
tracing::warn!("PipeWire command channel closed; bypass toggle had no graph effect");
}
}
ok(id, &Value::Null)
}
Err(e) => store_err_to_response(id, e),
@ -548,7 +505,7 @@ mod tests {
assert!(
body.get("warnings")
.and_then(|w| w.as_array())
.is_none_or(|a| a.is_empty()),
.map_or(true, |a| a.is_empty()),
"expected empty/absent warnings on healthy startup"
);
}
@ -1092,10 +1049,7 @@ mod tests {
node_id,
to,
app_label,
} = cmd
else {
panic!("expected RouteStream, got {cmd:?}");
};
} = cmd;
assert_eq!(node_id, 42);
assert_eq!(to, Route::Bypass);
assert_eq!(app_label, "firefox");

View file

@ -17,7 +17,6 @@ pub mod agc;
pub mod app_level;
pub mod error;
pub mod ipc;
pub mod meters;
pub mod profile;
pub mod profile_store;
pub mod profile_watcher;

View file

@ -1,192 +0,0 @@
//! Bus-level meter snapshot shared between the audio thread and the
//! AGC controller.
//!
//! Phase 4g.
//!
//! The audio thread writes [`BusMetrics`] after each
//! `playback_process` call using `try_lock` — it must never block on
//! the lock. The AGC controller reads on its 50 ms tick, combines
//! with `ebur128` readings (momentary / short-term / integrated
//! LUFS) and the current AGC gain target, and publishes a
//! [`headroom_ipc::MeterTick`] on `Topic::Meters` for any IPC client
//! that's subscribed.
//!
//! Per-app meter events (Phase 6e) are a separate stream emitted
//! directly from the registry watcher. The two coexist on the same
//! topic; clients see both kinds and key off the event payload shape
//! to tell them apart.
//!
//! Wait-free on the audio side: a missed write (lock contended for
//! the few nanoseconds the reader holds it) is harmless — the next
//! quantum overwrites the slot. Dropped meter samples don't degrade
//! the AGC; the controller reads the freshest available snapshot.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use parking_lot::Mutex;
/// Snapshot of bus-level DSP metrics, written by the audio thread
/// after the AGC → Compressor → Limiter chain runs.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BusMetrics {
/// Compressor gain reduction in dB (negative when reducing).
pub compressor_gr_db: f32,
/// Limiter total gain reduction in dB (the min of soft and hard
/// gain, in dB).
pub limiter_total_gr_db: f32,
/// Limiter soft-tier gain reduction in dB.
pub limiter_soft_gr_db: f32,
/// Limiter hard-tier gain reduction in dB. Non-zero only when
/// the soft tier wasn't enough — that's the alarm condition.
pub limiter_hard_gr_db: f32,
/// True peak in dBTP observed by the limiter's per-quantum peak
/// detector. Bounded above by the hard ceiling on the *output*;
/// this field is the peak the limiter *saw on its input*, which
/// is informative for tuning soft-tier headroom.
pub true_peak_dbtp: f32,
}
/// Cheap-to-clone shared handle. Audio thread + AGC controller each
/// hold a clone.
pub type SharedBusMetrics = Arc<Mutex<BusMetrics>>;
/// Construct an empty shared metrics handle.
#[must_use]
pub fn shared() -> SharedBusMetrics {
Arc::new(Mutex::new(BusMetrics::default()))
}
/// Rolling timing stats for the bus filter's `playback_process`
/// callback. Updated from the audio thread via lock-free atomics,
/// read (and reset) by the AGC controller's slow tick. Used to
/// detect the ~10 s-cadence BUSY spikes mentioned in PLAN §11
/// follow-ups, and (longer-term) as a general health signal — if
/// `playback_us_max` creeps up over the run, something downstream
/// is unhappy.
#[derive(Debug, Default)]
pub struct PlaybackTiming {
/// Number of playback_process invocations.
pub call_count: AtomicU64,
/// Cumulative duration in microseconds.
pub sum_us: AtomicU64,
/// Max duration observed in microseconds across all calls.
pub max_us: AtomicU64,
/// Number of calls whose duration exceeded the spike threshold.
pub spike_count: AtomicU64,
/// Duration of the most recent spike in microseconds.
pub last_spike_us: AtomicU64,
/// `call_count` snapshot when the most recent spike fired (so a
/// reader can detect "no new spike since last read" by comparing
/// against its previous snapshot).
pub last_spike_at_call: AtomicU64,
}
impl PlaybackTiming {
/// Threshold above which a call is counted as a "spike".
///
/// The steady-state cost of the playback callback scales with
/// the PipeWire quantum: on a 1024-frame quantum it runs in
/// ~240 μs (PLAN §4.7); on the 8192-frame quantum the Mbox
/// negotiates here it sits around ~2.2 ms in release builds.
/// 5 ms is comfortably above both regimes and only fires on
/// real outliers (the ~10 s-cadence "BUSY" spike PLAN §11
/// chases would have to be ~2× steady-state at any quantum to
/// trip this).
pub const SPIKE_THRESHOLD_US: u64 = 5_000;
/// Record one observation. Wait-free.
#[inline]
pub fn record(&self, dur_us: u64) {
self.call_count.fetch_add(1, Ordering::Relaxed);
self.sum_us.fetch_add(dur_us, Ordering::Relaxed);
let mut cur_max = self.max_us.load(Ordering::Relaxed);
while dur_us > cur_max {
match self.max_us.compare_exchange_weak(
cur_max,
dur_us,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(v) => cur_max = v,
}
}
if dur_us > Self::SPIKE_THRESHOLD_US {
let count = self.call_count.load(Ordering::Relaxed);
self.spike_count.fetch_add(1, Ordering::Relaxed);
self.last_spike_us.store(dur_us, Ordering::Relaxed);
self.last_spike_at_call.store(count, Ordering::Relaxed);
}
}
/// Take a snapshot of current counters. Doesn't reset.
pub fn snapshot(&self) -> PlaybackTimingSnapshot {
PlaybackTimingSnapshot {
call_count: self.call_count.load(Ordering::Relaxed),
sum_us: self.sum_us.load(Ordering::Relaxed),
max_us: self.max_us.load(Ordering::Relaxed),
spike_count: self.spike_count.load(Ordering::Relaxed),
last_spike_us: self.last_spike_us.load(Ordering::Relaxed),
last_spike_at_call: self.last_spike_at_call.load(Ordering::Relaxed),
}
}
}
/// Plain-old-data snapshot of [`PlaybackTiming`] for the controller's
/// per-tick logging.
#[derive(Debug, Default, Clone, Copy)]
pub struct PlaybackTimingSnapshot {
/// Cumulative call count.
pub call_count: u64,
/// Cumulative duration in microseconds.
pub sum_us: u64,
/// Max single-call duration in microseconds observed so far.
pub max_us: u64,
/// Cumulative count of calls above the spike threshold.
pub spike_count: u64,
/// Duration of the most recent spike in microseconds.
pub last_spike_us: u64,
/// `call_count` when the most recent spike fired.
pub last_spike_at_call: u64,
}
/// Cheap-to-clone shared handle for [`PlaybackTiming`].
pub type SharedPlaybackTiming = Arc<PlaybackTiming>;
/// Construct an empty shared timing handle.
#[must_use]
pub fn shared_timing() -> SharedPlaybackTiming {
Arc::new(PlaybackTiming::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_zero() {
let m = BusMetrics::default();
assert_eq!(m.compressor_gr_db, 0.0);
assert_eq!(m.limiter_total_gr_db, 0.0);
assert_eq!(m.limiter_soft_gr_db, 0.0);
assert_eq!(m.limiter_hard_gr_db, 0.0);
assert_eq!(m.true_peak_dbtp, 0.0);
}
#[test]
fn shared_is_cheap_to_clone() {
let a = shared();
let b = a.clone();
*a.lock() = BusMetrics {
compressor_gr_db: -3.0,
limiter_total_gr_db: -1.0,
limiter_soft_gr_db: -1.0,
limiter_hard_gr_db: 0.0,
true_peak_dbtp: -0.5,
};
let snap = *b.lock();
assert!((snap.compressor_gr_db - -3.0).abs() < 1e-6);
assert!((snap.true_peak_dbtp - -0.5).abs() < 1e-6);
}
}

View file

@ -150,7 +150,6 @@ impl Profile {
MakeupGain::Db(v) => Some(v),
};
CompressorConfig {
enabled: self.compressor.enabled,
threshold_db: self.compressor.threshold_db,
ratio: self.compressor.ratio,
knee_db: self.compressor.knee_db,

View file

@ -637,56 +637,37 @@ fn materialize_skipping(
}
fn apply_route_overrides(profile: &mut Profile, overrides: &BTreeMap<String, Route>) {
// Prepend two rules per overlay entry so the match catches
// whichever identity field the stream actually advertises.
//
// Why two rules and not one with both fields set? The matcher
// ANDs across non-empty fields, so a rule with both
// `process_binary` *and* `application_name` populated would
// only match a stream that has *both* properties set to the
// same string. Many CLI tools (pw-cat being the canonical
// case, plus various Electron / Flatpak wrappers) only set
// `application.name` and leave `application.process.binary`
// unset — they'd miss the AND-shape rule despite the user's
// clear intent.
//
// Two single-field rules with the same route effectively form
// an OR across the identity fields. PipeWire iterates rules in
// order and returns on first match, so emitting both is cheap
// (constant per override) and correct in either case.
//
// No retain pre-pass: `materialize` is stateless (it
// serializes the base profile fresh from `pick_base` each
// call), so overlay rules can't accumulate across consecutive
// `set_route` calls. A retain pre-pass would only deduplicate
// rules whose *base profile* TOML coincidentally has the same
// shape — silently removing a user-authored rule that was
// never an overlay artefact. The prepended order means
// overlay rules win first-match iteration over any genuinely
// duplicate base-profile rule anyway, so no correctness gain;
// dropping the retain closes the data-loss surface Codex
// flagged in its audit of the route.set match-by-name change.
let mut new_rules: Vec<RouteRule> = Vec::with_capacity(overrides.len() * 2);
for (app, route) in overrides {
new_rules.push(RouteRule {
// Drop any existing single-app user rule matching an override, then
// prepend the overrides as one rule per app at the top of the list.
let override_apps: std::collections::HashSet<&String> = overrides.keys().collect();
profile
.rules
.retain(|r| !is_single_app_rule_for_any(r, &override_apps));
let mut new_rules: Vec<RouteRule> = overrides
.iter()
.map(|(app, route)| RouteRule {
match_: RouteRuleMatch {
process_binary: vec![app.clone()],
..Default::default()
},
route: *route,
});
new_rules.push(RouteRule {
match_: RouteRuleMatch {
application_name: vec![app.clone()],
..Default::default()
},
route: *route,
});
}
})
.collect();
new_rules.extend(std::mem::take(&mut profile.rules));
profile.rules = new_rules;
}
fn is_single_app_rule_for_any(
rule: &RouteRule,
apps: &std::collections::HashSet<&String>,
) -> bool {
rule.match_.process_binary.len() == 1
&& apps.contains(&rule.match_.process_binary[0])
&& rule.match_.application_name.is_empty()
&& rule.match_.portal_app_id.is_empty()
&& rule.match_.media_role.is_empty()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -959,96 +940,6 @@ mod tests {
assert_eq!(rule.route, Route::Bypass);
}
#[test]
fn set_route_emits_both_process_binary_and_application_name_rules() {
// The route.set CLI verb accepts a single app identifier
// but streams can advertise themselves via either
// `application.process.binary` or `application.name`
// (or neither — those go through default_route). The
// overlay materialises BOTH single-field rules so each
// possible identity field is covered.
let (paths, _g) = tmp_paths();
let mut s = ProfileStore::load(&paths).unwrap();
s.set_route("pw-cat", Route::Bypass).unwrap();
let rules = &s.effective().rules;
let proc_rule = rules
.iter()
.find(|r| r.match_.process_binary == vec!["pw-cat".to_string()])
.expect("process_binary rule");
assert_eq!(proc_rule.route, Route::Bypass);
assert!(proc_rule.match_.application_name.is_empty());
let name_rule = rules
.iter()
.find(|r| r.match_.application_name == vec!["pw-cat".to_string()])
.expect("application_name rule");
assert_eq!(name_rule.route, Route::Bypass);
assert!(name_rule.match_.process_binary.is_empty());
}
#[test]
fn set_route_then_unset_leaves_no_residual_rules() {
// Both the process_binary and application_name variants
// of a single-app override must clear on unset; otherwise
// a re-add would stack rules and the matcher would carry
// dead entries indefinitely.
let (paths, _g) = tmp_paths();
let mut s = ProfileStore::load(&paths).unwrap();
s.set_route("pw-cat", Route::Bypass).unwrap();
s.unset_route("pw-cat").unwrap();
let residual: Vec<_> = s
.effective()
.rules
.iter()
.filter(|r| {
r.match_.process_binary == vec!["pw-cat".to_string()]
|| r.match_.application_name == vec!["pw-cat".to_string()]
})
.collect();
assert!(residual.is_empty(), "leftover override rules: {residual:#?}");
}
#[test]
fn user_rule_with_overlay_shape_survives_set_route_for_same_app() {
// Regression for Codex audit Q5: an earlier retain pre-pass in
// `apply_route_overrides` would silently drop any base-profile
// rule whose single-field shape coincided with the overlay's
// emit pattern. The fix is to delete the retain entirely —
// prepending already makes the overlay win first-match
// iteration, and removing the retain closes the data-loss
// surface. This test pins the surviving-rule behaviour so a
// future refactor can't quietly reintroduce the prune.
let (paths, _g) = tmp_paths();
fs::write(
paths.config_dir.join("profiles/custom.toml"),
r#"
name = "custom"
description = "user custom"
default_route = { route = "processed" }
[[rules]]
match = { process_binary = ["obs"] }
route = "processed"
"#,
)
.unwrap();
let mut s = ProfileStore::load(&paths).unwrap();
s.use_profile("custom").unwrap();
// Sanity: user rule is loaded once.
assert_eq!(s.effective().rules.len(), 1);
s.set_route("obs", Route::Bypass).unwrap();
let rules = &s.effective().rules;
// Two overlay rules (process_binary + application_name) plus
// the preserved user rule.
assert_eq!(rules.len(), 3, "rules: {rules:#?}");
assert_eq!(rules[0].route, Route::Bypass);
assert_eq!(rules[0].match_.process_binary, vec!["obs".to_string()]);
assert_eq!(rules[1].route, Route::Bypass);
assert_eq!(rules[1].match_.application_name, vec!["obs".to_string()]);
assert_eq!(rules[2].route, Route::Processed);
assert_eq!(rules[2].match_.process_binary, vec!["obs".to_string()]);
}
#[test]
fn set_route_updates_existing_override() {
let (paths, _g) = tmp_paths();

View file

@ -53,24 +53,4 @@ pub enum PwCommand {
/// Cached app label for log lines / events.
app_label: String,
},
/// Re-run `routing::evaluate` against every known stream and
/// enqueue routes where the decision changed since last time.
/// Posted by IPC handlers that mutate routing inputs — global
/// bypass toggle (F1), profile.use / profile.reload / route.set
/// / route.unset (F2). The handler reads current state (bypass,
/// effective profile, real sink) at apply time, not at post
/// time, so a stale command is harmless.
ReevaluateAll,
/// Rebuild the bus filter at a new sample rate. Posted when
/// the real sink's Format-param listener detects a rate that
/// doesn't match what the filter is currently running at —
/// either at cold boot (ALSA sinks only publish their rate
/// via Format, not in their props dict, so the initial filter
/// is created at the fallback rate before the Format event
/// fires) or on a sink hot-swap that changed the rate.
/// Causes a ~50100 ms audio dropout during the swap.
RebuildFilter {
/// New filter sample rate in Hz.
sample_rate: u32,
},
}

View file

@ -53,26 +53,13 @@ use headroom_dsp::{
};
use crate::error::DaemonError;
use crate::meters::{BusMetrics, SharedBusMetrics, SharedPlaybackTiming};
use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
/// Sample rate the filter operates at. The DSP kernels are
/// constructed for this rate; if PipeWire negotiates a different
/// rate the filter logs a warning and the DSP may sound slightly off
/// in time-based parameters until Phase 4 wires rate updates.
/// Sample rate the filter uses when no real sink is yet known
/// (cold boot, or `default.audio.sink` hasn't resolved). The
/// runtime overrides this via [`Filter::create`]'s `sample_rate`
/// argument once a real-sink rate is captured from the registry.
/// 48 kHz matches the PipeWire graph default; nothing else is
/// load-bearing at this number.
pub const DEFAULT_SAMPLE_RATE: u32 = 48_000;
/// Backward-compatibility alias for the old const name. Internal
/// callers should take the rate as a parameter; this exists so
/// out-of-tree code (`headroom-core` doc readers, downstream
/// experiments) doesn't break on the rename.
pub const FILTER_SAMPLE_RATE: u32 = DEFAULT_SAMPLE_RATE;
pub const FILTER_SAMPLE_RATE: u32 = 48_000;
/// Number of channels the filter operates on (stereo only in v0).
pub const CHANNELS: u32 = 2;
@ -228,16 +215,6 @@ struct PlaybackState {
samples_starved: u64,
/// Counter of measurement samples dropped (best-effort push).
measurement_dropped: u64,
/// Bus-level meter snapshot shared with the AGC controller for
/// meter publication. Audio thread does `try_lock` and skips on
/// contention (which is vanishingly rare — the reader holds the
/// lock for nanoseconds).
bus_metrics: SharedBusMetrics,
/// Lock-free rolling timing stats for the playback callback.
/// Used by 8e to investigate the ~10 s-cadence BUSY spikes
/// noted in PLAN §11 follow-ups, and as a general health
/// signal going forward.
timing: SharedPlaybackTiming,
}
/// The filter pipeline.
@ -275,18 +252,6 @@ pub struct FilterBundle {
/// Consumer end of the AGC measurement ring. Hand to the
/// `headroom-core::agc` controller.
pub measurement_consumer: Consumer<f32>,
/// Bus-level meter snapshot. The audio thread keeps it fresh on
/// every `playback_process` call; the AGC controller reads it on
/// each tick and publishes a `MeterTick` event.
pub bus_metrics: SharedBusMetrics,
/// Playback callback timing stats. Updated lock-free from the
/// audio thread; sampled by the AGC controller's slow tick.
pub timing: SharedPlaybackTiming,
/// The sample rate the filter is running at — read from the
/// real sink at construction time, or [`DEFAULT_SAMPLE_RATE`]
/// if no real sink was known yet. Callers (runtime,
/// AgcController) need it to size their own state.
pub sample_rate: u32,
}
impl Filter {
@ -302,11 +267,7 @@ impl Filter {
/// # Errors
/// [`DaemonError::PipeWire`] if stream creation or connection
/// fails.
pub fn create(
core: &Core,
init: FilterInit,
sample_rate: u32,
) -> Result<FilterBundle, DaemonError> {
pub fn create(core: &Core, init: FilterInit) -> Result<FilterBundle, DaemonError> {
let (producer, consumer) = RingBuffer::<f32>::new(RING_CAPACITY);
let (cmd_producer, cmd_consumer) = RingBuffer::<AudioCmd>::new(CMD_RING_CAPACITY);
let (measurement_producer, measurement_consumer) =
@ -314,18 +275,10 @@ impl Filter {
let control = FilterControl {
cmd_producer: Arc::new(Mutex::new(cmd_producer)),
};
let bus_metrics = crate::meters::shared();
let timing = crate::meters::shared_timing();
// The limiter's `sanitized()` caps the *internal* (post-
// oversample) rate, so a 96 kHz base + the default 4×
// oversample auto-drops to 2× → 192 kHz internal rather
// than 384 kHz. Keeps the FIR cost bounded as we follow
// higher real-sink rates.
let limiter_cfg = init.limiter.sanitize_for_rate(sample_rate as f32);
let compressor = Compressor::new(init.compressor, sample_rate as f32);
let limiter = Limiter::new(limiter_cfg, sample_rate as f32);
let mut agc = AgcGain::new(init.agc, sample_rate as f32);
let compressor = Compressor::new(init.compressor, FILTER_SAMPLE_RATE as f32);
let limiter = Limiter::new(init.limiter, FILTER_SAMPLE_RATE as f32);
let mut agc = AgcGain::new(init.agc, FILTER_SAMPLE_RATE as f32);
agc.set_enabled(init.agc_enabled);
let capture = build_capture_stream(core)?;
@ -349,17 +302,15 @@ impl Filter {
limiter,
samples_starved: 0,
measurement_dropped: 0,
bus_metrics: bus_metrics.clone(),
timing: timing.clone(),
})
.process(playback_process)
.register()
.map_err(|e| DaemonError::pipewire(format!("playback register: {e}")))?;
// One format POD, two connects. Both streams want the same
// interpretation (F32LE stereo at `sample_rate`) and the
// interpretation (F32LE stereo at FILTER_SAMPLE_RATE) and the
// POD bytes live on this stack for the duration of both calls.
let format_bytes = build_format_pod_bytes(sample_rate)?;
let format_bytes = build_format_pod_bytes()?;
let format_pod =
Pod::from_bytes(&format_bytes).ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?;
@ -384,7 +335,7 @@ impl Filter {
.map_err(|e| DaemonError::pipewire(format!("playback connect: {e}")))?;
tracing::info!(
sample_rate,
sample_rate = FILTER_SAMPLE_RATE,
channels = CHANNELS,
ring_capacity = RING_CAPACITY,
"filter streams created and connected"
@ -399,9 +350,6 @@ impl Filter {
},
control,
measurement_consumer,
bus_metrics,
timing,
sample_rate,
})
}
}
@ -447,12 +395,12 @@ fn build_playback_stream(core: &Core) -> Result<Stream, DaemonError> {
.map_err(|e| DaemonError::pipewire(format!("playback Stream::new: {e}")))
}
/// Serialize our preferred audio format (F32LE stereo at the
/// runtime-supplied `sample_rate`) into a SPA POD byte buffer.
fn build_format_pod_bytes(sample_rate: u32) -> Result<Vec<u8>, DaemonError> {
/// Serialize our preferred audio format (F32LE stereo at
/// [`FILTER_SAMPLE_RATE`]) into a SPA POD byte buffer.
fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(sample_rate);
info.set_rate(FILTER_SAMPLE_RATE);
info.set_channels(CHANNELS);
let obj = Object {
@ -467,14 +415,8 @@ fn build_format_pod_bytes(sample_rate: u32) -> Result<Vec<u8>, DaemonError> {
Ok(bytes)
}
/// Capture process callback. Realtime-thread, allocation-free —
/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds
/// so any inadvertent allocation aborts immediately.
/// Capture process callback. Realtime-thread, allocation-free.
fn capture_process(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) {
assert_no_alloc::assert_no_alloc(|| capture_process_inner(stream, state));
}
fn capture_process_inner(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) {
let Some(mut buffer) = stream.dequeue_buffer() else {
return; // Out of buffers; pipewire is queueing for us.
};
@ -565,19 +507,8 @@ fn drain_audio_commands(state: &mut PlaybackState) {
}
}
/// Playback process callback. Realtime-thread, allocation-free —
/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds.
/// Wraps the inner with an Instant timer; the duration is recorded
/// into [`PlaybackTiming`] (lock-free atomics, no allocation), and
/// the AGC controller drains the stats on its 50 ms tick.
/// Playback process callback. Realtime-thread, allocation-free.
fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) {
let start = std::time::Instant::now();
assert_no_alloc::assert_no_alloc(|| playback_process_inner(stream, state));
let dur_us = start.elapsed().as_micros() as u64;
state.timing.record(dur_us);
}
fn playback_process_inner(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) {
drain_audio_commands(state);
let Some(mut buffer) = stream.dequeue_buffer() else {
@ -646,21 +577,6 @@ fn playback_process_inner(stream: &pipewire::stream::StreamRef, state: &mut Play
.saturating_add((starved_frames * CHANNELS as usize) as u64);
}
// Snapshot bus-level meter state for the AGC controller. `try_lock`
// so we never block on a daemon-thread reader; a contended quantum
// simply drops this update — the next one along will land.
if produced_frames > 0 {
if let Some(mut metrics) = state.bus_metrics.try_lock() {
*metrics = BusMetrics {
compressor_gr_db: state.compressor.gain_reduction_db(),
limiter_total_gr_db: state.limiter.gain_reduction_db(),
limiter_soft_gr_db: state.limiter.soft_gain_reduction_db(),
limiter_hard_gr_db: state.limiter.hard_gain_reduction_db(),
true_peak_dbtp: state.limiter.true_peak_dbtp(),
};
}
}
// Tell PipeWire how much we wrote.
let chunk = data.chunk_mut();
*chunk.size_mut() = (max_frames * stride_bytes) as u32;
@ -734,11 +650,8 @@ mod tests {
let mut compressor = Compressor::new(CompressorConfig::default(), SR);
let mut limiter = Limiter::new(LimiterConfig::default(), SR);
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
let bad = LimiterConfig {
// structural; can't apply in place
oversample: 8,
..LimiterConfig::default()
};
let mut bad = LimiterConfig::default();
bad.oversample = 8; // structural; can't apply in place
// Should not panic, should not change the limiter.
apply_audio_cmd(
AudioCmd::SetLimiter(bad),

View file

@ -157,15 +157,6 @@ impl PwContext {
&self.core
}
/// Borrow the routing state's `Rc<RefCell<RoutingState>>`, if
/// the routing engine has been started. Lets `runtime` install
/// the filter-rebuild handles after `start_routing` without
/// having to thread them through that method's signature.
#[must_use]
pub fn routing_state(&self) -> Option<Rc<RefCell<crate::pw::registry::RoutingState>>> {
self.routing.borrow().as_ref().map(|w| w.state().clone())
}
/// Create `headroom-processed` and do a roundtrip to confirm it
/// landed on the server.
///

File diff suppressed because it is too large Load diff

View file

@ -234,12 +234,7 @@ fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
/// Audio-thread `process` callback. Allocation-free, bounded by the
/// block length. Computes `peak` and `mean_sq` over the interleaved
/// samples and pushes one [`MeasurementSample`] to the controller.
/// Guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds.
fn tap_process(stream: &pipewire::stream::StreamRef, state: &mut TapState) {
assert_no_alloc::assert_no_alloc(|| tap_process_inner(stream, state));
}
fn tap_process_inner(stream: &pipewire::stream::StreamRef, state: &mut TapState) {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};

View file

@ -34,15 +34,6 @@ pub struct PwNodeInfo {
/// `node.dont-move` — if set true, the stream opted out of being
/// rerouted. Honoured by skipping routing entirely.
pub dont_move: bool,
/// `audio.channels` — the stream's declared channel count.
/// `None` if the property is absent (older PipeWire / odd
/// clients). Used to force `>2ch` streams onto the bypass
/// path: the bus filter is stereo-only by construction, so
/// pulling a 5.1 stream into the processed sink would either
/// drop four channels (if we used explicit links naively) or
/// produce a downmix that wasn't asked for. Either way the
/// safer default is "leave surround alone."
pub audio_channels: Option<u32>,
}
impl PwNodeInfo {
@ -66,34 +57,13 @@ pub enum RoutingDecision {
/// Evaluate a stream against the profile's routing rules.
///
/// Returns [`RoutingDecision::Skip`] if the stream isn't a routable
/// playback stream. When `bypass_global` is true, every routable
/// stream gets [`Route::Bypass`] regardless of rule match — the
/// global kill switch overrides everything. Otherwise returns the
/// first-match route, or the profile's `default_route` if no rule
/// matches.
/// playback stream. Otherwise returns the first-match route, or the
/// profile's `default_route` if no rule matches.
#[must_use]
pub fn evaluate(info: &PwNodeInfo, profile: &Profile, bypass_global: bool) -> RoutingDecision {
pub fn evaluate(info: &PwNodeInfo, profile: &Profile) -> RoutingDecision {
if !info.is_routable_playback() {
return RoutingDecision::Skip;
}
// Global bypass: nothing reaches the processed sink. Implemented
// as a real graph operation (4k explicit links to the real sink)
// rather than just a metadata write — see PwCommand::ReevaluateAll
// and `set_global_bypass` in the registry.
if bypass_global {
return RoutingDecision::Route(Route::Bypass);
}
// Force-bypass anything wider than stereo. PLAN §3's surround
// contract: the bus filter is F32 stereo by construction, so
// pulling a 5.1+ stream into `headroom-processed` either drops
// channels (with explicit links) or produces an unrequested
// downmix (if WP's adapter gets involved). Routing it straight
// to the real sink preserves the user's intended layout. If
// the real sink isn't 5.1-capable PipeWire's source-side
// adapter handles the downmix — that's its job, not ours.
if matches!(info.audio_channels, Some(ch) if ch > 2) {
return RoutingDecision::Route(Route::Bypass);
}
for rule in &profile.rules {
if matches(info, &rule.match_) {
return RoutingDecision::Route(rule.route);
@ -143,7 +113,7 @@ mod tests {
let mut info = playback("firefox");
info.media_class = Some("Stream/Input/Audio".into());
let profile = Profile::default_v0();
assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
}
#[test]
@ -151,72 +121,7 @@ mod tests {
let mut info = playback("firefox");
info.dont_move = true;
let profile = Profile::default_v0();
assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
}
#[test]
fn surround_streams_force_bypass_regardless_of_rule_match() {
// The default profile routes `firefox` to processed. A 5.1
// firefox stream (rare but valid — some browser content
// declares surround) must still bypass: the bus filter is
// stereo-only and the explicit-link path would otherwise
// drop FC/LFE/SL/SR. PLAN §3 surround contract.
let mut info = playback("firefox");
info.audio_channels = Some(6);
let profile = Profile::default_v0();
assert_eq!(
evaluate(&info, &profile, false),
RoutingDecision::Route(Route::Bypass)
);
}
#[test]
fn stereo_and_mono_streams_follow_normal_rules() {
// Sanity: the surround forcer only kicks in for >2ch.
let profile = Profile::default_v0();
for ch in [None, Some(1), Some(2)] {
let mut info = playback("firefox");
info.audio_channels = ch;
assert_eq!(
evaluate(&info, &profile, false),
RoutingDecision::Route(Route::Processed),
"channels={ch:?}"
);
}
}
#[test]
fn application_name_only_rule_matches_stream_with_no_process_binary() {
// The shape `route set` emits when expanded into an
// `application_name`-keyed override. Verifies that a
// stream missing `application.process.binary` (typical
// of pw-cat, many CLI tools, some Flatpak wrappers) is
// still matched by the user's intent.
use headroom_ipc::{RouteRule, RouteRuleMatch};
let mut profile = Profile::default_v0();
// Override at the top of the rule list.
profile.rules.insert(
0,
RouteRule {
match_: RouteRuleMatch {
application_name: vec!["pw-cat".into()],
..Default::default()
},
route: Route::Bypass,
},
);
// Stream advertises only application.name = "pw-cat".
let info = PwNodeInfo {
node_id: 9,
media_class: Some("Stream/Output/Audio".into()),
application_process_binary: None,
application_name: Some("pw-cat".into()),
..Default::default()
};
assert_eq!(
evaluate(&info, &profile, false),
RoutingDecision::Route(Route::Bypass)
);
assert_eq!(evaluate(&info, &profile), RoutingDecision::Skip);
}
#[test]
@ -224,7 +129,7 @@ mod tests {
let info = playback("mpv");
let profile = Profile::default_v0();
assert_eq!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Bypass)
);
}
@ -234,7 +139,7 @@ mod tests {
let info = playback("firefox");
let profile = Profile::default_v0();
assert_eq!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Processed)
);
}
@ -245,7 +150,7 @@ mod tests {
let profile = Profile::default_v0();
// default_v0 has `default_route = Processed`.
assert_eq!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Processed)
);
}
@ -272,7 +177,7 @@ mod tests {
});
let info = playback("firefox");
assert_eq!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Bypass)
);
}
@ -287,7 +192,7 @@ mod tests {
});
let info = playback("firefox");
assert_eq!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Bypass)
);
}
@ -308,7 +213,7 @@ mod tests {
// process_binary matches but media_role doesn't (None on info).
let info = playback("firefox");
assert_ne!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Bypass)
);
@ -316,7 +221,7 @@ mod tests {
let mut info2 = playback("firefox");
info2.media_role = Some("Communication".into());
assert_eq!(
evaluate(&info2, &profile, false),
evaluate(&info2, &profile),
RoutingDecision::Route(Route::Bypass)
);
}
@ -335,7 +240,7 @@ mod tests {
let mut info = playback("DiscordWrapper");
info.portal_app_id = Some("com.discordapp.Discord".into());
assert_eq!(
evaluate(&info, &profile, false),
evaluate(&info, &profile),
RoutingDecision::Route(Route::Processed)
);
}

View file

@ -103,48 +103,24 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> {
agc_enabled: effective.agc.enabled,
}
};
// Read the real sink's native rate (captured during the brief
// window the registry watcher has been running) so the filter
// can match it and skip the output-edge resample for content
// at that rate. Falls back to PipeWire's 48 kHz default if the
// real sink hasn't surfaced yet — Phase C will rebuild the
// filter when the rate later resolves to something else.
let initial_rate = daemon_state
.lock()
.real_sink
.sample_rate
.unwrap_or(crate::pw::filter::DEFAULT_SAMPLE_RATE);
tracing::info!(initial_rate, "creating filter at real-sink-matched rate");
let FilterBundle {
filter,
filter: _filter,
control: filter_control,
measurement_consumer,
bus_metrics,
timing,
sample_rate: filter_rate,
} = Filter::create(pw.core(), filter_init, initial_rate)?;
{
let mut s = daemon_state.lock();
s.filter_control = Some(filter_control.clone());
s.filter_sample_rate = Some(filter_rate);
}
} = Filter::create(pw.core(), filter_init)?;
daemon_state.lock().filter_control = Some(filter_control.clone());
// Spin up the slow AGC controller. Ticks on the PipeWire main
// loop via a timer source; reads the active profile's [agc]
// config at each tick (so profile.use takes effect on the next
// tick) and pushes a smoothed target_db to the audio thread via
// FilterControl. Also publishes `meters` topic ticks at
// `profile.meters.publish_hz` (capped at 20 Hz, the AGC tick
// rate) — 4g.
// FilterControl.
let agc_controller = AgcController::new(
filter_rate,
crate::pw::filter::FILTER_SAMPLE_RATE,
crate::pw::filter::CHANNELS,
measurement_consumer,
filter_control,
daemon_state.clone(),
bus_metrics,
timing,
)
.map_err(DaemonError::from)?;
let agc_controller = Rc::new(RefCell::new(agc_controller));
@ -165,25 +141,6 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> {
// mechanism (see 4h).
pw.start_routing(daemon_state.clone())?;
// Hand the filter + an AGC handle to the routing state so the
// Format-param listener (registered when the real sink resolves
// its negotiated audio.rate) can ask the registry thread to
// rebuild the filter at a new rate via
// `PwCommand::RebuildFilter`. Filter ownership moves here:
// RoutingState now drops it on daemon shutdown via PwContext's
// drop order. The Filter is `Some(filter)` here unconditionally
// — `install_filter_rebuild_handles` overwrites whatever's in
// the slot.
if let Some(routing_state) = pw.routing_state() {
routing_state
.borrow_mut()
.install_filter_rebuild_handles(filter, agc_controller.clone());
} else {
// start_routing succeeded above so this branch shouldn't
// fire; keep the filter alive defensively if it ever does.
tracing::warn!("routing_state unavailable post-start_routing; keeping filter local");
}
publish_daemon_started(&daemon_state, &pending_warnings, active_missing.as_deref());
pw.run_until_signal()?;

View file

@ -55,13 +55,6 @@ pub struct DaemonState {
/// PipeWire global id of `headroom-processed`, captured when the
/// registry surfaces it. `None` until then.
pub processed_sink_id: Option<u32>,
/// Sample rate the filter is currently running at, in Hz.
/// `None` until `Filter::create` has been called (very early
/// boot only). Matches the real sink's native rate at the time
/// the filter was last (re)built. Used to populate the
/// processed sink's `sample_rate` field in `status` and to
/// drive Layer A's block-period.
pub filter_sample_rate: Option<u32>,
/// Snapshot of the user's preferred hardware sink. Phase 4h
/// keeps this fresh from `default.audio.sink`.
pub real_sink: SinkInfo,
@ -96,7 +89,6 @@ impl DaemonState {
started_at: Instant::now(),
profiles,
processed_sink_id: None,
filter_sample_rate: None,
real_sink: SinkInfo::default(),
streams: HashMap::new(),
broadcaster: Broadcaster::new(),
@ -119,14 +111,13 @@ impl DaemonState {
return None;
}
self.real_sink = SinkInfo {
// node_id + sample_rate stay unknown for now —
// registry's `try_capture_real_sink` resolves both
// when it sees the matching `Audio/Sink` global. The
// 4i routing path operates on name alone.
// node_id stays unknown for now — Headroom routes by name
// via `target.object = {"name":"…"}`, which is what
// WirePlumber expects. 4i may resolve the id when ad-hoc
// per-stream overrides need it.
node_id: None,
name: Some(new_name.to_owned()),
ready: true,
sample_rate: None,
};
Some(
self.streams

View file

@ -19,14 +19,6 @@ pub enum Detector {
/// Compressor parameters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CompressorConfig {
/// Master enable. When `false`, [`Compressor::process_frame`]
/// returns the input unchanged and reports zero gain reduction.
/// The compressor's envelope state is *not* reset while disabled,
/// so a stale envelope can briefly affect the first few samples
/// after re-enabling — but with typical release time constants
/// (tens to hundreds of ms) any residual transient is below the
/// audibility threshold.
pub enabled: bool,
/// Threshold in dBFS. Inputs above this start compressing.
pub threshold_db: f32,
/// Compression ratio (>= 1.0).
@ -48,7 +40,6 @@ pub struct CompressorConfig {
impl Default for CompressorConfig {
fn default() -> Self {
Self {
enabled: true,
threshold_db: -24.0,
ratio: 2.5,
knee_db: 6.0,
@ -117,23 +108,11 @@ impl Compressor {
self.last_gr_db
}
/// Update parameters. Recomputes alphas. Envelope state is kept
/// across same-enabled transitions so live tweaks don't pop, but
/// reset on a `disabled → enabled` transition so a stale
/// envelope from before the disable doesn't bleed out at the
/// release time-constant when processing resumes (otherwise
/// switching from a `transparent` profile back to a compressing
/// one would briefly duck on the first ~100 ms of audio for no
/// reason).
/// Update parameters. Recomputes alphas. Envelope state is kept,
/// so live tweaks don't pop.
pub fn set_config(&mut self, cfg: CompressorConfig) {
let cfg = cfg.sanitized();
let was_disabled = !self.cfg.enabled;
self.cfg = cfg;
if was_disabled && self.cfg.enabled {
self.envelope_db = -200.0;
self.rms_state = 0.0;
self.last_gr_db = 0.0;
}
self.attack_alpha = time_to_alpha(cfg.attack_ms, self.sample_rate);
self.release_alpha = time_to_alpha(cfg.release_ms, self.sample_rate);
self.rms_alpha = time_to_alpha(cfg.rms_window_ms, self.sample_rate);
@ -141,13 +120,6 @@ impl Compressor {
/// Process one stereo frame.
pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
if !self.cfg.enabled {
// Pass through untouched and report no reduction, so the
// bus meters reflect "compressor off" rather than the
// last value before disable.
self.last_gr_db = 0.0;
return (left, right);
}
let det_lin = match self.cfg.detector {
Detector::Peak => left.abs().max(right.abs()),
Detector::Rms => {
@ -284,79 +256,6 @@ mod tests {
assert_eq!(cfg.ratio, 1.0);
}
#[test]
fn disabled_compressor_passes_signal_through_unchanged() {
// Same hot input that would compress hard in the enabled
// test above. With `enabled: false`, output equals input
// exactly (no makeup gain, no reduction), and the reporter
// shows zero GR — so the `transparent` and `bypass-all`
// profiles actually do what their name claims.
let cfg = CompressorConfig {
enabled: false,
threshold_db: -20.0,
ratio: 4.0,
makeup_db: Some(12.0),
..CompressorConfig::default()
};
let mut c = Compressor::new(cfg, 48_000.0);
for _ in 0..1_000 {
let (l, r) = c.process_frame(0.5, 0.5);
assert_eq!(l, 0.5);
assert_eq!(r, 0.5);
}
assert_eq!(c.gain_reduction_db(), 0.0);
}
#[test]
fn enable_transition_resets_stale_envelope() {
// Run a loud signal through an enabled compressor to wind
// the envelope up, then disable + re-enable via set_config.
// The first sample after re-enable must NOT see the stale
// envelope (which would otherwise duck the signal until
// release_ms wound it down). Concretely: with a quiet input
// after re-enable, the envelope should be at the floor, so
// GR is zero — same as a freshly-constructed compressor.
let loud_cfg = CompressorConfig {
enabled: true,
threshold_db: -20.0,
ratio: 4.0,
attack_ms: 0.1,
release_ms: 1000.0, // slow release so stale state would otherwise stick
knee_db: 0.0,
makeup_db: Some(0.0),
..CompressorConfig::default()
};
let mut c = Compressor::new(loud_cfg, 48_000.0);
// Drive hot signal to wind envelope up.
for _ in 0..2_000 {
c.process_frame(0.5, 0.5);
}
assert!(
c.gain_reduction_db() < -5.0,
"precondition: envelope should be wound up; gr={}",
c.gain_reduction_db()
);
// Disable, then re-enable — should reset.
let mut disabled_cfg = loud_cfg;
disabled_cfg.enabled = false;
c.set_config(disabled_cfg);
c.set_config(loud_cfg);
// Now drive a quiet signal. With reset envelope, GR should
// ride near zero; without reset, the stale envelope would
// bleed gain reduction out over ~release_ms.
let (l, r) = c.process_frame(0.001, 0.001);
assert!(
c.gain_reduction_db().abs() < 0.01,
"envelope didn't reset across enable transition; gr={}",
c.gain_reduction_db()
);
// Output should be quiet (within makeup-applied scale).
assert!(l.abs() < 0.01);
assert!(r.abs() < 0.01);
}
#[test]
fn static_curve_at_threshold_with_soft_knee() {
// At exactly threshold, soft knee contributes exactly half the

View file

@ -140,22 +140,10 @@ impl Default for LimiterConfig {
}
}
/// Internal-rate cap (Hz). The limiter's true-peak detector
/// upsamples to `sample_rate × oversample`. Above ~192 kHz the
/// FIR cost rises linearly with effectively no gain — at base
/// rates ≥ 96 kHz the signal already has plenty of bandwidth
/// for inter-sample-peak detection. We cap the *effective*
/// internal rate here and drop the oversample factor on high
/// base rates accordingly.
pub const MAX_INTERNAL_RATE_HZ: f32 = 192_000.0;
impl LimiterConfig {
/// Sanitize a user-supplied configuration: clamp ceiling,
/// oversample factor, ensure odd FIR length, sanitize the soft
/// tier if present. Rate-agnostic — callers that know the
/// audio thread's sample rate should prefer
/// [`Self::sanitize_for_rate`] so the oversample factor scales
/// down on high-rate inputs.
/// tier if present.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.ceiling_dbtp > 0.0 {
@ -174,27 +162,6 @@ impl LimiterConfig {
self
}
/// Sanitize and additionally cap the oversample factor so the
/// post-upsample internal rate stays ≤ [`MAX_INTERNAL_RATE_HZ`].
/// Examples at the default `oversample = 4`:
/// 44.1 kHz → 4× → 176.4 kHz (under cap, untouched)
/// 48 kHz → 4× → 192 kHz (at cap, untouched)
/// 96 kHz → 2× → 192 kHz (cap engaged, dropped from 4)
/// 192 kHz → 1× → 192 kHz (cap engaged, no oversampling)
/// Always returns at least `oversample = 1`.
#[must_use]
pub fn sanitize_for_rate(self, sample_rate: f32) -> Self {
let mut s = self.sanitized();
if sample_rate > 0.0 {
let max_os =
(MAX_INTERNAL_RATE_HZ / sample_rate).floor().max(1.0) as usize;
if s.oversample > max_os {
s.oversample = max_os;
}
}
s
}
/// Convenience: brickwall only (no soft tier).
#[must_use]
pub fn brickwall_only() -> Self {
@ -648,40 +615,6 @@ mod tests {
use super::*;
use std::f32::consts::PI;
// ----------------------------------------------------------------
// sanitize_for_rate: oversample factor scales down so the
// internal (post-upsample) rate stays bounded.
// ----------------------------------------------------------------
#[test]
fn sanitize_for_rate_caps_oversample_at_internal_192k() {
// Default config has oversample = 4.
let default = LimiterConfig::default();
assert_eq!(default.oversample, 4);
// At 48 kHz: 4× = 192 kHz, at the cap, untouched.
assert_eq!(default.sanitize_for_rate(48_000.0).oversample, 4);
// At 44.1 kHz: 4× = 176.4 kHz, under the cap.
assert_eq!(default.sanitize_for_rate(44_100.0).oversample, 4);
// At 96 kHz: 4× = 384 kHz, exceeds; drop to 2× = 192 kHz.
assert_eq!(default.sanitize_for_rate(96_000.0).oversample, 2);
// At 192 kHz: cap forces oversample = 1.
assert_eq!(default.sanitize_for_rate(192_000.0).oversample, 1);
// Pathological rate above the cap still leaves at least 1.
assert_eq!(default.sanitize_for_rate(384_000.0).oversample, 1);
}
#[test]
fn sanitize_for_rate_preserves_user_lower_oversample() {
// User who explicitly set oversample = 2 at 48 kHz should
// keep it; the rate cap doesn't push the value *up*.
let cfg = LimiterConfig {
oversample: 2,
..LimiterConfig::default()
};
assert_eq!(cfg.sanitize_for_rate(48_000.0).oversample, 2);
}
// ----------------------------------------------------------------
// try_set_config: scalar updates apply in place, structural
// changes are rejected.
@ -691,12 +624,10 @@ mod tests {
fn try_set_config_applies_scalar_changes() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let cfg = LimiterConfig {
ceiling_dbtp: -3.0,
release_ms: 200.0,
hold_ms: 10.0,
..LimiterConfig::default()
};
let mut cfg = LimiterConfig::default();
cfg.ceiling_dbtp = -3.0;
cfg.release_ms = 200.0;
cfg.hold_ms = 10.0;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);
let active = l.config();
@ -709,10 +640,8 @@ mod tests {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// Start with soft on. Disable it.
let mut cfg = LimiterConfig {
soft: None,
..LimiterConfig::default()
};
let mut cfg = LimiterConfig::default();
cfg.soft = None;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
assert!(l.config().soft.is_none());
assert!(l.effective_soft_ceiling_dbtp().is_none());
@ -735,10 +664,8 @@ mod tests {
fn try_set_config_rejects_oversample_change() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let cfg = LimiterConfig {
oversample: 8,
..LimiterConfig::default()
};
let mut cfg = LimiterConfig::default();
cfg.oversample = 8;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
// Limiter unchanged.
assert_eq!(l.config().oversample, LimiterConfig::default().oversample);
@ -748,11 +675,8 @@ mod tests {
fn try_set_config_rejects_lookahead_change() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let cfg = LimiterConfig {
// resizes delay + peak buffer
lookahead_ms: 5.0,
..LimiterConfig::default()
};
let mut cfg = LimiterConfig::default();
cfg.lookahead_ms = 5.0; // resizes delay + peak buffer
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
}
@ -760,10 +684,8 @@ mod tests {
fn try_set_config_rejects_fir_taps_change() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let cfg = LimiterConfig {
fir_taps: 63,
..LimiterConfig::default()
};
let mut cfg = LimiterConfig::default();
cfg.fir_taps = 63;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
}

View file

@ -13,9 +13,9 @@ mod proto;
pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};
pub use error::{Error, ErrorCode, ProtoError};
pub use proto::{
DaemonEvent, Event, HelloData, LayerALevel, MeterTick, Op, ProfileEvent, ProfileInfo, Request,
Response, ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent,
ServerFrame, SinkInfo, Sinks, Status, StreamRoute, Topic,
DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
SinkInfo, Sinks, Status, StreamRoute, Topic,
};
/// Wire-protocol version. Bumped only on incompatible changes.

View file

@ -391,13 +391,6 @@ pub struct SinkInfo {
/// True if the sink is currently linked and accepting audio.
#[serde(default)]
pub ready: bool,
/// Sink's native sample rate (Hz), when known. The filter
/// matches the *real* sink's rate to skip the output-edge
/// resample; the processed sink advertises whatever rate the
/// filter is currently running at. Older clients that don't
/// understand the field treat it as absent (serde `default`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_rate: Option<u32>,
}
/// One playback stream and where it's routed.
@ -532,49 +525,10 @@ pub enum RoutingEvent {
/// Route assigned.
to: Route,
},
/// A stream tracked by the routing engine went away (its
/// PipeWire node disappeared). Clients should drop any state
/// indexed by `node_id`.
StreamRemoved {
/// Node id of the departed stream.
node_id: u32,
},
/// A Layer A (per-app level control) tap was attached to a
/// stream — the daemon will start managing its
/// `Props.channelVolumes` and publishing `meters/layer_a_level`
/// events for it.
LayerAAttached {
/// Node id of the managed stream.
node_id: u32,
/// Application identifier.
app: String,
},
/// A Layer A tap was torn down (typically because the stream
/// went away). Clients should drop Layer A state for `node_id`.
LayerADetached {
/// Node id whose tap was torn down.
node_id: u32,
},
/// A persistent rule was added, replaced, or removed.
RuleChanged,
}
/// `meters/layer_a_level` payload — published when the per-app
/// (Layer A) level controller writes a new `channelVolumes` value to
/// a managed stream.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayerALevel {
/// Source PipeWire node id.
pub node_id: u32,
/// Application identifier.
pub app: String,
/// Linear volume that was written (1.0 = unity).
pub volume_lin: f32,
/// Smoothed gain reduction the controller currently asserts, in
/// dB. ≤ 0 dB when reducing.
pub reduction_db: f32,
}
/// `daemon` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]

183
flake.nix
View file

@ -11,118 +11,97 @@
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ]
(system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
rustPlatform = pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
rustPlatform = pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
# Native libs the audio crates link against.
nativeAudioBuildInputs = with pkgs; [
pipewire
pipewire.dev
# Native libs the audio crates link against.
nativeAudioBuildInputs = with pkgs; [
pipewire
pipewire.dev
];
nativeBuildTools = with pkgs; [
pkg-config
clang
];
commonEnv = {
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig";
};
in
{
# `nix develop` — full dev environment.
devShells.default = pkgs.mkShell ({
name = "headroom-dev";
nativeBuildInputs = nativeBuildTools ++ [
rustToolchain
pkgs.rust-analyzer
];
nativeBuildTools = with pkgs; [
pkg-config
clang
];
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
socat # poke the IPC socket
jq # pretty-print JSON
pipewire # for pw-cli, pw-cat, etc.
wireplumber
]);
commonEnv = {
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig";
};
in
{
# `nix develop` — full dev environment.
devShells.default = pkgs.mkShell ({
name = "headroom-dev";
shellHook = ''
echo "headroom dev shell rustc $(rustc --version | cut -d' ' -f2)"
echo " cargo build / cargo test for iteration."
echo " nix build .#headroom for the packaged binary."
export RUST_BACKTRACE=1
export RUST_LOG=headroom=debug,info
'';
} // commonEnv);
nativeBuildInputs = nativeBuildTools ++ [
rustToolchain
pkgs.rust-analyzer
];
# `nix build` — the final packaged daemon + CLI.
packages = rec {
default = headroom;
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
socat # poke the IPC socket
jq # pretty-print JSON
pipewire # for pw-cli, pw-cat, etc.
wireplumber
]);
headroom = rustPlatform.buildRustPackage ({
pname = "headroom";
version = (builtins.fromTOML (builtins.readFile ./crates/headroom-cli/Cargo.toml)).package.version;
shellHook = ''
echo "headroom dev shell rustc $(rustc --version | cut -d' ' -f2)"
echo " cargo build / cargo test for iteration."
echo " nix build .#headroom for the packaged binary."
export RUST_BACKTRACE=1
export RUST_LOG=headroom=debug,info
'';
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
# allowBuiltinFetchGit = true;
};
nativeBuildInputs = nativeBuildTools;
buildInputs = nativeAudioBuildInputs;
# We ship two binaries from the workspace: `headroom` (cli + daemon).
cargoBuildFlags = [ "-p" "headroom-cli" ];
doCheck = true;
cargoTestFlags = [ "--workspace" ];
meta = with pkgs.lib; {
description = "AGC + compressor + true-peak limiter daemon for PipeWire";
license = licenses.gpl3Plus;
platforms = platforms.linux;
mainProgram = "headroom";
};
} // commonEnv);
};
# `nix build` — the final packaged daemon + CLI.
packages = rec {
default = headroom;
# Reserved for the eventual user-service module.
# nixosModules.default = import ./nix/module.nix;
headroom = rustPlatform.buildRustPackage ({
pname = "headroom";
# Pull from the workspace Cargo.toml — the per-crate
# manifests use `version.workspace = true` which evaluates
# to a table here, not a string.
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
# allowBuiltinFetchGit = true;
};
nativeBuildInputs = nativeBuildTools;
buildInputs = nativeAudioBuildInputs;
# We ship one binary from the workspace: `headroom` (cli + daemon).
cargoBuildFlags = [ "-p" "headroom-cli" ];
doCheck = true;
cargoTestFlags = [ "--workspace" ];
# Install the systemd user unit (templated with @bindir@
# so the unit refers to the absolute path of the binary in
# this derivation, never to whatever happens to be on
# PATH) and ship the canonical profiles under
# share/headroom/profiles so users / modules can copy
# them into XDG_CONFIG_HOME on first run.
postInstall = ''
install -Dm644 contrib/systemd/headroom.service \
"$out/lib/systemd/user/headroom.service"
substituteInPlace "$out/lib/systemd/user/headroom.service" \
--replace-fail '@bindir@' "$out/bin"
mkdir -p "$out/share/headroom/profiles"
cp -r profiles/. "$out/share/headroom/profiles/"
'';
meta = with pkgs.lib; {
description = "AGC + compressor + true-peak limiter daemon for PipeWire";
license = licenses.gpl3Plus;
platforms = platforms.linux;
mainProgram = "headroom";
};
} // commonEnv);
};
formatter = pkgs.nixpkgs-fmt;
}) // {
# System-independent outputs — modules.
nixosModules.default = import ./nix/nixos-module.nix self;
homeModules.default = import ./nix/home-module.nix self;
};
formatter = pkgs.nixpkgs-fmt;
});
}

View file

@ -1,119 +0,0 @@
# Home Manager module — installs the headroom binary, the systemd
# user service, and (optionally) a default set of profiles into the
# user's XDG_CONFIG_HOME.
#
# Headroom is a per-user daemon that talks to PipeWire over the user
# session, so the Home Manager scope is its natural install point. A
# separate NixOS module (./nixos-module.nix) covers the case where the
# user wants `headroom` on every account's PATH or wants to enable the
# service at the system level via systemd-user; that module simply
# delegates the heavy lifting to `services.headroom` (this file) when
# Home Manager is in use.
self:
{ config, lib, pkgs, ... }:
let
inherit (lib) mkEnableOption mkOption mkIf types literalExpression;
cfg = config.services.headroom;
package = cfg.package;
# Profiles shipped by the package, suitable for symlinking into the
# user's XDG_CONFIG_HOME so they show up in `headroom profile list`
# without the user having to copy them by hand.
shippedProfilesDir = "${package}/share/headroom/profiles";
in
{
options.services.headroom = {
enable = mkEnableOption "Headroom PipeWire AGC + compressor + true-peak limiter daemon";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.system}.headroom;
defaultText = literalExpression "headroom.packages.\${pkgs.system}.headroom";
description = ''
The headroom package to install. Override to pin a local
build (e.g. `path:/home/me/code/headroom`) when iterating.
'';
};
installDefaultProfiles = mkOption {
type = types.bool;
default = true;
description = ''
Symlink the profiles shipped with the package into
`$XDG_CONFIG_HOME/headroom/profiles/`. Disable if you
maintain your own profile set and don't want the shipped
ones cluttering `headroom profile list`.
'';
};
extraProfiles = mkOption {
type = types.attrsOf types.path;
default = { };
example = literalExpression ''
{
"studio.toml" = ./profiles/studio.toml;
}
'';
description = ''
Additional profile TOML files to drop into the user's
profile directory, keyed by filename. Overrides any
identically-named shipped profile.
'';
};
};
config = mkIf cfg.enable {
home.packages = [ package ];
# Symlink shipped profiles + any user-provided extras into the
# user's XDG_CONFIG_HOME. The daemon's profile watcher
# (notify-debouncer-mini) treats symlinks identically to
# regular files, so this is transparent.
xdg.configFile = lib.mkMerge [
(mkIf cfg.installDefaultProfiles (
lib.mapAttrs'
(name: _: lib.nameValuePair "headroom/profiles/${name}" {
source = "${shippedProfilesDir}/${name}";
})
(builtins.readDir shippedProfilesDir)
))
(lib.mapAttrs'
(name: path: lib.nameValuePair "headroom/profiles/${name}" {
source = path;
})
cfg.extraProfiles)
];
# systemd user unit. The unit shipped by the package already
# carries the right ExecStart with an absolute path baked in,
# so we just symlink it into the user's services directory and
# let Home Manager start it via its systemd-user machinery.
systemd.user.services.headroom = {
Unit = {
Description = "Headroom audio daemon (PipeWire AGC + compressor + true-peak limiter)";
Documentation = "https://github.com/amaanq/headroom";
After = [ "pipewire.service" "pipewire-pulse.service" "wireplumber.service" ];
Requires = [ "pipewire.service" ];
Wants = [ "wireplumber.service" ];
};
Service = {
Type = "simple";
ExecStart = "${package}/bin/headroom daemon";
Restart = "on-failure";
RestartSec = "2s";
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "headroom";
LimitRTPRIO = 20;
LimitRTTIME = 200000;
LimitNICE = -11;
};
Install = {
WantedBy = [ "pipewire.service" ];
};
};
};
}

View file

@ -1,61 +0,0 @@
# NixOS module — system-wide install. Headroom itself is a user-scope
# daemon (it talks to the user's PipeWire session), so this module's
# job is narrow:
#
# 1. Make the `headroom` binary available on every login's PATH.
# 2. Drop the systemd user unit into the system-wide location so a
# user can `systemctl --user enable --now headroom` without first
# having to use Home Manager.
# 3. Ensure the standard audio stack (PipeWire + WirePlumber) is
# enabled, since headroom can't function without them.
#
# For per-user defaults — activeProfile, shipped-profile install,
# RT-priority tuning — use the Home Manager module
# (`homeModules.default`) instead. The two compose.
self:
{ config, lib, pkgs, ... }:
let
inherit (lib) mkEnableOption mkOption mkIf types literalExpression;
cfg = config.programs.headroom;
in
{
options.programs.headroom = {
enable = mkEnableOption "Headroom PipeWire AGC + compressor + true-peak limiter daemon";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.system}.headroom;
defaultText = literalExpression "headroom.packages.\${pkgs.system}.headroom";
description = ''
The headroom package to install system-wide.
'';
};
};
config = mkIf cfg.enable {
# Binary + manpages (when we have them) on the global PATH.
environment.systemPackages = [ cfg.package ];
# Make the shipped systemd user unit discoverable by `systemctl
# --user`. Setting `packages` here is the canonical NixOS way to
# install user-scope unit files from a package — it materialises
# `/etc/systemd/user/headroom.service` pointing at the package's
# `lib/systemd/user/headroom.service`.
systemd.packages = [ cfg.package ];
# Headroom requires PipeWire; refuse to evaluate the module if
# the user enabled headroom but not pipewire, with a pointer
# rather than a confusing runtime failure.
assertions = [
{
assertion = config.services.pipewire.enable;
message = ''
programs.headroom.enable requires services.pipewire.enable = true;
headroom is a PipeWire-only daemon.
'';
}
];
};
}