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:
atagen 2026-05-21 13:35:27 +10:00
parent 79e4baedd0
commit e528a98417
8 changed files with 1283 additions and 31 deletions

380
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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 }

View file

@ -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),
}

View 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");
}
}

View file

@ -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 {

View file

@ -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.

View file

@ -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")]