From 9c4d7de87c0f30a079b89123d6deba7837d6ea6d Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Sat, 14 Feb 2026 13:11:16 -0800 Subject: [PATCH 1/4] add streaming API --- examples/read_track.rs | 23 ++++++++++--- src/lib.rs | 29 ++++++++++++++++ src/linux.rs | 8 +++++ src/macos.rs | 10 +++++- src/stream.rs | 72 +++++++++++++++++++++++++++++++++++++++ src/windows.rs | 12 ++++++- src/windows_read_track.rs | 4 +-- 7 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 src/stream.rs diff --git a/examples/read_track.rs b/examples/read_track.rs index 020079c..c4a99a2 100644 --- a/examples/read_track.rs +++ b/examples/read_track.rs @@ -1,4 +1,4 @@ -use cd_da_reader::CdReader; +use cd_da_reader::{CdReader, RetryConfig, TrackStreamConfig}; fn main() -> Result<(), Box> { let drive_path = default_drive_path(); @@ -33,9 +33,24 @@ fn read_cd(path: &str) -> Result<(), Box> { .ok_or_else(|| std::io::Error::other("no audio tracks in TOC"))?; println!("Reading track {}", last_audio_track.number); - let data = reader.read_track(&toc, last_audio_track.number)?; - let wav_track = CdReader::create_wav(data); - std::fs::write("myfile.wav", wav_track)?; + let stream_cfg = TrackStreamConfig { + sectors_per_chunk: 27, + retry: RetryConfig { + max_attempts: 5, + initial_backoff_ms: 30, + max_backoff_ms: 500, + reduce_chunk_on_retry: true, + min_sectors_per_read: 1, + }, + }; + let mut stream = reader.open_track_stream(&toc, last_audio_track.number, stream_cfg)?; + + let mut pcm = Vec::new(); + while let Some(chunk) = stream.next_chunk()? { + pcm.extend_from_slice(&chunk); + } + let wav = CdReader::create_wav(pcm); + std::fs::write("myfile.wav", wav)?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index f9cf69f..e6cf8b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,10 +46,12 @@ mod windows; mod discovery; mod errors; mod retry; +mod stream; mod utils; pub use discovery::DriveInfo; pub use errors::{CdReaderError, ScsiError, ScsiOp}; pub use retry::RetryConfig; +pub use stream::{TrackStream, TrackStreamConfig}; mod parse_toc; @@ -213,6 +215,33 @@ impl CdReader { compile_error!("Unsupported platform") } } + + pub(crate) fn read_sectors_with_retry( + &self, + start_lba: u32, + sectors: u32, + cfg: &RetryConfig, + ) -> Result, CdReaderError> { + #[cfg(target_os = "windows")] + { + windows::read_sectors_with_retry(start_lba, sectors, cfg) + } + + #[cfg(target_os = "macos")] + { + macos::read_sectors_with_retry(start_lba, sectors, cfg) + } + + #[cfg(target_os = "linux")] + { + linux::read_sectors_with_retry(start_lba, sectors, cfg) + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + compile_error!("Unsupported platform") + } + } } impl Drop for CdReader { diff --git a/src/linux.rs b/src/linux.rs index e144a1d..0ced759 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -197,6 +197,14 @@ pub fn read_track_with_retry( cfg: &RetryConfig, ) -> std::result::Result, CdReaderError> { let (start_lba, sectors) = get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?; + read_sectors_with_retry(start_lba, sectors, cfg) +} + +pub fn read_sectors_with_retry( + start_lba: u32, + sectors: u32, + cfg: &RetryConfig, +) -> std::result::Result, CdReaderError> { read_cd_audio_range(start_lba, sectors, cfg) } diff --git a/src/macos.rs b/src/macos.rs index 0305e8c..e89d165 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -112,11 +112,19 @@ pub fn read_track_with_retry( toc: &Toc, track_no: u8, cfg: &RetryConfig, +) -> Result, CdReaderError> { + let (start_lba, sectors) = get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?; + read_sectors_with_retry(start_lba, sectors, cfg) +} + +pub fn read_sectors_with_retry( + start_lba: u32, + sectors: u32, + cfg: &RetryConfig, ) -> Result, CdReaderError> { const SECTOR_BYTES: usize = 2352; const MAX_SECTORS_PER_XFER: u32 = 27; - let (start_lba, sectors) = get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?; let mut out = Vec::::with_capacity((sectors as usize) * SECTOR_BYTES); let mut remaining = sectors; let mut lba = start_lba; diff --git a/src/stream.rs b/src/stream.rs new file mode 100644 index 0000000..d83276b --- /dev/null +++ b/src/stream.rs @@ -0,0 +1,72 @@ +use std::cmp::min; + +use crate::{CdReader, CdReaderError, RetryConfig, Toc, utils}; + +#[derive(Debug, Clone)] +pub struct TrackStreamConfig { + pub sectors_per_chunk: u32, + pub retry: RetryConfig, +} + +impl Default for TrackStreamConfig { + fn default() -> Self { + Self { + sectors_per_chunk: 27, + retry: RetryConfig::default(), + } + } +} + +pub struct TrackStream<'a> { + reader: &'a CdReader, + next_lba: u32, + remaining_sectors: u32, + total_sectors: u32, + cfg: TrackStreamConfig, +} + +impl<'a> TrackStream<'a> { + pub fn next_chunk(&mut self) -> Result>, CdReaderError> { + if self.remaining_sectors == 0 { + return Ok(None); + } + + let sectors = min(self.remaining_sectors, self.cfg.sectors_per_chunk.max(1)); + let chunk = self + .reader + .read_sectors_with_retry(self.next_lba, sectors, &self.cfg.retry)?; + + self.next_lba += sectors; + self.remaining_sectors -= sectors; + + Ok(Some(chunk)) + } + + pub fn total_sectors(&self) -> u32 { + self.total_sectors + } + + pub fn consumed_sectors(&self) -> u32 { + self.total_sectors - self.remaining_sectors + } +} + +impl CdReader { + pub fn open_track_stream<'a>( + &'a self, + toc: &Toc, + track_no: u8, + cfg: TrackStreamConfig, + ) -> Result, CdReaderError> { + let (start_lba, sectors) = + utils::get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?; + + Ok(TrackStream { + reader: self, + next_lba: start_lba, + remaining_sectors: sectors, + total_sectors: sectors, + cfg, + }) + } +} diff --git a/src/windows.rs b/src/windows.rs index ec80630..4f14699 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -164,13 +164,23 @@ pub fn read_track_with_retry( toc: &Toc, track_no: u8, cfg: &RetryConfig, +) -> Result, CdReaderError> { + let (start_lba, sectors) = + crate::utils::get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?; + read_sectors_with_retry(start_lba, sectors, cfg) +} + +pub fn read_sectors_with_retry( + start_lba: u32, + sectors: u32, + cfg: &RetryConfig, ) -> Result, CdReaderError> { let handle = unsafe { DRIVE_HANDLE .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Drive not opened")) .map_err(CdReaderError::Io)? }; - windows_read_track::read_track_with_retry(handle, toc, track_no, cfg) + windows_read_track::read_audio_range_with_retry(handle, start_lba, sectors, cfg) } fn parse_sense(sense: &[u8], sense_len: u8) -> (Option, Option, Option) { diff --git a/src/windows_read_track.rs b/src/windows_read_track.rs index 541a2c3..c558d47 100644 --- a/src/windows_read_track.rs +++ b/src/windows_read_track.rs @@ -21,11 +21,11 @@ pub fn read_track_with_retry( cfg: &RetryConfig, ) -> Result, CdReaderError> { let (start_lba, sectors) = get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?; - read_cd_audio_range(handle, start_lba, sectors, cfg) + read_audio_range_with_retry(handle, start_lba, sectors, cfg) } // --- READ CD (0xBE): read an arbitrary LBA range as CD-DA (2352 bytes/sector) --- -fn read_cd_audio_range( +pub fn read_audio_range_with_retry( handle: HANDLE, start_lba: u32, sectors: u32, From b17f58d74b818711a49e9bc345ac4a6982f3d084 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Sun, 15 Feb 2026 00:18:18 -0800 Subject: [PATCH 2/4] add seek to the streaming API --- src/stream.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 3 deletions(-) diff --git a/src/stream.rs b/src/stream.rs index d83276b..b469162 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -19,6 +19,7 @@ impl Default for TrackStreamConfig { pub struct TrackStream<'a> { reader: &'a CdReader, + start_lba: u32, next_lba: u32, remaining_sectors: u32, total_sectors: u32, @@ -26,15 +27,24 @@ pub struct TrackStream<'a> { } impl<'a> TrackStream<'a> { + const SECTORS_PER_SECOND: f32 = 75.0; + pub fn next_chunk(&mut self) -> Result>, CdReaderError> { + self.next_chunk_with(|lba, sectors, retry| { + self.reader.read_sectors_with_retry(lba, sectors, retry) + }) + } + + fn next_chunk_with(&mut self, mut read_fn: F) -> Result>, CdReaderError> + where + F: FnMut(u32, u32, &RetryConfig) -> Result, CdReaderError>, + { if self.remaining_sectors == 0 { return Ok(None); } let sectors = min(self.remaining_sectors, self.cfg.sectors_per_chunk.max(1)); - let chunk = self - .reader - .read_sectors_with_retry(self.next_lba, sectors, &self.cfg.retry)?; + let chunk = read_fn(self.next_lba, sectors, &self.cfg.retry)?; self.next_lba += sectors; self.remaining_sectors -= sectors; @@ -49,6 +59,43 @@ impl<'a> TrackStream<'a> { pub fn consumed_sectors(&self) -> u32 { self.total_sectors - self.remaining_sectors } + + pub fn current_sector(&self) -> u32 { + self.consumed_sectors() + } + + pub fn seek_to_sector(&mut self, sector: u32) -> Result<(), CdReaderError> { + if sector > self.total_sectors { + return Err(CdReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek sector is out of track bounds", + ))); + } + + self.next_lba = self.start_lba + sector; + self.remaining_sectors = self.total_sectors - sector; + Ok(()) + } + + pub fn current_seconds(&self) -> f32 { + self.current_sector() as f32 / Self::SECTORS_PER_SECOND + } + + pub fn total_seconds(&self) -> f32 { + self.total_sectors as f32 / Self::SECTORS_PER_SECOND + } + + pub fn seek_to_seconds(&mut self, seconds: f32) -> Result<(), CdReaderError> { + if !seconds.is_finite() || seconds < 0.0 { + return Err(CdReaderError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek seconds must be a finite non-negative number", + ))); + } + + let target_sector = (seconds * Self::SECTORS_PER_SECOND).round() as u32; + self.seek_to_sector(target_sector.min(self.total_sectors)) + } } impl CdReader { @@ -63,6 +110,7 @@ impl CdReader { Ok(TrackStream { reader: self, + start_lba, next_lba: start_lba, remaining_sectors: sectors, total_sectors: sectors, @@ -70,3 +118,117 @@ impl CdReader { }) } } + +#[cfg(test)] +mod tests { + use super::{TrackStream, TrackStreamConfig}; + use crate::{CdReader, CdReaderError, RetryConfig}; + + fn mk_stream( + start_lba: u32, + total_sectors: u32, + sectors_per_chunk: u32, + ) -> TrackStream<'static> { + let reader: &'static CdReader = Box::leak(Box::new(CdReader {})); + TrackStream { + reader, + start_lba, + next_lba: start_lba, + remaining_sectors: total_sectors, + total_sectors, + cfg: TrackStreamConfig { + sectors_per_chunk, + retry: RetryConfig::default(), + }, + } + } + + #[test] + fn seek_to_sector_updates_position() { + let mut stream = mk_stream(10_000, 1_000, 27); + stream.seek_to_sector(250).unwrap(); + + assert_eq!(stream.current_sector(), 250); + assert_eq!(stream.next_lba, 10_250); + assert_eq!(stream.remaining_sectors, 750); + } + + #[test] + fn seek_to_sector_returns_error_out_of_bounds() { + let mut stream = mk_stream(10_000, 1_000, 27); + let err = stream.seek_to_sector(1_001).unwrap_err(); + + match err { + CdReaderError::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput), + _ => panic!("expected Io(InvalidInput)"), + } + } + + #[test] + fn seek_to_seconds_and_time_helpers_work() { + let mut stream = mk_stream(10_000, 750, 27); // 10 seconds + assert_eq!(stream.total_seconds(), 10.0); + + stream.seek_to_seconds(2.0).unwrap(); + assert_eq!(stream.current_sector(), 150); + assert!((stream.current_seconds() - 2.0).abs() < f32::EPSILON); + } + + #[test] + fn seek_to_seconds_rejects_invalid_input() { + let mut stream = mk_stream(10_000, 750, 27); + let err = stream.seek_to_seconds(f32::NAN).unwrap_err(); + match err { + CdReaderError::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput), + _ => panic!("expected Io(InvalidInput)"), + } + } + + #[test] + fn next_chunk_reads_expected_size_and_advances() { + let mut stream = mk_stream(10_000, 100, 27); + let mut called = false; + + let chunk = stream + .next_chunk_with(|lba, sectors, _| { + called = true; + assert_eq!(lba, 10_000); + assert_eq!(sectors, 27); + Ok(vec![0u8; (sectors as usize) * 2352]) + }) + .unwrap() + .unwrap(); + + assert!(called); + assert_eq!(chunk.len(), 27 * 2352); + assert_eq!(stream.current_sector(), 27); + assert_eq!(stream.remaining_sectors, 73); + } + + #[test] + fn next_chunk_returns_none_when_finished() { + let mut stream = mk_stream(10_000, 0, 27); + let result = stream.next_chunk_with(|_, _, _| Ok(vec![1, 2, 3])).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn next_chunk_error_does_not_advance_position() { + let mut stream = mk_stream(10_000, 100, 27); + let err = stream + .next_chunk_with(|_, _, _| { + Err(CdReaderError::Io(std::io::Error::other( + "simulated read failure", + ))) + }) + .unwrap_err(); + + match err { + CdReaderError::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::Other), + _ => panic!("expected Io(Other)"), + } + assert_eq!(stream.current_sector(), 0); + assert_eq!(stream.next_lba, 10_000); + assert_eq!(stream.remaining_sectors, 100); + } +} From efeb1d7b334c1cb4a5c41e68b307c1d4661bd336 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Sun, 15 Feb 2026 12:00:18 -0800 Subject: [PATCH 3/4] update documentation --- README.md | 19 ++++++++++++++++++- src/lib.rs | 6 +++++- src/stream.rs | 47 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6e0052b..6413646 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,21 @@ This is a library to read audio CDs. This is intended to be a fairly low-level l It works on Windows, macOS and Linux, although each platform has slightly different behaviour regarding the handle exclusivity. Specifically, on macOS, it will not work if you use the audio CD somewhere -- the library will attempt to unmount it, claim exclusive access and only after read the data from it. After it is done, it will remount the CD back so other apps can use, which will cause the OS to treat as if you just inserted the CD. -There is an example to read TOC and save a track ([ref](./examples/read_track.rs)); the example is cross-platform, but you'll likely need to adjust the drive letter. On Windows, simply look in your File Explorer; on macOS, execute `diskutil list` and find the drive with `Audio CD` name; on Linux, call `cat /proc/sys/dev/cdrom/info`. \ No newline at end of file +For example, if you want to read TOC and save the first track as a WAV file, you can do the following: + +```rust +let reader = CDReader::open_default()?; +let toc = reader.read_toc()?; + +let first_audio_track = toc + .tracks + .iter() + .find(|track| track.is_audio) + .ok_or_else(|| std::io::Error::other("no audio tracks in TOC"))?; + +let data = reader.read_track(&toc, last_audio_track.number)?; +let wav_track = CdReader::create_wav(data); +std::fs::write("myfile.wav", wav_track)?; +``` + +You can open a specific drive, but often the machine will have only 1 valid audio CD, so the default drive method should work in most scenarios. Reading track data is a pretty slow operation due to size and CD reading speeds, so there is a streaming API available if you want to interact with chunks of data directly. \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index e6cf8b1..67a5525 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,13 +95,17 @@ pub struct Toc { /// Please note that you should not read multiple CDs at the same time, and preferably do /// not use it in multiple threads. CD drives are a physical thing and they really want to /// have exclusive access, because of that currently only sequential access is supported. +/// +/// This is especially true on macOS, where releasing exclusive lock on the audio CD will +/// cause it to remount, and the default application (very likely Apple Music) will get +/// the exclusive access and it will be challenging to implement a reliable waiting strategy. pub struct CdReader {} impl CdReader { /// Opens a CD drive at the specified path in order to read data. /// /// It is crucial to call this function and not to create the Reader - /// by yourself, as each OS needs its own way of handling the drive acess. + /// by yourself, as each OS needs its own way of handling the drive access. /// /// You don't need to close the drive, it will be handled automatically /// when the `CdReader` is dropped. On macOS, that will cause the CD drive diff --git a/src/stream.rs b/src/stream.rs index b469162..d3fcca4 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -2,9 +2,14 @@ use std::cmp::min; use crate::{CdReader, CdReaderError, RetryConfig, Toc, utils}; +/// Configuration for streamed track reads. #[derive(Debug, Clone)] pub struct TrackStreamConfig { + /// Target chunk size in sectors for each `next_chunk` call. + /// + /// `27` sectors is approximately 64 KiB of CD-DA payload. pub sectors_per_chunk: u32, + /// Retry policy applied to each chunk read. pub retry: RetryConfig, } @@ -17,6 +22,13 @@ impl Default for TrackStreamConfig { } } +/// Track-scoped streaming reader for CD-DA PCM data. +/// You can iterate through the data manually; this +/// allows to receive initial data much faster and +/// also allows you to navigate to specific points. +/// +/// To create one, use "reader.open_track_stream" method in +/// order to have correct drive's lifecycle management. pub struct TrackStream<'a> { reader: &'a CdReader, start_lba: u32, @@ -29,6 +41,9 @@ pub struct TrackStream<'a> { impl<'a> TrackStream<'a> { const SECTORS_PER_SECOND: f32 = 75.0; + /// Read the next chunk of PCM data. + /// + /// Returns `Ok(None)` when end-of-track is reached. pub fn next_chunk(&mut self) -> Result>, CdReaderError> { self.next_chunk_with(|lba, sectors, retry| { self.reader.read_sectors_with_retry(lba, sectors, retry) @@ -52,18 +67,23 @@ impl<'a> TrackStream<'a> { Ok(Some(chunk)) } + /// Total number of sectors in this track stream. pub fn total_sectors(&self) -> u32 { self.total_sectors } - pub fn consumed_sectors(&self) -> u32 { - self.total_sectors - self.remaining_sectors - } - + /// Current stream position as a track-relative sector index. + /// Keep in mind that if you are playing the sound directly, this + /// is likely not the track's current position because you probably + /// keep some of the data in your buffer. pub fn current_sector(&self) -> u32 { - self.consumed_sectors() + self.total_sectors - self.remaining_sectors } + /// Seek to an absolute track-relative sector position. + /// + /// Valid range is `0..=total_sectors()`. + /// If the sector value is higher than the total, it will throw an error. pub fn seek_to_sector(&mut self, sector: u32) -> Result<(), CdReaderError> { if sector > self.total_sectors { return Err(CdReaderError::Io(std::io::Error::new( @@ -77,14 +97,25 @@ impl<'a> TrackStream<'a> { Ok(()) } + /// Current stream position in seconds. Functionally equivalent + /// to "current_sector", but converted to seconds. + /// + /// Audio CD timing uses `75 sectors = 1 second`. pub fn current_seconds(&self) -> f32 { self.current_sector() as f32 / Self::SECTORS_PER_SECOND } + /// Total stream duration in seconds. Functionally equivalent + /// to "total_sectors", but converted to seconds. + /// + /// Audio CD timing uses `75 sectors = 1 second`. pub fn total_seconds(&self) -> f32 { self.total_sectors as f32 / Self::SECTORS_PER_SECOND } + /// Seek to an absolute track-relative time position in seconds. + /// + /// Input is converted to sector offset and clamped to track bounds. pub fn seek_to_seconds(&mut self, seconds: f32) -> Result<(), CdReaderError> { if !seconds.is_finite() || seconds < 0.0 { return Err(CdReaderError::Io(std::io::Error::new( @@ -99,6 +130,12 @@ impl<'a> TrackStream<'a> { } impl CdReader { + /// Open a streaming reader for a specific track in the provided TOC. + /// It is important to create track streams through this method so the + /// lifetime for the drive exclusive access is managed through a single + /// CDReader instance. + /// + /// Use `TrackStream::next_chunk` to pull sector-aligned PCM chunks. pub fn open_track_stream<'a>( &'a self, toc: &Toc, From d8e39d6da1d71ea6838c3cfb345486f467702a37 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Sun, 15 Feb 2026 12:00:29 -0800 Subject: [PATCH 4/4] update version to 0.3.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f22f4dd..37ff3fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "cd-da-reader" -version = "0.2.1" +version = "0.3.0" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 6451272..7c09f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cd-da-reader" -version = "0.2.1" +version = "0.3.0" edition = "2024" description = "CD-DA (audio CD) reading library" repository = "https://github.com/Bloomca/rust-cd-da-reader"