stage 2
This commit is contained in:
commit
ca1910de60
39 changed files with 6328 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/target
|
||||
/result
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
.direnv/
|
||||
.envrc
|
||||
664
Cargo.lock
generated
Normal file
664
Cargo.lock
generated
Normal 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
80
Cargo.toml
Normal 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
337
IPC.md
Normal 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
874
PLAN.md
Normal 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 | ~1–2 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 ~1–2 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
|
||||
(5–20 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 (2–5 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 ≈ 8–15 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
36
README.md
Normal 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.
|
||||
25
crates/headroom-cli/Cargo.toml
Normal file
25
crates/headroom-cli/Cargo.toml
Normal 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 }
|
||||
256
crates/headroom-cli/src/main.rs
Normal file
256
crates/headroom-cli/src/main.rs
Normal 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
|
||||
}
|
||||
22
crates/headroom-client/Cargo.toml
Normal file
22
crates/headroom-client/Cargo.toml
Normal 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 = []
|
||||
30
crates/headroom-client/README.md
Normal file
30
crates/headroom-client/README.md
Normal 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.
|
||||
498
crates/headroom-client/src/client.rs
Normal file
498
crates/headroom-client/src/client.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
19
crates/headroom-client/src/lib.rs
Normal file
19
crates/headroom-client/src/lib.rs
Normal 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,
|
||||
};
|
||||
39
crates/headroom-core/Cargo.toml
Normal file
39
crates/headroom-core/Cargo.toml
Normal 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 = []
|
||||
31
crates/headroom-core/src/lib.rs
Normal file
31
crates/headroom-core/src/lib.rs
Normal 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),
|
||||
}
|
||||
19
crates/headroom-dsp/Cargo.toml
Normal file
19
crates/headroom-dsp/Cargo.toml
Normal 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 = []
|
||||
19
crates/headroom-dsp/README.md
Normal file
19
crates/headroom-dsp/README.md
Normal 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.
|
||||
268
crates/headroom-dsp/src/compressor.rs
Normal file
268
crates/headroom-dsp/src/compressor.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
76
crates/headroom-dsp/src/delay.rs
Normal file
76
crates/headroom-dsp/src/delay.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
98
crates/headroom-dsp/src/envelope.rs
Normal file
98
crates/headroom-dsp/src/envelope.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
crates/headroom-dsp/src/lib.rs
Normal file
25
crates/headroom-dsp/src/lib.rs
Normal 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;
|
||||
850
crates/headroom-dsp/src/limiter.rs
Normal file
850
crates/headroom-dsp/src/limiter.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
230
crates/headroom-dsp/src/oversample.rs
Normal file
230
crates/headroom-dsp/src/oversample.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
108
crates/headroom-dsp/src/sliding_max.rs
Normal file
108
crates/headroom-dsp/src/sliding_max.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
crates/headroom-dsp/src/util.rs
Normal file
60
crates/headroom-dsp/src/util.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
16
crates/headroom-ipc/Cargo.toml
Normal file
16
crates/headroom-ipc/Cargo.toml
Normal 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 }
|
||||
12
crates/headroom-ipc/README.md
Normal file
12
crates/headroom-ipc/README.md
Normal 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.
|
||||
267
crates/headroom-ipc/src/codec.rs
Normal file
267
crates/headroom-ipc/src/codec.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
125
crates/headroom-ipc/src/error.rs
Normal file
125
crates/headroom-ipc/src/error.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
69
crates/headroom-ipc/src/lib.rs
Normal file
69
crates/headroom-ipc/src/lib.rs
Normal 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
|
||||
}
|
||||
693
crates/headroom-ipc/src/proto.rs
Normal file
693
crates/headroom-ipc/src/proto.rs
Normal 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
74
docs/ipc-by-hand.md
Normal 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
82
flake.lock
generated
Normal 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
107
flake.nix
Normal 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
24
profiles/bypass-all.toml
Normal 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
64
profiles/default.toml
Normal 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
47
profiles/night.toml
Normal 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
49
profiles/speech.toml
Normal 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
25
profiles/transparent.toml
Normal 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
4
rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "1.86.0"
|
||||
components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"]
|
||||
profile = "minimal"
|
||||
Loading…
Add table
Add a link
Reference in a new issue