diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index bdd2fcb1..3c11c844 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -43,7 +43,8 @@ impl TimeFormat { fn min_secs(s: Samples, sr: SampleRate) -> String { let neg = s < 0; - let ms_total = (sr.seconds(s.saturating_abs()) * 1000.0).floor() as i64; + // Integer maths: a float `seconds * 1000` lands just below exact millisecond positions. + let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX); let mins = ms_total / 60_000; let secs = (ms_total / 1000) % 60; let ms = ms_total % 1000; diff --git a/crates/time/tests/min_secs_exact_ms.rs b/crates/time/tests/min_secs_exact_ms.rs new file mode 100644 index 00000000..738a4bf4 --- /dev/null +++ b/crates/time/tests/min_secs_exact_ms.rs @@ -0,0 +1,36 @@ +//! Min:Secs shows exact millisecond positions exactly (no float-floor off-by-one). + +use soundcraft_time::{FrameRate, SampleRate, TempoMap, TimeFormat, format_position}; + +fn min_secs(s: i64, sr: SampleRate) -> String { + format_position(s, TimeFormat::MinSecs, sr, &TempoMap::default(), FrameRate::default(), 0, 0) +} + +fn expected(ms_total: i64) -> String { + format!("{}:{:02}.{:03}", ms_total / 60_000, (ms_total / 1000) % 60, ms_total % 1000) +} + +#[test] +fn known_positions_at_44100() { + assert_eq!(min_secs(88_641, SampleRate::HZ_44100), "0:02.010"); + assert_eq!(min_secs(89_523, SampleRate::HZ_44100), "0:02.030"); +} + +#[test] +fn negative_position_at_44100() { + assert_eq!(min_secs(-88_641, SampleRate::HZ_44100), "-0:02.010"); +} + +#[test] +fn every_exact_millisecond_displays_exactly() { + for sr in [SampleRate::HZ_44100, SampleRate::HZ_48000] { + let hz = i64::from(sr.hz()); + for ms in 0..=600_000_i64 { + if (ms * hz) % 1000 != 0 { + continue; + } + let s = ms * hz / 1000; + assert_eq!(min_secs(s, sr), expected(ms), "{hz} Hz, sample {s}"); + } + } +}