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
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<FilterBundle, DaemonError> {
|
||||
pub fn create(
|
||||
core: &Core,
|
||||
init: FilterInit,
|
||||
sample_rate: u32,
|
||||
) -> Result<FilterBundle, DaemonError> {
|
||||
let (producer, consumer) = RingBuffer::<f32>::new(RING_CAPACITY);
|
||||
let (cmd_producer, cmd_consumer) = RingBuffer::<AudioCmd>::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<Stream, DaemonError> {
|
|||
.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<Vec<u8>, 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<Vec<u8>, 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 {
|
||||
|
|
|
|||
|
|
@ -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<u32>) -> 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<u32, PwNodeInfo>,
|
||||
/// 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::<u32>().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::<Node, _>(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<u32> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
/// 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<u32>,
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
}
|
||||
|
||||
/// One playback stream and where it's routed.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue