cd_da_reader/lib.rs
1//! # CD-DA (audio CD) reading library
2//!
3//! This library provides cross-platform audio CD reading capabilities (tested
4//! on Windows, macOS and Linux). It was written to enable CD ripping, but it can
5//! also be used to build a live audio CD player. The primary API reads physical
6//! discs; to read from a file, image, or another custom source, implement
7//! [`AudioSectorReader`] and provide a [`Toc`].
8//!
9//! Physical-disc access uses platform CD-drive APIs on macOS and direct SCSI
10//! commands on Windows and Linux. The library abstracts both access to the drive
11//! and reading the data, so callers do not interact with the hardware directly.
12//! It operates entirely in user space.
13//!
14//! A typical drive-backed read happens in this order:
15//!
16//! 1. Get a CD drive's handle
17//! 2. Read the ToC (table of contents) of the audio CD
18//! 3. Read track data using ranges from the ToC
19//!
20//! ## CD access
21//!
22//! The easiest way to open a drive is to use [`CdReader::open_default`], which scans
23//! all drives and opens the first one that contains an audio CD:
24//!
25//! ```no_run
26//! use cd_da_reader::CdReader;
27//!
28//! let reader = CdReader::open_default()?;
29//! # Ok::<(), Box<dyn std::error::Error>>(())
30//! ```
31//!
32//! If you need to pick a specific drive, use [`CdReader::list_drives`] followed
33//! by calling [`CdReader::open`] with the selected drive:
34//!
35//! ```no_run
36//! use cd_da_reader::CdReader;
37//!
38//! let drives = CdReader::list_drives()?;
39//! let selected = drives
40//! .iter()
41//! .find(|drive| drive.has_audio_cd) // we check for audio by checking ToC
42//! .ok_or("no drive with an audio CD found")?;
43//!
44//! let reader = CdReader::open(selected)?;
45//! # Ok::<(), Box<dyn std::error::Error>>(())
46//! ```
47//!
48//! If you already know the platform-specific device path, use
49//! [`CdReader::open_path`] instead.
50//!
51//! ## Reading ToC
52//!
53//! Each audio CD carries a Table of Contents with the block address of every
54//! track. You need to read it first before issuing any track read commands:
55//!
56//! ```no_run
57//! use cd_da_reader::CdReader;
58//!
59//! let reader = CdReader::open_default()?;
60//! let toc = reader.read_toc()?;
61//! # Ok::<(), Box<dyn std::error::Error>>(())
62//! ```
63//!
64//! The returned [`Toc`] contains a [`Vec<Track>`](Track). Each [`Track`] reports
65//! its disc track number in [`Track::number`] and whether it contains audio in
66//! [`Track::is_audio`]. Track numbers are not zero-based indices into
67//! [`Toc::tracks`] and are not guaranteed to begin at 1 (but they usually do).
68//!
69//! Each track also has two equivalent address fields:
70//!
71//! - **`start_lba`** -- Logical Block Address, which is a sector index.
72//! LBA 0 is the first readable sector after the 2-second lead-in pre-gap.
73//! This is the format used internally for read commands.
74//! - **`start_msf`** — Minutes/Seconds/Frames, a time-based address inherited
75//! from the physical disc layout. A "frame" is one sector; the spec defines
76//! 75 frames per second. MSF includes a fixed 2-second (150-frame) lead-in
77//! offset, so `(0, 2, 0)` corresponds to LBA 0. You can convert between them easily:
78//! `LBA + 150 = total frames`, then divide by 75 and 60 for M/S/F.
79//!
80//! ## Reading tracks
81//!
82//! Pass the [`Toc`] and the track's [`Track::number`] to
83//! [`CdReader::read_track`]. The library calculates the sector boundaries
84//! automatically. On CD-Extra discs
85//! where the last audio track is followed only by data tracks, the trailing
86//! audio/data session gap is excluded from the audio read -- this is usually
87//! what you want, and you can read custom range by using [`CdReader::read_sector_range`].
88//!
89//! ```no_run
90//! use cd_da_reader::CdReader;
91//!
92//! let reader = CdReader::open_default()?;
93//! let toc = reader.read_toc()?;
94//!
95//! // Track numbers come from the disc; do not assume the first audio track is #1.
96//! let track = toc
97//! .tracks
98//! .iter()
99//! .find(|track| track.is_audio)
100//! .ok_or("no audio tracks found")?;
101//! let data = reader.read_track(&toc, track.number)?;
102//! # Ok::<(), Box<dyn std::error::Error>>(())
103//! ```
104//!
105//! [`CdReader::read_track`] is a blocking call that buffers the complete track,
106//! so it can take some time and use hundreds of megabytes of memory. The
107//! streaming API instead returns sector-aligned chunks as they are read, which
108//! keeps memory usage low and supports progress reporting or playback before the
109//! complete track is available.
110//!
111//! Streaming is still synchronous: each [`TrackStream::next_chunk`] call waits
112//! for the drive to return the next chunk. This is often suitable for a CLI,
113//! where the read loop can run on the main thread and report progress. A GUI
114//! should run the loop on a worker thread so drive reads do not block its event
115//! loop. Open a stream with [`CdReader::open_track_stream`]:
116//!
117//! ```no_run
118//! use cd_da_reader::CdReader;
119//!
120//! let reader = CdReader::open_default()?;
121//! let toc = reader.read_toc()?;
122//!
123//! // Select by track metadata rather than assuming track #1 contains audio.
124//! let track = toc
125//! .tracks
126//! .iter()
127//! .find(|track| track.is_audio)
128//! .ok_or("no audio tracks found")?;
129//! let mut stream = reader.open_track_stream(&toc, track.number)?;
130//! while let Some(chunk) = stream.next_chunk()? {
131//! // process chunk — raw PCM, 2 352 bytes per sector
132//! }
133//! # Ok::<(), Box<dyn std::error::Error>>(())
134//! ```
135//!
136//! ## Audio track format
137//!
138//! Audio track data is raw
139//! [PCM](https://en.wikipedia.org/wiki/Pulse-code_modulation), the same
140//! uncompressed sample representation used by PCM WAV files. Audio CDs use
141//! signed 16-bit little-endian stereo PCM sampled at 44,100 Hz:
142//!
143//! ```text
144//! 44,100 sample frames * 2 channels * 2 bytes = 176,400 bytes/second
145//! ```
146//!
147//! Each audio sector holds exactly 2,352 bytes (176,400 ÷ 75 = 2,352), which
148//! gives 75 sectors per second. A typical 3-minute track is about 31.8 MB
149//! (30.3 MiB). A 74-minute disc contains about 783 MB (747 MiB) of raw PCM;
150//! common 80-minute media contains about 847 MB (808 MiB).
151//!
152//! Converting raw PCM to a playable WAV file only requires prepending a 44-byte
153//! RIFF header — [`create_wav`] does exactly that:
154//!
155//! ```no_run
156//! use cd_da_reader::{CdReader, create_wav};
157//!
158//! let reader = CdReader::open_default()?;
159//! let toc = reader.read_toc()?;
160//! let track = toc
161//! .tracks
162//! .iter()
163//! .find(|track| track.is_audio)
164//! .ok_or("no audio tracks found")?;
165//! let data = reader.read_track(&toc, track.number)?;
166//! let wav = create_wav(data);
167//! let output = format!("track{:02}.wav", track.number);
168//! std::fs::write(output, wav)?;
169//! # Ok::<(), Box<dyn std::error::Error>>(())
170//! ```
171//!
172//! ## Read options
173//!
174//! [`CdReader::read_track`] and [`CdReader::open_track_stream`] use the
175//! [`ReadOptions`] defaults: CD-DA audio sectors, the default retry policy, and
176//! no read-speed change. These settings are sufficient for most audio reads.
177//!
178//! For more control, start with `ReadOptions::default()` and pass the configured
179//! options to [`CdReader::read_track_with_options`] or [`CdReader::open_track_stream_with_options`].
180//! The configurable options are:
181//!
182//! - **Sector format:** [`SectorReadFormat`] controls the type and layout of
183//! sectors returned by the drive. [`SectorReadFormat::Audio`] is the default.
184//! For a data track, [`CdReader::detect_track_format`] can select an
185//! appropriate default format to pass to [`ReadOptions::with_format`].
186//! - **Retry policy:** [`RetryConfig`] controls the number of attempts, retry
187//! delays, and adaptive reduction of the number of sectors requested after a
188//! failed read. Its defaults are suitable for most drives.
189//! - **Read speed:** [`ReadSpeed`] requests an automatic or custom drive speed.
190//! The default, [`ReadSpeed::Unchanged`], issues no speed-change request.
191//! Requested speeds are not guaranteed, and this crate does not restore the
192//! previous drive setting afterward. Speed behavior depends on the OS and
193//! drive firmware.
194//!
195//! ```no_run
196//! use cd_da_reader::{CdReader, ReadOptions, ReadSpeed, RetryConfig, SectorReadFormat};
197//!
198//! let reader = CdReader::open_default()?;
199//! let toc = reader.read_toc()?;
200//! let track = toc
201//! .tracks
202//! .iter()
203//! .find(|track| track.is_audio)
204//! .ok_or("no audio tracks found")?;
205//! let options = ReadOptions::default()
206//! .with_format(SectorReadFormat::Audio)
207//! .with_retry(RetryConfig::default().with_max_attempts(6))
208//! .with_read_speed(ReadSpeed::CustomMultiplier(4));
209//! let data = reader.read_track_with_options(&toc, track.number, &options)?;
210//! # Ok::<(), Box<dyn std::error::Error>>(())
211//! ```
212//!
213//! ## Metadata
214//!
215//! Audio CDs carry almost no semantic metadata. [CD-TEXT] exists but is
216//! unreliable and because of that is not provided by this library. The practical approach is to
217//! calculate a Disc ID from the ToC and look it up on a service such as
218//! [MusicBrainz]. The [`Toc`] struct exposes everything required for the
219//! [MusicBrainz disc ID algorithm].
220//!
221//! [CD-TEXT]: https://en.wikipedia.org/wiki/CD-Text
222//! [MusicBrainz]: https://musicbrainz.org/
223//! [MusicBrainz disc ID algorithm]: https://musicbrainz.org/doc/Disc_ID_Calculation
224mod platform;
225
226mod data_reader;
227mod discovery;
228mod errors;
229mod read_loop;
230mod retry;
231mod stream;
232mod utils;
233
234mod backend;
235pub use backend::{
236 AudioSectorReader, AudioTrackStream, TrackBounds, open_track_stream, open_track_stream_at,
237 open_track_stream_with_bounds, read_track, read_track_with_bounds,
238};
239pub use data_reader::{ReadOptions, ReadSpeed, SectorReadFormat};
240pub use discovery::DriveInfo;
241pub use errors::{CdReaderError, ScsiError, ScsiOp};
242pub use retry::RetryConfig;
243pub use stream::TrackStream;
244
245mod parse_toc;
246pub use parse_toc::lba_to_msf;
247
248/// Representation of the track from ToC, purely in terms of data location on the CD.
249#[derive(Debug)]
250pub struct Track {
251 /// Track number from the Table of Contents (read from the CD itself).
252 /// It usually starts with 1, but you should read this value directly when
253 /// reading raw track data. There might be gaps, and also in the future
254 /// there might be hidden track support, which will be located at number 0.
255 pub number: u8,
256 /// starting offset
257 pub start_lba: u32,
258 /// Track start address in `(minutes, seconds, frames)` (MSF) form.
259 ///
260 /// MSF uses 75 frames per second and includes the standard 150-frame
261 /// lead-in offset, so LBA 0 corresponds to `(0, 2, 0)`. See [`lba_to_msf`].
262 pub start_msf: (u8, u8, u8),
263 /// Whether the TOC identifies this as an audio track.
264 /// A value of `false` indicates a data track.
265 pub is_audio: bool,
266}
267
268/// Table of Contents, read directly from the Audio CD. The most important part
269/// is the `tracks` vector, which allows you to read raw track data.
270///
271/// If you read from file/image directly, you need to construct it manually.
272#[derive(Debug)]
273pub struct Toc {
274 /// First track number reported in the TOC header.
275 ///
276 /// This is a disc track number, not a zero-based index into [`Toc::tracks`].
277 /// It does not have to start with 1 and can be up to 99.
278 pub first_track: u8,
279 /// Helper value with the last track number. You should not use it directly to
280 /// iterate over all available tracks, as there might be gaps.
281 pub last_track: u8,
282 /// List of tracks with LBA and MSF offsets
283 pub tracks: Vec<Track>,
284 /// LBA at which the lead-out area begins, as reported by the disc TOC.
285 ///
286 /// Track-bound calculations use this as the upper bound only for the last
287 /// entry in [`Toc::tracks`]. If another track follows, its start and any
288 /// applicable CD-Extra session-gap handling determine the preceding track's
289 /// bound instead. The lead-out LBA is also required to calculate a
290 /// MusicBrainz Disc ID.
291 pub leadout_lba: u32,
292}
293
294/// Prepends a standard 44-byte RIFF/WAVE header to raw CD-DA PCM.
295///
296/// `data` must already contain headerless, signed 16-bit little-endian,
297/// interleaved stereo PCM sampled at 44,100 Hz. This function does not validate
298/// or convert the audio data; it only adds a header describing that format.
299///
300/// PCM returned by [`CdReader::read_track`] or the source-independent
301/// [`read_track`] function already has the required format. The returned vector
302/// contains a complete WAV file and can be written directly to a `.wav` file.
303pub fn create_wav(data: Vec<u8>) -> Vec<u8> {
304 let mut header = utils::create_wav_header(data.len() as u32);
305 header.extend_from_slice(&data);
306 header
307}
308
309/// Helper struct to interact with the audio CD. Internally it holds a platform-specific
310/// handle to the open CD drive to read from it and it is correctly closed when CDReader
311/// is dropped.
312pub struct CdReader {
313 drive: platform::Drive,
314}
315
316impl CdReader {
317 /// Opens a drive returned by [`CdReader::list_drives`].
318 ///
319 /// The reader owns the opened drive until it is dropped.
320 ///
321 /// # Errors
322 ///
323 /// Returns [`CdReaderError::Io`] if the discovered drive path cannot be
324 /// opened with the access required for raw drive commands.
325 pub fn open(drive: &DriveInfo) -> Result<Self, CdReaderError> {
326 Self::open_path(&drive.path)
327 }
328
329 /// Opens a CD drive at a platform-specific device path.
330 ///
331 /// Example paths are `/dev/sr0` on Linux, `disk6` on macOS, and
332 /// `\\.\E:` on Windows. The reader owns the opened drive until it is dropped.
333 ///
334 /// # Errors
335 ///
336 /// Returns [`CdReaderError::Io`] if `path` is invalid or the operating
337 /// system cannot open it with the required access.
338 pub fn open_path(path: &str) -> Result<Self, CdReaderError> {
339 Ok(Self {
340 drive: platform::Drive::open(path)?,
341 })
342 }
343
344 /// Builds a reader from an already-open handle to the drive's device node,
345 /// instead of opening the path ourselves.
346 ///
347 /// This exists for privileged access. Reading a raw optical device can
348 /// require more rights than the calling process has, and there is no way to
349 /// gain them after the fact — the descriptor has to come from somewhere
350 /// else. A caller that hits `EPERM` / `EACCES` from [`CdReader::open_path`]
351 /// can obtain one through a privilege-escalation helper (macOS
352 /// `/usr/libexec/authopen`, a setuid helper, a launchd service) and hand it
353 /// over here.
354 ///
355 /// The handle must refer to the drive's device node — `/dev/rdiskN` on
356 /// macOS, `/dev/srN` on Linux — and the reader takes ownership of it,
357 /// closing it on drop.
358 ///
359 /// On Linux the handle must have been opened `O_RDWR`: the SG_IO ioctls
360 /// this crate issues are rejected on a read-only descriptor. On macOS
361 /// `O_RDONLY` is correct and preferred, since a write-capable open of an
362 /// optical device demands exclusivity.
363 #[cfg(unix)]
364 pub fn from_file(file: std::fs::File) -> Self {
365 Self {
366 drive: platform::Drive::from_file(file),
367 }
368 }
369
370 #[cfg(test)]
371 pub(crate) fn test_reader() -> Self {
372 Self {
373 drive: platform::Drive::test_drive(),
374 }
375 }
376
377 /// Read Table of Contents for the opened drive. You'll likely only need to access
378 /// `tracks` from the returned value in order to iterate and read each track's raw data.
379 /// Please note that each track in the vector has `number` property, which you should use
380 /// when calling `read_track`, as it doesn't start with 0. It is important to do so,
381 /// because in the future it might include 0 for the hidden track.
382 ///
383 /// # Errors
384 ///
385 /// Returns [`CdReaderError::Io`] or [`CdReaderError::Scsi`] if the drive
386 /// command fails, and [`CdReaderError::Parse`] if the returned TOC is
387 /// malformed.
388 pub fn read_toc(&self) -> Result<Toc, CdReaderError> {
389 self.drive.read_toc()
390 }
391
392 /// Read an audio track using the default options.
393 ///
394 /// It returns raw PCM data, but if you want to save it directly and make it playable,
395 /// wrap the result with [`create_wav`].
396 ///
397 /// # Errors
398 ///
399 /// Returns the same errors as [`CdReader::read_track_with_options`].
400 pub fn read_track(&self, toc: &Toc, track_no: u8) -> Result<Vec<u8>, CdReaderError> {
401 self.read_track_with_options(toc, track_no, &ReadOptions::default())
402 }
403
404 /// Read a complete track using explicit read options.
405 ///
406 /// # Errors
407 ///
408 /// - Returns [`CdReaderError::TrackFormatMismatch`] if the selected sector
409 /// format is incompatible with the track.
410 /// - Returns [`CdReaderError::Io`] if the track is absent, its bounds are
411 /// invalid, or an operating-system drive operation fails.
412 /// - Returns [`CdReaderError::Scsi`] if the drive rejects a read command.
413 pub fn read_track_with_options(
414 &self,
415 toc: &Toc,
416 track_no: u8,
417 options: &ReadOptions,
418 ) -> Result<Vec<u8>, CdReaderError> {
419 if let Some(track) = toc.tracks.iter().find(|track| track.number == track_no) {
420 data_reader::validate_track_format(track, options.format())?;
421 }
422
423 let (start_lba, sectors) =
424 utils::get_track_bounds(toc, track_no).map_err(CdReaderError::Io)?;
425 self.read_sector_range(start_lba, sectors, options)
426 }
427
428 /// Read an arbitrary range of sectors using explicit read options.
429 ///
430 /// # Low-level API
431 ///
432 /// Callers are responsible for providing valid sector boundaries and selecting
433 /// a format compatible with the sectors on the disc. Prefer [`CdReader::read_track`]
434 /// or [`CdReader::read_track_with_options`] when reading a complete TOC track.
435 ///
436 /// # Errors
437 ///
438 /// - Returns [`CdReaderError::Io`] if the range is invalid, the read-speed
439 /// request fails, or an operating-system read fails.
440 /// - Returns [`CdReaderError::Scsi`] if the drive rejects a read command.
441 pub fn read_sector_range(
442 &self,
443 start_lba: u32,
444 sectors: u32,
445 options: &ReadOptions,
446 ) -> Result<Vec<u8>, CdReaderError> {
447 let format = options.format();
448 self.drive.request_read_speed(options.read_speed())?;
449 read_loop::read_sectors_chunked(
450 start_lba,
451 sectors,
452 format,
453 options.retry(),
454 |lba, chunk_sectors| self.drive.read_cd_chunk(lba, chunk_sectors, format),
455 )
456 }
457}