1use 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
27pub 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 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
116fn opens(e: &score::Event, start: f64) -> bool {
118 e.kind != score::Kind::Held && e.at_secs - start <= RMS_SECS
119}
120
121fn 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
179fn 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
257struct 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
303fn 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}