1use std::collections::{BTreeMap, HashSet};
31use std::fmt;
32use std::num::NonZeroUsize;
33
34use serde::Serialize;
35use serde_json::Value;
36use sha2::{Digest, Sha256};
37
38use super::recipe::Recipe;
39use super::session::{BoxedRenderer, RESTART_AFTER, RenderOutcome, RenderSession};
40use super::{HostIdentity, Oversampling, RenderError, RenderJob, Rendered};
41use crate::audio::{Audio, AudioError, SampleRate};
42use crate::cast;
43use crate::dsp::{Summary, quantile};
44use crate::units::{Bpm, Decibels};
45
46pub const SPREAD_RUNS: usize = 20;
48pub const MIN_RUNS: usize = 3;
51pub const SPREAD_QUANTILE: f64 = 0.95;
53pub const COVERAGE_PERCENT: usize = 95;
56pub const STATISTIC: &str = "p95_pairwise_distance";
58pub const TOLERANCE: &str = "conformal_95_leave_one_out_median";
60pub const ONSET_RANGE_DB: Decibels = Decibels::from_const(40.0);
63pub const ENVELOPE_FRAMES_PER_S: u32 = 100;
65pub const ENVELOPE_RANGE_DB: Decibels = Decibels::from_const(40.0);
68pub const LEVEL_FLOOR_DB: f64 = 0.01;
71
72const SILENT_DB: f64 = -400.0;
74
75#[derive(Debug)]
77pub enum SpreadError {
78 Render(String),
80 Runs(usize),
82 Repeated {
84 distinct: usize,
86 renders: usize,
88 },
89 Silent(String),
91 SampleRate {
93 clip: SampleRate,
95 renders: SampleRate,
97 },
98 UnknownUnit(String),
100 MissingRow(&'static str),
102 Audio(AudioError),
104}
105
106impl fmt::Display for SpreadError {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 match self {
109 Self::Render(cause) => write!(f, "a render of the spread failed: {cause}"),
110 Self::Runs(runs) => write!(
111 f,
112 "a spread takes {MIN_RUNS} to {RESTART_AFTER} renders, not {runs}: fewer leave no leave-one-out median, and more pass the restart, whose fresh instance repeats the first draw. Give a count in that range"
113 ),
114 Self::Repeated { distinct, renders } => write!(
115 f,
116 "only {distinct} of {renders} renders differ, so the spread would count repeats as draws. Render the draws in sequence on one plug-in instance, with no restart between them"
117 ),
118 Self::Silent(what) => {
119 write!(f, "{what} is silent: no sample lies above the tail floor")
120 }
121 Self::SampleRate { clip, renders } => write!(
122 f,
123 "the clip runs at {clip} and the renders at {renders}. Render at the clip's sample rate"
124 ),
125 Self::UnknownUnit(path) => write!(
126 f,
127 "the layer report number {path} has no known unit. Name the field with a unit suffix, or add it to the unitless fields of render::spread"
128 ),
129 Self::Audio(error) => write!(f, "the recording cannot take its fitted gain: {error}"),
130 Self::MissingRow(output) => write!(
131 f,
132 "the spread has no row for {output}, so the comparison would pass without it. Compare with the rows of clip_rows"
133 ),
134 }
135 }
136}
137
138impl std::error::Error for SpreadError {}
139
140pub fn draw(
157 spawn: impl Fn(usize) -> Result<BoxedRenderer, RenderError> + Send + Sync + 'static,
158 job: &RenderJob,
159 count: usize,
160) -> Result<Vec<Rendered>, SpreadError> {
161 if count < MIN_RUNS || !u64::try_from(count).is_ok_and(|count| count <= RESTART_AFTER) {
162 return Err(SpreadError::Runs(count));
163 }
164 let session = RenderSession::new(NonZeroUsize::MIN, spawn);
167 let mut renders = Vec::with_capacity(count);
168 for _ in 0..count {
169 for outcome in session.render_all(std::slice::from_ref(job)) {
170 match outcome {
171 RenderOutcome::Rendered(rendered) => renders.push(rendered),
172 RenderOutcome::Failed(failure) => {
173 return Err(SpreadError::Render(
174 failure
175 .errors
176 .last()
177 .map_or_else(|| "no error".to_owned(), ToString::to_string),
178 ));
179 }
180 }
181 }
182 }
183 distinct(&renders)?;
184 Ok(renders)
185}
186
187pub fn distinct(renders: &[Rendered]) -> Result<(), SpreadError> {
194 let hashes: HashSet<[u8; 32]> = renders
195 .iter()
196 .map(|rendered| {
197 let audio = &rendered.audio;
198 let mut hash = Sha256::new();
199 hash.update(audio.sample_rate().get().to_le_bytes());
200 for channel in audio.channels() {
201 for sample in channel {
202 hash.update(sample.to_le_bytes());
203 }
204 }
205 hash.finalize().into()
206 })
207 .collect();
208 if hashes.len() == renders.len() {
209 Ok(())
210 } else {
211 Err(SpreadError::Repeated {
212 distinct: hashes.len(),
213 renders: renders.len(),
214 })
215 }
216}
217
218#[derive(Clone, Debug, PartialEq)]
224pub struct ClipMetrics {
225 pub sample_rate: SampleRate,
227 pub rms_db: f64,
229 pub peak_db: f64,
231 pub duration_s: f64,
233 pub envelope_db: Vec<f64>,
236 energy: Vec<f64>,
239}
240
241impl ClipMetrics {
242 pub fn of(audio: &Audio, what: &str) -> Result<Self, SpreadError> {
249 let silent = || SpreadError::Silent(what.to_owned());
250 let channels = audio.channels();
251 let magnitude = |frame: usize| {
252 channels
253 .iter()
254 .map(|channel| channel[frame].abs())
255 .fold(0.0, f64::max)
256 };
257 let frames = audio.frames();
258 let peak = (0..frames).map(magnitude).fold(0.0, f64::max);
259 let floor = Recipe::current().tail_floor_gain();
260 if peak <= floor {
261 return Err(silent());
262 }
263 let threshold = peak
264 * Decibels::clamped(-ONSET_RANGE_DB.get())
265 .to_amplitude_gain()
266 .unwrap_or(0.0);
267 let onset = (0..frames)
268 .find(|frame| magnitude(*frame) >= threshold)
269 .ok_or_else(silent)?;
270 let end = (onset..frames)
271 .rev()
272 .find(|frame| magnitude(*frame) > floor)
273 .ok_or_else(silent)?
274 + 1;
275 let count = cast::usize_to_f64(channels.len());
276 let mut energy = Vec::with_capacity(end - onset + 1);
277 energy.push(0.0);
278 let mut sum = 0.0;
279 for frame in onset..end {
280 sum += channels.iter().map(|c| c[frame] * c[frame]).sum::<f64>() / count;
281 energy.push(sum);
282 }
283 let mean_square =
284 |from: usize, to: usize| (energy[to] - energy[from]) / cast::usize_to_f64(to - from);
285 let length = end - onset;
286 let step = usize::try_from(audio.sample_rate().get() / ENVELOPE_FRAMES_PER_S)
287 .unwrap_or(1)
288 .max(1);
289 let envelope_db = (0..length)
290 .step_by(step)
291 .map(|from| power_db(mean_square(from, (from + step).min(length))))
292 .collect();
293 Ok(Self {
294 sample_rate: audio.sample_rate(),
295 rms_db: power_db(mean_square(0, length)),
296 peak_db: 20.0 * peak.log10(),
297 duration_s: cast::usize_to_f64(length) / f64::from(audio.sample_rate().get()),
298 envelope_db,
299 energy,
300 })
301 }
302
303 fn rms_over(&self, frames: usize) -> f64 {
305 power_db(self.energy[frames] / cast::usize_to_f64(frames))
306 }
307
308 fn frames(&self) -> usize {
309 self.energy.len() - 1
310 }
311}
312
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum ClipOutput {
316 RmsDb,
320 PeakDb,
322 PeakToRmsDb,
327 DurationS,
329 EnvelopeDistanceDb,
331}
332
333impl ClipOutput {
334 pub const ALL: [Self; 5] = [
336 Self::RmsDb,
337 Self::PeakDb,
338 Self::PeakToRmsDb,
339 Self::DurationS,
340 Self::EnvelopeDistanceDb,
341 ];
342
343 pub const SHAPE: [Self; 3] = [Self::PeakToRmsDb, Self::DurationS, Self::EnvelopeDistanceDb];
350
351 #[must_use]
353 pub fn name(self) -> &'static str {
354 match self {
355 Self::RmsDb => "rms_db",
356 Self::PeakDb => "peak_db",
357 Self::PeakToRmsDb => "peak_to_rms_db",
358 Self::DurationS => "duration_s",
359 Self::EnvelopeDistanceDb => "envelope_distance_db",
360 }
361 }
362
363 #[must_use]
365 pub fn unit(self) -> &'static str {
366 match self {
367 Self::DurationS => "s",
368 Self::RmsDb | Self::PeakDb | Self::PeakToRmsDb | Self::EnvelopeDistanceDb => "dB",
369 }
370 }
371
372 #[must_use]
376 pub fn value(self, clip: &ClipMetrics) -> Option<f64> {
377 match self {
378 Self::RmsDb => Some(clip.rms_db),
379 Self::PeakDb => Some(clip.peak_db),
380 Self::PeakToRmsDb => Some(clip.peak_db - clip.rms_db),
381 Self::DurationS => Some(clip.duration_s),
382 Self::EnvelopeDistanceDb => None,
383 }
384 }
385
386 #[must_use]
388 pub fn distance(self, a: &ClipMetrics, b: &ClipMetrics) -> f64 {
389 match self {
390 Self::RmsDb => {
391 let frames = a.frames().min(b.frames());
392 (a.rms_over(frames) - b.rms_over(frames)).abs()
393 }
394 Self::PeakDb => (a.peak_db - b.peak_db).abs(),
395 Self::PeakToRmsDb => {
396 let frames = a.frames().min(b.frames());
397 ((a.peak_db - a.rms_over(frames)) - (b.peak_db - b.rms_over(frames))).abs()
398 }
399 Self::DurationS => (a.duration_s - b.duration_s).abs(),
400 Self::EnvelopeDistanceDb => envelope_distance(a, b),
401 }
402 }
403
404 #[must_use]
407 pub fn floor(self, sample_rate: SampleRate) -> f64 {
408 match self {
409 Self::DurationS => 1.0 / f64::from(sample_rate.get()),
410 Self::RmsDb | Self::PeakDb | Self::PeakToRmsDb | Self::EnvelopeDistanceDb => {
411 LEVEL_FLOOR_DB
412 }
413 }
414 }
415}
416
417#[must_use]
425pub fn envelope_distance(a: &ClipMetrics, b: &ClipMetrics) -> f64 {
426 let loudest = a
427 .envelope_db
428 .iter()
429 .chain(&b.envelope_db)
430 .fold(SILENT_DB, |x, y| x.max(*y));
431 let bottom = loudest - ENVELOPE_RANGE_DB.get();
432 let level =
433 |envelope: &[f64], frame: usize| envelope.get(frame).copied().unwrap_or(bottom).max(bottom);
434 let frames = a.envelope_db.len().max(b.envelope_db.len());
435 let (sum, count) = (0..frames)
436 .map(|frame| (level(&a.envelope_db, frame), level(&b.envelope_db, frame)))
437 .filter(|(x, y)| x.max(*y) > bottom)
438 .fold((0.0, 0_usize), |(sum, count), (x, y)| {
439 (sum + (x - y).abs(), count + 1)
440 });
441 if count == 0 {
442 0.0
443 } else {
444 sum / cast::usize_to_f64(count)
445 }
446}
447
448#[must_use]
458pub fn tolerance_rank(count: usize) -> usize {
459 (COVERAGE_PERCENT * (count + 1)).div_ceil(100).min(count)
460}
461
462#[derive(Clone, Debug, PartialEq, Serialize)]
464pub struct SpreadRow {
465 pub output: String,
468 pub unit: String,
470 pub spread: f64,
473 pub tolerance: f64,
477 pub floor: f64,
481 pub held_out_within: usize,
485 #[serde(skip_serializing_if = "Option::is_none")]
490 pub adjacent_ratio: Option<f64>,
491 #[serde(skip_serializing_if = "Option::is_none")]
495 pub lag1_autocorrelation: Option<f64>,
496}
497
498impl SpreadRow {
499 #[must_use]
507 pub fn of(
508 output: &str,
509 unit: &str,
510 count: usize,
511 distance: impl Fn(usize, usize) -> f64,
512 values: Option<&[f64]>,
513 floor: f64,
514 ) -> Option<Self> {
515 if count < MIN_RUNS || !(floor.is_finite() && floor > 0.0) {
516 return None;
517 }
518 let mut matrix = vec![0.0; count * count];
519 for i in 0..count {
520 for j in i + 1..count {
521 let d = distance(i, j);
522 matrix[i * count + j] = d;
523 matrix[j * count + i] = d;
524 }
525 }
526 let at = |i: usize, j: usize| matrix[i * count + j];
527 let all: Vec<usize> = (0..count).collect();
528 let pairs: Vec<f64> = (0..count)
529 .flat_map(|i| (i + 1..count).map(move |j| (i, j)))
530 .map(|(i, j)| at(i, j))
531 .collect();
532 let tolerance = tolerance_of(&all, at)?;
533 let mut held_out_within = 0;
534 for held in 0..count {
535 let rest: Vec<usize> = (0..count).filter(|i| *i != held).collect();
536 let difference = median(&rest.iter().map(|j| at(held, *j)).collect::<Vec<_>>())?;
537 if difference <= tolerance_of(&rest, at)?.max(floor) {
538 held_out_within += 1;
539 }
540 }
541 let adjacent = median(&(1..count).map(|i| at(i - 1, i)).collect::<Vec<_>>())?;
542 let typical = median(&pairs)?;
543 Some(Self {
544 output: output.to_owned(),
545 unit: unit.to_owned(),
546 spread: sorted_quantile(pairs, SPREAD_QUANTILE)?,
547 tolerance,
548 floor,
549 held_out_within,
550 adjacent_ratio: (typical > 0.0).then(|| adjacent / typical),
551 lag1_autocorrelation: values.and_then(lag1_autocorrelation),
552 })
553 }
554
555 #[must_use]
558 pub fn of_values(output: &str, unit: &str, values: &[f64], floor: f64) -> Option<Self> {
559 Self::of(
560 output,
561 unit,
562 values.len(),
563 |i, j| (values[i] - values[j]).abs(),
564 Some(values),
565 floor,
566 )
567 }
568
569 #[must_use]
572 pub fn limit(&self) -> f64 {
573 self.tolerance.max(self.floor)
574 }
575}
576
577fn tolerance_of(of: &[usize], at: impl Fn(usize, usize) -> f64) -> Option<f64> {
581 let mut medians = of
582 .iter()
583 .map(|i| {
584 median(
585 &of.iter()
586 .filter(|j| *j != i)
587 .map(|j| at(*i, *j))
588 .collect::<Vec<_>>(),
589 )
590 })
591 .collect::<Option<Vec<_>>>()?;
592 medians.sort_by(f64::total_cmp);
593 medians
594 .get(tolerance_rank(of.len()).checked_sub(1)?)
595 .copied()
596}
597
598#[must_use]
607pub fn lag1_autocorrelation(values: &[f64]) -> Option<f64> {
608 let first = *values.first()?;
612 if values.iter().all(|value| value.total_cmp(&first).is_eq()) {
613 return None;
614 }
615 let steps: Vec<f64> = values.iter().map(|value| value - first).collect();
619 let mean = steps.iter().sum::<f64>() / cast::usize_to_f64(steps.len());
620 let deviation: Vec<f64> = steps.iter().map(|step| step - mean).collect();
621 let variance: f64 = deviation.iter().map(|d| d * d).sum();
622 let lagged: f64 = deviation.windows(2).map(|pair| pair[0] * pair[1]).sum();
623 (variance > 0.0 && variance.is_finite()).then(|| lagged / variance)
624}
625
626pub fn clip_rows(clips: &[ClipMetrics]) -> Result<Vec<SpreadRow>, SpreadError> {
633 let Some(rate) = shared_rate(clips)?.filter(|_| clips.len() >= MIN_RUNS) else {
634 return Err(SpreadError::Runs(clips.len()));
635 };
636 ClipOutput::ALL
637 .into_iter()
638 .map(|output| {
639 let values: Option<Vec<f64>> = clips.iter().map(|clip| output.value(clip)).collect();
640 SpreadRow::of(
641 output.name(),
642 output.unit(),
643 clips.len(),
644 |i, j| output.distance(&clips[i], &clips[j]),
645 values.as_deref(),
646 output.floor(rate),
647 )
648 .ok_or(SpreadError::MissingRow(output.name()))
649 })
650 .collect()
651}
652
653fn shared_rate(clips: &[ClipMetrics]) -> Result<Option<SampleRate>, SpreadError> {
654 let Some(first) = clips.first() else {
655 return Ok(None);
656 };
657 match clips
658 .iter()
659 .find(|clip| clip.sample_rate != first.sample_rate)
660 {
661 Some(other) => Err(SpreadError::SampleRate {
662 clip: other.sample_rate,
663 renders: first.sample_rate,
664 }),
665 None => Ok(Some(first.sample_rate)),
666 }
667}
668
669pub fn layer_rows(reports: &[Value]) -> Result<Vec<SpreadRow>, SpreadError> {
683 if reports.len() < MIN_RUNS {
684 return Err(SpreadError::Runs(reports.len()));
685 }
686 let mut paths: BTreeMap<String, (String, Vec<f64>)> = BTreeMap::new();
687 for report in reports {
688 for (path, unit, value) in layer_values(report)? {
689 paths
690 .entry(path)
691 .or_insert_with(|| (unit, Vec::new()))
692 .1
693 .push(value);
694 }
695 }
696 Ok(paths
697 .iter()
698 .filter(|(_, (_, values))| values.len() == reports.len())
699 .filter_map(|(path, (unit, values))| {
700 let floor = if unit == "dB" {
701 LEVEL_FLOOR_DB
702 } else {
703 (values.iter().fold(0.0, |max: f64, v| max.max(v.abs())) * f64::from(f32::EPSILON))
704 .max(f64::from(f32::MIN_POSITIVE))
705 };
706 SpreadRow::of_values(path, unit, values, floor)
707 })
708 .collect())
709}
710
711pub fn layer_values(report: &Value) -> Result<Vec<(String, String, f64)>, SpreadError> {
729 let mut values = Vec::new();
730 if let Some(root) = report.as_object() {
731 for (key, group) in root {
732 if !NON_LAYERS.contains(&key.as_str()) {
733 walk(key, key, group, &mut values)?;
734 }
735 }
736 }
737 Ok(values)
738}
739
740pub const NAMED_UNITS: [(&str, &str); 20] = [
746 ("bend_events", "count"),
747 ("centroid_change", "ratio"),
748 ("centroid_over_f0", "ratio"),
749 ("curve", "ratio"),
750 ("dropped", "count"),
751 ("extent_factor", "ratio"),
752 ("flatness", "ratio"),
753 ("key", "st"),
754 ("max_polyphony", "count"),
755 ("mean_polyphony", "count"),
756 ("n_notes_used", "count"),
757 ("overlap_fraction", "ratio"),
758 ("pump_beats", "count"),
759 ("pump_bpm", "BPM"),
760 ("residual", "ratio"),
761 ("rho", "ratio"),
762 ("share", "ratio"),
763 ("tail_vote_count", "count"),
764 ("tail_weight", "ratio"),
765 ("velocity", "velocity"),
766];
767
768const NON_LAYERS: [&str; 5] = ["schema", "source", "synth", "transcription", "conventions"];
770const SKIPPED: [&str; 11] = [
773 "confidence",
774 "iqr",
775 "lo_hz",
776 "hi_hz",
777 "bpm",
778 "bpm_given",
779 "bpm_refined",
780 "sample_rate_hz",
781 "ticks_per_beat",
782 "suggested_edits",
783 "unrepresentable",
784];
785
786fn walk(
787 path: &str,
788 name: &str,
789 value: &Value,
790 out: &mut Vec<(String, String, f64)>,
791) -> Result<(), SpreadError> {
792 match value {
793 Value::Object(map) => {
794 for (key, inner) in map {
795 if SKIPPED.contains(&key.as_str()) {
796 continue;
797 }
798 if key == "value" || key == "median" {
799 walk(path, name, inner, out)?;
800 } else {
801 walk(&format!("{path}.{key}"), key, inner, out)?;
802 }
803 }
804 }
805 Value::Array(items) => {
806 for (index, inner) in items.iter().enumerate() {
807 walk(&format!("{path}[{index}]"), name, inner, out)?;
808 }
809 }
810 Value::Number(number) => {
811 if let Some(number) = number.as_f64() {
812 let unit =
813 unit_of(name).ok_or_else(|| SpreadError::UnknownUnit(path.to_owned()))?;
814 out.push((path.to_owned(), unit.to_owned(), number));
815 }
816 }
817 _ => {}
818 }
819 Ok(())
820}
821
822fn unit_of(name: &str) -> Option<&'static str> {
823 if let Some((_, unit)) = NAMED_UNITS.iter().find(|(named, _)| *named == name) {
824 return Some(unit);
825 }
826 [
827 ("_dbfs", "dB"),
828 ("_db", "dB"),
829 ("_hz", "Hz"),
830 ("_deg", "deg"),
831 ("_ms", "ms"),
832 ("_s", "s"),
833 ("_st", "st"),
834 ("_ct", "ct"),
835 ]
836 .iter()
837 .find(|(suffix, _)| name.ends_with(suffix))
838 .map(|(_, unit)| *unit)
839}
840
841#[derive(Clone, Debug, PartialEq, Serialize)]
843pub struct SpreadTable {
844 pub identity: HostIdentity,
846 pub recipe_version: u32,
848 pub oversampling: Oversampling,
850 pub n: usize,
852 pub statistic: String,
854 pub tolerance: String,
856 pub coverage: f64,
859 pub preset_sha256: String,
861 #[serde(skip_serializing_if = "Option::is_none")]
864 pub midi_sha256: Option<String>,
865 pub bpm: Bpm,
867 pub outputs: Vec<SpreadRow>,
869}
870
871impl SpreadTable {
872 pub fn new(
878 renders: &[Rendered],
879 preset_sha256: String,
880 bpm: Bpm,
881 outputs: Vec<SpreadRow>,
882 ) -> Result<Self, SpreadError> {
883 let first = renders
884 .first()
885 .ok_or_else(|| SpreadError::Render("no render came back".to_owned()))?;
886 Ok(Self {
887 identity: first.record.identity.clone(),
888 recipe_version: first.record.recipe_version,
889 oversampling: first.record.oversampling,
890 n: renders.len(),
891 statistic: STATISTIC.to_owned(),
892 tolerance: TOLERANCE.to_owned(),
893 coverage: cast::usize_to_f64(tolerance_rank(renders.len()))
894 / cast::usize_to_f64(renders.len() + 1),
895 preset_sha256,
896 midi_sha256: first.record.midi_sha256.clone(),
897 bpm,
898 outputs,
899 })
900 }
901}
902
903#[derive(Clone, Debug, PartialEq, Serialize)]
905pub struct Comparison {
906 pub output: String,
908 pub unit: String,
910 #[serde(skip_serializing_if = "Option::is_none")]
912 pub clip: Option<f64>,
913 #[serde(skip_serializing_if = "Option::is_none")]
915 pub renders: Option<f64>,
916 pub difference: f64,
919 pub limit: f64,
922 pub ratio: f64,
924 pub within: bool,
926}
927
928pub fn compare(
940 clip: &ClipMetrics,
941 renders: &[ClipMetrics],
942 rows: &[SpreadRow],
943) -> Result<Vec<Comparison>, SpreadError> {
944 compare_on(&ClipOutput::ALL, clip, renders, rows)
945}
946
947pub const GAIN_RANGE_DB: f64 = 3.0;
951
952#[derive(Clone, Debug, PartialEq, Serialize)]
955pub struct FittedComparison {
956 pub gain_db: f64,
958 pub gain_beyond_range: bool,
961 pub comparisons: Vec<Comparison>,
964 pub within: bool,
966}
967
968#[must_use]
974pub fn fit_gain(clip: &ClipMetrics, renders: &[ClipMetrics]) -> Option<f64> {
975 median(
976 &renders
977 .iter()
978 .map(|render| {
979 let frames = clip.frames().min(render.frames());
980 render.rms_over(frames) - clip.rms_over(frames)
981 })
982 .collect::<Vec<_>>(),
983 )
984}
985
986pub fn compare_fitted(
998 recording: &Audio,
999 what: &str,
1000 renders: &[ClipMetrics],
1001 rows: &[SpreadRow],
1002) -> Result<FittedComparison, SpreadError> {
1003 let clip = ClipMetrics::of(recording, what)?;
1004 check_rate(&clip, renders)?;
1005 let gain_db = fit_gain(&clip, renders).ok_or(SpreadError::Runs(renders.len()))?;
1006 let amplitude = Decibels::clamped(gain_db)
1007 .to_amplitude_gain()
1008 .unwrap_or(1.0);
1009 let scaled = Audio::new(
1010 recording.sample_rate(),
1011 recording
1012 .channels()
1013 .iter()
1014 .map(|channel| channel.iter().map(|x| x * amplitude).collect())
1015 .collect(),
1016 )
1017 .map_err(SpreadError::Audio)?;
1018 let fitted = ClipMetrics::of(&scaled, what)?;
1019 let comparisons = compare_on(&ClipOutput::SHAPE, &fitted, renders, rows)?;
1020 Ok(FittedComparison {
1021 gain_db,
1022 gain_beyond_range: gain_db.abs() > GAIN_RANGE_DB,
1023 within: comparisons.iter().all(|comparison| comparison.within),
1024 comparisons,
1025 })
1026}
1027
1028fn check_rate(clip: &ClipMetrics, renders: &[ClipMetrics]) -> Result<(), SpreadError> {
1029 match shared_rate(renders)? {
1030 Some(rate) if rate != clip.sample_rate => Err(SpreadError::SampleRate {
1031 clip: clip.sample_rate,
1032 renders: rate,
1033 }),
1034 _ => Ok(()),
1035 }
1036}
1037
1038fn compare_on(
1039 outputs: &[ClipOutput],
1040 clip: &ClipMetrics,
1041 renders: &[ClipMetrics],
1042 rows: &[SpreadRow],
1043) -> Result<Vec<Comparison>, SpreadError> {
1044 check_rate(clip, renders)?;
1045 outputs
1046 .iter()
1047 .map(|&output| {
1048 let row = rows
1049 .iter()
1050 .find(|row| row.output == output.name())
1051 .ok_or(SpreadError::MissingRow(output.name()))?;
1052 let difference = median(
1053 &renders
1054 .iter()
1055 .map(|render| output.distance(clip, render))
1056 .collect::<Vec<_>>(),
1057 )
1058 .ok_or(SpreadError::Runs(renders.len()))?;
1059 let limit = row.limit();
1060 Ok(Comparison {
1061 output: row.output.clone(),
1062 unit: row.unit.clone(),
1063 clip: output.value(clip),
1064 renders: renders
1065 .iter()
1066 .map(|render| output.value(render))
1067 .collect::<Option<Vec<_>>>()
1068 .and_then(|values| median(&values)),
1069 difference,
1070 limit,
1071 ratio: difference / limit,
1072 within: difference <= limit,
1073 })
1074 })
1075 .collect()
1076}
1077
1078fn median(values: &[f64]) -> Option<f64> {
1079 Summary::of(values).map(Summary::median)
1080}
1081
1082fn sorted_quantile(mut values: Vec<f64>, q: f64) -> Option<f64> {
1083 values.sort_by(f64::total_cmp);
1084 quantile(&values, q)
1085}
1086
1087fn power_db(power: f64) -> f64 {
1088 Decibels::from_power_ratio(power).map_or(SILENT_DB, Decibels::get)
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094
1095 fn tone(gain: f64, seconds: f64, lead: usize) -> Audio {
1096 let rate = 44_100.0;
1097 let frames = cast::round_to_usize(seconds * rate).unwrap();
1098 let samples: Vec<f64> = (0..lead)
1099 .map(|_| 0.0)
1100 .chain((0..frames).map(|n| {
1101 gain * (std::f64::consts::TAU * 440.0 * cast::usize_to_f64(n) / rate).sin()
1102 }))
1103 .collect();
1104 Audio::new(
1105 SampleRate::new(44_100).unwrap(),
1106 vec![samples.clone(), samples],
1107 )
1108 .unwrap()
1109 }
1110
1111 fn metrics(gain: f64, seconds: f64, lead: usize) -> ClipMetrics {
1112 ClipMetrics::of(&tone(gain, seconds, lead), "the tone").unwrap()
1113 }
1114
1115 #[test]
1116 fn a_sine_has_its_level_peak_and_length() {
1117 let metrics = metrics(0.5, 1.0, 4410);
1118 assert!((metrics.rms_db + 9.03).abs() < 0.01, "{}", metrics.rms_db);
1120 assert!((metrics.peak_db + 6.02).abs() < 0.01, "{}", metrics.peak_db);
1121 assert!(
1123 (metrics.duration_s - 1.0).abs() < 0.001,
1124 "{}",
1125 metrics.duration_s
1126 );
1127 assert_eq!(metrics.envelope_db.len(), 100);
1128 let silent = ClipMetrics::of(&tone(0.0, 1.0, 0), "the clip").unwrap_err();
1129 assert!(
1130 silent.to_string().starts_with("the clip is silent"),
1131 "{silent}"
1132 );
1133 }
1134
1135 #[test]
1136 fn the_envelope_distance_aligns_onsets_and_counts_a_longer_tail() {
1137 let a = metrics(0.5, 1.0, 0);
1138 let late = metrics(0.5, 1.0, 22_050);
1139 assert!(envelope_distance(&a, &late) < 1e-6);
1140 let quieter = metrics(0.25, 1.0, 0);
1141 assert!((envelope_distance(&a, &quieter) - 6.02).abs() < 0.01);
1142 let longer = metrics(0.5, 2.0, 0);
1143 assert!((envelope_distance(&a, &longer) - 20.0).abs() < 0.5);
1145 }
1146
1147 #[test]
1148 fn the_rms_level_compares_over_the_common_span() {
1149 let rate = 44_100;
1152 let full: Vec<f64> = (0..rate)
1153 .map(|n| {
1154 let gain = if n < rate / 2 { 0.5 } else { 0.05 };
1155 gain * (std::f64::consts::TAU * 440.0 * f64::from(n) / f64::from(rate)).sin()
1156 })
1157 .collect();
1158 let rate = SampleRate::new(44_100).unwrap();
1159 let long = ClipMetrics::of(&Audio::new(rate, vec![full.clone()]).unwrap(), "a").unwrap();
1160 let cut = ClipMetrics::of(
1161 &Audio::new(rate, vec![full[..22_050].to_vec()]).unwrap(),
1162 "b",
1163 )
1164 .unwrap();
1165 assert!(cut.rms_db - long.rms_db > 2.0);
1166 assert!(ClipOutput::RmsDb.distance(&long, &cut) < 1e-9);
1167 }
1168
1169 #[test]
1170 fn a_layer_report_gives_its_estimates_with_units() {
1171 let report = serde_json::json!({
1172 "schema": "layers/1",
1173 "conventions": { "frame_size": 2048 },
1174 "stem": {
1175 "bpm": 120.0,
1176 "duration_s": 4.5,
1177 "rms_dbfs": { "value": -16.6, "confidence": { "spread": 1.0 } },
1178 "centroid_hz": { "median": 238.2, "iqr": 115.8 },
1179 "band_share": { "value": [{ "lo_hz": 20.0, "hi_hz": 60.0, "share": 0.1 }] },
1180 "stereo": { "full": { "rho": 0.49, "ipd_deg": 67.1 } },
1181 "crest_db": { "value": null }
1182 }
1183 });
1184 let values = layer_values(&report).unwrap();
1185 let named: Vec<(&str, &str)> = values
1186 .iter()
1187 .map(|(path, unit, _)| (path.as_str(), unit.as_str()))
1188 .collect();
1189 assert_eq!(
1190 named,
1191 [
1192 ("stem.duration_s", "s"),
1193 ("stem.rms_dbfs", "dB"),
1194 ("stem.centroid_hz", "Hz"),
1195 ("stem.band_share[0].share", "ratio"),
1196 ("stem.stereo.full.rho", "ratio"),
1197 ("stem.stereo.full.ipd_deg", "deg"),
1198 ]
1199 );
1200 }
1201
1202 #[test]
1203 fn a_field_with_no_known_unit_is_refused() {
1204 let report = serde_json::json!({ "stem": { "notes": { "value": 3 } } });
1205 let error = layer_values(&report).unwrap_err();
1206 assert!(
1207 error.to_string().contains("stem.notes has no known unit"),
1208 "{error}"
1209 );
1210 }
1211
1212 #[test]
1213 fn a_layer_number_that_one_report_lacks_gets_no_row() {
1214 let report = |rms: f64, centroid: Option<f64>| {
1215 serde_json::json!({
1216 "stem": { "rms_dbfs": { "value": rms }, "centroid_hz": { "median": centroid } }
1217 })
1218 };
1219 let reports = [
1220 report(-16.0, Some(200.0)),
1221 report(-16.2, None),
1222 report(-16.1, Some(210.0)),
1223 ];
1224 let rows = layer_rows(&reports).unwrap();
1225 let names: Vec<&str> = rows.iter().map(|row| row.output.as_str()).collect();
1226 assert_eq!(names, ["stem.rms_dbfs"]);
1227 assert_eq!(rows[0].floor, LEVEL_FLOOR_DB);
1228 assert!((rows[0].spread - 0.19).abs() < 1e-9, "{:?}", rows[0]);
1229 }
1230
1231 #[test]
1232 fn the_spread_is_the_95th_percentile_of_the_pairs() {
1233 let values: Vec<f64> = (0..20).map(f64::from).collect();
1234 let mut pairs: Vec<f64> = Vec::new();
1236 for i in 0..20 {
1237 for j in i + 1..20 {
1238 pairs.push(f64::from(j - i));
1239 }
1240 }
1241 assert_eq!(pairs.len(), 190);
1242 pairs.sort_by(f64::total_cmp);
1243 let expected = quantile(&pairs, 0.95).unwrap();
1244 let row = SpreadRow::of_values("x", "dB", &values, 1e-9).unwrap();
1245 assert!((row.spread - expected).abs() < 1e-12);
1246 assert!(SpreadRow::of_values("x", "dB", &values[..2], 1e-9).is_none());
1247 }
1248
1249 #[test]
1250 fn the_tolerance_rank_covers_95_percent_of_n_plus_one() {
1251 assert_eq!(tolerance_rank(20), 20);
1253 assert_eq!(tolerance_rank(19), 19);
1254 assert_eq!(tolerance_rank(40), 39);
1255 assert_eq!(tolerance_rank(100), 96);
1256 assert_eq!(tolerance_rank(3), 3);
1258 }
1259
1260 #[test]
1261 fn the_tolerance_is_the_leave_one_out_median_at_the_rank() {
1262 let values: Vec<f64> = (0..5).map(f64::from).collect();
1265 let row = SpreadRow::of_values("x", "dB", &values, 1e-9).unwrap();
1266 assert_eq!(row.tolerance, 2.5);
1267 assert_eq!(row.held_out_within, 3);
1270 assert_eq!(row.adjacent_ratio, Some(0.5));
1272 }
1273
1274 #[test]
1275 fn a_drift_over_the_draws_shows_in_both_diagnostics() {
1276 let rising: Vec<f64> = (0..20).map(f64::from).collect();
1279 let interleaved: Vec<f64> = (0..20)
1280 .map(|i| f64::from(if i % 2 == 0 { i / 2 } else { 19 - i / 2 }))
1281 .collect();
1282 let drift = SpreadRow::of_values("x", "dB", &rising, 1e-9).unwrap();
1283 let mixed = SpreadRow::of_values("x", "dB", &interleaved, 1e-9).unwrap();
1284 assert_eq!(
1285 (drift.spread, drift.tolerance),
1286 (mixed.spread, mixed.tolerance)
1287 );
1288 let drift_r = drift.lag1_autocorrelation.unwrap();
1289 let mixed_r = mixed.lag1_autocorrelation.unwrap();
1290 assert!(drift_r > 0.8, "{drift_r}");
1291 assert!(mixed_r < -0.8, "{mixed_r}");
1292 assert!(drift.adjacent_ratio.unwrap() < 0.2, "{drift:?}");
1293 assert!(mixed.adjacent_ratio.unwrap() > 1.5, "{mixed:?}");
1294 assert_eq!(lag1_autocorrelation(&[1.0; 5]), None);
1295 }
1296
1297 #[test]
1298 fn a_value_that_every_render_repeats_has_no_autocorrelation() {
1299 assert_eq!(lag1_autocorrelation(&[4.2; 20]), None);
1302 assert_eq!(lag1_autocorrelation(&[0.1; 3]), None);
1303 let row = SpreadRow::of_values("x", "s", &[4.2; 20], 1e-9).unwrap();
1304 assert_eq!(row.lag1_autocorrelation, None);
1305 }
1306
1307 #[test]
1308 fn values_one_step_apart_give_the_autocorrelation_of_their_pattern() {
1309 let values: Vec<f64> = (0..20)
1313 .map(|i| if i % 2 == 0 { 4.2 } else { 4.2_f64.next_up() })
1314 .collect();
1315 let r = lag1_autocorrelation(&values).unwrap();
1316 assert!((r + 0.95).abs() < 1e-12, "{r}");
1317 }
1318
1319 #[test]
1320 fn a_floor_of_zero_gives_no_row() {
1321 assert!(SpreadRow::of_values("x", "ratio", &[0.0; 4], 0.0).is_none());
1322 let zero = || serde_json::json!({ "stem": { "flatness": { "value": 0.0 } } });
1323 let rows = layer_rows(&[zero(), zero(), zero()]).unwrap();
1324 assert!(rows[0].floor > 0.0, "{rows:?}");
1325 assert!(rows[0].limit() > 0.0, "{rows:?}");
1326 }
1327
1328 #[test]
1329 fn too_few_layer_reports_fail_like_too_few_clips() {
1330 let report = || serde_json::json!({ "stem": { "rms_dbfs": { "value": -16.0 } } });
1331 let error = layer_rows(&[report(), report()]).unwrap_err();
1332 assert!(matches!(error, SpreadError::Runs(2)), "{error}");
1333 assert!(layer_rows(&[report(), report(), report()]).is_ok());
1334 }
1335
1336 #[test]
1337 fn the_peak_to_rms_distance_ignores_a_gain() {
1338 let base = metrics(0.25, 0.5, 0);
1339 let other = metrics(0.3, 0.4, 0);
1340 let distance = ClipOutput::PeakToRmsDb.distance(&base, &other);
1341 for gain in [0.501_187_233_627_272_2, 1.995_262_314_968_879_5] {
1343 let louder = metrics(0.25 * gain, 0.5, 0);
1344 assert!(
1345 (ClipOutput::PeakToRmsDb.distance(&louder, &other) - distance).abs() < 1e-9,
1346 "{gain}"
1347 );
1348 assert!(
1349 ClipOutput::PeakToRmsDb.distance(&louder, &base) < 1e-9,
1350 "{gain}"
1351 );
1352 assert!(ClipOutput::RmsDb.distance(&louder, &base) > 5.9, "{gain}");
1353 }
1354 }
1355
1356 #[test]
1357 fn a_fitted_comparison_refuses_a_spread_without_a_shape_row() {
1358 let clips: Vec<ClipMetrics> = (0..3).map(|_| metrics(0.5, 0.2, 0)).collect();
1359 let mut rows = clip_rows(&clips).unwrap();
1360 rows.retain(|row| row.output != "envelope_distance_db");
1361 let error = compare_fitted(&tone(0.4, 0.2, 0), "the recording", &clips, &rows).unwrap_err();
1362 assert!(
1363 matches!(error, SpreadError::MissingRow("envelope_distance_db")),
1364 "{error}"
1365 );
1366 }
1367
1368 #[test]
1369 fn too_few_clips_or_a_missing_row_fail_the_comparison() {
1370 let clips: Vec<ClipMetrics> = (0..2).map(|_| metrics(0.5, 0.2, 0)).collect();
1371 let error = clip_rows(&clips).unwrap_err();
1372 assert!(matches!(error, SpreadError::Runs(2)), "{error}");
1373 let clips: Vec<ClipMetrics> = (0..3).map(|_| metrics(0.5, 0.2, 0)).collect();
1374 let mut rows = clip_rows(&clips).unwrap();
1375 assert_eq!(rows.len(), ClipOutput::ALL.len());
1376 rows.remove(1);
1377 let error = compare(&clips[0], &clips, &rows).unwrap_err();
1378 assert!(
1379 matches!(error, SpreadError::MissingRow("peak_db")),
1380 "{error}"
1381 );
1382 }
1383
1384 #[test]
1385 fn a_repeated_output_compares_against_the_floor() {
1386 let row = SpreadRow::of_values("x", "dB", &[1.0; 4], LEVEL_FLOOR_DB).unwrap();
1387 assert_eq!((row.spread, row.tolerance), (0.0, 0.0));
1388 assert_eq!(row.adjacent_ratio, None);
1389 assert_eq!(row.limit(), LEVEL_FLOOR_DB);
1390 let one_sample = ClipOutput::DurationS.floor(SampleRate::new(48_000).unwrap());
1391 assert!((one_sample - 1.0 / 48_000.0).abs() < 1e-15);
1392 }
1393
1394 #[test]
1395 fn a_clip_at_another_sample_rate_does_not_compare() {
1396 let renders: Vec<ClipMetrics> = (0..3).map(|_| metrics(0.5, 0.2, 0)).collect();
1397 let rows = clip_rows(&renders).unwrap();
1398 let samples = vec![0.5; 4800];
1399 let other = ClipMetrics::of(
1400 &Audio::new(SampleRate::new(48_000).unwrap(), vec![samples]).unwrap(),
1401 "c",
1402 )
1403 .unwrap();
1404 let error = compare(&other, &renders, &rows).unwrap_err();
1405 assert!(matches!(error, SpreadError::SampleRate { .. }), "{error}");
1406 }
1407}