diff --git a/Cargo.lock b/Cargo.lock index 5d6e4ab..a3283c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -128,12 +134,27 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -253,6 +274,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -320,12 +355,72 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "mio 1.2.0", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dasp_frame" version = "0.11.0" @@ -391,6 +486,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -505,6 +612,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -516,9 +634,12 @@ name = "headroom-cli" version = "0.1.0" dependencies = [ "clap", + "crossbeam-channel", + "crossterm", "headroom-client", "headroom-core", "headroom-ipc", + "ratatui", "serde_json", "thiserror 2.0.18", "tracing", @@ -591,6 +712,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.14.0" @@ -598,7 +725,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -621,6 +757,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -656,6 +805,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -738,6 +896,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "lock_api" version = "0.4.14" @@ -753,6 +917,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -786,6 +959,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.27.1" @@ -821,7 +1006,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio", + "mio 0.8.11", "walkdir", "windows-sys 0.48.0", ] @@ -896,6 +1081,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -954,6 +1145,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags 2.11.1", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1004,6 +1216,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1096,6 +1333,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 1.2.0", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1118,12 +1366,40 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.117" @@ -1327,6 +1603,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -1410,7 +1697,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1428,13 +1724,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1443,42 +1755,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 61cbdd9..3e86881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # CLI clap = { version = "4.5", features = ["derive"] } +# TUI (monitor). Pinned to versions whose transitive deps still build +# on the project's pinned rustc 1.86 (newer ratatui pulls +# `instability` 0.3.12 + `darling` 0.23 which need 1.88+). +ratatui = "=0.28.1" +crossterm = "=0.28.1" + # Concurrency / control plane crossbeam-channel = "0.5" parking_lot = "0.12" diff --git a/crates/headroom-cli/Cargo.toml b/crates/headroom-cli/Cargo.toml index d310f42..ee5e7c9 100644 --- a/crates/headroom-cli/Cargo.toml +++ b/crates/headroom-cli/Cargo.toml @@ -19,6 +19,9 @@ headroom-core = { workspace = true } headroom-ipc = { workspace = true } clap = { workspace = true } +crossbeam-channel = { workspace = true } +crossterm = { workspace = true } +ratatui = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/headroom-cli/src/main.rs b/crates/headroom-cli/src/main.rs index 3977755..ae79f21 100644 --- a/crates/headroom-cli/src/main.rs +++ b/crates/headroom-cli/src/main.rs @@ -6,6 +6,8 @@ #![forbid(unsafe_code)] +mod tui; + use std::path::PathBuf; use std::process::ExitCode; @@ -66,12 +68,19 @@ enum Cmd { /// Reload profile files from disk. Reload, - /// Subscribe to event topics and print as line-delimited JSON. + /// Live monitor. Defaults to a full-screen TUI; `--json` falls back + /// to the line-delimited JSON stream that previous versions + /// produced (useful for scripting and tests). Monitor { - /// Topics to subscribe to (comma-separated). - /// Defaults to `meters` if none given. + /// Topics to subscribe to (comma-separated). Only honoured with + /// `--json`; the TUI always subscribes to all four event topics. #[arg(value_delimiter = ',', default_value = "meters")] topics: Vec, + + /// Emit one JSON event per line on stdout instead of drawing + /// the TUI. + #[arg(long)] + json: bool, }, } @@ -170,13 +179,28 @@ fn init_tracing() { fn run() -> Result<(), CliError> { let cli = Cli::parse(); - init_tracing(); + + // TUI takes over the terminal; don't let `tracing` scribble on top + // of it. The JSON-mode monitor also benefits from a quieter stderr. + let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. }); + if !tui_mode { + init_tracing(); + } match cli.cmd { Cmd::Daemon => { headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?; Ok(()) } + Cmd::Monitor { json: false, .. } => { + // Connect on the main thread so the initial `status` / + // `route.list` round-trips happen before we enter raw mode. + let client = match cli.socket.as_deref() { + Some(p) => Client::connect_at(p)?, + None => Client::connect()?, + }; + tui::run(client).map_err(CliError::Tui) + } cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)), } } @@ -247,18 +271,23 @@ fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> { let reloaded = client.profile_reload()?; println!("reloaded: {reloaded:?}"); } - Cmd::Monitor { topics } => { - let pw_topics: Vec = topics.iter().copied().map(Topic::from).collect(); - client.subscribe(&pw_topics)?; - loop { - let ev = client.next_event()?; - println!( - "{} {}/{} {}", - chrono_like_now(), - ev.topic, - ev.event, - serde_json::to_string(&ev.data)?, - ); + Cmd::Monitor { topics, json } => { + if json { + let pw_topics: Vec = + topics.iter().copied().map(Topic::from).collect(); + client.subscribe(&pw_topics)?; + loop { + let ev = client.next_event()?; + println!( + "{} {}/{} {}", + chrono_like_now(), + ev.topic, + ev.event, + serde_json::to_string(&ev.data)?, + ); + } + } else { + unreachable!("TUI monitor is dispatched before `with_client`") } } } @@ -276,6 +305,9 @@ enum CliError { #[error("json: {0}")] Json(#[from] serde_json::Error), + #[error("tui: {0}")] + Tui(tui::TuiError), + #[error("{0}")] Other(String), } diff --git a/crates/headroom-cli/src/tui.rs b/crates/headroom-cli/src/tui.rs new file mode 100644 index 0000000..600b8a9 --- /dev/null +++ b/crates/headroom-cli/src/tui.rs @@ -0,0 +1,810 @@ +//! `headroom monitor` TUI. Subscribes to `meters`, `routing`, +//! `profile`, and `daemon`, renders bus DSP gauges + loudness + +//! per-stream routing + status header. +//! +//! Architecture: the main thread owns the terminal and the draw loop. +//! A reader thread owns the `Client` and forwards each subscription +//! event over a crossbeam channel. On quit the main thread restores +//! the terminal and exits; the reader thread is reaped by the OS. +//! (A CLI binary doesn't need a graceful reader shutdown — the kernel +//! tears the UnixStream down on process exit.) + +use std::collections::BTreeMap; +use std::io; +use std::thread; +use std::time::{Duration, Instant}; + +use crossbeam_channel::{select, tick, unbounded, Receiver}; +use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers}; +use headroom_client::{Client, ClientError}; +use headroom_ipc::{ + DaemonEvent, Event, LayerALevel, MeterTick, ProfileEvent, Route, RoutingEvent, Status, + StreamRoute, Topic, +}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table}, + Frame, Terminal, +}; + +/// Errors specific to the TUI subcommand. +#[derive(Debug, thiserror::Error)] +pub enum TuiError { + #[error("client: {0}")] + Client(#[from] ClientError), + + #[error("terminal: {0}")] + Io(#[from] io::Error), +} + +/// Entry point — owns the connected client through initial RPCs, then +/// hands it off to the reader thread and enters the draw loop. +pub fn run(mut client: Client) -> Result<(), TuiError> { + // Subscribe + initial state, all on the main thread before the + // terminal goes into raw mode. Any error here bubbles cleanly. + let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon]; + client.subscribe(&topics)?; + let status = client.status()?; + let route_list = client.route_list()?; + + // Spawn reader. + let (tx, rx) = unbounded::(); + let reader_handle = thread::Builder::new() + .name("headroom-monitor-rx".into()) + .spawn(move || reader_loop(client, tx)) + .map_err(TuiError::Io)?; + + // Terminal up. + let mut terminal = ratatui::init(); + let outcome = draw_loop(&mut terminal, status, route_list, rx); + ratatui::restore(); + + // Detach the reader: process exit (or the dropped channel) will + // tear the connection down. We don't need its result. + drop(reader_handle); + + outcome +} + +// --------------------------------------------------------------------------- +// Reader thread +// --------------------------------------------------------------------------- + +enum Msg { + Event(Event), + Disconnected(String), +} + +fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender) { + loop { + match client.next_event() { + Ok(ev) => { + if tx.send(Msg::Event(ev)).is_err() { + return; + } + } + Err(e) => { + let _ = tx.send(Msg::Disconnected(e.to_string())); + return; + } + } + } +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +struct UiState { + daemon_version: String, + profile: String, + bypass: bool, + /// Daemon uptime as of connect, plus our local elapsed. + base_uptime_s: u64, + connected_at: Instant, + default_route: Route, + streams: BTreeMap, + /// Per-stream Layer A state. Presence = tap attached; the inner + /// `Option` is the latest smoothed reduction in dB (None + /// until the first `meters/layer_a_level` event arrives). + layer_a: BTreeMap>, + meters: Option, + /// Wall-clock instant the last meter tick arrived. Used to show + /// staleness if the audio thread stops feeding the AGC. + last_meter_at: Option, + overflow_total: u64, + last_error: Option, + disconnected: Option, +} + +impl UiState { + fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self { + let mut streams = BTreeMap::new(); + for s in route_list.current { + streams.insert(s.node_id, s); + } + // Streams reported on `status` superset; merge. + for s in status.streams.iter() { + streams.entry(s.node_id).or_insert_with(|| s.clone()); + } + Self { + daemon_version: status.version, + profile: status.profile, + bypass: status.bypass, + base_uptime_s: status.uptime_s, + connected_at: Instant::now(), + default_route: route_list.default_route, + streams, + layer_a: BTreeMap::new(), + meters: None, + last_meter_at: None, + overflow_total: 0, + last_error: None, + disconnected: None, + } + } + + fn uptime_s(&self) -> u64 { + self.base_uptime_s + .saturating_add(self.connected_at.elapsed().as_secs()) + } + + fn apply_event(&mut self, ev: Event) { + match ev.topic { + Topic::Meters if ev.event == "tick" => { + if let Ok(m) = serde_json::from_value::(ev.data) { + self.meters = Some(m); + self.last_meter_at = Some(Instant::now()); + } + } + Topic::Meters if ev.event == "layer_a_level" => { + if let Ok(l) = serde_json::from_value::(ev.data) { + self.layer_a.insert(l.node_id, Some(l.reduction_db)); + } + } + Topic::Routing => { + if let Ok(re) = serde_json::from_value::(routing_payload(&ev)) { + match re { + RoutingEvent::StreamRouted { node_id, app, to } => { + self.streams.insert( + node_id, + StreamRoute { + node_id, + app, + route: to, + }, + ); + } + RoutingEvent::StreamRemoved { node_id } => { + self.streams.remove(&node_id); + self.layer_a.remove(&node_id); + } + RoutingEvent::LayerAAttached { node_id, .. } => { + // Mark managed; reduction unknown until the + // first `layer_a_level` event lands. + self.layer_a.entry(node_id).or_insert(None); + } + RoutingEvent::LayerADetached { node_id } => { + self.layer_a.remove(&node_id); + } + RoutingEvent::RuleChanged => { /* TUI doesn't display rules */ } + _ => {} + } + } + } + Topic::Profile => { + if let Ok(ProfileEvent::Changed { name, .. }) = + serde_json::from_value::(profile_payload(&ev)) + { + self.profile = name; + } + } + Topic::Daemon => { + if let Ok(de) = serde_json::from_value::(daemon_payload(&ev)) { + match de { + DaemonEvent::Overflow { + lost, total_lost, .. + } => { + self.overflow_total = total_lost.max(self.overflow_total + lost as u64); + } + DaemonEvent::Error { code, message } => { + self.last_error = Some(format!("{code}: {message}")); + } + DaemonEvent::Shutdown => { + self.disconnected = Some("daemon shutdown".into()); + } + DaemonEvent::Started { version } => { + self.daemon_version = version; + } + _ => {} + } + } + } + _ => {} + } + } +} + +/// The wire frame carries `{event, topic, data}` — the typed enum lives +/// inside `data` but is `#[serde(tag = "event")]`, so we re-inject the +/// event name to make serde happy. Same dance for the other topics. +fn routing_payload(ev: &Event) -> serde_json::Value { + inject_event(&ev.event, &ev.data) +} +fn profile_payload(ev: &Event) -> serde_json::Value { + inject_event(&ev.event, &ev.data) +} +fn daemon_payload(ev: &Event) -> serde_json::Value { + inject_event(&ev.event, &ev.data) +} + +fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value { + let mut obj = match data { + serde_json::Value::Object(m) => m.clone(), + _ => serde_json::Map::new(), + }; + obj.insert("event".into(), serde_json::Value::String(event.to_string())); + serde_json::Value::Object(obj) +} + +// --------------------------------------------------------------------------- +// Draw loop +// --------------------------------------------------------------------------- + +fn draw_loop( + terminal: &mut Terminal, + status: Status, + route_list: headroom_ipc::RouteList, + rx: Receiver, +) -> Result<(), TuiError> { + let mut state = UiState::new(status, route_list); + // 10 Hz redraw floor so uptime + staleness counters tick even when + // there are no events flowing. + let ticker = tick(Duration::from_millis(100)); + let input_rx = spawn_input_thread(); + + loop { + terminal.draw(|f| draw(f, &state))?; + + select! { + recv(rx) -> msg => match msg { + Ok(Msg::Event(ev)) => state.apply_event(ev), + Ok(Msg::Disconnected(reason)) => { + state.disconnected = Some(reason); + // Final paint, then linger briefly so the user sees + // the disconnected banner. + terminal.draw(|f| draw(f, &state))?; + thread::sleep(Duration::from_millis(800)); + return Ok(()); + } + Err(_) => return Ok(()), + }, + recv(input_rx) -> msg => match msg { + Ok(InputMsg::Quit) => return Ok(()), + Ok(InputMsg::Other) => {} + Err(_) => return Ok(()), + }, + recv(ticker) -> _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// Input thread +// --------------------------------------------------------------------------- + +enum InputMsg { + Quit, + Other, +} + +fn spawn_input_thread() -> Receiver { + let (tx, rx) = unbounded::(); + thread::Builder::new() + .name("headroom-monitor-input".into()) + .spawn(move || loop { + // Block on the next terminal event; crossterm's read() is + // a blocking syscall against stdin. + let Ok(ev) = event::read() else { return }; + let msg = match ev { + CtEvent::Key(k) if is_quit(&k) => InputMsg::Quit, + CtEvent::Key(_) | CtEvent::Resize(_, _) => InputMsg::Other, + _ => continue, + }; + if tx.send(msg).is_err() { + return; + } + }) + .expect("spawn input thread"); + rx +} + +fn is_quit(k: &KeyEvent) -> bool { + matches!(k.code, KeyCode::Char('q') | KeyCode::Esc) + || (k.modifiers.contains(KeyModifiers::CONTROL) + && matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))) +} + +// --------------------------------------------------------------------------- +// Drawing +// --------------------------------------------------------------------------- + +fn draw(f: &mut Frame, state: &UiState) { + let area = f.area(); + let outer = Block::default() + .borders(Borders::ALL) + .title(Span::styled( + " headroom monitor ", + Style::default().add_modifier(Modifier::BOLD), + )) + .title_top(Line::from(header_status(state)).right_aligned()) + .title_bottom(Line::from(footer_text(state)).right_aligned()); + let inner = outer.inner(area); + f.render_widget(outer, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), // bus gauges + Constraint::Length(5), // loudness + Constraint::Min(4), // streams table + ]) + .split(inner); + + draw_bus(f, chunks[0], state); + draw_loudness(f, chunks[1], state); + draw_streams(f, chunks[2], state); +} + +fn header_status(state: &UiState) -> Vec> { + let bypass_span = if state.bypass { + Span::styled( + " BYPASS ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ) + } else { + Span::styled(" processed ", Style::default().fg(Color::Green)) + }; + vec![ + Span::raw(" profile: "), + Span::styled(state.profile.clone(), Style::default().bold()), + Span::raw(" "), + bypass_span, + Span::raw(format!( + " v{} uptime {} ", + state.daemon_version, + fmt_uptime(state.uptime_s()) + )), + ] +} + +fn footer_text(state: &UiState) -> Vec> { + let mut parts: Vec = vec![ + Span::raw(" q/Esc/Ctrl-C quit "), + Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::raw(" subscribed: meters routing profile daemon "), + ]; + if state.overflow_total > 0 { + parts.push(Span::styled("·", Style::default().fg(Color::DarkGray))); + parts.push(Span::styled( + format!(" dropped: {} ", state.overflow_total), + Style::default().fg(Color::Yellow), + )); + } + if let Some(err) = &state.last_error { + parts.push(Span::styled("·", Style::default().fg(Color::DarkGray))); + parts.push(Span::styled( + format!(" daemon error: {err} "), + Style::default().fg(Color::Red), + )); + } + if let Some(reason) = &state.disconnected { + parts.push(Span::styled("·", Style::default().fg(Color::DarkGray))); + parts.push(Span::styled( + format!(" disconnected: {reason} "), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + } + parts +} + +fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" bus dsp "); + let inner = block.inner(area); + f.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let m = state.meters; + draw_gauge_row( + f, + rows[0], + GaugeRow { + label: "AGC target", + value: m.map(|t| t.agc_gain_db), + min: -12.0, + max: 12.0, + unit: "dB", + color: Color::Cyan, + }, + ); + draw_gauge_row( + f, + rows[1], + GaugeRow { + label: "Compressor GR", + value: m.map(|t| t.compressor_gr_db), + min: -24.0, + max: 0.0, + unit: "dB", + color: Color::Magenta, + }, + ); + draw_gauge_row( + f, + rows[2], + GaugeRow { + label: "Limiter GR", + value: m.map(|t| t.limiter_gr_db), + min: -24.0, + max: 0.0, + unit: "dB", + color: Color::Red, + }, + ); + draw_gauge_row( + f, + rows[3], + GaugeRow { + label: "True peak", + value: m.map(|t| t.true_peak_dbtp), + min: -60.0, + max: 3.0, + unit: "dBTP", + color: Color::Green, + }, + ); +} + +struct GaugeRow<'a> { + label: &'a str, + value: Option, + min: f32, + max: f32, + unit: &'a str, + color: Color, +} + +/// One labeled gauge row: `LABEL VALUE [████░░░░] min..max`. +fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) { + let GaugeRow { + label, + value, + min, + max, + unit, + color, + } = row; + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(16), + Constraint::Length(14), + Constraint::Min(8), + Constraint::Length(14), + ]) + .split(area); + + f.render_widget(Paragraph::new(format!(" {label}")), cols[0]); + + let value_str = value + .map(|v| format!("{v:+7.2} {unit}")) + .unwrap_or_else(|| " -- ".to_string()); + f.render_widget( + Paragraph::new(value_str).alignment(Alignment::Right), + cols[1], + ); + + let pct = match value { + Some(v) => { + let clamped = v.clamp(min, max); + ((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64 + } + None => 0.0, + }; + let gauge = Gauge::default() + .gauge_style(Style::default().fg(color)) + .ratio(pct) + .label(""); + f.render_widget(gauge, cols[2]); + + f.render_widget( + Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right), + cols[3], + ); +} + +fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" loudness (BS.1770) "); + let inner = block.inner(area); + f.render_widget(block, area); + + let staleness = state + .last_meter_at + .map(|t| t.elapsed()) + .unwrap_or(Duration::ZERO); + let stale = staleness > Duration::from_millis(500); + + let (mom, st, intg) = match state.meters { + Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)), + None => (None, None, None), + }; + + let lines = vec![ + lufs_line("Momentary (400 ms)", mom, stale), + lufs_line("Short-term (3 s)", st, stale), + lufs_line("Integrated (gated)", intg, stale), + ]; + f.render_widget(Paragraph::new(lines), inner); +} + +fn lufs_line(label: &str, v: Option, stale: bool) -> Line<'static> { + let val = match v { + Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => { + format!("{x:+7.2} LUFS") + } + Some(_) => " -- LUFS".to_string(), + None => " -- LUFS".to_string(), + }; + let style = if stale { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + Line::from(vec![ + Span::raw(format!(" {label:<24}")), + Span::styled(val, style), + ]) +} + +fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) { + let title = format!( + " streams ({}) — default: {} ", + state.streams.len(), + state.default_route + ); + let block = Block::default().borders(Borders::ALL).title(title); + + let header = Row::new(vec!["node", "app", "route", "layer A"]) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let rows: Vec = state + .streams + .values() + .map(|s| { + let route_cell = match s.route { + Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)), + Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)), + }; + let la_cell = match state.layer_a.get(&s.node_id) { + Some(Some(db)) => Cell::from(format!("{db:+5.1} dB")) + .style(Style::default().fg(Color::Magenta)), + Some(None) => Cell::from("attached") + .style(Style::default().fg(Color::DarkGray)), + None => Cell::from("—").style(Style::default().fg(Color::DarkGray)), + }; + Row::new(vec![ + Cell::from(s.node_id.to_string()), + Cell::from(s.app.clone()), + route_cell, + la_cell, + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(8), + Constraint::Min(20), + Constraint::Length(12), + Constraint::Length(10), + ]; + let table = Table::new(rows, widths).header(header).block(block); + f.render_widget(table, area); +} + +fn fmt_uptime(s: u64) -> String { + let h = s / 3600; + let m = (s % 3600) / 60; + let sec = s % 60; + if h > 0 { + format!("{h}h{m:02}m{sec:02}s") + } else if m > 0 { + format!("{m}m{sec:02}s") + } else { + format!("{sec}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use headroom_ipc::{Sinks, Status}; + + fn empty_state() -> UiState { + let status = Status { + version: "test".into(), + protocol: 1, + uptime_s: 0, + profile: "default".into(), + bypass: false, + sinks: Sinks::default(), + streams: vec![], + warnings: vec![], + }; + let route_list = headroom_ipc::RouteList { + rules: vec![], + current: vec![], + default_route: Route::Processed, + }; + UiState::new(status, route_list) + } + + #[test] + fn meter_tick_event_updates_state() { + let mut state = empty_state(); + let tick = MeterTick { + momentary_lufs: -19.3, + shortterm_lufs: -20.1, + integrated_lufs: -19.8, + true_peak_dbtp: -1.4, + gain_reduction_db: -2.1, + compressor_gr_db: -0.8, + limiter_gr_db: -1.3, + agc_gain_db: 0.5, + }; + let ev = Event::new(Topic::Meters, "tick", &tick).unwrap(); + state.apply_event(ev); + let got = state.meters.expect("meters set"); + assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON); + assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON); + assert!(state.last_meter_at.is_some()); + } + + #[test] + fn stream_removed_prunes_state() { + let mut state = empty_state(); + // Insert via stream_routed first. + state.apply_event( + Event::new( + Topic::Routing, + "stream_routed", + &serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }), + ) + .unwrap(), + ); + state.apply_event( + Event::new( + Topic::Routing, + "layer_a_attached", + &serde_json::json!({ "node_id": 7, "app": "x" }), + ) + .unwrap(), + ); + assert!(state.streams.contains_key(&7)); + assert!(state.layer_a.contains_key(&7)); + + state.apply_event( + Event::new( + Topic::Routing, + "stream_removed", + &serde_json::json!({ "node_id": 7 }), + ) + .unwrap(), + ); + assert!(!state.streams.contains_key(&7)); + assert!(!state.layer_a.contains_key(&7)); + } + + #[test] + fn layer_a_level_updates_reduction() { + let mut state = empty_state(); + state.apply_event( + Event::new( + Topic::Routing, + "layer_a_attached", + &serde_json::json!({ "node_id": 11, "app": "loud-app" }), + ) + .unwrap(), + ); + assert_eq!(state.layer_a.get(&11), Some(&None)); + + state.apply_event( + Event::new( + Topic::Meters, + "layer_a_level", + &serde_json::json!({ + "node_id": 11, + "app": "loud-app", + "volume_lin": 0.256_f32, + "reduction_db": -11.8_f32, + }), + ) + .unwrap(), + ); + let r = state.layer_a.get(&11).copied().flatten().unwrap(); + assert!((r - -11.8).abs() < 1e-4); + } + + #[test] + fn routing_event_inserts_stream() { + let mut state = empty_state(); + let ev = Event::new( + Topic::Routing, + "stream_routed", + &serde_json::json!({ + "node_id": 42, + "app": "firefox", + "to": "bypass", + }), + ) + .unwrap(); + state.apply_event(ev); + let s = state.streams.get(&42).expect("stream tracked"); + assert_eq!(s.app, "firefox"); + assert_eq!(s.route, Route::Bypass); + } + + #[test] + fn profile_changed_updates_active() { + let mut state = empty_state(); + let ev = Event::new( + Topic::Profile, + "changed", + &serde_json::json!({ + "name": "night", + "previous": "default", + }), + ) + .unwrap(); + state.apply_event(ev); + assert_eq!(state.profile, "night"); + } + + #[test] + fn daemon_overflow_accumulates() { + let mut state = empty_state(); + let ev = Event::new( + Topic::Daemon, + "overflow", + &serde_json::json!({ + "lost_topic": "meters", + "lost": 3u32, + "total_lost": 5u64, + }), + ) + .unwrap(); + state.apply_event(ev); + assert_eq!(state.overflow_total, 5); + } + + #[test] + fn fmt_uptime_buckets() { + assert_eq!(fmt_uptime(5), "5s"); + assert_eq!(fmt_uptime(75), "1m15s"); + assert_eq!(fmt_uptime(3725), "1h02m05s"); + } +} diff --git a/crates/headroom-core/src/agc.rs b/crates/headroom-core/src/agc.rs index 354eca5..b4b54ce 100644 --- a/crates/headroom-core/src/agc.rs +++ b/crates/headroom-core/src/agc.rs @@ -32,8 +32,10 @@ const TICK_BUF_SAMPLES: usize = 8192; /// Loudness floor we treat as "no usable measurement yet" — returned /// by `ebur128` before its short-term window has filled, or during -/// digital silence. -const LOUDNESS_FLOOR_LUFS: f32 = -200.0; +/// digital silence. Published as-is in `MeterTick.*_lufs` fields, so +/// clients can use this constant to recognise "no measurement" without +/// hard-coding the number. +pub const LOUDNESS_FLOOR_LUFS: f32 = -200.0; /// Slow AGC controller. pub struct AgcController { diff --git a/crates/headroom-ipc/src/lib.rs b/crates/headroom-ipc/src/lib.rs index 1bb9490..c9d45ff 100644 --- a/crates/headroom-ipc/src/lib.rs +++ b/crates/headroom-ipc/src/lib.rs @@ -13,9 +13,9 @@ mod proto; pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES}; pub use error::{Error, ErrorCode, ProtoError}; pub use proto::{ - DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response, - ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame, - SinkInfo, Sinks, Status, StreamRoute, Topic, + DaemonEvent, Event, HelloData, LayerALevel, MeterTick, Op, ProfileEvent, ProfileInfo, Request, + Response, ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, + ServerFrame, SinkInfo, Sinks, Status, StreamRoute, Topic, }; /// Wire-protocol version. Bumped only on incompatible changes. diff --git a/crates/headroom-ipc/src/proto.rs b/crates/headroom-ipc/src/proto.rs index 4b5aabf..cbe761c 100644 --- a/crates/headroom-ipc/src/proto.rs +++ b/crates/headroom-ipc/src/proto.rs @@ -525,10 +525,49 @@ pub enum RoutingEvent { /// Route assigned. to: Route, }, + /// A stream tracked by the routing engine went away (its + /// PipeWire node disappeared). Clients should drop any state + /// indexed by `node_id`. + StreamRemoved { + /// Node id of the departed stream. + node_id: u32, + }, + /// A Layer A (per-app level control) tap was attached to a + /// stream — the daemon will start managing its + /// `Props.channelVolumes` and publishing `meters/layer_a_level` + /// events for it. + LayerAAttached { + /// Node id of the managed stream. + node_id: u32, + /// Application identifier. + app: String, + }, + /// A Layer A tap was torn down (typically because the stream + /// went away). Clients should drop Layer A state for `node_id`. + LayerADetached { + /// Node id whose tap was torn down. + node_id: u32, + }, /// A persistent rule was added, replaced, or removed. RuleChanged, } +/// `meters/layer_a_level` payload — published when the per-app +/// (Layer A) level controller writes a new `channelVolumes` value to +/// a managed stream. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayerALevel { + /// Source PipeWire node id. + pub node_id: u32, + /// Application identifier. + pub app: String, + /// Linear volume that was written (1.0 = unity). + pub volume_lin: f32, + /// Smoothed gain reduction the controller currently asserts, in + /// dB. ≤ 0 dB when reducing. + pub reduction_db: f32, +} + /// `daemon` topic events. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")]