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}