//! Disk reader for streaming audio playback. //! //! Provides lock-free read-ahead buffers for audio files that cannot be kept //! fully decoded in memory. A background thread fills these buffers ahead of //! the playhead so the audio callback never blocks on I/O or decoding. //! //! **InMemory** files bypass the disk reader entirely — their data is already //! available as `&[f32]`. **Mapped** files (mmap'd WAV/AIFF) also bypass the //! disk reader for now (OS page cache handles paging). **Compressed** files //! (MP3, FLAC, OGG, etc.) use a `CompressedReader` that stream-decodes on //! demand via Symphonia into a `ReadAheadBuffer`. use std::cell::UnsafeCell; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::DecoderOptions; use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; /// Read-ahead distance in seconds. const PREFETCH_SECONDS: f64 = 2.0; /// How often the disk reader thread wakes up to check for work (ms). const POLL_INTERVAL_MS: u64 = 5; // --------------------------------------------------------------------------- // ReadAheadBuffer // --------------------------------------------------------------------------- /// Lock-free read-ahead buffer shared between the disk reader (writer) and the /// audio callback (reader). /// /// # Thread safety /// /// This is a **single-producer single-consumer** (SPSC) structure: /// - **Producer** (disk reader thread): calls `write_samples()` and /// `advance_start()` to fill and reclaim buffer space. /// - **Consumer** (audio callback): calls `read_sample()` and `has_range()` /// to access decoded audio. /// /// The producer only writes to indices **beyond** `valid_frames`, while the /// consumer only reads indices **within** `[start_frame, start_frame + /// valid_frames)`. Because the two threads always operate on disjoint regions, /// the sample data itself requires no locking. Atomics with Acquire/Release /// ordering on `start_frame` and `valid_frames` provide the happens-before /// relationship that guarantees the consumer sees completed writes. /// /// The `UnsafeCell` wrapping the buffer data allows the producer to mutate it /// through a shared `&self` reference. This is sound because only one thread /// (the producer) ever writes, and it writes to a region that the consumer /// cannot yet see (gated by the `valid_frames` atomic). pub struct ReadAheadBuffer { /// Interleaved f32 samples stored as a circular buffer. /// Wrapped in `UnsafeCell` to allow the producer to write through `&self`. buffer: UnsafeCell>, /// The absolute frame number of the oldest valid frame in the ring. start_frame: AtomicU64, /// Number of valid frames starting from `start_frame`. valid_frames: AtomicU64, /// Total capacity in frames. capacity_frames: usize, /// Number of audio channels. channels: u32, /// Source file sample rate. sample_rate: u32, /// Last file-local frame requested by the audio callback. /// Written by the consumer (render_from_file), read by the disk reader. /// The disk reader uses this instead of the global playhead to know /// where in the file to buffer around. target_frame: AtomicU64, } // SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument. // In short: SPSC access pattern with atomic coordination means no data races. // The circular design means advance_start never moves data — it only bumps // the start pointer, so the consumer never sees partially-shifted memory. unsafe impl Send for ReadAheadBuffer {} unsafe impl Sync for ReadAheadBuffer {} impl std::fmt::Debug for ReadAheadBuffer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ReadAheadBuffer") .field("capacity_frames", &self.capacity_frames) .field("channels", &self.channels) .field("sample_rate", &self.sample_rate) .field("start_frame", &self.start_frame.load(Ordering::Relaxed)) .field("valid_frames", &self.valid_frames.load(Ordering::Relaxed)) .finish() } } impl ReadAheadBuffer { /// Create a new read-ahead buffer with the given capacity (in seconds). pub fn new(capacity_seconds: f64, sample_rate: u32, channels: u32) -> Self { let capacity_frames = (capacity_seconds * sample_rate as f64) as usize; let buffer_len = capacity_frames * channels as usize; Self { buffer: UnsafeCell::new(vec![0.0f32; buffer_len].into_boxed_slice()), start_frame: AtomicU64::new(0), valid_frames: AtomicU64::new(0), capacity_frames, channels, sample_rate, target_frame: AtomicU64::new(0), } } /// Map an absolute frame number to a ring-buffer sample index. #[inline(always)] fn ring_index(&self, frame: u64, channel: usize) -> usize { let ring_frame = (frame as usize) % self.capacity_frames; ring_frame * self.channels as usize + channel } /// Snapshot the current valid range. Call once per audio callback, then /// pass the returned `(start, end)` to `read_sample` for consistent reads. #[inline] pub fn snapshot(&self) -> (u64, u64) { let start = self.start_frame.load(Ordering::Acquire); let valid = self.valid_frames.load(Ordering::Acquire); (start, start + valid) } /// Read a single interleaved sample using a pre-loaded range snapshot. /// Returns `0.0` if the frame is outside `[snap_start, snap_end)`. /// Called from the **audio callback** (consumer). #[inline] pub fn read_sample(&self, frame: u64, channel: usize, snap_start: u64, snap_end: u64) -> f32 { if frame < snap_start || frame >= snap_end { return 0.0; } let idx = self.ring_index(frame, channel); // SAFETY: We only read indices that the producer has already written // and published via valid_frames. The circular layout means // advance_start never moves data, so no torn reads are possible. let buffer = unsafe { &*self.buffer.get() }; buffer[idx] } /// Check whether a contiguous range of frames is fully available. #[inline] pub fn has_range(&self, start: u64, count: u64) -> bool { let buf_start = self.start_frame.load(Ordering::Acquire); let valid = self.valid_frames.load(Ordering::Acquire); start >= buf_start && start + count <= buf_start + valid } /// Current start frame of the buffer. #[inline] pub fn start_frame(&self) -> u64 { self.start_frame.load(Ordering::Acquire) } /// Number of valid frames currently in the buffer. #[inline] pub fn valid_frames_count(&self) -> u64 { self.valid_frames.load(Ordering::Acquire) } /// Update the target frame — the file-local frame the audio callback /// is currently reading from. Called by `render_from_file` (consumer). #[inline] pub fn set_target_frame(&self, frame: u64) { self.target_frame.store(frame, Ordering::Relaxed); } /// Get the target frame set by the audio callback. /// Called by the disk reader thread (producer). #[inline] pub fn target_frame(&self) -> u64 { self.target_frame.load(Ordering::Relaxed) } /// Reset the buffer to start at `new_start` with zero valid frames. /// Called by the **disk reader thread** (producer) after a seek. pub fn reset(&self, new_start: u64) { self.valid_frames.store(0, Ordering::Release); self.start_frame.store(new_start, Ordering::Release); } /// Write interleaved samples into the buffer, extending the valid range. /// Called by the **disk reader thread** (producer only). /// Returns the number of frames actually written (may be less than `frames` /// if the buffer is full). /// /// # Safety /// Must only be called from the single producer thread. pub fn write_samples(&self, samples: &[f32], frames: usize) -> usize { let valid = self.valid_frames.load(Ordering::Acquire) as usize; let remaining_capacity = self.capacity_frames - valid; let write_frames = frames.min(remaining_capacity); if write_frames == 0 { return 0; } let ch = self.channels as usize; let start = self.start_frame.load(Ordering::Acquire); let write_start_frame = start as usize + valid; // SAFETY: We only write to ring positions beyond the current valid // range, which the consumer cannot access. Only one producer calls this. let buffer = unsafe { &mut *self.buffer.get() }; // Write with wrap-around: the ring position may cross the buffer end. let ring_start = (write_start_frame % self.capacity_frames) * ch; let total_samples = write_frames * ch; let buffer_sample_len = self.capacity_frames * ch; let first_chunk = total_samples.min(buffer_sample_len - ring_start); buffer[ring_start..ring_start + first_chunk] .copy_from_slice(&samples[..first_chunk]); if first_chunk < total_samples { // Wrap around to the beginning of the buffer. let second_chunk = total_samples - first_chunk; buffer[..second_chunk] .copy_from_slice(&samples[first_chunk..first_chunk + second_chunk]); } // Make the new samples visible to the consumer. self.valid_frames .store((valid + write_frames) as u64, Ordering::Release); write_frames } /// Advance the buffer start, discarding frames behind the playhead. /// Called by the **disk reader thread** (producer only) to reclaim space. /// /// Because this is a circular buffer, advancing the start only updates /// atomic counters — no data is moved, so the consumer never sees /// partially-shifted memory. pub fn advance_start(&self, new_start: u64) { let old_start = self.start_frame.load(Ordering::Acquire); if new_start <= old_start { return; } let advance_frames = (new_start - old_start) as usize; let valid = self.valid_frames.load(Ordering::Acquire) as usize; if advance_frames >= valid { // All data is stale — just reset. self.valid_frames.store(0, Ordering::Release); self.start_frame.store(new_start, Ordering::Release); return; } let new_valid = valid - advance_frames; // Store valid_frames first (shrinking the visible range), then // advance start_frame. The consumer always sees a consistent // sub-range of valid data. self.valid_frames .store(new_valid as u64, Ordering::Release); self.start_frame.store(new_start, Ordering::Release); } } // --------------------------------------------------------------------------- // CompressedReader // --------------------------------------------------------------------------- /// Wraps a Symphonia decoder for streaming a single compressed audio file. struct CompressedReader { format_reader: Box, decoder: Box, track_id: u32, /// Current decoder position in frames. current_frame: u64, sample_rate: u32, channels: u32, #[allow(dead_code)] total_frames: u64, /// Temporary decode buffer. sample_buf: Option>, } impl CompressedReader { /// Open a compressed audio file and prepare for streaming decode. fn open(path: &Path) -> Result { let file = std::fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } let probed = symphonia::default::get_probe() .format( &hint, mss, &FormatOptions::default(), &MetadataOptions::default(), ) .map_err(|e| format!("Failed to probe file: {}", e))?; let format_reader = probed.format; let track = format_reader .tracks() .iter() .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) .ok_or_else(|| "No audio tracks found".to_string())?; let track_id = track.id; let codec_params = &track.codec_params; let sample_rate = codec_params.sample_rate.unwrap_or(44100); let channels = codec_params .channels .map(|c| c.count()) .unwrap_or(2) as u32; let total_frames = codec_params.n_frames.unwrap_or(0); let decoder = symphonia::default::get_codecs() .make(codec_params, &DecoderOptions::default()) .map_err(|e| format!("Failed to create decoder: {}", e))?; Ok(Self { format_reader, decoder, track_id, current_frame: 0, sample_rate, channels, total_frames, sample_buf: None, }) } /// Seek to a specific frame. Returns the actual frame reached (may differ /// for compressed formats that can only seek to keyframes). fn seek(&mut self, target_frame: u64) -> Result { let seek_to = SeekTo::TimeStamp { ts: target_frame, track_id: self.track_id, }; let seeked = self .format_reader .seek(SeekMode::Coarse, seek_to) .map_err(|e| format!("Seek failed: {}", e))?; let actual_frame = seeked.actual_ts; self.current_frame = actual_frame; // Reset the decoder after seeking. self.decoder.reset(); Ok(actual_frame) } /// Decode the next chunk of audio into `out`. Returns the number of frames /// decoded. Returns `Ok(0)` at end-of-file. fn decode_next(&mut self, out: &mut Vec) -> Result { out.clear(); loop { let packet = match self.format_reader.next_packet() { Ok(p) => p, Err(symphonia::core::errors::Error::IoError(ref e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { return Ok(0); // EOF } Err(e) => return Err(format!("Read packet error: {}", e)), }; if packet.track_id() != self.track_id { continue; } match self.decoder.decode(&packet) { Ok(decoded) => { if self.sample_buf.is_none() { let spec = *decoded.spec(); let duration = decoded.capacity() as u64; self.sample_buf = Some(SampleBuffer::new(duration, spec)); } if let Some(ref mut buf) = self.sample_buf { buf.copy_interleaved_ref(decoded); let samples = buf.samples(); out.extend_from_slice(samples); let frames = samples.len() / self.channels as usize; self.current_frame += frames as u64; return Ok(frames); } return Ok(0); } Err(symphonia::core::errors::Error::DecodeError(_)) => { continue; // Skip corrupt packets. } Err(e) => return Err(format!("Decode error: {}", e)), } } } } // --------------------------------------------------------------------------- // DiskReaderCommand // --------------------------------------------------------------------------- /// Commands sent from the engine to the disk reader thread. pub enum DiskReaderCommand { /// Start streaming a compressed file. ActivateFile { pool_index: usize, path: PathBuf, buffer: Arc, }, /// Stop streaming a file. DeactivateFile { pool_index: usize }, /// The playhead has jumped — refill buffers from the new position. Seek { frame: u64 }, /// Shut down the disk reader thread. Shutdown, } // --------------------------------------------------------------------------- // DiskReader // --------------------------------------------------------------------------- /// Manages background read-ahead for compressed audio files. /// /// The engine creates a `DiskReader` at startup. When a compressed file is /// imported, it sends an `ActivateFile` command. The disk reader opens a /// Symphonia decoder and starts filling the file's `ReadAheadBuffer` ahead /// of the shared playhead. pub struct DiskReader { /// Channel to send commands to the background thread. command_tx: rtrb::Producer, /// Shared playhead position (frames). The engine updates this atomically. #[allow(dead_code)] playhead_frame: Arc, /// Whether the reader thread is running. running: Arc, /// Background thread handle. thread_handle: Option>, } impl DiskReader { /// Create a new disk reader with a background thread. pub fn new(playhead_frame: Arc, _sample_rate: u32) -> Self { let (command_tx, command_rx) = rtrb::RingBuffer::new(64); let running = Arc::new(AtomicBool::new(true)); let thread_running = running.clone(); let thread_handle = std::thread::Builder::new() .name("disk-reader".into()) .spawn(move || { Self::reader_thread(command_rx, thread_running); }) .expect("Failed to spawn disk reader thread"); Self { command_tx, playhead_frame, running, thread_handle: Some(thread_handle), } } /// Send a command to the disk reader thread. pub fn send(&mut self, cmd: DiskReaderCommand) { let _ = self.command_tx.push(cmd); } /// Create a `ReadAheadBuffer` for a compressed file. pub fn create_buffer(sample_rate: u32, channels: u32) -> Arc { Arc::new(ReadAheadBuffer::new( PREFETCH_SECONDS + 1.0, // extra headroom sample_rate, channels, )) } /// The disk reader background thread. fn reader_thread( mut command_rx: rtrb::Consumer, running: Arc, ) { let mut active_files: HashMap)> = HashMap::new(); let mut decode_buf = Vec::with_capacity(8192); while running.load(Ordering::Relaxed) { // Process commands. while let Ok(cmd) = command_rx.pop() { match cmd { DiskReaderCommand::ActivateFile { pool_index, path, buffer, } => match CompressedReader::open(&path) { Ok(reader) => { eprintln!("[DiskReader] Activated pool={}, ch={}, sr={}, path={:?}", pool_index, reader.channels, reader.sample_rate, path); active_files.insert(pool_index, (reader, buffer)); } Err(e) => { eprintln!( "[DiskReader] Failed to open compressed file {:?}: {}", path, e ); } }, DiskReaderCommand::DeactivateFile { pool_index } => { active_files.remove(&pool_index); } DiskReaderCommand::Seek { frame } => { for (_, (reader, buffer)) in active_files.iter_mut() { buffer.set_target_frame(frame); buffer.reset(frame); if let Err(e) = reader.seek(frame) { eprintln!("[DiskReader] Seek error: {}", e); } } } DiskReaderCommand::Shutdown => { return; } } } // Fill each active file's buffer ahead of its target frame. // Each file's target_frame is set by the audio callback in // render_from_file, giving the file-local frame being read. // This is independent of the global engine playhead. for (_pool_index, (reader, buffer)) in active_files.iter_mut() { let target = buffer.target_frame(); let buf_start = buffer.start_frame(); let buf_valid = buffer.valid_frames_count(); let buf_end = buf_start + buf_valid; // If the target has jumped behind or far ahead of the buffer, // seek the decoder and reset. if target < buf_start || target > buf_end + reader.sample_rate as u64 { buffer.reset(target); let _ = reader.seek(target); continue; } // Advance the buffer start to reclaim space behind the target. // Keep a small lookback for sinc interpolation (~32 frames). let lookback = 64u64; let advance_to = target.saturating_sub(lookback); if advance_to > buf_start { buffer.advance_start(advance_to); } // Calculate how far ahead we need to fill. let buf_start = buffer.start_frame(); let buf_valid = buffer.valid_frames_count(); let buf_end = buf_start + buf_valid; let prefetch_target = target + (PREFETCH_SECONDS * reader.sample_rate as f64) as u64; if buf_end >= prefetch_target { continue; // Already filled far enough ahead. } // Decode more data into the buffer. match reader.decode_next(&mut decode_buf) { Ok(0) => {} // EOF Ok(frames) => { let was_empty = buffer.valid_frames_count() == 0; buffer.write_samples(&decode_buf, frames); if was_empty { eprintln!("[DiskReader] pool={}: first fill, {} frames, buf_start={}, valid={}", _pool_index, frames, buffer.start_frame(), buffer.valid_frames_count()); } } Err(e) => { eprintln!("[DiskReader] Decode error: {}", e); } } } // Sleep briefly to avoid busy-spinning when all buffers are full. std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)); } } } impl Drop for DiskReader { fn drop(&mut self) { self.running.store(false, Ordering::Release); let _ = self.command_tx.push(DiskReaderCommand::Shutdown); if let Some(handle) = self.thread_handle.take() { let _ = handle.join(); } } }