cd_da_reader/backend.rs
1//! Pluggable audio-sector backings.
2//!
3//! [`CdReader`](crate::CdReader) reads CD-DA sectors from a physical drive over
4//! SCSI/ioctl, but everything *above* the raw sector read — the
5//! [`Track`](crate::Track)/[`Toc`](crate::Toc) types, the track-bounds math
6//! (including the CD-Extra trailing-gap rule), and WAV wrapping — is
7//! hardware-independent. [`AudioSectorReader`] exposes that seam so any backing
8//! that can produce raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory
9//! buffer, a network stream, ...) reuses the same machinery **without this crate
10//! taking on any image-format dependencies**.
11//!
12//! The image format lives in the caller: implement [`AudioSectorReader`] for
13//! your backing, build a [`Toc`](crate::Toc) from the image's own track metadata
14//! (see [`lba_to_msf`](crate::lba_to_msf)), then read PCM in the exact same
15//! little-endian, 2352-byte/sector format the physical reader produces — ready
16//! for [`create_wav`](crate::create_wav). Read a whole track at once with
17//! [`read_track`], or pull it incrementally with [`open_track_stream`] (the
18//! file/image counterpart to [`TrackStream`](crate::TrackStream)).
19//!
20//! ## Track bounds and the CD-Extra gap
21//!
22//! Resolving a track's sector range from a [`Toc`] differs on exactly one track:
23//! the last audio track before a trailing data session on a CD-Extra disc. There
24//! the crate subtracts the inter-session gap — correct whenever that gap is part
25//! of the addressing (a physical disc, or an image whose TOC preserves the real
26//! LBAs), but wrong when the tracks are addressed back-to-back with the gap
27//! stripped out (a `chdman extractcd`-style contiguous extract), where it would
28//! drop ~2.5 min of real audio. Only the backing knows its own layout, so
29//! [`read_track`] / [`open_track_stream`] default to [`TrackBounds::SessionGap`];
30//! a contiguous backing must pass [`TrackBounds::Gapless`] (or supply explicit
31//! bounds via [`open_track_stream_at`]).
32//!
33//! See `examples/file_backend.rs` for a complete, dependency-free example.
34
35use std::cmp::min;
36
37use crate::{CdReader, CdReaderError, ReadOptions, Toc, utils};
38
39/// The physical drive is itself an [`AudioSectorReader`], so drive-backed and
40/// file-backed code can share the generic [`read_track`] path. This uses the
41/// default read options (audio sectors, default retry policy); for explicit
42/// control, prefer the inherent [`CdReader::read_track_with_options`].
43impl AudioSectorReader for CdReader {
44 type Error = CdReaderError;
45
46 fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result<Vec<u8>, Self::Error> {
47 self.read_sector_range(start_lba, count, &ReadOptions::default())
48 }
49}
50
51/// A source of raw CD-DA audio sectors.
52///
53/// This trait separates source-specific I/O from the crate's track-level logic.
54/// Meaning that you can provide your implementation for any source which can provide
55/// audio CD sectors, like a disc image, decoded container, in-memory disc, or a remote
56/// source. [`read_track`] and [`open_track_stream`] use a caller-provided [`Toc`] to calculate
57/// sector ranges, then retrieve those sectors through [`read_audio_sectors`](Self::read_audio_sectors).
58///
59/// Implementations are responsible only for reading sectors. They do not build
60/// the [`Toc`], select tracks, calculate track boundaries, or account for
61/// CD-Extra session gaps. The backing's sector address space must agree with the
62/// `start_lba` and `leadout_lba` values in the supplied `Toc`; layout differences
63/// are expressed separately through [`TrackBounds`].
64///
65/// # Audio format
66///
67/// Each sector must contain exactly 2,352 bytes of headerless PCM audio:
68///
69/// - 44,100 sample frames per second
70/// - signed 16-bit little-endian samples
71/// - two interleaved channels, left followed by right
72/// - 588 stereo sample frames per sector
73///
74/// One sector therefore represents 1/75 second of audio. Returned data must not
75/// include a WAV header, CD sector headers, subchannel data, or padding. It is
76/// byte-for-byte compatible with [`CdReader::read_track`] and can be passed
77/// directly to [`create_wav`](crate::create_wav).
78///
79/// # Addressing and read semantics
80///
81/// `start_lba` is an absolute sector index within the backing, not an offset
82/// relative to a track. A request covers the half-open range
83/// `start_lba..start_lba + count`.
84///
85/// Calls are independent and may be repeated or issued out of order, such as
86/// after seeking a stream. On success, the returned vector must contain exactly
87/// `count * 2352` bytes. A zero-sector request should return an empty vector.
88/// Invalid ranges, short reads, and decoding or I/O failures must return an
89/// error rather than partial data.
90///
91/// The method takes `&self` so callers can retain a shared reference to the
92/// source. Implementations backed by a mutable file cursor or decoder should
93/// use positioned reads or interior mutability.
94pub trait AudioSectorReader {
95 /// Error produced when this backing cannot satisfy a sector read.
96 ///
97 /// Helper APIs preserve this error as the source of [`CdReaderError::Backend`].
98 type Error: std::error::Error + Send + Sync + 'static;
99
100 /// Read the sector range `start_lba..start_lba + count`.
101 ///
102 /// A successful call returns exactly `count * 2352` bytes in the format
103 /// described by [`AudioSectorReader`].
104 ///
105 /// # Errors
106 ///
107 /// Returns an error if the complete requested range cannot be returned.
108 fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result<Vec<u8>, Self::Error>;
109}
110
111/// Policy for deriving a track's half-open sector range from a [`Toc`].
112///
113/// A track begins at its own [`Track::start_lba`](crate::Track::start_lba).
114/// Normally it ends at the next track's `start_lba`, or at
115/// [`Toc::leadout_lba`] when it is the final track.
116///
117/// # CD-Extra session gaps
118///
119/// A CD-Extra disc places a standard 11,400-sector inter-session gap between
120/// its final audio track and the following data session. This is 152 seconds,
121/// or 2 minutes 32 seconds. In a geometry-preserving address space, the first
122/// data track's `start_lba` lies after that gap, so treating it as the audio
123/// track's end would incorrectly include the gap in the audio range.
124///
125/// Some image formats and extracts remove the inter-session gap and store the
126/// tracks back-to-back. In that layout, the next track's `start_lba` is already
127/// the correct end of the audio track; subtracting 11,400 sectors would instead
128/// truncate 152 seconds of audio.
129///
130/// This policy affects only the last audio track followed exclusively by data
131/// tracks. All other tracks have identical bounds under both variants.
132///
133/// Choose the variant according to the address space represented jointly by the
134/// backing and its `Toc`:
135///
136/// - [`SessionGap`](Self::SessionGap) for a physical disc or an image that
137/// preserves the disc's original sector geometry.
138/// - [`Gapless`](Self::Gapless) for a contiguous, gap-stripped image or extract.
139///
140/// `Gapless` refers only to the CD-Extra inter-session gap. It does not remove
141/// ordinary track pregaps or provide gapless playback.
142///
143/// [`read_track`] and [`open_track_stream`] use [`SessionGap`](Self::SessionGap)
144/// by default. Use [`read_track_with_bounds`] or
145/// [`open_track_stream_with_bounds`] when the source requires an explicit
146/// policy.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum TrackBounds {
149 /// The address space includes the CD-Extra inter-session gap.
150 ///
151 /// When applicable, the final audio track ends 11,400 sectors before the
152 /// following data track.
153 SessionGap,
154 /// The address space stores tracks contiguously without the CD-Extra
155 /// inter-session gap.
156 ///
157 /// Every track ends at the next track's start, or at the lead-out.
158 Gapless,
159}
160
161impl TrackBounds {
162 fn resolve(self, toc: &Toc, track_no: u8) -> std::io::Result<(u32, u32)> {
163 match self {
164 TrackBounds::SessionGap => utils::get_track_bounds(toc, track_no),
165 TrackBounds::Gapless => utils::get_gapless_track_bounds(toc, track_no),
166 }
167 }
168}
169
170/// Reads one complete audio track from an [`AudioSectorReader`] into memory.
171///
172/// `track_no` is the disc track number stored in [`Track::number`](crate::Track::number),
173/// not an index into [`Toc::tracks`](crate::Toc::tracks). The source and the
174/// `Toc` must use the same LBA address space.
175///
176/// This convenience function resolves the track's sector range using
177/// [`TrackBounds::SessionGap`]. That policy is appropriate for physical discs
178/// and images that preserve the original CD geometry, including the CD-Extra
179/// inter-session gap. For a contiguous, gap-stripped source, use
180/// [`read_track_with_bounds`] with [`TrackBounds::Gapless`].
181///
182/// The returned vector contains headerless CD-DA PCM in the format required by
183/// [`AudioSectorReader`]: signed 16-bit little-endian stereo at 44.1 kHz, with
184/// 2,352 bytes per sector. It can be passed directly to
185/// [`create_wav`](crate::create_wav).
186///
187/// This is a blocking operation that buffers the entire track, which may require
188/// hundreds of megabytes. Use [`open_track_stream`] or
189/// [`open_track_stream_with_bounds`] to process the track incrementally.
190///
191/// Only audio tracks are meaningful for this API. Callers should select a track
192/// whose [`Track::is_audio`](crate::Track::is_audio) field is `true`.
193///
194/// # Errors
195///
196/// Returns [`CdReaderError::Io`] if `track_no` is absent from the `Toc` or its
197/// calculated sector bounds are invalid.
198///
199/// Returns [`CdReaderError::Backend`] if the source cannot read the requested
200/// sectors. The source's original error is preserved as the boxed
201/// [`source`](std::error::Error::source).
202pub fn read_track<R: AudioSectorReader>(
203 src: &R,
204 toc: &Toc,
205 track_no: u8,
206) -> Result<Vec<u8>, CdReaderError> {
207 read_track_with_bounds(src, toc, track_no, TrackBounds::SessionGap)
208}
209
210/// Reads one complete audio track into memory using an explicit [`TrackBounds`] policy.
211///
212/// This is the configurable form of [`read_track`], which always uses
213/// [`TrackBounds::SessionGap`]. The `bounds` argument controls how the track's
214/// end LBA is calculated from the `Toc`, specifically whether the CD-Extra
215/// inter-session gap is present in the source's address space.
216///
217/// Use [`TrackBounds::SessionGap`] for a physical disc or geometry-preserving
218/// image. Use [`TrackBounds::Gapless`] for a contiguous, gap-stripped source.
219///
220/// The source and `Toc` must use the same LBA address space. All other behavior,
221/// including the returned PCM format and whole-track buffering, is identical to
222/// [`read_track`]. Use [`open_track_stream_with_bounds`] to process the track
223/// incrementally with an explicit bounds policy.
224///
225/// # Errors
226///
227/// Returns [`CdReaderError::Io`] if the track is absent from the `Toc` or its
228/// calculated sector bounds are invalid.
229///
230/// Returns [`CdReaderError::Backend`] if the source cannot read the requested
231/// sectors. The source's original error is preserved as the error's
232/// [`source`](std::error::Error::source).
233pub fn read_track_with_bounds<R: AudioSectorReader>(
234 src: &R,
235 toc: &Toc,
236 track_no: u8,
237 bounds: TrackBounds,
238) -> Result<Vec<u8>, CdReaderError> {
239 let (start_lba, sectors) = bounds.resolve(toc, track_no).map_err(CdReaderError::Io)?;
240 src.read_audio_sectors(start_lba, sectors)
241 .map_err(|e| CdReaderError::Backend(Box::new(e)))
242}
243
244/// A pull-based, sector-aligned stream of raw CD-DA PCM from an [`AudioSectorReader`].
245///
246/// An `AudioTrackStream` borrows its source and represents a fixed sector
247/// range. Each call to [`next_chunk`](Self::next_chunk) synchronously reads and
248/// returns the next portion of that range. Once all sectors have been consumed,
249/// it returns `Ok(None)`.
250///
251/// Unlike [`read_track`], the stream does not allocate or retain the entire
252/// track. Callers can process and discard each returned chunk before requesting
253/// the next one. Chunks contain complete CD-DA sectors in the format specified
254/// by [`AudioSectorReader`]; the final chunk may contain fewer sectors than the
255/// configured chunk size.
256///
257/// The chunk size can be changed with [`with_sectors_per_chunk`](Self::with_sectors_per_chunk).
258/// Stream position is relative to the beginning of its sector range and can be inspected
259/// or changed with [`current_sector`](Self::current_sector), [`seek_to_sector`](Self::seek_to_sector),
260/// and [`seek_to_seconds`](Self::seek_to_seconds).
261///
262/// Create a stream with:
263///
264/// - [`open_track_stream`] to resolve a track from a `Toc` using
265/// [`TrackBounds::SessionGap`].
266/// - [`open_track_stream_with_bounds`] to resolve a track using an explicit
267/// [`TrackBounds`] policy.
268/// - [`open_track_stream_at`] to stream an explicit absolute sector range
269/// without consulting a `Toc`.
270///
271/// This is the source-independent audio counterpart to [`TrackStream`](crate::TrackStream),
272/// which is tied to [`CdReader`] and supports drive-specific read options and data-sector formats.
273pub struct AudioTrackStream<'a, R: AudioSectorReader> {
274 src: &'a R,
275 start_lba: u32,
276 next_lba: u32,
277 remaining_sectors: u32,
278 total_sectors: u32,
279 sectors_per_chunk: u32,
280}
281
282impl<'a, R: AudioSectorReader> AudioTrackStream<'a, R> {
283 const DEFAULT_SECTORS_PER_CHUNK: u32 = 27;
284 const SECTORS_PER_SECOND: f32 = 75.0;
285
286 fn new(src: &'a R, start_lba: u32, sectors: u32) -> Self {
287 Self {
288 src,
289 start_lba,
290 next_lba: start_lba,
291 remaining_sectors: sectors,
292 total_sectors: sectors,
293 sectors_per_chunk: Self::DEFAULT_SECTORS_PER_CHUNK,
294 }
295 }
296
297 /// Set the target chunk size in sectors (default 27; a full chunk is
298 /// `sectors_per_chunk * 2352` bytes). Zero is normalized to one.
299 pub fn with_sectors_per_chunk(mut self, sectors: u32) -> Self {
300 self.sectors_per_chunk = sectors.max(1);
301 self
302 }
303
304 /// Read the next chunk of PCM, or `Ok(None)` at end-of-track.
305 ///
306 /// Each chunk is `sectors_per_chunk * 2352` bytes except possibly the last.
307 ///
308 /// # Errors
309 ///
310 /// Returns [`CdReaderError::Backend`] if the backing read fails. The stream
311 /// position does not advance on error, so a retry re-reads the same chunk.
312 pub fn next_chunk(&mut self) -> Result<Option<Vec<u8>>, CdReaderError> {
313 if self.remaining_sectors == 0 {
314 return Ok(None);
315 }
316
317 let sectors = min(self.remaining_sectors, self.sectors_per_chunk);
318 let chunk = self
319 .src
320 .read_audio_sectors(self.next_lba, sectors)
321 .map_err(|e| CdReaderError::Backend(Box::new(e)))?;
322
323 self.next_lba += sectors;
324 self.remaining_sectors -= sectors;
325
326 Ok(Some(chunk))
327 }
328
329 /// Total number of sectors in this track.
330 pub fn total_sectors(&self) -> u32 {
331 self.total_sectors
332 }
333
334 /// Current position as a track-relative sector index.
335 pub fn current_sector(&self) -> u32 {
336 self.total_sectors - self.remaining_sectors
337 }
338
339 /// Seek to a track-relative sector position (valid range `0..=total_sectors()`).
340 ///
341 /// # Errors
342 ///
343 /// Returns [`CdReaderError::Io`] containing
344 /// [`std::io::ErrorKind::InvalidInput`] if `sector` exceeds the track
345 /// length.
346 pub fn seek_to_sector(&mut self, sector: u32) -> Result<(), CdReaderError> {
347 if sector > self.total_sectors {
348 return Err(CdReaderError::Io(std::io::Error::new(
349 std::io::ErrorKind::InvalidInput,
350 "seek sector is out of track bounds",
351 )));
352 }
353
354 self.next_lba = self.start_lba + sector;
355 self.remaining_sectors = self.total_sectors - sector;
356 Ok(())
357 }
358
359 /// Current position in seconds (75 sectors = 1 second).
360 pub fn current_seconds(&self) -> f32 {
361 self.current_sector() as f32 / Self::SECTORS_PER_SECOND
362 }
363
364 /// Total track duration in seconds (75 sectors = 1 second).
365 pub fn total_seconds(&self) -> f32 {
366 self.total_sectors as f32 / Self::SECTORS_PER_SECOND
367 }
368
369 /// Seek to a track-relative time in seconds, clamped to the track length.
370 ///
371 /// # Errors
372 ///
373 /// Returns [`CdReaderError::Io`] containing
374 /// [`std::io::ErrorKind::InvalidInput`] if `seconds` is negative or not
375 /// finite.
376 pub fn seek_to_seconds(&mut self, seconds: f32) -> Result<(), CdReaderError> {
377 if !seconds.is_finite() || seconds < 0.0 {
378 return Err(CdReaderError::Io(std::io::Error::new(
379 std::io::ErrorKind::InvalidInput,
380 "seek seconds must be a finite non-negative number",
381 )));
382 }
383
384 let target_sector = (seconds * Self::SECTORS_PER_SECOND).round() as u32;
385 self.seek_to_sector(target_sector.min(self.total_sectors))
386 }
387}
388
389/// Open a streaming reader for a track assuming the TOC includes the inter-session
390/// gap ([`TrackBounds::SessionGap`]). See [`AudioTrackStream`].
391///
392/// # Errors
393///
394/// Returns [`CdReaderError::Io`] if the track is absent or its bounds are
395/// invalid.
396pub fn open_track_stream<'a, R: AudioSectorReader>(
397 src: &'a R,
398 toc: &Toc,
399 track_no: u8,
400) -> Result<AudioTrackStream<'a, R>, CdReaderError> {
401 open_track_stream_with_bounds(src, toc, track_no, TrackBounds::SessionGap)
402}
403
404/// Open a streaming reader for a track with an explicit [`TrackBounds`] geometry.
405/// Use [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout.
406///
407/// # Errors
408///
409/// Returns [`CdReaderError::Io`] if the track is absent or its bounds are
410/// invalid.
411pub fn open_track_stream_with_bounds<'a, R: AudioSectorReader>(
412 src: &'a R,
413 toc: &Toc,
414 track_no: u8,
415 bounds: TrackBounds,
416) -> Result<AudioTrackStream<'a, R>, CdReaderError> {
417 let (start_lba, sectors) = bounds.resolve(toc, track_no).map_err(CdReaderError::Io)?;
418 Ok(AudioTrackStream::new(src, start_lba, sectors))
419}
420
421/// Open a streaming reader over an explicit absolute sector range
422/// (`start_lba .. start_lba + sectors`), bypassing TOC bounds resolution.
423///
424/// For backings that compute their own track layout — e.g. reading
425/// `[start_lba(n) .. start_lba(n + 1))` from a contiguous extract — this is the
426/// zero-policy primitive: no TOC lookup, no CD-Extra rule, and no failure mode.
427pub fn open_track_stream_at<R: AudioSectorReader>(
428 src: &R,
429 start_lba: u32,
430 sectors: u32,
431) -> AudioTrackStream<'_, R> {
432 AudioTrackStream::new(src, start_lba, sectors)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::{Track, create_wav, lba_to_msf};
439
440 /// Minimal in-memory backing: whole-disc PCM sliced by sector.
441 struct MemDisc {
442 pcm: Vec<u8>,
443 }
444
445 impl AudioSectorReader for MemDisc {
446 type Error = std::io::Error;
447
448 fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result<Vec<u8>, Self::Error> {
449 let start = start_lba as usize * 2352;
450 let end = start + count as usize * 2352;
451 self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| {
452 std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc")
453 })
454 }
455 }
456
457 fn toc_two_tracks(t1_sectors: u32, t2_sectors: u32) -> Toc {
458 Toc {
459 first_track: 1,
460 last_track: 2,
461 tracks: vec![
462 Track {
463 number: 1,
464 start_lba: 0,
465 start_msf: lba_to_msf(0),
466 is_audio: true,
467 },
468 Track {
469 number: 2,
470 start_lba: t1_sectors,
471 start_msf: lba_to_msf(t1_sectors),
472 is_audio: true,
473 },
474 ],
475 leadout_lba: t1_sectors + t2_sectors,
476 }
477 }
478
479 #[test]
480 fn reads_track_bytes_for_the_right_range() {
481 let (t1, t2) = (100u32, 50u32);
482 let disc = MemDisc {
483 pcm: vec![0u8; (t1 + t2) as usize * 2352],
484 };
485 let toc = toc_two_tracks(t1, t2);
486
487 let track1 = read_track(&disc, &toc, 1).unwrap();
488 let track2 = read_track(&disc, &toc, 2).unwrap();
489
490 assert_eq!(track1.len(), t1 as usize * 2352);
491 assert_eq!(track2.len(), t2 as usize * 2352);
492 }
493
494 #[test]
495 fn create_wav_wraps_backend_pcm() {
496 let disc = MemDisc {
497 pcm: vec![0u8; 10 * 2352],
498 };
499 // Single-track disc: no next track, so the leadout bounds the read.
500 let toc = Toc {
501 first_track: 1,
502 last_track: 1,
503 tracks: vec![Track {
504 number: 1,
505 start_lba: 0,
506 start_msf: lba_to_msf(0),
507 is_audio: true,
508 }],
509 leadout_lba: 10,
510 };
511
512 let pcm = read_track(&disc, &toc, 1).unwrap();
513 let wav = create_wav(pcm);
514 assert_eq!(&wav[0..4], b"RIFF");
515 assert_eq!(&wav[8..12], b"WAVE");
516 assert_eq!(wav.len(), 44 + 10 * 2352);
517 }
518
519 #[test]
520 fn missing_track_is_an_io_error() {
521 let disc = MemDisc {
522 pcm: vec![0u8; 2352],
523 };
524 let toc = toc_two_tracks(1, 0);
525 match read_track(&disc, &toc, 99) {
526 Err(CdReaderError::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
527 other => panic!("expected Io(NotFound), got {other:?}"),
528 }
529 }
530
531 #[test]
532 fn backend_failure_is_a_backend_error() {
533 // Disc holds one sector but the TOC claims track 1 is five, so the read
534 // runs past the end — a backing failure, not a TOC error.
535 let disc = MemDisc {
536 pcm: vec![0u8; 2352],
537 };
538 let toc = toc_two_tracks(5, 10);
539 match read_track(&disc, &toc, 1) {
540 Err(CdReaderError::Backend(e)) => {
541 let io = e
542 .downcast_ref::<std::io::Error>()
543 .expect("backend error preserves the io::Error");
544 assert_eq!(io.kind(), std::io::ErrorKind::UnexpectedEof);
545 }
546 other => panic!("expected Backend error, got {other:?}"),
547 }
548 }
549
550 #[test]
551 fn stream_pulls_sector_aligned_chunks() {
552 let sectors = 100u32;
553 let disc = MemDisc {
554 pcm: vec![0u8; sectors as usize * 2352],
555 };
556
557 let mut stream = open_track_stream_at(&disc, 0, sectors).with_sectors_per_chunk(27);
558 assert_eq!(stream.total_sectors(), sectors);
559
560 let mut total = 0usize;
561 let mut chunks = 0usize;
562 while let Some(chunk) = stream.next_chunk().unwrap() {
563 assert_eq!(chunk.len() % 2352, 0);
564 total += chunk.len();
565 chunks += 1;
566 }
567
568 assert_eq!(total, sectors as usize * 2352);
569 assert_eq!(chunks, 4); // 27 + 27 + 27 + 19
570 assert!(stream.next_chunk().unwrap().is_none());
571 }
572
573 #[test]
574 fn stream_seek_repositions() {
575 // Stream starts at absolute LBA 10 for 300 sectors, so the backing must
576 // cover absolute sectors 10..310.
577 let disc = MemDisc {
578 pcm: vec![0u8; 310 * 2352],
579 };
580 let mut stream = open_track_stream_at(&disc, 10, 300).with_sectors_per_chunk(1000);
581
582 stream.seek_to_sector(250).unwrap();
583 assert_eq!(stream.current_sector(), 250);
584 assert!((stream.current_seconds() - 250.0 / 75.0).abs() < f32::EPSILON);
585
586 let chunk = stream.next_chunk().unwrap().unwrap();
587 assert_eq!(chunk.len(), 50 * 2352); // 300 - 250 sectors, one big chunk
588 assert!(stream.next_chunk().unwrap().is_none());
589
590 assert!(stream.seek_to_sector(301).is_err());
591 }
592
593 #[test]
594 fn open_track_stream_resolves_toc_bounds() {
595 let (t1, t2) = (40u32, 60u32);
596 let disc = MemDisc {
597 pcm: vec![0u8; (t1 + t2) as usize * 2352],
598 };
599 let toc = toc_two_tracks(t1, t2);
600
601 let stream = open_track_stream(&disc, &toc, 2).unwrap();
602 assert_eq!(stream.total_sectors(), t2);
603 }
604}