Skip to main content

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}