This commit is contained in:
atagen 2026-05-19 16:33:09 +10:00
commit ca1910de60
39 changed files with 6328 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
/result
**/*.rs.bk
Cargo.lock.bak
.direnv/
.envrc

664
Cargo.lock generated Normal file
View file

@ -0,0 +1,664 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "headroom-cli"
version = "0.1.0"
dependencies = [
"clap",
"headroom-client",
"headroom-core",
"headroom-ipc",
"serde_json",
"thiserror",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "headroom-client"
version = "0.1.0"
dependencies = [
"headroom-ipc",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "headroom-core"
version = "0.1.0"
dependencies = [
"crossbeam-channel",
"headroom-dsp",
"headroom-ipc",
"parking_lot",
"serde",
"serde_json",
"signal-hook",
"thiserror",
"toml",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "headroom-dsp"
version = "0.1.0"
[[package]]
name = "headroom-ipc"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

80
Cargo.toml Normal file
View file

@ -0,0 +1,80 @@
[workspace]
resolver = "2"
members = [
"crates/headroom-dsp",
"crates/headroom-ipc",
"crates/headroom-client",
"crates/headroom-core",
"crates/headroom-cli",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.86"
license = "GPL-3.0-or-later"
homepage = "https://github.com/amaanq/headroom"
repository = "https://github.com/amaanq/headroom"
authors = ["Headroom contributors"]
[workspace.dependencies]
# Internal crates
headroom-dsp = { path = "crates/headroom-dsp", version = "0.1.0" }
headroom-ipc = { path = "crates/headroom-ipc", version = "0.1.0" }
headroom-client = { path = "crates/headroom-client", version = "0.1.0" }
headroom-core = { path = "crates/headroom-core", version = "0.1.0" }
# Serde / JSON / TOML
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# Errors / logging
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# CLI
clap = { version = "4.5", features = ["derive"] }
# Concurrency / control plane
crossbeam-channel = "0.5"
parking_lot = "0.12"
signal-hook = "0.3"
# Realtime audio
rtrb = "0.3"
basedrop = "0.1"
assert_no_alloc = "1.1"
# DSP
ebur128 = "0.1"
fundsp = "0.20"
# PipeWire
pipewire = "0.8"
libspa = "0.8"
# Profile hot-reload
notify = "6.1"
notify-debouncer-mini = "0.4"
# Logging — journald optional
tracing-journald = "0.3"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
debug = "line-tables-only"
overflow-checks = false
panic = "abort"
[profile.dev]
opt-level = 1 # audio code is unusable at -O0
debug = true
overflow-checks = true
[profile.bench]
inherits = "release"
debug = "full"

337
IPC.md Normal file
View file

@ -0,0 +1,337 @@
# Headroom IPC — wire protocol
Normative specification of Headroom's control protocol. Version `1`.
This is the contract between the daemon and any client (the first-party
`headroom` CLI, the first-party `headroom-client` Rust crate, and any third
party — Qt/QuickShell panel, Eww widget, shell script). The Rust
representation lives in the `headroom-ipc` crate; this document is the
authoritative source.
---
## 1. Transport
- **Type:** Unix-domain socket, `SOCK_STREAM`.
- **Path:** `${XDG_RUNTIME_DIR}/headroom/control.sock`, falling back to
`/run/user/${UID}/headroom/control.sock` if `XDG_RUNTIME_DIR` is unset.
- **Permissions:** the parent directory is created `0700`, the socket
itself `0600`. Authn/authz is purely filesystem-based, matching the
conventions of PipeWire and Wayland.
- **Encoding:** UTF-8 JSON, one message per frame.
## 2. Framing
Each frame is a 4-byte big-endian unsigned length followed by exactly
that many bytes of JSON payload.
```
+--------+--------+--------+--------+----...----+
| len high low | payload |
+--------+--------+--------+--------+----...----+
```
- Maximum frame size: **1 MiB** (1 048 576 bytes). Larger frames are a
protocol violation; the server closes the connection.
- The payload MUST be a single JSON value (object). Pretty-printing is
permitted but discouraged.
- No trailing newline or NUL terminator inside the frame.
## 3. Message shapes
Every payload is a JSON object with one of three top-level shapes,
distinguished by which discriminating field is present.
### 3.1 Request — client → server
```json
{
"id": <u64>,
"op": "<string>",
"args": <object | omitted>
}
```
- `id`: client-chosen identifier, **must be unique across in-flight
requests on a connection**. The server echoes this verbatim in the
paired response. Clients may reuse an `id` once they have received
the corresponding response.
- `op`: operation name. See §5.
- `args`: optional argument object. May be omitted if the operation
takes no arguments.
### 3.2 Response — server → client
Exactly one response is emitted per request, with the same `id`.
```json
{ "id": <u64>, "result": <value> }
```
or
```json
{ "id": <u64>, "error": { "code": "<string>", "message": "<string>" } }
```
- Mutually exclusive: either `result` or `error`, never both. (Both
fields together is a server bug.)
- `result` may be any JSON value, including `null` for operations that
succeed with no data.
- `error.code` is a stable machine-readable string from §6.
- `error.message` is human-readable English. Not stable; do not pattern
match.
### 3.3 Event — server → client
```json
{
"event": "<string>",
"topic": "<string>",
"data": <object>
}
```
- Events have no `id`. A client distinguishes events from responses by
presence of `event` / `topic` (events) vs. `id` (responses).
- `topic`: subscription topic the event belongs to (§4).
- `event`: name of the event within that topic.
- A client only receives events for topics it has explicitly
subscribed to, with one exception: every new connection receives a
`hello` event before any other traffic (see §7).
## 4. Subscriptions
A client opts in to event streams by calling `subscribe` with a list of
topics, and opts out with `unsubscribe`. Subscription state is
per-connection and resets when the connection closes.
### Topics
| Topic | Cadence | Purpose |
|-----------|-------------------|---------|
| `meters` | publish_hz (≤ 60) | Live loudness, peak, gain-reduction telemetry. |
| `profile` | on change | Profile use/reload events. |
| `routing` | on change | Rule changes; per-stream routing decisions. |
| `daemon` | on change | Lifecycle, errors, overflow notifications. |
### Backpressure
The server maintains a bounded queue per subscriber per topic
(default 64 messages; topic-overrideable in profile). When a queue is
full at publish time, the new event is **dropped**, the per-(subscriber,
topic) drop counter increments, and a `daemon` `overflow` event is
emitted to the affected subscriber describing the loss:
```json
{
"event": "overflow",
"topic": "daemon",
"data": { "lost_topic": "meters", "lost": 42, "total_lost": 197 }
}
```
`overflow` events themselves cannot be dropped — if the `daemon` queue
is also full, the server closes the connection. A well-behaved client
either drains promptly or filters topics it doesn't care about.
The control thread never blocks on a slow client. Audio is never
affected by subscriber behaviour: meter publishing is rate-limited,
runs on a dedicated thread, and reads from a non-blocking source.
---
## 5. Operations
All operations are listed below with their full argument and result
schemas. `args` is omitted from the request when its schema is empty.
### Catalogue
| op | args | result |
|--------------------|-----------------------------------------------|------------------------------|
| `status` | — | `Status` |
| `profile.list` | — | `{ profiles: ProfileInfo[] }`|
| `profile.use` | `{ name: string }` | `{ name: string }` |
| `profile.show` | `{ name?: string }` | `Profile` |
| `profile.reload` | — | `{ reloaded: string[] }` |
| `route.list` | — | `RouteList` |
| `route.set` | `{ app: string, to: "processed"\|"bypass" }` | `null` |
| `route.unset` | `{ app: string }` | `null` |
| `route.stream` | `{ node_id: u32, to: "processed"\|"bypass" }` | `null` |
| `setting.get` | `{ key: string }` | `{ key: string, value: any }`|
| `setting.set` | `{ key: string, value: any }` | `null` |
| `setting.list` | — | `{ settings: object }` |
| `bypass.set` | `{ enabled: bool }` | `null` |
| `subscribe` | `{ topics: string[] }` | `{ subscribed: string[] }` |
| `unsubscribe` | `{ topics: string[] }` | `{ unsubscribed: string[] }` |
### Object schemas
#### `Status`
```json
{
"version": "0.1.0",
"protocol": 1,
"uptime_s": 482,
"profile": "default",
"bypass": false,
"sinks": {
"processed": { "node_id": 51, "ready": true },
"real": { "node_id": 35, "name": "alsa_output.pci-0000_00_1f.3.analog-stereo" }
},
"streams": [
{ "node_id": 73, "app": "firefox", "route": "processed" },
{ "node_id": 81, "app": "spotify", "route": "bypass" }
]
}
```
#### `ProfileInfo`
```json
{ "name": "default", "active": true, "description": "Gentle …" }
```
#### `Profile`
The full profile document. Identical to the TOML profile, serialized as
JSON. See `PLAN.md` §5 for the field set.
#### `RouteList`
```json
{
"rules": [
{ "match": { "process_binary": ["firefox"] }, "route": "processed" }
],
"current": [
{ "node_id": 73, "app": "firefox", "route": "processed" }
],
"default_route": "processed"
}
```
### Setting keys
`setting.get`/`setting.set` use dotted keys into the active profile.
Examples:
- `agc.target_lufs` (float)
- `agc.enabled` (bool)
- `compressor.threshold_db` (float)
- `compressor.ratio` (float)
- `limiter.ceiling_dbtp` (float)
- `limiter.lookahead_ms` (float)
- `limiter.oversample` (integer, one of 1/2/4/8)
- `meters.publish_hz` (float)
Headroom rejects sets that would violate invariants (e.g.
`limiter.ceiling_dbtp > 0.0`). See §6 for error codes.
---
## 6. Errors
`error.code` is one of:
| code | meaning |
|-------------------|--------------------------------------------------------------|
| `INVALID_FRAME` | Malformed framing or non-JSON payload. Connection is closed. |
| `INVALID_MESSAGE` | Valid JSON, but doesn't fit a known message shape. |
| `UNKNOWN_OP` | `op` does not name a known operation. |
| `INVALID_ARGS` | `args` missing a required field, wrong type, or out of range.|
| `NOT_FOUND` | Profile / app / stream / setting key does not exist. |
| `CONFLICT` | Operation would violate an invariant (e.g. ceiling > 0). |
| `BUSY` | Daemon transiently cannot serve the request (rare). |
| `INTERNAL` | Bug. Includes a `message` for debugging. |
Frame-level violations (`INVALID_FRAME` of size, framing, encoding)
cause the connection to be closed after the error is sent.
Message-level errors leave the connection open.
---
## 7. Connection lifecycle
1. Client connects. Server immediately emits a `hello` event:
```json
{
"event": "hello",
"topic": "control",
"data": {
"daemon": "headroom",
"version": "0.1.0",
"protocol": 1
}
}
```
This event is **not** gated on subscription — every client gets it.
2. Client sends requests; server replies. Client may `subscribe` to
topics at any time and will start receiving events for those
topics.
3. Either side may close the socket at any time. The server cleans up
subscription state. Outstanding requests are dropped (no response).
There is no formal `bye`. Closing the socket is the protocol.
---
## 8. Versioning
The protocol uses a single integer version number, currently `1`.
- **Additions** (new ops, new optional fields, new events, new error
codes) do not bump the protocol version. Clients MUST ignore unknown
fields on objects they receive and MUST be tolerant of new event
topics they did not subscribe to (they should never see those, but
belt and braces).
- **Removals or semantic changes** bump the protocol version. The
daemon may reject connections from clients that declare incompatible
versions (TBD: client may include `Hello` request with declared
version; not yet specified).
Clients SHOULD log a warning if the `protocol` value in `hello` does
not match the version they were built against, and proceed.
---
## 9. Example exchange
```
C → S len=58
{"id":1,"op":"profile.use","args":{"name":"night"}}
S → C len=24
{"id":1,"result":{"name":"night"}}
C → S len=49
{"id":2,"op":"subscribe","args":{"topics":["meters"]}}
S → C len=37
{"id":2,"result":{"subscribed":["meters"]}}
S → C len=137
{"event":"tick","topic":"meters","data":{
"momentary_lufs":-19.3,"shortterm_lufs":-20.1,
"integrated_lufs":-19.8,"true_peak_dbtp":-1.4,
"gain_reduction_db":-2.1,"agc_gain_db":0.5
}}
```
---
## 10. Reference
The authoritative Rust binding to this protocol is the `headroom-ipc`
crate; the `headroom-client` crate wraps it with a blocking `Client`
(and an optional async `AsyncClient` behind the `async` feature). Both
live in this repository.
Third-party clients should target this document, not the Rust types,
to remain interoperable across implementations.

874
PLAN.md Normal file
View file

@ -0,0 +1,874 @@
# Headroom
A Rust AGC + compressor + true-peak limiter for PipeWire. Per-application
exclusion, profile-based presets, single-binary daemon, scriptable over a
Unix-domain socket.
This document is the canonical plan. It supersedes the earlier
conversational sketch.
---
## 1. Goals & non-goals
### Goals
- **Hard safety net.** Output is guaranteed to stay below a configurable
ceiling (default **0.1 dBTP**) with proper inter-sample peak handling.
This guarantee survives daemon misbehaviour, profile reloads, and bad
routing decisions — it is enforced inline in the audio path.
- **Per-application exclusion.** Music players, games, and DAWs route
around the processor; browsers, voice chat, and "everything else" go
through it. Rules are app-level and live in profiles.
- **Drop-in defaults.** First-run experience: install, enable user
service, done. No mandatory config. Power users edit TOML or use the
CLI.
- **Profiles** for distinct listening scenarios (default / night /
speech / transparent / bypass-all).
- **Single binary.** Daemon, filter, routing, and control loop all live
in one process. The DSP kernels are a separate crate so they can be
reused (LV2/standalone) later.
- **Scriptable.** Unix-domain-socket IPC with a documented JSON schema
so anyone can write an alternative client (Qt/QuickShell panel, Eww
widget, scripts). A first-party Rust crate (`headroom-ipc`) wraps it.
- **Rust, lean dep tree.** No NIH where mature crates exist, no bloat
where they don't.
### Non-goals (v0)
- Surround / >2-channel content. v0 is stereo only; >2ch is routed
directly to the real sink, untouched by Headroom's filter chain.
- LV2/CLAP plugin distribution. The DSP crate is plugin-shaped so this
is cheap to add later, but it's not a v0 deliverable.
- GUI. Third parties can build one against the IPC.
- Capture-side processing (microphone). v0 is playback only.
---
## 2. Architecture
Each app's audio takes one of four end-to-end paths, chosen by two
**orthogonal** profile flags: a routing decision (processed vs.
bypass) and a per-app level-control flag (on vs. off).
```
┌─── optional, opt-in per app (Layer A) ────────────────┐
│ │
│ ┌─► passive tap ─► peak + RMS ─► AppLevelController │
│ │ (sibling link in same quantum) │ │
│ │ │ │
│ │ Props.channelVolumes write ◄──────┘ │
│ │ │
└───┼───────────────────────────────────────────────────┘
│ APP STREAM NODE
│ ┌──────────────────────────┐
│ │ raw output │
app's audio ───►├──►│ × channelVolumes │──► output port
│ └──────────────────────────┘
│ │
└────────────────────────────────────────────│
routing decision (Layer B) │
target.object set by daemon │
┌─────────────────────────────────────────┴┐
▼ ▼
route = "bypass" route = "processed"
target.object = target.object =
preferred_real_sink headroom-processed
│ │
│ ▼
│ ┌─────────────────────┐
│ │ headroom-processed │
│ │ (virtual sink, the │
│ │ system default) │
│ └─────────┬───────────┘
│ ▼
│ ┌─────────────────────┐
│ │ headroom-filter │
│ │ (pw_stream pair) │
│ Layer C (bus DSP) │ AGC → compressor │
│ │ → soft → hard │
│ └─────────┬───────────┘
│ │
▼ ▼
preferred_real_sink ◄──────────────────────► (DAC)
```
### The four end-to-end paths
| | Routing = bypass | Routing = processed |
|---|---|---|
| **per-app off** | ① **true bypass** — Headroom touches nothing on the signal path. Same latency as if Headroom weren't installed. | ③ **bus DSP only** — stream flows through `headroom-processed` and the inline chain. `channelVolumes` left at whatever the user/app set. |
| **per-app on** | ② **per-app only** — level-reactive `channelVolumes` writes, no graph hop. Zero added signal-path latency. | ④ **full stack** — per-app level control *and* bus DSP. Maximum protection. |
Path-by-path properties:
| Path | Signal-path latency added | Limiter contract? | Per-app gain ride? |
|---|---|---|---|
| ① bypass / per-app off | 0 | no | no |
| ② bypass / per-app on | 0 | no | yes (Layer A) |
| ③ processed / per-app off | filter hop + ~2 ms lookahead | yes (Layer C hard tier) | no |
| ④ processed / per-app on | filter hop + ~2 ms lookahead | yes (Layer C hard tier) | yes (Layer A) |
The two flags are independent. A competitive game's typical config
is ①: zero Headroom involvement in its audio. A user concerned about
notification dings on top of that game would put Discord on ② or ④
(so notifications get tamed via Discord's own `channelVolumes`)
while leaving the game on ①.
```
headroom-core (daemon, one process)
• per-app level controllers (Layer A)
• routing engine + preferred_real_sink (Layer B)
• slow AGC loop, profile manager (Layer C)
• IPC server
$XDG_RUNTIME_DIR/headroom/control.sock
┌───────────┴───────────┐
▼ ▼
headroom CLI third-party clients
(Qt panel, widgets, …)
```
See §4 for Layer A's mechanics and §5 for the PipeWire-level details
of Layers B and C.
### One virtual sink, one daemon process
- `headroom-processed` — virtual sink. Set as the system default so
new streams land in it by default. Its monitor is captured by
`headroom-filter`, pushed through the DSP graph, and emitted to the
current `preferred_real_sink`.
- **No bypass sink.** Streams marked `route = "bypass"` are pointed
directly at `preferred_real_sink` via a `target.object` metadata
write. They pay zero added latency vs. running without Headroom
installed at all — there's no extra graph hop, no extra DSP. The
word "bypass" in the profile DSL means "route directly to the real
sink, untouched."
- The **daemon** owns:
- the one virtual sink (created on startup, torn down on exit);
- the filter (a pair of `pw_stream`s — capture + playback — running
on PipeWire's realtime audio thread, with the playback half
targeting `preferred_real_sink`);
- one **`AppLevelController`** per managed app stream (§4), each
with its own passive `pw_stream` tap, peak/RMS envelopes, and
`Props.channelVolumes` writer. Created/destroyed on stream
lifecycle events.
- **`preferred_real_sink` tracking.** The daemon watches the
`default.audio.sink` metadata key. When the user changes the
system default (via pavucontrol, `wpctl set-default`, etc.) to a
hardware sink, the daemon (a) treats that sink as the new
`preferred_real_sink`, (b) re-links `headroom-filter`'s playback
stream to it, and (c) rewrites `target.object` for every
currently-bypassed stream so they follow. Hotplug / Bluetooth
handoffs use the same machinery.
- the slow AGC loop (reads loudness, writes gain target into the
filter via an `rtrb` channel);
- the routing engine (subscribes to the PipeWire registry, evaluates
rules on new streams, writes `target.object` to the `default`
metadata: either `headroom-processed` for processed streams or
`preferred_real_sink` for bypassed streams);
- the IPC server.
### Why no `headroom-bypass` sink
An earlier iteration of the design had a second virtual sink
(`headroom-bypass`) that loopback'd to the real sink, so "bypassed"
streams routed to it. This added one PipeWire quantum of latency to
every bypassed stream for no functional benefit — `module-loopback`
buffers across the quantum boundary even when the DSP is a no-op.
Direct routing via `target.object` skips the hop entirely. The win is
real for competitive games, DAW monitoring, and music players: they
now ride exactly the same path they'd take if Headroom weren't
installed.
### Why this is *not* the "analytical sink + adjust master volume"
### shape originally proposed
Volume control via SPA `Props` updates is not sample-accurate. A true-peak
limiter needs a small internal delay line so gain reduction is applied
to the same samples that were analyzed. Therefore the **brickwall must
be inline**. The analytical-monitor approach is still used — for the
*slow* AGC loop, where multi-second time constants make control-plane
latency irrelevant — but it cannot own the ceiling.
### Why a `pw_stream` pair, not an LV2 plugin in `module-filter-chain`
LV2 is not native to PipeWire; it's one of several plugin formats
`module-filter-chain` happens to host (via lilv). Using LV2 would split
Headroom into a plugin + a daemon + a filter-chain JSON, pull in a lilv
runtime, and force gain-target updates through a 32-bit-float control-port
abstraction. A `pw_stream` capture+playback pair is the same pattern
`module-filter-chain` itself uses internally, but written directly in
Rust against `pipewire-rs`, in the same process as the rest of the
daemon. One binary, no IPC for parameter updates, idiomatic Rust audio
thread. An LV2 wrapper of `headroom-dsp` remains a viable optional
deliverable for use in DAWs.
---
## 3. DSP
### 3.1 Two-tier true-peak limiter (`headroom-dsp::limiter`)
The limiter has **two parallel tiers** sharing the same upsampler,
downsampler, delay line, and sliding peak buffer. Both run at the
oversampled rate.
**Hard tier — the safety contract.** Output ceiling default
**0.1 dBTP**, configurable. Instant attack on the gain envelope plus a
brief hold and a slow release. Two defensive `clamp` stages downstream
(once in the oversampled domain, once at the input rate after
downsampling) guarantee the contract numerically — the envelope can
misbehave and the contract still holds. Never bypassed, never
disabled.
**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
to a comfortable peak-to-loudness ratio (default 14 dB) *before* they
ever threaten the hard ceiling. When the AGC hasn't yet provided a
program loudness (startup, after reset), the soft tier falls back to a
static ceiling. Disabled by omitting `[limiter.soft]` in a profile —
useful for the `transparent` profile where users want pure brickwall
behavior.
Algorithm (per oversampled sample, after upsampling):
1. Push raw `|s|` into the sliding-window peak buffer; read the
max-of-window.
2. **Soft tier** computes target = `soft_ceiling / window_peak` (clamped
to ≤ 1), runs through the smooth attack/release envelope, yields
`soft_gain`.
3. **Hard tier** predicts the worst-case effective peak after the soft
tier acts (max of `window_peak * soft_gain` and the asymptote
`min(window_peak, soft_ceiling)`), then sizes `hard_target` to keep
that under the hard ceiling. Instant attack, hold, exponential
release. Yields `hard_gain`.
4. `total_gain = min(soft_gain, hard_gain)`.
5. Multiply the delayed sample by `total_gain`.
6. Clamp at hard ceiling (defense-in-depth).
7. Downsample, clamp again at hard ceiling at the input rate.
When the soft tier is doing its job, the hard tier's "predicted-post-soft"
target stays above 1.0 and the hard tier never engages. When the soft
tier is mid-attack (peak just arrived), the hard tier snaps in as a
safety, then releases as the soft tier catches up.
The compressor and AGC stages run *before* the limiter.
### 3.2 Feed-forward compressor (`headroom-dsp::compressor`)
Standard shape: log-domain detector (peak or RMS, switchable) →
ratio + soft knee → attack/release envelope smoother → makeup gain →
linear gain → apply to (small) delayed input. ~150 lines of clean code.
Defaults aimed at "gentle, transparent": threshold 24 dBFS,
ratio 2.5:1, knee 6 dB, attack 10 ms, release 100 ms, makeup auto.
### 3.3 Slow AGC (`headroom-core::agc`)
Algorithmic descendant of EasyEffects' `autogain.cpp`. Runs *outside*
the audio thread, on a ~50 ms control tick.
- Feeds the audio thread's monitor tap into `ebur128` with
`Mode::M | S | I | TRUE_PEAK`.
- Computes `target_gain_dB = target_lufs measured_lufs`.
- Smooths with separate attack/release coefficients (leaky integrator).
- Gates when momentary loudness < silence threshold.
- Soft-clamps so the AGC can never push more than ±N dB (profile knob).
- Writes the new gain target into the audio thread via an `rtrb` queue.
The AGC's gain is applied *before* the compressor. The compressor and
limiter still own their own behaviour and ceilings.
### 3.4 Measurement: `ebur128`
`Mode::M | S | I | TRUE_PEAK`. EBU TECH 3341/3342 conformant via the
`ebur128` crate. Constructed on the daemon thread; fed from a ring-buffer
consumer that pulls from the audio thread. The audio thread allocates
nothing.
This is **bus-level** measurement only — used to drive the slow AGC
loop and meter the processed sink output. Per-app measurement (§4)
uses a different, much cheaper metric.
---
## 4. Per-application level control (Layer A)
An opt-in, near-zero-latency feedback loop that watches each managed
application's output stream and adjusts its `Props.channelVolumes`
multiplier in response to **two parallel level metrics**:
- a **fast peak envelope** that catches short bursts and sustained
loud passages (think: a notification ding, a video that just got
louder), and
- a **slow RMS envelope** that catches *sustained loudness*
mismatches (think: "Discord is permanently louder than everything
else even when nobody's shouting").
A stream's applied gain reduction is `max(peak_reduction,
rms_reduction)` — whichever path is asking for more cut wins, and
recovery only happens when *both* paths agree the stream has settled.
This is the layer's whole point: the peak path handles transients
within one quantum; the RMS path keeps long-term inter-app loudness
balanced. Neither alone is enough.
Orthogonal to bus routing — a stream can be processed *or* bypassed
*and* level-controlled independently. Its goal is "tame noisy apps
without startling the listener and without making the chronic
loudmouth permanently dominate," while the signal path itself stays
untouched.
### 4.1 Why this is zero-latency
The per-app multiplier is the `channelVolumes` value PipeWire already
applies inside the app's stream node — it's the same number
`pavucontrol`'s per-app slider writes to. Adjusting it doesn't insert
a graph node; nothing new sits between the app and its destination
sink. The only cost is that **the analysis happens via a sibling
fanout link**, not in the playback path: PipeWire schedules fanout
consumers in parallel within the same quantum, so the playback path's
timing is identical to the no-tap case.
```
┌──► passive tap (analysis only)
│ │
│ ▼
│ peak + RMS envelopes
│ (audio thread, sub-ms)
app stream ──────┤ │
(output port) │ ▼
│ rtrb push
│ │
│ ▼
│ AppLevelController (daemon thread)
│ │
│ │ Props.channelVolumes write
│ ▼ (back into the app stream node)
│ ┌─────────────────────┐
└──►│ app stream multiplies
│ by channelVolumes, │──► (its sink — Layer B)
│ then publishes. │
└─────────────────────┘
```
### 4.2 The metrics: peak + RMS, no LUFS
LUFS is the wrong measurement here. Its shortest window (momentary,
400 ms) blurs out exactly the transients we want to catch, and the
K-weighting filter adds CPU for no benefit when we're trying to react
fast. We also explicitly want a *second* path that targets sustained
loudness — for that, plain mean-square RMS is the right cheap stand-in,
not LUFS.
| Metric | Window | Job |
|---|---|---|
| **Peak envelope**`max(\|samples\|)` per block, smoothed | ~100 ms attack window, ~500 ms release | Fast: catches a notification ding, a clip getting louder, a partner standing up and shouting. Triggers cut on `peak_threshold_db` (default 6 dBFS). |
| **RMS envelope** — block mean-square, smoothed | ~12 s | Slow: catches "this app is just chronically louder than everything else." Triggers cut on `rms_target_db` (default ≈ 20 dBFS RMS). |
Both are computed from the *same* raw buffer in the audio thread, so
the audio-thread cost is one additional MAC accumulator and a max-
scan per sample. Cost analysis in §4.7.
### 4.3 Architecture
For each managed playback stream (matched by routing rule — see §6):
1. **Audio thread (tap stream's process callback):**
- Pull the buffer from the fanout link.
- `peak = max(|samples|)` over the block.
- `mean_sq = Σ(x*x) / n` over the block.
- Push `{node_id, peak, mean_sq}` to a per-stream `rtrb`.
2. **Daemon thread (`AppLevelController` per stream):**
- Drain the rtrb.
- Update peak envelope (one-pole, fast α — attack within a block,
release ~500 ms).
- Update RMS envelope (one-pole, slow α — window ~12 s).
- Compute `peak_reduction_db` and `rms_reduction_db` independently,
then `proposed = max(peak_reduction_db, rms_reduction_db)`.
- Smooth toward `proposed`.
- If the smoothed value is significantly different from
last-written AND we're not rate-limited (~10 Hz max writes per
stream), submit `Props.channelVolumes` update.
The recovery condition is intentionally *both*-paths-agree: a
release on the peak path only counts toward unwinding gain
reduction if the RMS path also reads quiet. This avoids the pumping
artefact where a transient-heavy stream would rapidly release
between transients only to be slapped back down on the next one.
### 4.4 Honouring user-set volumes
The daemon subscribes to `Props` param-change events on each managed
stream. When a `channelVolumes` change arrives that's meaningfully
different from `last_written_volume`, it wasn't us — the user
adjusted via pavucontrol, a hotkey, an app's own UI, etc. The
controller then either:
- **defers entirely** (stops adjusting the stream until the user opts
back in via `headroom per-app reset <app>`), or
- **treats the user value as a ceiling** (continues to cut on spikes
but never raises above what the user wanted).
Default is the ceiling behaviour — it's the principle-of-least-surprise
choice. Users who want strict deference set a profile flag.
#### A historical concern: apps that fight back
Some PulseAudio-era apps (Discord most famously) used to read and
re-assert their own `channelVolumes` periodically, fighting any
external volume manager. The pattern produced a visible ping-pong
loop and effectively disabled per-app management.
The pattern is largely absent from modern PipeWire-native and
Electron-based apps in 2024+: in-app sliders write `channelVolumes`
only on user interaction, not on a timer. From Headroom's
perspective, those user-interaction writes are indistinguishable from
a pavucontrol slider move — both are legitimate external changes the
deference policy correctly yields to.
If a fight-back app does appear, the **ceiling** deference mode
degrades gracefully:
- App produces hot output → Headroom cuts to 0.5.
- App writes `channelVolumes = 1.0` back over our cut.
- Headroom detects the external change, marks the new value
(1.0) as the ceiling, and stops actively writing.
- Layer A becomes effectively inert for that stream — there is no
ping-pong, the user just doesn't get the per-app cut they were
hoping for. The bus-level Layer C limiter (if engaged) still
enforces the absolute output ceiling regardless.
Explicit pattern detection and rate-limiting of ceiling updates
(e.g., "ignore ceiling-restoring writes that arrive within N seconds
of our own writes") is deferred to v1, pending evidence from
real-world testing that any modern app warrants it. The graceful
degradation property is the v0 contract.
### 4.5 Reaction-time honesty
The signal-path latency is **zero**. The reaction latency to a spike
is bounded by:
```
spike in block N ─► analysis (same quantum)
─► rtrb push (ns)
─► controller computes (μs)
─► Props write to pw main loop
─► applied to block N+1 of the app stream
```
So sustained loud passages are attenuated within ~one quantum
(520 ms depending on the system's quantum). **Isolated one-block
transients still leak through** — the first block carrying the spike
plays with the old gain; subsequent blocks see the reduction. This
is the irreducible cost of "no lookahead allowed." For absolute
spike prevention you need lookahead, which means latency, which
contradicts the constraint of this layer.
The bus-level Layer C limiter (§3.1) catches anything that would
exceed the absolute ceiling regardless of whether Layer A has caught
up. Layer A reduces *workload* on Layer C by pre-attenuating noisy
apps; it doesn't replace it.
### 4.6 Layered budget summary
| Layer | Metric | Time scale | Signal-path latency added |
|---|---|---|---|
| A: per-app peak | sample peak per block | tens of ms | **0** |
| A: per-app RMS | block mean-square | seconds | **0** |
| C: inline soft tier | true-peak, lookahead | sub-ms | shared with hard tier |
| C: inline hard tier | true-peak, lookahead | sub-ms | ~2 ms lookahead |
| C: bus AGC | LUFS (ebur128) | many seconds | — (control plane only) |
Five distinct jobs, five distinct time scales, no two layers
duplicate each other. Layer A is the cheapest line of defense and
the only one that costs zero latency on the audio path.
### 4.7 Resource budget per stream
| | No TRUE_PEAK (recommended for Layer A) |
|---|---|
| Audio thread per quantum | ~10 μs (peak + RMS pass) |
| Daemon thread per measurement | ~few μs (HashMap lookup + envelope math) |
| Memory per controller | ~100 bytes |
| Memory per ebur128 (if enabled) | — N/A; Layer A doesn't use ebur128 |
At realistic stream counts (25 managed apps): **<0.5% CPU total,
<1 KB RAM total**. Doesn't move the needle.
### 4.8 Lifecycle
- **Stream appears** with `media.class = Stream/Output/Audio`
matching a `[[per_app.rules]]` pattern: create tap link
(`pw_link_create`), spawn controller, register rtrb.
- **Stream disappears** (`pw_registry::global_removed`): tear down
tap, drop controller, clean up rtrb.
- **App restarts**: new `node_id` → fresh controller. User-volume
deference state is per-stream-instance, which is the right default.
---
## 5. PipeWire integration
### 4.1 Sinks
Created on daemon startup by emitting a `pipewire.conf.d` fragment into
`$XDG_CONFIG_HOME/pipewire/pipewire.conf.d/headroom.conf` (if not already
present) and reloading. Alternative: create them at runtime via
`pw-loopback` equivalents using `pipewire-rs`. v0 ships with the
runtime-creation path so the install footprint is "one binary, one
unit file."
Sink properties:
- `headroom-processed`: `node.name=headroom-processed`,
`media.class=Audio/Sink`, `audio.position=[FL,FR]`,
`node.description="Headroom (processed)"`. Promoted to system
default on startup so new streams land in it by default.
There is no second sink. Bypassed streams are routed directly at the
current `preferred_real_sink` via `target.object` metadata writes
(see §4.3).
### 4.2 The filter
Two `pw_stream`s:
- **Capture stream** linked to `headroom-processed`'s monitor. Format:
`F32 LE`, channels 2, rate matched to real sink, latency-quantum
matched (default 1024 frames; configurable).
- **Playback stream** linked to the current `preferred_real_sink`.
Same format.
`process` callback: pull a buffer from capture, run AGC gain →
compressor → limiter → push to playback. Allocation-free. Parameter
updates arrive over an `rtrb` SPSC queue from the control thread.
### 4.3 Routing
- Subscribe to `pw_registry` global-added events.
- On any new node with `media.class == "Stream/Output/Audio"` and
`node.dont-move != true`:
- Read `application.process.binary`, `application.name`,
`pipewire.access.portal.app_id`, `media.role`.
- Evaluate routing rules from the active profile to decide
`processed` vs. `bypass`.
- Write `target.object` into the `default` metadata:
- `processed``headroom-processed`'s `object.serial`.
- `bypass``preferred_real_sink`'s `object.serial`.
WirePlumber honours this for any movable stream.
- Watch `default.audio.sink` metadata changes. When the user switches
the system default to a hardware sink, the daemon:
- records that sink as the new `preferred_real_sink`,
- re-links `headroom-filter`'s playback stream to it,
- rewrites `target.object` for every currently-bypassed stream so
they follow the new hardware,
- re-asserts `headroom-processed` as the *default for new streams*
(so subsequent app launches still land in the processor).
- Hotplug (sink appears/disappears) goes through the same code path.
### 4.4 Stream identification
| Property | Reliability | Use |
|---|---|---|
| `application.process.binary` | high (kernel-sourced) | primary key |
| `application.name` | medium | secondary / display |
| `pipewire.access.portal.app_id` | high (Flatpak only) | match sandboxed apps |
| `media.role` | low (most apps omit) | bonus signal only |
| `media.class` | structural | gate to playback streams |
---
## 6. Profiles
Location: `$XDG_CONFIG_HOME/headroom/profiles/*.toml` (overriding
shipped defaults in `/usr/share/headroom/profiles/` if installed
system-wide). Hot-reloaded via `notify-debouncer-mini`.
Each profile is a complete listening scenario. Schema (`headroom-core::profile`):
```toml
name = "default"
description = "Gentle transparent processing for everyday use."
[agc]
enabled = true
target_lufs = -18.0 # ITU-R BS.1770 integrated target
attack_ms = 2000.0
release_ms = 800.0
silence_threshold_lufs = -70.0
max_boost_db = 12.0
max_cut_db = 12.0
[compressor]
enabled = true
detector = "peak" # "peak" | "rms"
threshold_db = -24.0
ratio = 2.5
knee_db = 6.0
attack_ms = 10.0
release_ms = 100.0
makeup_db = "auto" # number or "auto"
[limiter]
ceiling_dbtp = -0.1
lookahead_ms = 2.0
release_ms = 80.0
hold_ms = 5.0
oversample = 4 # 1 | 2 | 4 | 8 (1 disables ISP detection)
link = "stereo" # "stereo" | "dual-mono"
[meters]
publish_hz = 20.0
[[rules]]
match = { process_binary = ["spotify", "mpv", "ardour", "reaper", "qpwgraph"] }
route = "bypass"
[[rules]]
match = { process_binary = ["firefox", "chromium", "google-chrome", "Discord", "discord", "element-desktop", "Slack", "zoom", "WEBRTC VoiceEngine"] }
route = "processed"
[default_route]
route = "processed" # safe default: anything unmatched is processed
# ----------------------------------------------------------------------
# Per-application level control (Layer A). Orthogonal to routing — you
# can enable per-app on bypass-routed streams to get zero-latency
# level control (e.g. tame Discord notifications without touching
# the game's audio path).
# ----------------------------------------------------------------------
[per_app]
enabled = true # master switch; false disables Layer A entirely
default_enabled = false # for streams not matched by any rule below
# Per-rule knobs. Matches use the same key set as [[rules]] above.
[[per_app.rules]]
match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom"] }
enabled = true
peak_threshold_db = -6.0 # short-window peak above this triggers cut
rms_target_db = -20.0 # long-term RMS target (slow path)
max_cut_db = 12.0 # never cut more than this
peak_attack_ms = 5.0
peak_release_ms = 500.0
rms_window_ms = 1500.0
defer_to_user = "ceiling" # "ceiling" | "strict"
[[per_app.rules]]
match = { process_binary = ["firefox", "chromium", "google-chrome"] }
enabled = true
peak_threshold_db = -3.0 # browsers run hotter; raise the trigger
rms_target_db = -18.0
# Music, DAWs, games default to per-app off — they're either trusted
# to set their own level or routed bypass for a reason.
[[per_app.rules]]
match = { process_binary = ["spotify", "mpv", "ardour", "reaper", "qpwgraph", "carla"] }
enabled = false
```
### Shipped profiles
| name | one-liner |
|---|---|
| `default` | Gentle transparent processing, sensible for daily use. |
| `night` | Aggressive: 20 LUFS, 4:1, fast release, narrow dynamic range. |
| `speech` | VoIP-focused; short attack, fast release, slight rumble cut. |
| `transparent` | Limiter only. Compressor + AGC bypassed. Safety net only. |
| `bypass-all` | Routes everything directly to the real sink. The kill switch. |
The limiter section of `bypass-all` is irrelevant in practice (nothing
flows through `headroom-processed`), but its ceiling field is still
respected as a fail-safe in case a stream lands on the processed sink
anyway.
---
## 7. IPC
Transport: Unix-domain socket, `SOCK_STREAM`, `0600`, at
`$XDG_RUNTIME_DIR/headroom/control.sock`.
Wire protocol: **see `IPC.md`** for the full normative schema.
Summary: u32 BE length prefix + UTF-8 JSON payload. Three message
shapes — `Request` (id + op + args), `Response` (id + result|error),
`Event` (topic + data). Subscribers signal interest by topic; events
fan out to all subscribers with bounded per-subscriber queues. Slow
subscribers have events **dropped** (overflow events count is itself
published on the `daemon` topic so clients know they fell behind).
The first-party Rust wrapper is `headroom-client`, mirroring how
[`niri-ipc`](https://github.com/YaLTeR/niri/tree/main/niri-ipc) wraps
Niri's socket: a thin, no-magic crate that re-exports the wire types
from `headroom-ipc` and adds a blocking `Client` (and an optional async
`AsyncClient` behind a feature flag).
---
## 8. CLI
```
headroom status # current profile, sinks, levels
headroom daemon # run the daemon (systemd Type=simple)
headroom profile list | use <name> | show [name]
headroom route list
headroom route set <app> processed|bypass # persists in user profile
headroom route unset <app>
headroom route stream <node-id> processed|bypass # ad-hoc
headroom set <key> <value> # tweak active profile in place
headroom get <key>
headroom bypass on|off # global kill switch
headroom reload # reload profiles from disk
headroom monitor # live meter TUI (uses subscribe)
```
CLI is sync, blocks on `UnixStream`. Talks the same JSON wire as any
other client.
---
## 9. Crates
```
headroom/
├── flake.nix # devshell + package
├── Cargo.toml # workspace
├── PLAN.md # this file
├── IPC.md # wire-protocol schema (normative)
├── README.md
└── crates/
├── headroom-dsp/ # AGC + compressor + limiter (pure DSP, no PW)
├── headroom-ipc/ # wire types, framing, serde; no I/O
├── headroom-client/ # blocking client (+ optional async); thin
├── headroom-core/ # daemon: PW integration, routing, profiles, IPC server
└── headroom-cli/ # `headroom` binary; depends on headroom-client
```
### External crates (final v0 dep list)
**Audio / DSP**
- `pipewire`, `libspa` — official PipeWire bindings.
- `ebur128` — measurement.
- `rtrb` — SPSC ring buffer (audio ↔ control).
- `basedrop` — RT-safe shared ownership.
- `assert_no_alloc` — debug-build tripwire.
**Plumbing**
- `serde`, `serde_json` — IPC + profile (de)serialization.
- `serde-toml` (`toml`) — profile files.
- `clap` (derive) — CLI.
- `tracing`, `tracing-subscriber`, `tracing-journald` — logs.
- `notify`, `notify-debouncer-mini` — profile hot-reload.
- `crossbeam-channel` — control-plane channels.
- `parking_lot` — mutexes.
- `signal-hook` — clean shutdown.
- `thiserror` — error types.
No `tokio`, no `zbus`, no `dbus-*`.
---
## 10. Nix
`flake.nix` ships:
- A **devshell** with rust toolchain (via `rust-overlay` for pinned
channel; default to a stable release pinned in
`rust-toolchain.toml`), `pkg-config`, `pipewire`'s dev outputs,
`clang` (for bindgen if invoked by deps), `socat` (handy for poking
the IPC), `jq`.
- A **package** output (`packages.<system>.default`) that builds the
daemon + CLI with `rustPlatform.buildRustPackage`. v0 uses
`cargoLock.lockFile`. Crane can come later if incremental builds in
CI become a bottleneck.
- A `nixosModules.default` placeholder so packagers can wire the user
unit later. Not implemented in v0 of the flake itself.
Intermediate dev work uses plain `cargo` inside `nix develop`. Final
builds and any CI go through `nix build`.
---
## 11. Phased implementation
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)*
**Phase 1 — IPC + client.** `headroom-ipc` (types, framing, codec) and
`headroom-client` (blocking `Client`) implemented against the schema in
`IPC.md`. Round-trip tests, fuzz the codec. *(this commit)*
**Phase 2 — DSP kernels.** `headroom-dsp` with limiter, compressor, AGC,
oversampler, envelope. Tested in isolation against synthesized
signals; limiter validated to hold a 0.1 dBTP ceiling on EBU TECH
3341 generators. *(this commit: limiter first)*
**Phase 3 — daemon core.** `headroom-core` brings up the
`headroom-processed` virtual sink, the filter (pw_stream pair),
the `preferred_real_sink` tracker, the registry subscriber, and the
routing engine. Hardcoded profile, no IPC server yet.
**Phase 4 — IPC server + profile manager.** Wire `headroom-core` to the
IPC schema. Profile loading + hot-reload. Slow AGC loop ticking on
real loudness measurements.
**Phase 5 — CLI + monitor TUI.** `headroom-cli` implements all the
subcommands above, plus a `monitor` TUI built on the meters
subscription.
**Phase 6 — Per-application level control (Layer A).** Per-managed-stream
tap creation, `AppLevelController` with peak + RMS envelopes,
`Props.channelVolumes` writer, user-volume deference logic,
`[per_app]` profile parsing, `headroom per-app …` CLI verbs, and a
per-stream meter event on the IPC. Land after the bus path is stable
so we have a baseline to compare against.
**Phase 7 — Packaging.** systemd user unit, install paths, default
profile install, basic NixOS module.
**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
- **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.
- **Latency budget.** Processed path: one quantum hop (the filter)
plus lookahead (~2 ms) plus 4× oversampling buffering ≈ 815 ms
added to processed-path latency. Fine for video/voice. Bypass path:
**zero added latency** — the stream rides the real sink directly.
- **Default-sink changes.** When the user switches the system default
to a hardware sink, the daemon adopts it as `preferred_real_sink`,
re-links the filter's playback, retargets bypassed streams, and
re-asserts `headroom-processed` as the default for new streams.
Watching `default.audio.sink` in the metadata is the trigger.
- **Sample-rate mismatch.** `headroom-processed`, the filter, and the
real sink must agree, or PipeWire resamples behind our back. The
filter should source its rate from the real sink and convert on the
capture side only.
- **Surround content downmix vs. passthrough.** v0 punts: anything
>2ch is routed directly to the real sink (bypass behaviour)
regardless of profile rule. Documented behaviour.
---
## 13. License
GPL-3.0-or-later for the daemon and CLI. `headroom-dsp` and `headroom-ipc`
are MPL-2.0 so third-party clients and plugin hosts can link them
without GPL contagion. (Re-evaluate when LSP-derived code is
introduced; current plan does not pull any.)

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# headroom
AGC + compressor + true-peak limiter daemon for PipeWire, in Rust.
Headroom puts a per-application audio safety net between noisy sources
(browsers, voice chat, random video) and your speakers, while leaving
the things you *don't* want compressed (music players, games, DAWs)
untouched.
- **Hard 0.1 dBTP ceiling** with proper inter-sample-peak handling.
- **Per-app exclusion** with profile-driven rules.
- **Single binary** daemon + CLI, controlled over a Unix-domain socket
with a documented JSON wire protocol (see [`IPC.md`](IPC.md)).
- **First-party Rust crate** (`headroom-client`) for programmatic use;
third-party clients (Qt panels, status bars, …) target the wire
protocol directly.
See [`PLAN.md`](PLAN.md) for the full design and roadmap.
## Status
Pre-alpha. Wire protocol and crate scaffolding are in; daemon and
filter are under construction.
## Building
```sh
nix develop # toolchain + pipewire dev libs + helpers
cargo build # iterate
nix build # final packaged headroom binary
```
## License
GPL-3.0-or-later for the daemon and CLI. `headroom-dsp` and `headroom-ipc`
are MPL-2.0 so they can be reused by non-GPL plugin hosts and clients.

View file

@ -0,0 +1,25 @@
[package]
name = "headroom-cli"
description = "Headroom CLI binary."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[[bin]]
name = "headroom"
path = "src/main.rs"
[dependencies]
headroom-client = { workspace = true }
headroom-core = { workspace = true }
headroom-ipc = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View file

@ -0,0 +1,256 @@
//! `headroom` — the user-facing CLI binary.
//!
//! For every subcommand other than `daemon`, this binary connects to
//! the running daemon over its Unix-domain socket and issues the
//! corresponding op. `daemon` enters [`headroom_core::run`] directly.
#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use headroom_client::{Client, ClientError, Route, Topic};
/// Headroom CLI.
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Cli {
/// Override the daemon control socket path.
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Debug, Subcommand)]
enum Cmd {
/// Run the headroom daemon in the foreground.
Daemon,
/// Show daemon status (active profile, sinks, current streams).
Status,
/// Profile management.
#[command(subcommand)]
Profile(ProfileCmd),
/// Routing rules and per-stream decisions.
#[command(subcommand)]
Route(RouteCmd),
/// Get a setting value from the active profile.
Get {
/// Dotted setting key.
key: String,
},
/// Set a setting value in the active profile.
Set {
/// Dotted setting key.
key: String,
/// New value, JSON-encoded.
value: String,
},
/// Toggle the global bypass kill switch.
Bypass {
/// `on` or `off`.
#[arg(value_enum)]
state: BypassState,
},
/// Reload profile files from disk.
Reload,
/// Subscribe to meter ticks and print as line-delimited JSON.
Monitor,
}
#[derive(Debug, Subcommand)]
enum ProfileCmd {
/// List known profiles.
List,
/// Activate the named profile.
Use {
/// Profile name.
name: String,
},
/// Show a profile in full.
Show {
/// Profile name (defaults to the active profile).
name: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum RouteCmd {
/// List routing rules and current per-stream decisions.
List,
/// Add or replace a routing rule for an app.
Set {
/// Application identifier (e.g. `application.process.binary`).
app: String,
/// Where to route.
#[arg(value_enum)]
to: RouteArg,
},
/// Remove an app's user routing rule.
Unset {
/// Application identifier.
app: String,
},
/// Reroute a specific live stream by node id.
Stream {
/// PipeWire node id.
node_id: u32,
/// Where to route.
#[arg(value_enum)]
to: RouteArg,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum BypassState {
On,
Off,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RouteArg {
Processed,
Bypass,
}
impl From<RouteArg> for Route {
fn from(r: RouteArg) -> Self {
match r {
RouteArg::Processed => Route::Processed,
RouteArg::Bypass => Route::Bypass,
}
}
}
fn init_tracing() {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info"));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.compact()
.init();
}
fn run() -> Result<(), CliError> {
let cli = Cli::parse();
init_tracing();
match cli.cmd {
Cmd::Daemon => {
headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?;
Ok(())
}
cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
}
}
fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError>
where
F: FnOnce(&mut Client) -> Result<(), CliError>,
{
let mut client = match socket {
Some(p) => Client::connect_at(p)?,
None => Client::connect()?,
};
f(&mut client)
}
fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
match cmd {
Cmd::Daemon => unreachable!("handled in `run`"),
Cmd::Status => {
let status = client.status()?;
println!("{}", serde_json::to_string_pretty(&status)?);
}
Cmd::Profile(ProfileCmd::List) => {
let profiles = client.profile_list()?;
for p in profiles {
let marker = if p.active { '*' } else { ' ' };
println!("{marker} {:<16} {}", p.name, p.description);
}
}
Cmd::Profile(ProfileCmd::Use { name }) => {
let active = client.profile_use(&name)?;
println!("active profile: {active}");
}
Cmd::Profile(ProfileCmd::Show { name }) => {
let body = client.profile_show(name.as_deref())?;
println!("{}", serde_json::to_string_pretty(&body)?);
}
Cmd::Route(RouteCmd::List) => {
let list = client.route_list()?;
println!("{}", serde_json::to_string_pretty(&list)?);
}
Cmd::Route(RouteCmd::Set { app, to }) => {
client.route_set(&app, to.into())?;
}
Cmd::Route(RouteCmd::Unset { app }) => {
client.route_unset(&app)?;
}
Cmd::Route(RouteCmd::Stream { node_id, to }) => {
client.route_stream(node_id, to.into())?;
}
Cmd::Get { key } => {
let v = client.setting_get(&key)?;
println!("{}", serde_json::to_string(&v)?);
}
Cmd::Set { key, value } => {
let parsed: serde_json::Value = serde_json::from_str(&value)
.map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?;
client.setting_set(&key, parsed)?;
}
Cmd::Bypass { state } => {
client.bypass_set(matches!(state, BypassState::On))?;
}
Cmd::Reload => {
let reloaded = client.profile_reload()?;
println!("reloaded: {reloaded:?}");
}
Cmd::Monitor => {
client.subscribe(&[Topic::Meters])?;
loop {
let ev = client.next_event()?;
println!("{}", serde_json::to_string(&ev.data)?);
}
}
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("client: {0}")]
Client(#[from] ClientError),
#[error("daemon: {0}")]
Daemon(String),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Other(String),
}
fn main() -> ExitCode {
if let Err(e) = run() {
eprintln!("headroom: {e}");
return ExitCode::from(1);
}
ExitCode::SUCCESS
}

View file

@ -0,0 +1,22 @@
[package]
name = "headroom-client"
description = "Blocking Rust client for the Headroom control protocol."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MPL-2.0"
homepage.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
[dependencies]
headroom-ipc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[features]
default = []
# Reserved for an eventual `tokio::net::UnixStream`-based async client.
async = []

View file

@ -0,0 +1,30 @@
# headroom-client
Blocking Rust client for the Headroom control protocol.
```rust
use headroom_client::{Client, Route};
use headroom_ipc::Topic;
let mut client = Client::connect()?;
println!("connected to headroom {}", client.hello().version);
client.profile_use("night")?;
client.route_set("firefox", Route::Processed)?;
client.subscribe(&[Topic::Meters])?;
loop {
let event = client.next_event()?;
println!("{}/{}: {}", event.topic, event.event, event.data);
}
# Ok::<(), headroom_client::ClientError>(())
```
The crate is a thin layer over [`headroom-ipc`](../headroom-ipc) — it
re-exports the wire types and adds a `Client` that owns a `UnixStream`,
correlates responses by `id`, and queues stray events received while a
request is in flight.
## License
MPL-2.0. Safe to depend on from non-GPL clients.

View file

@ -0,0 +1,498 @@
//! The blocking [`Client`].
use std::collections::VecDeque;
use std::io::{BufReader, BufWriter};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use headroom_ipc::{
default_socket_path, Codec, Event, HelloData, Op, ProtoError, Request, Response,
ResponsePayload, Route, ServerFrame, Status, Topic,
};
/// Errors produced by the blocking client.
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
/// I/O or codec failure on the underlying socket.
#[error("ipc: {0}")]
Ipc(#[from] headroom_ipc::Error),
/// The server's first frame was not the expected `hello` event.
#[error("expected hello event from server, got {0}")]
BadHello(String),
/// The server sent a response with an id we never issued.
#[error("response with unknown id {0}")]
UnknownResponseId(u64),
/// The server returned a protocol-level error for an op.
#[error("server error: {0}")]
Protocol(#[from] ProtoError),
/// Could not determine the default socket path.
#[error("no default socket path (XDG_RUNTIME_DIR unset and /proc/self/status unreadable)")]
NoDefaultPath,
/// A typed-helper response failed to deserialize into the expected
/// shape.
#[error("response shape mismatch: {0}")]
DecodeResult(serde_json::Error),
}
/// Blocking client for the Headroom control protocol.
///
/// Owns a connected `UnixStream`. Single-threaded by construction; do
/// not share across threads. If you need to do request/response on one
/// connection while another consumes events, open two connections.
pub struct Client {
reader: BufReader<UnixStream>,
writer: BufWriter<UnixStream>,
codec: Codec,
next_id: u64,
pending_events: VecDeque<Event>,
hello: HelloData,
socket_path: PathBuf,
}
impl Client {
/// Connect to the headroom daemon at its default socket path.
pub fn connect() -> Result<Self, ClientError> {
let path = default_socket_path().ok_or(ClientError::NoDefaultPath)?;
Self::connect_at(&path)
}
/// Connect to the headroom daemon at the given socket path.
pub fn connect_at(path: &Path) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path).map_err(|e| ClientError::Ipc(e.into()))?;
let reader_half = stream.try_clone().map_err(|e| ClientError::Ipc(e.into()))?;
let writer_half = stream;
let mut me = Self {
reader: BufReader::new(reader_half),
writer: BufWriter::new(writer_half),
codec: Codec::new(),
next_id: 1,
pending_events: VecDeque::new(),
// Placeholder; populated immediately below.
hello: HelloData {
daemon: String::new(),
version: String::new(),
protocol: 0,
},
socket_path: path.to_path_buf(),
};
me.handshake()?;
Ok(me)
}
fn handshake(&mut self) -> Result<(), ClientError> {
let frame: ServerFrame = self.codec.read(&mut self.reader)?;
match frame {
ServerFrame::Event(ev)
if ev.topic == Topic::Control && ev.event.as_str() == "hello" =>
{
let hello: HelloData =
serde_json::from_value(ev.data).map_err(ClientError::DecodeResult)?;
self.hello = hello;
Ok(())
}
ServerFrame::Event(ev) => Err(ClientError::BadHello(format!(
"{} event on {}",
ev.event, ev.topic
))),
ServerFrame::Response(r) => Err(ClientError::BadHello(format!("response id={}", r.id))),
}
}
/// The `hello` payload received on connect.
#[must_use]
pub fn hello(&self) -> &HelloData {
&self.hello
}
/// The socket path this client is connected to.
#[must_use]
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
fn alloc_id(&mut self) -> u64 {
let id = self.next_id;
// Wrap unconditionally — `u64::MAX` requests on one connection
// is the universe heat-death threshold; correctness, not perf.
self.next_id = self.next_id.wrapping_add(1);
id
}
/// Send a request and block until the paired response arrives.
///
/// Stray events received in the meantime are queued and surfaced
/// by subsequent [`next_event`](Self::next_event) calls.
pub fn send(&mut self, op: Op) -> Result<serde_json::Value, ClientError> {
let payload = self.send_raw(op)?;
match payload {
ResponsePayload::Ok { result } => Ok(result),
ResponsePayload::Err { error } => Err(ClientError::Protocol(error)),
}
}
/// Like [`send`](Self::send) but returns the raw [`ResponsePayload`].
///
/// Useful when you need the protocol-level error in-band rather
/// than as a [`ClientError::Protocol`].
pub fn send_raw(&mut self, op: Op) -> Result<ResponsePayload, ClientError> {
let id = self.alloc_id();
let req = Request::new(id, op);
self.codec.write(&mut self.writer, &req)?;
loop {
let frame: ServerFrame = self.codec.read(&mut self.reader)?;
match frame {
ServerFrame::Response(Response {
id: rid,
payload: _,
}) if rid != id => {
return Err(ClientError::UnknownResponseId(rid));
}
ServerFrame::Response(Response { payload, .. }) => return Ok(payload),
ServerFrame::Event(ev) => {
self.pending_events.push_back(ev);
}
}
}
}
/// Block until the next event arrives.
///
/// Drains the internal queue first; only then reads from the socket.
/// If a response is read instead of an event, it is rejected as
/// [`ClientError::UnknownResponseId`] — meaning the client issued
/// no matching request, so the response is unsolicited.
pub fn next_event(&mut self) -> Result<Event, ClientError> {
if let Some(ev) = self.pending_events.pop_front() {
return Ok(ev);
}
match self.codec.read::<_, ServerFrame>(&mut self.reader)? {
ServerFrame::Event(ev) => Ok(ev),
ServerFrame::Response(r) => Err(ClientError::UnknownResponseId(r.id)),
}
}
/// Return a queued event without blocking, if any.
///
/// Does **not** read from the socket. Use this in a hand-rolled
/// loop where you interleave [`send`](Self::send) with event
/// draining.
pub fn pending_event(&mut self) -> Option<Event> {
self.pending_events.pop_front()
}
// ---------------------------------------------------------------
// Typed convenience wrappers
// ---------------------------------------------------------------
fn send_into<T: DeserializeOwned>(&mut self, op: Op) -> Result<T, ClientError> {
let value = self.send(op)?;
serde_json::from_value(value).map_err(ClientError::DecodeResult)
}
/// `status`
pub fn status(&mut self) -> Result<Status, ClientError> {
self.send_into(Op::Status)
}
/// `profile.list`
pub fn profile_list(&mut self) -> Result<Vec<headroom_ipc::ProfileInfo>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
profiles: Vec<headroom_ipc::ProfileInfo>,
}
let body: Body = self.send_into(Op::ProfileList)?;
Ok(body.profiles)
}
/// `profile.use`
pub fn profile_use(&mut self, name: &str) -> Result<String, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
name: String,
}
let body: Body = self.send_into(Op::ProfileUse {
name: name.to_owned(),
})?;
Ok(body.name)
}
/// `profile.show`
pub fn profile_show(
&mut self,
name: Option<&str>,
) -> Result<serde_json::Value, ClientError> {
self.send(Op::ProfileShow {
name: name.map(String::from),
})
}
/// `profile.reload`
pub fn profile_reload(&mut self) -> Result<Vec<String>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
reloaded: Vec<String>,
}
let body: Body = self.send_into(Op::ProfileReload)?;
Ok(body.reloaded)
}
/// `route.list`
pub fn route_list(&mut self) -> Result<headroom_ipc::RouteList, ClientError> {
self.send_into(Op::RouteList)
}
/// `route.set`
pub fn route_set(&mut self, app: &str, to: Route) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::RouteSet {
app: app.to_owned(),
to,
})?;
Ok(())
}
/// `route.unset`
pub fn route_unset(&mut self, app: &str) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::RouteUnset {
app: app.to_owned(),
})?;
Ok(())
}
/// `route.stream`
pub fn route_stream(&mut self, node_id: u32, to: Route) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::RouteStream { node_id, to })?;
Ok(())
}
/// `setting.get`
pub fn setting_get(&mut self, key: &str) -> Result<serde_json::Value, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
#[allow(dead_code)]
key: String,
value: serde_json::Value,
}
let body: Body = self.send_into(Op::SettingGet {
key: key.to_owned(),
})?;
Ok(body.value)
}
/// `setting.set`
pub fn setting_set(
&mut self,
key: &str,
value: serde_json::Value,
) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::SettingSet {
key: key.to_owned(),
value,
})?;
Ok(())
}
/// `bypass.set`
pub fn bypass_set(&mut self, enabled: bool) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::BypassSet { enabled })?;
Ok(())
}
/// `subscribe`
pub fn subscribe(&mut self, topics: &[Topic]) -> Result<Vec<Topic>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
subscribed: Vec<Topic>,
}
let body: Body = self.send_into(Op::Subscribe {
topics: topics.to_vec(),
})?;
Ok(body.subscribed)
}
/// `unsubscribe`
pub fn unsubscribe(&mut self, topics: &[Topic]) -> Result<Vec<Topic>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
unsubscribed: Vec<Topic>,
}
let body: Body = self.send_into(Op::Unsubscribe {
topics: topics.to_vec(),
})?;
Ok(body.unsubscribed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufReader, BufWriter};
use std::os::unix::net::UnixStream;
use std::thread;
use headroom_ipc::{Codec, Event, HelloData, Op, Request, Response, ServerFrame, Topic};
/// A tiny in-process server that runs on the other end of a
/// `UnixStream::pair`. Knows just enough to exercise the client.
fn spawn_test_server() -> (UnixStream, thread::JoinHandle<()>) {
let (a, b) = UnixStream::pair().unwrap();
let server_handle = thread::spawn(move || {
let codec = Codec::new();
let read_side = b.try_clone().unwrap();
let mut reader = BufReader::new(read_side);
let mut writer = BufWriter::new(b);
// Send hello.
let hello = Event::new(
Topic::Control,
"hello",
&HelloData {
daemon: "headroom".into(),
version: "0.1.0-test".into(),
protocol: headroom_ipc::PROTOCOL_VERSION,
},
)
.unwrap();
codec
.write(&mut writer, &ServerFrame::Event(hello))
.unwrap();
// Serve one round.
loop {
let req: Request = match codec.read(&mut reader) {
Ok(r) => r,
Err(_) => return,
};
let resp = match req.op {
Op::Status => Response::ok(
req.id,
&serde_json::json!({
"version": "0.1.0-test",
"protocol": headroom_ipc::PROTOCOL_VERSION,
"uptime_s": 0u64,
"profile": "default",
"bypass": false,
"sinks": {
"processed": {"ready": false},
"real": {"ready": false},
},
"streams": []
}),
)
.unwrap(),
Op::ProfileUse { name } => {
Response::ok(req.id, &serde_json::json!({ "name": name })).unwrap()
}
Op::Subscribe { topics } => {
// Acknowledge, then push one event of each
// subscribed topic so the client can demonstrate
// event handling.
let body = serde_json::json!({ "subscribed": &topics });
let resp = Response::ok(req.id, &body).unwrap();
codec
.write(&mut writer, &ServerFrame::Response(resp.clone()))
.unwrap();
for t in topics {
let ev = Event::new(t, "tick", &serde_json::json!({})).unwrap();
codec.write(&mut writer, &ServerFrame::Event(ev)).unwrap();
}
continue;
}
_ => Response::ok(req.id, &serde_json::Value::Null).unwrap(),
};
codec
.write(&mut writer, &ServerFrame::Response(resp))
.unwrap();
}
});
(a, server_handle)
}
fn client_on(stream: UnixStream) -> Client {
let reader_half = stream.try_clone().unwrap();
let writer_half = stream;
let mut me = Client {
reader: BufReader::new(reader_half),
writer: BufWriter::new(writer_half),
codec: Codec::new(),
next_id: 1,
pending_events: VecDeque::new(),
hello: HelloData {
daemon: String::new(),
version: String::new(),
protocol: 0,
},
socket_path: PathBuf::from("<test>"),
};
me.handshake().unwrap();
me
}
#[test]
fn handshake_then_status() {
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
assert_eq!(client.hello().daemon, "headroom");
assert_eq!(client.hello().protocol, headroom_ipc::PROTOCOL_VERSION);
let status = client.status().unwrap();
assert_eq!(status.profile, "default");
assert!(!status.bypass);
}
#[test]
fn profile_use_returns_name() {
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
let name = client.profile_use("night").unwrap();
assert_eq!(name, "night");
}
#[test]
fn subscribe_then_consume_event() {
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
let acked = client.subscribe(&[Topic::Meters]).unwrap();
assert_eq!(acked, vec![Topic::Meters]);
let ev = client.next_event().unwrap();
assert_eq!(ev.topic, Topic::Meters);
assert_eq!(ev.event, "tick");
}
#[test]
fn events_interleaved_during_request_are_queued() {
// The test server pushes events *after* the subscribe response,
// so let's check that requesting another op afterwards drains
// them through the queue.
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
client.subscribe(&[Topic::Meters, Topic::Profile]).unwrap();
// Now issue another request. The server hasn't sent the events
// until we read more, but our client will keep reading.
let status = client.status().unwrap();
assert_eq!(status.profile, "default");
// We may have buffered events from the prior subscribe and from
// any in flight; drain them.
let mut topics = Vec::new();
while let Some(ev) = client.pending_event() {
topics.push(ev.topic);
}
// The events arrived between the subscribe-ack and the status
// response; both should be queued.
assert!(topics.contains(&Topic::Meters));
assert!(topics.contains(&Topic::Profile));
}
}

View file

@ -0,0 +1,19 @@
//! Blocking client for the Headroom control protocol.
//!
//! See [`Client`] for the entry point. The wire types are re-exported
//! from [`headroom-ipc`](headroom_ipc); third-party clients that want to
//! talk JSON directly should target the spec in `IPC.md`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod client;
pub use client::{Client, ClientError};
pub use headroom_ipc::{
default_socket_path, Codec, DaemonEvent, Error as IpcError, ErrorCode, Event, HelloData,
MeterTick, Op, ProfileEvent, ProfileInfo, ProtoError, Request, Response, ResponsePayload,
Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame, SinkInfo, Sinks,
Status, StreamRoute, Topic, PROTOCOL_VERSION,
};

View file

@ -0,0 +1,39 @@
[package]
name = "headroom-core"
description = "Headroom daemon core: PipeWire integration, routing, profiles, IPC server."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[dependencies]
headroom-dsp = { workspace = true }
headroom-ipc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
crossbeam-channel = { workspace = true }
parking_lot = { workspace = true }
signal-hook = { workspace = true }
# The PipeWire and audio-thread deps are declared but not yet wired up
# in the v0 scaffolding. They are pulled in here so the workspace
# resolves a consistent dep tree from the start.
# pipewire = { workspace = true }
# libspa = { workspace = true }
# rtrb = { workspace = true }
# basedrop = { workspace = true }
# ebur128 = { workspace = true }
# notify = { workspace = true }
# notify-debouncer-mini = { workspace = true }
# tracing-journald = { workspace = true }
[features]
default = []

View file

@ -0,0 +1,31 @@
//! Headroom daemon core.
//!
//! Phase 0/1 scaffolding: this crate currently exposes only the daemon
//! entry-point shape that `headroom-cli` calls into. The real daemon
//! (PipeWire integration, routing, slow AGC loop, IPC server) lands in
//! Phase 3 and 4 per `PLAN.md`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
/// Run the daemon to completion. Currently a placeholder.
///
/// # Errors
/// Returns `Err` if startup fails. The current scaffolding always
/// returns `Ok` — it logs an "unimplemented" message and exits.
pub fn run() -> Result<(), DaemonError> {
tracing::warn!("headroom-core::run is a placeholder; daemon not yet implemented");
Ok(())
}
/// Errors from running the daemon.
#[derive(Debug, thiserror::Error)]
pub enum DaemonError {
/// I/O error.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// Generic failure with a message.
#[error("{0}")]
Other(String),
}

View file

@ -0,0 +1,19 @@
[package]
name = "headroom-dsp"
description = "DSP kernels for Headroom: true-peak limiter, compressor, AGC envelope helpers."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MPL-2.0"
homepage.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
[dependencies]
# Kept intentionally empty. The DSP crate must build clean on any host
# and is the most reusable piece in the workspace. If you find yourself
# wanting to add a dependency here, think twice.
[features]
default = []

View file

@ -0,0 +1,19 @@
# headroom-dsp
DSP kernels for Headroom. Pure Rust, no dependencies.
- `Limiter` — feed-forward true-peak brickwall with configurable
oversampling (1/2/4/8×), lookahead, hold, and release.
- `Compressor` — log-domain feed-forward with peak or RMS detector,
soft knee, attack/release, and optional auto-makeup.
- `AttackRelease` — exponential envelope follower (peak / inverse-gain
modes).
- `DelayLine`, `SlidingMaxBuffer`, `PolyphaseUpsampler`,
`PolyphaseDownsampler` — supporting building blocks.
All processors are allocation-free in their `process_*` methods.
Construction allocates; do not construct in the audio thread.
## License
MPL-2.0.

View file

@ -0,0 +1,268 @@
//! Feed-forward dynamics compressor.
//!
//! Log-domain detector → static curve with soft knee → smoothed
//! envelope → linear gain → apply (no internal delay).
use crate::util::{db_to_lin, lin_to_db, time_to_alpha};
/// Detector type used to build the side-chain signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Detector {
/// Maximum of `|left|, |right|`. Fast, low CPU, slightly more
/// percussive feel on transients.
Peak,
/// One-pole low-passed mean square. Smoother, more relaxed on
/// percussive material.
Rms,
}
/// Compressor parameters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CompressorConfig {
/// Threshold in dBFS. Inputs above this start compressing.
pub threshold_db: f32,
/// Compression ratio (>= 1.0).
pub ratio: f32,
/// Soft-knee width in dB. `0.0` is a hard knee.
pub knee_db: f32,
/// Attack time in ms.
pub attack_ms: f32,
/// Release time in ms.
pub release_ms: f32,
/// Makeup gain in dB. `None` selects an automatic mild boost.
pub makeup_db: Option<f32>,
/// Detector type.
pub detector: Detector,
/// RMS window in ms (only used when `detector == Rms`).
pub rms_window_ms: f32,
}
impl Default for CompressorConfig {
fn default() -> Self {
Self {
threshold_db: -24.0,
ratio: 2.5,
knee_db: 6.0,
attack_ms: 10.0,
release_ms: 100.0,
makeup_db: None,
detector: Detector::Peak,
rms_window_ms: 5.0,
}
}
}
impl CompressorConfig {
/// Clamp ratio to `>= 1.0`, knee/attack/release/window to `>= 0`.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.ratio < 1.0 {
self.ratio = 1.0;
}
self.knee_db = self.knee_db.max(0.0);
self.attack_ms = self.attack_ms.max(0.0);
self.release_ms = self.release_ms.max(0.0);
self.rms_window_ms = self.rms_window_ms.max(0.1);
self
}
}
/// Feed-forward compressor (stereo-linked).
pub struct Compressor {
cfg: CompressorConfig,
sample_rate: f32,
envelope_db: f32,
attack_alpha: f32,
release_alpha: f32,
rms_state: f32,
rms_alpha: f32,
last_gr_db: f32,
}
impl Compressor {
/// Construct with the given config and input sample rate.
#[must_use]
pub fn new(cfg: CompressorConfig, sample_rate: f32) -> Self {
let cfg = cfg.sanitized();
Self {
cfg,
sample_rate,
envelope_db: -200.0,
attack_alpha: time_to_alpha(cfg.attack_ms, sample_rate),
release_alpha: time_to_alpha(cfg.release_ms, sample_rate),
rms_state: 0.0,
rms_alpha: time_to_alpha(cfg.rms_window_ms, sample_rate),
last_gr_db: 0.0,
}
}
/// Active configuration.
#[must_use]
pub fn config(&self) -> CompressorConfig {
self.cfg
}
/// Most recent gain reduction in dB (negative when compressing).
#[must_use]
pub fn gain_reduction_db(&self) -> f32 {
self.last_gr_db
}
/// 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();
self.cfg = cfg;
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);
}
/// Process one stereo frame.
pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
let det_lin = match self.cfg.detector {
Detector::Peak => left.abs().max(right.abs()),
Detector::Rms => {
let sq = 0.5 * left.mul_add(left, right * right);
self.rms_state += self.rms_alpha * (sq - self.rms_state);
self.rms_state.max(0.0).sqrt()
}
};
let det_db = lin_to_db(det_lin.max(1e-20));
if det_db > self.envelope_db {
self.envelope_db += self.attack_alpha * (det_db - self.envelope_db);
} else {
self.envelope_db += self.release_alpha * (det_db - self.envelope_db);
}
let gr_db = static_curve_gain_reduction(
self.envelope_db,
self.cfg.threshold_db,
self.cfg.ratio,
self.cfg.knee_db,
);
let makeup_db = self
.cfg
.makeup_db
.unwrap_or_else(|| auto_makeup(self.cfg.threshold_db, self.cfg.ratio));
let lin_gain = db_to_lin(-gr_db + makeup_db);
self.last_gr_db = -gr_db;
(left * lin_gain, right * lin_gain)
}
/// Reset envelopes and detector state.
pub fn reset(&mut self) {
self.envelope_db = -200.0;
self.rms_state = 0.0;
self.last_gr_db = 0.0;
}
}
/// Static compression curve. Returns the positive gain reduction (in
/// dB) that should be subtracted from the input level.
fn static_curve_gain_reduction(
input_db: f32,
threshold_db: f32,
ratio: f32,
knee_db: f32,
) -> f32 {
let over = input_db - threshold_db;
if knee_db > 0.0 && over > -knee_db * 0.5 && over < knee_db * 0.5 {
// Quadratic soft knee.
let x = over + knee_db * 0.5;
let factor = (x * x) / (2.0 * knee_db);
factor * (1.0 - 1.0 / ratio)
} else if over > 0.0 {
over * (1.0 - 1.0 / ratio)
} else {
0.0
}
}
/// Mild auto-makeup: compensate for half the static gain reduction at
/// 0 dBFS. Conservative on purpose — the limiter is downstream and we
/// don't want to push it.
fn auto_makeup(threshold_db: f32, ratio: f32) -> f32 {
let gr_at_zero = (-threshold_db).max(0.0) * (1.0 - 1.0 / ratio);
gr_at_zero * 0.5
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn below_threshold_is_unity_minus_makeup() {
let mut c = Compressor::new(CompressorConfig::default(), 48_000.0);
// Drive a long, low signal and check we land at expected gain.
let mut last = (0.0_f32, 0.0_f32);
for _ in 0..10_000 {
last = c.process_frame(0.01, 0.01);
}
// Below threshold: gain reduction is zero, only makeup applied.
let makeup_db = auto_makeup(-24.0, 2.5);
let expected = 0.01 * db_to_lin(makeup_db);
assert!(
(last.0 - expected).abs() < 1e-3,
"got {} expected {}",
last.0,
expected
);
assert!(c.gain_reduction_db().abs() < 0.1);
}
#[test]
fn above_threshold_reduces_gain() {
let cfg = CompressorConfig {
threshold_db: -20.0,
ratio: 4.0,
knee_db: 0.0, // hard knee for clean math
attack_ms: 0.1,
release_ms: 0.1,
makeup_db: Some(0.0), // no makeup so we test pure reduction
..CompressorConfig::default()
};
let mut c = Compressor::new(cfg, 48_000.0);
// Drive ~-6 dBFS = 0.5 in linear.
let target = 0.5_f32;
let mut last_out = 0.0;
for _ in 0..2_000 {
let (l, _) = c.process_frame(target, target);
last_out = l;
}
// Input is 14 dB above threshold. With ratio 4, GR = 14*(1-0.25) = 10.5 dB.
// Expected output: -6 - 10.5 = -16.5 dB linear = 0.1496.
let expected_db = -6.0 - 14.0 * (1.0 - 0.25);
let expected_lin = db_to_lin(expected_db);
let got_db = lin_to_db(last_out);
assert!(
(got_db - expected_db).abs() < 0.5,
"got {got_db} expected {expected_db}"
);
assert!(c.gain_reduction_db() < -5.0, "gr was {}", c.gain_reduction_db());
let _ = expected_lin;
}
#[test]
fn ratio_below_one_is_clamped() {
let cfg = CompressorConfig {
ratio: 0.5,
..CompressorConfig::default()
}
.sanitized();
assert_eq!(cfg.ratio, 1.0);
}
#[test]
fn static_curve_at_threshold_with_soft_knee() {
// At exactly threshold, soft knee contributes exactly half the
// ratio's compression amount at the upper knee shoulder.
let gr = static_curve_gain_reduction(-24.0, -24.0, 4.0, 6.0);
// At over==0 inside the knee, x = knee/2, factor = knee/8.
// GR = knee/8 * (1 - 1/4) = 6/8 * 0.75 = 0.5625
assert!((gr - 0.5625).abs() < 1e-4, "gr={gr}");
}
}

View file

@ -0,0 +1,76 @@
//! A simple fixed-length sample delay line.
/// FIFO sample delay of fixed length.
///
/// `push_pop(x)` writes `x` and returns the sample that was written
/// `len` calls ago (initialized to zero).
pub struct DelayLine {
buf: Vec<f32>,
write_idx: usize,
}
impl DelayLine {
/// Construct a new delay line of `samples` length.
///
/// Lengths of 0 are clamped to 1 so the type always behaves like a
/// one-sample identity at minimum.
#[must_use]
pub fn new(samples: usize) -> Self {
Self {
buf: vec![0.0; samples.max(1)],
write_idx: 0,
}
}
/// Effective delay in samples.
#[must_use]
pub fn len(&self) -> usize {
self.buf.len()
}
/// Always false.
#[must_use]
pub fn is_empty(&self) -> bool {
false
}
/// Write `x` and return the sample written `len` calls ago.
pub fn push_pop(&mut self, x: f32) -> f32 {
let out = self.buf[self.write_idx];
self.buf[self.write_idx] = x;
self.write_idx = (self.write_idx + 1) % self.buf.len();
out
}
/// Clear the delay line to silence.
pub fn reset(&mut self) {
for v in &mut self.buf {
*v = 0.0;
}
self.write_idx = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delays_exactly_n_samples() {
let mut d = DelayLine::new(4);
let expected = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0];
let inputs = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
for (i, &x) in inputs.iter().enumerate() {
let y = d.push_pop(x);
assert!((y - expected[i]).abs() < 1e-9, "i={i} y={y}");
}
}
#[test]
fn zero_length_clamps_to_one() {
let mut d = DelayLine::new(0);
assert_eq!(d.len(), 1);
assert_eq!(d.push_pop(1.0), 0.0);
assert_eq!(d.push_pop(2.0), 1.0);
}
}

View file

@ -0,0 +1,98 @@
//! Exponential attack/release envelope follower.
use crate::util::time_to_alpha;
/// One-pole smoother with separate attack and release coefficients.
pub struct AttackRelease {
attack_alpha: f32,
release_alpha: f32,
state: f32,
}
impl AttackRelease {
/// Construct from times in milliseconds and a sample rate (Hz).
#[must_use]
pub fn new(attack_ms: f32, release_ms: f32, sample_rate: f32) -> Self {
Self {
attack_alpha: time_to_alpha(attack_ms, sample_rate),
release_alpha: time_to_alpha(release_ms, sample_rate),
state: 0.0,
}
}
/// Update the coefficients (e.g. after a sample-rate change).
pub fn set_times(&mut self, attack_ms: f32, release_ms: f32, sample_rate: f32) {
self.attack_alpha = time_to_alpha(attack_ms, sample_rate);
self.release_alpha = time_to_alpha(release_ms, sample_rate);
}
/// Peak-detector mode: attack on rising input, release on falling.
/// Typical use: envelope detector for compressors.
pub fn process_peak(&mut self, target: f32) -> f32 {
if target > self.state {
self.state += self.attack_alpha * (target - self.state);
} else {
self.state += self.release_alpha * (target - self.state);
}
self.state
}
/// Gain-follower mode: attack on falling input (gain dropping),
/// release on rising input (gain recovering toward unity). The
/// inverse direction from [`process_peak`](Self::process_peak).
pub fn process_gain(&mut self, target: f32) -> f32 {
if target < self.state {
self.state += self.attack_alpha * (target - self.state);
} else {
self.state += self.release_alpha * (target - self.state);
}
self.state
}
/// Current state.
#[must_use]
pub fn state(&self) -> f32 {
self.state
}
/// Force the state to a given value.
pub fn reset(&mut self, value: f32) {
self.state = value;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peak_mode_attacks_fast_releases_slow() {
let fs = 48_000.0;
let mut env = AttackRelease::new(0.1, 100.0, fs);
// Drive to 1.0 and let it settle.
for _ in 0..100 {
env.process_peak(1.0);
}
assert!(env.state() > 0.99);
// Drop input to 0.0 and verify slow decay.
env.process_peak(0.0);
assert!(env.state() > 0.999);
for _ in 0..10 {
env.process_peak(0.0);
}
// Still well above zero on the release time scale.
assert!(env.state() > 0.8);
}
#[test]
fn gain_mode_attacks_on_drop() {
let fs = 48_000.0;
let mut env = AttackRelease::new(0.1, 100.0, fs);
env.reset(1.0);
// Demand a gain drop. Should snap down quickly.
for _ in 0..100 {
env.process_gain(0.5);
}
assert!(env.state() < 0.51);
}
}

View file

@ -0,0 +1,25 @@
//! DSP kernels for Headroom.
//!
//! The contract: every `process_*` method on the public types is
//! allocation-free and bounded-time. Construction (`new`) allocates and
//! is not realtime-safe — do it ahead of time.
//!
//! See `PLAN.md` §3 for the role each kernel plays in the daemon.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod compressor;
mod delay;
mod envelope;
mod limiter;
mod oversample;
mod sliding_max;
pub mod util;
pub use compressor::{Compressor, CompressorConfig, Detector};
pub use delay::DelayLine;
pub use envelope::AttackRelease;
pub use limiter::{Limiter, LimiterConfig, SoftTierConfig};
pub use oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
pub use sliding_max::SlidingMaxBuffer;

View file

@ -0,0 +1,850 @@
//! Two-tier true-peak limiter.
//!
//! Architecture (per channel, stereo-linked gain):
//!
//! ```text
//! input ─► upsample ─► delay ─► × gain ─► clamp ─► downsample ─► clamp ─► out
//! │ ▲
//! └─► peak ─────┤
//! │ │
//! ▼ │
//! ┌────────┐ │
//! │ soft │── × │ smooth attack/release
//! │ ceil │ │ target = program_lufs + max_psr_db
//! └────────┘ │
//! ┌────────┐ │
//! │ hard │── × ┘ instant attack + hold + release
//! │ ceil │ target = ceiling_dbtp (e.g. 0.1)
//! └────────┘
//! ```
//!
//! The **hard tier** enforces the absolute output contract: a
//! configurable ceiling (default `0.1 dBTP`) with full inter-sample
//! peak handling. Its gain has instant attack, a brief hold, and an
//! exponential release. Two defensive `clamp` stages downstream
//! guarantee the contract numerically — the envelope can misbehave and
//! the contract still holds.
//!
//! The **soft tier** sits in parallel. Its target ceiling is *dynamic*:
//! `program_loudness_lufs + soft.max_psr_db`. It uses a smooth
//! attack/release envelope (musical, not slappy) and pulls transients
//! down to a comfortable peak-to-loudness ratio *before* they ever
//! threaten the hard ceiling. Listeners hear "loud-but-not-shocking"
//! transients instead of bricks landing exactly at the ceiling.
//!
//! The two tiers share the upsampler, downsampler, delay line, and
//! sliding peak buffer. The cost of the soft tier is one extra
//! envelope evaluation per oversampled sample — no additional latency,
//! no additional FIR work.
//!
//! When no program loudness has been provided (typical at startup, or
//! before the AGC's first `ebur128` window completes), the soft tier
//! falls back to a static ceiling. When the soft tier is disabled
//! entirely (`LimiterConfig::soft = None`), the limiter behaves as a
//! pure brickwall — see the `transparent` profile.
use crate::delay::DelayLine;
use crate::envelope::AttackRelease;
use crate::oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
use crate::sliding_max::SlidingMaxBuffer;
use crate::util::{db_to_lin, lin_to_db, time_to_alpha};
/// Soft-tier configuration.
///
/// The soft tier targets a *dynamic* ceiling computed as
/// `program_loudness_lufs + max_psr_db`. It is responsible for the
/// listening experience — keeping the peak-to-loudness ratio bounded
/// — without acting as a safety contract (that's the hard tier's job).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SoftTierConfig {
/// Maximum allowed peak-to-shortterm-loudness ratio in dB.
/// Effective ceiling becomes `program_lufs + max_psr_db`.
pub max_psr_db: f32,
/// Fallback ceiling in dBTP used when no program loudness has been
/// supplied (e.g. during startup before the AGC has measured the
/// first short-term window).
pub static_ceiling_dbtp: f32,
/// Attack time in ms (smooth, not instant).
pub attack_ms: f32,
/// Release time in ms.
pub release_ms: f32,
}
impl Default for SoftTierConfig {
fn default() -> Self {
Self {
max_psr_db: 14.0,
static_ceiling_dbtp: -6.0,
attack_ms: 5.0,
release_ms: 200.0,
}
}
}
impl SoftTierConfig {
/// Sanitize: positive ceilings clamp to 0, non-finite or negative
/// times clamp to small positives.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.static_ceiling_dbtp > 0.0 {
self.static_ceiling_dbtp = 0.0;
}
if !self.max_psr_db.is_finite() || self.max_psr_db < 0.0 {
self.max_psr_db = 0.0;
}
if self.attack_ms < 0.0 || !self.attack_ms.is_finite() {
self.attack_ms = 0.0;
}
if self.release_ms < 0.0 || !self.release_ms.is_finite() {
self.release_ms = 0.0;
}
self
}
}
/// Configurable limiter parameters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LimiterConfig {
/// Hard-tier output ceiling in dBTP. Must be `<= 0.0`.
pub ceiling_dbtp: f32,
/// Lookahead time in milliseconds. Sets the delay-line length and
/// the size of the peak-detector sliding window. Shared by both
/// tiers.
pub lookahead_ms: f32,
/// Hard-tier exponential release (toward unity gain) in ms.
pub release_ms: f32,
/// Hard-tier hold time after a gain reduction before release
/// begins, in ms.
pub hold_ms: f32,
/// Oversampling factor. 1 disables ISP detection; 4 is the
/// BS.1770-4 reference.
pub oversample: usize,
/// Number of FIR taps used by the oversampling filter (odd).
pub fir_taps: usize,
/// Soft-tier configuration. `None` disables the soft tier and the
/// limiter behaves as a pure brickwall.
pub soft: Option<SoftTierConfig>,
}
impl Default for LimiterConfig {
fn default() -> Self {
Self {
ceiling_dbtp: -0.1,
lookahead_ms: 2.0,
release_ms: 80.0,
hold_ms: 5.0,
oversample: 4,
fir_taps: 31,
soft: Some(SoftTierConfig::default()),
}
}
}
impl LimiterConfig {
/// Sanitize a user-supplied configuration: clamp ceiling,
/// oversample factor, ensure odd FIR length, sanitize the soft
/// tier if present.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.ceiling_dbtp > 0.0 {
self.ceiling_dbtp = 0.0;
}
self.oversample = self.oversample.clamp(1, 8);
if self.fir_taps < 5 {
self.fir_taps = 5;
}
if self.fir_taps % 2 == 0 {
self.fir_taps += 1;
}
if let Some(soft) = self.soft {
self.soft = Some(soft.sanitized());
}
self
}
/// Convenience: brickwall only (no soft tier).
#[must_use]
pub fn brickwall_only() -> Self {
Self {
soft: None,
..Self::default()
}
}
}
const MAX_OVERSAMPLE: usize = 8;
/// Two-tier feed-forward true-peak limiter.
pub struct Limiter {
cfg: LimiterConfig,
ceiling_lin: f32,
os: usize,
// Per-channel oversampler / downsampler / delay-line paths.
up_l: PolyphaseUpsampler,
up_r: PolyphaseUpsampler,
down_l: PolyphaseDownsampler,
down_r: PolyphaseDownsampler,
delay_l: DelayLine,
delay_r: DelayLine,
/// Sliding-window peak (oversampled domain), shared across
/// channels and tiers.
peak_buf: SlidingMaxBuffer,
// ---- Hard tier state (instant attack + hold + release) ----
hard_gain: f32,
hold_remaining: u32,
hold_samples_os: u32,
hard_release_alpha: f32,
// ---- Soft tier state (smooth envelope) ----
soft_envelope: Option<AttackRelease>,
soft_max_psr_db: f32,
soft_static_ceiling_lin: f32,
program_loudness_lufs: Option<f32>,
/// Effective soft ceiling in linear gain, recomputed whenever the
/// program loudness changes (or once at startup).
soft_ceiling_lin: f32,
// Scratch buffers (sized for the maximum supported oversample
// factor).
up_buf_l: [f32; MAX_OVERSAMPLE],
up_buf_r: [f32; MAX_OVERSAMPLE],
gained_buf_l: [f32; MAX_OVERSAMPLE],
gained_buf_r: [f32; MAX_OVERSAMPLE],
// Telemetry (sampled per frame).
last_peak_lin: f32,
last_gr_db: f32,
last_soft_gr_db: f32,
last_hard_gr_db: f32,
}
impl Limiter {
/// Construct from a configuration and input sample rate.
///
/// Allocates the FIR coefficients, polyphase tables, and delay
/// buffers. Not realtime-safe.
#[must_use]
pub fn new(cfg: LimiterConfig, sample_rate: f32) -> Self {
let cfg = cfg.sanitized();
let os = cfg.oversample;
let lowpass = if os > 1 {
design_lowpass_blackman(cfg.fir_taps, 0.45 / os as f32)
} else {
vec![1.0]
};
let os_rate = sample_rate * os as f32;
let lookahead_samples_os = (cfg.lookahead_ms * 1e-3 * os_rate).round() as usize;
let lookahead_samples_os = lookahead_samples_os.max(1);
let hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
let hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
let ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
let (soft_envelope, soft_max_psr_db, soft_static_ceiling_lin, soft_ceiling_lin) =
if let Some(soft) = cfg.soft {
let env = AttackRelease::new(soft.attack_ms, soft.release_ms, os_rate);
let static_ceiling_lin = db_to_lin(soft.static_ceiling_dbtp);
(
Some(env),
soft.max_psr_db,
static_ceiling_lin,
static_ceiling_lin,
)
} else {
(None, 0.0, 1.0, 1.0)
};
let mut me = Self {
cfg,
ceiling_lin,
os,
up_l: PolyphaseUpsampler::new(os, &lowpass),
up_r: PolyphaseUpsampler::new(os, &lowpass),
down_l: PolyphaseDownsampler::new(os, &lowpass),
down_r: PolyphaseDownsampler::new(os, &lowpass),
delay_l: DelayLine::new(lookahead_samples_os),
delay_r: DelayLine::new(lookahead_samples_os),
peak_buf: SlidingMaxBuffer::new(lookahead_samples_os),
hard_gain: 1.0,
hold_remaining: 0,
hold_samples_os,
hard_release_alpha,
soft_envelope,
soft_max_psr_db,
soft_static_ceiling_lin,
program_loudness_lufs: None,
soft_ceiling_lin,
up_buf_l: [0.0; MAX_OVERSAMPLE],
up_buf_r: [0.0; MAX_OVERSAMPLE],
gained_buf_l: [0.0; MAX_OVERSAMPLE],
gained_buf_r: [0.0; MAX_OVERSAMPLE],
last_peak_lin: 0.0,
last_gr_db: 0.0,
last_soft_gr_db: 0.0,
last_hard_gr_db: 0.0,
};
// Seed soft envelope to unity so we don't start with phantom
// gain reduction during the first frames.
if let Some(env) = &mut me.soft_envelope {
env.reset(1.0);
}
me
}
/// Active configuration.
#[must_use]
pub fn config(&self) -> LimiterConfig {
self.cfg
}
/// Hard-tier output ceiling in dBTP.
#[must_use]
pub fn ceiling_dbtp(&self) -> f32 {
self.cfg.ceiling_dbtp
}
/// Most recent total gain reduction in dB (negative when limiting).
/// This is the *applied* reduction: `min(soft_gain, hard_gain)`.
#[must_use]
pub fn gain_reduction_db(&self) -> f32 {
self.last_gr_db
}
/// Most recent soft-tier gain reduction in dB.
#[must_use]
pub fn soft_gain_reduction_db(&self) -> f32 {
self.last_soft_gr_db
}
/// Most recent hard-tier gain reduction in dB.
///
/// A non-zero value here indicates the soft tier did not keep the
/// signal under the absolute ceiling and the brickwall engaged.
/// Routinely non-zero values for benign material suggest the soft
/// tier is under-configured (too high `max_psr_db`, too slow
/// attack, or the lookahead is too short for the chosen attack).
#[must_use]
pub fn hard_gain_reduction_db(&self) -> f32 {
self.last_hard_gr_db
}
/// Most recent observed true-peak in dBTP.
#[must_use]
pub fn true_peak_dbtp(&self) -> f32 {
lin_to_db(self.last_peak_lin.max(1e-20))
}
/// Effective soft ceiling currently in use, in dBTP.
///
/// Equals `program_loudness_lufs + soft.max_psr_db` when both are
/// known, otherwise the configured `soft.static_ceiling_dbtp`.
/// Returns `None` if the soft tier is disabled.
#[must_use]
pub fn effective_soft_ceiling_dbtp(&self) -> Option<f32> {
self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))
}
/// Update the program loudness used to compute the dynamic soft
/// ceiling. Typically called by the AGC at its tick rate with the
/// short-term BS.1770 loudness; non-finite values are ignored.
pub fn set_program_loudness_lufs(&mut self, lufs: f32) {
if !lufs.is_finite() {
return;
}
self.program_loudness_lufs = Some(lufs);
self.recompute_soft_ceiling();
}
/// Forget the program loudness; soft tier falls back to its static
/// ceiling. Useful when the AGC stalls or is reset.
pub fn clear_program_loudness(&mut self) {
self.program_loudness_lufs = None;
self.recompute_soft_ceiling();
}
fn recompute_soft_ceiling(&mut self) {
self.soft_ceiling_lin = match (self.cfg.soft, self.program_loudness_lufs) {
(Some(_), Some(lufs)) => {
let dynamic_dbtp = (lufs + self.soft_max_psr_db).min(0.0);
db_to_lin(dynamic_dbtp)
}
(Some(_), None) => self.soft_static_ceiling_lin,
(None, _) => 1.0,
};
}
/// Process one stereo frame.
///
/// Allocation-free. Returns `(left, right)` guaranteed to lie
/// within `±ceiling_dbtp` (the hard contract).
pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
// Sanitize NaN / Inf to zero defensively; never propagate
// garbage into the limiter state.
let left = if left.is_finite() { left } else { 0.0 };
let right = if right.is_finite() { right } else { 0.0 };
self.up_l.process(left, &mut self.up_buf_l[..self.os]);
self.up_r.process(right, &mut self.up_buf_r[..self.os]);
let mut frame_peak = 0.0_f32;
let mut min_soft_gain = 1.0_f32;
let mut min_total_gain = 1.0_f32;
for k in 0..self.os {
let s_l = self.up_buf_l[k];
let s_r = self.up_buf_r[k];
let peak = s_l.abs().max(s_r.abs());
frame_peak = frame_peak.max(peak);
let window_peak = self.peak_buf.push_and_max(peak);
// ---- Soft tier --------------------------------------
let soft_gain = if let Some(env) = &mut self.soft_envelope {
let target = if window_peak > self.soft_ceiling_lin && window_peak > 1e-20 {
self.soft_ceiling_lin / window_peak
} else {
1.0
};
env.process_gain(target)
} else {
1.0
};
if soft_gain < min_soft_gain {
min_soft_gain = soft_gain;
}
// ---- Hard tier --------------------------------------
// The hard tier defends the ceiling, but it shouldn't do
// redundant work when the soft tier already handles the
// peak. So compute the predicted peak *after* the soft
// tier acts, then size the hard gain against that.
//
// `predicted_post_soft` takes the max of:
// - `immediate`: the peak with the *current* soft gain
// applied (safe if soft hasn't ramped yet)
// - `asymptotic`: the peak after the soft tier converges
// to its target (the steady-state)
// The max is the more conservative (larger) prediction.
let predicted_post_soft = if self.soft_envelope.is_some() {
let asymptotic = window_peak.min(self.soft_ceiling_lin);
let immediate = window_peak * soft_gain;
asymptotic.max(immediate)
} else {
window_peak
};
let hard_target =
if predicted_post_soft > self.ceiling_lin && predicted_post_soft > 1e-20 {
self.ceiling_lin / predicted_post_soft
} else {
1.0
};
if hard_target < self.hard_gain {
self.hard_gain = hard_target;
self.hold_remaining = self.hold_samples_os;
} else if self.hold_remaining > 0 {
self.hold_remaining -= 1;
} else {
self.hard_gain += self.hard_release_alpha * (hard_target - self.hard_gain);
if self.hard_gain > hard_target {
self.hard_gain = hard_target;
}
}
// ---- Combine ----------------------------------------
let total_gain = soft_gain.min(self.hard_gain);
if total_gain < min_total_gain {
min_total_gain = total_gain;
}
let d_l = self.delay_l.push_pop(s_l);
let d_r = self.delay_r.push_pop(s_r);
let mut out_l = d_l * total_gain;
let mut out_r = d_r * total_gain;
// Defense-in-depth #1: brickwall clip in the oversampled
// domain. Prevents extreme overshoots from passing into
// the downsampler.
out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
self.gained_buf_l[k] = out_l;
self.gained_buf_r[k] = out_r;
}
let mut out_l = self.down_l.process(&self.gained_buf_l[..self.os]);
let mut out_r = self.down_r.process(&self.gained_buf_r[..self.os]);
// Defense-in-depth #2: brickwall clip at the input sample
// rate, after downsampling. Guards against FIR-induced ringing
// nudging the output above the ceiling in the downsampled
// domain.
out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
self.last_peak_lin = frame_peak;
self.last_soft_gr_db = lin_to_db(min_soft_gain.max(1e-12));
self.last_hard_gr_db = lin_to_db(self.hard_gain.max(1e-12));
self.last_gr_db = lin_to_db(min_total_gain.max(1e-12));
(out_l, out_r)
}
/// Process an interleaved stereo buffer in place.
pub fn process_interleaved_stereo(&mut self, buf: &mut [f32]) {
debug_assert!(buf.len() % 2 == 0);
for frame in buf.chunks_exact_mut(2) {
let (l, r) = self.process_frame(frame[0], frame[1]);
frame[0] = l;
frame[1] = r;
}
}
/// Reset all internal state. Program loudness is also cleared.
pub fn reset(&mut self) {
self.up_l.reset();
self.up_r.reset();
self.down_l.reset();
self.down_r.reset();
self.delay_l.reset();
self.delay_r.reset();
self.peak_buf.reset();
self.hard_gain = 1.0;
self.hold_remaining = 0;
if let Some(env) = &mut self.soft_envelope {
env.reset(1.0);
}
self.program_loudness_lufs = None;
self.recompute_soft_ceiling();
self.last_peak_lin = 0.0;
self.last_gr_db = 0.0;
self.last_soft_gr_db = 0.0;
self.last_hard_gr_db = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
fn run_sine(
limiter: &mut Limiter,
freq: f32,
amp_db: f32,
samples: usize,
sr: f32,
) -> Vec<f32> {
let amp = db_to_lin(amp_db);
let mut out = Vec::with_capacity(samples * 2);
for n in 0..samples {
let t = n as f32 / sr;
let s = amp * (2.0 * PI * freq * t).sin();
let (l, r) = limiter.process_frame(s, s);
out.push(l);
out.push(r);
}
out
}
// ----------------------------------------------------------------
// Hard-tier contract: holds with or without the soft tier present.
// ----------------------------------------------------------------
#[test]
fn passes_signal_below_both_ceilings_unchanged() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// -18 dBFS is below the default static soft ceiling of -6 dBTP
// and the hard ceiling. Neither tier should engage.
let out = run_sine(&mut l, 440.0, -18.0, 4_800, sr);
let max_abs = out.iter().skip(1_000).fold(0.0_f32, |a, &b| a.max(b.abs()));
let max_db = lin_to_db(max_abs);
assert!(
(max_db - (-18.0)).abs() < 0.5,
"expected ~-18 dB, got {max_db}"
);
assert!(
l.gain_reduction_db().abs() < 0.5,
"expected ~0 GR, got {}",
l.gain_reduction_db()
);
}
#[test]
fn enforces_hard_ceiling_on_hot_signal_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
let ceiling_lin = db_to_lin(-0.1);
let max_abs = out
.iter()
.skip(2_000)
.fold(0.0_f32, |a, &b| a.max(b.abs()));
assert!(
max_abs <= ceiling_lin + 1e-6,
"above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
);
}
#[test]
fn enforces_hard_ceiling_on_intersample_peak_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let ceiling_lin = db_to_lin(-0.1);
let mut max_abs = 0.0_f32;
let mut sign = 1.0_f32;
let amp = 0.95_f32;
for n in 0..9_600 {
let s = sign * amp;
sign = -sign;
let (lo, ro) = l.process_frame(s, s);
if n > 1_500 {
max_abs = max_abs.max(lo.abs()).max(ro.abs());
}
}
assert!(
max_abs <= ceiling_lin + 1e-6,
"ISP: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
);
}
#[test]
fn enforces_hard_ceiling_on_transient_impulse_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let ceiling_lin = db_to_lin(-0.1);
let mut max_abs = 0.0_f32;
for n in 0..4_800_usize {
let s = if n == 1_000 { 4.0 } else { 0.0 };
let (lo, ro) = l.process_frame(s, s);
max_abs = max_abs.max(lo.abs()).max(ro.abs());
}
assert!(
max_abs <= ceiling_lin + 1e-6,
"impulse: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
);
}
#[test]
fn brickwall_only_skips_soft_tier_entirely() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);
assert!(l.effective_soft_ceiling_dbtp().is_none());
// Drive a hot signal; brickwall must still hold.
let out = run_sine(&mut l, 440.0, 6.0, 4_800, sr);
let ceiling_lin = db_to_lin(-0.1);
let max_abs = out.iter().skip(800).fold(0.0_f32, |a, &b| a.max(b.abs()));
assert!(max_abs <= ceiling_lin + 1e-6);
// No soft gain reduction should ever have been recorded.
assert!(l.soft_gain_reduction_db().abs() < 1e-6);
}
// ----------------------------------------------------------------
// Soft tier: static fallback ceiling
// ----------------------------------------------------------------
#[test]
fn soft_tier_static_ceiling_engages_before_hard() {
let sr = 48_000.0;
// Static soft ceiling at -6 dBTP, attack short enough to
// settle inside the lookahead.
let cfg = LimiterConfig {
lookahead_ms: 5.0,
soft: Some(SoftTierConfig {
static_ceiling_dbtp: -6.0,
attack_ms: 1.0,
release_ms: 100.0,
..SoftTierConfig::default()
}),
..LimiterConfig::default()
};
let mut l = Limiter::new(cfg, sr);
// Drive a +6 dB sine — well above the soft ceiling.
let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
// Output should sit near the soft ceiling, well below hard.
let soft_ceiling_lin = db_to_lin(-6.0);
let max_abs = out
.iter()
.skip(2_000)
.fold(0.0_f32, |a, &b| a.max(b.abs()));
// Allow small overshoot during soft attack (gain hasn't fully
// settled when the peak arrives), but it must be well under
// the hard ceiling.
assert!(
max_abs <= soft_ceiling_lin * 1.1,
"output above soft ceiling: max_abs={max_abs}, soft_lin={soft_ceiling_lin}"
);
// Soft tier should report meaningful GR; hard tier ideally
// does very little once the soft tier has settled.
assert!(
l.soft_gain_reduction_db() < -3.0,
"soft GR too small: {}",
l.soft_gain_reduction_db()
);
}
// ----------------------------------------------------------------
// Soft tier: dynamic ceiling from program loudness
// ----------------------------------------------------------------
#[test]
fn dynamic_ceiling_tracks_program_loudness() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// Default max_psr_db = 14.
l.set_program_loudness_lufs(-18.0);
let dyn_ceiling = l.effective_soft_ceiling_dbtp().expect("soft tier active");
assert!(
(dyn_ceiling - (-4.0)).abs() < 1e-3,
"expected -4 dBTP, got {dyn_ceiling}"
);
// Move the program louder; ceiling rises (and clamps at 0).
l.set_program_loudness_lufs(-2.0);
let dyn_ceiling = l.effective_soft_ceiling_dbtp().unwrap();
assert!(
(-0.1..=0.0).contains(&dyn_ceiling),
"expected clamp near 0 dBTP, got {dyn_ceiling}"
);
// Clear it; falls back to static.
l.clear_program_loudness();
let fallback = l.effective_soft_ceiling_dbtp().unwrap();
assert!(
(fallback - (-6.0)).abs() < 1e-3,
"expected static -6 dBTP, got {fallback}"
);
}
#[test]
fn dynamic_ceiling_bounds_psr_on_hot_transient() {
let sr = 48_000.0;
// Long lookahead and fast soft attack so the soft tier
// demonstrably catches the transient before the hard tier
// needs to.
let cfg = LimiterConfig {
lookahead_ms: 5.0,
soft: Some(SoftTierConfig {
max_psr_db: 14.0,
static_ceiling_dbtp: -6.0,
attack_ms: 1.0,
release_ms: 100.0,
}),
..LimiterConfig::default()
};
let mut l = Limiter::new(cfg, sr);
l.set_program_loudness_lufs(-18.0);
// Expected dynamic ceiling: -18 + 14 = -4 dBTP ≈ 0.631 lin.
let dyn_ceil_lin = db_to_lin(-4.0);
// Slam a +6 dBFS impulse.
let mut max_after = 0.0_f32;
for n in 0..4_800_usize {
let s = if n == 800 { db_to_lin(6.0) } else { 0.0 };
let (lo, _) = l.process_frame(s, s);
if n > 700 {
max_after = max_after.max(lo.abs());
}
}
// Output should be at or below the dynamic soft ceiling with
// a small ringing margin. Critically, the hard tier should
// *not* be the thing that catches it — its GR should be small.
assert!(
max_after <= dyn_ceil_lin * 1.15,
"soft tier didn't bound the transient: max={max_after}, dyn_ceil={dyn_ceil_lin}"
);
// The hard tier may snap briefly at peak entry (soft envelope
// hasn't ramped yet), then take its release time to recover.
// We don't require zero hard engagement here — only that it
// isn't doing the majority of the work.
assert!(
l.hard_gain_reduction_db().abs() < 4.0,
"hard tier engaged unreasonably: {}",
l.hard_gain_reduction_db()
);
}
// ----------------------------------------------------------------
// Misc
// ----------------------------------------------------------------
#[test]
fn nan_inputs_do_not_propagate_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
for _ in 0..1_000 {
let (lo, ro) = l.process_frame(f32::NAN, f32::INFINITY);
assert!(lo.is_finite() && ro.is_finite());
}
}
#[test]
fn ceiling_clamps_positive_config_to_zero() {
let cfg = LimiterConfig {
ceiling_dbtp: 3.0,
..LimiterConfig::default()
}
.sanitized();
assert_eq!(cfg.ceiling_dbtp, 0.0);
}
#[test]
fn set_program_loudness_ignores_non_finite() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// Establish a baseline.
l.set_program_loudness_lufs(-20.0);
let baseline = l.effective_soft_ceiling_dbtp().unwrap();
// NaN / Inf should be ignored.
l.set_program_loudness_lufs(f32::NAN);
assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
l.set_program_loudness_lufs(f32::INFINITY);
assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
}
#[test]
fn soft_tier_reduces_perceived_peak_to_loudness_ratio() {
// The whole point of the soft tier: a transient on top of a
// quieter program should NOT come out near the hard ceiling.
let sr = 48_000.0;
let cfg = LimiterConfig {
lookahead_ms: 5.0,
soft: Some(SoftTierConfig {
max_psr_db: 12.0,
static_ceiling_dbtp: -8.0,
attack_ms: 1.0,
release_ms: 100.0,
}),
..LimiterConfig::default()
};
let mut brickwall = Limiter::new(LimiterConfig::brickwall_only(), sr);
let mut two_tier = Limiter::new(cfg, sr);
two_tier.set_program_loudness_lufs(-20.0);
let mut bw_peak = 0.0_f32;
let mut tt_peak = 0.0_f32;
for n in 0..4_800_usize {
// Quiet program with a single big spike.
let s = if n == 1_200 { db_to_lin(3.0) } else { 0.01 };
let (lo_bw, _) = brickwall.process_frame(s, s);
let (lo_tt, _) = two_tier.process_frame(s, s);
if n > 1_000 {
bw_peak = bw_peak.max(lo_bw.abs());
tt_peak = tt_peak.max(lo_tt.abs());
}
}
// Brickwall lets the spike through near the hard ceiling.
// Two-tier holds it much lower.
assert!(
tt_peak < bw_peak * 0.6,
"soft tier did not meaningfully reduce peak: bw={bw_peak}, tt={tt_peak}"
);
}
}

View file

@ -0,0 +1,230 @@
//! Polyphase FIR up/downsamplers.
//!
//! Used by the true-peak limiter to detect inter-sample peaks via
//! oversampled-domain peak detection (per ITU-R BS.1770-4).
use std::f32::consts::PI;
/// Design a Blackman-windowed sinc lowpass FIR.
///
/// * `taps` — filter length. Odd values give a linear-phase filter
/// with an exact group delay of `(taps - 1) / 2` samples.
/// * `fc` — normalized cutoff in `0.0..0.5` (fraction of sample rate).
///
/// Coefficients are normalized for unity DC gain. Suitable as the
/// prototype lowpass for `M`-times oversampling at `fc = 0.5 / M`
/// (slightly below Nyquist of the lower rate).
#[must_use]
pub fn design_lowpass_blackman(taps: usize, fc: f32) -> Vec<f32> {
let taps = taps.max(1);
let m = (taps as f32 - 1.0).max(1.0);
let mut h = vec![0.0_f32; taps];
let mut sum = 0.0_f32;
for (n, h_n) in h.iter_mut().enumerate() {
let x = n as f32 - m / 2.0;
let sinc = if x.abs() < 1e-9 {
2.0 * fc
} else {
(2.0 * PI * fc * x).sin() / (PI * x)
};
let w = 0.42 - 0.5 * (2.0 * PI * n as f32 / m).cos() + 0.08 * (4.0 * PI * n as f32 / m).cos();
*h_n = sinc * w;
sum += *h_n;
}
if sum.abs() > 1e-12 {
for v in &mut h {
*v /= sum;
}
}
h
}
/// Polyphase FIR upsampler.
///
/// One input sample produces `factor` output samples. Coefficients are
/// pre-scaled by `factor` so the output's DC gain equals the input.
pub struct PolyphaseUpsampler {
factor: usize,
taps_per_phase: usize,
/// `phases[j * taps_per_phase + p] = h[p * factor + j] * factor`.
phases: Vec<f32>,
history: Vec<f32>,
write_idx: usize,
}
impl PolyphaseUpsampler {
/// Construct from a prototype lowpass and an upsample `factor`.
#[must_use]
pub fn new(factor: usize, fir_taps: &[f32]) -> Self {
let factor = factor.max(1);
let taps_per_phase = fir_taps.len().div_ceil(factor);
let mut phases = vec![0.0_f32; factor * taps_per_phase];
for (n, &h) in fir_taps.iter().enumerate() {
let j = n % factor;
let p = n / factor;
phases[j * taps_per_phase + p] = h * factor as f32;
}
Self {
factor,
taps_per_phase,
phases,
history: vec![0.0_f32; taps_per_phase.max(1)],
write_idx: 0,
}
}
/// Upsample factor.
#[must_use]
pub fn factor(&self) -> usize {
self.factor
}
/// Push one input sample and emit `factor` output samples into
/// `out[..factor]`. `out` must have length `>= factor`.
pub fn process(&mut self, x: f32, out: &mut [f32]) {
debug_assert!(out.len() >= self.factor);
let len = self.history.len();
self.history[self.write_idx] = x;
let just_written = self.write_idx;
self.write_idx = (self.write_idx + 1) % len;
for (j, slot) in out.iter_mut().take(self.factor).enumerate() {
let phase = &self.phases[j * self.taps_per_phase..(j + 1) * self.taps_per_phase];
let mut acc = 0.0_f32;
for (p, &h) in phase.iter().enumerate() {
let idx = (just_written + len - p) % len;
acc += h * self.history[idx];
}
*slot = acc;
}
}
/// Clear filter history.
pub fn reset(&mut self) {
for v in &mut self.history {
*v = 0.0;
}
self.write_idx = 0;
}
}
/// FIR downsampler. Takes `factor` input samples and emits one output.
///
/// Uses the same prototype lowpass coefficients as the upsampler.
/// Implementation is straightforward (no polyphase split) — for our
/// filter length the savings are modest and code clarity wins.
pub struct PolyphaseDownsampler {
factor: usize,
taps: Vec<f32>,
history: Vec<f32>,
write_idx: usize,
}
impl PolyphaseDownsampler {
/// Construct from a prototype lowpass and a downsample `factor`.
#[must_use]
pub fn new(factor: usize, fir_taps: &[f32]) -> Self {
let factor = factor.max(1);
Self {
factor,
taps: fir_taps.to_vec(),
history: vec![0.0_f32; fir_taps.len().max(1)],
write_idx: 0,
}
}
/// Downsample factor.
#[must_use]
pub fn factor(&self) -> usize {
self.factor
}
/// Push `factor` input samples and return one filtered output.
pub fn process(&mut self, ins: &[f32]) -> f32 {
debug_assert_eq!(ins.len(), self.factor);
let len = self.history.len();
for &x in ins {
self.history[self.write_idx] = x;
self.write_idx = (self.write_idx + 1) % len;
}
let mut acc = 0.0_f32;
for (k, &h) in self.taps.iter().enumerate() {
let idx = (self.write_idx + len - 1 - k) % len;
acc += h * self.history[idx];
}
acc
}
/// Clear filter history.
pub fn reset(&mut self) {
for v in &mut self.history {
*v = 0.0;
}
self.write_idx = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fir(taps: usize, factor: usize) -> Vec<f32> {
design_lowpass_blackman(taps, 0.45 / factor as f32)
}
#[test]
fn upsampler_dc_gain_preserved() {
let h = fir(31, 4);
let mut up = PolyphaseUpsampler::new(4, &h);
// Drive DC and let the filter settle, then check unity gain.
let mut buf = [0.0_f32; 8];
let mut last_avg = 0.0;
for _ in 0..200 {
up.process(1.0, &mut buf);
last_avg = buf[..4].iter().sum::<f32>() / 4.0;
}
assert!((last_avg - 1.0).abs() < 1e-3, "got {last_avg}");
}
#[test]
fn down_then_up_roundtrip_is_bounded() {
// Stuff zero-padded input through up then down; output amplitude
// should approximately equal input on smooth signals.
let h = fir(31, 4);
let mut up = PolyphaseUpsampler::new(4, &h);
let mut down = PolyphaseDownsampler::new(4, &h);
let mut max_err = 0.0_f32;
let mut up_buf = [0.0_f32; 8];
// Drive a slow sine well below Nyquist.
for n in 0..2_000 {
let t = n as f32 / 48_000.0;
let x = (2.0 * std::f32::consts::PI * 1_000.0 * t).sin() * 0.5;
up.process(x, &mut up_buf);
let y = down.process(&up_buf[..4]);
// After group-delay warm-up, the error should be small.
if n > 80 {
max_err = max_err.max((x - y).abs());
}
}
// The filter is symmetric, so up/down with the same kernel
// introduces ~6 dB attenuation by design (each pass contributes
// half the gain). What we care about here is finite, bounded
// output and no runaway.
assert!(max_err < 1.0, "max_err {max_err}");
}
#[test]
fn upsampler_handles_impulse() {
let h = fir(15, 4);
let mut up = PolyphaseUpsampler::new(4, &h);
let mut buf = [0.0_f32; 8];
up.process(1.0, &mut buf);
// Some non-zero output expected on first impulse already.
assert!(buf[..4].iter().any(|&v| v.abs() > 1e-6));
// Drive zeros; output decays to zero.
for _ in 0..200 {
up.process(0.0, &mut buf);
}
assert!(buf[..4].iter().all(|&v| v.abs() < 1e-6));
}
}

View file

@ -0,0 +1,108 @@
//! Amortized-O(1) sliding-window maximum.
//!
//! Uses the standard monotonic-decreasing-deque trick. The deque's
//! capacity is bounded by `window`, so it never reallocates after
//! construction.
use std::collections::VecDeque;
/// Streaming max over a fixed-size sliding window.
pub struct SlidingMaxBuffer {
window: usize,
counter: u64,
/// `(index, value)`, monotonically decreasing in value from front.
deque: VecDeque<(u64, f32)>,
}
impl SlidingMaxBuffer {
/// Construct with the given window size. Lengths of 0 are clamped
/// to 1.
#[must_use]
pub fn new(window: usize) -> Self {
let window = window.max(1);
Self {
window,
counter: 0,
deque: VecDeque::with_capacity(window),
}
}
/// Window length in samples.
#[must_use]
pub fn window(&self) -> usize {
self.window
}
/// Push `value` and return the maximum over the most recent
/// `window` samples (inclusive of the value just pushed).
pub fn push_and_max(&mut self, value: f32) -> f32 {
// Drop entries that have aged out of the window.
let cutoff = self.counter.saturating_sub(self.window as u64 - 1);
while let Some(&(idx, _)) = self.deque.front() {
if idx < cutoff {
self.deque.pop_front();
} else {
break;
}
}
// Drop entries from the back smaller than the new value — they
// can never become the maximum.
while let Some(&(_, v)) = self.deque.back() {
if v <= value {
self.deque.pop_back();
} else {
break;
}
}
self.deque.push_back((self.counter, value));
self.counter += 1;
// SAFETY-ish: deque is non-empty (we just pushed).
self.deque.front().map_or(0.0, |&(_, v)| v)
}
/// Reset to empty state.
pub fn reset(&mut self) {
self.counter = 0;
self.deque.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracks_window_max() {
let mut s = SlidingMaxBuffer::new(3);
assert_eq!(s.push_and_max(1.0), 1.0);
assert_eq!(s.push_and_max(3.0), 3.0);
assert_eq!(s.push_and_max(2.0), 3.0);
assert_eq!(s.push_and_max(2.0), 3.0); // 3.0 aged out... actually still in (window=3, last 3 are [3,2,2])
assert_eq!(s.push_and_max(0.5), 2.0); // window is now [2,2,0.5]
assert_eq!(s.push_and_max(0.5), 2.0); // [2,0.5,0.5]
assert_eq!(s.push_and_max(0.5), 0.5); // [0.5,0.5,0.5]
}
#[test]
fn monotonically_decreasing_input() {
let mut s = SlidingMaxBuffer::new(4);
for (i, &v) in [5.0_f32, 4.0, 3.0, 2.0, 1.0, 0.5].iter().enumerate() {
let m = s.push_and_max(v);
// After window is filled, max is the value `window-1` back.
let expected = match i {
0..=3 => 5.0,
4 => 4.0,
_ => 3.0,
};
assert_eq!(m, expected);
}
}
#[test]
fn window_one_is_identity() {
let mut s = SlidingMaxBuffer::new(1);
for v in [1.0, 2.0, 0.5, 9.0_f32, -3.0] {
assert_eq!(s.push_and_max(v), v);
}
}
}

View file

@ -0,0 +1,60 @@
//! Common helpers: dB <-> linear conversions, time constants.
/// Lower bound used to avoid `log10(0)`.
pub const PEAK_FLOOR: f32 = 1e-20;
/// Convert linear amplitude to decibels. Inputs at or below
/// [`PEAK_FLOOR`] clamp to `-200 dB`.
#[must_use]
pub fn lin_to_db(x: f32) -> f32 {
if x <= PEAK_FLOOR {
-200.0
} else {
20.0 * x.log10()
}
}
/// Convert decibels to linear amplitude.
#[must_use]
pub fn db_to_lin(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
/// Convert a time constant in milliseconds to a one-pole smoother
/// coefficient at the given sample rate.
///
/// `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`. The returned alpha is
/// `1 - exp(-1 / (tau * fs))` where `tau` is `time_ms / 1000`. A
/// `time_ms` of 0 or below returns `1.0` (instantaneous).
#[must_use]
pub fn time_to_alpha(time_ms: f32, sample_rate: f32) -> f32 {
if time_ms <= 0.0 || sample_rate <= 0.0 {
1.0
} else {
let tau_samples = (time_ms * 1e-3) * sample_rate;
1.0 - (-1.0 / tau_samples).exp()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn db_round_trips() {
for db in [-60.0, -20.0, -6.0, -0.1, 0.0, 3.0, 6.0_f32] {
let lin = db_to_lin(db);
let back = lin_to_db(lin);
assert!((back - db).abs() < 1e-3, "db={db} back={back}");
}
}
#[test]
fn time_to_alpha_endpoints() {
assert_eq!(time_to_alpha(0.0, 48_000.0), 1.0);
assert!(time_to_alpha(1000.0, 48_000.0) < 0.01);
// Very fast attack: alpha approaches 1.
let a_fast = time_to_alpha(0.01, 48_000.0);
assert!(a_fast > 0.05, "fast alpha was {a_fast}");
}
}

View file

@ -0,0 +1,16 @@
[package]
name = "headroom-ipc"
description = "Headroom control-protocol types, framing, and codec. No I/O."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MPL-2.0"
homepage.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View file

@ -0,0 +1,12 @@
# headroom-ipc
Wire types, framing, and codec for the Headroom control protocol.
This crate is the authoritative Rust binding to the protocol defined in
[`IPC.md`](../../IPC.md). It performs no I/O; pair it with `headroom-client`
for a ready-to-use client, or use it directly to implement your own.
## License
MPL-2.0. Third-party clients (including non-GPL ones) can depend on this
crate without affecting their license.

View file

@ -0,0 +1,267 @@
//! Length-prefixed JSON framing.
//!
//! Wire format: a 4-byte big-endian unsigned length, followed by exactly
//! that many bytes of UTF-8 JSON. Each frame is a single JSON value.
use std::io::{Read, Write};
use serde::{de::DeserializeOwned, Serialize};
use crate::error::Error;
/// Default upper bound on a single frame's payload size.
pub const DEFAULT_MAX_FRAME_BYTES: usize = 1024 * 1024; // 1 MiB
/// Lower bound enforced by [`Codec::with_max_frame_size`].
///
/// Frames below this size are silly small and almost always indicate a
/// bug; the limit is enforced so misuse fails loudly rather than
/// rejecting normal traffic.
pub const MIN_MAX_FRAME_BYTES: usize = 256;
/// A stateless framing codec.
///
/// Instances are cheap to clone. The codec owns no buffers; callers
/// supply their own readers and writers.
#[derive(Debug, Clone, Copy)]
pub struct Codec {
max_frame_bytes: usize,
}
impl Default for Codec {
fn default() -> Self {
Self {
max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
}
}
}
impl Codec {
/// Returns a codec with the default 1 MiB frame limit.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns a codec with the supplied frame size cap.
///
/// The cap is clamped to at least [`MIN_MAX_FRAME_BYTES`].
#[must_use]
pub fn with_max_frame_size(bytes: usize) -> Self {
Self {
max_frame_bytes: bytes.max(MIN_MAX_FRAME_BYTES),
}
}
/// Maximum payload size, in bytes.
#[must_use]
pub fn max_frame_bytes(self) -> usize {
self.max_frame_bytes
}
/// Serialize `msg` and write it as a framed payload.
///
/// # Errors
/// - [`Error::Json`] if the value cannot be serialized.
/// - [`Error::FrameTooLarge`] if the serialized form exceeds the cap.
/// - [`Error::Io`] on write failure.
pub fn write<W: Write, T: Serialize>(self, mut w: W, msg: &T) -> Result<(), Error> {
// Serialize first so we know the exact length up-front.
let buf = serde_json::to_vec(msg)?;
if buf.len() > self.max_frame_bytes {
return Err(Error::FrameTooLarge {
actual: buf.len(),
limit: self.max_frame_bytes,
});
}
let len = u32::try_from(buf.len()).expect("buf.len() <= max_frame_bytes <= u32::MAX");
w.write_all(&len.to_be_bytes())?;
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Read one framed payload from `r` and deserialize it.
///
/// # Errors
/// - [`Error::Closed`] if EOF is hit before any bytes of the length
/// prefix arrive (graceful close).
/// - [`Error::Io`] on partial reads or other I/O failure.
/// - [`Error::FrameTooLarge`] if the announced length exceeds the cap.
/// - [`Error::Json`] if the payload fails to deserialize.
pub fn read<R: Read, T: DeserializeOwned>(self, mut r: R) -> Result<T, Error> {
let mut len_buf = [0u8; 4];
match read_full_or_eof(&mut r, &mut len_buf)? {
ReadOutcome::Full => {}
ReadOutcome::ZeroAtStart => return Err(Error::Closed),
}
let len = u32::from_be_bytes(len_buf) as usize;
if len > self.max_frame_bytes {
return Err(Error::FrameTooLarge {
actual: len,
limit: self.max_frame_bytes,
});
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(serde_json::from_slice(&buf)?)
}
}
enum ReadOutcome {
Full,
ZeroAtStart,
}
fn read_full_or_eof<R: Read>(r: &mut R, buf: &mut [u8]) -> Result<ReadOutcome, std::io::Error> {
let mut read = 0;
while read < buf.len() {
match r.read(&mut buf[read..]) {
Ok(0) => {
if read == 0 {
return Ok(ReadOutcome::ZeroAtStart);
}
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"eof mid-length-prefix",
));
}
Ok(n) => read += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(ReadOutcome::Full)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Event, Op, Request, Response, ServerFrame, Topic};
use std::io::Cursor;
#[test]
fn write_read_request_roundtrip() {
let codec = Codec::new();
let req = Request::new(
42,
Op::ProfileUse {
name: "night".into(),
},
);
let mut buf = Vec::new();
codec.write(&mut buf, &req).unwrap();
let mut cur = Cursor::new(&buf);
let back: Request = codec.read(&mut cur).unwrap();
assert_eq!(back, req);
}
#[test]
fn server_frame_round_trip_response() {
let codec = Codec::new();
let resp = Response::ok(1, &serde_json::json!({"name": "default"})).unwrap();
let frame = ServerFrame::Response(resp.clone());
let mut buf = Vec::new();
codec.write(&mut buf, &frame).unwrap();
let mut cur = Cursor::new(&buf);
let back: ServerFrame = codec.read(&mut cur).unwrap();
match back {
ServerFrame::Response(r) => assert_eq!(r, resp),
ServerFrame::Event(_) => panic!("decoded as event"),
}
}
#[test]
fn server_frame_round_trip_event() {
let codec = Codec::new();
let ev = Event::new(Topic::Daemon, "shutdown", &serde_json::json!({})).unwrap();
let frame = ServerFrame::Event(ev.clone());
let mut buf = Vec::new();
codec.write(&mut buf, &frame).unwrap();
let mut cur = Cursor::new(&buf);
let back: ServerFrame = codec.read(&mut cur).unwrap();
match back {
ServerFrame::Event(e) => assert_eq!(e, ev),
ServerFrame::Response(_) => panic!("decoded as response"),
}
}
#[test]
fn rejects_oversized_frames_on_write() {
let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);
// A big string that will serialize > 256 bytes.
let req = Request::new(
1,
Op::SettingSet {
key: "x".into(),
value: serde_json::Value::String("a".repeat(1024)),
},
);
let mut buf = Vec::new();
let err = codec.write(&mut buf, &req).unwrap_err();
assert!(matches!(err, Error::FrameTooLarge { .. }));
}
#[test]
fn rejects_oversized_frames_on_read() {
let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);
// Hand-craft a length prefix that exceeds the cap.
let mut buf = Vec::new();
let bad_len: u32 = MIN_MAX_FRAME_BYTES as u32 + 1;
buf.extend_from_slice(&bad_len.to_be_bytes());
// No need to follow with payload; we expect early rejection.
let mut cur = Cursor::new(&buf);
let err = codec.read::<_, serde_json::Value>(&mut cur).unwrap_err();
assert!(matches!(err, Error::FrameTooLarge { .. }));
}
#[test]
fn graceful_eof_at_frame_boundary() {
let codec = Codec::new();
let mut cur = Cursor::new(Vec::<u8>::new());
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Closed));
}
#[test]
fn mid_frame_eof_is_io_error() {
let codec = Codec::new();
// Half a length prefix.
let mut cur = Cursor::new(vec![0u8, 0u8]);
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn rejects_invalid_json_payload() {
let codec = Codec::new();
let payload = b"not-json";
let mut buf = Vec::new();
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
let mut cur = Cursor::new(&buf);
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Json(_)));
}
#[test]
fn back_to_back_frames() {
let codec = Codec::new();
let a = Request::new(1, Op::Status);
let b = Request::new(2, Op::ProfileList);
let mut buf = Vec::new();
codec.write(&mut buf, &a).unwrap();
codec.write(&mut buf, &b).unwrap();
let mut cur = Cursor::new(&buf);
let ra: Request = codec.read(&mut cur).unwrap();
let rb: Request = codec.read(&mut cur).unwrap();
assert_eq!(ra, a);
assert_eq!(rb, b);
}
}

View file

@ -0,0 +1,125 @@
//! Error types for the protocol crate.
use serde::{Deserialize, Serialize};
/// Stable machine-readable error code emitted in `error.code`.
///
/// Adding variants is a non-breaking change. Removing or renaming
/// variants is breaking and bumps `PROTOCOL_VERSION`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ErrorCode {
/// Malformed framing or non-JSON payload. Connection is closed.
InvalidFrame,
/// Valid JSON, but does not match any known message shape.
InvalidMessage,
/// `op` does not name a known operation.
UnknownOp,
/// `args` is missing a field, has the wrong type, or is out of range.
InvalidArgs,
/// Named profile / app / stream / setting key does not exist.
NotFound,
/// Operation would violate an invariant.
Conflict,
/// Daemon transiently cannot serve the request.
Busy,
/// Server-side bug. `message` contains debug detail.
Internal,
}
impl ErrorCode {
/// Returns the canonical SCREAMING_SNAKE wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::InvalidFrame => "INVALID_FRAME",
ErrorCode::InvalidMessage => "INVALID_MESSAGE",
ErrorCode::UnknownOp => "UNKNOWN_OP",
ErrorCode::InvalidArgs => "INVALID_ARGS",
ErrorCode::NotFound => "NOT_FOUND",
ErrorCode::Conflict => "CONFLICT",
ErrorCode::Busy => "BUSY",
ErrorCode::Internal => "INTERNAL",
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Error payload as it appears on the wire inside a `Response`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtoError {
/// Stable machine-readable code.
pub code: ErrorCode,
/// Human-readable English message. Not stable; do not pattern match.
pub message: String,
}
impl ProtoError {
/// Construct a new protocol error.
#[must_use]
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for ProtoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ProtoError {}
/// Errors produced by the codec and high-level helpers in this crate.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Underlying I/O failed.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// JSON encoding or decoding failed.
#[error("json: {0}")]
Json(#[from] serde_json::Error),
/// A frame exceeded the configured maximum size.
#[error("frame too large: {actual} bytes (limit {limit})")]
FrameTooLarge {
/// Actual size of the offending frame in bytes.
actual: usize,
/// Configured maximum, in bytes.
limit: usize,
},
/// The peer answered with an error response.
#[error("protocol: {0}")]
Protocol(#[from] ProtoError),
/// The peer sent an unexpected frame given protocol context (e.g.
/// a response with a mismatched id).
#[error("unexpected frame: {0}")]
UnexpectedFrame(String),
/// The connection was closed mid-frame.
#[error("connection closed")]
Closed,
}
impl Error {
/// True if this error indicates the connection should be torn down.
#[must_use]
pub fn is_fatal(&self) -> bool {
matches!(
self,
Error::Io(_) | Error::FrameTooLarge { .. } | Error::Closed
)
}
}

View file

@ -0,0 +1,69 @@
//! Headroom control-protocol types and framing.
//!
//! The authoritative protocol specification is in `IPC.md` at the
//! repository root. This crate is its Rust binding.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod codec;
mod error;
mod proto;
pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};
pub use error::{Error, ErrorCode, ProtoError};
pub use proto::{
DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
SinkInfo, Sinks, Status, StreamRoute, Topic,
};
/// Wire-protocol version. Bumped only on incompatible changes.
pub const PROTOCOL_VERSION: u32 = 1;
/// Default Unix-domain socket path stem inside `$XDG_RUNTIME_DIR`.
///
/// The full socket path is `${XDG_RUNTIME_DIR}/headroom/control.sock`,
/// falling back to `/run/user/$UID/headroom/control.sock` when
/// `XDG_RUNTIME_DIR` is unset.
pub const DEFAULT_SOCKET_DIR: &str = "headroom";
/// Default socket filename.
pub const DEFAULT_SOCKET_NAME: &str = "control.sock";
/// Returns the conventional control-socket path for the current user.
///
/// Honours `XDG_RUNTIME_DIR`, with the documented fallback to
/// `/run/user/$UID/...`. Returns `None` when neither is determinable.
#[must_use]
pub fn default_socket_path() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
if !dir.is_empty() {
return Some(
PathBuf::from(dir)
.join(DEFAULT_SOCKET_DIR)
.join(DEFAULT_SOCKET_NAME),
);
}
}
// SAFETY: `nix`-free fallback. Read uid from /proc/self/status as
// a pure-Rust dependency-light path.
let uid = read_self_uid()?;
Some(
PathBuf::from(format!("/run/user/{uid}"))
.join(DEFAULT_SOCKET_DIR)
.join(DEFAULT_SOCKET_NAME),
)
}
fn read_self_uid() -> Option<u32> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
for line in s.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
let first = rest.split_whitespace().next()?;
return first.parse().ok();
}
}
None
}

View file

@ -0,0 +1,693 @@
//! Protocol message types.
//!
//! See `IPC.md` for the normative specification.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::ProtoError;
// ---------------------------------------------------------------------------
// Topics
// ---------------------------------------------------------------------------
/// A subscription topic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Topic {
/// Live loudness / peak / GR telemetry.
Meters,
/// Profile use / reload events.
Profile,
/// Routing rule and per-stream events.
Routing,
/// Daemon lifecycle and overflow.
Daemon,
/// Synthetic topic for `hello`. Clients never subscribe to it.
Control,
}
impl Topic {
/// Returns the canonical wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Topic::Meters => "meters",
Topic::Profile => "profile",
Topic::Routing => "routing",
Topic::Daemon => "daemon",
Topic::Control => "control",
}
}
/// Topics that clients are allowed to subscribe to.
#[must_use]
pub const fn subscribable() -> &'static [Topic] {
&[Topic::Meters, Topic::Profile, Topic::Routing, Topic::Daemon]
}
}
impl std::fmt::Display for Topic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Route enum
// ---------------------------------------------------------------------------
/// Routing decision for a single application or stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Route {
/// Route the stream through the processed (filtered) sink.
Processed,
/// Route the stream directly to the real hardware sink (no
/// processing, no extra graph hop).
Bypass,
}
impl Route {
/// Returns the canonical wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Route::Processed => "processed",
Route::Bypass => "bypass",
}
}
}
impl std::fmt::Display for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------
/// All known operations, as a strongly-typed enum.
///
/// The wire form is `{ "op": "<name>", "args": <args> }` with operations
/// that take no arguments omitting the `args` field. Operations with no
/// arguments serialize as `{ "op": "<name>" }`; serde fills the unit
/// variants accordingly.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", content = "args")]
#[non_exhaustive]
pub enum Op {
/// Snapshot of daemon state.
#[serde(rename = "status")]
Status,
/// List all known profiles.
#[serde(rename = "profile.list")]
ProfileList,
/// Activate the named profile.
#[serde(rename = "profile.use")]
ProfileUse {
/// Profile name.
name: String,
},
/// Show a profile in full. `name` defaults to the active profile
/// when omitted.
#[serde(rename = "profile.show")]
ProfileShow {
/// Profile name. `None` means the active profile.
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Reload all profile files from disk.
#[serde(rename = "profile.reload")]
ProfileReload,
/// List routing rules and current per-stream decisions.
#[serde(rename = "route.list")]
RouteList,
/// Add or replace a routing rule for an app (persistent).
#[serde(rename = "route.set")]
RouteSet {
/// Application identifier (typically `application.process.binary`).
app: String,
/// Target route.
to: Route,
},
/// Remove a user routing rule for an app.
#[serde(rename = "route.unset")]
RouteUnset {
/// Application identifier.
app: String,
},
/// One-shot reroute of a specific live stream.
#[serde(rename = "route.stream")]
RouteStream {
/// PipeWire node id of the stream.
node_id: u32,
/// Target route.
to: Route,
},
/// Get a single setting from the active profile.
#[serde(rename = "setting.get")]
SettingGet {
/// Dotted setting key (e.g. `compressor.threshold_db`).
key: String,
},
/// Set a single setting in the active profile.
#[serde(rename = "setting.set")]
SettingSet {
/// Dotted setting key.
key: String,
/// New value. Must match the setting's type.
value: Value,
},
/// List all settings (active profile).
#[serde(rename = "setting.list")]
SettingList,
/// Enable or disable the global bypass kill switch.
#[serde(rename = "bypass.set")]
BypassSet {
/// `true` to route everything through bypass.
enabled: bool,
},
/// Subscribe to one or more event topics on this connection.
#[serde(rename = "subscribe")]
Subscribe {
/// Topics to subscribe to.
topics: Vec<Topic>,
},
/// Unsubscribe from one or more topics on this connection.
#[serde(rename = "unsubscribe")]
Unsubscribe {
/// Topics to unsubscribe from.
topics: Vec<Topic>,
},
}
impl Op {
/// Canonical wire name of this operation.
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Op::Status => "status",
Op::ProfileList => "profile.list",
Op::ProfileUse { .. } => "profile.use",
Op::ProfileShow { .. } => "profile.show",
Op::ProfileReload => "profile.reload",
Op::RouteList => "route.list",
Op::RouteSet { .. } => "route.set",
Op::RouteUnset { .. } => "route.unset",
Op::RouteStream { .. } => "route.stream",
Op::SettingGet { .. } => "setting.get",
Op::SettingSet { .. } => "setting.set",
Op::SettingList => "setting.list",
Op::BypassSet { .. } => "bypass.set",
Op::Subscribe { .. } => "subscribe",
Op::Unsubscribe { .. } => "unsubscribe",
}
}
}
// ---------------------------------------------------------------------------
// Request / Response / Event
// ---------------------------------------------------------------------------
/// A client-to-server request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
/// Client-chosen identifier. Echoed back in the paired response.
pub id: u64,
/// The operation and its arguments. Flattened: contributes `op`
/// and (optionally) `args` fields at the same level as `id`.
#[serde(flatten)]
pub op: Op,
}
impl Request {
/// Construct a new request.
#[must_use]
pub fn new(id: u64, op: Op) -> Self {
Self { id, op }
}
}
/// A server-to-client response, paired with a request by `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Response {
/// Matches the originating request's `id`.
pub id: u64,
/// Result or error.
#[serde(flatten)]
pub payload: ResponsePayload,
}
impl Response {
/// Build an OK response from any serializable value.
///
/// # Errors
/// Returns the underlying serde error if `value` fails to serialize.
pub fn ok<T: Serialize>(id: u64, value: &T) -> Result<Self, serde_json::Error> {
Ok(Self {
id,
payload: ResponsePayload::Ok {
result: serde_json::to_value(value)?,
},
})
}
/// Build an error response.
#[must_use]
pub fn err(id: u64, error: ProtoError) -> Self {
Self {
id,
payload: ResponsePayload::Err { error },
}
}
}
/// Discriminated body of a `Response`.
///
/// On the wire this is a single field — either `result` or `error` —
/// inlined alongside `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsePayload {
/// Successful result.
Ok {
/// Operation-specific result body. JSON; typically an object.
result: Value,
},
/// Operation failed.
Err {
/// Error payload.
error: ProtoError,
},
}
/// Server-to-client event on a subscribed topic.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// Event name within the topic (e.g. `tick`, `changed`).
pub event: String,
/// Subscription topic this event belongs to.
pub topic: Topic,
/// Event payload. Shape depends on `topic` and `event`.
pub data: Value,
}
impl Event {
/// Construct a new event with a JSON-serializable payload.
///
/// # Errors
/// Returns the underlying serde error if `data` fails to serialize.
pub fn new<T: Serialize>(
topic: Topic,
event: impl Into<String>,
data: &T,
) -> Result<Self, serde_json::Error> {
Ok(Self {
event: event.into(),
topic,
data: serde_json::to_value(data)?,
})
}
}
/// A single frame as a client would receive it from the server: either
/// a paired response or a fan-out event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ServerFrame {
/// Response to a prior request.
Response(Response),
/// Subscription event.
Event(Event),
}
// ---------------------------------------------------------------------------
// Result body shapes for typed access
// ---------------------------------------------------------------------------
/// Result body of `status`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Status {
/// Daemon version string (semver).
pub version: String,
/// Wire protocol version.
pub protocol: u32,
/// Daemon uptime in seconds.
pub uptime_s: u64,
/// Active profile name.
pub profile: String,
/// Global bypass flag.
pub bypass: bool,
/// Sink status snapshot.
pub sinks: Sinks,
/// Currently-tracked playback streams.
pub streams: Vec<StreamRoute>,
}
/// Sink-side of `Status`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Sinks {
/// The processed virtual sink. The only sink Headroom creates.
pub processed: SinkInfo,
/// The hardware sink Headroom is currently forwarding to, and
/// where bypassed streams are routed directly.
pub real: SinkInfo,
}
/// Information about one sink.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SinkInfo {
/// PipeWire node id, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<u32>,
/// Human-readable sink name, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// True if the sink is currently linked and accepting audio.
#[serde(default)]
pub ready: bool,
}
/// One playback stream and where it's routed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StreamRoute {
/// PipeWire node id.
pub node_id: u32,
/// Application identifier (typically `application.process.binary`).
pub app: String,
/// Active route.
pub route: Route,
}
/// Summary entry returned by `profile.list`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProfileInfo {
/// Profile name.
pub name: String,
/// True if this is the active profile.
pub active: bool,
/// Short description from the profile document.
pub description: String,
}
/// Result body of `route.list`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteList {
/// Active rules, in evaluation order.
pub rules: Vec<RouteRule>,
/// Current per-stream routing decisions.
pub current: Vec<StreamRoute>,
/// Fallback route when no rule matches.
pub default_route: Route,
}
/// One routing rule.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteRule {
/// Conditions that must all hold for this rule to fire.
#[serde(rename = "match")]
pub match_: RouteRuleMatch,
/// Route to assign when the rule fires.
pub route: Route,
}
/// Match conditions for a routing rule.
///
/// All present fields must hold (logical AND); within a field, any
/// listed value matches (logical OR). Empty match is implicitly true.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RouteRuleMatch {
/// Match on `application.process.binary`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub process_binary: Vec<String>,
/// Match on `application.name`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub application_name: Vec<String>,
/// Match on `pipewire.access.portal.app_id` (Flatpak).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portal_app_id: Vec<String>,
/// Match on `media.role`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub media_role: Vec<String>,
}
// ---------------------------------------------------------------------------
// Event payload shapes (typed)
// ---------------------------------------------------------------------------
/// Payload of a `hello` event (sent on connect).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HelloData {
/// Always `"headroom"`.
pub daemon: String,
/// Daemon version string (semver).
pub version: String,
/// Wire protocol version.
pub protocol: u32,
}
/// Payload of a `meters` tick event.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MeterTick {
/// Momentary loudness (BS.1770 M, 400 ms window), in LUFS.
pub momentary_lufs: f32,
/// Short-term loudness (BS.1770 S, 3 s window), in LUFS.
pub shortterm_lufs: f32,
/// Integrated loudness (BS.1770 I, gated), in LUFS.
pub integrated_lufs: f32,
/// Maximum true peak across channels, in dBTP.
pub true_peak_dbtp: f32,
/// Combined gain reduction (compressor + limiter), in dB.
pub gain_reduction_db: f32,
/// Compressor stage gain reduction, in dB.
pub compressor_gr_db: f32,
/// Limiter stage gain reduction, in dB.
pub limiter_gr_db: f32,
/// AGC contribution (positive = boost), in dB.
pub agc_gain_db: f32,
}
/// `profile` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProfileEvent {
/// Active profile changed.
Changed {
/// New active profile.
name: String,
/// Previous active profile.
previous: String,
},
/// One or more profile files were reloaded.
Reloaded {
/// Names of profiles whose definitions changed on disk.
changed: Vec<String>,
},
}
/// `routing` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RoutingEvent {
/// A new stream was assigned a route.
StreamRouted {
/// Node id of the routed stream.
node_id: u32,
/// Application identifier.
app: String,
/// Route assigned.
to: Route,
},
/// A persistent rule was added, replaced, or removed.
RuleChanged,
}
/// `daemon` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DaemonEvent {
/// Daemon started.
Started {
/// Daemon version string.
version: String,
},
/// Daemon shutting down.
Shutdown,
/// One or more events were dropped on this connection.
Overflow {
/// Topic whose queue overflowed.
lost_topic: Topic,
/// Number lost in this batch.
lost: u32,
/// Total lost on this connection so far.
total_lost: u64,
},
/// Non-fatal daemon error.
Error {
/// Stable code (matches `ErrorCode` when applicable).
code: String,
/// Human-readable detail.
message: String,
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip<T>(v: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de>,
{
let s = serde_json::to_string(v).expect("serialize");
serde_json::from_str(&s).expect("deserialize")
}
#[test]
fn op_status_serializes_without_args() {
let req = Request::new(1, Op::Status);
let s = serde_json::to_string(&req).unwrap();
// Must be the flat form, no `kind` wrapper, no `args` field.
assert_eq!(s, r#"{"id":1,"op":"status"}"#);
let back: Request = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn op_profile_use_round_trips_flat() {
let req = Request::new(
7,
Op::ProfileUse {
name: "night".into(),
},
);
let s = serde_json::to_string(&req).unwrap();
assert_eq!(s, r#"{"id":7,"op":"profile.use","args":{"name":"night"}}"#);
let back: Request = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn route_set_serializes_canonical() {
let req = Request::new(
12,
Op::RouteSet {
app: "firefox".into(),
to: Route::Processed,
},
);
let s = serde_json::to_string(&req).unwrap();
assert_eq!(
s,
r#"{"id":12,"op":"route.set","args":{"app":"firefox","to":"processed"}}"#
);
}
#[test]
fn response_ok_shape() {
let resp = Response::ok(3, &serde_json::json!({ "name": "default" })).unwrap();
let s = serde_json::to_string(&resp).unwrap();
assert_eq!(s, r#"{"id":3,"result":{"name":"default"}}"#);
}
#[test]
fn response_err_shape() {
let resp = Response::err(4, ProtoError::new(crate::ErrorCode::NotFound, "missing"));
let s = serde_json::to_string(&resp).unwrap();
assert_eq!(
s,
r#"{"id":4,"error":{"code":"NOT_FOUND","message":"missing"}}"#
);
}
#[test]
fn server_frame_distinguishes_response_from_event() {
let resp = Response::ok(1, &serde_json::json!(null)).unwrap();
let s = serde_json::to_string(&resp).unwrap();
let frame: ServerFrame = serde_json::from_str(&s).unwrap();
assert!(matches!(frame, ServerFrame::Response(_)));
let ev = Event::new(Topic::Meters, "tick", &serde_json::json!({})).unwrap();
let s = serde_json::to_string(&ev).unwrap();
let frame: ServerFrame = serde_json::from_str(&s).unwrap();
assert!(matches!(frame, ServerFrame::Event(_)));
}
#[test]
fn meter_tick_roundtrip() {
let m = 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 back = roundtrip(&m);
assert_eq!(back, m);
}
#[test]
fn topic_string_canonical() {
assert_eq!(Topic::Meters.as_str(), "meters");
assert_eq!(serde_json::to_string(&Topic::Meters).unwrap(), "\"meters\"");
let t: Topic = serde_json::from_str("\"profile\"").unwrap();
assert_eq!(t, Topic::Profile);
}
#[test]
fn route_string_canonical() {
let r: Route = serde_json::from_str("\"bypass\"").unwrap();
assert_eq!(r, Route::Bypass);
assert_eq!(serde_json::to_string(&r).unwrap(), "\"bypass\"");
}
#[test]
fn error_code_screaming_snake() {
let s = serde_json::to_string(&crate::ErrorCode::InvalidFrame).unwrap();
assert_eq!(s, "\"INVALID_FRAME\"");
let c: crate::ErrorCode = serde_json::from_str("\"UNKNOWN_OP\"").unwrap();
assert_eq!(c, crate::ErrorCode::UnknownOp);
}
#[test]
fn subscribe_op_roundtrip() {
let req = Request::new(
5,
Op::Subscribe {
topics: vec![Topic::Meters, Topic::Profile],
},
);
let back: Request = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(back, req);
}
}

74
docs/ipc-by-hand.md Normal file
View file

@ -0,0 +1,74 @@
# Poking the IPC by hand
The control protocol is plain length-prefixed JSON over a Unix socket.
You can drive it from a shell with `socat` and a tiny helper.
## Send a single request
```sh
# Send `{"id":1,"op":"status"}` as one framed message.
python3 - "$XDG_RUNTIME_DIR/headroom/control.sock" <<'PY'
import json, socket, struct, sys, os
sock_path = sys.argv[1]
msg = json.dumps({"id": 1, "op": "status"}).encode()
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sock_path)
def read_frame(s):
buf = b""
while len(buf) < 4: buf += s.recv(4 - len(buf))
n = struct.unpack(">I", buf)[0]
body = b""
while len(body) < n: body += s.recv(n - len(body))
return body
# Drop the hello.
hello = read_frame(s)
print("hello:", hello.decode())
s.sendall(struct.pack(">I", len(msg)) + msg)
print("reply:", read_frame(s).decode())
PY
```
## Subscribe and tail meters
```sh
python3 - "$XDG_RUNTIME_DIR/headroom/control.sock" <<'PY'
import json, socket, struct, sys
sock_path = sys.argv[1]
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(sock_path)
def read_frame(s):
buf = b""
while len(buf) < 4: buf += s.recv(4 - len(buf))
n = struct.unpack(">I", buf)[0]
body = b""
while len(body) < n: body += s.recv(n - len(body))
return body
def send(msg):
b = json.dumps(msg).encode()
s.sendall(struct.pack(">I", len(b)) + b)
read_frame(s) # hello
send({"id": 1, "op": "subscribe", "args": {"topics": ["meters"]}})
ack = json.loads(read_frame(s))
print("subscribed:", ack)
while True:
ev = json.loads(read_frame(s))
if ev.get("topic") == "meters":
print(ev["data"])
PY
```
## Notes
- Frames are 4-byte big-endian length + UTF-8 JSON. No newlines, no
NUL terminators.
- The server always emits one `hello` event on the `control` topic
immediately after `accept()` — read it first.
- Errors come back as `{"id": N, "error": {"code": "...", "message": "..."}}`.
See `IPC.md` §6 for the error-code table.
- `socat` works too, but framing makes raw `socat` awkward — pipe via
a tiny script that reads/writes length prefixes.

82
flake.lock generated Normal file
View file

@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1779074409,
"narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2a77b5b1dc952f214e8102acdef1622b68515560",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

107
flake.nix Normal file
View file

@ -0,0 +1,107 @@
{
description = "Headroom AGC + compressor + true-peak limiter daemon for PipeWire";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
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 ];
};
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
rustPlatform = pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
# Native libs the audio crates link against.
nativeAudioBuildInputs = with pkgs; [
pipewire
pipewire.dev
];
nativeBuildTools = with pkgs; [
pkg-config
clang
];
commonEnv = {
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig";
};
in
{
# `nix develop` — full dev environment.
devShells.default = pkgs.mkShell ({
name = "headroom-dev";
nativeBuildInputs = nativeBuildTools ++ [
rustToolchain
pkgs.rust-analyzer
];
buildInputs = nativeAudioBuildInputs ++ (with pkgs; [
socat # poke the IPC socket
jq # pretty-print JSON
pipewire # for pw-cli, pw-cat, etc.
wireplumber
]);
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);
# `nix build` — the final packaged daemon + CLI.
packages = rec {
default = headroom;
headroom = rustPlatform.buildRustPackage ({
pname = "headroom";
version = (builtins.fromTOML (builtins.readFile ./crates/headroom-cli/Cargo.toml)).package.version;
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);
};
# Reserved for the eventual user-service module.
# nixosModules.default = import ./nix/module.nix;
formatter = pkgs.nixpkgs-fmt;
});
}

24
profiles/bypass-all.toml Normal file
View file

@ -0,0 +1,24 @@
name = "bypass-all"
description = "Kill switch. Everything routes to the bypass sink; limiter stands by."
[agc]
enabled = false
[compressor]
enabled = false
[limiter]
# Still configured as a fail-safe in case a stream lands on the
# processed sink anyway.
ceiling_dbtp = -0.1
lookahead_ms = 2.0
release_ms = 80.0
hold_ms = 5.0
oversample = 4
link = "stereo"
[meters]
publish_hz = 10.0
[default_route]
route = "bypass"

64
profiles/default.toml Normal file
View file

@ -0,0 +1,64 @@
name = "default"
description = "Gentle transparent processing for everyday use."
[agc]
enabled = true
target_lufs = -18.0
attack_ms = 2000.0
release_ms = 800.0
silence_threshold_lufs = -70.0
max_boost_db = 12.0
max_cut_db = 12.0
[compressor]
enabled = true
detector = "peak"
threshold_db = -24.0
ratio = 2.5
knee_db = 6.0
attack_ms = 10.0
release_ms = 100.0
makeup_db = "auto"
[limiter]
ceiling_dbtp = -0.1
lookahead_ms = 2.0
release_ms = 80.0
hold_ms = 5.0
oversample = 4
link = "stereo"
# Soft-tier (musical, dynamic). Targets program_lufs + max_psr_db so
# transients don't shock the listener even though they're under the
# hard ceiling. Omit `[limiter.soft]` to get pure brickwall behavior.
[limiter.soft]
max_psr_db = 14.0
static_ceiling_dbtp = -6.0
attack_ms = 5.0
release_ms = 200.0
[meters]
publish_hz = 20.0
# Music players and DAWs are routed around the processor.
[[rules]]
match = { process_binary = ["spotify", "mpv", "vlc", "ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
route = "bypass"
# Browsers and voice chat are processed.
[[rules]]
match = { process_binary = [
"firefox",
"chromium",
"google-chrome",
"Discord",
"discord",
"element-desktop",
"Slack",
"zoom",
"WEBRTC VoiceEngine",
] }
route = "processed"
[default_route]
route = "processed"

47
profiles/night.toml Normal file
View file

@ -0,0 +1,47 @@
name = "night"
description = "Aggressive narrow-dynamic listening for late hours."
[agc]
enabled = true
target_lufs = -20.0
attack_ms = 1000.0
release_ms = 400.0
silence_threshold_lufs = -70.0
max_boost_db = 18.0
max_cut_db = 6.0
[compressor]
enabled = true
detector = "peak"
threshold_db = -28.0
ratio = 4.0
knee_db = 8.0
attack_ms = 5.0
release_ms = 60.0
makeup_db = "auto"
[limiter]
ceiling_dbtp = -1.0
lookahead_ms = 3.0
release_ms = 60.0
hold_ms = 5.0
oversample = 4
link = "stereo"
# Tight dynamic range: 8 dB max PSR keeps transients from disturbing
# others at low listening volume.
[limiter.soft]
max_psr_db = 8.0
static_ceiling_dbtp = -10.0
attack_ms = 3.0
release_ms = 150.0
[meters]
publish_hz = 20.0
[[rules]]
match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
route = "bypass"
[default_route]
route = "processed"

49
profiles/speech.toml Normal file
View file

@ -0,0 +1,49 @@
name = "speech"
description = "VoIP-focused: fast envelopes, controlled dynamic range."
[agc]
enabled = true
target_lufs = -16.0
attack_ms = 600.0
release_ms = 200.0
silence_threshold_lufs = -65.0
max_boost_db = 20.0
max_cut_db = 6.0
[compressor]
enabled = true
detector = "peak"
threshold_db = -22.0
ratio = 3.0
knee_db = 4.0
attack_ms = 3.0
release_ms = 50.0
makeup_db = "auto"
[limiter]
ceiling_dbtp = -0.3
lookahead_ms = 2.0
release_ms = 50.0
hold_ms = 3.0
oversample = 4
link = "stereo"
# Speech-tuned: moderate PSR cap so plosives are controlled without
# losing diction sparkle.
[limiter.soft]
max_psr_db = 10.0
static_ceiling_dbtp = -8.0
attack_ms = 2.0
release_ms = 100.0
[meters]
publish_hz = 20.0
# Mark common VoIP apps explicitly so this profile makes sense even
# without a daemon-wide rule.
[[rules]]
match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom", "WEBRTC VoiceEngine"] }
route = "processed"
[default_route]
route = "processed"

25
profiles/transparent.toml Normal file
View file

@ -0,0 +1,25 @@
name = "transparent"
description = "Limiter only. Compressor and AGC bypassed; the safety net."
[agc]
enabled = false
[compressor]
enabled = false
[limiter]
ceiling_dbtp = -0.1
lookahead_ms = 2.0
release_ms = 80.0
hold_ms = 5.0
oversample = 4
link = "stereo"
# Note: no [limiter.soft] section. Pure brickwall behavior: anything
# under -0.1 dBTP passes untouched. Use this profile when you want
# only the safety net and accept transients can hit the ceiling.
[meters]
publish_hz = 20.0
[default_route]
route = "processed"

4
rust-toolchain.toml Normal file
View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.86.0"
components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"]
profile = "minimal"