Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions ac-rs/ZMQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -1551,7 +1551,7 @@ reading either.
"vrms_at_0dbfs_in": <float> | null, // post-scale, projected to 0 dBFS
"out_state": "measured" | "unchanged" | "absent",
"in_state": "measured" | "unchanged" | "absent",
"tau_state": "measured" | "not_measured_no_loopback" | "error"
"tau_state": "measured" | "not_measured_low_snr" | "error"
| "disagree_period_shift" | "disagree_other" | "refused_xrun",
"tau_s": <float> | null, // interface round-trip delay, seconds; only non-null when tau_state == "measured"
"tau_sample_rate": <int>, // condition τ was measured/attempted under
Expand All @@ -1564,6 +1564,8 @@ reading either.
"tau_delta_samples": <int>, // #347: round((reading2 - reading1) * sample_rate) — present only on disagree_*
"tau_periods": <int>, // #347: signed period count — present only on tau_state == "disagree_period_shift"
"tau_error": "<message>", // present when tau_state is "error", "disagree_period_shift", or "disagree_other"
"tau_pre_impulse_snr_db": <float>, // #368: the (worse-of-two, when both ran) peak's pre-impulse SNR — present on measured / not_measured_low_snr / disagree_*, absent on error
"tau_snr_threshold_db": <float>, // #368: the threshold that SNR was judged against — present alongside tau_pre_impulse_snr_db
"error": "<message>", // only present on partial failure (voltage-cal save)
"input_port": "<port>", // #370: resolved server-side, e.g. "system:capture_2" — not the client's copy of the request
"output_port": "<port>" // ditto, e.g. "system:playback_5"
Expand All @@ -1587,35 +1589,63 @@ not only what this run measured, and the `*_state` word says which:
| `unchanged` | the prompt was skipped; the previously stored value stands |
| `absent` | the field holds no value — never set, or just cleared |

**τ (interface latency, #281/#347)** is not prompt-driven — it piggybacks
on the loopback state `cal_prompt` step 2 already established, so there is
no third interactive step and no `unchanged` state (skipping a voltage
**τ (interface latency, #281/#347)** is not prompt-driven — it is not a
third interactive step and has no `unchanged` state (skipping a voltage
prompt does not affect it). #347: a single reading is not a measurement of
τ on this stack — round-trip latency for a fixed path can vary by exactly
one period between client lifetimes, invisible within any one lifetime
(stable to 0.001 frames). `calibrate` therefore always runs τ as **two**
independent client lifecycles (fresh `start`/`stop` each) and compares
them before storing anything:
them before storing anything.

**#368**: τ used to run only when `cal_prompt` step 2's `loopback` flag
was `true` — a captured-level proxy for "is this cable patched" that a
loopback 3 dB hot or 4 dB low both failed even though both carried a real,
measurable arrival, and that a loud but uncorrelated interferer could
still pass. τ is now attempted unconditionally; the gate lives inside the
measurement itself, on the deconvolved peak's own pre-impulse SNR, which
is the quantity that actually distinguishes "patched" from "not patched."
`cal_prompt` step 2's own `loopback` flag is unchanged and keeps gating
only whether the DMM prompt pre-fills the output reading — a separate,
still-unity-keyed decision.

| `tau_state` | meaning |
|-------------|---------|
| `measured` | loopback detected this run; two independent readings agreed to the whole sample and their average was appended to `tau_history` |
| `not_measured_no_loopback` | loopback not detected this run — nothing to measure τ against |
| `error` | loopback was detected but a lifecycle's own measurement failed (`tau_error` names why, including which reading); the voltage-cal legs above are unaffected |
| `measured` | two independent readings agreed to the whole sample and their average was appended to `tau_history` |
| `not_measured_low_snr` | a lifecycle's deconvolved peak was below `tau_snr_threshold_db` pre-impulse SNR — not distinguishable from noise, so nothing was measured |
| `error` | a lifecycle's own measurement failed for a reason other than low SNR (`tau_error` names why, including which reading); the voltage-cal legs above are unaffected |
| `disagree_period_shift` | the two readings disagreed by an exact multiple of `tau_period_size` samples — a graph-buffering shift (software), not hardware drift. Nothing is stored. |
| `disagree_other` | the two readings disagreed, but not by a period multiple — a different fault class. Nothing is stored. |
| `refused_xrun` | either lifecycle's own `AudioEngine::xruns()` delta was nonzero (#369) — checked *before* the two readings are compared, so this fires even when they would otherwise have agreed, closing the corroboration hole a doubly-corrupted agreeing pair would leave in the `measured` path. Nothing is stored. |
| `refused_xrun` | either lifecycle's own `AudioEngine::xruns()` delta was nonzero (#369) — checked *before* the two readings are compared, so this fires even when they would otherwise have agreed, closing the corroboration hole a doubly-corrupted agreeing pair would leave in the `measured` path. Also takes precedence over `not_measured_low_snr` (#368/#369 merge decision): a lifecycle that crosses an xrun skips its own SNR gate entirely, so a capture an xrun corrupted is never reported as merely low-SNR — a contaminated capture's SNR figure is not a meaningful "no arrival" reading. Nothing is stored. |

`tau_sample_rate` / `tau_period_size` are the conditions the attempt ran
under (present regardless of `tau_state`, including `error`), so a
`not_measured_no_loopback` or `error` result is still legible against
`not_measured_low_snr` or `error` result is still legible against
`cal.json` history without a second round trip. `tau_period_size: null`
does not mean unknown — see `AudioEngine::period_size` in
`ac-daemon/src/audio/mod.rs`: some backends cannot report a period size
at all, which is a documented backend limitation, distinct from a period
size that simply wasn't queried (and means `disagree_period_shift` can
never fire on that backend — any disagreement there is `disagree_other`).

`tau_pre_impulse_snr_db` / `tau_snr_threshold_db` (#368) are present on
every state where at least one lifecycle reached deconvolution
(`measured`, `not_measured_low_snr`, `disagree_*`), absent on `error`
(which can fail before a peak was ever located), and **also absent on
`refused_xrun`** (#369): an xrun-crossed lifecycle's SNR gate never runs
(see the `refused_xrun` row above), so there is no SNR figure to report —
the state name itself names the cause, and no number is offered that
could be misread as a scored noise floor. On `measured` and `disagree_*`,
the SNR reported is the worse (lower) of the two lifecycles' — both
necessarily cleared the threshold, since a lifecycle that didn't, and
carried no xrun, would have produced `not_measured_low_snr` instead, and
a lifecycle that did carry one would have diverted the whole run to
`refused_xrun` before either `measured` or `disagree_*` could be reached
— so this is a diagnostic figure alongside the result rather than a
second gate. `tau_snr_threshold_db` is a derived constant (see
`ac-daemon/src/handlers/calibrate/tau/measure.rs`'s `TAU_SNR_THRESHOLD_DB` doc
comment for its provenance), not measured on this exact sweep.

On either disagreement state, `tau_reading1_s` / `tau_reading2_s` are the
raw seconds values from the two lifecycles, shown verbatim rather than
compressed to a delta — the fractional part staying identical across a
Expand Down
34 changes: 26 additions & 8 deletions ac-rs/crates/ac-cli/src/commands/calibrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,15 +270,33 @@ fn render_tau_leg(data: &serde_json::Value) -> Vec<String> {
"disagree_period_shift" | "disagree_other" => {
render_tau_disagreement_leg(state, data, sample_rate)
}
// #368: the peak's own SNR fell short of the threshold it was
// judged against — both are what the daemon actually measured, so
// print them rather than an inferred wiring conclusion.
"not_measured_low_snr" => {
match (
data.get("tau_pre_impulse_snr_db").and_then(|v| v.as_f64()),
data.get("tau_snr_threshold_db").and_then(|v| v.as_f64()),
) {
(Some(snr), Some(threshold)) => vec![format!(
" {:<8}not measured (peak SNR {snr:.2} dB, need {threshold:.2} dB, \
threshold derived)",
"Delay:"
)],
// Fields absent (older daemon claiming this state without
// them): fall through to the raw-state rendering below
// rather than assert numbers the daemon never sent.
_ => vec![format!(" {:<8}not measured (state: {state})", "Delay:")],
}
}
"refused_xrun" => render_tau_xrun_leg(data),
// "not_measured_no_loopback" and anything unrecognised (older
// daemon without this field): state the observation, not an
// inferred cause — `is_loopback` is what the daemon saw, not a
// claim about physical wiring the instrument cannot verify.
_ => vec![format!(
" {:<8}not measured (loopback not detected this run)",
"Delay:"
)],
// Anything unrecognised (older daemon, or a future state this
// client doesn't know): state the raw wire value, not an inferred
// cause the instrument cannot verify. Also covers the retired
// `"not_measured_no_loopback"` state — an old daemon predating
// #368 that still sends it renders here, on the raw value, rather
// than asserting the wiring conclusion #368 removed.
_ => vec![format!(" {:<8}not measured (state: {state})", "Delay:")],
}
}

Expand Down
29 changes: 10 additions & 19 deletions ac-rs/crates/ac-core/src/measurement/report/ir_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ impl MeasurementReport {
let arrival_s = delay_samples as f64 / *sample_rate_hz as f64;

let pre_region = pre_impulse_region(linear_ir, peak_index);
let pre_impulse_snr_db = pre_impulse_snr_db(pre_region, peak_magnitude);
// Same formula `ac-daemon`'s τ gate calls (#368) — one definition
// of "pre-impulse SNR", not two that can drift.
let pre_impulse_snr_db =
crate::measurement::sweep::pre_impulse_snr_db(linear_ir, peak_index);
let (gate_window_s, gate_f_low_hz, gate_window_kind) =
resolve_gate(payload.gate.as_ref(), window_len, *sample_rate_hz);
let verdict = ir_verdict(peak_magnitude, pre_region, pre_impulse_snr_db);
Expand Down Expand Up @@ -104,25 +107,13 @@ pub(super) fn ir_peak(linear_ir: &[f64]) -> (usize, f64) {
/// floor estimate upward. Empty when the guard band consumes the whole
/// pre-peak window — which [`ir_verdict`] treats as a failure, not as a
/// clean floor.
///
/// The guard arithmetic itself lives in `measurement::sweep` (#368), so
/// `ac-daemon`'s τ gate and this read-out cannot drift apart on what
/// "pre-impulse" means; this only turns the length into the slice
/// [`ir_verdict`] needs for its empty check.
pub(super) fn pre_impulse_region(linear_ir: &[f64], peak_index: usize) -> &[f64] {
let guard = (linear_ir.len() / 32).max(8);
&linear_ir[..peak_index.saturating_sub(guard)]
}

/// `20·log10(peak / rms(pre_region))`. `+inf` for an empty region (nothing
/// to measure) and for a true-silent one (`rms == 0.0`); [`ir_verdict`] is
/// what separates those two cases, since only the first is a failure.
pub(super) fn pre_impulse_snr_db(pre_region: &[f64], peak_magnitude: f64) -> f64 {
if pre_region.is_empty() {
return f64::INFINITY;
}
let mean_sq = pre_region.iter().map(|v| v * v).sum::<f64>() / pre_region.len() as f64;
let rms = mean_sq.sqrt();
if rms > 0.0 {
20.0 * (peak_magnitude / rms).log10()
} else {
f64::INFINITY
}
&linear_ir[..crate::measurement::sweep::pre_impulse_region_len(linear_ir.len(), peak_index)]
}

/// Gate duration, low-frequency limit and window shape for an IR payload.
Expand Down
38 changes: 38 additions & 0 deletions ac-rs/crates/ac-core/src/measurement/sweep/harmonics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,44 @@ pub fn extract_irs(
})
}

/// Number of samples [`pre_impulse_snr_db`] measures its noise floor over:
/// everything strictly before `peak_index`, minus the guard band. Zero when
/// the guard band consumes the whole pre-peak window, which is the case
/// `pre_impulse_snr_db` answers with `f64::INFINITY` — a caller that must
/// distinguish "infinite because the floor is silent" from "infinite
/// because there was no floor to measure" (`report::ir_stats`, #376)
/// checks this rather than keeping its own copy of the guard arithmetic.
pub fn pre_impulse_region_len(ir_len: usize, peak_index: usize) -> usize {
let guard = (ir_len / 32).max(8);
peak_index.saturating_sub(guard).min(ir_len)
}

/// Pre-impulse SNR of a linear impulse response, in dB: the located peak's
/// magnitude over the RMS of everything strictly before it, minus a small
/// guard band (`(ir.len() / 32).max(8)` samples) so the peak's own skirt
/// doesn't bias the floor estimate upward. `f64::INFINITY` when the
/// pre-peak region is empty or measures true silence (zero RMS).
///
/// Lifted out of `report::ir_stats` (#368) so `ac-daemon`'s τ gate can call
/// the same formula on the same quantity — "does the deconvolution find a
/// peak with adequate SNR" — rather than keeping two copies of one
/// calculation that could drift apart. `peak_index` is the caller's own
/// argmax over `ir`; this does not recompute it.
pub fn pre_impulse_snr_db(ir: &[f64], peak_index: usize) -> f64 {
let pre_region = &ir[..pre_impulse_region_len(ir.len(), peak_index)];
if pre_region.is_empty() {
return f64::INFINITY;
}
let peak_magnitude = ir.get(peak_index).map(|v| v.abs()).unwrap_or(0.0);
let mean_sq = pre_region.iter().map(|v| v * v).sum::<f64>() / pre_region.len() as f64;
let rms = mean_sq.sqrt();
if rms > 0.0 {
20.0 * (peak_magnitude / rms).log10()
} else {
f64::INFINITY
}
}

/// Copy `len` samples of `buf` starting at signed index `start`, padding
/// with zeros wherever the request falls outside the buffer, and scale
/// sample `i` by `weight(i)`.
Expand Down
4 changes: 3 additions & 1 deletion ac-rs/crates/ac-core/src/measurement/sweep/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ mod tail_decay;

pub use deconv::{deconvolve_full, inverse_sweep, log_sweep};
pub use gated::{gated_frequency_response, tukey_window, GatedResponsePoint};
pub use harmonics::{extract_irs, DeconvolvedIrs, HarmonicIr};
pub use harmonics::{
extract_irs, pre_impulse_region_len, pre_impulse_snr_db, DeconvolvedIrs, HarmonicIr,
};
pub use tail_decay::{check_tail_decay, TailDecayCheck};

/// Parameters for a Farina log sweep.
Expand Down
41 changes: 41 additions & 0 deletions ac-rs/crates/ac-daemon/src/audio/fake/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,47 @@ pub(super) fn period_size_override() -> Option<u32> {
})
}

/// Opt-in, fake-only test hooks (#368): let an external integration test
/// simulate a low/no-SNR capture — the muted-route rig case #368's AC3
/// needs reachable under `--fake-audio`, which by default always returns a
/// clean, noiseless delayed copy of the played signal (the loopback shape
/// every other τ test relies on).
///
/// `AC_FAKE_TAU_GAIN_OVERRIDE`: models the loopback cable's own gain, so it
/// scales both `play_and_capture`'s played-signal copy (the τ ESS) and
/// `capture_block`'s tone synthesis (`calibrate` step 2's captured level,
/// via `capture_rms`) — the same cable, read by two different captures.
/// `1.0` (unset) keeps the existing unity loopback on both paths; `0.0`
/// simulates a fully muted route. Before PR #384's codex-qa finding this
/// scaled only `play_and_capture`, so an off-unity gain never reached step
/// 2's `captured_dbfs`/`loopback` fields.
/// `AC_FAKE_TAU_NOISE_AMPLITUDE_OVERRIDE`: peak amplitude of broadband
/// dither added to every sample of `play_and_capture`'s output. `0.0`
/// (unset) is byte-identical to pre-#368 behaviour — with the gain also at
/// its default, `out[j] = 0.0 + s * 1.0 == s`. Combined with a `0.0` gain,
/// the deconvolved IR then contains only the dither at every position, so
/// the peak the daemon finds is indistinguishable from its own noise
/// floor, matching a real muted route's low pre-impulse SNR.
pub(super) fn tau_gain_override() -> f32 {
static OVERRIDE: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
*OVERRIDE.get_or_init(|| {
std::env::var("AC_FAKE_TAU_GAIN_OVERRIDE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1.0)
})
}

pub(super) fn tau_noise_amplitude_override() -> f32 {
static OVERRIDE: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
*OVERRIDE.get_or_init(|| {
std::env::var("AC_FAKE_TAU_NOISE_AMPLITUDE_OVERRIDE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0)
})
}

/// Opt-in, fake-only test hook (#369): lets a test drive one or both of
/// `measure_tau_twice`'s two lifecycles across a nonzero xrun count.
/// Without this, `FakeEngine::xruns()` never leaves the 0 it is
Expand Down
Loading