//! Continuous phase-wheel analysis on the native-rate audio stream. const HILBERT_TAPS: usize = 33; const HILBERT_HALF: usize = (HILBERT_TAPS - 1) / 2; const BANDPASS_LOW_HZ: f64 = 300.0; const BANDPASS_HIGH_HZ: f64 = 5_000.0; #[derive(Clone, Copy, Debug, Default)] pub struct PhaseWheelSnapshot { pub angle_rad: Option, pub coherence: f32, pub level: f32, pub peak: f32, } #[derive(Clone, Copy, Debug, Default)] struct BandpassChannel { hp_x: f64, hp_y: f64, lp_y: f64, } impl BandpassChannel { fn process(&mut self, sample: f64, hp_alpha: f64, lp_alpha: f64) -> f64 { let hp = hp_alpha * (self.hp_y + sample - self.hp_x); self.hp_x = sample; self.hp_y = hp; self.lp_y = lp_alpha * hp + (1.0 - lp_alpha) * self.lp_y; self.lp_y } } pub struct PhaseWheelAnalyzer { sample_rate: u32, hp_alpha: f64, lp_alpha: f64, band_l: BandpassChannel, band_r: BandpassChannel, ring_l: [f64; HILBERT_TAPS], ring_r: [f64; HILBERT_TAPS], hilbert: [f64; HILBERT_TAPS], write: usize, fill: usize, cross_re: f64, cross_im: f64, weight_sum: f64, amplitude_sum: f64, amplitude_peak: f64, count: usize, } impl PhaseWheelAnalyzer { pub fn new(sample_rate: u32) -> Self { let mut analyzer = Self { sample_rate: 0, hp_alpha: 0.0, lp_alpha: 0.0, band_l: BandpassChannel::default(), band_r: BandpassChannel::default(), ring_l: [0.0; HILBERT_TAPS], ring_r: [0.0; HILBERT_TAPS], hilbert: build_hilbert_kernel(), write: 0, fill: 0, cross_re: 0.0, cross_im: 0.0, weight_sum: 0.0, amplitude_sum: 0.0, amplitude_peak: 0.0, count: 0, }; analyzer.configure(sample_rate); analyzer } pub fn configure(&mut self, sample_rate: u32) { let rate = sample_rate.max(8_000); if self.sample_rate == rate { return; } self.sample_rate = rate; self.hp_alpha = highpass_alpha(rate, BANDPASS_LOW_HZ); self.lp_alpha = lowpass_alpha(rate, BANDPASS_HIGH_HZ); self.band_l = BandpassChannel::default(); self.band_r = BandpassChannel::default(); self.ring_l.fill(0.0); self.ring_r.fill(0.0); self.write = 0; self.fill = 0; self.clear_accumulator(); } pub fn process(&mut self, left: f32, right: f32) { let filtered_l = self .band_l .process(left as f64, self.hp_alpha, self.lp_alpha); let filtered_r = self .band_r .process(right as f64, self.hp_alpha, self.lp_alpha); self.ring_l[self.write] = filtered_l; self.ring_r[self.write] = filtered_r; self.write = (self.write + 1) % HILBERT_TAPS; self.fill = (self.fill + 1).min(HILBERT_TAPS); if self.fill < HILBERT_TAPS { return; } // `write` points at the oldest sample. The real component is delayed // by half the FIR length, so it is aligned with the causal Hilbert FIR. let real_index = (self.write + HILBERT_HALF) % HILBERT_TAPS; let l_re = self.ring_l[real_index].clamp(-1.0, 1.0); let r_re = self.ring_r[real_index].clamp(-1.0, 1.0); let mut l_im = 0.0; let mut r_im = 0.0; // The ideal odd Hilbert kernel has zero coefficients at every even // offset; with a 33-tap kernel those are the even tap indices. for tap in (1..HILBERT_TAPS).step_by(2) { let index = (self.write + tap) % HILBERT_TAPS; l_im += self.ring_l[index] * self.hilbert[tap]; r_im += self.ring_r[index] * self.hilbert[tap]; } let mag_l = l_re.hypot(l_im).min(1.0); let mag_r = r_re.hypot(r_im).min(1.0); let weight = mag_l * mag_r; // zL * conj(zR): its argument is the energy-weighted L/R phase. self.cross_re += l_re * r_re + l_im * r_im; self.cross_im += l_im * r_re - l_re * r_im; self.weight_sum += weight; let amplitude = 0.5 * (mag_l + mag_r); self.amplitude_sum += amplitude; self.amplitude_peak = self.amplitude_peak.max(amplitude); self.count += 1; } pub fn take_snapshot(&mut self) -> PhaseWheelSnapshot { let level = if self.count > 0 { (self.amplitude_sum / self.count as f64) as f32 } else { 0.0 }; let resultant = self.cross_re.hypot(self.cross_im); let angle_rad = if self.weight_sum > 1e-12 && resultant > self.weight_sum * 1e-9 { Some(self.cross_im.atan2(self.cross_re) as f32) } else { None }; let coherence = if self.weight_sum > 1e-12 { (resultant / self.weight_sum).clamp(0.0, 1.0) as f32 } else { 0.0 }; let snapshot = PhaseWheelSnapshot { angle_rad, coherence, level, peak: self.amplitude_peak as f32, }; self.clear_accumulator(); snapshot } fn clear_accumulator(&mut self) { self.cross_re = 0.0; self.cross_im = 0.0; self.weight_sum = 0.0; self.amplitude_sum = 0.0; self.amplitude_peak = 0.0; self.count = 0; } } fn highpass_alpha(sample_rate: u32, cutoff: f64) -> f64 { let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0)); let dt = 1.0 / sample_rate.max(1) as f64; (rc / (rc + dt)).clamp(0.0, 1.0) } fn lowpass_alpha(sample_rate: u32, cutoff: f64) -> f64 { let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0)); let dt = 1.0 / sample_rate.max(1) as f64; (dt / (rc + dt)).clamp(0.0, 1.0) } fn build_hilbert_kernel() -> [f64; HILBERT_TAPS] { let mut kernel = [0.0; HILBERT_TAPS]; for (index, value) in kernel.iter_mut().enumerate() { let offset = index as isize - HILBERT_HALF as isize; if offset == 0 || offset % 2 == 0 { continue; } let window = 0.54 - 0.46 * ((2.0 * std::f64::consts::PI * index as f64) / (HILBERT_TAPS - 1) as f64).cos(); *value = 2.0 / (std::f64::consts::PI * offset as f64) * window; } kernel } #[cfg(test)] mod tests { use super::*; fn feed_tone(analyzer: &mut PhaseWheelAnalyzer, phase: f64, samples: usize) { let omega = 2.0 * std::f64::consts::PI * 1_000.0 / 48_000.0; for index in 0..samples { let t = omega * index as f64; analyzer.process((0.5 * t.sin()) as f32, (0.5 * (t - phase).sin()) as f32); } } #[test] fn continuous_analyzer_tracks_tone_phase() { let mut analyzer = PhaseWheelAnalyzer::new(48_000); feed_tone(&mut analyzer, std::f64::consts::FRAC_PI_2, 4_800); let snapshot = analyzer.take_snapshot(); let angle = snapshot.angle_rad.expect("coherent tone has a phase"); assert!((angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 0.03); assert!( snapshot.coherence > 0.9, "coherence was {}", snapshot.coherence ); assert!(snapshot.level > 0.1); assert!(snapshot.peak >= snapshot.level); } #[test] fn snapshot_reset_does_not_reset_filter_or_hilbert_history() { let mut analyzer = PhaseWheelAnalyzer::new(48_000); feed_tone(&mut analyzer, 0.4, 2_400); let first = analyzer.take_snapshot().angle_rad.unwrap(); feed_tone(&mut analyzer, 0.4, 800); let second = analyzer.take_snapshot().angle_rad.unwrap(); assert!((first - second).abs() < 0.03); } #[test] fn one_sided_signal_does_not_invent_a_phase() { let mut analyzer = PhaseWheelAnalyzer::new(48_000); for index in 0..2_400 { let t = 2.0 * std::f64::consts::PI * 1_000.0 * index as f64 / 48_000.0; analyzer.process((0.5 * t.sin()) as f32, 0.0); } let snapshot = analyzer.take_snapshot(); assert!(snapshot.angle_rad.is_none()); assert_eq!(snapshot.coherence, 0.0); } #[test] fn energetic_component_dominates_a_quiet_conflicting_tone() { let mut analyzer = PhaseWheelAnalyzer::new(48_000); for index in 0..9_600 { let t = index as f64 / 48_000.0; let strong = 2.0 * std::f64::consts::PI * 1_000.0 * t; let quiet = 2.0 * std::f64::consts::PI * 2_000.0 * t; let left = 0.5 * strong.sin() + 0.04 * quiet.sin(); let right = 0.5 * strong.sin() + 0.04 * (quiet - std::f64::consts::FRAC_PI_2).sin(); analyzer.process(left as f32, right as f32); } let angle = analyzer.take_snapshot().angle_rad.unwrap(); assert!(angle.abs() < 0.03, "quiet tone pulled phase to {angle}"); } }