commit ca1910de605eb13658f2d69669ebef2a02d13589 Author: atagen Date: Tue May 19 16:33:09 2026 +1000 stage 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..343344c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +/result +**/*.rs.bk +Cargo.lock.bak +.direnv/ +.envrc diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1702c01 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..203114c --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/IPC.md b/IPC.md new file mode 100644 index 0000000..20e5d83 --- /dev/null +++ b/IPC.md @@ -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": , + "op": "", + "args": +} +``` + +- `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": , "result": } +``` + +or + +```json +{ "id": , "error": { "code": "", "message": "" } } +``` + +- 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": "", + "topic": "", + "data": +} +``` + +- 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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..def5b9f --- /dev/null +++ b/PLAN.md @@ -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 `), 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 | show [name] +headroom route list +headroom route set processed|bypass # persists in user profile +headroom route unset +headroom route stream processed|bypass # ad-hoc +headroom set # tweak active profile in place +headroom get +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..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.) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5e8226 --- /dev/null +++ b/README.md @@ -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. diff --git a/crates/headroom-cli/Cargo.toml b/crates/headroom-cli/Cargo.toml new file mode 100644 index 0000000..d310f42 --- /dev/null +++ b/crates/headroom-cli/Cargo.toml @@ -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 } diff --git a/crates/headroom-cli/src/main.rs b/crates/headroom-cli/src/main.rs new file mode 100644 index 0000000..4b4139a --- /dev/null +++ b/crates/headroom-cli/src/main.rs @@ -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, + + #[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, + }, +} + +#[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 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(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 +} diff --git a/crates/headroom-client/Cargo.toml b/crates/headroom-client/Cargo.toml new file mode 100644 index 0000000..0e51681 --- /dev/null +++ b/crates/headroom-client/Cargo.toml @@ -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 = [] diff --git a/crates/headroom-client/README.md b/crates/headroom-client/README.md new file mode 100644 index 0000000..48e5f7d --- /dev/null +++ b/crates/headroom-client/README.md @@ -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. diff --git a/crates/headroom-client/src/client.rs b/crates/headroom-client/src/client.rs new file mode 100644 index 0000000..de39f7b --- /dev/null +++ b/crates/headroom-client/src/client.rs @@ -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, + writer: BufWriter, + codec: Codec, + next_id: u64, + pending_events: VecDeque, + hello: HelloData, + socket_path: PathBuf, +} + +impl Client { + /// Connect to the headroom daemon at its default socket path. + pub fn connect() -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + self.pending_events.pop_front() + } + + // --------------------------------------------------------------- + // Typed convenience wrappers + // --------------------------------------------------------------- + + fn send_into(&mut self, op: Op) -> Result { + let value = self.send(op)?; + serde_json::from_value(value).map_err(ClientError::DecodeResult) + } + + /// `status` + pub fn status(&mut self) -> Result { + self.send_into(Op::Status) + } + + /// `profile.list` + pub fn profile_list(&mut self) -> Result, ClientError> { + #[derive(serde::Deserialize)] + struct Body { + profiles: Vec, + } + let body: Body = self.send_into(Op::ProfileList)?; + Ok(body.profiles) + } + + /// `profile.use` + pub fn profile_use(&mut self, name: &str) -> Result { + #[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 { + self.send(Op::ProfileShow { + name: name.map(String::from), + }) + } + + /// `profile.reload` + pub fn profile_reload(&mut self) -> Result, ClientError> { + #[derive(serde::Deserialize)] + struct Body { + reloaded: Vec, + } + let body: Body = self.send_into(Op::ProfileReload)?; + Ok(body.reloaded) + } + + /// `route.list` + pub fn route_list(&mut self) -> Result { + 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 { + #[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, ClientError> { + #[derive(serde::Deserialize)] + struct Body { + subscribed: Vec, + } + let body: Body = self.send_into(Op::Subscribe { + topics: topics.to_vec(), + })?; + Ok(body.subscribed) + } + + /// `unsubscribe` + pub fn unsubscribe(&mut self, topics: &[Topic]) -> Result, ClientError> { + #[derive(serde::Deserialize)] + struct Body { + unsubscribed: Vec, + } + 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(""), + }; + 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)); + } +} diff --git a/crates/headroom-client/src/lib.rs b/crates/headroom-client/src/lib.rs new file mode 100644 index 0000000..6a36362 --- /dev/null +++ b/crates/headroom-client/src/lib.rs @@ -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, +}; diff --git a/crates/headroom-core/Cargo.toml b/crates/headroom-core/Cargo.toml new file mode 100644 index 0000000..1c49548 --- /dev/null +++ b/crates/headroom-core/Cargo.toml @@ -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 = [] diff --git a/crates/headroom-core/src/lib.rs b/crates/headroom-core/src/lib.rs new file mode 100644 index 0000000..269eeab --- /dev/null +++ b/crates/headroom-core/src/lib.rs @@ -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), +} diff --git a/crates/headroom-dsp/Cargo.toml b/crates/headroom-dsp/Cargo.toml new file mode 100644 index 0000000..7fd7ca4 --- /dev/null +++ b/crates/headroom-dsp/Cargo.toml @@ -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 = [] diff --git a/crates/headroom-dsp/README.md b/crates/headroom-dsp/README.md new file mode 100644 index 0000000..efe6ff0 --- /dev/null +++ b/crates/headroom-dsp/README.md @@ -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. diff --git a/crates/headroom-dsp/src/compressor.rs b/crates/headroom-dsp/src/compressor.rs new file mode 100644 index 0000000..db2a462 --- /dev/null +++ b/crates/headroom-dsp/src/compressor.rs @@ -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, + /// 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}"); + } +} diff --git a/crates/headroom-dsp/src/delay.rs b/crates/headroom-dsp/src/delay.rs new file mode 100644 index 0000000..f9db823 --- /dev/null +++ b/crates/headroom-dsp/src/delay.rs @@ -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, + 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); + } +} diff --git a/crates/headroom-dsp/src/envelope.rs b/crates/headroom-dsp/src/envelope.rs new file mode 100644 index 0000000..2bbbc73 --- /dev/null +++ b/crates/headroom-dsp/src/envelope.rs @@ -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); + } +} diff --git a/crates/headroom-dsp/src/lib.rs b/crates/headroom-dsp/src/lib.rs new file mode 100644 index 0000000..df3d265 --- /dev/null +++ b/crates/headroom-dsp/src/lib.rs @@ -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; diff --git a/crates/headroom-dsp/src/limiter.rs b/crates/headroom-dsp/src/limiter.rs new file mode 100644 index 0000000..c03c602 --- /dev/null +++ b/crates/headroom-dsp/src/limiter.rs @@ -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, +} + +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, + soft_max_psr_db: f32, + soft_static_ceiling_lin: f32, + program_loudness_lufs: Option, + /// 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 { + 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 { + 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}" + ); + } +} diff --git a/crates/headroom-dsp/src/oversample.rs b/crates/headroom-dsp/src/oversample.rs new file mode 100644 index 0000000..969f6ee --- /dev/null +++ b/crates/headroom-dsp/src/oversample.rs @@ -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 { + 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, + history: Vec, + 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, + history: Vec, + 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 { + 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::() / 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)); + } +} diff --git a/crates/headroom-dsp/src/sliding_max.rs b/crates/headroom-dsp/src/sliding_max.rs new file mode 100644 index 0000000..30582e9 --- /dev/null +++ b/crates/headroom-dsp/src/sliding_max.rs @@ -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); + } + } +} diff --git a/crates/headroom-dsp/src/util.rs b/crates/headroom-dsp/src/util.rs new file mode 100644 index 0000000..a023dc6 --- /dev/null +++ b/crates/headroom-dsp/src/util.rs @@ -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}"); + } +} diff --git a/crates/headroom-ipc/Cargo.toml b/crates/headroom-ipc/Cargo.toml new file mode 100644 index 0000000..5281dff --- /dev/null +++ b/crates/headroom-ipc/Cargo.toml @@ -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 } diff --git a/crates/headroom-ipc/README.md b/crates/headroom-ipc/README.md new file mode 100644 index 0000000..b56c22c --- /dev/null +++ b/crates/headroom-ipc/README.md @@ -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. diff --git a/crates/headroom-ipc/src/codec.rs b/crates/headroom-ipc/src/codec.rs new file mode 100644 index 0000000..80617e2 --- /dev/null +++ b/crates/headroom-ipc/src/codec.rs @@ -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(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(self, mut r: R) -> Result { + 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: &mut R, buf: &mut [u8]) -> Result { + 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::::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); + } +} diff --git a/crates/headroom-ipc/src/error.rs b/crates/headroom-ipc/src/error.rs new file mode 100644 index 0000000..26861d6 --- /dev/null +++ b/crates/headroom-ipc/src/error.rs @@ -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) -> 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 + ) + } +} diff --git a/crates/headroom-ipc/src/lib.rs b/crates/headroom-ipc/src/lib.rs new file mode 100644 index 0000000..1bb9490 --- /dev/null +++ b/crates/headroom-ipc/src/lib.rs @@ -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 { + 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 { + 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 +} diff --git a/crates/headroom-ipc/src/proto.rs b/crates/headroom-ipc/src/proto.rs new file mode 100644 index 0000000..1203146 --- /dev/null +++ b/crates/headroom-ipc/src/proto.rs @@ -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": "", "args": }` with operations +/// that take no arguments omitting the `args` field. Operations with no +/// arguments serialize as `{ "op": "" }`; 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, + }, + + /// 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, + }, + + /// Unsubscribe from one or more topics on this connection. + #[serde(rename = "unsubscribe")] + Unsubscribe { + /// Topics to unsubscribe from. + topics: Vec, + }, +} + +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(id: u64, value: &T) -> Result { + 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( + topic: Topic, + event: impl Into, + data: &T, + ) -> Result { + 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, +} + +/// 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, + /// Human-readable sink name, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 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, + /// Current per-stream routing decisions. + pub current: Vec, + /// 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, + /// Match on `application.name`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub application_name: Vec, + /// Match on `pipewire.access.portal.app_id` (Flatpak). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub portal_app_id: Vec, + /// Match on `media.role`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub media_role: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + }, +} + +/// `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(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); + } +} diff --git a/docs/ipc-by-hand.md b/docs/ipc-by-hand.md new file mode 100644 index 0000000..8ef8d1c --- /dev/null +++ b/docs/ipc-by-hand.md @@ -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. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..02bc5c9 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1829bd6 --- /dev/null +++ b/flake.nix @@ -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; + }); +} diff --git a/profiles/bypass-all.toml b/profiles/bypass-all.toml new file mode 100644 index 0000000..e6554ce --- /dev/null +++ b/profiles/bypass-all.toml @@ -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" diff --git a/profiles/default.toml b/profiles/default.toml new file mode 100644 index 0000000..779043c --- /dev/null +++ b/profiles/default.toml @@ -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" diff --git a/profiles/night.toml b/profiles/night.toml new file mode 100644 index 0000000..adf73ce --- /dev/null +++ b/profiles/night.toml @@ -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" diff --git a/profiles/speech.toml b/profiles/speech.toml new file mode 100644 index 0000000..02063c0 --- /dev/null +++ b/profiles/speech.toml @@ -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" diff --git a/profiles/transparent.toml b/profiles/transparent.toml new file mode 100644 index 0000000..ff8c7c8 --- /dev/null +++ b/profiles/transparent.toml @@ -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" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8f20189 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.86.0" +components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] +profile = "minimal"