Drops the FILTER_SAMPLE_RATE const dependency from the filter's
creation path so the audio thread can run at whatever rate the
real sink negotiates, not unconditionally 48 kHz. Closes one of
the two output-edge resamples PLAN §3.1's F5 caveat called out
— content matching the real-sink rate now passes through the
limiter without an output-side resample elevating its true peaks.
Phase A (foundation)
- `Filter::create(core, init, sample_rate)` takes the rate as a
runtime parameter. `DEFAULT_SAMPLE_RATE` keeps 48 kHz as the
fallback constant; `FILTER_SAMPLE_RATE` is kept as a
back-compat alias.
- `build_format_pod_bytes(sample_rate)` parameterised so the SPA
Format the filter advertises matches the chosen rate.
- `FilterBundle.sample_rate` exposed so the AGC controller and
`runtime` can size their own state.
- New `LimiterConfig::sanitize_for_rate(sample_rate)` caps the
oversample factor so the internal (post-upsample) rate stays
≤ 192 kHz: 48 k base → 4× = 192 k; 96 k → 2× = 192 k; 192 k
→ 1× = 192 k. Keeps the FIR cost from doubling each time the
base rate doubles, with negligible loss of true-peak detection
quality at high base rates (the signal already has plenty of
bandwidth). Two regression tests lock the math in.
Phase B (data plumbing)
- `SinkInfo` (wire-level) gains an optional `sample_rate`
field. `headroom status` now reports the processed sink's
running rate and the real sink's native rate — useful for
debugging "did the daemon actually match my hardware?"
without resorting to `pw-link`.
- `state::RealSink.sample_rate` populated by the registry
watcher from two sources:
- The `audio.rate` property (many virtual sinks expose it).
- A `Format`-param listener bound to the real sink's `Node`
proxy (ALSA sinks only expose the rate in the negotiated
Format, not in their property dict). New
`install_real_sink_format_listener` mirrors the
channelVolumes-listener pattern Layer A already uses.
Listener cleaned up in `on_global_remove` when the real
sink departs.
- `state::DaemonState.filter_sample_rate` mirrors the bus
filter's currently-running rate; surfaced in `status`.
- Layer A's block-period constant becomes a runtime function
(`layer_a_block_dt_s(sample_rate)`) so 96 k / 192 k hardware
gets correctly-scaled controller time-constants.
Known gap: filter created at boot uses whatever rate is known at
that moment. For ALSA sinks the Format listener fires ~tens of ms
*after* the registry capture — by which time the filter is
already created at the fallback rate. The next commit (Phase C)
rebuilds the filter when the listener delivers a rate different
from what the filter is running at.
Verified
- 191 tests pass (was 189; +2 for the new
`sanitize_for_rate` cases); clippy clean at -D warnings
--all-targets.
- Live: cold-boot against a 48 kHz Mbox shows
`status.sinks.processed.sample_rate = 48000` +
`status.sinks.real.sample_rate = 48000`, daemon log records
"creating filter at real-sink-matched rate initial_rate=48000"
and "real sink Format negotiated; updating sample_rate
new_rate=48000" within ~55 ms of each other. For sinks where
`audio.rate` IS in props (some virtual sinks) the rate is
captured before filter creation.
`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.
Phase 4 first four checkpoints — daemon now serves the wire protocol
specified in IPC.md and broadcasts events to subscribers.
4a IPC server skeleton
UnixListener at $XDG_RUNTIME_DIR/headroom/control.sock, accept
thread, per-connection thread, hello-on-connect, codec
round-trip, 0600 perms with stale-socket detection. Caught and
fixed a sigprocmask ordering bug: block SIGTERM/SIGINT
process-wide BEFORE the IPC accept thread spawns, otherwise it
inherits the unblocked mask and the signal takes the default
disposition before pipewire's signalfd can read it.
4b Read-only ops + shared state
Arc<Mutex<DaemonState>> (parking_lot) for cross-thread daemon
state. RoutingState moved off Rc<RefCell<>>-only and reads
profile from the shared lock. Captures the headroom-processed
node id via the registry. Implements: status, profile.list,
profile.show, route.list, setting.get (serde-roundtrip dotted
lookup), setting.list (flattened).
4c Mutating ops
profile.use (idempotent no-op until 4e ships the disk loader),
profile.reload (empty list till 4e), route.set/unset with
single-app user-rule replace semantics, setting.set with serde
round-trip type-safety, bypass.set. CLI fix:
allow_hyphen_values so 'headroom set foo.bar -0.5' works.
4d Subscriptions + broadcast
Per-connection split into reader thread + writer thread, joined
by a bounded crossbeam_channel<ServerFrame>(64). Broadcaster in
DaemonState fans out events via try_send; bounded queues drop
on overflow with per-(subscriber, topic) counters and a
daemon::overflow flush event piggybacked onto the next
successful publish.
Live events wired: daemon::started, daemon::shutdown,
routing::rule_changed, routing::stream_routed,
routing::stream_removed. CLI 'monitor [topics]' command
subscribes by topic list.
Workspace deps unchanged; uses already-declared crossbeam-channel,
parking_lot. Sinks/SinkInfo gained Default derives.
Tests: 97 passing (28 dsp, 20 ipc, 45 core, 4 client). Clippy clean
at default level under -D warnings.
Remaining Phase 4 punch-list (recommended order):
4e profile TOML loader + hot reload (notify-debouncer-mini)
4h preferred_real_sink tracking
4i target.object routing reliability on real WirePlumber
4f slow AGC loop with ebur128
4g meters publishing
4j auto-promote to default sink (optional flag)