use crate::Curve;
use crate::roomeq::types::RecordingConfiguration;
#[derive(Debug, Clone, PartialEq)]
pub enum BassPhaseConfidence {
Trustworthy { mean_coherence: f64 },
Degraded { reason: &'static str },
}
pub const DEFAULT_COHERENCE_THRESHOLD: f64 = 0.9;
pub const MIN_SNR_DB: f64 = 10.0;
pub const MIN_BASS_OCTAVE_DURATION_S: f32 = 2.0;
pub const MIN_NUM_SWEEPS: u8 = 4;
pub fn bass_phase_confidence(
curves: &[Curve],
band: (f64, f64),
recording: Option<&RecordingConfiguration>,
) -> BassPhaseConfidence {
if curves.is_empty() {
return BassPhaseConfidence::Degraded {
reason: "no_curves",
};
}
let (band_lo, band_hi) = band;
if !band_lo.is_finite() || !band_hi.is_finite() || band_lo < 0.0 || band_hi <= band_lo {
return BassPhaseConfidence::Degraded {
reason: "invalid_band",
};
}
if curves.iter().any(|c| c.phase.is_none()) {
return BassPhaseConfidence::Degraded {
reason: "no_phase_data",
};
}
if curves.iter().any(|c| c.coherence.is_none()) {
return BassPhaseConfidence::Degraded {
reason: "no_coherence_data",
};
}
if let Some(rec) = recording {
let num_sweeps = rec.num_sweeps.unwrap_or(1);
let octave_duration = rec.bass_octave_duration_s.unwrap_or(0.0);
if num_sweeps < MIN_NUM_SWEEPS || octave_duration < MIN_BASS_OCTAVE_DURATION_S {
return BassPhaseConfidence::Degraded {
reason: "insufficient_bass_duration",
};
}
}
let coh_threshold = recording
.and_then(|r| r.coherence_threshold)
.map(|v| v as f64)
.unwrap_or(DEFAULT_COHERENCE_THRESHOLD);
let mean_coh = mean_coherence_in_band(curves, band_lo, band_hi);
if mean_coh < coh_threshold {
return BassPhaseConfidence::Degraded {
reason: "coherence_below_threshold",
};
}
if has_snr_data(curves) && !snr_above_threshold(curves, band_lo, band_hi, MIN_SNR_DB) {
return BassPhaseConfidence::Degraded {
reason: "snr_below_10db",
};
}
BassPhaseConfidence::Trustworthy {
mean_coherence: mean_coh,
}
}
fn mean_coherence_in_band(curves: &[Curve], band_lo: f64, band_hi: f64) -> f64 {
let mut sum = 0.0_f64;
let mut count = 0_usize;
for c in curves {
let coh = c
.coherence
.as_ref()
.expect("coherence presence checked by the caller");
if c.freq.len() != coh.len() {
continue; }
for (f, cv) in c.freq.iter().zip(coh.iter()) {
if *f >= band_lo && *f <= band_hi && cv.is_finite() {
sum += cv;
count += 1;
}
}
}
if count == 0 {
0.0
} else {
sum / count as f64
}
}
fn has_snr_data(curves: &[Curve]) -> bool {
curves.iter().all(|c| c.noise_floor_db.is_some())
}
fn snr_above_threshold(curves: &[Curve], band_lo: f64, band_hi: f64, min_snr_db: f64) -> bool {
for c in curves {
let nf = c
.noise_floor_db
.as_ref()
.expect("noise_floor_db presence checked by the caller");
if c.freq.len() != c.spl.len() || c.freq.len() != nf.len() {
continue; }
for ((&f, &signal_db), &noise_db) in c.freq.iter().zip(c.spl.iter()).zip(nf.iter()) {
if f >= band_lo
&& f <= band_hi
&& signal_db.is_finite()
&& noise_db.is_finite()
&& signal_db - noise_db < min_snr_db
{
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array1;
fn log_freqs(n: usize, lo: f64, hi: f64) -> Array1<f64> {
Array1::from_vec(
(0..n)
.map(|i| lo * (hi / lo).powf(i as f64 / (n - 1) as f64))
.collect(),
)
}
fn healthy_curve(n: usize, coherence: f64, spl_db: f64, noise_db: f64) -> Curve {
let freq = log_freqs(n, 20.0, 200.0);
let spl = Array1::from_elem(n, spl_db);
let phase = Array1::from_elem(n, 0.0);
let coh = Array1::from_elem(n, coherence);
let noise = Array1::from_elem(n, noise_db);
Curve {
freq,
spl,
phase: Some(phase),
coherence: Some(coh),
noise_floor_db: Some(noise),
..Default::default()
}
}
fn healthy_recording() -> RecordingConfiguration {
RecordingConfiguration {
num_sweeps: Some(4),
bass_octave_duration_s: Some(3.0),
coherence_threshold: Some(0.9),
..Default::default()
}
}
#[test]
fn empty_curves_returns_no_curves() {
let v = bass_phase_confidence(&[], (20.0, 100.0), None);
assert_eq!(
v,
BassPhaseConfidence::Degraded {
reason: "no_curves"
}
);
}
#[test]
fn invalid_band_returns_invalid_band() {
let c = [healthy_curve(16, 0.95, 85.0, -60.0)];
assert_eq!(
bass_phase_confidence(&c, (100.0, 100.0), None),
BassPhaseConfidence::Degraded {
reason: "invalid_band"
}
);
assert_eq!(
bass_phase_confidence(&c, (-10.0, 100.0), None),
BassPhaseConfidence::Degraded {
reason: "invalid_band"
}
);
assert_eq!(
bass_phase_confidence(&c, (20.0, f64::INFINITY), None),
BassPhaseConfidence::Degraded {
reason: "invalid_band"
}
);
}
#[test]
fn missing_phase_returns_no_phase_data() {
let mut c = healthy_curve(16, 0.95, 85.0, -60.0);
c.phase = None;
assert_eq!(
bass_phase_confidence(&[c], (20.0, 100.0), None),
BassPhaseConfidence::Degraded {
reason: "no_phase_data"
}
);
}
#[test]
fn missing_coherence_returns_no_coherence_data() {
let mut c = healthy_curve(16, 0.95, 85.0, -60.0);
c.coherence = None;
assert_eq!(
bass_phase_confidence(&[c], (20.0, 100.0), None),
BassPhaseConfidence::Degraded {
reason: "no_coherence_data"
}
);
}
#[test]
fn too_few_sweeps_returns_insufficient_bass_duration() {
let c = [healthy_curve(16, 0.95, 85.0, -60.0)];
let mut rec = healthy_recording();
rec.num_sweeps = Some(1);
assert_eq!(
bass_phase_confidence(&c, (20.0, 100.0), Some(&rec)),
BassPhaseConfidence::Degraded {
reason: "insufficient_bass_duration"
}
);
}
#[test]
fn too_short_bass_octave_returns_insufficient_bass_duration() {
let c = [healthy_curve(16, 0.95, 85.0, -60.0)];
let mut rec = healthy_recording();
rec.bass_octave_duration_s = Some(1.0); assert_eq!(
bass_phase_confidence(&c, (20.0, 100.0), Some(&rec)),
BassPhaseConfidence::Degraded {
reason: "insufficient_bass_duration"
}
);
}
#[test]
fn low_coherence_returns_coherence_below_threshold() {
let c = [healthy_curve(16, 0.5, 85.0, -60.0)];
let rec = healthy_recording();
assert_eq!(
bass_phase_confidence(&c, (20.0, 100.0), Some(&rec)),
BassPhaseConfidence::Degraded {
reason: "coherence_below_threshold"
}
);
}
#[test]
fn low_snr_returns_snr_below_10db() {
let c = [healthy_curve(16, 0.95, 85.0, 80.0)];
let rec = healthy_recording();
assert_eq!(
bass_phase_confidence(&c, (20.0, 100.0), Some(&rec)),
BassPhaseConfidence::Degraded {
reason: "snr_below_10db"
}
);
}
#[test]
fn trustworthy_when_everything_passes() {
let c = [healthy_curve(32, 0.95, 85.0, -60.0)];
let rec = healthy_recording();
match bass_phase_confidence(&c, (20.0, 100.0), Some(&rec)) {
BassPhaseConfidence::Trustworthy { mean_coherence } => {
assert!(
(mean_coherence - 0.95).abs() < 1e-6,
"mean_coherence should ≈ 0.95, got {mean_coherence}"
);
}
other => panic!("expected Trustworthy, got {other:?}"),
}
}
#[test]
fn trustworthy_without_recording_config_uses_defaults() {
let c = [healthy_curve(32, 0.95, 85.0, -60.0)];
match bass_phase_confidence(&c, (20.0, 100.0), None) {
BassPhaseConfidence::Trustworthy { .. } => {}
other => panic!("expected Trustworthy, got {other:?}"),
}
}
#[test]
fn no_noise_floor_skips_snr_check() {
let mut c = healthy_curve(16, 0.95, 85.0, -60.0);
c.noise_floor_db = None;
let rec = healthy_recording();
match bass_phase_confidence(&[c], (20.0, 100.0), Some(&rec)) {
BassPhaseConfidence::Trustworthy { .. } => {}
other => panic!("expected Trustworthy, got {other:?}"),
}
}
#[test]
fn override_coherence_threshold_via_recording_config() {
let c = [healthy_curve(32, 0.85, 85.0, -60.0)];
let default_verdict = bass_phase_confidence(&c, (20.0, 100.0), None);
assert_eq!(
default_verdict,
BassPhaseConfidence::Degraded {
reason: "coherence_below_threshold"
}
);
let mut rec = healthy_recording();
rec.coherence_threshold = Some(0.8);
match bass_phase_confidence(&c, (20.0, 100.0), Some(&rec)) {
BassPhaseConfidence::Trustworthy { mean_coherence } => {
assert!((mean_coherence - 0.85).abs() < 1e-6);
}
other => panic!("expected Trustworthy, got {other:?}"),
}
}
#[test]
fn priority_order_no_phase_beats_low_coherence() {
let c0 = {
let mut c = healthy_curve(16, 0.95, 85.0, -60.0);
c.phase = None;
c
};
let c1 = healthy_curve(16, 0.5, 85.0, -60.0);
assert_eq!(
bass_phase_confidence(&[c0, c1], (20.0, 100.0), None),
BassPhaseConfidence::Degraded {
reason: "no_phase_data"
}
);
}
}