diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index d2c2facb..dfc06fa0 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -35,12 +35,13 @@ with-wgpu-gl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] # ---------------------------------- AUDIO ------------------------------------ # Umbrella features -audio = ["audio-output-device", "audio-sound-buffer"] +audio = ["audio-output-device", "audio-sound-buffer", "audio-playback"] # Granular feature flags audio-output-device = ["lambda-rs-platform/audio-device"] audio-sound-buffer-wav = ["lambda-rs-platform/audio-decode-wav"] audio-sound-buffer-vorbis = ["lambda-rs-platform/audio-decode-vorbis"] +audio-playback = ["audio-output-device", "audio-sound-buffer"] # Umbrella feature audio-sound-buffer = [ diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 03e4474a..f5188f73 100644 --- a/crates/lambda-rs/src/audio/buffer.rs +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -1,6 +1,9 @@ #![allow(clippy::needless_return)] -use std::path::Path; +use std::{ + path::Path, + sync::Arc, +}; use crate::audio::AudioError; @@ -8,7 +11,7 @@ use crate::audio::AudioError; /// playback. #[derive(Clone, Debug, PartialEq)] pub struct SoundBuffer { - samples: Vec, + samples: Arc<[f32]>, sample_rate: u32, channels: u16, } @@ -139,12 +142,60 @@ impl SoundBuffer { } return Ok(Self { - samples: decoded.samples, + samples: decoded.samples.into(), sample_rate: decoded.sample_rate, channels: decoded.channels, }); } + /// Construct a `SoundBuffer` from interleaved samples for unit tests. + /// + /// # Arguments + /// - `samples`: Interleaved samples, `frames * channels` in length. + /// - `sample_rate`: Sample rate in Hz. + /// - `channels`: Interleaved channel count. + /// + /// # Returns + /// A validated `SoundBuffer` constructed from the provided samples. + /// + /// # Errors + /// Returns [`AudioError::InvalidData`] when the metadata is invalid or when + /// the sample vector length is not a multiple of `channels`. + #[cfg(test)] + pub(crate) fn from_interleaved_samples_for_test( + samples: Vec, + sample_rate: u32, + channels: u16, + ) -> Result { + if sample_rate == 0 { + return Err(AudioError::InvalidData { + details: "test sound buffer sample rate was 0".to_string(), + }); + } + + if channels == 0 { + return Err(AudioError::InvalidData { + details: "test sound buffer channel count was 0".to_string(), + }); + } + + if !samples.len().is_multiple_of(channels as usize) { + return Err(AudioError::InvalidData { + details: format!( + "test sound buffer sample length was not divisible by channels (samples={}, channels={})", + samples.len(), + channels + ), + }); + } + + return Ok(Self { + samples: samples.into(), + sample_rate, + channels, + }); + } + /// Return the sample rate in Hz. /// /// # Returns @@ -166,7 +217,7 @@ impl SoundBuffer { /// # Returns /// A slice of interleaved samples. pub fn samples(&self) -> &[f32] { - return self.samples.as_slice(); + return self.samples.as_ref(); } /// Return the number of frames in this buffer. @@ -233,7 +284,7 @@ mod tests { #[test] fn duration_seconds_computes_expected_value() { let buffer = SoundBuffer { - samples: vec![0.0; 48000], + samples: vec![0.0; 48000].into(), sample_rate: 48000, channels: 1, }; @@ -246,7 +297,7 @@ mod tests { #[test] fn frames_returns_zero_when_channels_is_zero() { let buffer = SoundBuffer { - samples: vec![0.0, 0.0], + samples: vec![0.0, 0.0].into(), sample_rate: 48_000, channels: 0, }; diff --git a/crates/lambda-rs/src/audio/mod.rs b/crates/lambda-rs/src/audio/mod.rs index 58f22bd9..ed2c9fee 100644 --- a/crates/lambda-rs/src/audio/mod.rs +++ b/crates/lambda-rs/src/audio/mod.rs @@ -27,3 +27,8 @@ pub mod devices; #[cfg(feature = "audio-output-device")] pub use devices::output::*; + +#[cfg(feature = "audio-playback")] +mod playback; +#[cfg(feature = "audio-playback")] +pub use playback::*; diff --git a/crates/lambda-rs/src/audio/playback/callback.rs b/crates/lambda-rs/src/audio/playback/callback.rs new file mode 100644 index 00000000..f186e734 --- /dev/null +++ b/crates/lambda-rs/src/audio/playback/callback.rs @@ -0,0 +1,711 @@ +use std::sync::Arc; + +use super::{ + CommandQueue, + PlaybackCommand, + PlaybackSharedState, + PlaybackState, + DEFAULT_GAIN_RAMP_FRAMES, + MAX_PLAYBACK_CHANNELS, +}; +use crate::audio::{ + AudioOutputWriter, + SoundBuffer, +}; + +/// A linear gain ramp used to de-click transport transitions. +#[derive(Clone, Copy, Debug, PartialEq)] +struct GainRamp { + current: f32, + target: f32, + step: f32, + frames_remaining: usize, +} + +impl GainRamp { + /// Create a silent ramp with a target gain of `0.0`. + /// + /// # Returns + /// A `GainRamp` initialized to silence. + fn silent() -> Self { + return Self { + current: 0.0, + target: 0.0, + step: 0.0, + frames_remaining: 0, + }; + } + + /// Begin ramping the gain toward a target. + /// + /// # Arguments + /// - `target`: Target gain in nominal range `[0.0, 1.0]`. + /// - `frames`: Ramp duration in frames. When `0`, the gain changes + /// immediately. + /// + /// # Returns + /// `()` after updating the ramp parameters. + fn start(&mut self, target: f32, frames: usize) { + let target = target.clamp(0.0, 1.0); + + if frames == 0 || (self.current - target).abs() <= f32::EPSILON { + self.current = target; + self.target = target; + self.step = 0.0; + self.frames_remaining = 0; + return; + } + + self.target = target; + self.frames_remaining = frames; + self.step = (target - self.current) / frames as f32; + return; + } + + /// Return whether the ramp is fully silent and stable. + /// + /// # Returns + /// `true` if the current and target gain are both `0.0` with no remaining + /// ramp frames. + fn is_silent(&self) -> bool { + return self.frames_remaining == 0 + && self.current.abs() <= f32::EPSILON + && self.target.abs() <= f32::EPSILON; + } + + /// Advance the ramp by one output frame. + /// + /// # Returns + /// `()` after advancing the ramp state. + fn advance_frame(&mut self) { + if self.frames_remaining == 0 { + return; + } + + self.current += self.step; + self.frames_remaining = self.frames_remaining.saturating_sub(1); + + if self.frames_remaining == 0 { + self.current = self.target; + self.step = 0.0; + } + + return; + } +} + +/// Deterministic single-slot playback scheduler. +/// +/// This scheduler is designed to run inside a real-time audio callback and +/// MUST NOT allocate or block while rendering audio. +struct PlaybackScheduler { + state: PlaybackState, + looping: bool, + cursor_samples: usize, + channels: usize, + ramp_frames: usize, + gain: GainRamp, + buffer: Option>, + last_frame_samples: [f32; MAX_PLAYBACK_CHANNELS], +} + +impl PlaybackScheduler { + /// Create a scheduler configured for a fixed output channel count. + /// + /// # Arguments + /// - `channels`: Interleaved output channel count. + /// + /// # Returns + /// A scheduler initialized to `Stopped` with no buffer. + #[allow(dead_code)] + fn new(channels: usize) -> Self { + return Self::new_with_ramp_frames(channels, DEFAULT_GAIN_RAMP_FRAMES); + } + + /// Create a scheduler configured for a fixed output channel count and ramp. + /// + /// # Arguments + /// - `channels`: Interleaved output channel count. + /// - `ramp_frames`: Gain ramp length for transport de-clicking in frames. + /// + /// # Returns + /// A scheduler initialized to `Stopped` with no buffer. + fn new_with_ramp_frames(channels: usize, ramp_frames: usize) -> Self { + return Self { + state: PlaybackState::Stopped, + looping: false, + cursor_samples: 0, + channels, + ramp_frames, + gain: GainRamp::silent(), + buffer: None, + last_frame_samples: [0.0; MAX_PLAYBACK_CHANNELS], + }; + } + + /// Replace the active buffer and reset playback to the start. + /// + /// # Arguments + /// - `buffer`: The decoded buffer to schedule. + /// + /// # Returns + /// `()` after updating the active buffer. + fn set_buffer(&mut self, buffer: Arc) { + self.buffer = Some(buffer); + self.cursor_samples = 0; + return; + } + + /// Enable or disable looping. + /// + /// # Arguments + /// - `looping`: Whether playback should loop on completion. + /// + /// # Returns + /// `()` after updating the looping flag. + fn set_looping(&mut self, looping: bool) { + self.looping = looping; + return; + } + + /// Transition the scheduler to playing. + /// + /// # Returns + /// `()` after updating the transport state. + fn play(&mut self) { + if self.state == PlaybackState::Playing { + return; + } + + self.state = PlaybackState::Playing; + self.gain.start(1.0, self.ramp_frames); + return; + } + + /// Transition the scheduler to paused without resetting position. + /// + /// # Returns + /// `()` after updating the transport state. + fn pause(&mut self) { + if self.state != PlaybackState::Playing { + return; + } + + self.state = PlaybackState::Paused; + self.gain.start(0.0, self.ramp_frames); + return; + } + + /// Stop playback and reset position to the start. + /// + /// # Returns + /// `()` after updating the transport state. + fn stop(&mut self) { + self.state = PlaybackState::Stopped; + self.cursor_samples = 0; + self.gain.start(0.0, self.ramp_frames); + return; + } + + /// Return the current transport state. + /// + /// # Returns + /// The current `PlaybackState`. + fn state(&self) -> PlaybackState { + return self.state; + } + + /// Return the current interleaved cursor position in samples. + /// + /// # Returns + /// The cursor position as an interleaved sample index. + #[allow(dead_code)] + fn cursor_samples(&self) -> usize { + return self.cursor_samples; + } + + /// Render audio for a callback tick into an output writer. + /// + /// # Arguments + /// - `writer`: Real-time writer for the current callback output buffer. + /// + /// # Returns + /// `()` after writing the output buffer. + fn render(&mut self, writer: &mut dyn AudioOutputWriter) { + let writer_channels = writer.channels() as usize; + let frames = writer.frames(); + + if writer_channels == 0 || frames == 0 { + return; + } + + if writer_channels > MAX_PLAYBACK_CHANNELS { + writer.clear(); + return; + } + + if writer_channels != self.channels { + writer.clear(); + return; + } + + for frame_index in 0..frames { + let frame_gain = self.gain.current; + + if self.state != PlaybackState::Playing && self.gain.is_silent() { + writer.clear(); + return; + } + + if self.state == PlaybackState::Playing { + let Some(buffer) = self.buffer.as_ref() else { + for channel_index in 0..writer_channels { + writer.set_sample(frame_index, channel_index, 0.0); + } + self.gain.advance_frame(); + continue; + }; + + let samples = buffer.samples(); + let mut frame_start = self.cursor_samples; + let mut frame_end = frame_start.saturating_add(writer_channels); + + if frame_end > samples.len() + && self.looping + && samples.len() >= writer_channels + { + self.cursor_samples = 0; + frame_start = 0; + frame_end = writer_channels; + } + + if frame_end <= samples.len() { + for channel_index in 0..writer_channels { + let sample = samples + .get(frame_start.saturating_add(channel_index)) + .copied() + .unwrap_or(0.0); + self.last_frame_samples[channel_index] = sample; + writer.set_sample(frame_index, channel_index, sample * frame_gain); + } + + self.cursor_samples = frame_end; + self.gain.advance_frame(); + continue; + } + + self.state = PlaybackState::Stopped; + self.cursor_samples = 0; + self.gain.start(0.0, self.ramp_frames); + } + + for channel_index in 0..writer_channels { + let sample = self.last_frame_samples[channel_index]; + writer.set_sample(frame_index, channel_index, sample * frame_gain); + } + + self.gain.advance_frame(); + } + + return; + } +} + +/// A callback-safe controller that drains transport commands and renders audio. +/// +/// This type is intended to be owned by the platform audio callback closure. +pub(super) struct PlaybackController { + command_queue: Arc>, + shared_state: Arc, + scheduler: PlaybackScheduler, +} + +impl PlaybackController { + /// Create a controller configured for a fixed output channel count. + /// + /// # Arguments + /// - `channels`: Interleaved output channel count. + /// - `command_queue`: Shared producer/consumer command queue. + /// + /// # Returns + /// A controller initialized to `Stopped` with no active buffer. + #[allow(dead_code)] + pub(super) fn new( + channels: usize, + command_queue: Arc>, + shared_state: Arc, + ) -> Self { + return Self::new_with_ramp_frames( + channels, + DEFAULT_GAIN_RAMP_FRAMES, + command_queue, + shared_state, + ); + } + + /// Create a controller with an explicit gain ramp length. + /// + /// # Arguments + /// - `channels`: Interleaved output channel count. + /// - `ramp_frames`: Gain ramp length in frames. + /// - `command_queue`: Shared producer/consumer command queue. + /// + /// # Returns + /// A controller initialized to `Stopped` with no active buffer. + pub(super) fn new_with_ramp_frames( + channels: usize, + ramp_frames: usize, + command_queue: Arc>, + shared_state: Arc, + ) -> Self { + return Self { + command_queue, + shared_state, + scheduler: PlaybackScheduler::new_with_ramp_frames(channels, ramp_frames), + }; + } + + /// Drain any pending transport commands. + /// + /// # Returns + /// `()` after applying all pending commands. + fn drain_commands(&mut self) { + while let Some(command) = self.command_queue.pop() { + match command { + PlaybackCommand::StopCurrent => { + self.scheduler.stop(); + self.shared_state.set_state(PlaybackState::Stopped); + } + PlaybackCommand::SetBuffer { + instance_id, + buffer, + } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } + self.scheduler.stop(); + self.scheduler.set_looping(false); + self.scheduler.set_buffer(buffer); + self.shared_state.set_state(PlaybackState::Stopped); + } + PlaybackCommand::SetLooping { + instance_id, + looping, + } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } + self.scheduler.set_looping(looping); + } + PlaybackCommand::Play { instance_id } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } + self.scheduler.play(); + self.shared_state.set_state(PlaybackState::Playing); + } + PlaybackCommand::Pause { instance_id } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } + self.scheduler.pause(); + self.shared_state.set_state(PlaybackState::Paused); + } + PlaybackCommand::Stop { instance_id } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } + self.scheduler.stop(); + self.shared_state.set_state(PlaybackState::Stopped); + } + } + } + + return; + } + + /// Render audio for a callback tick. + /// + /// # Arguments + /// - `writer`: Real-time writer for the current callback output buffer. + /// + /// # Returns + /// `()` after draining commands and writing the output buffer. + pub(super) fn render(&mut self, writer: &mut dyn AudioOutputWriter) { + self.drain_commands(); + self.scheduler.render(writer); + self.shared_state.set_state(self.scheduler.state()); + return; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::SoundBuffer; + + struct TestAudioOutput { + channels: u16, + frames: usize, + samples: Vec, + } + + impl TestAudioOutput { + fn new(channels: u16, frames: usize) -> Self { + return Self { + channels, + frames, + samples: vec![0.0; channels as usize * frames], + }; + } + + fn sample(&self, frame: usize, channel: usize) -> f32 { + let index = frame + .saturating_mul(self.channels as usize) + .saturating_add(channel); + return self.samples.get(index).copied().unwrap_or(0.0); + } + + fn max_abs(&self) -> f32 { + return self.samples.iter().fold(0.0_f32, |accumulator, value| { + return accumulator.max(value.abs()); + }); + } + } + + impl AudioOutputWriter for TestAudioOutput { + fn channels(&self) -> u16 { + return self.channels; + } + + fn frames(&self) -> usize { + return self.frames; + } + + fn clear(&mut self) { + for value in self.samples.iter_mut() { + *value = 0.0; + } + return; + } + + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ) { + let index = frame_index + .saturating_mul(self.channels as usize) + .saturating_add(channel_index); + if let Some(value) = self.samples.get_mut(index) { + *value = sample; + } + return; + } + } + + fn make_test_buffer(samples: Vec, channels: u16) -> Arc { + let buffer = + SoundBuffer::from_interleaved_samples_for_test(samples, 48_000, channels) + .expect("test buffer creation failed"); + return Arc::new(buffer); + } + + /// Scheduler MUST stop and reset the cursor when a buffer completes. + #[test] + fn scheduler_stops_after_completion() { + let buffer = make_test_buffer(vec![0.5, 0.5, 0.5, 0.5], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 2); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 8); + scheduler.render(&mut writer); + + assert_eq!(scheduler.state(), PlaybackState::Stopped); + assert_eq!(scheduler.cursor_samples(), 0); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + assert!(writer.max_abs() <= 0.5); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + assert!(writer.max_abs() <= f32::EPSILON); + return; + } + + /// Pause MUST preserve cursor and fade to silence. + #[test] + fn scheduler_pause_preserves_cursor() { + let buffer = + make_test_buffer(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 2); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + + let cursor_before_pause = scheduler.cursor_samples(); + scheduler.pause(); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + assert_eq!(scheduler.cursor_samples(), cursor_before_pause); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + assert!(writer.max_abs() <= f32::EPSILON); + return; + } + + /// Stop MUST reset the cursor and fade to silence. + #[test] + fn scheduler_stop_resets_cursor() { + let buffer = make_test_buffer(vec![0.25; 32], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 2); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 8); + scheduler.render(&mut writer); + assert!(scheduler.cursor_samples() > 0); + + scheduler.stop(); + assert_eq!(scheduler.state(), PlaybackState::Stopped); + assert_eq!(scheduler.cursor_samples(), 0); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + assert!(writer.max_abs() <= f32::EPSILON); + return; + } + + /// Looping MUST wrap the cursor and continue producing samples. + #[test] + fn scheduler_looping_wraps() { + let buffer = make_test_buffer(vec![0.1, 0.2], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 0); + scheduler.set_buffer(buffer); + scheduler.set_looping(true); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer); + + assert_eq!(scheduler.state(), PlaybackState::Playing); + assert!((writer.sample(0, 0) - 0.1).abs() <= 1e-6); + assert!((writer.sample(1, 0) - 0.2).abs() <= 1e-6); + assert!((writer.sample(2, 0) - 0.1).abs() <= 1e-6); + assert!((writer.sample(3, 0) - 0.2).abs() <= 1e-6); + return; + } + + /// Transport transitions MUST avoid hard discontinuities. + #[test] + fn scheduler_pause_is_continuous_at_transition_boundary() { + let buffer = make_test_buffer(vec![0.5; 64], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 2); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 8); + scheduler.render(&mut writer); + let last = writer.sample(7, 0); + + scheduler.pause(); + + let mut writer = TestAudioOutput::new(1, 1); + scheduler.render(&mut writer); + let first = writer.sample(0, 0); + + assert!((last - first).abs() <= 1e-6); + return; + } + + /// Controllers MUST drain queued commands before rendering audio. + #[test] + fn controller_drains_commands_before_render() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.1, 0.2], 1); + + shared_state.set_active_instance_id(1); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::SetLooping { + instance_id: 1, + looping: true, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state.clone(), + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + + assert!((writer.sample(0, 0) - 0.1).abs() <= 1e-6); + assert!((writer.sample(1, 0) - 0.2).abs() <= 1e-6); + assert!((writer.sample(2, 0) - 0.1).abs() <= 1e-6); + assert!((writer.sample(3, 0) - 0.2).abs() <= 1e-6); + assert_eq!(shared_state.state(), PlaybackState::Playing); + return; + } + + /// Controllers MUST ignore transport commands for inactive instances. + #[test] + fn controller_ignores_commands_for_inactive_instance() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.1, 0.2], 1); + + shared_state.set_active_instance_id(2); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue.clone(), + shared_state, + ); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut writer = TestAudioOutput::new(1, 1); + controller.render(&mut writer); + assert!(writer.max_abs() <= f32::EPSILON); + return; + } +} diff --git a/crates/lambda-rs/src/audio/playback/context.rs b/crates/lambda-rs/src/audio/playback/context.rs new file mode 100644 index 00000000..0ba59615 --- /dev/null +++ b/crates/lambda-rs/src/audio/playback/context.rs @@ -0,0 +1,714 @@ +use std::sync::Arc; + +use super::{ + CommandQueue, + PlaybackCommand, + PlaybackCommandQueue, + PlaybackController, + PlaybackSharedState, + PlaybackState, + DEFAULT_GAIN_RAMP_FRAMES, + DEFAULT_OUTPUT_CHANNELS, + DEFAULT_OUTPUT_SAMPLE_RATE, + MAX_PLAYBACK_CHANNELS, +}; +use crate::audio::{ + AudioError, + AudioOutputDevice, + AudioOutputDeviceBuilder, + SoundBuffer, +}; + +/// A lightweight handle controlling the active sound playback slot. +/// +/// Only the most recently returned `SoundInstance` for an `AudioContext` is +/// considered active. Calls on inactive instances are no-ops and state queries +/// report `Stopped`. +pub struct SoundInstance { + instance_id: u64, + command_queue: Arc, + shared_state: Arc, +} + +impl SoundInstance { + fn is_active(&self) -> bool { + return self.shared_state.active_instance_id() == self.instance_id; + } + + /// Begin playback, or resume if paused. + /// + /// # Returns + /// `()` after updating the requested transport state. + pub fn play(&mut self) { + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::Play { + instance_id: self.instance_id, + }); + return; + } + + /// Pause playback, preserving playback position. + /// + /// # Returns + /// `()` after updating the requested transport state. + pub fn pause(&mut self) { + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::Pause { + instance_id: self.instance_id, + }); + return; + } + + /// Stop playback and reset position to the start of the buffer. + /// + /// # Returns + /// `()` after updating the requested transport state. + pub fn stop(&mut self) { + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::Stop { + instance_id: self.instance_id, + }); + return; + } + + /// Enable or disable looping playback. + /// + /// # Arguments + /// - `looping`: Whether the sound should loop on completion. + /// + /// # Returns + /// `()` after updating the looping flag. + pub fn set_looping(&mut self, looping: bool) { + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::SetLooping { + instance_id: self.instance_id, + looping, + }); + return; + } + + /// Query the current state of this instance. + /// + /// # Returns + /// The current transport state. + pub fn state(&self) -> PlaybackState { + if !self.is_active() { + return PlaybackState::Stopped; + } + + return self.shared_state.state(); + } + + /// Convenience query for `state() == PlaybackState::Playing`. + /// + /// # Returns + /// `true` if the instance state is `Playing`. + pub fn is_playing(&self) -> bool { + return self.state() == PlaybackState::Playing; + } + + /// Convenience query for `state() == PlaybackState::Paused`. + /// + /// # Returns + /// `true` if the instance state is `Paused`. + pub fn is_paused(&self) -> bool { + return self.state() == PlaybackState::Paused; + } + + /// Convenience query for `state() == PlaybackState::Stopped`. + /// + /// # Returns + /// `true` if the instance state is `Stopped`. + pub fn is_stopped(&self) -> bool { + return self.state() == PlaybackState::Stopped; + } +} + +/// A playback context owning an output device and one active playback slot. +pub struct AudioContext { + _output_device: Option, + command_queue: Arc, + shared_state: Arc, + next_instance_id: u64, + output_sample_rate: u32, + output_channels: u16, +} + +/// Builder for creating an `AudioContext`. +#[derive(Debug, Clone)] +pub struct AudioContextBuilder { + sample_rate: Option, + channels: Option, + label: Option, +} + +impl AudioContextBuilder { + /// Create a builder with engine defaults. + /// + /// # Returns + /// A builder with no explicit configuration requests. + pub fn new() -> Self { + return Self { + sample_rate: None, + channels: None, + label: None, + }; + } + + /// Request an output sample rate. + /// + /// # Arguments + /// - `rate`: Requested output sample rate in frames per second. + /// + /// # Returns + /// The updated builder. + pub fn with_sample_rate(mut self, rate: u32) -> Self { + self.sample_rate = Some(rate); + return self; + } + + /// Request an output channel count. + /// + /// # Arguments + /// - `channels`: Requested interleaved output channel count. + /// + /// # Returns + /// The updated builder. + pub fn with_channels(mut self, channels: u16) -> Self { + self.channels = Some(channels); + return self; + } + + /// Attach a label for diagnostics. + /// + /// # Arguments + /// - `label`: A human-readable label used for diagnostics. + /// + /// # Returns + /// The updated builder. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Build an `AudioContext` using the requested configuration. + /// + /// # Returns + /// An initialized audio context handle. + /// + /// # Errors + /// Returns an error if the output device cannot be initialized or if the + /// requested configuration is invalid or unsupported. + pub fn build(self) -> Result { + let sample_rate = self.sample_rate.unwrap_or(DEFAULT_OUTPUT_SAMPLE_RATE); + let channels = self.channels.unwrap_or(DEFAULT_OUTPUT_CHANNELS); + + if channels as usize > MAX_PLAYBACK_CHANNELS { + return Err(AudioError::InvalidChannels { + requested: channels, + }); + } + + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + let command_queue_for_callback = command_queue.clone(); + let shared_state_for_callback = shared_state.clone(); + + let mut controller = PlaybackController::new_with_ramp_frames( + channels as usize, + DEFAULT_GAIN_RAMP_FRAMES, + command_queue_for_callback, + shared_state_for_callback, + ); + + let mut output_builder = AudioOutputDeviceBuilder::new() + .with_sample_rate(sample_rate) + .with_channels(channels); + + if let Some(label) = self.label { + output_builder = output_builder.with_label(&label); + } + + let output_device = output_builder.build_with_output_callback( + move |writer, callback_info| { + if callback_info.sample_rate != sample_rate + || callback_info.channels != channels + { + writer.clear(); + return; + } + + controller.render(writer); + return; + }, + )?; + + return Ok(AudioContext { + _output_device: Some(output_device), + command_queue, + shared_state, + next_instance_id: 1, + output_sample_rate: sample_rate, + output_channels: channels, + }); + } +} + +impl Default for AudioContextBuilder { + fn default() -> Self { + return Self::new(); + } +} + +impl AudioContext { + /// Play a decoded `SoundBuffer` through this context. + /// + /// # Arguments + /// - `buffer`: The decoded sound buffer to schedule for playback. + /// + /// # Returns + /// A lightweight `SoundInstance` handle for controlling playback. + /// + /// # Errors + /// Returns [`AudioError::InvalidData`] when the sound buffer does not match + /// the output configuration, or when the buffer contains no samples. Returns + /// [`AudioError::Platform`] when the internal callback command queue is full. + pub fn play_sound( + &mut self, + buffer: &SoundBuffer, + ) -> Result { + if buffer.sample_rate() != self.output_sample_rate { + return Err(AudioError::InvalidData { + details: format!( + "sound buffer sample rate did not match output (buffer_sample_rate={}, output_sample_rate={})", + buffer.sample_rate(), + self.output_sample_rate + ), + }); + } + + if buffer.channels() != self.output_channels { + return Err(AudioError::InvalidData { + details: format!( + "sound buffer channel count did not match output (buffer_channels={}, output_channels={})", + buffer.channels(), + self.output_channels + ), + }); + } + + if buffer.samples().is_empty() { + return Err(AudioError::InvalidData { + details: "sound buffer contained no samples".to_string(), + }); + } + + let instance_id = self.next_instance_id; + self.next_instance_id = self.next_instance_id.wrapping_add(1); + if self.next_instance_id == 0 { + self.next_instance_id = 1; + } + + let previous_instance_id = self.shared_state.active_instance_id(); + let previous_state = self.shared_state.state(); + self.shared_state.set_active_instance_id(instance_id); + self.shared_state.set_state(PlaybackState::Stopped); + + let shared_buffer = Arc::new(buffer.clone()); + + let _result = self.command_queue.push(PlaybackCommand::StopCurrent); + + if self + .command_queue + .push(PlaybackCommand::SetBuffer { + instance_id, + buffer: shared_buffer, + }) + .is_err() + { + self + .shared_state + .set_active_instance_id(previous_instance_id); + self.shared_state.set_state(previous_state); + return Err(AudioError::Platform { + details: "audio playback command queue was full (SetBuffer)" + .to_string(), + }); + } + + if self + .command_queue + .push(PlaybackCommand::Play { instance_id }) + .is_err() + { + self + .shared_state + .set_active_instance_id(previous_instance_id); + self.shared_state.set_state(previous_state); + return Err(AudioError::Platform { + details: "audio playback command queue was full (Play)".to_string(), + }); + } + + self.shared_state.set_state(PlaybackState::Playing); + + return Ok(SoundInstance { + instance_id, + command_queue: self.command_queue.clone(), + shared_state: self.shared_state.clone(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_context(sample_rate: u32, channels: u16) -> AudioContext { + return AudioContext { + _output_device: None, + command_queue: Arc::new(CommandQueue::new()), + shared_state: Arc::new(PlaybackSharedState::new()), + next_instance_id: 1, + output_sample_rate: sample_rate, + output_channels: channels, + }; + } + + fn create_test_sound_buffer( + sample_rate: u32, + channels: u16, + frames: usize, + ) -> SoundBuffer { + let sample_count = frames * channels as usize; + let samples = vec![0.0; sample_count]; + return SoundBuffer::from_interleaved_samples_for_test( + samples, + sample_rate, + channels, + ) + .expect("test sound buffer must be valid"); + } + + fn fill_command_queue(queue: &PlaybackCommandQueue) { + while queue.push(PlaybackCommand::StopCurrent).is_ok() {} + return; + } + + /// `SoundInstance` methods MUST be no-ops when the instance is inactive. + #[test] + fn sound_instance_is_no_op_when_inactive() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(1); + shared_state.set_state(PlaybackState::Playing); + + let mut instance = SoundInstance { + instance_id: 1, + command_queue: command_queue.clone(), + shared_state: shared_state.clone(), + }; + + assert_eq!(instance.state(), PlaybackState::Playing); + assert!(instance.is_playing()); + assert!(!instance.is_paused()); + assert!(!instance.is_stopped()); + + shared_state.set_active_instance_id(2); + shared_state.set_state(PlaybackState::Paused); + + assert_eq!(instance.state(), PlaybackState::Stopped); + assert!(!instance.is_playing()); + assert!(!instance.is_paused()); + assert!(instance.is_stopped()); + + instance.play(); + instance.pause(); + instance.stop(); + instance.set_looping(true); + + assert!(command_queue.pop().is_none()); + return; + } + + /// `SoundInstance` MUST enqueue commands when it is the active instance. + #[test] + fn sound_instance_enqueues_commands_when_active() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(7); + shared_state.set_state(PlaybackState::Stopped); + + let mut instance = SoundInstance { + instance_id: 7, + command_queue: command_queue.clone(), + shared_state: shared_state.clone(), + }; + + instance.play(); + instance.pause(); + instance.set_looping(true); + instance.stop(); + + assert!(matches!( + command_queue.pop(), + Some(PlaybackCommand::Play { instance_id: 7 }) + )); + assert!(matches!( + command_queue.pop(), + Some(PlaybackCommand::Pause { instance_id: 7 }) + )); + assert!(matches!( + command_queue.pop(), + Some(PlaybackCommand::SetLooping { + instance_id: 7, + looping: true + }) + )); + assert!(matches!( + command_queue.pop(), + Some(PlaybackCommand::Stop { instance_id: 7 }) + )); + assert!(command_queue.pop().is_none()); + return; + } + + /// `SoundInstance` state queries MUST reflect the shared state when active. + #[test] + fn sound_instance_state_follows_shared_state_when_active() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(1); + + let instance = SoundInstance { + instance_id: 1, + command_queue, + shared_state: shared_state.clone(), + }; + + shared_state.set_state(PlaybackState::Stopped); + assert_eq!(instance.state(), PlaybackState::Stopped); + assert!(instance.is_stopped()); + assert!(!instance.is_playing()); + assert!(!instance.is_paused()); + + shared_state.set_state(PlaybackState::Playing); + assert_eq!(instance.state(), PlaybackState::Playing); + assert!(instance.is_playing()); + assert!(!instance.is_paused()); + assert!(!instance.is_stopped()); + + shared_state.set_state(PlaybackState::Paused); + assert_eq!(instance.state(), PlaybackState::Paused); + assert!(!instance.is_playing()); + assert!(instance.is_paused()); + assert!(!instance.is_stopped()); + return; + } + + /// The builder MUST reject unsupported channel counts before device init. + #[test] + fn audio_context_builder_rejects_too_many_channels() { + let result = AudioContextBuilder::new() + .with_channels((MAX_PLAYBACK_CHANNELS + 1) as u16) + .build(); + + assert!(matches!( + result, + Err(AudioError::InvalidChannels { requested }) + if requested == (MAX_PLAYBACK_CHANNELS + 1) as u16 + )); + return; + } + + /// Builder configuration MUST store requested fields. + #[test] + fn audio_context_builder_stores_configuration() { + let builder = AudioContextBuilder::new() + .with_sample_rate(48_000) + .with_channels(2) + .with_label("test-context"); + + assert_eq!(builder.sample_rate, Some(48_000)); + assert_eq!(builder.channels, Some(2)); + assert_eq!(builder.label.as_deref(), Some("test-context")); + return; + } + + /// The builder MUST reject invalid sample rates before device selection. + #[test] + fn audio_context_builder_rejects_invalid_sample_rate() { + let result = AudioContextBuilder::new().with_sample_rate(0).build(); + assert!(matches!( + result, + Err(AudioError::InvalidSampleRate { requested: 0 }) + )); + return; + } + + /// The builder MUST reject invalid channel counts before device selection. + #[test] + fn audio_context_builder_rejects_invalid_channels() { + let result = AudioContextBuilder::new().with_channels(0).build(); + assert!(matches!( + result, + Err(AudioError::InvalidChannels { requested: 0 }) + )); + return; + } + + /// `play_sound` MUST reject sound buffers with mismatched sample rates. + #[test] + fn play_sound_rejects_sample_rate_mismatch() { + let mut context = create_test_context(48_000, 2); + let buffer = create_test_sound_buffer(44_100, 2, 4); + + let result = context.play_sound(&buffer); + assert!(matches!(result, Err(AudioError::InvalidData { .. }))); + + assert!(context.command_queue.pop().is_none()); + assert_eq!(context.shared_state.active_instance_id(), 0); + assert_eq!(context.shared_state.state(), PlaybackState::Stopped); + return; + } + + /// `play_sound` MUST reject sound buffers with mismatched channel counts. + #[test] + fn play_sound_rejects_channel_mismatch() { + let mut context = create_test_context(48_000, 2); + let buffer = create_test_sound_buffer(48_000, 1, 4); + + let result = context.play_sound(&buffer); + assert!(matches!(result, Err(AudioError::InvalidData { .. }))); + + assert!(context.command_queue.pop().is_none()); + assert_eq!(context.shared_state.active_instance_id(), 0); + assert_eq!(context.shared_state.state(), PlaybackState::Stopped); + return; + } + + /// `play_sound` MUST reject empty sound buffers. + #[test] + fn play_sound_rejects_empty_samples() { + let mut context = create_test_context(48_000, 2); + let buffer = create_test_sound_buffer(48_000, 2, 0 /* frames */); + + let result = context.play_sound(&buffer); + assert!(matches!(result, Err(AudioError::InvalidData { .. }))); + + assert!(context.command_queue.pop().is_none()); + assert_eq!(context.shared_state.active_instance_id(), 0); + assert_eq!(context.shared_state.state(), PlaybackState::Stopped); + return; + } + + /// `play_sound` MUST schedule stop, buffer, then play commands. + #[test] + fn play_sound_enqueues_commands_and_updates_state() { + let mut context = create_test_context(48_000, 2); + let buffer = create_test_sound_buffer(48_000, 2, 4); + + let instance = context.play_sound(&buffer).expect("must play sound"); + assert_eq!(instance.instance_id, 1); + assert_eq!(context.shared_state.active_instance_id(), 1); + assert_eq!(context.shared_state.state(), PlaybackState::Playing); + assert_eq!(instance.state(), PlaybackState::Playing); + + assert!(matches!( + context.command_queue.pop(), + Some(PlaybackCommand::StopCurrent) + )); + match context.command_queue.pop() { + Some(PlaybackCommand::SetBuffer { + instance_id, + buffer: scheduled_buffer, + }) => { + assert_eq!(instance_id, 1); + assert_eq!(scheduled_buffer.as_ref(), &buffer); + } + other => { + panic!("expected SetBuffer command, got {other:?}"); + } + } + assert!(matches!( + context.command_queue.pop(), + Some(PlaybackCommand::Play { instance_id: 1 }) + )); + assert!(context.command_queue.pop().is_none()); + return; + } + + /// `play_sound` MUST restore previous state when the queue is full. + #[test] + fn play_sound_restores_state_when_queue_full_for_set_buffer() { + let mut context = create_test_context(48_000, 2); + let buffer = create_test_sound_buffer(48_000, 2, 4); + + context.shared_state.set_active_instance_id(9); + context.shared_state.set_state(PlaybackState::Paused); + + fill_command_queue(&context.command_queue); + + let result = context.play_sound(&buffer); + assert!(matches!(result, Err(AudioError::Platform { .. }))); + + assert_eq!(context.shared_state.active_instance_id(), 9); + assert_eq!(context.shared_state.state(), PlaybackState::Paused); + return; + } + + /// `play_sound` MUST restore previous state when play cannot be enqueued. + #[test] + fn play_sound_restores_state_when_queue_full_for_play() { + let mut context = create_test_context(48_000, 2); + let buffer = create_test_sound_buffer(48_000, 2, 4); + + context.shared_state.set_active_instance_id(3); + context.shared_state.set_state(PlaybackState::Paused); + + fill_command_queue(&context.command_queue); + let _first_popped = context.command_queue.pop(); + let _second_popped = context.command_queue.pop(); + + let result = context.play_sound(&buffer); + assert!(matches!(result, Err(AudioError::Platform { .. }))); + + assert_eq!(context.shared_state.active_instance_id(), 3); + assert_eq!(context.shared_state.state(), PlaybackState::Paused); + return; + } + + /// Instance ids MUST wrap without using id `0`. + #[test] + fn play_sound_instance_id_wraps_to_one() { + let mut context = create_test_context(48_000, 2); + context.next_instance_id = u64::MAX; + + let buffer = create_test_sound_buffer(48_000, 2, 4); + + let instance = context.play_sound(&buffer).expect("must play sound"); + assert_eq!(instance.instance_id, u64::MAX); + assert_eq!(context.next_instance_id, 1); + return; + } +} diff --git a/crates/lambda-rs/src/audio/playback/mod.rs b/crates/lambda-rs/src/audio/playback/mod.rs new file mode 100644 index 00000000..92f7fb90 --- /dev/null +++ b/crates/lambda-rs/src/audio/playback/mod.rs @@ -0,0 +1,44 @@ +#![allow(clippy::needless_return)] + +//! Single-sound playback and transport controls. +//! +//! This module provides a minimal, backend-agnostic playback facade that +//! supports one active `SoundBuffer` at a time. + +mod callback; +mod context; +mod transport; + +use callback::PlaybackController; +use transport::{ + CommandQueue, + PlaybackCommand, + PlaybackCommandQueue, + PlaybackSharedState, +}; + +const DEFAULT_GAIN_RAMP_FRAMES: usize = 128; +const DEFAULT_OUTPUT_SAMPLE_RATE: u32 = 48_000; +const DEFAULT_OUTPUT_CHANNELS: u16 = 2; +const MAX_PLAYBACK_CHANNELS: usize = 8; +const PLAYBACK_COMMAND_CAPACITY: usize = 256; + +/// A queryable playback state for a `SoundInstance`. +/// +/// This state is observable from the application thread and is intended to +/// provide basic transport visibility. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlaybackState { + /// The sound is currently playing. + Playing, + /// The sound is currently paused. + Paused, + /// The sound is stopped and positioned at the start. + Stopped, +} + +pub use context::{ + AudioContext, + AudioContextBuilder, + SoundInstance, +}; diff --git a/crates/lambda-rs/src/audio/playback/transport.rs b/crates/lambda-rs/src/audio/playback/transport.rs new file mode 100644 index 00000000..424ec834 --- /dev/null +++ b/crates/lambda-rs/src/audio/playback/transport.rs @@ -0,0 +1,272 @@ +use std::{ + cell::UnsafeCell, + mem::MaybeUninit, + sync::{ + atomic::{ + AtomicU64, + AtomicU8, + AtomicUsize, + Ordering, + }, + Arc, + }, +}; + +use super::{ + PlaybackState, + PLAYBACK_COMMAND_CAPACITY, +}; +use crate::audio::SoundBuffer; + +/// A fixed-capacity, single-producer/single-consumer queue. +/// +/// The queue is designed for real-time audio callbacks: +/// - `push` and `pop` MUST NOT block. +/// - `pop` MUST NOT allocate. +/// +/// # Safety +/// This type is only sound when used as SPSC (exactly one producer thread and +/// one consumer thread). +pub(super) struct CommandQueue { + buffer: [UnsafeCell>; CAPACITY], + head: AtomicUsize, + tail: AtomicUsize, +} + +unsafe impl Send for CommandQueue {} +unsafe impl Sync for CommandQueue {} + +impl CommandQueue { + /// Create a new empty queue. + /// + /// # Returns + /// A queue with a fixed capacity. + pub(super) fn new() -> Self { + assert!(CAPACITY > 0, "command queue capacity must be non-zero"); + + return Self { + buffer: std::array::from_fn(|_| { + return UnsafeCell::new(MaybeUninit::uninit()); + }), + head: AtomicUsize::new(0), + tail: AtomicUsize::new(0), + }; + } + + /// Attempt to enqueue a value. + /// + /// # Arguments + /// - `value`: The value to enqueue. + /// + /// # Returns + /// `Ok(())` when the value was enqueued. `Err(value)` when the queue is full. + pub(super) fn push(&self, value: T) -> Result<(), T> { + let head = self.head.load(Ordering::Acquire); + let tail = self.tail.load(Ordering::Relaxed); + + if tail.wrapping_sub(head) >= CAPACITY { + return Err(value); + } + + let index = tail % CAPACITY; + let slot = self.buffer[index].get(); + unsafe { + (&mut *slot).write(value); + } + + self.tail.store(tail.wrapping_add(1), Ordering::Release); + return Ok(()); + } + + /// Attempt to dequeue a value. + /// + /// # Returns + /// `Some(value)` when a value is available, otherwise `None`. + pub(super) fn pop(&self) -> Option { + let tail = self.tail.load(Ordering::Acquire); + let head = self.head.load(Ordering::Relaxed); + + if head == tail { + return None; + } + + let index = head % CAPACITY; + let slot = self.buffer[index].get(); + let value = unsafe { (&*slot).assume_init_read() }; + + self.head.store(head.wrapping_add(1), Ordering::Release); + return Some(value); + } +} + +impl Drop for CommandQueue { + fn drop(&mut self) { + let tail = self.tail.load(Ordering::Relaxed); + let mut head = self.head.load(Ordering::Relaxed); + + while head != tail { + let index = head % CAPACITY; + let slot = self.buffer[index].get(); + unsafe { + std::ptr::drop_in_place((&mut *slot).as_mut_ptr()); + } + head = head.wrapping_add(1); + } + + return; + } +} + +/// Commands produced by `SoundInstance` transport operations. +#[derive(Debug)] +pub(super) enum PlaybackCommand { + StopCurrent, + SetBuffer { + instance_id: u64, + buffer: Arc, + }, + SetLooping { + instance_id: u64, + looping: bool, + }, + Play { + instance_id: u64, + }, + Pause { + instance_id: u64, + }, + Stop { + instance_id: u64, + }, +} + +pub(super) type PlaybackCommandQueue = + CommandQueue; + +/// Shared, queryable state for the active playback slot. +pub(super) struct PlaybackSharedState { + active_instance_id: AtomicU64, + state: AtomicU8, +} + +impl PlaybackSharedState { + /// Create a new shared playback state initialized to `Stopped`. + /// + /// # Returns + /// A shared state container initialized to instance id `0` and `Stopped`. + pub(super) fn new() -> Self { + return Self { + active_instance_id: AtomicU64::new(0), + state: AtomicU8::new(playback_state_to_u8(PlaybackState::Stopped)), + }; + } + + /// Set the active instance id. + /// + /// # Arguments + /// - `instance_id`: The active instance id. + /// + /// # Returns + /// `()` after updating the active instance id. + pub(super) fn set_active_instance_id(&self, instance_id: u64) { + self + .active_instance_id + .store(instance_id, Ordering::Release); + return; + } + + /// Return the active instance id. + /// + /// # Returns + /// The active instance id. + pub(super) fn active_instance_id(&self) -> u64 { + return self.active_instance_id.load(Ordering::Acquire); + } + + /// Set the observable playback state. + /// + /// # Arguments + /// - `state`: The state to store. + /// + /// # Returns + /// `()` after updating the stored playback state. + pub(super) fn set_state(&self, state: PlaybackState) { + self + .state + .store(playback_state_to_u8(state), Ordering::Release); + return; + } + + /// Return the observable playback state. + /// + /// # Returns + /// The stored playback state. + pub(super) fn state(&self) -> PlaybackState { + let value = self.state.load(Ordering::Acquire); + return playback_state_from_u8(value); + } +} + +fn playback_state_to_u8(state: PlaybackState) -> u8 { + match state { + PlaybackState::Stopped => { + return 0; + } + PlaybackState::Playing => { + return 1; + } + PlaybackState::Paused => { + return 2; + } + } +} + +fn playback_state_from_u8(value: u8) -> PlaybackState { + match value { + 1 => { + return PlaybackState::Playing; + } + 2 => { + return PlaybackState::Paused; + } + _ => { + return PlaybackState::Stopped; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Command queues MUST preserve FIFO ordering. + #[test] + fn command_queue_preserves_order() { + let queue: CommandQueue = CommandQueue::new(); + + queue.push(1).unwrap(); + queue.push(2).unwrap(); + queue.push(3).unwrap(); + + assert_eq!(queue.pop(), Some(1)); + assert_eq!(queue.pop(), Some(2)); + assert_eq!(queue.pop(), Some(3)); + assert!(queue.pop().is_none()); + return; + } + + /// Command queues MUST reject pushes when full. + #[test] + fn command_queue_rejects_when_full() { + let queue: CommandQueue = CommandQueue::new(); + + assert!(queue.push(10).is_ok()); + assert!(queue.push(11).is_ok()); + assert!(matches!(queue.push(12), Err(12))); + + assert_eq!(queue.pop(), Some(10)); + assert_eq!(queue.pop(), Some(11)); + assert!(queue.pop().is_none()); + return; + } +} diff --git a/demos/audio/Cargo.toml b/demos/audio/Cargo.toml index 2c683133..4d215962 100644 --- a/demos/audio/Cargo.toml +++ b/demos/audio/Cargo.toml @@ -9,8 +9,9 @@ lambda-rs = { path = "../../crates/lambda-rs" } [features] default = ["audio"] -audio = ["audio-output-device", "audio-sound-buffer"] +audio = ["audio-output-device", "audio-sound-buffer", "audio-playback"] audio-output-device = ["lambda-rs/audio-output-device"] audio-sound-buffer = ["lambda-rs/audio-sound-buffer"] audio-sound-buffer-wav = ["lambda-rs/audio-sound-buffer-wav"] audio-sound-buffer-vorbis = ["lambda-rs/audio-sound-buffer-vorbis"] +audio-playback = ["lambda-rs/audio-playback"] diff --git a/demos/audio/src/bin/sound_playback_transport.rs b/demos/audio/src/bin/sound_playback_transport.rs new file mode 100644 index 00000000..9686c502 --- /dev/null +++ b/demos/audio/src/bin/sound_playback_transport.rs @@ -0,0 +1,52 @@ +#![allow(clippy::needless_return)] +//! Audio demo exercising `AudioContext` transport controls. +//! +//! This demo validates that `AudioContext` can play a decoded `SoundBuffer` +//! through the output device and that `SoundInstance` transport operations +//! (play/pause/stop/looping) behave as expected. + +use std::time::Duration; + +use lambda::audio::{ + AudioContextBuilder, + SoundBuffer, +}; + +fn main() { + const SLASH_VORBIS_STEREO_48000_OGG: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg" + )); + + let buffer = + SoundBuffer::from_ogg_bytes(SLASH_VORBIS_STEREO_48000_OGG).unwrap(); + + let mut context = AudioContextBuilder::new() + .with_label("sound-playback-transport") + .with_sample_rate(buffer.sample_rate()) + .with_channels(buffer.channels()) + .build() + .unwrap(); + + let mut instance = context.play_sound(&buffer).unwrap(); + std::thread::sleep(Duration::from_millis(250)); + + instance.pause(); + std::thread::sleep(Duration::from_millis(250)); + + instance.play(); + std::thread::sleep(Duration::from_millis(250)); + + instance.stop(); + std::thread::sleep(Duration::from_millis(250)); + + instance.play(); + std::thread::sleep(Duration::from_millis(300)); + + instance.set_looping(true); + std::thread::sleep(Duration::from_secs(2)); + + instance.set_looping(false); + std::thread::sleep(Duration::from_millis(300)); + return; +} diff --git a/docs/features.md b/docs/features.md index e9952a7d..05293d1b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,13 +3,13 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2026-02-06T23:33:29Z" -version: "0.1.14" +last_updated: "2026-02-10T00:00:00Z" +version: "0.1.15" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "d9ae52363df035954079bf2ebdc194d18281862d" +repo_commit: "fa36b760348f7b4a924220885fa88684bded03f6" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio", "physics"] @@ -62,7 +62,7 @@ Rendering backends Audio - `audio` (umbrella, disabled by default): enables audio support by composing granular audio features. This umbrella includes `audio-output-device` and - `audio-sound-buffer`. + `audio-sound-buffer`, and `audio-playback`. - `audio-output-device` (granular, disabled by default): enables audio output device enumeration and callback-based audio output via `lambda::audio`. This feature enables `lambda-rs-platform/audio-device` internally. Expected @@ -73,6 +73,11 @@ Audio `lambda::audio::SoundBuffer` loading APIs by composing the granular decode features below. This umbrella has no runtime cost unless a sound file is decoded and loaded into memory. +- `audio-playback` (granular, disabled by default): enables single-sound + playback through an `AudioContext` with basic transport controls + (`SoundInstance::{play,pause,stop}`), state queries, and looping. This + feature composes `audio-output-device` and `audio-sound-buffer` and has no + runtime cost unless an `AudioContext` is built and kept alive. - `audio-sound-buffer-wav` (granular, disabled by default): enables WAV decode support for `SoundBuffer`. This feature enables `lambda-rs-platform/audio-decode-wav` internally. Runtime cost is incurred at @@ -168,6 +173,8 @@ Physics depend on `rapier2d` directly via this crate. ## Changelog +- 0.1.15 (2026-02-10): Document `audio-playback` in `lambda-rs` and update + metadata. - 0.1.14 (2026-02-06): Document 2D physics feature flags in `lambda-rs` and `lambda-rs-platform`. - 0.1.13 (2026-02-02): Document `SoundBuffer` decode features for WAV and OGG diff --git a/docs/specs/README.md b/docs/specs/README.md index 6be684e7..2e512431 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,8 +3,8 @@ title: "Specifications Index" document_id: "specs-index-2026-02-07" status: "living" created: "2026-02-07T00:00:00Z" -last_updated: "2026-02-07T20:58:44Z" -version: "0.1.1" +last_updated: "2026-02-09T00:00:00Z" +version: "0.1.2" owners: ["lambda-sh"] reviewers: ["engine"] tags: ["index", "specs", "docs"] @@ -23,6 +23,7 @@ tags: ["index", "specs", "docs"] - Audio Devices — [audio/audio-devices.md](audio/audio-devices.md) - Audio File Loading — [audio/audio-file-loading.md](audio/audio-file-loading.md) +- Sound Playback and Transport Controls — [audio/sound-playback.md](audio/sound-playback.md) ## Runtime / Events diff --git a/docs/specs/audio/sound-playback.md b/docs/specs/audio/sound-playback.md new file mode 100644 index 00000000..f81e419c --- /dev/null +++ b/docs/specs/audio/sound-playback.md @@ -0,0 +1,383 @@ +--- +title: "Sound Playback and Transport Controls" +document_id: "audio-sound-playback-2026-02-09" +status: "draft" +created: "2026-02-09T00:00:00Z" +last_updated: "2026-02-09T00:10:00Z" +version: "0.1.1" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "e1150369fb5024e47d4b8a19c116c16f8fb9abad" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "audio", "lambda-rs"] +--- + +# Sound Playback and Transport Controls + +## Table of Contents + +- [Summary](#summary) +- [Scope](#scope) +- [Terminology](#terminology) +- [Architecture Overview](#architecture-overview) +- [Design](#design) + - [API Surface](#api-surface) + - [Behavior](#behavior) + - [Validation and Errors](#validation-and-errors) + - [Cargo Features](#cargo-features) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) + +## Summary + +- Add the ability to play a decoded `SoundBuffer` through an initialized audio + output device with basic transport controls: play, pause, stop. +- Provide a lightweight `SoundInstance` handle returned from `play_sound` for + controlling and querying playback state. +- Support looping playback for a single active sound instance. +- Maintain backend-agnostic behavior by implementing playback scheduling in + `lambda-rs` while using `lambda-rs-platform` only for the output device and + callback transport. + +Rationale +- A minimal playback layer is required for demos and manual validation beyond + device initialization and file decoding. +- Single-sound playback establishes the transport and thread-safety model that + future mixing work can extend. + +## Scope + +### Goals + +- Play a `SoundBuffer` to completion through the default audio output device. +- Pause and resume playback without audible artifacts. +- Stop playback and reset the playback position. +- Query playback state (`playing`, `paused`, `stopped`). +- Enable and disable looping playback. +- Provide a runnable example demonstrating play/pause/stop and looping. + +### Non-Goals + +- Volume control. +- Pitch/speed control. +- Spatial audio. +- Multiple simultaneous sounds. +- Streaming decode (disk-backed or incremental decode). +- Resampling or general channel remapping. + +## Terminology + +- Transport controls: operations that control playback flow (play, pause, stop). +- Sound instance: a lightweight handle for controlling one playback slot. +- Playback cursor: the current sample index within an interleaved sample buffer. +- Real-time audio thread: the platform thread that runs the audio output + callback and MUST be treated as latency-sensitive. + +## Architecture Overview + +- Crate `lambda` (package: `lambda-rs`) + - Hosts the public playback API (`AudioContext`, `SoundInstance`) and the + playback scheduler executed inside the audio callback. + - MUST remain backend-agnostic and MUST NOT expose platform or vendor types. +- Crate `lambda_platform` (package: `lambda-rs-platform`) + - Provides the output device and callback transport via + `lambda_platform::audio::cpal`. + +Data flow + +``` +application + └── lambda::audio + ├── SoundBuffer (decoded samples) + ├── AudioContextBuilder::build() -> AudioContext + │ └── AudioOutputDeviceBuilder::build_with_output_callback(...) + └── AudioContext::play_sound(&SoundBuffer) -> SoundInstance + └── SoundInstance::{play,pause,stop,set_looping,...} + └── transport commands -> playback scheduler (audio callback) + └── AudioOutputWriter::set_sample(...) + └── lambda_platform::audio::cpal (internal) + └── cpal -> OS audio backend +``` + +## Design + +### API Surface + +This section describes the public API surface added to `lambda-rs`. + +Module layout (new) + +- `crates/lambda-rs/src/audio/playback.rs` (or `audio/playback/mod.rs`) + - Defines `AudioContext`, `AudioContextBuilder`, `SoundInstance`, and + `PlaybackState`. +- `crates/lambda-rs/src/audio/mod.rs` + - Re-exports playback types when `audio-playback` is enabled. + +Public API + +```rust +// crates/lambda-rs/src/audio/playback.rs + +/// A queryable playback state for a `SoundInstance`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlaybackState { + Playing, + Paused, + Stopped, +} + +/// A lightweight handle controlling the active sound playback slot. +pub struct SoundInstance { + /* internal handle */ +} + +impl SoundInstance { + /// Begin playback, or resume if paused. + pub fn play(&mut self); + /// Pause playback, preserving playback position. + pub fn pause(&mut self); + /// Stop playback and reset position to the start of the buffer. + pub fn stop(&mut self); + /// Enable or disable looping playback. + pub fn set_looping(&mut self, looping: bool); + /// Query the current state of this instance. + pub fn state(&self) -> PlaybackState; + /// Convenience query for `state() == PlaybackState::Playing`. + pub fn is_playing(&self) -> bool; + /// Convenience query for `state() == PlaybackState::Paused`. + pub fn is_paused(&self) -> bool; + /// Convenience query for `state() == PlaybackState::Stopped`. + pub fn is_stopped(&self) -> bool; +} + +/// A playback context owning an output device and one active playback slot. +pub struct AudioContext { + /* internal device + playback scheduler state */ +} + +/// Builder for creating an `AudioContext`. +#[derive(Debug, Clone)] +pub struct AudioContextBuilder { + sample_rate: Option, + channels: Option, + label: Option, +} + +impl AudioContextBuilder { + pub fn new() -> Self; + pub fn with_sample_rate(self, rate: u32) -> Self; + pub fn with_channels(self, channels: u16) -> Self; + pub fn with_label(self, label: &str) -> Self; + pub fn build(self) -> Result; +} + +impl AudioContext { + /// Play a decoded `SoundBuffer` through this context. + pub fn play_sound( + &mut self, + buffer: &SoundBuffer, + ) -> Result; +} +``` + +Notes +- `AudioContext` is the only way to use `SoundInstance` playback. +- The playback system MUST support exactly one active sound at a time. +- `AudioOutputDevice` remains available for direct callback use and is not + replaced by this API. + +### Behavior + +Playback lifecycle + +- `AudioContext::play_sound` MUST stop any currently active sound playback, + reset the playback cursor, and begin playing the provided buffer. +- `SoundInstance::play` MUST transition the instance to `Playing`: + - If the instance is `Paused`, playback MUST resume from the current cursor. + - If the instance is `Stopped`, playback MUST start from the beginning. + - If the instance is already `Playing`, the call MUST be a no-op. +- `SoundInstance::pause` MUST transition the instance to `Paused` and MUST + preserve the cursor. +- `SoundInstance::stop` MUST transition the instance to `Stopped` and MUST + reset the cursor to the start of the buffer. +- When the buffer is exhausted and looping is disabled, playback MUST + transition to `Stopped` and MUST reset the cursor to the start of the buffer. + +Looping + +- `SoundInstance::set_looping(true)` MUST cause playback to wrap to the start + when the end of the buffer is reached. +- `SoundInstance::set_looping(false)` MUST cause playback to stop at the end + of the buffer on the next exhaustion event. +- Looping changes MUST take effect without requiring a restart. + +Output behavior + +- When `PlaybackState` is `Stopped` or `Paused`, the audio callback MUST write + silence to the output buffer. +- When `PlaybackState` is `Playing`, the audio callback MUST write sequential + interleaved samples from the active `SoundBuffer` into the output buffer. +- The callback MUST NOT allocate and MUST NOT block. + +Artifact avoidance (transport de-clicking) + +- Transitions between audible output and silence (pause, stop, completion, and + resume) MUST apply a short gain ramp to prevent discontinuities. +- The ramp length SHOULD be fixed and short (for example, 64–256 frames) and + MUST be applied entirely within the audio callback without allocation. + +Sound instance validity + +- Only the most recently returned `SoundInstance` for an `AudioContext` is + considered active. +- Calls on an inactive `SoundInstance` MUST be no-ops. +- Queries on an inactive `SoundInstance` MUST return `PlaybackState::Stopped`. + +### Validation and Errors + +General rules + +- All public APIs MUST return actionable, backend-agnostic errors and MUST NOT + panic. +- The audio callback MUST NOT panic. Failures inside the callback MUST degrade + to silence. + +`AudioContextBuilder::build` + +- MUST return `AudioError::NoDefaultDevice` when no default output device + exists. +- MUST forward configuration and platform failures using the existing + `AudioError` variants produced by output device initialization. + +`AudioContext::play_sound` + +- MUST return `AudioError::InvalidData` when the provided buffer has no + samples, `sample_rate == 0`, or `channels == 0`. +- MUST return `AudioError::InvalidData` when the provided buffer is not + compatible with the context output configuration. + +Compatibility validation + +- `SoundBuffer::sample_rate()` MUST equal the `AudioContext` output sample + rate. +- `SoundBuffer::channels()` MUST equal the `AudioContext` output channel count. +- No resampling or channel remapping is performed. + +### Cargo Features + +This specification introduces a new granular feature in `lambda-rs` to gate +playback behavior and dependencies. + +Crate `lambda-rs` (package: `lambda-rs`) + +- New granular feature (disabled by default) + - `audio-playback`: enables the `AudioContext` and `SoundInstance` playback + API. This feature MUST compose `audio-output-device` and + `audio-sound-buffer` internally. +- Existing umbrella feature (disabled by default) + - `audio`: MUST include `audio-playback` for discoverability and to provide + a complete audio surface. + +Crate `lambda-rs-platform` (package: `lambda-rs-platform`) + +- No new features are required. Playback uses the existing `audio-device` + output callback transport. + +Documentation + +- `docs/features.md` MUST be updated in the implementation change that adds + `audio-playback`. + +## Constraints and Rules + +- The playback system MUST support exactly one active sound instance. +- The callback MUST treat `SoundBuffer` samples as interleaved `f32` in nominal + range `[-1.0, 1.0]` and MUST clamp any out-of-range values before writing. +- Playback MUST be deterministic for a given buffer and output configuration. +- The audio callback MUST avoid blocking, locking, and allocation. + +## Performance Considerations + +Recommendations + +- Prefer fixed-capacity, non-blocking transport mechanisms for main-thread to + audio-thread state changes. + - Rationale: callback jitter and contention can cause audible dropouts. +- Keep the callback inner loop branch-light and avoid per-sample atomics by + snapshotting state once per callback tick. + - Rationale: reduces overhead and improves callback stability. + +## Requirements Checklist + +Functionality +- [ ] Feature flags defined (`audio-playback`) +- [ ] `SoundBuffer` plays to completion +- [ ] Transport controls implemented (play/pause/stop) +- [ ] Looping implemented +- [ ] Playback state query implemented +- [ ] Transport de-clicking implemented + +API Surface +- [ ] `AudioContext` and `AudioContextBuilder` implemented +- [ ] `SoundInstance` implemented +- [ ] `lambda::audio` re-exports wired and feature-gated + +Validation and Errors +- [ ] Buffer compatibility validation implemented +- [ ] Errors are actionable and backend-agnostic + +Performance +- [ ] Callback does not allocate or block +- [ ] Shared-state communication avoids locks + +Documentation and Examples +- [ ] `docs/features.md` updated +- [ ] Runnable example added demonstrating transport controls + +## Verification and Testing + +Unit tests + +- Add focused unit tests for: + - state transitions and idempotency + - cursor reset behavior on stop and completion + - looping wrap behavior + - inactive instance no-op behavior + +Commands + +- `cargo test -p lambda-rs --features audio-playback -- --nocapture` + +Manual checks + +- Add an example runnable at `demos/audio/src/bin/sound_playback_transport.rs` + that uses the fixture + `crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg` and: + - plays briefly + - pauses and resumes + - stops and restarts from the beginning + - enables looping and verifies continuous playback for at least 1 second + +Command + +- `cargo run -p lambda-demos-audio --features audio-playback --bin sound_playback_transport` + +## Compatibility and Migration + +- No existing API surface is removed. +- The `lambda-rs` `audio` feature umbrella composition changes by adding + `audio-playback`. This is not expected to break builds because `audio` is + disabled by default, but it MAY increase compile time when `audio` is + enabled. + +## Changelog + +- 2026-02-09 (v0.1.1) — Specify a concrete transport example and fixture path. +- 2026-02-09 (v0.1.0) — Initial draft.