1use realfft::RealFftPlanner;
13use realfft::num_complex::Complex64;
14
15use super::SampleRate;
16use crate::cast;
17use crate::confidence::{ConfidenceError, Estimate, Level, NoSpread, Reason, ReasonCode, Signal};
18use crate::dsp::{Stft, StftParams, low_pass_zero_phase};
19use crate::units::{Bpm, Hz};
20
21pub const SEARCH_SHARE: f64 = 0.03;
24pub const BPM_STEP: f64 = 0.01;
26pub const MAX_BEAT_LAGS: usize = 32;
28pub const MIN_BEAT_LAGS: usize = 4;
31pub const OVERLAP_BEATS: usize = 4;
34pub const PEAK_RATIO_MIN: f64 = 1.5;
37pub const PERIODICITY_MIN: f64 = 0.4;
46pub const SPARSE_PERIODICITY_MIN: f64 = 0.75;
58const SHARP_HALF_S: f64 = 0.025;
60const PEAK_REACH_S: f64 = 0.03;
63const PEAK_RATIO_FLOOR: f64 = 1e-9;
66const SPARSE_BEATS: [usize; 2] = [2, 4];
68pub const SHARPNESS_MIN: f64 = 0.9;
78pub const SPARSE_ONSETS_MAX: f64 = 0.8;
88pub const TEMPO_CONVENTION: &str = "onset strength: the rise of each third-octave band power \
90from 30 Hz, compressed as ln(1 + P / knee) with the knee 60 dB below full scale, against the \
91frame 10 ms earlier, in Hann frames of the power of two of samples nearest 46.4 ms (2048 at 44.1 \
92and 48 kHz) on a 1 ms hop, low-passed at 20 Hz, less its \
93100 ms mean, below 10 % of its largest rise set to 0; each tempo t with |given / t - 1| at \
94most 3 % (2.91 % below to 3.09 % above the given one), in 0.01 BPM steps, scores the unbiased \
95autocorrelation summed at 1 to 32 beats; the refined tempo is used when its score is at least \
961.5 times the median score (tempo_peak) and either 0.4 of the lag count times the zero-lag \
97autocorrelation (beat_periodicity), or 0.75 of that on every second or fourth beat lag alone \
98(sparse_periodicity) with at most 0.8 peaks of the onset strength per beat at the slowest \
99tempo of the search (onsets_per_beat), \
100and at least 0.9 of the energy of the rise before its low-pass lies within 25 ms of its peaks, \
101each the largest within 30 ms (onset_sharpness); both shares stop at 1; cycles_in_span counts \
102the whole beats of the stem at the slowest tempo of the search, given / 1.03, so the longest lag \
103keeps 4 beats of overlap at every candidate; a stem of fewer than 8 keeps the given tempo";
104
105const ONSET_HOP_S: f64 = 0.001;
107const ONSET_FRAME_S: f64 = 0.0464;
110const FLUX_LAG_S: f64 = 0.01;
112const ONSET_LOW_PASS_HZ: f64 = 20.0;
116const ONSET_MEAN_S: f64 = 0.1;
118const ONSET_FLOOR_SHARE: f64 = 0.1;
123const FLUX_KNEE_DB: f64 = -60.0;
128
129const BAND_LOW_HZ: f64 = 30.0;
131const BAND_MIN_BINS: usize = 6;
134
135#[derive(Clone, Debug, PartialEq)]
137pub struct Tempo {
138 given: Bpm,
139 refined: Estimate<Bpm, NoSpread>,
140 used: Bpm,
141}
142
143impl Tempo {
144 pub fn exact(given: Bpm) -> Result<Self, ConfidenceError> {
150 Ok(Self {
151 given,
152 refined: Estimate::none(vec![Reason::new(ReasonCode::BpmExact, Signal::Flag(true))?])?,
153 used: given,
154 })
155 }
156
157 pub fn refine(mono: &[f64], rate: SampleRate, given: Bpm) -> Result<Self, ConfidenceError> {
174 let keep = |reason: Reason| -> Result<Self, ConfidenceError> {
175 Ok(Self {
176 given,
177 refined: Estimate::none(vec![reason])?,
178 used: given,
179 })
180 };
181 let hop = cast::round_to_usize(f64::from(rate.get()) * ONSET_HOP_S)
182 .unwrap_or(1)
183 .max(1);
184 let fps = f64::from(rate.get()) / cast::usize_to_f64(hop);
185 let candidates = candidates(given);
186 let (Some(&slowest), Some(_)) = (candidates.first(), candidates.last()) else {
187 return keep(Reason::new(ReasonCode::TempoPeak, Signal::Number(0.0))?);
188 };
189 let flux = onset_strength(mono, rate, hop);
190 let onsets = smooth(&flux, fps);
191 let beats = cast::usize_to_f64(onsets.len()) / fps * slowest / 60.0;
192 let whole_beats = cast::floor_to_usize(beats).unwrap_or(0);
193 let lags = whole_beats.saturating_sub(OVERLAP_BEATS).min(MAX_BEAT_LAGS);
194 if lags < MIN_BEAT_LAGS {
195 return keep(Reason::new(
196 ReasonCode::CyclesInSpan,
197 Signal::Count(u64::try_from(whole_beats).unwrap_or(u64::MAX)),
198 )?);
199 }
200 let longest = cast::usize_to_f64(lags) * 60.0 / slowest * fps;
201 let acf = autocorrelation(&onsets, cast::ceil_to_usize(longest).unwrap_or(0) + 2);
202 let scores: Vec<f64> = candidates
203 .iter()
204 .map(|&bpm| {
205 let period = 60.0 / bpm * fps;
206 (1..=lags)
207 .map(|k| interpolate(&acf, cast::usize_to_f64(k) * period))
208 .sum()
209 })
210 .collect();
211 let Some((best_at, &best)) = scores.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))
212 else {
213 return keep(Reason::new(ReasonCode::TempoPeak, Signal::Number(0.0))?);
214 };
215 if best.is_nan() || best <= 0.0 {
216 return keep(Reason::new(ReasonCode::TempoPeak, Signal::Number(0.0))?);
217 }
218 let ratio = best / median(&scores).max(best * PEAK_RATIO_FLOOR);
221 let periodicity = (best / (cast::usize_to_f64(lags) * acf[0])).min(1.0);
224 let period = 60.0 / candidates[best_at] * fps;
225 let sparse = SPARSE_BEATS
226 .iter()
227 .filter(|&&every| lags / every >= MIN_BEAT_LAGS)
228 .map(|&every| {
229 let count = lags / every;
230 let sum: f64 = (1..=count)
231 .map(|j| interpolate(&acf, cast::usize_to_f64(j * every) * period))
232 .sum();
233 sum / (cast::usize_to_f64(count) * acf[0])
234 })
235 .fold(0.0, f64::max)
236 .min(1.0);
237 let sharp = sharpness(&flux, fps);
238 let per_beat = cast::usize_to_f64(peaks(&onsets, fps).len()) / beats;
239 let refined_bpm = Bpm::clamped(candidates[best_at]);
240 let trusted = ratio >= PEAK_RATIO_MIN
241 && sharp >= SHARPNESS_MIN
242 && (periodicity >= PERIODICITY_MIN
243 || (sparse >= SPARSE_PERIODICITY_MIN && per_beat <= SPARSE_ONSETS_MAX));
244 let reasons = vec![
245 Reason::new(ReasonCode::TempoPeak, Signal::Number(ratio))?,
246 Reason::new(ReasonCode::BeatPeriodicity, Signal::Number(periodicity))?,
247 Reason::new(ReasonCode::SparsePeriodicity, Signal::Number(sparse))?,
248 Reason::new(ReasonCode::OnsetSharpness, Signal::Number(sharp))?,
249 Reason::new(ReasonCode::OnsetsPerBeat, Signal::Number(per_beat))?,
250 Reason::new(
251 ReasonCode::CyclesInSpan,
252 Signal::Count(u64::try_from(whole_beats).unwrap_or(u64::MAX)),
253 )?,
254 ];
255 Ok(Self {
256 given,
257 refined: Estimate::new(
258 refined_bpm,
259 if trusted { Level::High } else { Level::Low },
260 None,
261 reasons,
262 )?,
263 used: if trusted { refined_bpm } else { given },
264 })
265 }
266
267 #[must_use]
269 pub fn given(&self) -> Bpm {
270 self.given
271 }
272
273 #[must_use]
276 pub fn refined(&self) -> &Estimate<Bpm, NoSpread> {
277 &self.refined
278 }
279
280 #[must_use]
282 pub fn used(&self) -> Bpm {
283 self.used
284 }
285
286 #[must_use]
289 pub fn trusted(&self) -> Option<Bpm> {
290 (self.refined.confidence().level() == Some(Level::High))
291 .then(|| self.refined.value().copied())
292 .flatten()
293 }
294
295 #[must_use]
297 pub fn summary(&self) -> String {
298 let given = self.given.get();
299 let reasons = self.refined.confidence().reasons();
300 let signal = |code| {
301 reasons
302 .iter()
303 .find(|reason| reason.code() == code)
304 .map(|reason| reason.value())
305 };
306 match (self.refined.value(), signal(ReasonCode::TempoPeak)) {
307 (Some(refined), Some(Signal::Number(ratio))) if self.trusted().is_some() => format!(
308 "tempo: {} BPM, refined from --bpm {given} (peak ratio {ratio:.2})",
309 refined.get()
310 ),
311 (Some(refined), Some(Signal::Number(ratio))) => {
312 let share = |code| match signal(code) {
313 Some(Signal::Number(share)) => share,
314 _ => 0.0,
315 };
316 format!(
317 "tempo: {given} BPM as given; the refined {} BPM is not trusted (peak ratio {ratio:.2}, needs {PEAK_RATIO_MIN}; onset sharpness {:.2}, needs {SHARPNESS_MIN}; beat periodicity {:.2}, needs {PERIODICITY_MIN}, or {:.2} at 2 or 4 beats with {:.2} onsets per beat, needs {SPARSE_PERIODICITY_MIN} with {SPARSE_ONSETS_MAX} at most)",
318 refined.get(),
319 share(ReasonCode::OnsetSharpness),
320 share(ReasonCode::BeatPeriodicity),
321 share(ReasonCode::SparsePeriodicity),
322 share(ReasonCode::OnsetsPerBeat),
323 )
324 }
325 _ if signal(ReasonCode::BpmExact).is_some() => {
326 format!("tempo: {given} BPM as given (--bpm-exact)")
327 }
328 _ => match (
329 signal(ReasonCode::CyclesInSpan),
330 candidates(self.given).first(),
331 ) {
332 (Some(Signal::Count(0)), Some(slowest)) => format!(
333 "tempo: {given} BPM as given; the stem is shorter than one beat at {slowest} BPM, the slowest tempo of the search"
334 ),
335 (Some(Signal::Count(beats)), Some(slowest)) => format!(
336 "tempo: {given} BPM as given; the stem holds {beats} whole beat{} at {slowest} BPM, the slowest tempo of the search, and refining needs {}",
337 if beats == 1 { "" } else { "s" },
338 MIN_BEAT_LAGS + OVERLAP_BEATS,
339 ),
340 _ => format!("tempo: {given} BPM as given; the stem has no onsets to refine it"),
341 },
342 }
343 }
344}
345
346fn candidates(given: Bpm) -> Vec<f64> {
349 let low = (given.get() / (1.0 + SEARCH_SHARE)).max(4.0);
350 let high = (given.get() / (1.0 - SEARCH_SHARE)).min(1000.0);
351 let per_bpm = (1.0 / BPM_STEP).round();
353 let first = cast::ceil_to_usize(low * per_bpm - 1e-9).unwrap_or(0);
354 let last = cast::floor_to_usize(high * per_bpm + 1e-9).unwrap_or(0);
355 (first..=last)
356 .map(|step| cast::usize_to_f64(step) / per_bpm)
357 .collect()
358}
359
360fn onset_strength(mono: &[f64], rate: SampleRate, hop: usize) -> Vec<f64> {
365 let frame_len = onset_frame_len(rate);
366 let peak = mono.iter().fold(0.0_f64, |m, x| m.max(x.abs()));
367 let Ok(params) = StftParams::new(frame_len).and_then(|p| p.with_hop(hop)) else {
368 return Vec::new();
369 };
370 let frames = params.frame_count(mono.len());
371 if peak == 0.0 {
372 return vec![0.0; frames];
373 }
374 let full_scale = peak * cast::usize_to_f64(frame_len) / 4.0;
376 let knee = full_scale * full_scale * 10_f64.powf(FLUX_KNEE_DB / 10.0);
377 let bands = bands(params.bins(), params.bin_width(rate).get());
378 let lag = cast::round_to_usize(FLUX_LAG_S * f64::from(rate.get()) / cast::usize_to_f64(hop))
379 .unwrap_or(1)
380 .max(1);
381 let mut stft = Stft::new(params);
382 let mut history: Vec<Vec<f64>> = Vec::with_capacity(lag + 1);
383 let mut strength = Vec::with_capacity(frames);
384 for frame in 0..frames {
385 let spectrum = stft.frame(mono, frame);
386 let levels: Vec<f64> = bands
387 .iter()
388 .map(|&(lo, hi)| {
389 let power: f64 = spectrum[lo..hi].iter().map(Complex64::norm_sqr).sum();
390 (power / knee).ln_1p()
391 })
392 .collect();
393 let rise = history
394 .first()
395 .filter(|_| history.len() == lag)
396 .map_or(0.0, |earlier| {
397 levels
398 .iter()
399 .zip(earlier)
400 .map(|(now, then)| (now - then).max(0.0))
401 .sum()
402 });
403 strength.push(rise);
404 if history.len() == lag {
405 history.remove(0);
406 }
407 history.push(levels);
408 }
409 strength
410}
411
412fn onset_frame_len(rate: SampleRate) -> usize {
415 let wanted = f64::from(rate.get()) * ONSET_FRAME_S;
416 let up = cast::ceil_to_usize(wanted)
417 .unwrap_or(4)
418 .max(4)
419 .next_power_of_two();
420 if cast::usize_to_f64(up) - wanted > wanted - cast::usize_to_f64(up / 2) {
421 (up / 2).max(4)
422 } else {
423 up
424 }
425}
426
427fn bands(bins: usize, bin_hz: f64) -> Vec<(usize, usize)> {
432 let mut bands = Vec::new();
433 let mut lo = cast::ceil_to_usize(BAND_LOW_HZ / bin_hz)
434 .unwrap_or(1)
435 .max(1);
436 while lo < bins {
437 let third =
438 cast::ceil_to_usize(cast::usize_to_f64(lo) * 2_f64.powf(1.0 / 3.0)).unwrap_or(bins);
439 let hi = third.max(lo + BAND_MIN_BINS).min(bins);
440 bands.push((lo, hi));
441 lo = hi;
442 }
443 bands
444}
445
446fn smooth(onsets: &[f64], fps: f64) -> Vec<f64> {
451 let rate = cast::round_to_usize(fps)
452 .and_then(|fps| u32::try_from(fps).ok())
453 .and_then(|fps| SampleRate::new(fps).ok());
454 let smoothed = rate
455 .and_then(|rate| low_pass_zero_phase(onsets, rate, Hz::from_const(ONSET_LOW_PASS_HZ)).ok());
456 let smoothed = smoothed.unwrap_or_else(|| onsets.to_vec());
457 let half = cast::round_to_usize(ONSET_MEAN_S * fps / 2.0).unwrap_or(0);
458 let mut prefix = Vec::with_capacity(smoothed.len() + 1);
459 prefix.push(0.0);
460 for value in &smoothed {
461 prefix.push(prefix[prefix.len() - 1] + value);
462 }
463 let rises: Vec<f64> = (0..smoothed.len())
464 .map(|i| {
465 let (lo, hi) = (i.saturating_sub(half), (i + half + 1).min(smoothed.len()));
466 let mean = (prefix[hi] - prefix[lo]) / cast::usize_to_f64(hi - lo);
467 (smoothed[i] - mean).max(0.0)
468 })
469 .collect();
470 let floor = ONSET_FLOOR_SHARE * rises.iter().fold(0.0_f64, |m, &v| m.max(v));
471 rises
472 .into_iter()
473 .map(|v| if v >= floor { v } else { 0.0 })
474 .collect()
475}
476
477fn sharpness(onsets: &[f64], fps: f64) -> f64 {
482 let total: f64 = onsets.iter().map(|v| v * v).sum();
483 if total <= 0.0 {
484 return 0.0;
485 }
486 let half = cast::round_to_usize(SHARP_HALF_S * fps).unwrap_or(0);
487 let mut near = vec![false; onsets.len()];
488 for i in peaks(onsets, fps) {
489 let end = (i + half + 1).min(onsets.len());
490 near[i.saturating_sub(half)..end].fill(true);
491 }
492 let peaked: f64 = onsets
493 .iter()
494 .zip(&near)
495 .filter(|&(_, &near)| near)
496 .map(|(v, _)| v * v)
497 .sum();
498 peaked / total
499}
500
501fn peaks(onsets: &[f64], fps: f64) -> Vec<usize> {
504 let reach = cast::round_to_usize(PEAK_REACH_S * fps).unwrap_or(1).max(1);
505 (0..onsets.len())
506 .filter(|&i| {
507 let value = onsets[i];
508 let (lo, hi) = (i.saturating_sub(reach), (i + reach + 1).min(onsets.len()));
509 value > 0.0
510 && onsets[lo..i].iter().all(|&other| other < value)
511 && onsets[i + 1..hi].iter().all(|&other| other <= value)
512 })
513 .collect()
514}
515
516fn autocorrelation(x: &[f64], lags: usize) -> Vec<f64> {
519 let lags = lags.min(x.len());
520 if lags == 0 {
521 return Vec::new();
522 }
523 let len = (x.len() + lags).next_power_of_two();
524 let mut planner = RealFftPlanner::<f64>::new();
525 let forward = planner.plan_fft_forward(len);
526 let inverse = planner.plan_fft_inverse(len);
527 let mut input = forward.make_input_vec();
528 input[..x.len()].copy_from_slice(x);
529 let mut spectrum = forward.make_output_vec();
530 if forward.process(&mut input, &mut spectrum).is_err() {
531 return vec![0.0; lags];
532 }
533 for bin in &mut spectrum {
534 *bin = Complex64::new(bin.norm_sqr(), 0.0);
535 }
536 let mut out = inverse.make_output_vec();
537 if inverse.process(&mut spectrum, &mut out).is_err() {
538 return vec![0.0; lags];
539 }
540 let scale = cast::usize_to_f64(len);
541 out.iter()
542 .take(lags)
543 .enumerate()
544 .map(|(lag, sum)| sum / scale / cast::usize_to_f64(x.len() - lag))
545 .collect()
546}
547
548fn interpolate(values: &[f64], at: f64) -> f64 {
550 let Some(index) = cast::floor_to_usize(at) else {
551 return 0.0;
552 };
553 let fraction = at - cast::usize_to_f64(index);
554 match (values.get(index), values.get(index + 1)) {
555 (Some(a), Some(b)) => a + (b - a) * fraction,
556 (Some(a), None) => *a,
557 _ => 0.0,
558 }
559}
560
561fn median(values: &[f64]) -> f64 {
563 let mut sorted = values.to_vec();
564 sorted.sort_by(f64::total_cmp);
565 let n = sorted.len();
566 match n {
567 0 => 0.0,
568 _ if n % 2 == 1 => sorted[n / 2],
569 _ => f64::midpoint(sorted[n / 2 - 1], sorted[n / 2]),
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 fn bpm(value: f64) -> Bpm {
578 Bpm::new(value).unwrap()
579 }
580
581 #[test]
582 fn the_search_holds_every_tempo_the_given_one_lies_within_three_percent_of() {
583 let steps = candidates(bpm(120.0));
584 assert!((steps[0] - 116.51).abs() < 1e-9, "{}", steps[0]);
585 assert!((steps[steps.len() - 1] - 123.71).abs() < 1e-9);
586 assert!(120.0 / steps[0] - 1.0 <= SEARCH_SHARE);
588 assert!(1.0 - 120.0 / steps[steps.len() - 1] <= SEARCH_SHARE);
589 }
590
591 #[test]
592 fn the_search_never_reaches_half_or_double_the_tempo() {
593 for given in [4.0, 60.0, 120.0, 173.5, 1000.0] {
594 let steps = candidates(bpm(given));
595 assert!(steps.iter().all(|&t| t > given * 0.9 && t < given * 1.1));
596 assert!(steps.iter().all(|&t| (4.0..=1000.0).contains(&t)));
597 }
598 }
599
600 #[test]
601 fn the_autocorrelation_divides_each_lag_by_its_products() {
602 let acf = autocorrelation(&[1.0, 1.0, 1.0, 1.0], 4);
603 for value in acf {
604 assert!((value - 1.0).abs() < 1e-12, "{value}");
605 }
606 }
607
608 #[test]
609 fn interpolation_reads_between_samples() {
610 assert!((interpolate(&[0.0, 2.0], 0.25) - 0.5).abs() < 1e-12);
611 assert!(interpolate(&[0.0, 2.0], 5.0).abs() < 1e-12);
612 }
613
614 #[test]
615 fn exact_keeps_the_given_tempo() {
616 let tempo = Tempo::exact(bpm(120.0)).unwrap();
617 assert_eq!(tempo.used(), bpm(120.0));
618 assert!(tempo.refined().value().is_none());
619 assert_eq!(tempo.summary(), "tempo: 120 BPM as given (--bpm-exact)");
620 }
621
622 #[test]
623 fn silence_keeps_the_given_tempo() {
624 let rate = SampleRate::new(8000).unwrap();
625 let tempo = Tempo::refine(&vec![0.0; 8000 * 6], rate, bpm(120.0)).unwrap();
626 assert_eq!(tempo.used(), bpm(120.0));
627 assert!(tempo.refined().value().is_none());
628 assert_eq!(
629 tempo.summary(),
630 "tempo: 120 BPM as given; the stem has no onsets to refine it"
631 );
632 }
633
634 #[test]
635 fn a_short_stem_keeps_the_given_tempo() {
636 let rate = SampleRate::new(8000).unwrap();
637 let clicks: Vec<f64> = (0..8000 * 3)
639 .map(|n| if n % 4000 == 0 { 1.0 } else { 0.0 })
640 .collect();
641 let tempo = Tempo::refine(&clicks, rate, bpm(120.0)).unwrap();
642 assert_eq!(tempo.used(), bpm(120.0));
643 assert_eq!(
645 tempo.summary(),
646 "tempo: 120 BPM as given; the stem holds 5 whole beats at 116.51 BPM, the slowest \
647tempo of the search, and refining needs 8"
648 );
649 }
650
651 #[test]
652 fn a_stem_of_8_beats_at_the_given_tempo_names_the_slowest_one() {
653 let rate = SampleRate::new(8000).unwrap();
654 let clicks: Vec<f64> = (0..8000 * 4)
657 .map(|n| if n % 4000 == 0 { 1.0 } else { 0.0 })
658 .collect();
659 let tempo = Tempo::refine(&clicks, rate, bpm(120.0)).unwrap();
660 assert_eq!(
661 tempo.summary(),
662 "tempo: 120 BPM as given; the stem holds 7 whole beats at 116.51 BPM, the slowest \
663tempo of the search, and refining needs 8"
664 );
665 }
666
667 #[test]
668 fn the_onset_frame_is_the_power_of_two_nearest_46_ms() {
669 let frame = |hz| onset_frame_len(SampleRate::new(hz).unwrap());
670 assert_eq!(frame(44_100), 2048);
671 assert_eq!(frame(48_000), 2048);
672 assert_eq!(frame(22_050), 1024);
673 assert_eq!(frame(96_000), 4096);
674 }
675
676 #[test]
677 fn a_stem_under_one_beat_says_so() {
678 let rate = SampleRate::new(8000).unwrap();
679 let tempo = Tempo::refine(&[0.5; 1600], rate, bpm(120.0)).unwrap();
680 assert_eq!(
681 tempo.summary(),
682 "tempo: 120 BPM as given; the stem is shorter than one beat at 116.51 BPM, the \
683slowest tempo of the search"
684 );
685 }
686
687 #[test]
688 fn an_untrusted_tempo_equal_to_the_given_one_is_not_called_refined() {
689 let reasons = vec![
690 Reason::new(ReasonCode::TempoPeak, Signal::Number(322.03)).unwrap(),
691 Reason::new(ReasonCode::BeatPeriodicity, Signal::Number(0.24)).unwrap(),
692 Reason::new(ReasonCode::SparsePeriodicity, Signal::Number(0.5)).unwrap(),
693 Reason::new(ReasonCode::OnsetSharpness, Signal::Number(1.0)).unwrap(),
694 Reason::new(ReasonCode::OnsetsPerBeat, Signal::Number(0.25)).unwrap(),
695 ];
696 let tempo = Tempo {
697 given: bpm(120.0),
698 refined: Estimate::new(bpm(120.0), Level::Low, None, reasons).unwrap(),
699 used: bpm(120.0),
700 };
701 assert_eq!(tempo.trusted(), None);
702 assert_eq!(
703 tempo.summary(),
704 "tempo: 120 BPM as given; the refined 120 BPM is not trusted (peak ratio 322.03, \
705needs 1.5; onset sharpness 1.00, needs 0.9; beat periodicity 0.24, needs 0.4, or 0.50 at 2 \
706or 4 beats with 0.25 onsets per beat, needs 0.75 with 0.8 at most)"
707 );
708 }
709}