Files
soundcraft/crates/time/src/timecode.rs
T
2026-10-13 04:29:45 -04:00

266 lines
9.3 KiB
Rust

//! SMPTE timecode and Feet+Frames.
use crate::{SampleRate, Samples, TimeError};
/// Session timecode rates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum FrameRate {
Fps23_976,
#[default]
Fps24,
Fps25,
Fps29_97,
Fps29_97Drop,
Fps30,
Fps47_952,
Fps48,
Fps50,
Fps59_94,
Fps59_94Drop,
Fps60,
Fps100,
Fps119_88,
Fps120,
}
impl FrameRate {
pub const ALL: [FrameRate; 15] = [
FrameRate::Fps23_976,
FrameRate::Fps24,
FrameRate::Fps25,
FrameRate::Fps29_97,
FrameRate::Fps29_97Drop,
FrameRate::Fps30,
FrameRate::Fps47_952,
FrameRate::Fps48,
FrameRate::Fps50,
FrameRate::Fps59_94,
FrameRate::Fps59_94Drop,
FrameRate::Fps60,
FrameRate::Fps100,
FrameRate::Fps119_88,
FrameRate::Fps120,
];
/// Real frames per second as a rational (num, den).
pub fn rational(self) -> (i64, i64) {
match self {
FrameRate::Fps23_976 => (24_000, 1001),
FrameRate::Fps24 => (24, 1),
FrameRate::Fps25 => (25, 1),
FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001),
FrameRate::Fps30 => (30, 1),
FrameRate::Fps47_952 => (48_000, 1001),
FrameRate::Fps48 => (48, 1),
FrameRate::Fps50 => (50, 1),
FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001),
FrameRate::Fps60 => (60, 1),
FrameRate::Fps100 => (100, 1),
FrameRate::Fps119_88 => (120_000, 1001),
FrameRate::Fps120 => (120, 1),
}
}
/// Frames per labelled second (the counting base).
pub fn nominal(self) -> i64 {
let (n, d) = self.rational();
(n + d / 2) / d
}
pub fn is_drop(self) -> bool {
matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop)
}
pub fn label(self) -> &'static str {
match self {
FrameRate::Fps23_976 => "23.976",
FrameRate::Fps24 => "24",
FrameRate::Fps25 => "25",
FrameRate::Fps29_97 => "29.97",
FrameRate::Fps29_97Drop => "29.97 Drop",
FrameRate::Fps30 => "30",
FrameRate::Fps47_952 => "47.952",
FrameRate::Fps48 => "48",
FrameRate::Fps50 => "50",
FrameRate::Fps59_94 => "59.94",
FrameRate::Fps59_94Drop => "59.94 Drop",
FrameRate::Fps60 => "60",
FrameRate::Fps100 => "100",
FrameRate::Fps119_88 => "119.88",
FrameRate::Fps120 => "120",
}
}
/// Whole frame index at a sample position (floored).
pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 {
let (n, d) = self.rational();
let num = i128::from(s) * i128::from(n);
let den = i128::from(sr.hz()) * i128::from(d);
if den == 0 {
return 0;
}
i64::try_from(num.div_euclid(den)).unwrap_or(0)
}
/// Sample position of the start of frame `f`.
pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples {
let (n, d) = self.rational();
if n == 0 {
return 0;
}
let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
}
/// Samples per frame (fractional).
pub fn samples_per_frame(self, sr: SampleRate) -> f64 {
let (n, d) = self.rational();
sr.as_f64() * d as f64 / n as f64
}
}
/// A timecode label.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Timecode {
pub negative: bool,
pub hours: i64,
pub minutes: i64,
pub seconds: i64,
pub frames: i64,
}
impl Timecode {
/// Label for an absolute frame count.
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
let negative = frames < 0;
let mut f = frames.saturating_abs();
let base = rate.nominal().max(1);
if rate.is_drop() {
// Drop 2 (or 4) labels each minute except every tenth.
let drop = if base >= 60 { 4 } else { 2 };
let per_10min = base * 600 - drop * 9;
let per_min = base * 60 - drop;
let tens = f / per_10min;
let rem = f % per_10min;
let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens };
f += extra;
}
let frames_l = f % base;
let total_secs = f / base;
Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l }
}
/// Absolute frame count of this label.
pub fn to_frames(&self, rate: FrameRate) -> i64 {
let base = rate.nominal().max(1);
let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes);
let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames);
if rate.is_drop() {
let drop = if base >= 60 { 4 } else { 2 };
f -= drop * (total_min - total_min / 10);
}
let f = if self.negative { -f } else { f };
i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX })
}
pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode {
Timecode::from_frames(rate.frames_at(s, sr), rate)
}
pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples {
rate.samples_at_frame(self.to_frames(rate), sr)
}
pub fn format(&self, rate: FrameRate) -> String {
let sep = if rate.is_drop() { ';' } else { ':' };
let sign = if self.negative { "-" } else { "" };
format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames)
}
/// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned).
pub fn parse(text: &str) -> Result<Timecode, TimeError> {
let t = text.trim();
let (negative, t) = match t.strip_prefix('-') {
Some(r) => (true, r),
None => (false, t),
};
let parts: Vec<&str> = t.split([':', ';', '.']).collect();
if parts.is_empty() || parts.len() > 4 {
return Err(TimeError::Parse(text.to_string(), "timecode"));
}
let mut nums = [0i64; 4];
let offset = 4 - parts.len();
for (i, p) in parts.iter().enumerate() {
let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?;
if v < 0 {
return Err(TimeError::Parse(text.to_string(), "timecode"));
}
if let Some(slot) = nums.get_mut(offset + i) {
*slot = v;
}
}
Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] })
}
}
/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0.
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) {
let f = rate.frames_at(s, sr).saturating_add(start);
(f.div_euclid(16), f.rem_euclid(16))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_drop_round_trip() {
let sr = SampleRate::HZ_48000;
let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr);
assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00");
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661);
}
#[test]
fn drop_frame_labels() {
let r = FrameRate::Fps29_97Drop;
// Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02.
assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02");
assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29");
// Minute 10 keeps its labels.
assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00");
for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] {
assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}");
}
}
#[test]
fn huge_timecode_saturates_instead_of_overflowing() {
let sr = SampleRate::HZ_48000;
for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] {
let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap();
assert_eq!(tc.to_samples(r, sr), i64::MAX);
let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap();
assert_eq!(tc.to_samples(r, sr), i64::MIN);
}
let tc = Timecode::parse("01:02:03:04").unwrap();
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
}
#[test]
fn parse_variants() {
let tc = Timecode::parse("01:02:03:04").unwrap();
assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4));
let tc = Timecode::parse("3:04").unwrap();
assert_eq!((tc.seconds, tc.frames), (3, 4));
assert!(Timecode::parse("a:b").is_err());
assert!(Timecode::parse("1:2:3:4:5").is_err());
}
#[test]
fn feet_and_frames() {
let sr = SampleRate::HZ_48000;
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8));
}
}