Skip to main content

math_audio_dsp/
fdw.rs

1//! Frequency-dependent windowing for room impulse responses.
2//!
3//! FDW analyzes an impulse response with a frequency-dependent Morlet-style
4//! gate: long windows in the bass and progressively shorter windows toward
5//! the treble. The resulting direct-energy ratio can be used as a
6//! per-frequency correction-depth curve.
7
8use crate::analysis::smooth_response_f32;
9use rayon::prelude::*;
10
11const MIN_POWER: f32 = 1.0e-30;
12
13/// Configuration for frequency-dependent windowing.
14#[derive(Debug, Clone)]
15pub struct FdwConfig {
16    /// Number of logarithmically spaced output frequency points.
17    pub num_points: usize,
18    /// Lowest analyzed frequency in Hz.
19    pub min_freq_hz: f32,
20    /// Highest analyzed frequency in Hz. Clamped to Nyquist at runtime.
21    pub max_freq_hz: f32,
22    /// Window length in cycles at each frequency before min/max clamping.
23    pub cycles: f32,
24    /// Minimum analysis window length in milliseconds.
25    pub min_window_ms: f32,
26    /// Maximum analysis window length in milliseconds.
27    pub max_window_ms: f32,
28    /// Optional octave smoothing for magnitude and direct-energy ratio.
29    pub smoothing_octaves: f32,
30    /// Hop size as a fraction of the current window length for energy scans.
31    pub hop_fraction: f32,
32    /// Direct gate radius in units of the current half-window support.
33    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/// FDW analysis result.
53#[derive(Debug, Clone)]
54pub struct FdwAnalysis {
55    /// Log-spaced frequencies in Hz.
56    pub frequencies: Vec<f32>,
57    /// FDW-gated magnitude around the direct sound, in dB.
58    pub magnitude_db: Vec<f32>,
59    /// Full time-frequency energy magnitude, in dB.
60    pub full_energy_db: Vec<f32>,
61    /// Direct / total time-frequency energy ratio, smoothed and clamped to 0..1.
62    pub direct_energy_ratio: Vec<f32>,
63    /// Alias for `direct_energy_ratio`, named for correction-depth consumers.
64    pub correction_depth: Vec<f32>,
65    /// Analysis window length per frequency, in milliseconds.
66    pub window_ms: Vec<f32>,
67    /// Direct sound sample used as the FDW gate center.
68    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
78/// Analyze an impulse response with frequency-dependent windowing.
79///
80/// `direct_sound_sample` should be the detected direct-sound TOA when available.
81/// If omitted, the largest absolute sample in the IR is used.
82pub 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}