diff --git a/README.md b/README.md index 2e18a2f..ad431b2 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,15 @@ [![Crates.io](https://img.shields.io/crates/v/cd-da-reader.svg)](https://crates.io/crates/cd-da-reader) [![CI](https://github.com/Bloomca/rust-cd-da-reader/actions/workflows/pull-request-workflow.yaml/badge.svg?branch=main)](https://github.com/Bloomca/rust-cd-da-reader/actions/workflows/pull-request-workflow.yaml) -This is a simple library to read audio CDs. At the core it was written to enable CD ripping, but you can also implement a live audio CD player with its help. It is cross-platform and tested on Windows, macOS and Linux and abstracts both access to the CD drive and reading the actual data from it. All operations happen in this order on each platform: +This library provides cross-platform audio CD reading capabilities and is tested on Windows, macOS, and Linux. It was written to enable CD ripping, but it can also be used to build a live audio CD player. The primary API reads physical discs; to read from a file, image, or another custom source, implement [`AudioSectorReader`](https://docs.rs/cd-da-reader/latest/cd_da_reader/trait.AudioSectorReader.html) and provide a [`Toc`](https://docs.rs/cd-da-reader/latest/cd_da_reader/struct.Toc.html). -1. Get a CD drive's handle -2. Read ToC (table of contents) of the audio CD -3. Read track data using ranges from ToC +Physical-disc access uses platform CD-drive APIs on macOS and direct SCSI commands on Windows and Linux. The library abstracts both access to the drive and reading the data, so callers do not interact with the hardware directly. + +A typical audio CD read happens in this order: + +1. Open a CD drive +2. Read the disc's TOC (Table of Contents) +3. Read track data using sector ranges from the TOC Let's go through each concept in order. @@ -18,7 +22,7 @@ First thing, we'll need to get a hold of the CD drive. You can see the drive's l This is a bit brittle, so this library provides a few helper methods to find a correct CD drive. By far the most straightforward approach is to simply open the "default" drive: ```rust -use cd_da_reader::{CdReader}; +use cd_da_reader::CdReader; let reader = CdReader::open_default()?; ``` @@ -26,7 +30,7 @@ let reader = CdReader::open_default()?; This code will scan the CD drives and will open the first one with an audio CD in it, and _usually_ this is what you want. If you want to provide a choice, there is an additional function to list all drives: ```rust -use cd_da_reader::{CdReader}; +use cd_da_reader::CdReader; let drives = CdReader::list_drives()?; ``` @@ -38,7 +42,10 @@ be opened directly: use cd_da_reader::CdReader; let drives = CdReader::list_drives()?; -let selected = drives.first().ok_or("no optical drives found")?; +let selected = drives + .iter() + .find(|drive| drive.has_audio_cd) + .ok_or("no drive with an audio CD found")?; let reader = CdReader::open(selected)?; ``` @@ -50,20 +57,20 @@ use cd_da_reader::CdReader; let reader = CdReader::open_path("disk14")?; ``` -## Reading ToC +## Reading the TOC -Each audio CD provides internal Table of Contents, which is an internal map of all the available tracks with the block addresses. The only semantic metadata we get from it is the number of tracks, but it is crucial to read it so that we can issues commands to read actual tracks data. +Each audio CD carries a Table of Contents containing the location and type of every track. Read it before issuing track-level read commands: ```rust -use cd_da_reader::{CdReader}; +use cd_da_reader::CdReader; let reader = CdReader::open_default()?; let toc = reader.read_toc()?; ``` -This will give us a struct like: +This returns a structure like: -``` +```text { first_track: 1, last_track: 11, @@ -82,67 +89,115 @@ This will give us a struct like: } ``` -**LBA (Logical Block Address)** is a simple sequential sector index. LBA 0 is the first readable sector after the 2-second lead-in pre-gap at the start of every disc. It is the most convenient format for issuing read commands and used internally to read data blocks. +Each track's `number` comes from the disc. It is not a zero-based index into `toc.tracks`, and track numbers are not guaranteed to begin at 1. Select tracks using their metadata—such as `is_audio`—and pass the reported `number` to the read APIs. -**MSF (Minutes:Seconds:Frames)** is a time-based address inherited from the physical disc layout. A "frame" here is one CD sector, and the spec defines 75 frames per second. MSF includes a fixed 2-second (150-frame) offset for the lead-in area, so `MSF (0, 2, 0)` corresponds to LBA 0 — the very start of track data. +Each track also has two equivalent address fields: -The two are fully interchangeable: `LBA + 150 = total frames from disc start`, from which minutes, seconds, and frames are derived by dividing by 75 and 60. You will typically only need LBA values for reading track data, while MSF is required for services like MusicBrainz disc ID calculation. +- **LBA (Logical Block Address):** a sequential sector index used internally for read commands. LBA 0 corresponds to the first program-area sector at MSF `(0, 2, 0)`. +- **MSF (Minutes:Seconds:Frames):** a time-based address inherited from the physical disc layout. One frame is one sector, and there are 75 frames per second. MSF includes the standard 150-frame offset, so `(0, 2, 0)` corresponds to LBA 0. + +The two are interchangeable: `LBA + 150 = total MSF frames`. Most callers only need the track number, while LBA is useful for custom sector ranges and MSF is required by services such as MusicBrainz disc ID calculation. ## Reading tracks -Finally, after we got ToC, we can read tracks. The usual boundaries for the track are the starting LBA and the starting LBA for the next track (or leadout LBA value for the last track). For CD-Extra discs where the last audio track is followed only by data tracks, the library subtracts the standard 11,400-sector audio/data session gap from the first data track start. This library abstracts these things and simply reads provided track numbers. To read a track, all you need to do is call: +Pass the TOC and a track's reported number to `CdReader::read_track`. The library calculates its sector boundaries automatically. Normally a track ends where the next one starts, or at the lead-out for the final track. On CD-Extra discs, the trailing audio/data session gap is excluded from the last audio track. ```rust -use cd_da_reader::{CdReader}; +use cd_da_reader::CdReader; let reader = CdReader::open_default()?; let toc = reader.read_toc()?; -// we assume that track #1 exists for simplicity -let data = reader.read_track(&toc, 1)?; + +// Track numbers come from the disc; do not assume the first audio track is #1. +let track = toc + .tracks + .iter() + .find(|track| track.is_audio) + .ok_or("no audio tracks found")?; +let data = reader.read_track(&toc, track.number)?; ``` -This is a blocking call and takes a lot of time (depends on the track length and CD/drive quality due to retries). If you want to do something with the data as it comes, use streaming API: +`read_track` is a blocking call that buffers the complete track, so it can take some time and use hundreds of megabytes of memory. The streaming API instead returns sector-aligned chunks as they are read, keeping memory usage low and allowing progress reporting or playback before the complete track is available. + +Streaming is still synchronous: each `next_chunk` call waits for the drive. This is often suitable for a CLI, where the loop can run on the main thread and report progress. A GUI should run the loop on a worker thread so drive reads do not block its event loop. ```rust use cd_da_reader::CdReader; let reader = CdReader::open_default()?; let toc = reader.read_toc()?; +let track = toc + .tracks + .iter() + .find(|track| track.is_audio) + .ok_or("no audio tracks found")?; -let mut stream = reader.open_track_stream(&toc, 1)?; +let mut stream = reader.open_track_stream(&toc, track.number)?; while let Some(chunk) = stream.next_chunk()? { - // do something with the chunk directly + // Process one sector-aligned PCM chunk. } ``` -## Track format +## Audio track format -The data you receive by reading tracks is [PCM](https://en.wikipedia.org/wiki/Pulse-code_modulation), the same raw format used by WAV files. Audio CDs use 16-bit stereo PCM sampled at 44,100 Hz, so each second of audio is: +Audio track data is raw [PCM](https://en.wikipedia.org/wiki/Pulse-code_modulation), the same uncompressed sample representation used by PCM WAV files. Audio CDs use signed 16-bit little-endian stereo PCM sampled at 44,100 Hz: -``` -44,100 samples * 2 channels * 2 bytes = 176,400 bytes/second +```text +44,100 sample frames * 2 channels * 2 bytes = 176,400 bytes/second ``` -Each CD sector holds exactly 2,352 bytes of audio payload (176,400 / 75 = 2,352), that's why there are 75 sectors per second. A typical 3-minute track is about 31 MB of raw PCM, and a full 74-minute CD holds ~650 MB. +Each audio sector contains exactly 2,352 bytes (176,400 / 75 = 2,352), giving 75 sectors per second. A typical 3-minute track is about 31.8 MB (30.3 MiB). A 74-minute disc contains about 783 MB (747 MiB) of raw PCM; common 80-minute media contains about 847 MB (808 MiB). -Converting PCM data to a playable WAV file only requires prepending a 44-byte RIFF header. In fact, there is a helper for that in this library: +`create_wav` prepends a standard 44-byte RIFF/WAVE header. It does not validate or convert the audio, but PCM returned by `CdReader::read_track` already has the required format: ```rust use cd_da_reader::{CdReader, create_wav}; let reader = CdReader::open_default()?; let toc = reader.read_toc()?; -// we assume that track #1 exists for simplicity -let data = reader.read_track(&toc, 1)?; +let track = toc + .tracks + .iter() + .find(|track| track.is_audio) + .ok_or("no audio tracks found")?; +let data = reader.read_track(&toc, track.number)?; let wav = create_wav(data); -std::fs::write("myfile.wav", wav)?; +let output = format!("track{:02}.wav", track.number); +std::fs::write(output, 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. +The returned vector contains a complete WAV file that can be written directly to disk. + +## Read options + +`CdReader::read_track` and `CdReader::open_track_stream` use the `ReadOptions` defaults: CD-DA audio sectors, the default retry policy, and no read-speed change. These settings are sufficient for most audio reads. + +For more control, start with `ReadOptions::default()` and pass the configured options to `CdReader::read_track_with_options` or `CdReader::open_track_stream_with_options`. The configurable options are: + +- **Sector format:** `SectorReadFormat` controls the type and layout of sectors returned by the drive. `SectorReadFormat::Audio` is the default. For a data track, `CdReader::detect_track_format` can select an appropriate default format to pass to `ReadOptions::with_format`. +- **Retry policy:** `RetryConfig` controls the number of attempts, retry delays, and adaptive reduction of the number of sectors requested after a failed read. Its defaults are suitable for most drives. +- **Read speed:** `ReadSpeed` requests an automatic or custom drive speed. The default, `ReadSpeed::Unchanged`, issues no speed-change request. Requested speeds are not guaranteed, and the crate does not restore the previous drive setting afterward. Speed behavior depends on the OS and drive firmware. + +```rust +use cd_da_reader::{CdReader, ReadOptions, ReadSpeed, RetryConfig, SectorReadFormat}; + +let reader = CdReader::open_default()?; +let toc = reader.read_toc()?; +let track = toc + .tracks + .iter() + .find(|track| track.is_audio) + .ok_or("no audio tracks found")?; +let options = ReadOptions::default() + .with_format(SectorReadFormat::Audio) + .with_retry(RetryConfig::default().with_max_attempts(6)) + .with_read_speed(ReadSpeed::CustomMultiplier(4)); +let data = reader.read_track_with_options(&toc, track.number, &options)?; +``` ## 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: +Blocking and streaming reads use the same `ReadOptions`, so reading a data track requires selecting a matching `SectorReadFormat`. Call `CdReader::detect_track_format` explicitly to choose an appropriate default: ```rust use cd_da_reader::{CdReader, ReadOptions, SectorReadFormat}; @@ -150,36 +205,53 @@ 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) +// A data track is any track for which `is_audio` is false. +let data_track = toc + .tracks + .iter() + .find(|track| !track.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)?; +let data = reader.read_track_with_options(&toc, data_track.number, &options)?; + +match format { + // A typical Mode 1 ISO 9660 track can be written as a mountable image. + SectorReadFormat::Mode1Cooked => std::fs::write("disc.iso", &data)?, + // Mode 2 remains raw and must be interpreted sector by sector. + SectorReadFormat::Mode2Raw => std::fs::write("disc.mode2.bin", &data)?, + other => return Err(format!("unexpected data-track format: {other:?}").into()), +} ``` -While audio data is PCM, with non-audio data it is up to you to detect and parse it. It can be a mountable image or it can be something else. 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 you need to interpet the data by yourself. You can see a full workflow — detect, save, and platform-specific mount commands — in `examples/save_data_track.rs`. +`Mode1Cooked` returns the 2,048-byte user-data field from each sector, which is directly usable when the track contains a typical ISO 9660 filesystem. `Mode1Raw` returns complete 2,352-byte Mode 1 sectors. Mode 2 is detected as `Mode2Raw`; because Form 1 and Form 2 are per-sector properties, callers must inspect each sector's XA subheader and extract the appropriate payload themselves. + +See `examples/save_data_track.rs` for a complete detect, stream, save, and platform-specific mounting workflow. ## 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: +Everything above a raw sector read is hardware-independent, so tracks can also come from an image, decoded container, in-memory disc, or another custom source. Implement `AudioSectorReader`, build a `Toc` from the source's track metadata, and ensure both use the same LBA address space. The reader must return CD-DA PCM as exactly 2,352 bytes per sector: signed 16-bit little-endian stereo at 44,100 Hz. ```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 + // Return exactly count * 2,352 bytes of CD-DA PCM. todo!() } } -let pcm = read_track(&image, &toc, 1)?; // build `toc` from the image's metadata +// Build `toc` from the source's metadata and use its reported track number. +let track = toc + .tracks + .iter() + .find(|track| track.is_audio) + .ok_or("no audio tracks found")?; +let pcm = read_track(&image, &toc, track.number)?; let wav = create_wav(pcm); ``` @@ -190,10 +262,10 @@ Two examples cover this, both dependency-free: - `examples/file_backend.rs` — the smallest possible backing (whole-disc PCM in memory), to show the shape of the trait. - `examples/bin_cue_backend.rs` — a real container: it parses a `.cue` sheet into a `Toc` and serves sectors out of the `.bin` with positioned reads. Point it at an image with `cargo run --example bin_cue_backend -- /path/to/disc.cue`, or run it bare and it synthesizes a small mixed-mode image to work against. -One caveat worth knowing before writing a backing: `read_track` defaults to `TrackBounds::SessionGap`, which subtracts the CD-Extra inter-session gap from the last audio track before a trailing data session. That is right for a physical disc or an image whose TOC preserves the disc's real LBAs, and wrong for an image whose tracks are addressed back-to-back with the gap stripped out (a single-`FILE` BIN/CUE, a `chdman extractcd` extract), where it would drop ~2.5 minutes of real audio. Those backings should pass `TrackBounds::Gapless` to `read_track_with_bounds` / `open_track_stream_with_bounds`. +One caveat worth knowing before writing a backing: `read_track` defaults to `TrackBounds::SessionGap`, which subtracts the CD-Extra inter-session gap from the last audio track before a trailing data session. That is correct for a physical disc or an image whose TOC preserves the disc's real LBAs. For a source whose tracks are addressed back-to-back with that gap stripped out—such as a single-`FILE` BIN/CUE or a `chdman extractcd` extract—the subtraction would remove 11,400 sectors (152 seconds) of real audio. Use `TrackBounds::Gapless` with `read_track_with_bounds` or `open_track_stream_with_bounds` for those sources. ## 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. +Audio CDs carry almost no semantic metadata. [CD-TEXT](https://en.wikipedia.org/wiki/CD-Text) exists, but it is unreliable and is not provided by this crate. -Instead, you can calculate a Disc ID for a service like [MusicBrainz](https://musicbrainz.org/), which requires full ToC for it: [ref](https://musicbrainz.org/doc/Disc_ID_Calculation). You can see an example of how to calculate the ID [here](https://github.com/Bloomca/audio-cd-ripper/blob/main/src/music_brainz/calculate_id.rs). +The practical approach is to calculate a Disc ID from the full TOC and look it up through a service such as [MusicBrainz](https://musicbrainz.org/). The `Toc` exposes the track addresses and lead-out required by the [MusicBrainz disc ID algorithm](https://musicbrainz.org/doc/Disc_ID_Calculation). You can see an example calculation [here](https://github.com/Bloomca/audio-cd-ripper/blob/main/src/music_brainz/calculate_id.rs). diff --git a/src/backend.rs b/src/backend.rs index b279409..0361360 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -50,62 +50,111 @@ impl AudioSectorReader for CdReader { /// 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. -/// -/// The read takes `&self`, matching [`CdReader`]. A backing that needs a mutable -/// handle (an open `File`, a decoder) should use positioned reads -/// (`read_at`/`seek_read`) or interior mutability so shared-borrow reads stay -/// possible. +/// This trait separates source-specific I/O from the crate's track-level logic. +/// Meaning that you can provide your implementation for any source which can provide +/// audio CD sectors, like a disc image, decoded container, in-memory disc, or a remote +/// source. [`read_track`] and [`open_track_stream`] use a caller-provided [`Toc`] to calculate +/// sector ranges, then retrieve those sectors through [`read_audio_sectors`](Self::read_audio_sectors). +/// +/// Implementations are responsible only for reading sectors. They do not build +/// the [`Toc`], select tracks, calculate track boundaries, or account for +/// CD-Extra session gaps. The backing's sector address space must agree with the +/// `start_lba` and `leadout_lba` values in the supplied `Toc`; layout differences +/// are expressed separately through [`TrackBounds`]. +/// +/// # Audio format +/// +/// Each sector must contain exactly 2,352 bytes of headerless PCM audio: +/// +/// - 44,100 sample frames per second +/// - signed 16-bit little-endian samples +/// - two interleaved channels, left followed by right +/// - 588 stereo sample frames per sector +/// +/// One sector therefore represents 1/75 second of audio. Returned data must not +/// include a WAV header, CD sector headers, subchannel data, or padding. It is +/// byte-for-byte compatible with [`CdReader::read_track`] and can be passed +/// directly to [`create_wav`](crate::create_wav). +/// +/// # Addressing and read semantics +/// +/// `start_lba` is an absolute sector index within the backing, not an offset +/// relative to a track. A request covers the half-open range +/// `start_lba..start_lba + count`. +/// +/// Calls are independent and may be repeated or issued out of order, such as +/// after seeking a stream. On success, the returned vector must contain exactly +/// `count * 2352` bytes. A zero-sector request should return an empty vector. +/// Invalid ranges, short reads, and decoding or I/O failures must return an +/// error rather than partial data. +/// +/// The method takes `&self` so callers can retain a shared reference to the +/// source. Implementations backed by a mutable file cursor or decoder should +/// use positioned reads or interior mutability. pub trait AudioSectorReader { - /// Error type produced by this backing. + /// Error produced when this backing cannot satisfy a sector read. /// - /// Bounded here rather than at each call site so an unusable error type - /// (`String`, say) is rejected at the `impl` — where the fix is — instead of - /// compiling happily and then failing at every [`read_track`] call. The - /// bound is what lets a failure be reported as - /// [`CdReaderError::Backend`], which keeps this error as its boxed - /// [`source`](std::error::Error::source). + /// Helper APIs preserve this error as the source of [`CdReaderError::Backend`]. type Error: std::error::Error + Send + Sync + 'static; - /// Read `count` sectors starting at absolute `start_lba`, returning exactly - /// `count * 2352` bytes of little-endian PCM. + /// Read the sector range `start_lba..start_lba + count`. + /// + /// A successful call returns exactly `count * 2352` bytes in the format + /// described by [`AudioSectorReader`]. /// /// # Errors /// - /// Implementations must return an error if they cannot provide the complete - /// requested sector range in the required format. + /// Returns an error if the complete requested range cannot be returned. fn read_audio_sectors(&self, start_lba: u32, count: u32) -> Result, Self::Error>; } -/// How a track's sector range is resolved from a [`Toc`]. -/// -/// The two policies differ on exactly one track: the **last audio track before a -/// trailing data session** on a CD-Extra disc. Every other track resolves -/// identically. Which one to use depends on whether the TOC's addressing includes -/// the inter-session gap — a property of how the source is laid out, not of -/// physical-vs-image. -/// -/// - [`SessionGap`](Self::SessionGap) subtracts the inter-session gap (matching -/// [`CdReader::read_track`](crate::CdReader::read_track)) — for a physical disc, -/// or any image whose TOC preserves the disc's real LBAs. -/// - [`Gapless`](Self::Gapless) does not: when tracks are addressed back-to-back -/// (a gap-stripped extract), a track spans from its `start_lba` to the next -/// track's start (or the leadout). Subtracting a gap that isn't there would drop -/// ~2.5 min of real audio. +/// Policy for deriving a track's half-open sector range from a [`Toc`]. +/// +/// A track begins at its own [`Track::start_lba`](crate::Track::start_lba). +/// Normally it ends at the next track's `start_lba`, or at +/// [`Toc::leadout_lba`] when it is the final track. +/// +/// # CD-Extra session gaps +/// +/// A CD-Extra disc places a standard 11,400-sector inter-session gap between +/// its final audio track and the following data session. This is 152 seconds, +/// or 2 minutes 32 seconds. In a geometry-preserving address space, the first +/// data track's `start_lba` lies after that gap, so treating it as the audio +/// track's end would incorrectly include the gap in the audio range. +/// +/// Some image formats and extracts remove the inter-session gap and store the +/// tracks back-to-back. In that layout, the next track's `start_lba` is already +/// the correct end of the audio track; subtracting 11,400 sectors would instead +/// truncate 152 seconds of audio. +/// +/// This policy affects only the last audio track followed exclusively by data +/// tracks. All other tracks have identical bounds under both variants. +/// +/// Choose the variant according to the address space represented jointly by the +/// backing and its `Toc`: +/// +/// - [`SessionGap`](Self::SessionGap) for a physical disc or an image that +/// preserves the disc's original sector geometry. +/// - [`Gapless`](Self::Gapless) for a contiguous, gap-stripped image or extract. +/// +/// `Gapless` refers only to the CD-Extra inter-session gap. It does not remove +/// ordinary track pregaps or provide gapless playback. +/// +/// [`read_track`] and [`open_track_stream`] use [`SessionGap`](Self::SessionGap) +/// by default. Use [`read_track_with_bounds`] or +/// [`open_track_stream_with_bounds`] when the source requires an explicit +/// policy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrackBounds { - /// Addressing includes the CD-Extra inter-session gap: apply the trailing-gap - /// rule. Correct for a physical disc or a geometry-preserving image. + /// The address space includes the CD-Extra inter-session gap. + /// + /// When applicable, the final audio track ends 11,400 sectors before the + /// following data track. SessionGap, - /// Tracks are addressed contiguously (gap stripped): no gap subtraction. + /// The address space stores tracks contiguously without the CD-Extra + /// inter-session gap. + /// + /// Every track ends at the next track's start, or at the lead-out. Gapless, } @@ -118,25 +167,38 @@ impl TrackBounds { } } -/// Read raw PCM for one track from any [`AudioSectorReader`] backing, assuming -/// the TOC includes the CD-Extra inter-session gap ([`TrackBounds::SessionGap`]). +/// Reads one complete audio track from an [`AudioSectorReader`] into memory. /// -/// 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. +/// `track_no` is the disc track number stored in [`Track::number`](crate::Track::number), +/// not an index into [`Toc::tracks`](crate::Toc::tracks). The source and the +/// `Toc` must use the same LBA address space. /// -/// If the backing addresses tracks contiguously without gaps, use +/// This convenience function resolves the track's sector range using +/// [`TrackBounds::SessionGap`]. That policy is appropriate for physical discs +/// and images that preserve the original CD geometry, including the CD-Extra +/// inter-session gap. For a contiguous, gap-stripped source, use /// [`read_track_with_bounds`] with [`TrackBounds::Gapless`]. /// +/// The returned vector contains headerless CD-DA PCM in the format required by +/// [`AudioSectorReader`]: signed 16-bit little-endian stereo at 44.1 kHz, with +/// 2,352 bytes per sector. It can be passed directly to +/// [`create_wav`](crate::create_wav). +/// +/// This is a blocking operation that buffers the entire track, which may require +/// hundreds of megabytes. Use [`open_track_stream`] or +/// [`open_track_stream_with_bounds`] to process the track incrementally. +/// +/// Only audio tracks are meaningful for this API. Callers should select a track +/// whose [`Track::is_audio`](crate::Track::is_audio) field is `true`. +/// /// # Errors /// -/// A bad track request (not in the TOC, or invalid bounds) is -/// [`CdReaderError::Io`]; a failure inside the backing is -/// [`CdReaderError::Backend`], which preserves the backing's own error as the -/// boxed [`source`](std::error::Error::source). +/// Returns [`CdReaderError::Io`] if `track_no` is absent from the `Toc` or its +/// calculated sector bounds are invalid. +/// +/// Returns [`CdReaderError::Backend`] if the source cannot read the requested +/// sectors. The source's original error is preserved as the boxed +/// [`source`](std::error::Error::source). pub fn read_track( src: &R, toc: &Toc, @@ -145,13 +207,29 @@ pub fn read_track( read_track_with_bounds(src, toc, track_no, TrackBounds::SessionGap) } -/// Read one track like [`read_track`], but with an explicit [`TrackBounds`] -/// geometry — pass [`TrackBounds::Gapless`] for a contiguous, gap-stripped layout. +/// Reads one complete audio track into memory using an explicit [`TrackBounds`] policy. +/// +/// This is the configurable form of [`read_track`], which always uses +/// [`TrackBounds::SessionGap`]. The `bounds` argument controls how the track's +/// end LBA is calculated from the `Toc`, specifically whether the CD-Extra +/// inter-session gap is present in the source's address space. +/// +/// Use [`TrackBounds::SessionGap`] for a physical disc or geometry-preserving +/// image. Use [`TrackBounds::Gapless`] for a contiguous, gap-stripped source. +/// +/// The source and `Toc` must use the same LBA address space. All other behavior, +/// including the returned PCM format and whole-track buffering, is identical to +/// [`read_track`]. Use [`open_track_stream_with_bounds`] to process the track +/// incrementally with an explicit bounds policy. /// /// # Errors /// -/// Returns [`CdReaderError::Io`] if the track is absent or its bounds are -/// invalid, and [`CdReaderError::Backend`] if the backing read fails. +/// Returns [`CdReaderError::Io`] if the track is absent from the `Toc` or its +/// calculated sector bounds are invalid. +/// +/// Returns [`CdReaderError::Backend`] if the source cannot read the requested +/// sectors. The source's original error is preserved as the error's +/// [`source`](std::error::Error::source). pub fn read_track_with_bounds( src: &R, toc: &Toc, @@ -163,16 +241,35 @@ pub fn read_track_with_bounds( .map_err(|e| CdReaderError::Backend(Box::new(e))) } -/// Streaming reader over an [`AudioSectorReader`] backing — the file/image -/// counterpart to [`TrackStream`](crate::TrackStream). +/// A pull-based, sector-aligned stream of raw CD-DA PCM from an [`AudioSectorReader`]. +/// +/// An `AudioTrackStream` borrows its source and represents a fixed sector +/// range. Each call to [`next_chunk`](Self::next_chunk) synchronously reads and +/// returns the next portion of that range. Once all sectors have been consumed, +/// it returns `Ok(None)`. +/// +/// Unlike [`read_track`], the stream does not allocate or retain the entire +/// track. Callers can process and discard each returned chunk before requesting +/// the next one. Chunks contain complete CD-DA sectors in the format specified +/// by [`AudioSectorReader`]; the final chunk may contain fewer sectors than the +/// configured chunk size. +/// +/// The chunk size can be changed with [`with_sectors_per_chunk`](Self::with_sectors_per_chunk). +/// Stream position is relative to the beginning of its sector range and can be inspected +/// or changed with [`current_sector`](Self::current_sector), [`seek_to_sector`](Self::seek_to_sector), +/// and [`seek_to_seconds`](Self::seek_to_seconds). +/// +/// Create a stream with: +/// +/// - [`open_track_stream`] to resolve a track from a `Toc` using +/// [`TrackBounds::SessionGap`]. +/// - [`open_track_stream_with_bounds`] to resolve a track using an explicit +/// [`TrackBounds`] policy. +/// - [`open_track_stream_at`] to stream an explicit absolute sector range +/// without consulting a `Toc`. /// -/// Pulls a track's PCM in sector-aligned chunks with -/// [`next_chunk`](Self::next_chunk) instead of buffering the whole track, so a -/// player can start immediately and hold only one chunk at a time. Open one -/// with [`open_track_stream`] (TOC + physical geometry), -/// [`open_track_stream_with_bounds`] (TOC + explicit [`TrackBounds`]), or -/// [`open_track_stream_at`] (an explicit absolute sector range, for backings -/// that compute their own bounds). +/// This is the source-independent audio counterpart to [`TrackStream`](crate::TrackStream), +/// which is tied to [`CdReader`] and supports drive-specific read options and data-sector formats. pub struct AudioTrackStream<'a, R: AudioSectorReader> { src: &'a R, start_lba: u32, diff --git a/src/data_reader/read_speed.rs b/src/data_reader/read_speed.rs index c10f32a..ee5cc1d 100644 --- a/src/data_reader/read_speed.rs +++ b/src/data_reader/read_speed.rs @@ -1,44 +1,35 @@ -/// Representation of read speed requested by the `SET CD SPEED` (0xBB) command. +/// A platform-independent read-speed request for an optical drive. /// -/// # Note +/// The requested speed is a preference, not a guarantee. Under MMC, a drive may +/// select the requested rate or a higher supported rate; the OS and drive +/// firmware may further adjust or reject the request. /// -/// According to the MMC-3 specification, the requested speed doesn't necessarily -/// match the actual read speed. The drive may select the specified read speed -/// or any higher rate. -/// -/// Therefore, this enum represents a requested speed, not a guaranteed actual -/// read speed. The actual behaviour is drive-dependent. +/// A successful speed change may remain active for subsequent reads. This crate +/// does not restore the previous setting, although the OS, firmware, or other +/// software may change it later. #[derive(Debug, Clone, Copy)] pub enum ReadSpeed { - /// Don't change the speed + /// Do not issue a speed-change request. /// - /// The speed depends on the OS, previous configuration, and other factors. + /// This is the default in [`ReadOptions`](crate::ReadOptions). The drive + /// retains whatever speed was previously selected by the OS, firmware, or + /// another application. Unchanged, - /// Request the drive-selected/optimal speed. - /// - /// According to the MMC-3 specification, the drive can select its optimal - /// speed when the `SET CD SPEED` command is executed with the read - /// speed (KB/s) set to 0xFFFF. - /// - /// On macOS and Windows, this variant requests `SET CD SPEED` with 0xFFFF. + /// Ask the platform and drive to select an automatic or optimal read speed. /// - /// On Linux, the read speed is selected by the `CDROM_SELECT_SPEED` - /// ioctl with speed = 0. It requests automatic speed selection. + /// On macOS and Windows, this uses the `0xFFFF` drive-selected/maximum-speed + /// sentinel associated with `SET CD SPEED` (`0xBB`). On Linux, + /// `CDROM_SELECT_SPEED` is called with a speed of zero to request automatic + /// selection. Optimal, - /// Use a custom speed with the specified multiplier. - /// - /// Although the CD-DA read speed should be requested in KB/s according to - /// the specification, this variant uses a multiplier for simplicity. - /// - /// For example, `ReadSpeed::CustomMultiplier(1)` represents the nominal - /// CD-DA 1x read rate (176.4 KB/s). The exact conversion is - /// platform-dependent. + /// Request a multiple of the nominal CD-DA 1× rate. /// - /// Another example: `ReadSpeed::CustomMultiplier(10)` represents 10x speed. + /// One unit represents 176.4 kB/s, so `CustomMultiplier(1)` requests 1× and + /// `CustomMultiplier(10)` requests 10×. macOS and Windows convert the + /// multiplier to kB/s, while Linux passes it as a speed multiplier. /// - /// `ReadSpeed::CustomMultiplier(0)` is equivalent to `ReadSpeed::Optimal`. - /// The value 0 is used as a sentinel. + /// `CustomMultiplier(0)` is equivalent to [`Optimal`](Self::Optimal). CustomMultiplier(u8), } diff --git a/src/data_reader/sector_read_format.rs b/src/data_reader/sector_read_format.rs index bb303d5..3e5be32 100644 --- a/src/data_reader/sector_read_format.rs +++ b/src/data_reader/sector_read_format.rs @@ -1,17 +1,61 @@ -/// Sector format requested through the READ CD (0xBE) command. +/// Selects the type and layout of sectors returned when reading from an optical drive. +/// +/// This is the crate's platform-independent representation of the sector type and +/// main-channel fields requested by the MMC `READ CD` command (`0xBE`) or the +/// equivalent platform API. +/// +/// [`ReadOptions`](crate::ReadOptions) defaults to [`Audio`](Self::Audio), so +/// callers reading audio tracks normally do not need to select a format. For a +/// data track, call +/// [`CdReader::detect_track_format`](crate::CdReader::detect_track_format) and +/// pass the result to [`ReadOptions::with_format`](crate::ReadOptions::with_format). Detection +/// chooses [`Mode1Cooked`](Self::Mode1Cooked) for Mode 1 tracks and +/// [`Mode2Raw`](Self::Mode2Raw) for Mode 2 tracks. +/// +/// Selecting a format does not convert the sectors. It tells the drive what +/// sector type and fields to return, so the selection must match the track being +/// read. A mismatched format may be rejected by the library or the drive. +/// +/// The `Raw` variants return the complete 2,352-byte main-channel sector. They +/// do not include subchannel data or C2 error information. Use +/// [`sector_size`](Self::sector_size) to obtain the number of bytes returned per +/// sector for any variant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SectorReadFormat { - /// CD-DA audio: 2352 bytes of PCM per sector. + /// CD-DA audio as 2,352 bytes of headerless PCM per sector. + /// + /// The samples are signed 16-bit little-endian stereo at 44.1 kHz. Each + /// sector contains 588 stereo sample frames and represents 1/75 second of + /// audio. The returned bytes can be passed directly to [`create_wav`](crate::create_wav). Audio, - /// Mode 1 user data only: 2048 bytes per sector. + + /// The 2,048-byte user-data field from a Mode 1 sector. + /// + /// The drive omits the sync pattern, sector header, Error Detection Code + /// (EDC), reserved bytes, and Error Correction Code (ECC). This is usually + /// the preferred representation for reading filesystems; concatenating the + /// cooked sectors of a typical ISO 9660 track produces a directly usable + /// disc image. Mode1Cooked, - /// Complete Mode 1 sector: 2352 bytes with sync, header, user data, EDC, - /// and ECC. + + /// A complete 2,352-byte Mode 1 main-channel sector. + /// + /// This includes the 12-byte sync pattern, 4-byte header, 2,048-byte user + /// data field, EDC, reserved bytes, and ECC. Use this when preserving or + /// inspecting the original sector framing. For normal filesystem access, + /// [`Mode1Cooked`](Self::Mode1Cooked) is usually more convenient. Mode1Raw, - /// Complete Mode 2 sector: 2352 bytes. + + /// A complete 2,352-byte Mode 2 main-channel sector. + /// + /// This is the only Mode 2 representation provided by the crate. Mode 2 XA + /// tracks can mix Form 1 and Form 2 sectors within the same track: Form 1 + /// carries 2,048 bytes of user data with stronger error correction, while + /// Form 2 carries 2,324 bytes of user data. /// - /// Mode 2 forms are a per-sector property. Consumers that need the - /// application payload must inspect each sector's XA subheader. + /// The form is recorded in each sector's XA subheader. The crate does not + /// expose a cooked Mode 2 reader or a public XA payload parser, so callers + /// must inspect each sector and extract the appropriate payload themselves. Mode2Raw, } diff --git a/src/lib.rs b/src/lib.rs index aac4184..3a6799d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,17 @@ //! # CD-DA (audio CD) reading library //! //! This library provides cross-platform audio CD reading capabilities (tested -//! on Windows, macOS and Linux). It was written to enable CD ripping, but you -//! can also implement a live audio CD player with its help, and implement -//! reading using your own files if you can provide ToC (table of contents) by -//! yourself. The library works through platform CD-drive APIs on macOS and -//! issuing direct SCSI commands on Windows and Linux and abstracts both access -//! to the CD drive and reading the actual data from it, so you don't deal with -//! the hardware directly. The library works in the userland. +//! on Windows, macOS and Linux). It was written to enable CD ripping, but it can +//! also be used to build a live audio CD player. The primary API reads physical +//! discs; to read from a file, image, or another custom source, implement +//! [`AudioSectorReader`] and provide a [`Toc`]. //! -//! All CD reading operations happen in this order: +//! Physical-disc access uses platform CD-drive APIs on macOS and direct SCSI +//! commands on Windows and Linux. The library abstracts both access to the drive +//! and reading the data, so callers do not interact with the hardware directly. +//! It operates entirely in user space. +//! +//! A typical drive-backed read happens in this order: //! //! 1. Get a CD drive's handle //! 2. Read the ToC (table of contents) of the audio CD @@ -34,7 +36,11 @@ //! use cd_da_reader::CdReader; //! //! let drives = CdReader::list_drives()?; -//! let selected = drives.first().ok_or("no optical drives found")?; +//! let selected = drives +//! .iter() +//! .find(|drive| drive.has_audio_cd) // we check for audio by checking ToC +//! .ok_or("no drive with an audio CD found")?; +//! //! let reader = CdReader::open(selected)?; //! # Ok::<(), Box>(()) //! ``` @@ -55,8 +61,12 @@ //! # Ok::<(), Box>(()) //! ``` //! -//! The returned [`Toc`] contains a [`Vec`](Track) where each entry has -//! two equivalent address fields: +//! The returned [`Toc`] contains a [`Vec`](Track). Each [`Track`] reports +//! its disc track number in [`Track::number`] and whether it contains audio in +//! [`Track::is_audio`]. Track numbers are not zero-based indices into +//! [`Toc::tracks`] and are not guaranteed to begin at 1 (but they usually do). +//! +//! Each track also has two equivalent address fields: //! //! - **`start_lba`** -- Logical Block Address, which is a sector index. //! LBA 0 is the first readable sector after the 2-second lead-in pre-gap. @@ -69,22 +79,40 @@ //! //! ## Reading tracks //! -//! Pass the [`Toc`] and a track number to [`CdReader::read_track`]. The -//! library calculates the sector boundaries automatically. On CD-Extra discs +//! Pass the [`Toc`] and the track's [`Track::number`] to +//! [`CdReader::read_track`]. The library calculates the sector boundaries +//! automatically. On CD-Extra discs //! where the last audio track is followed only by data tracks, the trailing -//! audio/data session gap is excluded from the audio read. +//! audio/data session gap is excluded from the audio read -- this is usually +//! what you want, and you can read custom range by using [`CdReader::read_sector_range`]. //! //! ```no_run //! use cd_da_reader::CdReader; //! //! let reader = CdReader::open_default()?; //! let toc = reader.read_toc()?; -//! let data = reader.read_track(&toc, 1)?; // we assume track #1 exists and is audio +//! +//! // Track numbers come from the disc; do not assume the first audio track is #1. +//! let track = toc +//! .tracks +//! .iter() +//! .find(|track| track.is_audio) +//! .ok_or("no audio tracks found")?; +//! let data = reader.read_track(&toc, track.number)?; //! # Ok::<(), Box>(()) //! ``` //! -//! This is a blocking call. For a live-playback or progress-reporting use case, -//! use the streaming API instead: +//! [`CdReader::read_track`] is a blocking call that buffers the complete track, +//! so it can take some time and use hundreds of megabytes of memory. The +//! streaming API instead returns sector-aligned chunks as they are read, which +//! keeps memory usage low and supports progress reporting or playback before the +//! complete track is available. +//! +//! Streaming is still synchronous: each [`TrackStream::next_chunk`] call waits +//! for the drive to return the next chunk. This is often suitable for a CLI, +//! where the read loop can run on the main thread and report progress. A GUI +//! should run the loop on a worker thread so drive reads do not block its event +//! loop. Open a stream with [`CdReader::open_track_stream`]: //! //! ```no_run //! use cd_da_reader::CdReader; @@ -92,26 +120,34 @@ //! let reader = CdReader::open_default()?; //! let toc = reader.read_toc()?; //! -//! let mut stream = reader.open_track_stream(&toc, 1)?; +//! // Select by track metadata rather than assuming track #1 contains audio. +//! let track = toc +//! .tracks +//! .iter() +//! .find(|track| track.is_audio) +//! .ok_or("no audio tracks found")?; +//! let mut stream = reader.open_track_stream(&toc, track.number)?; //! while let Some(chunk) = stream.next_chunk()? { //! // process chunk — raw PCM, 2 352 bytes per sector //! } //! # Ok::<(), Box>(()) //! ``` //! -//! ## Track format +//! ## Audio track format //! -//! Track data is raw [PCM](https://en.wikipedia.org/wiki/Pulse-code_modulation), -//! the same format used inside WAV files. Audio CDs use 16-bit stereo PCM -//! sampled at 44 100 Hz: +//! Audio track data is raw +//! [PCM](https://en.wikipedia.org/wiki/Pulse-code_modulation), the same +//! uncompressed sample representation used by PCM WAV files. Audio CDs use +//! signed 16-bit little-endian stereo PCM sampled at 44,100 Hz: //! //! ```text -//! 44 100 samples * 2 channels * 2 bytes = 176 400 bytes/second +//! 44,100 sample frames * 2 channels * 2 bytes = 176,400 bytes/second //! ``` //! -//! Each sector holds exactly 2 352 bytes (176 400 ÷ 75 = 2 352), that's where -//! 75 sectors per second comes from. A typical 3-minute track is -//! ~31 MB; a full 74-minute CD is ~650 MB. +//! Each audio sector holds exactly 2,352 bytes (176,400 ÷ 75 = 2,352), which +//! gives 75 sectors per second. A typical 3-minute track is about 31.8 MB +//! (30.3 MiB). A 74-minute disc contains about 783 MB (747 MiB) of raw PCM; +//! common 80-minute media contains about 847 MB (808 MiB). //! //! Converting raw PCM to a playable WAV file only requires prepending a 44-byte //! RIFF header — [`create_wav`] does exactly that: @@ -121,42 +157,56 @@ //! //! let reader = CdReader::open_default()?; //! let toc = reader.read_toc()?; -//! let data = reader.read_track(&toc, 1)?; +//! let track = toc +//! .tracks +//! .iter() +//! .find(|track| track.is_audio) +//! .ok_or("no audio tracks found")?; +//! let data = reader.read_track(&toc, track.number)?; //! let wav = create_wav(data); -//! std::fs::write("track01.wav", wav)?; +//! let output = format!("track{:02}.wav", track.number); +//! std::fs::write(output, wav)?; //! # Ok::<(), Box>(()) //! ``` //! //! ## Read options //! -//! [`CdReader::read_track`] and [`CdReader::open_track_stream`] use sensible defaults for audio -//! CDs and should be enough to get started. For more control, build [`ReadOptions`] from its -//! defaults and pass it to [`CdReader::read_track_with_options`] or -//! [`CdReader::open_track_stream_with_options`]. The configurable options are: -//! -//! - **Sector format:** [`SectorReadFormat::Audio`] returns 2,352 bytes of PCM per sector and is -//! the default. Data tracks can be read as the 2,048-byte [`SectorReadFormat::Mode1Cooked`] -//! payload or as complete 2,352-byte [`SectorReadFormat::Mode1Raw`] or -//! [`SectorReadFormat::Mode2Raw`] sectors. Mode 2 is available only as raw sectors; inspect -//! each sector's XA subheader to locate its payload. Use [`CdReader::detect_track_format`] when -//! the data-track format is not already known. -//! - **Retry policy:** [`RetryConfig`] controls the number of attempts, retry delays, and adaptive -//! reduction of the number of sectors read at once. Its defaults are suitable for most drives. -//! - **Read speed:** [`ReadSpeed`] requests an optimal or custom drive speed. The default, -//! [`ReadSpeed::Unchanged`], leaves the current setting alone. Requested speeds are not -//! guaranteed, and the previous drive setting is not restored after the read. Speed settings -//! are drive and OS dependent. +//! [`CdReader::read_track`] and [`CdReader::open_track_stream`] use the +//! [`ReadOptions`] defaults: CD-DA audio sectors, the default retry policy, and +//! no read-speed change. These settings are sufficient for most audio reads. +//! +//! For more control, start with `ReadOptions::default()` and pass the configured +//! options to [`CdReader::read_track_with_options`] or [`CdReader::open_track_stream_with_options`]. +//! The configurable options are: +//! +//! - **Sector format:** [`SectorReadFormat`] controls the type and layout of +//! sectors returned by the drive. [`SectorReadFormat::Audio`] is the default. +//! For a data track, [`CdReader::detect_track_format`] can select an +//! appropriate default format to pass to [`ReadOptions::with_format`]. +//! - **Retry policy:** [`RetryConfig`] controls the number of attempts, retry +//! delays, and adaptive reduction of the number of sectors requested after a +//! failed read. Its defaults are suitable for most drives. +//! - **Read speed:** [`ReadSpeed`] requests an automatic or custom drive speed. +//! The default, [`ReadSpeed::Unchanged`], issues no speed-change request. +//! Requested speeds are not guaranteed, and this crate does not restore the +//! previous drive setting afterward. Speed behavior depends on the OS and +//! drive firmware. //! //! ```no_run //! use cd_da_reader::{CdReader, ReadOptions, ReadSpeed, RetryConfig, SectorReadFormat}; //! //! let reader = CdReader::open_default()?; //! let toc = reader.read_toc()?; +//! let track = toc +//! .tracks +//! .iter() +//! .find(|track| track.is_audio) +//! .ok_or("no audio tracks found")?; //! let options = ReadOptions::default() //! .with_format(SectorReadFormat::Audio) //! .with_retry(RetryConfig::default().with_max_attempts(6)) //! .with_read_speed(ReadSpeed::CustomMultiplier(4)); -//! let data = reader.read_track_with_options(&toc, 1, &options)?; +//! let data = reader.read_track_with_options(&toc, track.number, &options)?; //! # Ok::<(), Box>(()) //! ``` //! @@ -195,7 +245,7 @@ pub use stream::TrackStream; 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. +/// Representation of the track from ToC, purely in terms of data location on the CD. #[derive(Debug)] pub struct Track { /// Track number from the Table of Contents (read from the CD itself). @@ -241,11 +291,15 @@ 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. +/// Prepends a standard 44-byte RIFF/WAVE header to raw CD-DA PCM. +/// +/// `data` must already contain headerless, signed 16-bit little-endian, +/// interleaved stereo PCM sampled at 44,100 Hz. This function does not validate +/// or convert the audio data; it only adds a header describing that format. /// -/// Use this with PCM returned by [`CdReader::read_track`] or obtained from a -/// file or image backing via [`read_track`]. +/// PCM returned by [`CdReader::read_track`] or the source-independent +/// [`read_track`] function already has the required format. The returned vector +/// contains a complete WAV file and can be written directly to a `.wav` file. pub fn create_wav(data: Vec) -> Vec { let mut header = utils::create_wav_header(data.len() as u32); header.extend_from_slice(&data);