Skip to main content

sva_samples/measure/
panel.rs

1// Concern: the perceptual axes per note and whole: pitch, sharpness, decay, attack, beating | Non-concern: finding notes (score/), comparing two (sva-core) | IO: (planes, rate, start, spl) -> Panel
2
3use std::f64::consts::TAU;
4
5use super::loudness_pattern::{self, LoudnessPattern, sharpness};
6use super::score::{self, Score};
7use crate::fft::fft;
8
9pub const MODEL: &str = "per note of `score` and over the whole target, off `loudness_pattern` \
10                         at 10 ms frames (every channel's power averaged): `sharpness_acum` the loudness-weighted mean of each \
11                         frame's sharpness (DIN 45692's weighting over Bark); `decay_phon_per_sec` \
12                         the least-squares slope of 10 log2 of the total loudness from two frames \
13                         past its loudest until 30 phon under it, null under five frames; \
14                         `beating_phon` the rms of each band's 10 log2 specific loudness about its \
15                         quadratic Savitzky-Golay trend (up to 51 frames, the most odd the \
16                         band's frames hold), over frames within 20 phon of the band's loudest, at least 20 \
17                         of them, weighted by the band's mean loudness: what moves at 0.5 to 20 \
18                         Hz; `attack_secs` the 10 to 90 % rise of the 1 ms rms to its loudest, \
19                         from the last sample at 10 % within 300 ms before it, null where none \
20                         is, a sound held or cropped mid-note (silence stands before a strike \
21                         at the target's start); per note, `pitches_hz` each voice's f0 moved as \
22                         its series' f0 moves read off the peaks of its first 16 harmonics, each \
23                         within 1.5 frame bins of k f0 in a Hann over the note's first 300 ms \
24                         zero-padded eightfold, parabolically interpolated in dB and weighted by \
25                         power: f0 = sum w f k / sum w k^2.";
26
27/// The loudness pattern's frame every axis but pitch and attack is read at.
28pub const FRAME_SECS: f64 = 0.01;
29const DECAY_SKIP: usize = 2;
30const DECAY_RANGE_PHON: f64 = 30.0;
31const DECAY_LEAST: usize = 5;
32const BEAT_RANGE_PHON: f64 = 20.0;
33const BEAT_LEAST: usize = 20;
34const BEAT_WIDEST: usize = 51;
35const RMS_SECS: f64 = 0.001;
36const ATTACK_REACH_SECS: f64 = 0.3;
37const ATTACK_LOW: f64 = 0.1;
38const ATTACK_HIGH: f64 = 0.9;
39const PITCH_SPAN_SECS: f64 = 0.3;
40const PITCH_SETTLE_SECS: f64 = 0.015;
41const PITCH_PAD: usize = 8;
42const PITCH_SEARCH_BINS: f64 = 1.5;
43const PITCH_HARMONICS: usize = 16;
44const LOG_FLOOR_SONE: f64 = 1e-4;
45
46#[derive(Clone, Copy, Debug, Default, PartialEq)]
47pub struct Axes {
48    pub sharpness_acum: Option<f64>,
49    pub decay_phon_per_sec: Option<f64>,
50    pub beating_phon: Option<f64>,
51    pub attack_secs: Option<f64>,
52}
53
54#[derive(Clone, Debug, PartialEq)]
55pub struct Note {
56    pub at_secs: f64,
57    pub length_secs: f64,
58    /// Each voice's f0, its own harmonics refined.
59    pub pitches_hz: Vec<f64>,
60    pub axes: Axes,
61}
62
63#[derive(Clone, Debug, PartialEq)]
64pub struct Panel {
65    pub spl_db: f64,
66    pub whole: Axes,
67    pub notes: Vec<Note>,
68}
69
70pub fn measure(planes: &[&[f64]], sr: f64, start: f64, spl_db: f64) -> Panel {
71    let pattern = loudness_pattern::measure(planes, sr, start, FRAME_SECS, spl_db);
72    let score = score::measure(planes, sr, start);
73    let len = super::held(planes);
74    let x: Vec<f64> = (0..len)
75        .map(|n| planes.iter().map(|p| p[n]).sum::<f64>() / planes.len() as f64)
76        .collect();
77    let rms = Rms::of(planes, sr, start);
78    let struck = score.events.first().is_some_and(|e| opens(e, start));
79    let whole = axes(&pattern, &rms, (start, start + len as f64 / sr), struck);
80    let notes = notes(&score, &pattern, &rms, (&x, sr, start));
81    Panel {
82        spl_db,
83        whole,
84        notes,
85    }
86}
87
88fn notes(
89    score: &Score,
90    pattern: &LoudnessPattern,
91    rms: &Rms,
92    (x, sr, start): (&[f64], f64, f64),
93) -> Vec<Note> {
94    score
95        .events
96        .iter()
97        .map(|e| {
98            let a = (((e.at_secs - start + PITCH_SETTLE_SECS) * sr) as usize).min(x.len());
99            let b = ((e.next_secs.min(e.at_secs + PITCH_SPAN_SECS) - start) * sr) as usize;
100            let seg = &x[a..b.min(x.len()).max(a)];
101            let struck = opens(e, start);
102            Note {
103                at_secs: e.at_secs,
104                length_secs: e.next_secs - e.at_secs,
105                pitches_hz: e
106                    .voices
107                    .iter()
108                    .filter_map(|v| refined(seg, sr, v))
109                    .collect(),
110                axes: axes(pattern, rms, (e.at_secs, e.next_secs), struck),
111            }
112        })
113        .collect()
114}
115
116/// A strike within a millisecond of the target's start, silence before it.
117fn opens(e: &score::Event, start: f64) -> bool {
118    e.kind != score::Kind::Held && e.at_secs - start <= RMS_SECS
119}
120
121/// `struck`: the span opens on a strike at the target's start, silence before it.
122fn axes(pattern: &LoudnessPattern, rms: &Rms, (a, b): (f64, f64), struck: bool) -> Axes {
123    let frames: Vec<&loudness_pattern::PatternFrame> = pattern
124        .frames
125        .iter()
126        .filter(|f| f.t_secs >= a && f.t_secs < b)
127        .collect();
128    let weighed: f64 = frames.iter().map(|f| f.total_sone).sum();
129    let sharpness_acum = (weighed > 0.0).then(|| {
130        frames
131            .iter()
132            .filter_map(|f| sharpness(&pattern.centres_hz, &f.specific).map(|s| s * f.total_sone))
133            .sum::<f64>()
134            / weighed
135    });
136    let level: Vec<f64> = frames
137        .iter()
138        .map(|f| 10.0 * libm::log2(f.total_sone + LOG_FLOOR_SONE))
139        .collect();
140    Axes {
141        sharpness_acum,
142        decay_phon_per_sec: frames
143            .iter()
144            .any(|f| f.total_sone > pattern.threshold_sone)
145            .then(|| decay(&level, pattern.frame_secs))
146            .flatten(),
147        beating_phon: beating(&frames, pattern.threshold_sone),
148        attack_secs: rms.attack(a - rms.start, b - rms.start, struck),
149    }
150}
151
152fn decay(level: &[f64], step: f64) -> Option<f64> {
153    let (p, top) =
154        level.iter().enumerate().fold(
155            (0, f64::NEG_INFINITY),
156            |m, (i, &v)| if v > m.1 { (i, v) } else { m },
157        );
158    let tail: Vec<f64> = level
159        .iter()
160        .skip(p + DECAY_SKIP)
161        .take_while(|v| **v >= top - DECAY_RANGE_PHON)
162        .copied()
163        .collect();
164    if tail.len() < DECAY_LEAST {
165        return None;
166    }
167    let n = tail.len() as f64;
168    let mean_t = (n - 1.0) / 2.0 * step;
169    let mean_v = tail.iter().sum::<f64>() / n;
170    let (mut num, mut den) = (0.0, 0.0);
171    for (i, v) in tail.iter().enumerate() {
172        let t = i as f64 * step - mean_t;
173        num += t * (v - mean_v);
174        den += t * t;
175    }
176    Some(num / den)
177}
178
179/// Quadratic Savitzky-Golay smoothing over `width` (odd) points, the ends fitted on the
180/// first and last full windows.
181fn trend(y: &[f64], width: usize) -> Vec<f64> {
182    let half = width / 2;
183    (0..y.len())
184        .map(|i| {
185            let c = i.clamp(half, y.len() - 1 - half);
186            let (mut s, mut st, mut stt, mut sy, mut sty, mut stty, mut s3, mut s4) =
187                (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
188            for (j, v) in y.iter().enumerate().take(c + half + 1).skip(c - half) {
189                let t = j as f64 - c as f64;
190                s += 1.0;
191                st += t;
192                stt += t * t;
193                s3 += t * t * t;
194                s4 += t * t * t * t;
195                sy += v;
196                sty += t * v;
197                stty += t * t * v;
198            }
199            let m = [[s, st, stt], [st, stt, s3], [stt, s3, s4]];
200            let coef = solve3(m, [sy, sty, stty]);
201            let t = i as f64 - c as f64;
202            coef[0] + coef[1] * t + coef[2] * t * t
203        })
204        .collect()
205}
206
207fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> [f64; 3] {
208    let det = |a: [[f64; 3]; 3]| {
209        a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
210            - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
211            + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
212    };
213    let d = det(m);
214    let mut out = [0.0; 3];
215    for (k, slot) in out.iter_mut().enumerate() {
216        let mut a = m;
217        for row in 0..3 {
218            a[row][k] = r[row];
219        }
220        *slot = det(a) / d;
221    }
222    out
223}
224
225fn beating(frames: &[&loudness_pattern::PatternFrame], floor: f64) -> Option<f64> {
226    let bands = frames.first()?.specific.len();
227    let (mut sum, mut weights) = (0.0, 0.0);
228    for z in 0..bands {
229        let y: Vec<f64> = frames
230            .iter()
231            .map(|f| 10.0 * libm::log2(f.specific[z] + floor))
232            .collect();
233        let top = y.iter().copied().fold(f64::NEG_INFINITY, f64::max);
234        let held: Vec<f64> = y
235            .into_iter()
236            .filter(|v| *v > top - BEAT_RANGE_PHON)
237            .collect();
238        if held.len() < BEAT_LEAST {
239            continue;
240        }
241        let width = (held.len() / 2 * 2 - 1).min(BEAT_WIDEST);
242        let smooth = trend(&held, width);
243        let rms = (held
244            .iter()
245            .zip(&smooth)
246            .map(|(v, s)| (v - s) * (v - s))
247            .sum::<f64>()
248            / held.len() as f64)
249            .sqrt();
250        let w = frames.iter().map(|f| f.specific[z]).sum::<f64>() / frames.len() as f64;
251        sum += rms * w;
252        weights += w;
253    }
254    (weights > 0.0).then(|| sum / weights)
255}
256
257/// The rms over 1 ms about every sample, every channel's power averaged.
258struct Rms {
259    level: Vec<f64>,
260    sr: f64,
261    start: f64,
262}
263
264impl Rms {
265    fn of(planes: &[&[f64]], sr: f64, start: f64) -> Rms {
266        let len = super::held(planes);
267        let mut running = vec![0.0; len + 1];
268        for n in 0..len {
269            let p = planes.iter().map(|c| c[n] * c[n]).sum::<f64>() / planes.len() as f64;
270            running[n + 1] = running[n] + p;
271        }
272        let half = ((RMS_SECS * sr / 2.0).round() as usize).max(1);
273        let level = (0..len)
274            .map(|n| {
275                let (lo, hi) = (n.saturating_sub(half), (n + half).min(len));
276                ((running[hi] - running[lo]) / (2 * half) as f64).sqrt()
277            })
278            .collect();
279        Rms { level, sr, start }
280    }
281
282    fn attack(&self, a: f64, b: f64, struck: bool) -> Option<f64> {
283        let at = |t: f64| ((t * self.sr).max(0.0) as usize).min(self.level.len());
284        let (lo, hi) = (at(a), at(b));
285        if hi <= lo {
286            return None;
287        }
288        let p = (lo..hi).fold(lo, |m, n| if self.level[n] > self.level[m] { n } else { m });
289        let peak = self.level[p];
290        if peak <= 0.0 {
291            return None;
292        }
293        let from = p.saturating_sub((ATTACK_REACH_SECS * self.sr) as usize);
294        let quiet = (from..=p)
295            .rev()
296            .find(|&n| self.level[n] <= ATTACK_LOW * peak);
297        let on = quiet.or((struck && from == 0).then_some(0))?;
298        let top = (on..=p).find(|&n| self.level[n] >= ATTACK_HIGH * peak)?;
299        Some((top - on).max(1) as f64 / self.sr)
300    }
301}
302
303/// A voice's f0 moved as its series' f0 moves when read off its first 16 harmonics' peaks,
304/// each searched within 1.5 frame bins of k f0.
305fn refined(seg: &[f64], sr: f64, voice: &score::Voiced) -> Option<f64> {
306    if seg.len() < 2 {
307        return None;
308    }
309    let n = (seg.len() * PITCH_PAD).next_power_of_two();
310    let (mut re, mut im) = (vec![0.0; n], vec![0.0; n]);
311    let last = (seg.len() - 1) as f64;
312    for (i, v) in seg.iter().enumerate() {
313        re[i] = v * (0.5 - 0.5 * libm::cos(TAU * i as f64 / last));
314    }
315    fft(&mut re, &mut im);
316    let db: Vec<f64> = (0..=n / 2)
317        .map(|k| 10.0 * libm::log10(re[k] * re[k] + im[k] * im[k] + 1e-30))
318        .collect();
319    let bin = sr / n as f64;
320    let reach = (PITCH_SEARCH_BINS * sr / seg.len() as f64 / bin).ceil() as usize;
321    let w = |p: &score::Partial| libm::pow(10.0, p.db / 10.0);
322    let k = |p: &score::Partial| p.harmonic as f64;
323    let series = voice
324        .partials
325        .iter()
326        .map(|p| w(p) * p.hz * k(p))
327        .sum::<f64>()
328        / voice
329            .partials
330            .iter()
331            .map(|p| w(p) * k(p) * k(p))
332            .sum::<f64>();
333    let (mut num, mut den) = (0.0, 0.0);
334    for h in (1..=PITCH_HARMONICS).map(|h| h as f64) {
335        let centre = (h * series / bin).round() as usize;
336        if centre < 1 || centre + reach + 1 >= db.len() {
337            break;
338        }
339        let lo = centre.saturating_sub(reach).max(1);
340        let j = (lo..=centre + reach).fold(lo, |m, j| if db[j] > db[m] { j } else { m });
341        let (a, b, c) = (db[j - 1], db[j], db[j + 1]);
342        let curve = a - 2.0 * b + c;
343        let shift = if curve < 0.0 {
344            (0.5 * (a - c) / curve).clamp(-0.5, 0.5)
345        } else {
346            0.0
347        };
348        let power = libm::pow(10.0, b / 10.0);
349        num += power * (j as f64 + shift) * bin * h;
350        den += power * h * h;
351    }
352    (den > 0.0 && series > 0.0).then(|| voice.hz * num / den / series)
353}