diff --git a/crates/headroom-core/src/ipc/ops.rs b/crates/headroom-core/src/ipc/ops.rs index 3b480b0..9ba2081 100644 --- a/crates/headroom-core/src/ipc/ops.rs +++ b/crates/headroom-core/src/ipc/ops.rs @@ -66,6 +66,11 @@ fn status(id: u64, state: &SharedState) -> Response { node_id: s.processed_sink_id, name: Some(crate::pw::sink::NODE_NAME.to_owned()), ready: s.processed_sink_id.is_some(), + // The processed sink advertises whatever rate the + // filter is currently running at (rate-matched to + // the real sink). `None` only during very early + // boot before `Filter::create` lands. + sample_rate: s.filter_sample_rate, }, real: s.real_sink.clone(), }, diff --git a/crates/headroom-core/src/pw/filter.rs b/crates/headroom-core/src/pw/filter.rs index acf3580..cb41460 100644 --- a/crates/headroom-core/src/pw/filter.rs +++ b/crates/headroom-core/src/pw/filter.rs @@ -60,7 +60,19 @@ use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME; /// constructed for this rate; if PipeWire negotiates a different /// rate the filter logs a warning and the DSP may sound slightly off /// in time-based parameters until Phase 4 wires rate updates. -pub const FILTER_SAMPLE_RATE: u32 = 48_000; +/// Sample rate the filter uses when no real sink is yet known +/// (cold boot, or `default.audio.sink` hasn't resolved). The +/// runtime overrides this via [`Filter::create`]'s `sample_rate` +/// argument once a real-sink rate is captured from the registry. +/// 48 kHz matches the PipeWire graph default; nothing else is +/// load-bearing at this number. +pub const DEFAULT_SAMPLE_RATE: u32 = 48_000; + +/// Backward-compatibility alias for the old const name. Internal +/// callers should take the rate as a parameter; this exists so +/// out-of-tree code (`headroom-core` doc readers, downstream +/// experiments) doesn't break on the rename. +pub const FILTER_SAMPLE_RATE: u32 = DEFAULT_SAMPLE_RATE; /// Number of channels the filter operates on (stereo only in v0). pub const CHANNELS: u32 = 2; @@ -270,6 +282,11 @@ pub struct FilterBundle { /// Playback callback timing stats. Updated lock-free from the /// audio thread; sampled by the AGC controller's slow tick. pub timing: SharedPlaybackTiming, + /// The sample rate the filter is running at — read from the + /// real sink at construction time, or [`DEFAULT_SAMPLE_RATE`] + /// if no real sink was known yet. Callers (runtime, + /// AgcController) need it to size their own state. + pub sample_rate: u32, } impl Filter { @@ -285,7 +302,11 @@ impl Filter { /// # Errors /// [`DaemonError::PipeWire`] if stream creation or connection /// fails. - pub fn create(core: &Core, init: FilterInit) -> Result { + pub fn create( + core: &Core, + init: FilterInit, + sample_rate: u32, + ) -> Result { let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); let (cmd_producer, cmd_consumer) = RingBuffer::::new(CMD_RING_CAPACITY); let (measurement_producer, measurement_consumer) = @@ -296,9 +317,15 @@ impl Filter { let bus_metrics = crate::meters::shared(); let timing = crate::meters::shared_timing(); - let compressor = Compressor::new(init.compressor, FILTER_SAMPLE_RATE as f32); - let limiter = Limiter::new(init.limiter, FILTER_SAMPLE_RATE as f32); - let mut agc = AgcGain::new(init.agc, FILTER_SAMPLE_RATE as f32); + // The limiter's `sanitized()` caps the *internal* (post- + // oversample) rate, so a 96 kHz base + the default 4× + // oversample auto-drops to 2× → 192 kHz internal rather + // than 384 kHz. Keeps the FIR cost bounded as we follow + // higher real-sink rates. + let limiter_cfg = init.limiter.sanitize_for_rate(sample_rate as f32); + let compressor = Compressor::new(init.compressor, sample_rate as f32); + let limiter = Limiter::new(limiter_cfg, sample_rate as f32); + let mut agc = AgcGain::new(init.agc, sample_rate as f32); agc.set_enabled(init.agc_enabled); let capture = build_capture_stream(core)?; @@ -330,9 +357,9 @@ impl Filter { .map_err(|e| DaemonError::pipewire(format!("playback register: {e}")))?; // One format POD, two connects. Both streams want the same - // interpretation (F32LE stereo at FILTER_SAMPLE_RATE) and the + // interpretation (F32LE stereo at `sample_rate`) and the // POD bytes live on this stack for the duration of both calls. - let format_bytes = build_format_pod_bytes()?; + let format_bytes = build_format_pod_bytes(sample_rate)?; let format_pod = Pod::from_bytes(&format_bytes).ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?; @@ -357,7 +384,7 @@ impl Filter { .map_err(|e| DaemonError::pipewire(format!("playback connect: {e}")))?; tracing::info!( - sample_rate = FILTER_SAMPLE_RATE, + sample_rate, channels = CHANNELS, ring_capacity = RING_CAPACITY, "filter streams created and connected" @@ -374,6 +401,7 @@ impl Filter { measurement_consumer, bus_metrics, timing, + sample_rate, }) } } @@ -419,12 +447,12 @@ fn build_playback_stream(core: &Core) -> Result { .map_err(|e| DaemonError::pipewire(format!("playback Stream::new: {e}"))) } -/// Serialize our preferred audio format (F32LE stereo at -/// [`FILTER_SAMPLE_RATE`]) into a SPA POD byte buffer. -fn build_format_pod_bytes() -> Result, DaemonError> { +/// Serialize our preferred audio format (F32LE stereo at the +/// runtime-supplied `sample_rate`) into a SPA POD byte buffer. +fn build_format_pod_bytes(sample_rate: u32) -> Result, DaemonError> { let mut info = AudioInfoRaw::new(); info.set_format(AudioFormat::F32LE); - info.set_rate(FILTER_SAMPLE_RATE); + info.set_rate(sample_rate); info.set_channels(CHANNELS); let obj = Object { diff --git a/crates/headroom-core/src/pw/registry.rs b/crates/headroom-core/src/pw/registry.rs index 13dee0f..61bce95 100644 --- a/crates/headroom-core/src/pw/registry.rs +++ b/crates/headroom-core/src/pw/registry.rs @@ -60,11 +60,25 @@ use crate::pw::tap::{MeasurementSample, StreamTap}; use crate::routing::{self, PwNodeInfo, RoutingDecision}; use crate::state::{RoutedStream, SharedState}; -/// Assumed audio-thread block period for Layer A controllers. Matches -/// the filter's hardcoded 1024-frame quantum at 48 kHz. Future work -/// reads the negotiated quantum from PipeWire when constructing each -/// controller. -const LAYER_A_BLOCK_DT_S: f32 = 1024.0 / 48_000.0; +/// Assumed audio-thread quantum for Layer A's block-rate +/// controllers. PipeWire's default is 1024 frames; nodes may +/// negotiate something different, but the controller's smoothing +/// constants are tolerant of small mismatches (the 30 ms +/// anti-bounce smoother stays in the right order of magnitude +/// from 512 to 2048 frames at any reasonable sample rate). The +/// actual `dt_s = QUANTUM_FRAMES / sample_rate` is computed at +/// controller-spawn time from the daemon's known real-sink rate +/// so 96 kHz / 192 kHz hardware gets correctly-scaled +/// time-constants without changing the filter quantum. +const LAYER_A_QUANTUM_FRAMES: f32 = 1024.0; + +/// Compute Layer A's block-period (seconds) for the active +/// sample rate. Falls back to the 48 kHz reference when no real +/// sink rate is known yet. +fn layer_a_block_dt_s(sample_rate: Option) -> f32 { + let sr = sample_rate.unwrap_or(crate::pw::filter::DEFAULT_SAMPLE_RATE); + LAYER_A_QUANTUM_FRAMES / (sr as f32) +} /// Lightweight view of a `Port` registry global. We track these to /// enable explicit port-level linking for Layer A taps: PipeWire's @@ -224,6 +238,13 @@ pub struct RoutingState { /// PipeWire. Populated by `try_route_stream`, cleared in /// `on_global_remove`. known_streams: HashMap, + /// Node proxy + Format-param listener for the current real + /// sink, used to capture its negotiated `audio.rate` (ALSA + /// sinks don't expose this in their property dict; it only + /// appears in the Format param). `Some` for the lifetime of + /// a real sink; replaced whenever `real_sink.node_id` + /// changes, dropped on removal. + real_sink_format_listener: Option<(u32, Node, NodeListener)>, } /// Per-stream Layer A bundle: the tap (audio path), the controller @@ -291,6 +312,7 @@ impl RoutingState { managed_route_links: HashMap::new(), default_reassertion: None, known_streams: HashMap::new(), + real_sink_format_listener: None, } } @@ -537,24 +559,110 @@ impl RoutingState { if name == PROCESSED_SINK_NAME { return; // tracked elsewhere } + let rate = dict.get("audio.rate").and_then(|s| s.parse::().ok()); self.sinks_by_name.insert(name.to_owned(), global.id); - let mut s = self.daemon.lock(); - if s.real_sink.name.is_none() { - tracing::info!( - node_id = global.id, - name, - "no preferred_real_sink yet; adopting first available Audio/Sink as fallback" - ); - s.real_sink.name = Some(name.to_owned()); - s.real_sink.node_id = Some(global.id); - } else if s.real_sink.name.as_deref() == Some(name) && s.real_sink.node_id != Some(global.id) { - tracing::info!( - node_id = global.id, - name, - "resolved preferred_real_sink node id" - ); - s.real_sink.node_id = Some(global.id); + let mut became_real_sink = false; + { + let mut s = self.daemon.lock(); + if s.real_sink.name.is_none() { + tracing::info!( + node_id = global.id, + name, + ?rate, + "no preferred_real_sink yet; adopting first available Audio/Sink as fallback" + ); + s.real_sink.name = Some(name.to_owned()); + s.real_sink.node_id = Some(global.id); + s.real_sink.sample_rate = rate; + became_real_sink = true; + } else if s.real_sink.name.as_deref() == Some(name) { + if s.real_sink.node_id != Some(global.id) { + tracing::info!( + node_id = global.id, + name, + "resolved preferred_real_sink node id" + ); + s.real_sink.node_id = Some(global.id); + became_real_sink = true; + } + // Update rate every time we (re-)see the sink: a + // first-registration without `audio.rate` (common + // for ALSA sinks) gets filled in by the Format + // listener installed below. + if rate.is_some() && s.real_sink.sample_rate != rate { + tracing::info!( + node_id = global.id, + name, + old_rate = ?s.real_sink.sample_rate, + new_rate = ?rate, + "real sink rate updated" + ); + s.real_sink.sample_rate = rate; + } + } } + // ALSA sinks don't carry `audio.rate` in their property + // dict — it lives in the negotiated Format param. Bind the + // node and subscribe to Format events so we can pull the + // rate from the next param callback. Only do this for the + // current real sink (the one filter routing actually + // matters for) so we don't accumulate proxies for sinks + // we'll never touch. + if became_real_sink { + self.install_real_sink_format_listener(global); + } + } + + /// Bind the node behind `sink_global` and subscribe to its + /// `Format` param so changes (including the initial + /// negotiated value PipeWire replays on subscribe) update + /// `real_sink.sample_rate`. Replaces any previously-installed + /// listener for a different node. + fn install_real_sink_format_listener(&mut self, sink_global: &GlobalObject<&DictRef>) { + let node_id = sink_global.id; + if let Some((prev_id, _, _)) = &self.real_sink_format_listener { + if *prev_id == node_id { + return; // already bound, nothing to do + } + } + let node = match self.registry.bind::(sink_global) { + Ok(n) => n, + Err(e) => { + tracing::warn!( + node_id, + error = %e, + "failed to bind real sink Node proxy; sample rate will fall back to default" + ); + self.real_sink_format_listener = None; + return; + } + }; + let daemon = self.daemon.clone(); + let listener = node + .add_listener_local() + .param(move |_seq, id, _index, _next, param_opt| { + if id != ParamType::Format { + return; + } + let Some(param) = param_opt else { return }; + let Some(rate) = extract_audio_rate(param) else { + return; + }; + let mut s = daemon.lock(); + if s.real_sink.sample_rate == Some(rate) { + return; + } + tracing::info!( + node_id, + old_rate = ?s.real_sink.sample_rate, + new_rate = rate, + "real sink Format negotiated; updating sample_rate" + ); + s.real_sink.sample_rate = Some(rate); + }) + .register(); + node.subscribe_params(&[ParamType::Format]); + self.real_sink_format_listener = Some((node_id, node, listener)); } /// Capture the global id of `headroom-filter.playback` when the @@ -837,9 +945,10 @@ impl RoutingState { app_level::evaluate(info, &s.profiles.effective().per_app) }; let Some(rule) = rule else { return }; + let block_dt_s = layer_a_block_dt_s(self.daemon.lock().real_sink.sample_rate); match StreamTap::start(&self.core, info.node_id) { Ok((tap, consumer)) => { - let controller = AppLevelController::new(rule, LAYER_A_BLOCK_DT_S); + let controller = AppLevelController::new(rule, block_dt_s); // Bind a Node proxy so 6d can write // `Props.channelVolumes`. If this fails we still spawn // the tap — the controller runs and we'll log when it @@ -1505,6 +1614,15 @@ impl RoutingState { if s.real_sink.name.as_deref() == Some(name.as_str()) { s.real_sink.name = None; s.real_sink.node_id = None; + s.real_sink.sample_rate = None; + drop(s); + // Drop the Format-param listener too; it points + // at a node that no longer exists. + if let Some((prev_id, _, _)) = &self.real_sink_format_listener { + if *prev_id == node_id { + self.real_sink_format_listener = None; + } + } } else if s.real_sink.node_id == Some(node_id) { // Defensive: id matched but name didn't (sinks // shouldn't double-register). Null the id. @@ -1583,6 +1701,30 @@ fn install_param_listener( .register() } +/// Parse a `Format` POD looking for `SPA_FORMAT_AUDIO_rate` and +/// return its integer value. ALSA sinks don't expose `audio.rate` +/// in their property dict — the rate only appears in the +/// negotiated Format param. Returns `None` if the pod isn't a +/// Format object or doesn't carry the rate field. +fn extract_audio_rate(pod: &Pod) -> Option { + let bytes = pod.as_bytes(); + let (_, value) = PodDeserializer::deserialize_any_from(bytes).ok()?; + let Value::Object(obj) = value else { return None }; + if obj.id != ParamType::Format.as_raw() { + return None; + } + for prop in obj.properties { + if prop.key == libspa_sys::SPA_FORMAT_AUDIO_rate { + if let Value::Int(rate) = prop.value { + if rate > 0 { + return Some(rate as u32); + } + } + } + } + None +} + /// Parse a `Props` POD looking for `SPA_PROP_channelVolumes` and /// return the first channel's value. Returns `None` if the pod isn't /// a Props object, or doesn't carry channelVolumes, or carries it in diff --git a/crates/headroom-core/src/runtime.rs b/crates/headroom-core/src/runtime.rs index 8c4a85e..0093c25 100644 --- a/crates/headroom-core/src/runtime.rs +++ b/crates/headroom-core/src/runtime.rs @@ -103,14 +103,32 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> { agc_enabled: effective.agc.enabled, } }; + // Read the real sink's native rate (captured during the brief + // window the registry watcher has been running) so the filter + // can match it and skip the output-edge resample for content + // at that rate. Falls back to PipeWire's 48 kHz default if the + // real sink hasn't surfaced yet — Phase C will rebuild the + // filter when the rate later resolves to something else. + let initial_rate = daemon_state + .lock() + .real_sink + .sample_rate + .unwrap_or(crate::pw::filter::DEFAULT_SAMPLE_RATE); + tracing::info!(initial_rate, "creating filter at real-sink-matched rate"); + let FilterBundle { filter: _filter, control: filter_control, measurement_consumer, bus_metrics, timing, - } = Filter::create(pw.core(), filter_init)?; - daemon_state.lock().filter_control = Some(filter_control.clone()); + sample_rate: filter_rate, + } = Filter::create(pw.core(), filter_init, initial_rate)?; + { + let mut s = daemon_state.lock(); + s.filter_control = Some(filter_control.clone()); + s.filter_sample_rate = Some(filter_rate); + } // Spin up the slow AGC controller. Ticks on the PipeWire main // loop via a timer source; reads the active profile's [agc] @@ -120,7 +138,7 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> { // `profile.meters.publish_hz` (capped at 20 Hz, the AGC tick // rate) — 4g. let agc_controller = AgcController::new( - crate::pw::filter::FILTER_SAMPLE_RATE, + filter_rate, crate::pw::filter::CHANNELS, measurement_consumer, filter_control, diff --git a/crates/headroom-core/src/state.rs b/crates/headroom-core/src/state.rs index f87074c..c62249a 100644 --- a/crates/headroom-core/src/state.rs +++ b/crates/headroom-core/src/state.rs @@ -55,6 +55,13 @@ pub struct DaemonState { /// PipeWire global id of `headroom-processed`, captured when the /// registry surfaces it. `None` until then. pub processed_sink_id: Option, + /// Sample rate the filter is currently running at, in Hz. + /// `None` until `Filter::create` has been called (very early + /// boot only). Matches the real sink's native rate at the time + /// the filter was last (re)built. Used to populate the + /// processed sink's `sample_rate` field in `status` and to + /// drive Layer A's block-period. + pub filter_sample_rate: Option, /// Snapshot of the user's preferred hardware sink. Phase 4h /// keeps this fresh from `default.audio.sink`. pub real_sink: SinkInfo, @@ -89,6 +96,7 @@ impl DaemonState { started_at: Instant::now(), profiles, processed_sink_id: None, + filter_sample_rate: None, real_sink: SinkInfo::default(), streams: HashMap::new(), broadcaster: Broadcaster::new(), @@ -111,13 +119,14 @@ impl DaemonState { return None; } self.real_sink = SinkInfo { - // node_id stays unknown for now — Headroom routes by name - // via `target.object = {"name":"…"}`, which is what - // WirePlumber expects. 4i may resolve the id when ad-hoc - // per-stream overrides need it. + // node_id + sample_rate stay unknown for now — + // registry's `try_capture_real_sink` resolves both + // when it sees the matching `Audio/Sink` global. The + // 4i routing path operates on name alone. node_id: None, name: Some(new_name.to_owned()), ready: true, + sample_rate: None, }; Some( self.streams diff --git a/crates/headroom-dsp/src/limiter.rs b/crates/headroom-dsp/src/limiter.rs index 617f7d2..3e61f0a 100644 --- a/crates/headroom-dsp/src/limiter.rs +++ b/crates/headroom-dsp/src/limiter.rs @@ -140,10 +140,22 @@ impl Default for LimiterConfig { } } +/// Internal-rate cap (Hz). The limiter's true-peak detector +/// upsamples to `sample_rate × oversample`. Above ~192 kHz the +/// FIR cost rises linearly with effectively no gain — at base +/// rates ≥ 96 kHz the signal already has plenty of bandwidth +/// for inter-sample-peak detection. We cap the *effective* +/// internal rate here and drop the oversample factor on high +/// base rates accordingly. +pub const MAX_INTERNAL_RATE_HZ: f32 = 192_000.0; + impl LimiterConfig { /// Sanitize a user-supplied configuration: clamp ceiling, /// oversample factor, ensure odd FIR length, sanitize the soft - /// tier if present. + /// tier if present. Rate-agnostic — callers that know the + /// audio thread's sample rate should prefer + /// [`Self::sanitize_for_rate`] so the oversample factor scales + /// down on high-rate inputs. #[must_use] pub fn sanitized(mut self) -> Self { if self.ceiling_dbtp > 0.0 { @@ -162,6 +174,27 @@ impl LimiterConfig { self } + /// Sanitize and additionally cap the oversample factor so the + /// post-upsample internal rate stays ≤ [`MAX_INTERNAL_RATE_HZ`]. + /// Examples at the default `oversample = 4`: + /// 44.1 kHz → 4× → 176.4 kHz (under cap, untouched) + /// 48 kHz → 4× → 192 kHz (at cap, untouched) + /// 96 kHz → 2× → 192 kHz (cap engaged, dropped from 4) + /// 192 kHz → 1× → 192 kHz (cap engaged, no oversampling) + /// Always returns at least `oversample = 1`. + #[must_use] + pub fn sanitize_for_rate(self, sample_rate: f32) -> Self { + let mut s = self.sanitized(); + if sample_rate > 0.0 { + let max_os = + (MAX_INTERNAL_RATE_HZ / sample_rate).floor().max(1.0) as usize; + if s.oversample > max_os { + s.oversample = max_os; + } + } + s + } + /// Convenience: brickwall only (no soft tier). #[must_use] pub fn brickwall_only() -> Self { @@ -615,6 +648,40 @@ mod tests { use super::*; use std::f32::consts::PI; + // ---------------------------------------------------------------- + // sanitize_for_rate: oversample factor scales down so the + // internal (post-upsample) rate stays bounded. + // ---------------------------------------------------------------- + + #[test] + fn sanitize_for_rate_caps_oversample_at_internal_192k() { + // Default config has oversample = 4. + let default = LimiterConfig::default(); + assert_eq!(default.oversample, 4); + + // At 48 kHz: 4× = 192 kHz, at the cap, untouched. + assert_eq!(default.sanitize_for_rate(48_000.0).oversample, 4); + // At 44.1 kHz: 4× = 176.4 kHz, under the cap. + assert_eq!(default.sanitize_for_rate(44_100.0).oversample, 4); + // At 96 kHz: 4× = 384 kHz, exceeds; drop to 2× = 192 kHz. + assert_eq!(default.sanitize_for_rate(96_000.0).oversample, 2); + // At 192 kHz: cap forces oversample = 1. + assert_eq!(default.sanitize_for_rate(192_000.0).oversample, 1); + // Pathological rate above the cap still leaves at least 1. + assert_eq!(default.sanitize_for_rate(384_000.0).oversample, 1); + } + + #[test] + fn sanitize_for_rate_preserves_user_lower_oversample() { + // User who explicitly set oversample = 2 at 48 kHz should + // keep it; the rate cap doesn't push the value *up*. + let cfg = LimiterConfig { + oversample: 2, + ..LimiterConfig::default() + }; + assert_eq!(cfg.sanitize_for_rate(48_000.0).oversample, 2); + } + // ---------------------------------------------------------------- // try_set_config: scalar updates apply in place, structural // changes are rejected. diff --git a/crates/headroom-ipc/src/proto.rs b/crates/headroom-ipc/src/proto.rs index cbe761c..a4114c6 100644 --- a/crates/headroom-ipc/src/proto.rs +++ b/crates/headroom-ipc/src/proto.rs @@ -391,6 +391,13 @@ pub struct SinkInfo { /// True if the sink is currently linked and accepting audio. #[serde(default)] pub ready: bool, + /// Sink's native sample rate (Hz), when known. The filter + /// matches the *real* sink's rate to skip the output-edge + /// resample; the processed sink advertises whatever rate the + /// filter is currently running at. Older clients that don't + /// understand the field treat it as absent (serde `default`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_rate: Option, } /// One playback stream and where it's routed.