Commit graph

5 commits

Author SHA1 Message Date
atagen
86d00c43d1 filter rate matching A+B: runtime-parameterised rate at boot
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.
2026-05-21 20:43:55 +10:00
atagen
e528a98417 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.
2026-05-21 13:35:27 +10:00
atagen
fcf421b94c stage 6: per-app 2026-05-20 23:49:58 +10:00
atagen
9edd809416 stage 4 (a–d): IPC server, ops, broadcast
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)
2026-05-19 23:14:18 +10:00
atagen
ca1910de60 stage 2 2026-05-19 16:33:09 +10:00