1use crate::analysis::smooth_response_f32;
9use rayon::prelude::*;
10
11const MIN_POWER: f32 = 1.0e-30;
12
13#[derive(Debug, Clone)]
15pub struct FdwConfig {
16 pub num_points: usize,
18 pub min_freq_hz: f32,
20 pub max_freq_hz: f32,
22 pub cycles: f32,
24 pub min_window_ms: f32,
26 pub max_window_ms: f32,
28 pub smoothing_octaves: f32,
30 pub hop_fraction: f32,
32 pub direct_gate_width_windows: f32,
34}
35
36impl Default for FdwConfig {
37 fn default() -> Self {
38 Self {
39 num_points: 512,
40 min_freq_hz: 20.0,
41 max_freq_hz: 20_000.0,
42 cycles: 8.0,
43 min_window_ms: 3.0,
44 max_window_ms: 500.0,
45 smoothing_octaves: 1.0 / 24.0,
46 hop_fraction: 0.25,
47 direct_gate_width_windows: 1.0,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct FdwAnalysis {
55 pub frequencies: Vec<f32>,
57 pub magnitude_db: Vec<f32>,
59 pub full_energy_db: Vec<f32>,
61 pub direct_energy_ratio: Vec<f32>,
63 pub correction_depth: Vec<f32>,
65 pub window_ms: Vec<f32>,
67 pub direct_sound_sample: usize,
69}
70
71struct FdwFrequencyPoint {
72 magnitude_db: f32,
73 full_energy_db: f32,
74 direct_energy_ratio: f32,
75 window_ms: f32,
76}
77
78pub fn analyze_impulse_response_fdw(
83 impulse_response: &[f32],
84 sample_rate: f32,
85 direct_sound_sample: Option<usize>,
86 config: &FdwConfig,
87) -> Result<FdwAnalysis, String> {
88 if impulse_response.is_empty() {
89 return Err("impulse_response must be non-empty".to_string());
90 }
91 if sample_rate <= 0.0 || !sample_rate.is_finite() {
92 return Err("sample_rate must be positive and finite".to_string());
93 }
94 if config.num_points == 0 {
95 return Err("num_points must be > 0".to_string());
96 }
97 if config.min_freq_hz <= 0.0 || !config.min_freq_hz.is_finite() {
98 return Err("min_freq_hz must be positive and finite".to_string());
99 }
100 if config.max_freq_hz <= 0.0 || !config.max_freq_hz.is_finite() {
101 return Err("max_freq_hz must be positive and finite".to_string());
102 }
103 if config.cycles <= 0.0 || !config.cycles.is_finite() {
104 return Err("cycles must be positive and finite".to_string());
105 }
106 if config.min_window_ms <= 0.0
107 || config.max_window_ms <= 0.0
108 || config.min_window_ms > config.max_window_ms
109 {
110 return Err("window limits must be positive and ordered".to_string());
111 }
112
113 let nyquist = sample_rate * 0.5;
114 let min_freq = config.min_freq_hz.min(nyquist);
115 let max_freq = config.max_freq_hz.min(nyquist);
116 if min_freq >= max_freq {
117 return Err(
118 "frequency range must span at least one positive band below Nyquist".to_string(),
119 );
120 }
121
122 let direct_sample = direct_sound_sample
123 .unwrap_or_else(|| find_direct_sound_sample(impulse_response))
124 .min(impulse_response.len() - 1);
125 let frequencies = log_spaced_frequencies(config.num_points, min_freq, max_freq);
126
127 let points: Vec<FdwFrequencyPoint> = frequencies
128 .par_iter()
129 .map(|&freq| {
130 let window_samples = fdw_window_samples(freq, sample_rate, config);
131 let sigma_samples = (window_samples as f32 / 6.0).max(1.0);
132 let half_support = (sigma_samples * 3.0).ceil() as usize;
133
134 let direct_power = morlet_power_at(
135 impulse_response,
136 direct_sample,
137 freq,
138 sample_rate,
139 sigma_samples,
140 half_support,
141 );
142 let (direct_tf_energy, total_tf_energy) = time_frequency_energy(
143 impulse_response,
144 direct_sample,
145 freq,
146 sample_rate,
147 window_samples,
148 sigma_samples,
149 half_support,
150 config,
151 );
152
153 FdwFrequencyPoint {
154 magnitude_db: power_to_db(direct_power),
155 full_energy_db: power_to_db(total_tf_energy),
156 direct_energy_ratio: if total_tf_energy > MIN_POWER {
157 (direct_tf_energy / total_tf_energy).clamp(0.0, 1.0)
158 } else {
159 0.0
160 },
161 window_ms: window_samples as f32 / sample_rate * 1000.0,
162 }
163 })
164 .collect();
165
166 let mut magnitude_db = Vec::with_capacity(points.len());
167 let mut full_energy_db = Vec::with_capacity(points.len());
168 let mut direct_energy_ratio = Vec::with_capacity(points.len());
169 let mut window_ms = Vec::with_capacity(points.len());
170
171 for point in points {
172 magnitude_db.push(point.magnitude_db);
173 full_energy_db.push(point.full_energy_db);
174 direct_energy_ratio.push(point.direct_energy_ratio);
175 window_ms.push(point.window_ms);
176 }
177
178 if config.smoothing_octaves > 0.0 {
179 magnitude_db = smooth_response_f32(&frequencies, &magnitude_db, config.smoothing_octaves);
180 full_energy_db =
181 smooth_response_f32(&frequencies, &full_energy_db, config.smoothing_octaves);
182 direct_energy_ratio =
183 smooth_response_f32(&frequencies, &direct_energy_ratio, config.smoothing_octaves)
184 .into_iter()
185 .map(|v| v.clamp(0.0, 1.0))
186 .collect();
187 }
188
189 Ok(FdwAnalysis {
190 frequencies,
191 magnitude_db,
192 full_energy_db,
193 correction_depth: direct_energy_ratio.clone(),
194 direct_energy_ratio,
195 window_ms,
196 direct_sound_sample: direct_sample,
197 })
198}
199
200fn find_direct_sound_sample(impulse_response: &[f32]) -> usize {
201 impulse_response
202 .iter()
203 .enumerate()
204 .max_by(|(_, a), (_, b)| {
205 a.abs()
206 .partial_cmp(&b.abs())
207 .unwrap_or(std::cmp::Ordering::Equal)
208 })
209 .map(|(idx, _)| idx)
210 .unwrap_or(0)
211}
212
213fn log_spaced_frequencies(num_points: usize, min_freq: f32, max_freq: f32) -> Vec<f32> {
214 if num_points == 1 {
215 return vec![min_freq];
216 }
217
218 let log_start = min_freq.ln();
219 let log_end = max_freq.ln();
220 (0..num_points)
221 .map(|i| (log_start + (log_end - log_start) * i as f32 / (num_points - 1) as f32).exp())
222 .collect()
223}
224
225fn fdw_window_samples(freq: f32, sample_rate: f32, config: &FdwConfig) -> usize {
226 let unclamped_ms = config.cycles / freq * 1000.0;
227 let window_ms = unclamped_ms.clamp(config.min_window_ms, config.max_window_ms);
228 let mut samples = (window_ms / 1000.0 * sample_rate).round() as usize;
229 samples = samples.max(3);
230 if samples.is_multiple_of(2) {
231 samples + 1
232 } else {
233 samples
234 }
235}
236
237fn morlet_power_at(
238 impulse_response: &[f32],
239 center: usize,
240 freq: f32,
241 sample_rate: f32,
242 sigma_samples: f32,
243 half_support: usize,
244) -> f32 {
245 let start = center.saturating_sub(half_support);
246 let end = (center + half_support + 1).min(impulse_response.len());
247 let omega = 2.0 * std::f32::consts::PI * freq / sample_rate;
248 let mut re = 0.0_f32;
249 let mut im = 0.0_f32;
250
251 for (offset, &sample) in impulse_response[start..end].iter().enumerate() {
252 let idx = start + offset;
253 let rel = idx as isize - center as isize;
254 let x = rel as f32 / sigma_samples;
255 let window = (-0.5 * x * x).exp();
256 let phase = -omega * rel as f32;
257 re += sample * window * phase.cos();
258 im += sample * window * phase.sin();
259 }
260
261 re * re + im * im
262}
263
264fn time_frequency_energy(
265 impulse_response: &[f32],
266 direct_sample: usize,
267 freq: f32,
268 sample_rate: f32,
269 window_samples: usize,
270 sigma_samples: f32,
271 half_support: usize,
272 config: &FdwConfig,
273) -> (f32, f32) {
274 let hop_fraction = config.hop_fraction.clamp(0.05, 1.0);
275 let hop = ((window_samples as f32 * hop_fraction).round() as usize).max(1);
276 let direct_radius =
277 ((half_support as f32) * config.direct_gate_width_windows.max(0.0)).round() as usize;
278
279 let mut centers = Vec::with_capacity(impulse_response.len() / hop + 3);
280 let mut center = 0usize;
281 while center < impulse_response.len() {
282 centers.push(center);
283 center = center.saturating_add(hop);
284 }
285 centers.push(direct_sample);
286 centers.push(impulse_response.len() - 1);
287 centers.sort_unstable();
288 centers.dedup();
289
290 let mut direct_energy = 0.0_f32;
291 let mut total_energy = 0.0_f32;
292
293 for center in centers {
294 let power = morlet_power_at(
295 impulse_response,
296 center,
297 freq,
298 sample_rate,
299 sigma_samples,
300 half_support,
301 );
302 total_energy += power;
303 if center.abs_diff(direct_sample) <= direct_radius {
304 direct_energy += power;
305 }
306 }
307
308 (direct_energy, total_energy)
309}
310
311fn power_to_db(power: f32) -> f32 {
312 10.0 * power.max(MIN_POWER).log10()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn fdw_magnitude_is_flat_for_single_impulse() {
321 let mut ir = vec![0.0_f32; 4096];
322 ir[256] = 1.0;
323 let config = FdwConfig {
324 num_points: 96,
325 min_freq_hz: 40.0,
326 max_freq_hz: 16_000.0,
327 smoothing_octaves: 0.0,
328 ..Default::default()
329 };
330
331 let analysis = analyze_impulse_response_fdw(&ir, 48_000.0, Some(256), &config).unwrap();
332 let max = analysis
333 .magnitude_db
334 .iter()
335 .copied()
336 .fold(f32::NEG_INFINITY, f32::max);
337 let min = analysis
338 .magnitude_db
339 .iter()
340 .copied()
341 .fold(f32::INFINITY, f32::min);
342
343 assert!(
344 max - min < 0.01,
345 "single-sample impulse should remain flat after FDW"
346 );
347 assert!(
348 analysis.direct_energy_ratio.iter().all(|&v| v > 0.99),
349 "dry impulse should be fully direct"
350 );
351 }
352
353 #[test]
354 fn fdw_rejects_late_reflection_more_at_high_frequency() {
355 let sample_rate = 48_000.0;
356 let mut ir = vec![0.0_f32; 8192];
357 ir[128] = 1.0;
358 ir[128 + (0.010 * sample_rate) as usize] = 0.8;
359 let config = FdwConfig {
360 num_points: 160,
361 min_freq_hz: 40.0,
362 max_freq_hz: 8_000.0,
363 smoothing_octaves: 0.0,
364 ..Default::default()
365 };
366
367 let analysis = analyze_impulse_response_fdw(&ir, sample_rate, Some(128), &config).unwrap();
368 let low = nearest_ratio(&analysis, 80.0);
369 let high = nearest_ratio(&analysis, 4_000.0);
370
371 assert!(
372 low > 0.95,
373 "bass FDW window should include modal/early energy, got {low:.3}"
374 );
375 assert!(
376 high < 0.75,
377 "HF FDW window should reject the 10ms reflection, got {high:.3}"
378 );
379 }
380
381 #[test]
382 fn fdw_validates_empty_ir() {
383 let err = analyze_impulse_response_fdw(&[], 48_000.0, None, &FdwConfig::default())
384 .expect_err("empty IR should be rejected");
385 assert!(err.contains("non-empty"));
386 }
387
388 fn nearest_ratio(analysis: &FdwAnalysis, freq: f32) -> f32 {
389 analysis
390 .frequencies
391 .iter()
392 .enumerate()
393 .min_by(|(_, a), (_, b)| {
394 (*a - freq)
395 .abs()
396 .partial_cmp(&(*b - freq).abs())
397 .unwrap_or(std::cmp::Ordering::Equal)
398 })
399 .map(|(idx, _)| analysis.direct_energy_ratio[idx])
400 .unwrap()
401 }
402}