cdtoc/
time.rs

1/*!
2# CDTOC: Time
3*/
4
5use crate::TocError;
6use dactyl::{
7	NiceElapsed,
8	NiceFloat,
9	traits::NiceInflection,
10};
11use std::{
12	fmt,
13	hash,
14	iter::Sum,
15	ops::{
16		Add,
17		AddAssign,
18		Sub,
19		SubAssign,
20		Div,
21		DivAssign,
22		Mul,
23		MulAssign,
24	},
25	time,
26};
27
28
29
30/// # Samples Per Sector.
31const SAMPLES_PER_SECTOR: u64 = 588;
32
33/// # Sectors Per Second.
34const SECTORS_PER_SECOND: u64 = 75;
35
36
37
38#[derive(Debug, Clone, Copy, Default, Ord, PartialOrd)]
39/// # (CDDA Sector) Duration.
40///
41/// This struct holds a non-lossy — at least up to about 7.8 billion years —
42/// CD sector duration (seconds + frames) for one or more tracks.
43///
44/// ## Examples
45///
46/// ```
47/// use cdtoc::Toc;
48///
49/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
50/// let track = toc.audio_track(9).unwrap();
51/// let duration = track.duration();
52///
53/// // The printable format is Dd HH:MM:SS+FF, though the day part is only
54/// // present if non-zero.
55/// assert_eq!(duration.to_string(), "00:01:55+04");
56///
57/// // The same as intelligible pieces:
58/// assert_eq!(duration.dhmsf(), (0, 0, 1, 55, 4));
59///
60/// // If that's too many pieces, you can get just the seconds and frames:
61/// assert_eq!(duration.seconds_frames(), (115, 4));
62/// ```
63///
64/// The value can also be lossily converted to more familiar formats via
65/// [`Duration::to_std_duration_lossy`] or [`Duration::to_f64_lossy`].
66///
67/// Durations can also be combined every which way, for example:
68///
69/// ```
70/// use cdtoc::{Toc, Duration};
71///
72/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
73/// let duration: Duration = toc.audio_tracks()
74///     .map(|t| t.duration())
75///     .sum();
76/// assert_eq!(duration.to_string(), "00:34:41+63");
77/// ```
78pub struct Duration(pub(crate) u64);
79
80impl<T> Add<T> for Duration
81where u64: From<T> {
82	type Output = Self;
83	#[inline]
84	fn add(self, other: T) -> Self { Self(self.0 + u64::from(other)) }
85}
86
87impl<T> AddAssign<T> for Duration
88where u64: From<T> {
89	#[inline]
90	fn add_assign(&mut self, other: T) { self.0 += u64::from(other); }
91}
92
93impl<T> Div<T> for Duration
94where u64: From<T> {
95	type Output = Self;
96	#[inline]
97	fn div(self, other: T) -> Self {
98		let other = u64::from(other);
99		if other == 0 { Self(0) }
100		else { Self(self.0.wrapping_div(other)) }
101	}
102}
103
104impl<T> DivAssign<T> for Duration
105where u64: From<T> {
106	#[inline]
107	fn div_assign(&mut self, other: T) {
108		let other = u64::from(other);
109		if other == 0 { self.0 = 0; }
110		else { self.0 = self.0.wrapping_div(other); }
111	}
112}
113
114impl Eq for Duration {}
115
116impl fmt::Display for Duration {
117	#[expect(clippy::many_single_char_names, reason = "Consistency is preferred.")]
118	#[inline]
119	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120		let (d, h, m, s, frames) = self.dhmsf();
121		if d == 0 {
122			write!(f, "{h:02}:{m:02}:{s:02}+{frames:02}")
123		}
124		else {
125			write!(f, "{d}d {h:02}:{m:02}:{s:02}+{frames:02}")
126		}
127	}
128}
129
130impl From<u32> for Duration {
131	#[inline]
132	fn from(src: u32) -> Self { Self(src.into()) }
133}
134
135impl From<u64> for Duration {
136	#[inline]
137	fn from(src: u64) -> Self { Self(src) }
138}
139
140impl From<usize> for Duration {
141	#[inline]
142	fn from(src: usize) -> Self { Self(src as u64) }
143}
144
145impl From<Duration> for u64 {
146	#[inline]
147	fn from(src: Duration) -> Self { src.0 }
148}
149
150impl hash::Hash for Duration {
151	#[inline]
152	fn hash<H: hash::Hasher>(&self, state: &mut H) { state.write_u64(self.0); }
153}
154
155impl PartialEq for Duration {
156	#[inline]
157	fn eq(&self, other: &Self) -> bool { self.0 == other.0 }
158}
159
160impl<T> Mul<T> for Duration
161where u64: From<T> {
162	type Output = Self;
163	#[inline]
164	fn mul(self, other: T) -> Self { Self(self.0 * u64::from(other)) }
165}
166
167impl<T> MulAssign<T> for Duration
168where u64: From<T> {
169	#[inline]
170	fn mul_assign(&mut self, other: T) { self.0 *= u64::from(other); }
171}
172
173impl<T> Sub<T> for Duration
174where u64: From<T> {
175	type Output = Self;
176	#[inline]
177	fn sub(self, other: T) -> Self { Self(self.0.saturating_sub(u64::from(other))) }
178}
179
180impl<T> SubAssign<T> for Duration
181where u64: From<T> {
182	#[inline]
183	fn sub_assign(&mut self, other: T) { self.0 = self.0.saturating_sub(u64::from(other)); }
184}
185
186impl Sum for Duration {
187	#[inline]
188	fn sum<I>(iter: I) -> Self
189	where I: Iterator<Item = Self> { iter.fold(Self::default(), |a, b| a + b) }
190}
191
192impl Duration {
193	/// # From CDDA Samples.
194	///
195	/// Derive the duration from the total number of a track's _CDDA-quality_
196	/// samples.
197	///
198	/// This method assumes the count was captured at a rate of 44.1 kHz, and
199	/// requires it divide evenly into the samples-per-sector size used by
200	/// standard audio CDs (`588`).
201	///
202	/// For more flexible (and/or approximate) sample/duration conversions, use
203	/// [`Duration::from_samples`] instead.
204	///
205	/// ## Examples
206	///
207	/// ```
208	/// use cdtoc::Duration;
209	///
210	/// let duration = Duration::from_cdda_samples(5_073_852).unwrap();
211	/// assert_eq!(
212	///     duration.to_string(),
213	///     "00:01:55+04",
214	/// );
215	/// ```
216	///
217	/// ## Errors
218	///
219	/// This will return an error if the sample count is not evenly divisible
220	/// by `588`, the number of samples-per-sector for a standard audio CD.
221	pub const fn from_cdda_samples(total_samples: u64) -> Result<Self, TocError> {
222		let out = total_samples.wrapping_div(SAMPLES_PER_SECTOR);
223		if total_samples.is_multiple_of(SAMPLES_PER_SECTOR) { Ok(Self(out)) }
224		else { Err(TocError::CDDASampleCount) }
225	}
226
227	#[expect(
228		clippy::cast_possible_truncation,
229		clippy::cast_sign_loss,
230		reason = "False positive.",
231	)]
232	#[must_use]
233	/// # From Samples (Rescaled).
234	///
235	/// Derive the equivalent CDDA duration for a track with an arbitrary
236	/// sample rate (i.e. not 44.1 kHz) or sample count.
237	///
238	/// This operation is potentially lossy and may result in a duration that
239	/// is off by ±1 frame.
240	///
241	/// For standard CDDA tracks, use [`Duration::from_cdda_samples`] instead.
242	///
243	/// ## Examples
244	///
245	/// ```
246	/// use cdtoc::Duration;
247	///
248	/// let duration = Duration::from_samples(96_000, 17_271_098);
249	/// assert_eq!(
250	///     duration.to_string(),
251	///     "00:02:59+68",
252	/// );
253	/// ```
254	pub fn from_samples(sample_rate: u32,  total_samples: u64) -> Self {
255		if sample_rate == 0 || total_samples == 0 { Self::default() }
256		else {
257			let sample_rate = u64::from(sample_rate);
258			let (s, rem) = (total_samples.wrapping_div(sample_rate), total_samples % sample_rate);
259			if rem == 0 { Self(s * SECTORS_PER_SECOND) }
260			else {
261				let f = NiceFloat::div_u64(rem * 75, sample_rate)
262					.map_or(0, |f| f.trunc() as u64);
263				Self(s * SECTORS_PER_SECOND + f)
264			}
265		}
266	}
267}
268
269impl Duration {
270	#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
271	#[expect(clippy::many_single_char_names, reason = "Consistency is preferred.")]
272	#[must_use]
273	/// # Days, Hours, Minutes, Seconds, Frames.
274	///
275	/// Carve up the duration into a quintuple of days, hours, minutes,
276	/// seconds, and frames.
277	///
278	/// ## Examples
279	///
280	/// ```
281	/// use cdtoc::Toc;
282	///
283	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
284	/// let track = toc.audio_track(9).unwrap();
285	/// assert_eq!(
286	///     track.duration().dhmsf(),
287	///     (0, 0, 1, 55, 4),
288	/// );
289	/// ```
290	pub const fn dhmsf(self) -> (u64, u8, u8, u8, u8) {
291		let (s, f) = self.seconds_frames();
292		if s <= 4_294_967_295 {
293			let (d, h, m, s) = NiceElapsed::dhms(s as u32);
294			(d as u64, h, m, s, f)
295		}
296		else {
297			let d = s.wrapping_div(86_400);
298			let [h, m, s] = NiceElapsed::hms((s - d * 86_400) as u32);
299			(d, h, m, s, f)
300		}
301	}
302
303	#[must_use]
304	/// # Total Samples.
305	///
306	/// Return the total number of samples.
307	///
308	/// ## Examples
309	///
310	/// ```
311	/// use cdtoc::Toc;
312	///
313	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
314	/// let track = toc.audio_track(9).unwrap();
315	/// assert_eq!(
316	///     track.duration().samples(),
317	///     5_073_852,
318	/// );
319	/// ```
320	pub const fn samples(self) -> u64 { self.0 * SAMPLES_PER_SECTOR }
321
322	#[must_use]
323	/// # Seconds + Frames.
324	///
325	/// Return the duration as a tuple containing the total number of seconds
326	/// and remaining frames (some fraction of a second).
327	///
328	/// Audio CDs have 75 frames per second, so the frame portion will always
329	/// be in the range of `0..75`.
330	///
331	/// ## Examples
332	///
333	/// ```
334	/// use cdtoc::Toc;
335	///
336	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
337	/// let track = toc.audio_track(9).unwrap();
338	/// assert_eq!(
339	///     track.duration().seconds_frames(),
340	///     (115, 4),
341	/// );
342	/// ```
343	pub const fn seconds_frames(self) -> (u64, u8) {
344		(self.0.wrapping_div(SECTORS_PER_SECOND), (self.0 % SECTORS_PER_SECOND) as u8)
345	}
346
347	#[must_use]
348	/// # Number of Sectors.
349	///
350	/// Return the total number of sectors.
351	///
352	/// ## Examples
353	///
354	/// ```
355	/// use cdtoc::Toc;
356	///
357	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
358	/// let track = toc.audio_track(9).unwrap();
359	/// assert_eq!(
360	///     track.duration().sectors(),
361	///     8629,
362	/// );
363	/// ```
364	pub const fn sectors(self) -> u64 { self.0 }
365
366	#[expect(clippy::cast_precision_loss, reason = "False positive.")]
367	#[must_use]
368	/// # To `f64` (Lossy).
369	///
370	/// Return the duration as a float (seconds.subseconds).
371	///
372	/// Given that 75ths don't always make the cleanest of fractions, there
373	/// will likely be some loss in precision.
374	///
375	/// ## Examples
376	///
377	/// ```
378	/// use cdtoc::Toc;
379	///
380	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
381	/// let track = toc.audio_track(9).unwrap();
382	/// assert_eq!(
383	///     track.duration().to_f64_lossy(),
384	///     115.05333333333333,
385	/// );
386	/// ```
387	pub const fn to_f64_lossy(self) -> f64 {
388		// Most durations will probably fit within `u32`, which converts
389		// cleanly.
390		if self.0 <= 4_294_967_295 { self.0 as f64 / 75.0 }
391		// Otherwise let's try to do it in parts and hope for the best.
392		else {
393			let (s, f) = self.seconds_frames();
394			s as f64 + ((f as f64) / 75.0)
395		}
396	}
397
398	#[must_use]
399	/// # To [`std::time::Duration`] (Lossy).
400	///
401	/// Return the value as a "normal" [`std::time::Duration`].
402	///
403	/// Note that the `std` struct only counts time down to the nanosecond, so
404	/// this value might be off by a few frames.
405	///
406	/// ## Examples
407	///
408	/// ```
409	/// use cdtoc::Toc;
410	///
411	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
412	/// let track = toc.audio_track(9).unwrap();
413	/// assert_eq!(
414	///     track.duration().to_std_duration_lossy().as_nanos(),
415	///     115_053_333_333,
416	/// );
417	/// ```
418	pub const fn to_std_duration_lossy(self) -> time::Duration {
419		// There are 1_000_000_000 nanoseconds per 75 sectors. Reducing this to
420		// 40_000_000:3 leaves less chance of temporary overflow.
421		if let Some(n) = self.0.checked_mul(40_000_000) {
422			time::Duration::from_nanos(n.wrapping_div(3))
423		}
424		else {
425			let (s, f) = self.seconds_frames();
426			time::Duration::from_secs(s).saturating_add(
427				time::Duration::from_nanos((f as u64 * 40_000_000).wrapping_div(3))
428			)
429		}
430	}
431
432	#[expect(clippy::many_single_char_names, reason = "Consistency is preferred.")]
433	#[must_use]
434	/// # To String Pretty.
435	///
436	/// Return a string reprsentation of the non-zero parts with English
437	/// labels, separated Oxford-comma-style.
438	///
439	/// ## Examples
440	///
441	/// ```
442	/// use cdtoc::{Toc, Duration};
443	///
444	/// let toc = Toc::from_cdtoc("9+96+5766+A284+E600+11FE5+15913+19A98+1E905+240CB+26280").unwrap();
445	/// let track = toc.audio_track(9).unwrap();
446	/// assert_eq!(
447	///     track.duration().to_string_pretty(),
448	///     "1 minute, 55 seconds, and 4 frames",
449	/// );
450	///
451	/// // Empty durations look like this:
452	/// assert_eq!(
453	///     Duration::default().to_string_pretty(),
454	///     "0 seconds",
455	/// );
456	/// ```
457	pub fn to_string_pretty(self) -> String {
458		let (d, h, m, s, f) = self.dhmsf();
459		let mut parts: Vec<String> = Vec::new();
460
461		// Days work the same way as the other parts, but have a different
462		// integer type.
463		if d != 0 { parts.push(d.nice_inflect("day", "days").to_string()); }
464
465		for (num, single, plural) in [
466			(h, "hour", "hours"),
467			(m, "minute", "minutes"),
468			(s, "second", "seconds"),
469			(f, "frame", "frames"),
470		] {
471			if num != 0 { parts.push(num.nice_inflect(single, plural).to_string()); }
472		}
473
474		match parts.len() {
475			0 => "0 seconds".to_owned(),
476			1 => parts.remove(0),
477			2 => parts.join(" and "),
478			n => {
479				let last = parts.remove(n - 1);
480				let mut out = parts.join(", ");
481				out.push_str(", and ");
482				out.push_str(&last);
483				out
484			},
485		}
486	}
487}