Skip to main content

secs_engine/
measurement.rs

1//! Measurement preparation on the upstream internal time axis.
2//! Upstream: `onset_index`, `windowed`, `_prepare`, `long_spectra`, `_delay`.
3
4use crate::grid::{BINS, N_FFT, SR, SR_F, design_freqs};
5use crate::setup::{Setup, SubFilter};
6use crate::{C, EngineConfig, EngineError, MeasurementSet, Samples};
7use secs_dsp::{fft, response};
8use std::{f64::consts::PI, sync::Arc};
9
10pub const WINDOW_MS: f64 = 500.0;
11
12/// Bytes of one design-grid spectrum (`BINS` complex values).
13pub(crate) const SPECTRUM_BYTES: u64 = (BINS * std::mem::size_of::<C>()) as u64;
14
15#[cfg(test)]
16thread_local! {
17    /// `build_spectra` calls on this thread; tests check that a refused
18    /// budget returns before any spectra are built.
19    pub(crate) static SPECTRA_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
20}
21
22/// IR cells of `roles` (all roles when `None`) across every position.
23pub(crate) fn cells(set: &MeasurementSet, roles: Option<&[String]>) -> u64 {
24    set.positions
25        .iter()
26        .flat_map(|(_, measured)| measured)
27        .filter(|(role, _)| roles.is_none_or(|roles| roles.contains(role)))
28        .count() as u64
29}
30
31/// Bytes of one `build_spectra` result: one spectrum per IR cell.
32pub(crate) fn spectra_bytes(set: &MeasurementSet) -> u64 {
33    cells(set, None).saturating_mul(SPECTRUM_BYTES)
34}
35
36/// Transient bytes of one `build_spectra` call beyond its result: per cell
37/// the window `w`, the zero-padded `padded` and the FFT's thread-local input
38/// and scratch (at most `N_FFT` f64 each), the FFT output and its `Arc` copy
39/// (one spectrum each), plus one `sub_filter_response` per filtered role.
40pub(crate) fn build_scratch_bytes(filters: usize) -> u64 {
41    (4 * N_FFT * std::mem::size_of::<f64>()) as u64
42        + (2 + filters as u64).saturating_mul(SPECTRUM_BYTES)
43}
44
45/// What `prepare` allocates: `h` (`h0` shares its handles) and the scratch.
46pub(crate) fn preparation_bytes(set: &MeasurementSet, setup: &Setup) -> u64 {
47    spectra_bytes(set).saturating_add(build_scratch_bytes(setup.sub_filters.len()))
48}
49
50pub fn onset_index(ir: &Samples) -> usize {
51    if ir.is_empty() {
52        return 0;
53    }
54    let mut peak = 0;
55    for i in 1..ir.len() {
56        if ir.at(i).abs() > ir.at(peak).abs() {
57            peak = i;
58        }
59    }
60    let threshold = 0.1 * ir.at(peak).abs();
61    (0..=peak)
62        .find(|&i| ir.at(i).abs() >= threshold)
63        .unwrap_or(peak)
64}
65
66fn window(ir: &Samples, start: usize, length: usize, taper: f64) -> (Vec<f64>, usize) {
67    let a = start.saturating_sub(96).min(ir.len());
68    let end = a.saturating_add(length).min(ir.len());
69    let mut w: Vec<_> = (a..end).map(|i| ir.at(i)).collect();
70    let k = (w.len() as f64 * taper) as usize;
71    let from = w.len() - k;
72    for (i, v) in w[from..].iter_mut().enumerate() {
73        *v *= 0.5 * (1.0 + (PI * i as f64 / k as f64).cos());
74    }
75    (w, a)
76}
77
78pub fn windowed(ir: &Samples, start: usize) -> (Vec<f64>, usize) {
79    window(ir, start, (WINDOW_MS / 1000.0 * SR_F) as usize, 0.2)
80}
81
82pub fn spectrum(x: &[f64]) -> Vec<C> {
83    let mut padded = vec![0.0; N_FFT];
84    let n = x.len().min(N_FFT);
85    padded[..n].copy_from_slice(&x[..n]);
86    fft::rfft(&padded)
87}
88
89/// The phase NumPy computes for `-2j * np.pi * f * t`: the imaginary part of
90/// two complex multiplications, `0.0 + (-2π·f)·t` (the `0.0 +` is the real
91/// part's contribution and turns a zero product into +0).
92pub(crate) fn ramp_phase(f: f64, t: f64) -> f64 {
93    0.0 + (-2.0 * PI * f) * t
94}
95
96/// `_delay`: `np.exp(-2j * np.pi * f * ms / 1000.0)`. NumPy divides the
97/// complex array by 1000 as a multiplication by the reciprocal (its complex
98/// division loop), so the phase is `ramp·0.001`, not `ramp/1000`; the real
99/// part stays zero, so the exponential is `(cos θ, sin θ)`.
100pub fn delay_response(ms: f64) -> Vec<C> {
101    design_freqs()
102        .iter()
103        .map(|&f| C::from_polar(1.0, ramp_phase(f, ms) * 0.001))
104        .collect()
105}
106
107pub fn sub_filter_response(filter: &SubFilter) -> Vec<C> {
108    let mut out = vec![C::new(1.0, 0.0); BINS];
109    for (hz, kind, typ) in [
110        (filter.hp_hz, "HP", filter.hp_type),
111        (filter.lp_hz, "LP", filter.lp_type),
112    ] {
113        if hz > 0.0 {
114            // BandType admits only the five names accepted by this primitive.
115            if let Ok(h) = response::band_filter(design_freqs(), hz, kind, typ.name()) {
116                for (v, h) in out.iter_mut().zip(h) {
117                    *v *= h;
118                }
119            }
120        }
121    }
122    if !filter.peq.is_empty() {
123        for (v, h) in out
124            .iter_mut()
125            .zip(response::peq_response(design_freqs(), &filter.peq))
126        {
127            *v *= h;
128        }
129    }
130    out
131}
132
133/// Ordered cells; cloning copies only the Arc handles, never a spectrum.
134#[derive(Debug, Clone, Default)]
135pub struct Spectra {
136    positions: Vec<u32>,
137    cells: Vec<Vec<(String, Arc<[C]>)>>,
138}
139
140impl Spectra {
141    pub fn positions(&self) -> &[u32] {
142        &self.positions
143    }
144    pub fn get(&self, position: u32, role: &str) -> Option<&Arc<[C]>> {
145        let p = self.positions.binary_search(&position).ok()?;
146        self.cells[p]
147            .iter()
148            .find(|(r, _)| r == role)
149            .map(|(_, h)| h)
150    }
151    pub fn roles_at(&self, position: u32) -> impl Iterator<Item = &str> {
152        self.positions
153            .binary_search(&position)
154            .ok()
155            .into_iter()
156            .flat_map(|p| self.cells[p].iter().map(|(r, _)| r.as_str()))
157    }
158    pub fn set(&mut self, position: u32, role: &str, value: Arc<[C]>) {
159        let p = self.ensure_position(position);
160        if let Some((_, h)) = self.cells[p].iter_mut().find(|(r, _)| r == role) {
161            *h = value;
162        } else {
163            self.cells[p].push((role.to_owned(), value));
164        }
165    }
166    fn ensure_position(&mut self, position: u32) -> usize {
167        match self.positions.binary_search(&position) {
168            Ok(p) => p,
169            Err(p) => {
170                self.positions.insert(p, position);
171                self.cells.insert(p, Vec::new());
172                p
173            }
174        }
175    }
176}
177
178#[derive(Debug, Clone)]
179pub struct Prepared {
180    pub arrival_s: Vec<(String, f64)>,
181    pub align_delay_ms: Vec<(String, f64)>,
182    pub base_s: f64,
183    pub h: Spectra,
184    pub h0: Spectra,
185}
186
187/// `Err(Cancelled)` once `cancelled()` reads true (TD-49). The preparation
188/// below polls it once per IR cell in every pass over the measurements and
189/// changes nothing else, so an uncancelled run keeps its arithmetic and order.
190fn check(cancelled: &dyn Fn() -> bool) -> Result<(), EngineError> {
191    if cancelled() {
192        Err(EngineError::Cancelled)
193    } else {
194        Ok(())
195    }
196}
197
198fn validate(set: &MeasurementSet, cancelled: &dyn Fn() -> bool) -> Result<(), EngineError> {
199    let mut previous = None;
200    for (pos, roles) in &set.positions {
201        if previous.is_some_and(|p| p >= *pos) {
202            return Err(EngineError::Input(
203                "positions must be strictly ascending".into(),
204            ));
205        }
206        previous = Some(*pos);
207        for (i, (role, m)) in roles.iter().enumerate() {
208            check(cancelled)?;
209            if m.sample_rate != SR {
210                return Err(EngineError::MeasurementSampleRate {
211                    position: *pos,
212                    role: role.clone(),
213                    actual_hz: m.sample_rate,
214                    expected_hz: SR,
215                });
216            }
217            if m.samples.is_empty()
218                || !m.t0.is_finite()
219                || (0..m.samples.len()).any(|i| !m.samples.at(i).is_finite())
220            {
221                return Err(EngineError::InvalidMeasurement {
222                    position: *pos,
223                    role: role.clone(),
224                });
225            }
226            if roles[..i].iter().any(|(r, _)| r == role) {
227                return Err(EngineError::DuplicateMeasurement {
228                    position: *pos,
229                    role: role.clone(),
230                });
231            }
232        }
233    }
234    Ok(())
235}
236
237fn build_spectra(
238    set: &MeasurementSet,
239    base: f64,
240    filters: &[(String, SubFilter)],
241    length: usize,
242    taper: f64,
243    cancelled: &dyn Fn() -> bool,
244) -> Result<Spectra, EngineError> {
245    #[cfg(test)]
246    SPECTRA_BUILT.with(|built| built.set(built.get() + 1));
247    let filters: Vec<_> = filters
248        .iter()
249        .map(|(r, f)| (r, sub_filter_response(f)))
250        .collect();
251    let mut out = Spectra::default();
252    for (pos, roles) in &set.positions {
253        out.ensure_position(*pos);
254        for (role, m) in roles {
255            check(cancelled)?;
256            let (w, a) = window(&m.samples, onset_index(&m.samples), length, taper);
257            let start_t = m.t0 + a as f64 / SR_F - base;
258            let mut h = spectrum(&w);
259            for (h, f) in h.iter_mut().zip(design_freqs()) {
260                *h *= C::from_polar(1.0, ramp_phase(*f, start_t));
261            }
262            if let Some((_, filter)) = filters.iter().find(|(r, _)| *r == role) {
263                for (h, f) in h.iter_mut().zip(filter) {
264                    *h *= f;
265                }
266            }
267            out.set(*pos, role, h.into());
268        }
269    }
270    Ok(out)
271}
272
273/// [`prepare_until`] without cancellation.
274pub fn prepare(
275    set: &MeasurementSet,
276    config: &EngineConfig,
277    setup: &Setup,
278) -> Result<Prepared, EngineError> {
279    prepare_until(set, config, setup, &|| false)
280}
281
282/// Validates the measurements and builds the 500 ms spectra on the internal
283/// axis, returning `Err(Cancelled)` at the next IR cell once `cancelled()`
284/// reads true (validation, reference onsets and FFTs all poll it).
285pub fn prepare_until(
286    set: &MeasurementSet,
287    config: &EngineConfig,
288    setup: &Setup,
289    cancelled: &dyn Fn() -> bool,
290) -> Result<Prepared, EngineError> {
291    validate(set, cancelled)?;
292    let main = set
293        .positions
294        .iter()
295        .find(|(p, _)| *p == config.main_position)
296        .ok_or(EngineError::MissingReferencePosition {
297            position: config.main_position,
298        })?;
299    if !main.1.iter().any(|(r, _)| config.mains.contains(r)) {
300        return Err(EngineError::MissingMainMeasurement {
301            position: config.main_position,
302        });
303    }
304    let arrival_s = main
305        .1
306        .iter()
307        .map(|(r, m)| {
308            check(cancelled)?;
309            Ok((r.clone(), m.t0 + onset_index(&m.samples) as f64 / SR_F))
310        })
311        .collect::<Result<Vec<_>, EngineError>>()?;
312    let latest = arrival_s
313        .iter()
314        .filter(|(r, _)| config.mains.contains(r) || config.subs.contains(r))
315        .map(|(_, a)| *a)
316        .fold(f64::NEG_INFINITY, f64::max);
317    let base_s = arrival_s
318        .iter()
319        .map(|(_, a)| *a)
320        .fold(f64::INFINITY, f64::min);
321    let align_delay_ms = arrival_s
322        .iter()
323        .map(|(r, a)| (r.clone(), 1000.0 * (latest - a)))
324        .collect();
325    let h = build_spectra(set, base_s, &setup.sub_filters, 24000, 0.2, cancelled)?;
326    // Upstream copies the dictionaries *after* applying fixed sub filters.
327    let h0 = h.clone();
328    Ok(Prepared {
329        arrival_s,
330        align_delay_ms,
331        base_s,
332        h,
333        h0,
334    })
335}
336
337/// Apply the same last multiplication as build_spectra, sharing untouched cells.
338/// `raw` must be built without filters on the requested window and time axis.
339pub(crate) fn filtered_spectra(raw: &Spectra, filters: &[(String, SubFilter)]) -> Spectra {
340    let filters: Vec<_> = filters
341        .iter()
342        .map(|(r, f)| (r, sub_filter_response(f)))
343        .collect();
344    let mut out = raw.clone();
345    for &position in raw.positions() {
346        for role in raw.roles_at(position) {
347            if let Some((_, filter)) = filters.iter().find(|(r, _)| r.as_str() == role) {
348                let h = raw.get(position, role).expect("existing cell");
349                out.set(
350                    position,
351                    role,
352                    h.iter().zip(filter).map(|(h, f)| h * f).collect(),
353                );
354            }
355        }
356    }
357    out
358}
359
360/// `long_spectra_until` without cancellation.
361pub fn long_spectra(
362    set: &MeasurementSet,
363    prepared: &Prepared,
364    sub_filters: &[(String, SubFilter)],
365    window_ms: f64,
366) -> Result<Spectra, EngineError> {
367    long_spectra_until(set, prepared, sub_filters, window_ms, &|| false)
368}
369
370/// `long_spectra`, returning `Err(Cancelled)` at the next IR cell once
371/// `cancelled()` reads true (validation, onsets and FFTs all poll it).
372pub fn long_spectra_until(
373    set: &MeasurementSet,
374    prepared: &Prepared,
375    sub_filters: &[(String, SubFilter)],
376    window_ms: f64,
377    cancelled: &dyn Fn() -> bool,
378) -> Result<Spectra, EngineError> {
379    validate(set, cancelled)?;
380    if !window_ms.is_finite() || window_ms <= 0.0 || !prepared.base_s.is_finite() {
381        return Err(EngineError::Input(
382            "invalid long window or spectral origin".into(),
383        ));
384    }
385    let length = (window_ms / 1000.0 * SR_F) as usize;
386    // Upstream's w[-0:] broadcasting fails for fewer than ten samples.
387    for (_, m) in set.positions.iter().flat_map(|(_, rs)| rs) {
388        check(cancelled)?;
389        let a = onset_index(&m.samples).saturating_sub(96);
390        if m.samples.len().saturating_sub(a).min(length) < 10 {
391            return Err(EngineError::Input(
392                "long window needs at least ten samples".into(),
393            ));
394        }
395    }
396    build_spectra(set, prepared.base_s, sub_filters, length, 0.1, cancelled)
397}