5: monitor TUI + wire fill-ins
`headroom monitor` becomes a full-screen ratatui TUI by default;
the previous behaviour (line-delimited JSON, useful for scripts and
tests) is preserved behind --json.
5 — Monitor TUI
New `crates/headroom-cli/src/tui.rs` (~700 lines incl. tests).
Main thread does subscribe + initial status() + route_list() before
entering raw mode, so connect errors surface as clean stderr
messages instead of corrupting the terminal. A reader thread owns
the headroom_client::Client and forwards each subscription event
through a crossbeam channel; an input thread blocks on
event::read() and forwards keys (q / Esc / Ctrl-C) through a
second channel; the main thread `select!`s both plus a 10 Hz
ticker (so uptime + staleness display advance even when no
events are flowing). On quit the OS reaps the reader; a CLI tool
doesn't need a graceful UnixStream shutdown.
Layout: outer block carries the profile / version / uptime in the
top-right title and a footer with subscribed topics + an overflow /
error / disconnected banner when relevant. Inside: bus DSP gauges
(AGC target, compressor GR, limiter GR, true peak), a loudness
panel (momentary / short-term / integrated, greyed when stale),
and a streams table with route + Layer A reduction column.
Wire types caught up to the daemon
`headroom-ipc::RoutingEvent` gained `StreamRemoved`,
`LayerAAttached`, `LayerADetached` variants — these are events the
daemon already publishes (registry.rs §pw) but that
weren't typed in the proto. Without `StreamRemoved` the TUI would
accumulate departed streams forever; without the Layer A pair the
per-stream column couldn't track tap state.
New `LayerALevel` struct types the `meters/layer_a_level` payload
(node_id, app, volume_lin, reduction_db).
`headroom_core::agc::LOUDNESS_FLOOR_LUFS` is now `pub` — it's
published as-is in MeterTick.*_lufs fields when ebur128 has no
useful measurement yet, so clients need it to render "no
measurement" without hard-coding `-200.0`.
Toolchain notes
ratatui and crossterm pinned to =0.28.1. Newer ratatui pulls in
`instability` 0.3.12 + `darling` 0.23 which need rustc 1.88+; the
project pins 1.86 via rust-toolchain.toml. Lockfile also pins
`instability` to 0.3.7 and `darling` to 0.20.10 (older patches that
still build on 1.86).
Verified
185 tests passing (was 178: +5 for TUI event mapping +
fmt_uptime, +2 for stream_removed / layer_a_level handling).
Clippy clean at -D warnings --all-targets.
Live smoke: daemon emits routing/{stream_routed, stream_removed,
layer_a_attached, layer_a_detached} and meters/{tick, layer_a_level}
in shapes that round-trip cleanly through the new typed enums.
TUI binary survives raw-mode init + initial RPCs + subscription
against a live daemon.
Known unrelated daemon gap (to be fixed next): pre-existing streams
aren't actually re-linked when the daemon writes target.object —
WirePlumber updates metadata but doesn't tear the old link down or
create a new one into the processed sink. Bus DSP path therefore
sees silence even when status reports route=processed. Not Phase 5;
addressed separately.
This commit is contained in:
parent
79e4baedd0
commit
e528a98417
8 changed files with 1283 additions and 31 deletions
380
Cargo.lock
generated
380
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<MonitorTopic>,
|
||||
|
||||
/// 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<Topic> = topics.iter().copied().map(Topic::from).collect();
|
||||
client.subscribe(&pw_topics)?;
|
||||
loop {
|
||||
let ev = client.next_event()?;
|
||||
println!(
|
||||
"{} {}/{} {}",
|
||||
chrono_like_now(),
|
||||
ev.topic,
|
||||
ev.event,
|
||||
serde_json::to_string(&ev.data)?,
|
||||
);
|
||||
Cmd::Monitor { topics, json } => {
|
||||
if json {
|
||||
let pw_topics: Vec<Topic> =
|
||||
topics.iter().copied().map(Topic::from).collect();
|
||||
client.subscribe(&pw_topics)?;
|
||||
loop {
|
||||
let ev = client.next_event()?;
|
||||
println!(
|
||||
"{} {}/{} {}",
|
||||
chrono_like_now(),
|
||||
ev.topic,
|
||||
ev.event,
|
||||
serde_json::to_string(&ev.data)?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
unreachable!("TUI monitor is dispatched before `with_client`")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -276,6 +305,9 @@ enum CliError {
|
|||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("tui: {0}")]
|
||||
Tui(tui::TuiError),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
|
|
|||
810
crates/headroom-cli/src/tui.rs
Normal file
810
crates/headroom-cli/src/tui.rs
Normal file
|
|
@ -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::<Msg>();
|
||||
let reader_handle = thread::Builder::new()
|
||||
.name("headroom-monitor-rx".into())
|
||||
.spawn(move || reader_loop(client, tx))
|
||||
.map_err(TuiError::Io)?;
|
||||
|
||||
// Terminal up.
|
||||
let mut terminal = ratatui::init();
|
||||
let outcome = draw_loop(&mut terminal, status, route_list, rx);
|
||||
ratatui::restore();
|
||||
|
||||
// Detach the reader: process exit (or the dropped channel) will
|
||||
// tear the connection down. We don't need its result.
|
||||
drop(reader_handle);
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Msg {
|
||||
Event(Event),
|
||||
Disconnected(String),
|
||||
}
|
||||
|
||||
fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
|
||||
loop {
|
||||
match client.next_event() {
|
||||
Ok(ev) => {
|
||||
if tx.send(Msg::Event(ev)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Msg::Disconnected(e.to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct UiState {
|
||||
daemon_version: String,
|
||||
profile: String,
|
||||
bypass: bool,
|
||||
/// Daemon uptime as of connect, plus our local elapsed.
|
||||
base_uptime_s: u64,
|
||||
connected_at: Instant,
|
||||
default_route: Route,
|
||||
streams: BTreeMap<u32, StreamRoute>,
|
||||
/// Per-stream Layer A state. Presence = tap attached; the inner
|
||||
/// `Option<f32>` is the latest smoothed reduction in dB (None
|
||||
/// until the first `meters/layer_a_level` event arrives).
|
||||
layer_a: BTreeMap<u32, Option<f32>>,
|
||||
meters: Option<MeterTick>,
|
||||
/// Wall-clock instant the last meter tick arrived. Used to show
|
||||
/// staleness if the audio thread stops feeding the AGC.
|
||||
last_meter_at: Option<Instant>,
|
||||
overflow_total: u64,
|
||||
last_error: Option<String>,
|
||||
disconnected: Option<String>,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
|
||||
let mut streams = BTreeMap::new();
|
||||
for s in route_list.current {
|
||||
streams.insert(s.node_id, s);
|
||||
}
|
||||
// Streams reported on `status` superset; merge.
|
||||
for s in status.streams.iter() {
|
||||
streams.entry(s.node_id).or_insert_with(|| s.clone());
|
||||
}
|
||||
Self {
|
||||
daemon_version: status.version,
|
||||
profile: status.profile,
|
||||
bypass: status.bypass,
|
||||
base_uptime_s: status.uptime_s,
|
||||
connected_at: Instant::now(),
|
||||
default_route: route_list.default_route,
|
||||
streams,
|
||||
layer_a: BTreeMap::new(),
|
||||
meters: None,
|
||||
last_meter_at: None,
|
||||
overflow_total: 0,
|
||||
last_error: None,
|
||||
disconnected: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn uptime_s(&self) -> u64 {
|
||||
self.base_uptime_s
|
||||
.saturating_add(self.connected_at.elapsed().as_secs())
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, ev: Event) {
|
||||
match ev.topic {
|
||||
Topic::Meters if ev.event == "tick" => {
|
||||
if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
|
||||
self.meters = Some(m);
|
||||
self.last_meter_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Topic::Meters if ev.event == "layer_a_level" => {
|
||||
if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
|
||||
self.layer_a.insert(l.node_id, Some(l.reduction_db));
|
||||
}
|
||||
}
|
||||
Topic::Routing => {
|
||||
if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
|
||||
match re {
|
||||
RoutingEvent::StreamRouted { node_id, app, to } => {
|
||||
self.streams.insert(
|
||||
node_id,
|
||||
StreamRoute {
|
||||
node_id,
|
||||
app,
|
||||
route: to,
|
||||
},
|
||||
);
|
||||
}
|
||||
RoutingEvent::StreamRemoved { node_id } => {
|
||||
self.streams.remove(&node_id);
|
||||
self.layer_a.remove(&node_id);
|
||||
}
|
||||
RoutingEvent::LayerAAttached { node_id, .. } => {
|
||||
// Mark managed; reduction unknown until the
|
||||
// first `layer_a_level` event lands.
|
||||
self.layer_a.entry(node_id).or_insert(None);
|
||||
}
|
||||
RoutingEvent::LayerADetached { node_id } => {
|
||||
self.layer_a.remove(&node_id);
|
||||
}
|
||||
RoutingEvent::RuleChanged => { /* TUI doesn't display rules */ }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::Profile => {
|
||||
if let Ok(ProfileEvent::Changed { name, .. }) =
|
||||
serde_json::from_value::<ProfileEvent>(profile_payload(&ev))
|
||||
{
|
||||
self.profile = name;
|
||||
}
|
||||
}
|
||||
Topic::Daemon => {
|
||||
if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
|
||||
match de {
|
||||
DaemonEvent::Overflow {
|
||||
lost, total_lost, ..
|
||||
} => {
|
||||
self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
|
||||
}
|
||||
DaemonEvent::Error { code, message } => {
|
||||
self.last_error = Some(format!("{code}: {message}"));
|
||||
}
|
||||
DaemonEvent::Shutdown => {
|
||||
self.disconnected = Some("daemon shutdown".into());
|
||||
}
|
||||
DaemonEvent::Started { version } => {
|
||||
self.daemon_version = version;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire frame carries `{event, topic, data}` — the typed enum lives
|
||||
/// inside `data` but is `#[serde(tag = "event")]`, so we re-inject the
|
||||
/// event name to make serde happy. Same dance for the other topics.
|
||||
fn routing_payload(ev: &Event) -> serde_json::Value {
|
||||
inject_event(&ev.event, &ev.data)
|
||||
}
|
||||
fn profile_payload(ev: &Event) -> serde_json::Value {
|
||||
inject_event(&ev.event, &ev.data)
|
||||
}
|
||||
fn daemon_payload(ev: &Event) -> serde_json::Value {
|
||||
inject_event(&ev.event, &ev.data)
|
||||
}
|
||||
|
||||
fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
|
||||
let mut obj = match data {
|
||||
serde_json::Value::Object(m) => m.clone(),
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
obj.insert("event".into(), serde_json::Value::String(event.to_string()));
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draw loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_loop<B: ratatui::backend::Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
status: Status,
|
||||
route_list: headroom_ipc::RouteList,
|
||||
rx: Receiver<Msg>,
|
||||
) -> Result<(), TuiError> {
|
||||
let mut state = UiState::new(status, route_list);
|
||||
// 10 Hz redraw floor so uptime + staleness counters tick even when
|
||||
// there are no events flowing.
|
||||
let ticker = tick(Duration::from_millis(100));
|
||||
let input_rx = spawn_input_thread();
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &state))?;
|
||||
|
||||
select! {
|
||||
recv(rx) -> msg => match msg {
|
||||
Ok(Msg::Event(ev)) => state.apply_event(ev),
|
||||
Ok(Msg::Disconnected(reason)) => {
|
||||
state.disconnected = Some(reason);
|
||||
// Final paint, then linger briefly so the user sees
|
||||
// the disconnected banner.
|
||||
terminal.draw(|f| draw(f, &state))?;
|
||||
thread::sleep(Duration::from_millis(800));
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
recv(input_rx) -> msg => match msg {
|
||||
Ok(InputMsg::Quit) => return Ok(()),
|
||||
Ok(InputMsg::Other) => {}
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
recv(ticker) -> _ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum InputMsg {
|
||||
Quit,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn spawn_input_thread() -> Receiver<InputMsg> {
|
||||
let (tx, rx) = unbounded::<InputMsg>();
|
||||
thread::Builder::new()
|
||||
.name("headroom-monitor-input".into())
|
||||
.spawn(move || loop {
|
||||
// Block on the next terminal event; crossterm's read() is
|
||||
// a blocking syscall against stdin.
|
||||
let Ok(ev) = event::read() else { return };
|
||||
let msg = match ev {
|
||||
CtEvent::Key(k) if is_quit(&k) => InputMsg::Quit,
|
||||
CtEvent::Key(_) | CtEvent::Resize(_, _) => InputMsg::Other,
|
||||
_ => continue,
|
||||
};
|
||||
if tx.send(msg).is_err() {
|
||||
return;
|
||||
}
|
||||
})
|
||||
.expect("spawn input thread");
|
||||
rx
|
||||
}
|
||||
|
||||
fn is_quit(k: &KeyEvent) -> bool {
|
||||
matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
|
||||
|| (k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw(f: &mut Frame, state: &UiState) {
|
||||
let area = f.area();
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled(
|
||||
" headroom monitor ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.title_top(Line::from(header_status(state)).right_aligned())
|
||||
.title_bottom(Line::from(footer_text(state)).right_aligned());
|
||||
let inner = outer.inner(area);
|
||||
f.render_widget(outer, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(6), // bus gauges
|
||||
Constraint::Length(5), // loudness
|
||||
Constraint::Min(4), // streams table
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
draw_bus(f, chunks[0], state);
|
||||
draw_loudness(f, chunks[1], state);
|
||||
draw_streams(f, chunks[2], state);
|
||||
}
|
||||
|
||||
fn header_status(state: &UiState) -> Vec<Span<'static>> {
|
||||
let bypass_span = if state.bypass {
|
||||
Span::styled(
|
||||
" BYPASS ",
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow),
|
||||
)
|
||||
} else {
|
||||
Span::styled(" processed ", Style::default().fg(Color::Green))
|
||||
};
|
||||
vec![
|
||||
Span::raw(" profile: "),
|
||||
Span::styled(state.profile.clone(), Style::default().bold()),
|
||||
Span::raw(" "),
|
||||
bypass_span,
|
||||
Span::raw(format!(
|
||||
" v{} uptime {} ",
|
||||
state.daemon_version,
|
||||
fmt_uptime(state.uptime_s())
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
|
||||
let mut parts: Vec<Span> = vec![
|
||||
Span::raw(" q/Esc/Ctrl-C quit "),
|
||||
Span::styled("·", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" subscribed: meters routing profile daemon "),
|
||||
];
|
||||
if state.overflow_total > 0 {
|
||||
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
|
||||
parts.push(Span::styled(
|
||||
format!(" dropped: {} ", state.overflow_total),
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
if let Some(err) = &state.last_error {
|
||||
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
|
||||
parts.push(Span::styled(
|
||||
format!(" daemon error: {err} "),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
}
|
||||
if let Some(reason) = &state.disconnected {
|
||||
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
|
||||
parts.push(Span::styled(
|
||||
format!(" disconnected: {reason} "),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" bus dsp ");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let m = state.meters;
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[0],
|
||||
GaugeRow {
|
||||
label: "AGC target",
|
||||
value: m.map(|t| t.agc_gain_db),
|
||||
min: -12.0,
|
||||
max: 12.0,
|
||||
unit: "dB",
|
||||
color: Color::Cyan,
|
||||
},
|
||||
);
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[1],
|
||||
GaugeRow {
|
||||
label: "Compressor GR",
|
||||
value: m.map(|t| t.compressor_gr_db),
|
||||
min: -24.0,
|
||||
max: 0.0,
|
||||
unit: "dB",
|
||||
color: Color::Magenta,
|
||||
},
|
||||
);
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[2],
|
||||
GaugeRow {
|
||||
label: "Limiter GR",
|
||||
value: m.map(|t| t.limiter_gr_db),
|
||||
min: -24.0,
|
||||
max: 0.0,
|
||||
unit: "dB",
|
||||
color: Color::Red,
|
||||
},
|
||||
);
|
||||
draw_gauge_row(
|
||||
f,
|
||||
rows[3],
|
||||
GaugeRow {
|
||||
label: "True peak",
|
||||
value: m.map(|t| t.true_peak_dbtp),
|
||||
min: -60.0,
|
||||
max: 3.0,
|
||||
unit: "dBTP",
|
||||
color: Color::Green,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct GaugeRow<'a> {
|
||||
label: &'a str,
|
||||
value: Option<f32>,
|
||||
min: f32,
|
||||
max: f32,
|
||||
unit: &'a str,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
/// One labeled gauge row: `LABEL VALUE [████░░░░] min..max`.
|
||||
fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
|
||||
let GaugeRow {
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
unit,
|
||||
color,
|
||||
} = row;
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(16),
|
||||
Constraint::Length(14),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(14),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
|
||||
|
||||
let value_str = value
|
||||
.map(|v| format!("{v:+7.2} {unit}"))
|
||||
.unwrap_or_else(|| " -- ".to_string());
|
||||
f.render_widget(
|
||||
Paragraph::new(value_str).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
);
|
||||
|
||||
let pct = match value {
|
||||
Some(v) => {
|
||||
let clamped = v.clamp(min, max);
|
||||
((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(color))
|
||||
.ratio(pct)
|
||||
.label("");
|
||||
f.render_widget(gauge, cols[2]);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
|
||||
cols[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" loudness (BS.1770) ");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let staleness = state
|
||||
.last_meter_at
|
||||
.map(|t| t.elapsed())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
let stale = staleness > Duration::from_millis(500);
|
||||
|
||||
let (mom, st, intg) = match state.meters {
|
||||
Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
lufs_line("Momentary (400 ms)", mom, stale),
|
||||
lufs_line("Short-term (3 s)", st, stale),
|
||||
lufs_line("Integrated (gated)", intg, stale),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
|
||||
let val = match v {
|
||||
Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
|
||||
format!("{x:+7.2} LUFS")
|
||||
}
|
||||
Some(_) => " -- LUFS".to_string(),
|
||||
None => " -- LUFS".to_string(),
|
||||
};
|
||||
let style = if stale {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::raw(format!(" {label:<24}")),
|
||||
Span::styled(val, style),
|
||||
])
|
||||
}
|
||||
|
||||
fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
|
||||
let title = format!(
|
||||
" streams ({}) — default: {} ",
|
||||
state.streams.len(),
|
||||
state.default_route
|
||||
);
|
||||
let block = Block::default().borders(Borders::ALL).title(title);
|
||||
|
||||
let header = Row::new(vec!["node", "app", "route", "layer A"])
|
||||
.style(Style::default().add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.streams
|
||||
.values()
|
||||
.map(|s| {
|
||||
let route_cell = match s.route {
|
||||
Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
|
||||
Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
|
||||
};
|
||||
let la_cell = match state.layer_a.get(&s.node_id) {
|
||||
Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
|
||||
.style(Style::default().fg(Color::Magenta)),
|
||||
Some(None) => Cell::from("attached")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
None => Cell::from("—").style(Style::default().fg(Color::DarkGray)),
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(s.node_id.to_string()),
|
||||
Cell::from(s.app.clone()),
|
||||
route_cell,
|
||||
la_cell,
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(8),
|
||||
Constraint::Min(20),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
];
|
||||
let table = Table::new(rows, widths).header(header).block(block);
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn fmt_uptime(s: u64) -> String {
|
||||
let h = s / 3600;
|
||||
let m = (s % 3600) / 60;
|
||||
let sec = s % 60;
|
||||
if h > 0 {
|
||||
format!("{h}h{m:02}m{sec:02}s")
|
||||
} else if m > 0 {
|
||||
format!("{m}m{sec:02}s")
|
||||
} else {
|
||||
format!("{sec}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use headroom_ipc::{Sinks, Status};
|
||||
|
||||
fn empty_state() -> UiState {
|
||||
let status = Status {
|
||||
version: "test".into(),
|
||||
protocol: 1,
|
||||
uptime_s: 0,
|
||||
profile: "default".into(),
|
||||
bypass: false,
|
||||
sinks: Sinks::default(),
|
||||
streams: vec![],
|
||||
warnings: vec![],
|
||||
};
|
||||
let route_list = headroom_ipc::RouteList {
|
||||
rules: vec![],
|
||||
current: vec![],
|
||||
default_route: Route::Processed,
|
||||
};
|
||||
UiState::new(status, route_list)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_tick_event_updates_state() {
|
||||
let mut state = empty_state();
|
||||
let tick = MeterTick {
|
||||
momentary_lufs: -19.3,
|
||||
shortterm_lufs: -20.1,
|
||||
integrated_lufs: -19.8,
|
||||
true_peak_dbtp: -1.4,
|
||||
gain_reduction_db: -2.1,
|
||||
compressor_gr_db: -0.8,
|
||||
limiter_gr_db: -1.3,
|
||||
agc_gain_db: 0.5,
|
||||
};
|
||||
let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
|
||||
state.apply_event(ev);
|
||||
let got = state.meters.expect("meters set");
|
||||
assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
|
||||
assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
|
||||
assert!(state.last_meter_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_removed_prunes_state() {
|
||||
let mut state = empty_state();
|
||||
// Insert via stream_routed first.
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"stream_routed",
|
||||
&serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"layer_a_attached",
|
||||
&serde_json::json!({ "node_id": 7, "app": "x" }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(state.streams.contains_key(&7));
|
||||
assert!(state.layer_a.contains_key(&7));
|
||||
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"stream_removed",
|
||||
&serde_json::json!({ "node_id": 7 }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(!state.streams.contains_key(&7));
|
||||
assert!(!state.layer_a.contains_key(&7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_a_level_updates_reduction() {
|
||||
let mut state = empty_state();
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Routing,
|
||||
"layer_a_attached",
|
||||
&serde_json::json!({ "node_id": 11, "app": "loud-app" }),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(state.layer_a.get(&11), Some(&None));
|
||||
|
||||
state.apply_event(
|
||||
Event::new(
|
||||
Topic::Meters,
|
||||
"layer_a_level",
|
||||
&serde_json::json!({
|
||||
"node_id": 11,
|
||||
"app": "loud-app",
|
||||
"volume_lin": 0.256_f32,
|
||||
"reduction_db": -11.8_f32,
|
||||
}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let r = state.layer_a.get(&11).copied().flatten().unwrap();
|
||||
assert!((r - -11.8).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_event_inserts_stream() {
|
||||
let mut state = empty_state();
|
||||
let ev = Event::new(
|
||||
Topic::Routing,
|
||||
"stream_routed",
|
||||
&serde_json::json!({
|
||||
"node_id": 42,
|
||||
"app": "firefox",
|
||||
"to": "bypass",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.apply_event(ev);
|
||||
let s = state.streams.get(&42).expect("stream tracked");
|
||||
assert_eq!(s.app, "firefox");
|
||||
assert_eq!(s.route, Route::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_changed_updates_active() {
|
||||
let mut state = empty_state();
|
||||
let ev = Event::new(
|
||||
Topic::Profile,
|
||||
"changed",
|
||||
&serde_json::json!({
|
||||
"name": "night",
|
||||
"previous": "default",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.apply_event(ev);
|
||||
assert_eq!(state.profile, "night");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_overflow_accumulates() {
|
||||
let mut state = empty_state();
|
||||
let ev = Event::new(
|
||||
Topic::Daemon,
|
||||
"overflow",
|
||||
&serde_json::json!({
|
||||
"lost_topic": "meters",
|
||||
"lost": 3u32,
|
||||
"total_lost": 5u64,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
state.apply_event(ev);
|
||||
assert_eq!(state.overflow_total, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_uptime_buckets() {
|
||||
assert_eq!(fmt_uptime(5), "5s");
|
||||
assert_eq!(fmt_uptime(75), "1m15s");
|
||||
assert_eq!(fmt_uptime(3725), "1h02m05s");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue