1use realfft::RealFftPlanner;
11
12use crate::audio::{BeatGrid, Division, SampleRate};
13use crate::cast;
14use crate::confidence::{ConfidenceError, Estimate, Level, Reason, ReasonCode, Signal};
15use crate::units::{Bpm, Cents, Hz, Normalized, Ratio, Seconds};
16
17use super::Window;
18use super::analytic::{FilterError, band_magnitude};
19use super::lomb::lomb_scargle;
20
21pub const TRACK_RATE_HZ: Hz = Hz::from_const(200.0);
23pub const SCAN_TOP_HZ: Hz = Hz::from_const(20.0);
26pub const SCAN_BOTTOM_HZ: Hz = Hz::from_const(0.2);
28pub const SPECTRUM_OVERSAMPLE: usize = 8;
30pub const MIN_CYCLES: f64 = 2.0;
33pub const LINE_PROMINENCE: Ratio = Ratio::from_const(LINE_PROMINENCE_VALUE);
39const LINE_PROMINENCE_VALUE: f64 = 8.0;
40pub const MIN_LINE_DEPTH: Normalized = Normalized::from_const(0.005);
43pub const FOLD_BINS: usize = 64;
45pub const BPM_SCAN: Normalized = Normalized::from_const(0.02);
47
48#[derive(Clone, Debug, PartialEq)]
50pub struct Track {
51 values: Vec<f64>,
52 rate: Hz,
53}
54
55impl Track {
56 #[must_use]
58 pub fn new(values: Vec<f64>, rate: Hz) -> Self {
59 Self { values, rate }
60 }
61
62 #[must_use]
64 pub fn values(&self) -> &[f64] {
65 &self.values
66 }
67
68 #[must_use]
70 pub fn rate(&self) -> Hz {
71 self.rate
72 }
73
74 #[must_use]
76 pub fn span(&self) -> Seconds {
77 Seconds::clamped(cast::usize_to_f64(self.values.len()) / self.rate.get())
78 }
79}
80
81#[must_use]
87pub fn scan_range(bpm: Bpm) -> (Hz, Hz) {
88 let four_bars = Division::ALL[0].rate(bpm).get();
89 let margin = 2.0_f64.powf(-2.0 / 12.0);
90 (
91 Hz::clamped(SCAN_BOTTOM_HZ.get().min(four_bars) * margin),
92 SCAN_TOP_HZ,
93 )
94}
95
96pub fn band_envelope(
104 samples: &[f64],
105 rate: SampleRate,
106 lo: Hz,
107 hi: Hz,
108) -> Result<Track, FilterError> {
109 let envelope = band_magnitude(samples, rate, lo, hi)?;
110 Ok(Track::new(
111 block_means(&envelope, rate.hz().get() / TRACK_RATE_HZ.get()),
112 TRACK_RATE_HZ,
113 ))
114}
115
116fn block_means(values: &[f64], ratio: f64) -> Vec<f64> {
120 let blocks = cast::floor_to_usize(cast::usize_to_f64(values.len()) / ratio).unwrap_or(0);
121 (0..blocks)
122 .filter_map(|j| {
123 let start = cast::floor_to_usize(cast::usize_to_f64(j) * ratio)?;
124 let end = cast::floor_to_usize(cast::usize_to_f64(j + 1) * ratio)?.min(values.len());
125 let block = values.get(start..end).filter(|block| !block.is_empty())?;
126 Some(block.iter().sum::<f64>() / cast::usize_to_f64(block.len()))
127 })
128 .collect()
129}
130
131fn detrend(values: &[f64]) -> Vec<f64> {
138 let peak = values.iter().copied().fold(0.0_f64, f64::max);
139 if peak <= 0.0 || values.len() < 4 {
140 return vec![0.0; values.len()];
141 }
142 let floor = peak * 1e-3;
143 let logs: Vec<f64> = values.iter().map(|v| v.max(floor).ln()).collect();
144 remove_polynomial(&logs, 1)
145}
146
147fn detrend_line(values: &[f64]) -> Vec<f64> {
150 remove_polynomial(values, 1)
151}
152
153fn remove_polynomial(values: &[f64], degree: usize) -> Vec<f64> {
156 let n = values.len();
157 let half = cast::usize_to_f64(n.saturating_sub(1)).max(1.0) / 2.0;
158 let x: Vec<f64> = (0..n).map(|i| cast::usize_to_f64(i) / half - 1.0).collect();
159 let mut residual = values.to_vec();
160 let mut basis: Vec<Vec<f64>> = Vec::new();
161 for power in 0..=degree {
162 let mut vector: Vec<f64> = x
163 .iter()
164 .map(|&v| v.powi(i32::try_from(power).unwrap_or(0)))
165 .collect();
166 for done in &basis {
167 let dot: f64 = vector.iter().zip(done).map(|(a, b)| a * b).sum();
168 for (value, base) in vector.iter_mut().zip(done) {
169 *value -= dot * base;
170 }
171 }
172 let norm = vector.iter().map(|v| v * v).sum::<f64>().sqrt();
173 if norm <= f64::EPSILON {
174 continue;
175 }
176 for value in &mut vector {
177 *value /= norm;
178 }
179 let dot: f64 = residual.iter().zip(&vector).map(|(a, b)| a * b).sum();
180 for (value, base) in residual.iter_mut().zip(&vector) {
181 *value -= dot * base;
182 }
183 basis.push(vector);
184 }
185 residual
186}
187
188#[derive(Clone, Debug, PartialEq)]
191pub struct ModulationSpectrum {
192 magnitudes: Vec<f64>,
193 bin_hz: f64,
194 rate: f64,
195 span: Seconds,
196 detrended: Vec<f64>,
197}
198
199impl ModulationSpectrum {
200 #[must_use]
202 pub fn of(track: &Track) -> Self {
203 let len = track.values().len();
204 let window = Window::Hann.samples(len);
205 let fft_len = (len * SPECTRUM_OVERSAMPLE).max(2);
206 let detrended = detrend(track.values());
207 let mut input: Vec<f64> = detrended.iter().zip(&window).map(|(v, w)| v * w).collect();
208 input.resize(fft_len, 0.0);
209 let fft = RealFftPlanner::<f64>::new().plan_fft_forward(fft_len);
210 let mut output = fft.make_output_vec();
211 let result = fft.process(&mut input, &mut output);
212 debug_assert!(result.is_ok(), "buffers come from the plan: {result:?}");
213 Self {
214 magnitudes: output.iter().map(|value| value.norm()).collect(),
215 bin_hz: track.rate().get() / cast::usize_to_f64(fft_len),
216 rate: track.rate().get(),
217 span: track.span(),
218 detrended,
219 }
220 }
221
222 #[must_use]
224 pub fn magnitudes(&self) -> &[f64] {
225 &self.magnitudes
226 }
227
228 #[must_use]
230 pub fn bin_width(&self) -> Hz {
231 Hz::clamped(self.bin_hz)
232 }
233
234 #[must_use]
237 pub fn magnitude_at(&self, frequency: f64) -> f64 {
238 let position = frequency / self.bin_hz;
239 let Some(lower) = cast::floor_to_usize(position) else {
240 return 0.0;
241 };
242 match (self.magnitudes.get(lower), self.magnitudes.get(lower + 1)) {
243 (Some(a), Some(b)) => a + (position - cast::usize_to_f64(lower)) * (b - a),
244 (Some(a), None) => *a,
245 _ => 0.0,
246 }
247 }
248
249 #[must_use]
252 pub fn peak(&self, lo: Hz, hi: Hz) -> Option<Hz> {
253 self.bins_in(lo, hi)
254 .max_by(|&a, &b| self.magnitudes[a].total_cmp(&self.magnitudes[b]))
255 .map(|bin| Hz::clamped(cast::usize_to_f64(bin) * self.bin_hz))
256 }
257
258 #[must_use]
261 pub fn fundamental(&self, lo: Hz, hi: Hz) -> Option<Hz> {
262 match self.pick(lo, hi) {
263 Pick::Rate { rate, .. } => Some(Hz::clamped(rate)),
264 Pick::NoLine { .. } => None,
265 }
266 }
267
268 fn pick(&self, lo: Hz, hi: Hz) -> Pick {
269 let scale = 4.0 / (self.span.get() * self.rate);
271 let bins = self.bins_in(lo, hi);
272 let guarded = bins.start().saturating_sub(1)
273 ..=(*bins.end() + 1).min(self.magnitudes.len().saturating_sub(1));
274 let points: Vec<(f64, f64)> = guarded
275 .map(|bin| {
276 (
277 cast::usize_to_f64(bin) * self.bin_hz,
278 self.magnitudes[bin] * scale,
279 )
280 })
281 .collect();
282 match pick_rate(&points) {
283 Pick::Rate { rate, prominence } => Pick::Rate {
284 rate: self.fit_rate(rate),
285 prominence,
286 },
287 none @ Pick::NoLine { .. } => none,
288 }
289 }
290
291 fn fit_rate(&self, rate: f64) -> f64 {
297 let resolution = 1.0 / self.span.get();
298 let times: Vec<Seconds> = (0..self.detrended.len())
299 .map(|i| Seconds::clamped(cast::usize_to_f64(i) / self.rate))
300 .collect();
301 let trials: Vec<Hz> = (0..=64)
302 .map(|k| rate - resolution + cast::usize_to_f64(k) * resolution / 32.0)
303 .filter(|f| *f > 0.0)
304 .map(Hz::clamped)
305 .collect();
306 let power = lomb_scargle(×, &self.detrended, &trials);
307 let best =
308 (1..power.len().saturating_sub(1)).max_by(|&a, &b| power[a].total_cmp(&power[b]));
309 best.map_or(rate, |i| {
310 refine(&[
311 (trials[i - 1].get(), power[i - 1].sqrt()),
312 (trials[i].get(), power[i].sqrt()),
313 (trials[i + 1].get(), power[i + 1].sqrt()),
314 ])
315 })
316 }
317
318 fn bins_in(&self, lo: Hz, hi: Hz) -> std::ops::RangeInclusive<usize> {
319 let first = cast::ceil_to_usize(lo.get() / self.bin_hz)
320 .unwrap_or(0)
321 .max(1);
322 let last = cast::floor_to_usize(hi.get() / self.bin_hz)
323 .unwrap_or(0)
324 .min(self.magnitudes.len().saturating_sub(1));
325 first..=last
326 }
327
328 pub fn rate(&self, lo: Hz, hi: Hz) -> Result<Estimate<Hz, Hz>, ConfidenceError> {
339 rate_estimate(self.pick(lo, hi), self.span)
340 }
341}
342
343#[derive(Clone, Copy, Debug, PartialEq)]
346pub(crate) enum Pick {
347 Rate { rate: f64, prominence: f64 },
349 NoLine { prominence: f64 },
351}
352
353pub(crate) fn pick_rate(points: &[(f64, f64)]) -> Pick {
368 let inside = points.get(1..points.len().saturating_sub(1)).unwrap_or(&[]);
369 let mut magnitudes: Vec<f64> = inside.iter().map(|p| p.1).collect();
370 magnitudes.sort_by(f64::total_cmp);
371 let median = magnitudes.get(magnitudes.len() / 2).copied().unwrap_or(0.0);
372 let candidates: Vec<(f64, f64)> = points
375 .windows(3)
376 .filter(|w| w[1].1 > w[0].1 && w[1].1 >= w[2].1)
377 .map(|w| (refine(w), w[1].1))
378 .collect();
379 let Some(&(strongest, magnitude)) = candidates.iter().max_by(|a, b| a.1.total_cmp(&b.1)) else {
380 return Pick::NoLine { prominence: 0.0 };
381 };
382 let prominence = if median > 0.0 {
385 magnitude / median
386 } else {
387 f64::MAX.sqrt()
388 };
389 if prominence < LINE_PROMINENCE.get() || magnitude < MIN_LINE_DEPTH.get() {
390 return Pick::NoLine { prominence };
391 }
392 Pick::Rate {
393 rate: strongest,
394 prominence,
395 }
396}
397
398fn refine(w: &[(f64, f64)]) -> f64 {
401 let [(f0, a), (f1, b), (f2, c)] = [w[0], w[1], w[2]];
402 if a <= 0.0 || b <= 0.0 || c <= 0.0 {
403 return f1;
404 }
405 let (a, b, c) = (a.ln(), b.ln(), c.ln());
406 let curvature = a - 2.0 * b + c;
407 if curvature >= 0.0 {
408 return f1;
409 }
410 let offset = (0.5 * (a - c) / curvature).clamp(-0.5, 0.5);
411 f1 + offset * (f2 - f0) / 2.0
412}
413
414pub(crate) fn rate_estimate(
417 pick: Pick,
418 span: Seconds,
419) -> Result<Estimate<Hz, Hz>, ConfidenceError> {
420 let (rate, prominence) = match pick {
421 Pick::Rate { rate, prominence } => (rate, prominence),
422 Pick::NoLine { prominence } => {
423 return Estimate::none(vec![Reason::new(
424 ReasonCode::LineProminence,
425 Signal::Number(prominence),
426 )?]);
427 }
428 };
429 let cycles = rate * span.get();
430 let reasons = vec![
431 Reason::new(ReasonCode::CyclesInSpan, Signal::Number(cycles))?,
432 Reason::new(ReasonCode::LineProminence, Signal::Number(prominence))?,
433 ];
434 if cycles < MIN_CYCLES {
435 Estimate::none(reasons)
436 } else {
437 let resolution = Hz::clamped(1.0 / span.get());
438 Estimate::new(Hz::clamped(rate), Level::Medium, Some(resolution), reasons)
439 }
440}
441
442#[must_use]
448pub fn cmndf_period(track: &Track, lo: Hz, hi: Hz) -> Option<Seconds> {
449 let values = detrend_line(track.values());
450 let rate = track.rate().get();
451 let min_lag = cast::ceil_to_usize(rate / hi.get()).unwrap_or(1).max(1);
452 let max_lag = cast::floor_to_usize(rate / lo.get())
453 .unwrap_or(0)
454 .min(values.len() / 2);
455 if max_lag <= min_lag {
456 return None;
457 }
458 let window = values.len() - max_lag;
459 let difference = |lag: usize| -> f64 {
460 values[..window]
461 .iter()
462 .zip(&values[lag..lag + window])
463 .map(|(a, b)| (a - b).powi(2))
464 .sum()
465 };
466 let mut running = 0.0;
467 let mut normalised = Vec::with_capacity(max_lag + 1);
468 for lag in 1..=max_lag {
469 let d = difference(lag);
470 running += d;
471 normalised.push(if running > 0.0 {
472 d * cast::usize_to_f64(lag) / running
473 } else {
474 1.0
475 });
476 }
477 let candidates = min_lag..=max_lag;
478 let dip = candidates.clone().find(|&lag| {
479 let value = normalised[lag - 1];
480 value < 0.1 && normalised.get(lag).is_none_or(|next| *next >= value)
481 });
482 let lag = dip
483 .or_else(|| candidates.min_by(|&a, &b| normalised[a - 1].total_cmp(&normalised[b - 1])))?;
484 let offset = match (normalised.get(lag.wrapping_sub(2)), normalised.get(lag)) {
487 (Some(&before), Some(&after)) if lag >= 2 => {
488 let at = normalised[lag - 1];
489 let curvature = before - 2.0 * at + after;
490 if curvature > 0.0 {
491 (0.5 * (before - after) / curvature).clamp(-0.5, 0.5)
492 } else {
493 0.0
494 }
495 }
496 _ => 0.0,
497 };
498 Some(Seconds::clamped((cast::usize_to_f64(lag) + offset) / rate))
499}
500
501#[must_use]
505pub fn fold(track: &Track, period: Seconds) -> Vec<f64> {
506 let mut sums = vec![0.0; FOLD_BINS];
507 let mut counts = vec![0_usize; FOLD_BINS];
508 let samples_per_period = period.get() * track.rate().get();
509 if samples_per_period <= 0.0 {
510 return sums;
511 }
512 for (i, value) in track.values().iter().enumerate() {
513 let phase = (cast::usize_to_f64(i) / samples_per_period).fract();
514 let bin = cast::floor_to_usize(phase * cast::usize_to_f64(FOLD_BINS))
515 .unwrap_or(0)
516 .min(FOLD_BINS - 1);
517 sums[bin] += value;
518 counts[bin] += 1;
519 }
520 sums.iter()
521 .zip(&counts)
522 .map(|(sum, &count)| {
523 if count > 0 {
524 sum / cast::usize_to_f64(count)
525 } else {
526 0.0
527 }
528 })
529 .collect()
530}
531
532#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
535pub enum ShapeKind {
536 SmoothSymmetric,
538 RampUp,
540 RampDown,
542 SquareLike,
544}
545
546#[derive(Clone, Copy, Debug, PartialEq)]
548pub struct Shape {
549 pub rise: Normalized,
551 pub plateau: Normalized,
553 pub kind: ShapeKind,
555}
556
557#[must_use]
563pub fn shape(cycle: &[f64]) -> Option<Shape> {
564 let (min_bin, min) = cycle
565 .iter()
566 .copied()
567 .enumerate()
568 .min_by(|a, b| a.1.total_cmp(&b.1))?;
569 let (max_bin, max) = cycle
570 .iter()
571 .copied()
572 .enumerate()
573 .max_by(|a, b| a.1.total_cmp(&b.1))?;
574 let range = max - min;
575 if range <= 0.0 {
576 return None;
577 }
578 let len = cycle.len();
579 let rise = cast::usize_to_f64((max_bin + len - min_bin) % len) / cast::usize_to_f64(len);
580 let plateau = cast::usize_to_f64(cycle.iter().filter(|&&v| (v - min) / range >= 0.9).count())
581 / cast::usize_to_f64(len);
582 let kind = if plateau >= 0.35 {
583 ShapeKind::SquareLike
584 } else if rise >= 0.75 {
585 ShapeKind::RampUp
586 } else if rise <= 0.25 {
587 ShapeKind::RampDown
588 } else {
589 ShapeKind::SmoothSymmetric
590 };
591 Some(Shape {
592 rise: Normalized::clamped(rise),
593 plateau: Normalized::clamped(plateau),
594 kind,
595 })
596}
597
598#[derive(Clone, Copy, Debug, PartialEq)]
600pub struct Snap {
601 pub division: Division,
603 pub bpm: Bpm,
605 pub rate: Hz,
607 pub residual: Cents,
609}
610
611#[must_use]
613pub fn snap(rate: Hz, bpm: Bpm) -> Snap {
614 let grid = BeatGrid::new(bpm, Seconds::clamped(0.0));
615 let residual = |division: Division| 1200.0 * (rate.get() / grid.rate(division).get()).log2();
616 let division = Division::ALL
617 .iter()
618 .copied()
619 .min_by(|&a, &b| residual(a).abs().total_cmp(&residual(b).abs()))
620 .unwrap_or(Division::ALL[0]);
621 Snap {
622 division,
623 bpm,
624 rate: grid.rate(division),
625 residual: Cents::clamped(residual(division)),
626 }
627}
628
629#[must_use]
633pub fn snap_scan(rate: Hz, bpm: Bpm) -> Snap {
634 let steps = cast::round_to_usize(BPM_SCAN.get() / 0.001).unwrap_or(0);
635 (0..=2 * steps)
636 .filter_map(|i| {
637 let share = (cast::usize_to_f64(i) - cast::usize_to_f64(steps)) * 0.001;
638 Bpm::new(bpm.get() * (1.0 + share)).ok()
639 })
640 .map(|tempo| snap(rate, tempo))
641 .min_by(|a, b| a.residual.get().abs().total_cmp(&b.residual.get().abs()))
642 .unwrap_or_else(|| snap(rate, bpm))
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use proptest::prelude::*;
649
650 #[test]
651 fn the_constants_pass_the_unit_check() {
652 assert_eq!(TRACK_RATE_HZ, Hz::new(200.0).unwrap());
653 assert_eq!(SCAN_TOP_HZ, Hz::new(20.0).unwrap());
654 assert_eq!(SCAN_BOTTOM_HZ, Hz::new(0.2).unwrap());
655 assert_eq!(BPM_SCAN, Normalized::new(0.02).unwrap());
656 assert_eq!(LINE_PROMINENCE, Ratio::new(8.0).unwrap());
657 assert_eq!(MIN_LINE_DEPTH, Normalized::new(0.005).unwrap());
658 }
659
660 fn track(values: Vec<f64>) -> Track {
661 Track::new(values, TRACK_RATE_HZ)
662 }
663
664 fn periodic(period: usize, cycles: usize, f: impl Fn(f64) -> f64) -> Vec<f64> {
665 (0..period * cycles)
666 .map(|i| f(cast::usize_to_f64(i % period) / cast::usize_to_f64(period)))
667 .collect()
668 }
669
670 #[test]
671 fn the_scan_starts_two_semitones_below_the_lower_of_02_hz_and_the_4_bar_rate() {
672 let margin = 2f64.powf(-2.0 / 12.0);
673 let (lo, hi) = scan_range(Bpm::new(140.0).unwrap());
674 assert!((lo.get() - 140.0 / 60.0 / 16.0 * margin).abs() < 1e-12);
675 assert_eq!(hi.get(), 20.0);
676 assert!((scan_range(Bpm::new(400.0).unwrap()).0.get() - 0.2 * margin).abs() < 1e-12);
677 }
678
679 #[test]
680 fn block_means_keep_a_fractional_ratio() {
681 assert_eq!(block_means(&[1.0, 3.0, 5.0, 7.0, 9.0], 2.5), vec![2.0, 7.0]);
682 }
683
684 #[test]
685 fn shapes_read_their_class() {
686 let sine = periodic(64, 1, |p| (std::f64::consts::TAU * p).sin());
687 let up = periodic(64, 1, |p| p);
688 let down = periodic(64, 1, |p| 1.0 - p);
689 let square = periodic(64, 1, |p| if p < 0.5 { 1.0 } else { 0.0 });
690 assert_eq!(shape(&sine).unwrap().kind, ShapeKind::SmoothSymmetric);
691 assert_eq!(shape(&up).unwrap().kind, ShapeKind::RampUp);
692 assert_eq!(shape(&down).unwrap().kind, ShapeKind::RampDown);
693 assert_eq!(shape(&square).unwrap().kind, ShapeKind::SquareLike);
694 assert!(shape(&[1.0; 64]).is_none());
695 }
696
697 #[test]
698 fn cmndf_places_the_period_between_samples() {
699 let values: Vec<f64> = (0..1600)
701 .map(|i| {
702 1.0 + 0.3 * (std::f64::consts::TAU * 7.3 * cast::usize_to_f64(i) / 200.0).sin()
703 })
704 .collect();
705 let period = cmndf_period(
706 &track(values),
707 Hz::new(1.0).unwrap(),
708 Hz::new(20.0).unwrap(),
709 )
710 .unwrap();
711 let rate = 1.0 / period.get();
712 assert!((rate - 7.3).abs() < 0.01, "{rate}");
713 }
714
715 #[test]
716 fn cmndf_finds_the_period_of_a_ramp() {
717 let ramp = track(periodic(50, 6, |p| p));
718 let period = cmndf_period(&ramp, Hz::new(1.0).unwrap(), Hz::new(20.0).unwrap()).unwrap();
719 assert!((period.get() - 0.25).abs() < 1e-4, "{period:?}");
722 }
723
724 proptest! {
725 #[test]
726 fn folding_an_exactly_periodic_envelope_reproduces_it(cycles in 2usize..8, phase in 0.0..1.0f64) {
727 let wave = |p: f64| (std::f64::consts::TAU * (p + phase)).sin() + 0.3 * (2.0 * std::f64::consts::TAU * p).cos();
729 let values = periodic(64, cycles, wave);
730 let folded = fold(&track(values), Seconds::new(64.0 / TRACK_RATE_HZ.get()).unwrap());
731 for (b, value) in folded.iter().enumerate() {
732 prop_assert!((value - wave(cast::usize_to_f64(b) / 64.0)).abs() < 1e-12);
733 }
734 }
735
736 #[test]
737 fn a_division_rate_snaps_to_itself(index in 0usize..20, bpm in 40.0..300.0f64) {
738 let bpm = Bpm::new(bpm).unwrap();
739 let division = Division::ALL[index];
740 let snapped = snap(division.rate(bpm), bpm);
741 prop_assert_eq!(snapped.division, division);
742 prop_assert!(snapped.residual.get().abs() < 1e-9);
743 }
744
745 #[test]
746 fn doubling_the_bpm_doubles_the_snapped_rate(rate in 0.2..20.0f64, bpm in 40.0..300.0f64) {
747 let once = snap(Hz::new(rate).unwrap(), Bpm::new(bpm).unwrap());
748 let twice = snap(Hz::new(2.0 * rate).unwrap(), Bpm::new(2.0 * bpm).unwrap());
749 prop_assert_eq!(once.division, twice.division);
750 prop_assert!((twice.rate.get() / once.rate.get() - 2.0).abs() < 1e-12);
751 }
752
753 #[test]
754 fn rate_times_beats_per_cycle_is_the_beat_rate(index in 0usize..20, bpm in 4.0..1000.0f64) {
755 let division = Division::ALL[index];
756 let bpm = Bpm::new(bpm).unwrap();
757 let product = division.rate(bpm).get() * division.beats_per_cycle();
758 prop_assert!((product / (bpm.get() / 60.0) - 1.0).abs() < 1e-12);
759 let doubled = Bpm::new(bpm.get() * 2.0).unwrap_or(bpm);
760 if doubled != bpm {
761 prop_assert!((division.rate(doubled).get() / division.rate(bpm).get() - 2.0).abs() < 1e-12);
762 }
763 }
764 }
765}