cd_da_reader/lib.rs
1//! # CD-DA (audio CD) reading library
2//!
3//! This library provides cross-platform audio CD reading capabilities
4//! (tested on Windows, macOS and Linux). It was written to enable CD ripping,
5//! but you can also implement a live audio CD player with its help.
6//! The library works by issuing direct SCSI commands and abstracts both
7//! access to the CD drive and reading the actual data from it, so you don't
8//! deal with the hardware directly.
9//!
10//! All operations happen in this order:
11//!
12//! 1. Get a CD drive's handle
13//! 2. Read the ToC (table of contents) of the audio CD
14//! 3. Read track data using ranges from the ToC
15//!
16//! ## CD access
17//!
18//! The easiest way to open a drive is to use [`CdReader::open_default`], which scans
19//! all drives and opens the first one that contains an audio CD:
20//!
21//! ```no_run
22//! use cd_da_reader::CdReader;
23//!
24//! let reader = CdReader::open_default()?;
25//! # Ok::<(), Box<dyn std::error::Error>>(())
26//! ```
27//!
28//! If you need to pick a specific drive, use [`CdReader::list_drives`] followed
29//! by calling [`CdReader::open`] with the specific drive:
30//!
31//! ```no_run
32//! use cd_da_reader::CdReader;
33//!
34//! // Windows / Linux: enumerate drives and inspect the has_audio_cd field
35//! let drives = CdReader::list_drives()?;
36//!
37//! // Any platform: open a known path directly
38//! // Windows: r"\\.\E:"
39//! // macOS: "disk6"
40//! // Linux: "/dev/sr0"
41//! let reader = CdReader::open("disk6")?;
42//! # Ok::<(), Box<dyn std::error::Error>>(())
43//! ```
44//!
45//! > **macOS note:** querying drives requires claiming exclusive access, which
46//! > unmounts the disc. Releasing it triggers a remount that hands control to
47//! > the default app (usually Apple Music). Use `open_default` or `open` with a
48//! > known path instead of `list_drives` on macOS.
49//!
50//! ## Reading ToC
51//!
52//! Each audio CD carries a Table of Contents with the block address of every
53//! track. You need to read it first before issuing any track read commands:
54//!
55//! ```no_run
56//! use cd_da_reader::CdReader;
57//!
58//! let reader = CdReader::open_default()?;
59//! let toc = reader.read_toc()?;
60//! # Ok::<(), Box<dyn std::error::Error>>(())
61//! ```
62//!
63//! The returned [`Toc`] contains a [`Vec<Track>`](Track) where each entry has
64//! two equivalent address fields:
65//!
66//! - **`start_lba`** -- Logical Block Address, which is a sector index.
67//! LBA 0 is the first readable sector after the 2-second lead-in pre-gap.
68//! This is the format used internally for read commands.
69//! - **`start_msf`** โ Minutes/Seconds/Frames, a time-based address inherited
70//! from the physical disc layout. A "frame" is one sector; the spec defines
71//! 75 frames per second. MSF includes a fixed 2-second (150-frame) lead-in
72//! offset, so `(0, 2, 0)` corresponds to LBA 0. You can convert between them easily:
73//! `LBA + 150 = total frames`, then divide by 75 and 60 for M/S/F.
74//!
75//! ## Reading tracks
76//!
77//! Pass the [`Toc`] and a track number to [`CdReader::read_track`]. The
78//! library calculates the sector boundaries automatically:
79//!
80//! ```no_run
81//! use cd_da_reader::CdReader;
82//!
83//! let reader = CdReader::open_default()?;
84//! let toc = reader.read_toc()?;
85//! let data = reader.read_track(&toc, 1)?; // we assume track #1 exists and is audio
86//! # Ok::<(), Box<dyn std::error::Error>>(())
87//! ```
88//!
89//! This is a blocking call. For a live-playback or progress-reporting use case,
90//! use the streaming API instead:
91//!
92//! ```no_run
93//! use cd_da_reader::{CdReader, RetryConfig, TrackStreamConfig};
94//!
95//! let reader = CdReader::open_default()?;
96//! let toc = reader.read_toc()?;
97//!
98//! let cfg = TrackStreamConfig {
99//! sectors_per_chunk: 27, // ~64 KB per chunk
100//! retry: RetryConfig::default(),
101//! };
102//!
103//! let mut stream = reader.open_track_stream(&toc, 1, cfg)?;
104//! while let Some(chunk) = stream.next_chunk()? {
105//! // process chunk โ raw PCM, 2 352 bytes per sector
106//! }
107//! # Ok::<(), Box<dyn std::error::Error>>(())
108//! ```
109//!
110//! ## Track format
111//!
112//! Track data is raw [PCM](https://en.wikipedia.org/wiki/Pulse-code_modulation),
113//! the same format used inside WAV files. Audio CDs use 16-bit stereo PCM
114//! sampled at 44 100 Hz:
115//!
116//! ```text
117//! 44 100 samples * 2 channels * 2 bytes = 176 400 bytes/second
118//! ```
119//!
120//! Each sector holds exactly 2 352 bytes (176 400 รท 75 = 2 352), that's where
121//! 75 sectors per second comes from. A typical 3-minute track is
122//! ~31 MB; a full 74-minute CD is ~650 MB.
123//!
124//! Converting raw PCM to a playable WAV file only requires prepending a 44-byte
125//! RIFF header โ [`CdReader::create_wav`] does exactly that:
126//!
127//! ```no_run
128//! use cd_da_reader::CdReader;
129//!
130//! let reader = CdReader::open_default()?;
131//! let toc = reader.read_toc()?;
132//! let data = reader.read_track(&toc, 1)?;
133//! let wav = CdReader::create_wav(data);
134//! std::fs::write("track01.wav", wav)?;
135//! # Ok::<(), Box<dyn std::error::Error>>(())
136//! ```
137//!
138//! ## Metadata
139//!
140//! Audio CDs carry almost no semantic metadata. [CD-TEXT] exists but is
141//! unreliable and because of that is not provided by this lbirary. The practical approach is to
142//! calculate a Disc ID from the ToC and look it up on a service such as
143//! [MusicBrainz]. The [`Toc`] struct exposes everything required for the
144//! [MusicBrainz disc ID algorithm].
145//!
146//! [CD-TEXT]: https://en.wikipedia.org/wiki/CD-Text
147//! [MusicBrainz]: https://musicbrainz.org/
148//! [MusicBrainz disc ID algorithm]: https://musicbrainz.org/doc/Disc_ID_Calculation
149#[cfg(target_os = "linux")]
150mod linux;
151#[cfg(target_os = "macos")]
152mod macos;
153#[cfg(target_os = "windows")]
154mod windows;
155
156mod discovery;
157mod errors;
158mod retry;
159mod stream;
160mod utils;
161pub use discovery::DriveInfo;
162pub use errors::{CdReaderError, ScsiError, ScsiOp};
163pub use retry::RetryConfig;
164pub use stream::{TrackStream, TrackStreamConfig};
165
166mod parse_toc;
167
168#[cfg(target_os = "windows")]
169mod windows_read_track;
170
171/// Representation of the track from TOC, purely in terms of data location on the CD.
172#[derive(Debug)]
173pub struct Track {
174 /// Track number from the Table of Contents (read from the CD itself).
175 /// It usually starts with 1, but you should read this value directly when
176 /// reading raw track data. There might be gaps, and also in the future
177 /// there might be hidden track support, which will be located at number 0.
178 pub number: u8,
179 /// starting offset, unnecessary to use directly
180 pub start_lba: u32,
181 /// starting offset, but in (minute, second, frame) format
182 pub start_msf: (u8, u8, u8),
183 pub is_audio: bool,
184}
185
186/// Table of Contents, read directly from the Audio CD. The most important part
187/// is the `tracks` vector, which allows you to read raw track data.
188#[derive(Debug)]
189pub struct Toc {
190 /// Helper value with the first track number
191 pub first_track: u8,
192 /// Helper value with the last track number. You should not use it directly to
193 /// iterate over all available tracks, as there might be gaps.
194 pub last_track: u8,
195 /// List of tracks with LBA and MSF offsets
196 pub tracks: Vec<Track>,
197 /// Used to calculate number of sectors for the last track. You'll also need this
198 /// in order to calculate MusicBrainz ID.
199 pub leadout_lba: u32,
200}
201
202/// Helper struct to interact with the audio CD. While it doesn't hold any internal data
203/// directly, it implements `Drop` trait, so that the CD drive handle is properly closed.
204///
205/// Please note that you should not read multiple CDs at the same time, and preferably do
206/// not use it in multiple threads. CD drives are a physical thing and they really want to
207/// have exclusive access, because of that currently only sequential access is supported.
208///
209/// This is especially true on macOS, where releasing exclusive lock on the audio CD will
210/// cause it to remount, and the default application (very likely Apple Music) will get
211/// the exclusive access and it will be challenging to implement a reliable waiting strategy.
212pub struct CdReader {}
213
214impl CdReader {
215 /// Opens a CD drive at the specified path in order to read data.
216 ///
217 /// It is crucial to call this function and not to create the Reader
218 /// by yourself, as each OS needs its own way of handling the drive access.
219 ///
220 /// You don't need to close the drive, it will be handled automatically
221 /// when the `CdReader` is dropped. On macOS, that will cause the CD drive
222 /// to be remounted, and the default application (like Apple Music) will
223 /// be called.
224 ///
225 /// # Arguments
226 ///
227 /// * `path` - The device path (e.g., "/dev/sr0" on Linux, "disk6" on macOS, and r"\\.\E:" on Windows)
228 ///
229 /// # Errors
230 ///
231 /// Returns an error if the drive cannot be opened
232 pub fn open(path: &str) -> std::io::Result<Self> {
233 #[cfg(target_os = "windows")]
234 {
235 windows::open_drive(path)?;
236 Ok(Self {})
237 }
238
239 #[cfg(target_os = "macos")]
240 {
241 macos::open_drive(path)?;
242 Ok(Self {})
243 }
244
245 #[cfg(target_os = "linux")]
246 {
247 linux::open_drive(path)?;
248 Ok(Self {})
249 }
250
251 #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
252 {
253 compile_error!("Unsupported platform")
254 }
255 }
256
257 /// While this is a low-level library and does not include any codecs to compress the audio,
258 /// it includes a helper function to convert raw PCM data into a wav file, which is done by
259 /// prepending a 44 RIFF bytes header
260 ///
261 /// # Arguments
262 ///
263 /// * `data` - vector of bytes received from `read_track` function
264 pub fn create_wav(data: Vec<u8>) -> Vec<u8> {
265 let mut header = utils::create_wav_header(data.len() as u32);
266 header.extend_from_slice(&data);
267 header
268 }
269
270 /// Read Table of Contents for the opened drive. You'll likely only need to access
271 /// `tracks` from the returned value in order to iterate and read each track's raw data.
272 /// Please note that each track in the vector has `number` property, which you should use
273 /// when calling `read_track`, as it doesn't start with 0. It is important to do so,
274 /// because in the future it might include 0 for the hidden track.
275 pub fn read_toc(&self) -> Result<Toc, CdReaderError> {
276 #[cfg(target_os = "windows")]
277 {
278 windows::read_toc()
279 }
280
281 #[cfg(target_os = "macos")]
282 {
283 macos::read_toc()
284 }
285
286 #[cfg(target_os = "linux")]
287 {
288 linux::read_toc()
289 }
290
291 #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
292 {
293 compile_error!("Unsupported platform")
294 }
295 }
296
297 /// Read raw data for the specified track number from the TOC.
298 /// It returns raw PCM data, but if you want to save it directly and make it playable,
299 /// wrap the result with `create_wav` function, that will prepend a RIFF header and
300 /// make it a proper music file.
301 pub fn read_track(&self, toc: &Toc, track_no: u8) -> Result<Vec<u8>, CdReaderError> {
302 self.read_track_with_retry(toc, track_no, &RetryConfig::default())
303 }
304
305 /// Read raw data for the specified track number from the TOC using explicit retry config.
306 pub fn read_track_with_retry(
307 &self,
308 toc: &Toc,
309 track_no: u8,
310 cfg: &RetryConfig,
311 ) -> Result<Vec<u8>, CdReaderError> {
312 #[cfg(target_os = "windows")]
313 {
314 windows::read_track_with_retry(toc, track_no, cfg)
315 }
316
317 #[cfg(target_os = "macos")]
318 {
319 macos::read_track_with_retry(toc, track_no, cfg)
320 }
321
322 #[cfg(target_os = "linux")]
323 {
324 linux::read_track_with_retry(toc, track_no, cfg)
325 }
326
327 #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
328 {
329 compile_error!("Unsupported platform")
330 }
331 }
332
333 pub(crate) fn read_sectors_with_retry(
334 &self,
335 start_lba: u32,
336 sectors: u32,
337 cfg: &RetryConfig,
338 ) -> Result<Vec<u8>, CdReaderError> {
339 #[cfg(target_os = "windows")]
340 {
341 windows::read_sectors_with_retry(start_lba, sectors, cfg)
342 }
343
344 #[cfg(target_os = "macos")]
345 {
346 macos::read_sectors_with_retry(start_lba, sectors, cfg)
347 }
348
349 #[cfg(target_os = "linux")]
350 {
351 linux::read_sectors_with_retry(start_lba, sectors, cfg)
352 }
353
354 #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
355 {
356 compile_error!("Unsupported platform")
357 }
358 }
359}
360
361impl Drop for CdReader {
362 fn drop(&mut self) {
363 #[cfg(target_os = "windows")]
364 {
365 windows::close_drive();
366 }
367
368 #[cfg(target_os = "macos")]
369 {
370 macos::close_drive();
371 }
372
373 #[cfg(target_os = "linux")]
374 {
375 linux::close_drive();
376 }
377 }
378}