From 7de4522e8c45f7df43dbe79240e5bffe140334e0 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 12 Jul 2026 18:44:40 -0400 Subject: [PATCH 1/5] feat: port AudioSectorReader file backing onto the 1.0 API Reconstructs the pluggable file/image backing from the pre-1.0 `file-based-backend` branch on top of main's unified read API, since a literal rebase would collide with main's independently-built data-track support. - backend.rs: AudioSectorReader trait, `read_track` free fn, and TrackReadError, with the CdReader impl now reading via `read_sector_range(.., &ReadOptions::default())`. - Expose the support surface the seam needs: free `create_wav` and a public `lba_to_msf` (for building a Toc from image metadata). - examples: file_backend (dependency-free backing) and save_data_track (detect -> read cooked -> save .iso -> mount). - docs/consuming-cd-da-reader.md: downstream-consumer guide covering the options model, sector formats, detection, the data-track workflow, Mode 1 vs Mode 2, file backing, and a pre-1.0 -> 1.0 migration table. - README: data-track and file-image sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 45 ++++++ docs/consuming-cd-da-reader.md | 281 +++++++++++++++++++++++++++++++++ examples/file_backend.rs | 85 ++++++++++ examples/save_data_track.rs | 94 +++++++++++ src/backend.rs | 203 ++++++++++++++++++++++++ src/lib.rs | 20 ++- src/parse_toc.rs | 8 +- 7 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 docs/consuming-cd-da-reader.md create mode 100644 examples/file_backend.rs create mode 100644 examples/save_data_track.rs create mode 100644 src/backend.rs diff --git a/README.md b/README.md index 87c20f6..41d994b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,51 @@ std::fs::write("myfile.wav", wav)?; This code will read the first track from the CD file and save it as a WAVE file, which will be playable by any music player. +## Reading data tracks + +Blocking reads and streaming reads share the same options struct, so switching from audio to data is just a matter of the format you pass. Every track's format can be auto-detected: + +```rust +use cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; + +let reader = CdReader::open_default()?; +let toc = reader.read_toc()?; + +// A "data track" is simply `!is_audio` — there is no dedicated helper. +let data_track = toc.tracks.iter().find(|t| !t.is_audio) + .ok_or("no data track on this disc")?; + +// Mode 1 data tracks detect as Mode1Cooked (2048 B user data per sector), +// which is exactly the ISO 9660 image — write it out and mount it. +let format = reader.detect_track_format(data_track)?; +let options = ReadOptions::default().with_format(format); +let image = reader.read_track_with_options(&toc, data_track.number, &options)?; +std::fs::write("disc.iso", &image)?; +``` + +Mode 1 is fully handled (`Mode1Cooked` for the ready-to-mount user data, `Mode1Raw` for the complete 2352-byte sector). Mode 2 is *detected* (`Mode2Raw`) but its per-sector XA payload extraction is left to the consumer. The full workflow — detect, save, and platform-specific mount commands — is in `examples/save_data_track.rs`, and the detailed guide is [docs/consuming-cd-da-reader.md](docs/consuming-cd-da-reader.md). + +## Reading from a file image + +Everything above a raw sector read is hardware-independent, so you can read tracks from an image (CHD, BIN/CUE, an in-memory buffer, ...) instead of a drive. Implement `AudioSectorReader` for your backing — it must return raw sectors in the exact CD-DA format the physical reader produces: 2352 bytes/sector, 16-bit signed little-endian, stereo — and reuse the crate's TOC/track machinery, with no image-format dependencies pulled into this crate: + +```rust +use cd_da_reader::{AudioSectorReader, create_wav, read_track}; + +impl AudioSectorReader for MyImage { + type Error = std::io::Error; + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + // return exactly count * 2352 bytes of little-endian PCM + todo!() + } +} + +let pcm = read_track(&image, &toc, 1)?; // build `toc` from the image's metadata +let wav = create_wav(pcm); // free fn; also CdReader::create_wav +``` + +`CdReader` itself implements `AudioSectorReader`, so drive-backed and file-backed code share the generic `read_track` path. See `examples/file_backend.rs` for a complete, dependency-free example. + ## What about metadata? You might have asked why do we expose LBA/MSF values if the track reading is abstracted behind specific track numbers. The reason for that is metadata. Even though there is a command [CD-TEXT](https://en.wikipedia.org/wiki/CD-Text) for storing data directly, it is not exposed in this library due to it being extremely unreliable. diff --git a/docs/consuming-cd-da-reader.md b/docs/consuming-cd-da-reader.md new file mode 100644 index 0000000..7aec030 --- /dev/null +++ b/docs/consuming-cd-da-reader.md @@ -0,0 +1,281 @@ +# Consuming `cd-da-reader` (1.0) + +A guide for downstream projects. It covers the unified read/stream model, sector +formats and auto-detection, the data-track workflow (save → mount → explore), +what is and isn't automatic for Mode 1 vs Mode 2, reading from file/image +backings, and a migration cheat-sheet from the pre-1.0 API. + +Everything here is exercised by the crate's `examples/`; each section links the +runnable one. + +--- + +## 1. The shape of the API + +Three steps, always in this order: + +```rust +use cd_da_reader::CdReader; + +let reader = CdReader::open_default()?; // 1. get a drive +let toc = reader.read_toc()?; // 2. read the table of contents +let pcm = reader.read_track(&toc, 1)?; // 3. read a track (audio, by default) +``` + +Opening a drive: + +| Call | Use when | +| --- | --- | +| `CdReader::open_default()` | Grab the first drive that has an audio CD. Usually what you want. | +| `CdReader::list_drives()` → `CdReader::open(&drive)` | Let the user choose; each `DriveInfo` has `has_audio_cd`. | +| `CdReader::open_path("disk6" \| "/dev/sr0" \| r"\\.\E:")` | You already know the platform device path. | + +The reader owns the open handle and closes it on `Drop`. Use one reader at a +time — CD drives are physical, sequential devices. + +--- + +## 2. One options struct for every read + +Blocking reads and streaming reads run over the **same** machinery; the only +difference is which options struct you hand in. + +- **Blocking:** [`ReadOptions`] → `read_track_with_options` / `read_sector_range` +- **Streaming:** [`TrackStreamOptions`] → `open_track_stream_with_options` + +Both are builders whose defaults read **audio** with the default retry policy, so +you override only what you need: + +```rust +use cd_da_reader::{ReadOptions, SectorReadFormat, RetryConfig}; + +let options = ReadOptions::default() + .with_format(SectorReadFormat::Mode1Cooked) // default: Audio + .with_retry(RetryConfig::default().with_max_attempts(6)); + +// NOTE: read_track_with_options takes the options by reference. +let data = reader.read_track_with_options(&toc, 3, &options)?; +``` + +The convenience methods are just defaults over this: + +| Convenience | Equivalent | +| --- | --- | +| `read_track(&toc, n)` | `read_track_with_options(&toc, n, &ReadOptions::default())` | +| `open_track_stream(&toc, n)` | `open_track_stream_with_options(&toc, n, TrackStreamOptions::default())` | + +`read_track_with_options` validates the requested format against the track type +from the TOC and returns `CdReaderError::TrackFormatMismatch` if, say, you ask for +`Audio` on a data track. `read_sector_range(start_lba, sectors, &options)` is the +low-level escape hatch: it does **no** validation and expects you to supply valid +bounds and a compatible format. + +--- + +## 3. Sector formats + +`SectorReadFormat` selects what the drive returns per sector: + +| Format | Bytes/sector | What it is | +| --- | --- | --- | +| `Audio` | 2352 | CD-DA PCM (16-bit signed LE, stereo, 44100 Hz). | +| `Mode1Cooked` | 2048 | Mode 1 **user data only** — sync/header/EDC/ECC stripped. This is the filesystem image. | +| `Mode1Raw` | 2352 | Complete Mode 1 sector: sync + header + 2048 user + EDC/ECC. | +| `Mode2Raw` | 2352 | Complete Mode 2 sector. The Mode 2 *form* is per-sector; the payload lives behind an XA subheader. | + +`format.sector_size()` returns these numbers, so +`bytes == sectors * format.sector_size()`. + +### Auto-detecting a track's format + +You rarely need to hard-code the format — ask the drive: + +```rust +for track in &toc.tracks { + let format = reader.detect_track_format(track)?; + println!("Track #{}: {format:?}", track.number); +} +``` + +See `examples/detect_track_formats.rs`. + +`detect_track_format` resolves to: + +- `Audio` for audio tracks (straight from the TOC), +- `Mode1Cooked` for Mode 1 data tracks, +- `Mode2Raw` for Mode 2 data tracks. + +It uses MMC `READ TRACK INFORMATION`, and falls back to inspecting one raw sector +if the drive's Data Mode field is inconclusive. If it still can't tell, you get +`CdReaderError::CannotDetectTrackFormat`. + +> There is **no** `find_data_track(&toc)` helper — a "data track" is just +> `!track.is_audio`, so the idiom is a plain filter: +> `toc.tracks.iter().find(|t| !t.is_audio)`. + +--- + +## 4. Reading a data track: save → mount → explore + +The common case is a mixed-mode / enhanced ("CD-Extra") disc: audio tracks plus a +data track holding an ISO 9660 filesystem with extra files (artwork, videos, +liner notes). + +Because `detect_track_format` returns `Mode1Cooked` for such a track, and cooked +Mode 1 is *exactly* the 2048-byte user data per sector, reading the whole track +cooked gives you a byte-for-byte ISO 9660 image you can write to `.iso` and mount: + +```rust +use cd_da_reader::{ReadOptions, SectorReadFormat}; + +let data_track = toc.tracks.iter().find(|t| !t.is_audio) + .ok_or("no data track on this disc")?; + +let format = reader.detect_track_format(data_track)?; // Mode1Cooked here +assert_eq!(format, SectorReadFormat::Mode1Cooked); + +let options = ReadOptions::default().with_format(format); +let image = reader.read_track_with_options(&toc, data_track.number, &options)?; +std::fs::write("disc.iso", &image)?; +``` + +Then mount and explore: + +| OS | Mount | Unmount | +| --- | --- | --- | +| macOS | `hdiutil attach disc.iso` | `hdiutil detach /Volumes/` | +| Linux | `sudo mount -o loop,ro disc.iso /mnt/cd` | `sudo umount /mnt/cd` | +| Windows | `Mount-DiskImage -ImagePath disc.iso` | `Dismount-DiskImage -ImagePath disc.iso` | + +The full runnable version — including the Mode 2 branch — is +`examples/save_data_track.rs`. To verify a data read against the on-disc ISO +structure (sync pattern, `CD001` signature, cooked-equals-raw), see +`examples/read_data_track.rs`. + +--- + +## 5. Mode 1 vs Mode 2 — what's automatic + +**Mode 1 is fully handled.** You can read it either way and both are reliable: + +- `Mode1Cooked` (2048 B) — the ready-to-mount user data. +- `Mode1Raw` (2352 B) — the complete sector if you want the framing/ECC. The + user data is `raw[16..16+2048]` (sync 12 + header 4). `detect_track_format` + returns `Mode1Cooked`, but you can request raw instead: + + ```rust + let options = ReadOptions::default().with_format(SectorReadFormat::Mode1Raw); + ``` + +**Mode 2 is detected but not cooked for you.** Mode 2 mixes Form 1 (2048-byte +payload) and Form 2 (2324-byte payload) sectors, and the form is encoded in an +8-byte **XA subheader** at the front of each sector's user area — it is a +*per-sector* property, so there is no single "cooked" size for the track. The +crate therefore exposes Mode 2 only as `Mode2Raw` (complete 2352-byte sectors) +and leaves payload extraction to you: read raw, and for each sector inspect the +subheader to decide Form 1 vs Form 2 and slice the payload accordingly. This is +intentionally the consumer's responsibility. + +--- + +## 6. Streaming + +Same formats and retry config, pulled incrementally — for live playback or +progress reporting instead of one big blocking read: + +```rust +use cd_da_reader::TrackStreamOptions; + +let options = TrackStreamOptions::default() + .with_sectors_per_chunk(27); // ~64 KB of audio per chunk +let mut stream = reader.open_track_stream_with_options(&toc, 1, options)?; + +while let Some(chunk) = stream.next_chunk()? { + // chunk length == sectors_this_chunk * format.sector_size() +} +``` + +`TrackStream` also exposes `total_sectors()`, `current_sector()`, +`current_seconds()`, `total_seconds()`, `seek_to_sector()`, and +`seek_to_seconds()`. See `examples/stream_with_progress.rs` and +`examples/stream_last_track.rs`. + +--- + +## 7. Reading from a file/image instead of a drive + +Everything above a raw sector read — the `Toc`/`Track` types, track-bounds math +(including the CD-Extra trailing-gap rule), and WAV wrapping — is +hardware-independent. Implement [`AudioSectorReader`] for any backing that can +yield raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory buffer, a +network stream) and reuse the same machinery — **the crate takes on no +image-format dependencies**: + +```rust +use cd_da_reader::{AudioSectorReader, Toc, Track, create_wav, lba_to_msf, read_track}; + +struct MyImage { /* ... */ } + +impl AudioSectorReader for MyImage { + type Error = std::io::Error; + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + // decode from your image and return exactly count * 2352 bytes + // (16-bit signed LE, stereo) + todo!() + } +} + +// Build a Toc from the image's own metadata (lba_to_msf fills start_msf), +// then read tracks with the free `read_track`: +let pcm = read_track(&image, &toc, 1)?; // Result, TrackReadError> +let wav = create_wav(pcm); // free fn; also CdReader::create_wav +``` + +`CdReader` itself implements `AudioSectorReader`, so drive-backed and file-backed +code can share the generic `read_track` path. `read_track` returns +`TrackReadError`, which keeps a bad track request (`Toc`) separate from a +failure inside your backing (`Backend(E)`), preserving your error type. Full +dependency-free example: `examples/file_backend.rs`. + +--- + +## 8. Errors + +`CdReaderError` is the one error type for drive operations: + +| Variant | Meaning | +| --- | --- | +| `Io(std::io::Error)` | OS/transport failure (open, ioctl, FFI). | +| `Scsi(ScsiError)` | Device reported a SCSI failure; carries status + sense. | +| `Parse(String)` | Couldn't parse a command response. | +| `TrackFormatMismatch { .. }` | Requested a format incompatible with the track type. | +| `CannotDetectTrackFormat { .. }` | `detect_track_format` couldn't determine the format. | +| `NoUsableDrive` | Enumeration found no drive with an audio CD. | + +The file-backing `read_track` uses its own `TrackReadError` (see §7) so your +backing's error type isn't flattened into the SCSI-oriented `CdReaderError`. + +--- + +## 9. Migrating from the pre-1.0 API + +1.0 renamed a lot for clarity. The mechanical substitutions: + +| Before (0.x) | Now (1.0) | +| --- | --- | +| `CdReader::open("disk6")` | `CdReader::open_path("disk6")`, or `CdReader::open(&drive_info)` | +| `SectorReadMode` | `SectorReadFormat` | +| `SectorReadMode::DataCooked` | `SectorReadFormat::Mode1Cooked` | +| `SectorReadMode::DataRaw` | `SectorReadFormat::Mode1Raw` (plus new `Mode2Raw`) | +| `read_data_sectors(lba, n, mode, &cfg)` | `read_sector_range(lba, n, &ReadOptions::default().with_format(fmt).with_retry(cfg))` | +| `read_track_with_retry(&toc, n, &cfg)` | `read_track_with_options(&toc, n, &ReadOptions::default().with_retry(cfg))` | +| `TrackStreamConfig { sectors_per_chunk, retry }` | `TrackStreamOptions::default().with_sectors_per_chunk(..).with_retry(..)` | +| `open_track_stream(&toc, n, cfg)` | `open_track_stream(&toc, n)` or `open_track_stream_with_options(&toc, n, options)` | + +New in 1.0: `detect_track_format`, `Mode1Raw` / `Mode2Raw`, per-read +`ReadOptions`, format validation on `read_track_with_options`, and the +`AudioSectorReader` file-backing seam. + +[`ReadOptions`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.ReadOptions.html +[`TrackStreamOptions`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.TrackStreamOptions.html +[`AudioSectorReader`]: https://docs.rs/cd-da-reader/latest/cd_da_reader/trait.AudioSectorReader.html diff --git a/examples/file_backend.rs b/examples/file_backend.rs new file mode 100644 index 0000000..e3fa1d5 --- /dev/null +++ b/examples/file_backend.rs @@ -0,0 +1,85 @@ +//! Read a track from a file/image backing instead of a physical drive. +//! +//! `cd-da-reader` doesn't bake in any image format (CHD, BIN/CUE, ...). Instead +//! you implement [`AudioSectorReader`] for your backing — supplying raw CD-DA +//! sectors (2352 bytes/sector, 16-bit signed little-endian stereo) — and reuse +//! the crate's TOC/track machinery via [`read_track`] and [`create_wav`]. +//! +//! This example uses a tiny in-memory backing so it stays dependency-free. A +//! real backing (e.g. CHD via `libchdman-rs`) would decode sectors in +//! `read_audio_sectors` and build its TOC from the image's track metadata. +//! +//! Run with: `cargo run --example file_backend` +mod common; + +use cd_da_reader::{AudioSectorReader, Toc, Track, create_wav, lba_to_msf, read_track}; + +/// A backing that holds whole-disc PCM in memory and slices it per sector. +struct InMemoryDisc { + /// Whole-disc PCM, laid out as 2352 bytes per sector. + pcm: Vec, +} + +impl AudioSectorReader for InMemoryDisc { + type Error = std::io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let start = start_lba as usize * 2352; + let end = start + count as usize * 2352; + self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc") + }) + } +} + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("file_backend")?; + + // A real backing derives this TOC from the image's own track metadata. + // Here we fabricate a 2-track disc: 2 seconds + 3 seconds of audio. + let track1_sectors = 75 * 2; + let track2_sectors = 75 * 3; + let total_sectors = track1_sectors + track2_sectors; + + let toc = Toc { + first_track: 1, + last_track: 2, + tracks: vec![ + Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }, + Track { + number: 2, + start_lba: track1_sectors, + start_msf: lba_to_msf(track1_sectors), + is_audio: true, + }, + ], + leadout_lba: total_sectors, + }; + + // Silence, just for the demo — a real backing decodes actual audio here. + let disc = InMemoryDisc { + pcm: vec![0u8; total_sectors as usize * 2352], + }; + + for track in &toc.tracks { + let pcm = read_track(&disc, &toc, track.number)?; + println!( + "track {}: {} bytes ({} sectors)", + track.number, + pcm.len(), + pcm.len() / 2352 + ); + + let wav = create_wav(pcm); + let output_path = output_dir.join(format!("track{:02}.wav", track.number)); + std::fs::write(&output_path, wav)?; + println!(" wrote {}", output_path.display()); + } + + Ok(()) +} diff --git a/examples/save_data_track.rs b/examples/save_data_track.rs new file mode 100644 index 0000000..382aa5a --- /dev/null +++ b/examples/save_data_track.rs @@ -0,0 +1,94 @@ +//! Save the data track of a mixed-mode / enhanced ("CD-Extra") disc as a +//! mountable image, then print how to mount it. +//! +//! This is the end-to-end data-track workflow: +//! 1. read the TOC and pick the (first) data track, +//! 2. auto-detect its sector format with `detect_track_format`, +//! 3. for Mode 1, read it **cooked** (2048 B/sector) — that is exactly the +//! ISO 9660 filesystem image, so it writes straight to a `.iso` you can +//! mount and explore, +//! 4. Mode 2 is detected but not auto-cooked here (see the note it prints and +//! `docs/consuming-cd-da-reader.md`). +//! +//! Run with: `cargo run --example save_data_track` +mod common; + +use cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; + +fn main() -> Result<(), Box> { + let output_dir = common::fresh_output_dir("save_data_track")?; + let reader = CdReader::open_default()?; + let toc = reader.read_toc()?; + + // There is no `find_data_track` helper in the crate — the idiom is a plain + // filter on the TOC, since "data track" is simply `!is_audio`. + let data_track = toc + .tracks + .iter() + .find(|track| !track.is_audio) + .ok_or("no data track on this disc (need a mixed-mode / enhanced CD)")?; + + let format = reader.detect_track_format(data_track)?; + println!("Data track #{} detected as {format:?}\n", data_track.number); + + match format { + SectorReadFormat::Mode1Cooked => { + // Cooked Mode 1 strips sync/header/EDC/ECC, leaving exactly the + // 2048-byte user data per sector — i.e. the raw ISO 9660 image. + let options = ReadOptions::default().with_format(SectorReadFormat::Mode1Cooked); + let image = reader.read_track_with_options(&toc, data_track.number, &options)?; + + let iso_path = output_dir.join(format!("track{:02}.iso", data_track.number)); + std::fs::write(&iso_path, &image)?; + println!( + "Wrote {} ({} bytes, {} sectors)\n", + iso_path.display(), + image.len(), + image.len() / 2048 + ); + print_mount_hint(&iso_path.display().to_string()); + } + SectorReadFormat::Mode2Raw => { + // Mode 2 forms are a per-sector property; producing a clean cooked + // payload requires inspecting each sector's XA subheader, which is + // left to the consumer. We save the complete raw sectors so nothing + // is lost, and point at the docs. + let options = ReadOptions::default().with_format(SectorReadFormat::Mode2Raw); + let raw = reader.read_track_with_options(&toc, data_track.number, &options)?; + + let bin_path = output_dir.join(format!("track{:02}.mode2.bin", data_track.number)); + std::fs::write(&bin_path, &raw)?; + println!( + "This is a Mode 2 track. Saved complete raw sectors to {} \ + ({} bytes, {} sectors).", + bin_path.display(), + raw.len(), + raw.len() / 2352 + ); + println!( + "Extracting a mountable filesystem from Mode 2 is consumer territory — \ + see docs/consuming-cd-da-reader.md (\"Mode 2\")." + ); + } + other => { + return Err(format!( + "data track #{} detected as {other:?}, which is unexpected for a data track", + data_track.number + ) + .into()); + } + } + + Ok(()) +} + +fn print_mount_hint(path: &str) { + println!("Mount it and explore the files:"); + if cfg!(target_os = "macos") { + println!(" hdiutil attach \"{path}\""); + } else if cfg!(target_os = "linux") { + println!(" sudo mount -o loop,ro \"{path}\" /mnt/cd"); + } else if cfg!(target_os = "windows") { + println!(" PowerShell: Mount-DiskImage -ImagePath \"{path}\""); + } +} diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..ddc78f8 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,203 @@ +//! Pluggable audio-sector backings. +//! +//! [`CdReader`](crate::CdReader) reads CD-DA sectors from a physical drive over +//! SCSI/ioctl, but everything *above* the raw sector read — the +//! [`Track`](crate::Track)/[`Toc`](crate::Toc) types, the track-bounds math +//! (including the CD-Extra trailing-gap rule), and WAV wrapping — is +//! hardware-independent. [`AudioSectorReader`] exposes that seam so any backing +//! that can produce raw CD-DA sectors (a CHD image, a BIN/CUE dump, an in-memory +//! buffer, a network stream, ...) reuses the same machinery **without this crate +//! taking on any image-format dependencies**. +//! +//! The image format lives in the caller: implement [`AudioSectorReader`] for +//! your backing, build a [`Toc`](crate::Toc) from the image's own track +//! metadata (see [`lba_to_msf`](crate::lba_to_msf)), then call [`read_track`] to +//! get PCM in the exact same little-endian, 2352-byte/sector format the physical +//! reader produces — ready for [`create_wav`](crate::create_wav). +//! +//! See `examples/file_backend.rs` for a complete, dependency-free example. + +use std::fmt; + +use crate::{CdReader, CdReaderError, ReadOptions, Toc, utils}; + +/// The physical drive is itself an [`AudioSectorReader`], so drive-backed and +/// file-backed code can share the generic [`read_track`] path. This uses the +/// default read options (audio sectors, default retry policy); for explicit +/// control, prefer the inherent [`CdReader::read_track_with_options`]. +impl AudioSectorReader for CdReader { + type Error = CdReaderError; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + self.read_sector_range(start_lba, count, &ReadOptions::default()) + } +} + +/// A source of raw CD-DA audio sectors. +/// +/// Implement this for any backing that can yield audio in the crate's canonical +/// format: **2352 bytes per sector, 16-bit signed little-endian, stereo, 44100 +/// Hz** — byte-for-byte identical to what +/// [`CdReader::read_track`](crate::CdReader::read_track) returns. +/// +/// `start_lba` is an absolute Logical Block Address (a sector index; LBA 0 is +/// the first sector after the lead-in), matching +/// [`Track::start_lba`](crate::Track::start_lba). A successful read of `count` +/// sectors must return exactly `count * 2352` bytes. +pub trait AudioSectorReader { + /// Error type produced by this backing. + type Error; + + /// Read `count` sectors starting at absolute `start_lba`, returning exactly + /// `count * 2352` bytes of little-endian PCM. + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error>; +} + +/// Error returned by [`read_track`]. +/// +/// Separates a bad track request (not in the TOC, or invalid TOC bounds) from a +/// failure inside the backing [`AudioSectorReader`], whose error type is +/// preserved as `E` rather than flattened into this crate's SCSI-oriented +/// [`CdReaderError`](crate::CdReaderError). +#[derive(Debug)] +pub enum TrackReadError { + /// The requested track number was not found in the TOC, or the resolved + /// sector bounds were invalid. + Toc(std::io::Error), + /// The backing [`AudioSectorReader`] failed while reading sectors. + Backend(E), +} + +impl fmt::Display for TrackReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toc(err) => write!(f, "TOC error: {err}"), + Self::Backend(err) => write!(f, "backend error: {err}"), + } + } +} + +impl std::error::Error for TrackReadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Toc(err) => Some(err), + Self::Backend(err) => Some(err), + } + } +} + +/// Read raw PCM for one track from any [`AudioSectorReader`] backing. +/// +/// This is the file/image counterpart to +/// [`CdReader::read_track`](crate::CdReader::read_track): it resolves the track's +/// sector range from `toc` (honouring the CD-Extra trailing-gap rule) and pulls +/// those sectors from `src`. The returned bytes are the same little-endian, +/// 2352-B/sector PCM, so [`create_wav`](crate::create_wav) wraps them into a +/// playable file unchanged. +pub fn read_track( + src: &R, + toc: &Toc, + track_no: u8, +) -> Result, TrackReadError> { + let (start_lba, sectors) = + utils::get_track_bounds(toc, track_no).map_err(TrackReadError::Toc)?; + src.read_audio_sectors(start_lba, sectors) + .map_err(TrackReadError::Backend) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Track, create_wav, lba_to_msf}; + + /// Minimal in-memory backing: whole-disc PCM sliced by sector. + struct MemDisc { + pcm: Vec, + } + + impl AudioSectorReader for MemDisc { + type Error = std::io::Error; + + fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error> { + let start = start_lba as usize * 2352; + let end = start + count as usize * 2352; + self.pcm.get(start..end).map(<[u8]>::to_vec).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of disc") + }) + } + } + + fn toc_two_tracks(t1_sectors: u32, t2_sectors: u32) -> Toc { + Toc { + first_track: 1, + last_track: 2, + tracks: vec![ + Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }, + Track { + number: 2, + start_lba: t1_sectors, + start_msf: lba_to_msf(t1_sectors), + is_audio: true, + }, + ], + leadout_lba: t1_sectors + t2_sectors, + } + } + + #[test] + fn reads_track_bytes_for_the_right_range() { + let (t1, t2) = (100u32, 50u32); + let disc = MemDisc { + pcm: vec![0u8; (t1 + t2) as usize * 2352], + }; + let toc = toc_two_tracks(t1, t2); + + let track1 = read_track(&disc, &toc, 1).unwrap(); + let track2 = read_track(&disc, &toc, 2).unwrap(); + + assert_eq!(track1.len(), t1 as usize * 2352); + assert_eq!(track2.len(), t2 as usize * 2352); + } + + #[test] + fn create_wav_wraps_backend_pcm() { + let disc = MemDisc { + pcm: vec![0u8; 10 * 2352], + }; + // Single-track disc: no next track, so the leadout bounds the read. + let toc = Toc { + first_track: 1, + last_track: 1, + tracks: vec![Track { + number: 1, + start_lba: 0, + start_msf: lba_to_msf(0), + is_audio: true, + }], + leadout_lba: 10, + }; + + let pcm = read_track(&disc, &toc, 1).unwrap(); + let wav = create_wav(pcm); + assert_eq!(&wav[0..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(wav.len(), 44 + 10 * 2352); + } + + #[test] + fn missing_track_is_a_toc_error() { + let disc = MemDisc { + pcm: vec![0u8; 2352], + }; + let toc = toc_two_tracks(1, 0); + match read_track(&disc, &toc, 99) { + Err(TrackReadError::Toc(_)) => {} + other => panic!("expected TOC error, got {other:?}"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index c142ca1..f10b008 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,6 +145,9 @@ mod read_loop; mod retry; mod stream; mod utils; + +mod backend; +pub use backend::{AudioSectorReader, TrackReadError, read_track}; pub use data_reader::{ReadOptions, SectorReadFormat}; pub use discovery::DriveInfo; pub use errors::{CdReaderError, ScsiError, ScsiOp}; @@ -152,6 +155,7 @@ pub use retry::RetryConfig; pub use stream::{TrackStream, TrackStreamOptions}; mod parse_toc; +pub use parse_toc::lba_to_msf; /// Representation of the track from TOC, purely in terms of data location on the CD. #[derive(Debug)] @@ -184,6 +188,18 @@ pub struct Toc { pub leadout_lba: u32, } +/// Wrap raw CD-DA PCM in a 44-byte WAV/RIFF header (44100 Hz, 2 channels, +/// 16-bit) so the bytes become a playable file. +/// +/// This is the free-function form of [`CdReader::create_wav`], usable without +/// naming the physical-drive type — for example on PCM obtained from a file or +/// image backing via [`read_track`]. +pub fn create_wav(data: Vec) -> Vec { + let mut header = utils::create_wav_header(data.len() as u32); + header.extend_from_slice(&data); + header +} + /// Helper struct to interact with the audio CD. Internally it holds a platform-specific /// handle to the open CD drive to read from it and it is correctly closed when CDReader /// is dropped. @@ -224,9 +240,7 @@ impl CdReader { /// /// * `data` - vector of bytes received from `read_track` function pub fn create_wav(data: Vec) -> Vec { - let mut header = utils::create_wav_header(data.len() as u32); - header.extend_from_slice(&data); - header + crate::create_wav(data) } /// Read Table of Contents for the opened drive. You'll likely only need to access diff --git a/src/parse_toc.rs b/src/parse_toc.rs index ded881b..1525e14 100644 --- a/src/parse_toc.rs +++ b/src/parse_toc.rs @@ -66,7 +66,13 @@ pub(crate) fn parse_toc(data: Vec) -> std::io::Result { } } -fn lba_to_msf(lba: u32) -> (u8, u8, u8) { +/// Convert a Logical Block Address to its Minutes/Seconds/Frames address. +/// +/// MSF addresses include the fixed 2-second (150-frame) lead-in offset, so +/// `lba_to_msf(0)` is `(0, 2, 0)`. This is handy when building a [`Toc`] for a +/// file/image backing (see [`AudioSectorReader`](crate::AudioSectorReader)), +/// where you have sector indices but need to populate [`Track::start_msf`]. +pub fn lba_to_msf(lba: u32) -> (u8, u8, u8) { let total_frames = lba + 150; // MSF addresses are offset by 150 let minutes = (total_frames / 75 / 60) as u8; let seconds = ((total_frames / 75) % 60) as u8; From 205c919bcd7ae1c4423f853b2ea8eccf4ab5e84a Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 12 Jul 2026 19:42:51 -0400 Subject: [PATCH 2/5] perf(example): stream save_data_track to disk instead of buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-track example read the entire track into a Vec before writing — a full data track can be hundreds of MB. Switch it to the streaming API (open_track_stream_with_options) so cooked/raw chunks are written as they arrive and peak memory is one ~64 KB chunk regardless of track size. Both the ISO (Mode1Cooked) and Mode 2 branches now share one stream_track_to_file helper with a progress line, which also showcases that reading and streaming run over the same options/read path. Docs updated to recommend streaming for large images. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/consuming-cd-da-reader.md | 14 +++++-- examples/save_data_track.rs | 72 +++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/docs/consuming-cd-da-reader.md b/docs/consuming-cd-da-reader.md index 7aec030..1dca5d9 100644 --- a/docs/consuming-cd-da-reader.md +++ b/docs/consuming-cd-da-reader.md @@ -139,6 +139,12 @@ let image = reader.read_track_with_options(&toc, data_track.number, &options)?; std::fs::write("disc.iso", &image)?; ``` +The blocking read above buffers the whole image in memory. A data track can be +hundreds of MB, so for anything non-trivial prefer the streaming path with the +same `Mode1Cooked` format — pull chunks and write them straight to the file, so +peak memory stays at one chunk. That is exactly what `examples/save_data_track.rs` +does. + Then mount and explore: | OS | Mount | Unmount | @@ -147,10 +153,10 @@ Then mount and explore: | Linux | `sudo mount -o loop,ro disc.iso /mnt/cd` | `sudo umount /mnt/cd` | | Windows | `Mount-DiskImage -ImagePath disc.iso` | `Dismount-DiskImage -ImagePath disc.iso` | -The full runnable version — including the Mode 2 branch — is -`examples/save_data_track.rs`. To verify a data read against the on-disc ISO -structure (sync pattern, `CD001` signature, cooked-equals-raw), see -`examples/read_data_track.rs`. +The full runnable version — streaming to disk with a progress line, including the +Mode 2 branch — is `examples/save_data_track.rs`. To verify a data read against +the on-disc ISO structure (sync pattern, `CD001` signature, cooked-equals-raw), +see `examples/read_data_track.rs`. --- diff --git a/examples/save_data_track.rs b/examples/save_data_track.rs index 382aa5a..591a127 100644 --- a/examples/save_data_track.rs +++ b/examples/save_data_track.rs @@ -4,16 +4,25 @@ //! This is the end-to-end data-track workflow: //! 1. read the TOC and pick the (first) data track, //! 2. auto-detect its sector format with `detect_track_format`, -//! 3. for Mode 1, read it **cooked** (2048 B/sector) — that is exactly the -//! ISO 9660 filesystem image, so it writes straight to a `.iso` you can -//! mount and explore, +//! 3. for Mode 1, stream it **cooked** (2048 B/sector) straight to a `.iso` — +//! cooked Mode 1 is exactly the ISO 9660 filesystem image, so it mounts as +//! is. Streaming keeps memory flat regardless of track size (a full data +//! track can be hundreds of MB), //! 4. Mode 2 is detected but not auto-cooked here (see the note it prints and //! `docs/consuming-cd-da-reader.md`). //! +//! Reads and streams run over the same options and read path, so the only +//! difference from a blocking `read_track_with_options` is that we pull chunks +//! and write them as they arrive. +//! //! Run with: `cargo run --example save_data_track` mod common; -use cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use cd_da_reader::{CdReader, SectorReadFormat, Toc, TrackStreamOptions}; fn main() -> Result<(), Box> { let output_dir = common::fresh_output_dir("save_data_track")?; @@ -35,16 +44,13 @@ fn main() -> Result<(), Box> { SectorReadFormat::Mode1Cooked => { // Cooked Mode 1 strips sync/header/EDC/ECC, leaving exactly the // 2048-byte user data per sector — i.e. the raw ISO 9660 image. - let options = ReadOptions::default().with_format(SectorReadFormat::Mode1Cooked); - let image = reader.read_track_with_options(&toc, data_track.number, &options)?; - let iso_path = output_dir.join(format!("track{:02}.iso", data_track.number)); - std::fs::write(&iso_path, &image)?; + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &iso_path)?; + println!( - "Wrote {} ({} bytes, {} sectors)\n", + "Wrote {} ({bytes} bytes, {} sectors)\n", iso_path.display(), - image.len(), - image.len() / 2048 + bytes / format.sector_size() as u64 ); print_mount_hint(&iso_path.display().to_string()); } @@ -53,17 +59,14 @@ fn main() -> Result<(), Box> { // payload requires inspecting each sector's XA subheader, which is // left to the consumer. We save the complete raw sectors so nothing // is lost, and point at the docs. - let options = ReadOptions::default().with_format(SectorReadFormat::Mode2Raw); - let raw = reader.read_track_with_options(&toc, data_track.number, &options)?; - let bin_path = output_dir.join(format!("track{:02}.mode2.bin", data_track.number)); - std::fs::write(&bin_path, &raw)?; + let bytes = stream_track_to_file(&reader, &toc, data_track.number, format, &bin_path)?; + println!( "This is a Mode 2 track. Saved complete raw sectors to {} \ - ({} bytes, {} sectors).", + ({bytes} bytes, {} sectors).", bin_path.display(), - raw.len(), - raw.len() / 2352 + bytes / format.sector_size() as u64 ); println!( "Extracting a mountable filesystem from Mode 2 is consumer territory — \ @@ -82,6 +85,39 @@ fn main() -> Result<(), Box> { Ok(()) } +/// Stream one track straight to a file in `format`, without ever holding the +/// whole track in memory. Returns the number of bytes written. +/// +/// Uses the streaming API so peak memory is one chunk (~64 KB) instead of the +/// entire track, which matters for large data images. +fn stream_track_to_file( + reader: &CdReader, + toc: &Toc, + track_no: u8, + format: SectorReadFormat, + path: &Path, +) -> Result> { + let options = TrackStreamOptions::default().with_format(format); + let mut stream = reader.open_track_stream_with_options(toc, track_no, options)?; + + let total_sectors = stream.total_sectors(); + let mut writer = BufWriter::new(File::create(path)?); + let mut written = 0u64; + + while let Some(chunk) = stream.next_chunk()? { + writer.write_all(&chunk)?; + written += chunk.len() as u64; + + let done = stream.current_sector(); + let pct = done as f32 / total_sectors as f32 * 100.0; + eprint!("\r {done}/{total_sectors} sectors ({pct:5.1}%)"); + } + eprintln!("\r {total_sectors}/{total_sectors} sectors (100.0%)"); + + writer.flush()?; + Ok(written) +} + fn print_mount_hint(path: &str) { println!("Mount it and explore the files:"); if cfg!(target_os = "macos") { From 885335edd699d5fa5a25b7117084895fbb95a0b7 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sun, 12 Jul 2026 19:43:42 -0400 Subject: [PATCH 3/5] chore: bump version to 1.0.0 First release carrying the breaking 1.0 API (SectorReadFormat, per-read ReadOptions, detect_track_format) plus the AudioSectorReader file backing. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cd4280..7ae1b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "cd-da-reader" -version = "0.4.1" +version = "1.0.0" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index ea04f27..b603d52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cd-da-reader" -version = "0.4.1" +version = "1.0.0" edition = "2024" description = "CD-DA (audio CD) reading library" repository = "https://github.com/Bloomca/rust-cd-da-reader" From 8c25645a47b0fb4e26a3764e82377725eb296787 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 17 Jul 2026 19:08:44 -0400 Subject: [PATCH 4/5] Rollback version bump --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b603d52..ea04f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cd-da-reader" -version = "1.0.0" +version = "0.4.1" edition = "2024" description = "CD-DA (audio CD) reading library" repository = "https://github.com/Bloomca/rust-cd-da-reader" From dace3b45572ea4d2c691cb22b330b2142239fa0c Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 17 Jul 2026 19:09:42 -0400 Subject: [PATCH 5/5] missed Cargo.lock in version bump exclusion --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 7ae1b93..0cd4280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "cd-da-reader" -version = "1.0.0" +version = "0.4.1" dependencies = [ "cc", "libc",