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.
This commit is contained in:
parent
4a80a16d79
commit
86d00c43d1
7 changed files with 318 additions and 42 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue