1use crate::Note;
4use secs_dsp::Complex64;
5
6pub const BAND_HZ: (f64, f64) = (40.0, 200.0);
8pub const CENTERS_HZ: [f64; 6] = [50.0, 63.0, 80.0, 100.0, 125.0, 160.0];
10pub const LOSS_DB: f64 = -6.0;
12pub const MAX_PAIRS: usize = 6;
14pub const FLOOR_DB: f64 = -30.0;
16
17pub type Band = (f64, f64, f64, f64);
19
20pub fn bands(f: &[f64], a: &[Complex64], b: &[Complex64]) -> Vec<Band> {
30 let edges = band_edges();
31 CENTERS_HZ
32 .iter()
33 .enumerate()
34 .filter_map(|(k, _)| {
35 let lo = edges[k];
36 let hi = edges[k + 1];
37 let bins = f
38 .iter()
39 .zip(a)
40 .zip(b)
41 .filter(|((frequency, _), _)| {
42 let last = k + 1 == CENTERS_HZ.len();
43 lo <= **frequency && (**frequency < hi || (last && **frequency <= hi))
44 })
45 .map(|((_, a), b)| (*a, *b))
46 .collect::<Vec<_>>();
47 if bins.is_empty() {
48 return None;
49 }
50
51 let cross = bins.iter().map(|(a, b)| *a * b.conj()).sum::<Complex64>();
52 let mut phase_deg = cross.arg().to_degrees();
53 if phase_deg <= -180.0 {
54 phase_deg = 180.0;
55 }
56
57 let combined = bins.iter().map(|(a, b)| (*a + *b).norm_sqr()).sum::<f64>();
58 let separate = bins
59 .iter()
60 .map(|(a, b)| (a.norm() + b.norm()).powi(2))
61 .sum::<f64>();
62 if !phase_deg.is_finite() || !combined.is_finite() || !separate.is_finite() {
68 return None;
69 }
70 let loss_db = if separate == 0.0 {
71 0.0
72 } else {
73 10.0 * (combined / separate).log10()
74 };
75 Some((lo, hi, phase_deg, loss_db.max(FLOOR_DB)))
76 })
77 .collect()
78}
79
80pub fn band_edges() -> Vec<f64> {
84 (0..=CENTERS_HZ.len())
85 .map(|k| {
86 if k == 0 {
87 BAND_HZ.0
88 } else if k == CENTERS_HZ.len() {
89 BAND_HZ.1
90 } else {
91 (CENTERS_HZ[k - 1] * CENTERS_HZ[k]).sqrt()
92 }
93 })
94 .collect()
95}
96
97pub fn check(f: &[f64], responses: &[(String, Vec<Complex64>)]) -> Vec<Note> {
102 let mut candidates = Vec::new();
103 for left in 0..responses.len() {
104 for right in left + 1..responses.len() {
105 let affected = bands(f, &responses[left].1, &responses[right].1)
106 .into_iter()
107 .filter(|band| band.3 <= LOSS_DB)
108 .collect::<Vec<_>>();
109 if let Some(worst) = affected.iter().map(|band| band.3).min_by(f64::total_cmp) {
110 candidates.push((worst, left, right, affected));
111 }
112 }
113 }
114 candidates.sort_by(|a, b| {
115 a.0.total_cmp(&b.0)
116 .then_with(|| a.1.cmp(&b.1))
117 .then_with(|| a.2.cmp(&b.2))
118 });
119 candidates
120 .into_iter()
121 .take(MAX_PAIRS)
122 .map(|(_, left, right, bands)| Note::PairPhase {
123 a: responses[left].0.clone(),
124 b: responses[right].0.clone(),
125 bands,
126 })
127 .collect()
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use std::f64::consts::PI;
134
135 fn response(phase_deg: f64) -> Vec<Complex64> {
136 CENTERS_HZ
137 .iter()
138 .map(|_| Complex64::from_polar(1.0, -phase_deg * PI / 180.0))
139 .collect()
140 }
141
142 fn assert_all_close(values: &[Band], phase_deg: f64, loss_db: f64) {
143 assert_eq!(values.len(), CENTERS_HZ.len());
144 for band in values {
145 assert!((band.2 - phase_deg).abs() < 1e-10, "{band:?}");
146 assert!((band.3 - loss_db).abs() < 0.03, "{band:?}");
147 }
148 }
149
150 #[test]
151 fn pair_phase_bands_share_edges_between_neighbours() {
152 let edges = band_edges();
153 assert_eq!(edges.len(), CENTERS_HZ.len() + 1);
154 let same = response(0.0);
155 let bands = bands(&CENTERS_HZ, &same, &same);
156 assert_eq!(bands.len(), CENTERS_HZ.len());
157 for (k, band) in bands.iter().enumerate() {
158 assert!(band.0 < CENTERS_HZ[k] && CENTERS_HZ[k] < band.1, "{band:?}");
159 if k + 1 < bands.len() {
160 assert_eq!(band.1, bands[k + 1].0);
161 }
162 }
163 assert_eq!(edges[0], BAND_HZ.0);
164 assert_eq!(edges[CENTERS_HZ.len()], BAND_HZ.1);
165 assert!((edges[3] - 89.4427).abs() < 0.01);
166 assert!((edges[4] - 111.8034).abs() < 0.01);
167 assert!((edges[5] - 141.4214).abs() < 0.01);
168 }
169
170 #[test]
171 fn pair_phase_bands_skip_non_finite_and_include_the_top_edge() {
172 let one = vec![Complex64::new(1.0, 0.0)];
173 let minus = vec![Complex64::new(-1.0, 0.0)];
174 let top = bands(&[BAND_HZ.1], &one, &minus);
175 assert_eq!(top.len(), 1);
176 assert_eq!(top[0].3, FLOOR_DB);
177 assert!(bands(&[BAND_HZ.0 - 0.01], &one, &minus).is_empty());
178 assert!(bands(&[100.0], &[Complex64::new(f64::NAN, 0.0)], &one).is_empty());
179 let huge = vec![Complex64::new(1e200, 0.0)];
180 assert!(bands(&[100.0], &huge, &huge).is_empty());
181 let big = vec![Complex64::new(1.3e154, 0.0)];
183 let big_minus = vec![Complex64::new(-1e153, 0.0)];
184 assert!(bands(&[100.0], &big, &big_minus).is_empty());
185 assert!(check(&[100.0], &[("L".into(), huge.clone()), ("R".into(), huge)]).is_empty());
186 }
187
188 #[test]
189 fn pair_phase_bands_measure_phase_difference_and_loss() {
190 let same = response(0.0);
191 assert_all_close(&bands(&CENTERS_HZ, &same, &same), 0.0, 0.0);
192 assert_all_close(
193 &bands(&CENTERS_HZ, &same, &response(90.0)),
194 90.0,
195 -3.010_299_956_639_812,
196 );
197 assert_all_close(
198 &bands(&CENTERS_HZ, &same, &response(120.0)),
199 120.0,
200 -6.020_599_913_279_624,
201 );
202 assert_all_close(
203 &bands(&CENTERS_HZ, &same, &response(180.0)),
204 180.0,
205 FLOOR_DB,
206 );
207 }
208
209 #[test]
210 fn pair_phase_check_reports_only_cancelling_pairs_worst_first() {
211 let responses = vec![
212 ("A".into(), response(0.0)),
213 ("B".into(), response(121.0)),
214 ("C".into(), response(60.0)),
215 ];
216 let notes = check(&CENTERS_HZ, &responses);
217 assert!(matches!(
218 notes.as_slice(),
219 [Note::PairPhase { a, b, bands }]
220 if a == "A" && b == "B" && bands.len() == CENTERS_HZ.len()
221 ));
222
223 let responses = [0.0, 30.0, 60.0, 180.0, 210.0, 240.0]
224 .into_iter()
225 .enumerate()
226 .map(|(index, phase)| (format!("R{index}"), response(phase)))
227 .collect::<Vec<_>>();
228 let notes = check(&CENTERS_HZ, &responses);
229 assert_eq!(notes.len(), MAX_PAIRS);
230 let worst = notes
231 .iter()
232 .map(|note| match note {
233 Note::PairPhase { bands, .. } => bands
234 .iter()
235 .map(|band| band.3)
236 .min_by(f64::total_cmp)
237 .unwrap(),
238 _ => unreachable!(),
239 })
240 .collect::<Vec<_>>();
241 assert!(worst.windows(2).all(|pair| pair[0] <= pair[1]));
242 assert!(worst.iter().take(3).all(|value| *value == FLOOR_DB));
243 }
244}