diff --git a/Cargo.lock b/Cargo.lock index 2693f935..99a24f21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1554,6 +1554,7 @@ name = "lambda-demos-audio" version = "0.1.0" dependencies = [ "lambda-rs", + "lambda-rs-args", ] [[package]] diff --git a/crates/lambda-rs/src/audio/playback/callback.rs b/crates/lambda-rs/src/audio/playback/callback.rs index f186e734..e3595295 100644 --- a/crates/lambda-rs/src/audio/playback/callback.rs +++ b/crates/lambda-rs/src/audio/playback/callback.rs @@ -101,7 +101,7 @@ impl GainRamp { struct PlaybackScheduler { state: PlaybackState, looping: bool, - cursor_samples: usize, + cursor_frames: f32, channels: usize, ramp_frames: usize, gain: GainRamp, @@ -134,7 +134,7 @@ impl PlaybackScheduler { return Self { state: PlaybackState::Stopped, looping: false, - cursor_samples: 0, + cursor_frames: 0.0, channels, ramp_frames, gain: GainRamp::silent(), @@ -152,7 +152,7 @@ impl PlaybackScheduler { /// `()` after updating the active buffer. fn set_buffer(&mut self, buffer: Arc) { self.buffer = Some(buffer); - self.cursor_samples = 0; + self.cursor_frames = 0.0; return; } @@ -202,7 +202,7 @@ impl PlaybackScheduler { /// `()` after updating the transport state. fn stop(&mut self) { self.state = PlaybackState::Stopped; - self.cursor_samples = 0; + self.cursor_frames = 0.0; self.gain.start(0.0, self.ramp_frames); return; } @@ -221,19 +221,30 @@ impl PlaybackScheduler { /// The cursor position as an interleaved sample index. #[allow(dead_code)] fn cursor_samples(&self) -> usize { - return self.cursor_samples; + let frames = self.cursor_frames.floor().max(0.0) as usize; + return frames.saturating_mul(self.channels); } /// Render audio for a callback tick into an output writer. /// /// # Arguments /// - `writer`: Real-time writer for the current callback output buffer. + /// - `master_volume`: Global/master volume scalar applied to all output. + /// - `instance_volume`: Per-instance volume scalar for the active slot. + /// - `instance_pitch`: Per-instance pitch/playback speed scalar. /// /// # Returns /// `()` after writing the output buffer. - fn render(&mut self, writer: &mut dyn AudioOutputWriter) { + fn render( + &mut self, + writer: &mut dyn AudioOutputWriter, + master_volume: f32, + instance_volume: f32, + instance_pitch: f32, + ) { let writer_channels = writer.channels() as usize; let frames = writer.frames(); + let soft_clip_enabled = master_volume * instance_volume > 1.0; if writer_channels == 0 || frames == 0 { return; @@ -251,6 +262,7 @@ impl PlaybackScheduler { for frame_index in 0..frames { let frame_gain = self.gain.current; + let output_gain = frame_gain * master_volume * instance_volume; if self.state != PlaybackState::Playing && self.gain.is_silent() { writer.clear(); @@ -267,41 +279,90 @@ impl PlaybackScheduler { }; 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); + let total_frames = buffer.frames(); + + let mut should_stop = false; + + if total_frames == 0 { + should_stop = true; + } else { + let total_frames_f32 = total_frames as f32; + + if self.cursor_frames >= total_frames_f32 { + if self.looping { + self.cursor_frames = + self.cursor_frames.rem_euclid(total_frames_f32); + } else { + should_stop = true; + } } - self.cursor_samples = frame_end; - self.gain.advance_frame(); - continue; + if !should_stop { + // When playback continues, we resample the buffer at a fractional + // frame cursor: + // + // - `cursor_frame_position` is the current playback position in + // frames (not interleaved samples). + // - `frame_index0` is the base frame index: floor(cursor). + // - `frame_lerp_t` is the fractional part in `[0.0, 1.0)`. + // - `frame_index1` is the next frame used for interpolation. If + // the cursor is on the last frame: + // - when looping, wrap to frame `0` + // - otherwise, reuse `frame_index0` (hold the last sample) + // + // Each output frame is produced by linear interpolation between + // the two source frames, then applying gain and clipping. + let cursor_frame_position = self.cursor_frames.max(0.0); + let frame_index0 = cursor_frame_position.floor() as usize; + let frame_lerp_t = cursor_frame_position - frame_index0 as f32; + let frame_index1 = if frame_index0.saturating_add(1) < total_frames + { + frame_index0 + 1 + } else if self.looping { + 0 + } else { + frame_index0 + }; + + for channel_index in 0..writer_channels { + let s0_index = frame_index0 + .saturating_mul(writer_channels) + .saturating_add(channel_index); + let s1_index = frame_index1 + .saturating_mul(writer_channels) + .saturating_add(channel_index); + let s0 = samples.get(s0_index).copied().unwrap_or(0.0); + let s1 = samples.get(s1_index).copied().unwrap_or(s0); + let sample = s0 + (s1 - s0) * frame_lerp_t; + + self.last_frame_samples[channel_index] = sample; + writer.set_sample( + frame_index, + channel_index, + clip_sample(sample * output_gain, soft_clip_enabled), + ); + } + + self.cursor_frames = cursor_frame_position + instance_pitch; + self.gain.advance_frame(); + continue; + } } - self.state = PlaybackState::Stopped; - self.cursor_samples = 0; - self.gain.start(0.0, self.ramp_frames); + if should_stop { + self.state = PlaybackState::Stopped; + self.cursor_frames = 0.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); + writer.set_sample( + frame_index, + channel_index, + clip_sample(sample * output_gain, soft_clip_enabled), + ); } self.gain.advance_frame(); @@ -311,6 +372,52 @@ impl PlaybackScheduler { } } +/// Clip a sample into the nominal output range. +/// +/// This function enforces “clipping awareness” for amplified output: +/// - values are bounded to `[-1.0, 1.0]` +/// - non-finite values map to `0.0` to avoid propagating NaNs/Infs downstream +/// - when soft clipping is enabled, values in the knee region are gently +/// compressed before saturating +/// +/// # Arguments +/// - `sample`: Candidate sample value. +/// - `soft_clip`: Whether to use a soft knee before saturation. +/// +/// # Returns +/// A finite, bounded sample suitable for `AudioOutputWriter`. +fn clip_sample(sample: f32, soft_clip: bool) -> f32 { + if !sample.is_finite() { + return 0.0; + } + + if !soft_clip { + return sample.clamp(-1.0, 1.0); + } + + // Soft knee limiter: + // - linear in [-KNEE_START, KNEE_START] + // - smoothstep curve from KNEE_START..1.0 + // - saturates to [-1.0, 1.0] beyond unity + const KNEE_START: f32 = 0.95; + + let sign = sample.signum(); + let x = sample.abs(); + + if x <= KNEE_START { + return sample; + } + + if x >= 1.0 { + return sign; + } + + let t = (x - KNEE_START) / (1.0 - KNEE_START); + let smoothstep = t * t * (3.0 - 2.0 * t); + let y = KNEE_START + (1.0 - KNEE_START) * smoothstep; + return sign * y; +} + /// A callback-safe controller that drains transport commands and renders audio. /// /// This type is intended to be owned by the platform audio callback closure. @@ -433,7 +540,15 @@ impl PlaybackController { /// `()` 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); + let master_volume = self.shared_state.master_volume(); + let instance_volume = self.shared_state.instance_volume(); + let instance_pitch = self.shared_state.instance_pitch(); + self.scheduler.render( + writer, + master_volume, + instance_volume, + instance_pitch, + ); self.shared_state.set_state(self.scheduler.state()); return; } @@ -522,17 +637,17 @@ mod tests { scheduler.play(); let mut writer = TestAudioOutput::new(1, 8); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert_eq!(scheduler.state(), PlaybackState::Stopped); assert_eq!(scheduler.cursor_samples(), 0); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert!(writer.max_abs() <= 0.5); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert!(writer.max_abs() <= f32::EPSILON); return; } @@ -548,17 +663,17 @@ mod tests { scheduler.play(); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); let cursor_before_pause = scheduler.cursor_samples(); scheduler.pause(); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert_eq!(scheduler.cursor_samples(), cursor_before_pause); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert!(writer.max_abs() <= f32::EPSILON); return; } @@ -573,7 +688,7 @@ mod tests { scheduler.play(); let mut writer = TestAudioOutput::new(1, 8); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert!(scheduler.cursor_samples() > 0); scheduler.stop(); @@ -581,9 +696,9 @@ mod tests { assert_eq!(scheduler.cursor_samples(), 0); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert!(writer.max_abs() <= f32::EPSILON); return; } @@ -599,7 +714,7 @@ mod tests { scheduler.play(); let mut writer = TestAudioOutput::new(1, 4); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); assert_eq!(scheduler.state(), PlaybackState::Playing); assert!((writer.sample(0, 0) - 0.1).abs() <= 1e-6); @@ -609,6 +724,84 @@ mod tests { return; } + /// Looping MUST wrap correctly when pitch advances the cursor fractionally. + #[test] + fn scheduler_looping_wraps_with_fractional_pitch() { + let buffer = make_test_buffer(vec![0.0, 1.0], 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, 1.0, 1.0, 1.5); + + assert_eq!(scheduler.state(), PlaybackState::Playing); + + assert!((writer.sample(0, 0) - 0.0).abs() <= 1e-6); + assert!((writer.sample(1, 0) - 0.5).abs() <= 1e-6); + assert!((writer.sample(2, 0) - 1.0).abs() <= 1e-6); + assert!((writer.sample(3, 0) - 0.5).abs() <= 1e-6); + return; + } + + /// Pitch `1.0` MUST reproduce the original sample sequence (no resampling). + #[test] + fn scheduler_pitch_one_reproduces_original_sequence() { + let buffer = make_test_buffer(vec![0.1, 0.2, 0.3, 0.4], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 0); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); + + 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.3).abs() <= 1e-6); + assert!((writer.sample(3, 0) - 0.4).abs() <= 1e-6); + return; + } + + /// Pitch `2.0` MUST advance twice as fast (every other input frame). + #[test] + fn scheduler_pitch_two_advances_twice_as_fast() { + let buffer = make_test_buffer(vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 0); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 3); + scheduler.render(&mut writer, 1.0, 1.0, 2.0); + + assert!((writer.sample(0, 0) - 0.0).abs() <= 1e-6); + assert!((writer.sample(1, 0) - 0.4).abs() <= 1e-6); + assert!((writer.sample(2, 0) - 0.8).abs() <= 1e-6); + return; + } + + /// Pitch `0.5` MUST advance half as fast using linear interpolation. + #[test] + fn scheduler_pitch_half_interpolates_between_frames() { + let buffer = make_test_buffer(vec![0.0, 0.2, 0.4, 0.6], 1); + + let mut scheduler = PlaybackScheduler::new_with_ramp_frames(1, 0); + scheduler.set_buffer(buffer); + scheduler.play(); + + let mut writer = TestAudioOutput::new(1, 4); + scheduler.render(&mut writer, 1.0, 1.0, 0.5); + + assert!((writer.sample(0, 0) - 0.0).abs() <= 1e-6); + assert!((writer.sample(1, 0) - 0.1).abs() <= 1e-6); + assert!((writer.sample(2, 0) - 0.2).abs() <= 1e-6); + assert!((writer.sample(3, 0) - 0.3).abs() <= 1e-6); + return; + } + /// Transport transitions MUST avoid hard discontinuities. #[test] fn scheduler_pause_is_continuous_at_transition_boundary() { @@ -619,13 +812,13 @@ mod tests { scheduler.play(); let mut writer = TestAudioOutput::new(1, 8); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); let last = writer.sample(7, 0); scheduler.pause(); let mut writer = TestAudioOutput::new(1, 1); - scheduler.render(&mut writer); + scheduler.render(&mut writer, 1.0, 1.0, 1.0); let first = writer.sample(0, 0); assert!((last - first).abs() <= 1e-6); @@ -708,4 +901,218 @@ mod tests { assert!(writer.max_abs() <= f32::EPSILON); return; } + + #[test] + /// Master volume `0.0` MUST silence output even while playing. + fn controller_applies_master_volume_zero_as_silence() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.8, 0.8, 0.8, 0.8], 1); + + shared_state.set_active_instance_id(1); + shared_state.set_master_volume(0.0); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state, + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + assert!(writer.max_abs() <= f32::EPSILON); + return; + } + + #[test] + /// Master volume MUST linearly scale output samples. + fn controller_scales_output_by_master_volume() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.4, 0.4, 0.4, 0.4], 1); + + shared_state.set_active_instance_id(1); + shared_state.set_master_volume(0.5); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state, + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + + for frame in 0..4 { + assert!((writer.sample(frame, 0) - 0.2).abs() <= 1e-6); + } + return; + } + + #[test] + /// Gains that amplify beyond unity MUST remain bounded by clipping. + fn controller_hard_clips_amplified_output() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.8, 0.8, 0.8, 0.8], 1); + + shared_state.set_active_instance_id(1); + shared_state.set_master_volume(2.0); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state, + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + + assert!(writer.max_abs() <= 1.0 + 1e-6); + assert!(writer.samples.iter().all(|value| value.is_finite())); + return; + } + + #[test] + /// Instance volume `0.0` MUST silence output even while playing. + fn controller_applies_instance_volume_zero_as_silence() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.8, 0.8, 0.8, 0.8], 1); + + shared_state.set_active_instance_id(1); + shared_state.set_instance_volume(0.0); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state, + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + assert!(writer.max_abs() <= f32::EPSILON); + return; + } + + #[test] + /// Instance volume MUST linearly scale output samples. + fn controller_scales_output_by_instance_volume() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.4, 0.4, 0.4, 0.4], 1); + + shared_state.set_active_instance_id(1); + shared_state.set_instance_volume(0.5); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state, + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + + for frame in 0..4 { + assert!((writer.sample(frame, 0) - 0.2).abs() <= 1e-6); + } + return; + } + + #[test] + /// Amplified per-instance gain MUST remain bounded by clipping. + fn controller_hard_clips_amplified_instance_volume() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + let buffer = make_test_buffer(vec![0.8, 0.8, 0.8, 0.8], 1); + + shared_state.set_active_instance_id(1); + shared_state.set_instance_volume(2.0); + + command_queue + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) + .unwrap(); + + let mut controller = PlaybackController::new_with_ramp_frames( + 1, + 0, + command_queue, + shared_state, + ); + + let mut writer = TestAudioOutput::new(1, 4); + controller.render(&mut writer); + + assert!(writer.max_abs() <= 1.0 + 1e-6); + assert!(writer.samples.iter().all(|value| value.is_finite())); + return; + } } diff --git a/crates/lambda-rs/src/audio/playback/context.rs b/crates/lambda-rs/src/audio/playback/context.rs index 0ba59615..3524b62a 100644 --- a/crates/lambda-rs/src/audio/playback/context.rs +++ b/crates/lambda-rs/src/audio/playback/context.rs @@ -35,6 +35,70 @@ impl SoundInstance { return self.shared_state.active_instance_id() == self.instance_id; } + /// Set volume where 1.0 is normal, 0.0 is silent, >1.0 amplifies. + /// + /// Calls on inactive instances are no-ops. + /// + /// # Arguments + /// - `volume`: The requested volume scalar. + /// + /// # Returns + /// `()` after updating the instance volume. + pub fn set_volume(&mut self, volume: f32) { + if !self.is_active() { + return; + } + + self.shared_state.set_instance_volume(volume); + return; + } + + /// Return the current volume for this instance. + /// + /// Inactive instances report a default volume of `1.0`. + /// + /// # Returns + /// The current volume scalar. + pub fn volume(&self) -> f32 { + if !self.is_active() { + return 1.0; + } + + return self.shared_state.instance_volume(); + } + + /// Set pitch where 1.0 is normal, 0.5 is half speed, 2.0 is double. + /// + /// Calls on inactive instances are no-ops. + /// + /// # Arguments + /// - `pitch`: The requested playback rate multiplier. + /// + /// # Returns + /// `()` after updating the instance pitch. + pub fn set_pitch(&mut self, pitch: f32) { + if !self.is_active() { + return; + } + + self.shared_state.set_instance_pitch(pitch); + return; + } + + /// Return the current pitch/playback speed for this instance. + /// + /// Inactive instances report a default pitch of `1.0`. + /// + /// # Returns + /// The current pitch multiplier. + pub fn pitch(&self) -> f32 { + if !self.is_active() { + return 1.0; + } + + return self.shared_state.instance_pitch(); + } + /// Begin playback, or resume if paused. /// /// # Returns @@ -275,6 +339,27 @@ impl Default for AudioContextBuilder { } impl AudioContext { + /// Set the global/master volume applied to all playback in this context. + /// + /// # Arguments + /// - `volume`: Master volume where `1.0` is normal, `0.0` is silent, and + /// values > `1.0` amplify. + /// + /// # Returns + /// `()` after updating the master volume. + pub fn set_master_volume(&mut self, volume: f32) { + self.shared_state.set_master_volume(volume); + return; + } + + /// Return the current global/master volume for this context. + /// + /// # Returns + /// The current master volume. + pub fn master_volume(&self) -> f32 { + return self.shared_state.master_volume(); + } + /// Play a decoded `SoundBuffer` through this context. /// /// # Arguments @@ -327,6 +412,8 @@ impl AudioContext { let previous_state = self.shared_state.state(); self.shared_state.set_active_instance_id(instance_id); self.shared_state.set_state(PlaybackState::Stopped); + self.shared_state.set_instance_volume(1.0); + self.shared_state.set_instance_pitch(1.0); let shared_buffer = Arc::new(buffer.clone()); @@ -389,6 +476,32 @@ mod tests { }; } + #[test] + /// `AudioContext` master volume MUST default to `1.0`. + fn audio_context_master_volume_defaults_to_one() { + let context = create_test_context(48_000, 2); + assert_eq!(context.master_volume(), 1.0); + return; + } + + #[test] + /// Negative master volume values MUST clamp to silence (`0.0`). + fn audio_context_master_volume_clamps_negative_to_zero() { + let mut context = create_test_context(48_000, 2); + context.set_master_volume(-1.0); + assert_eq!(context.master_volume(), 0.0); + return; + } + + #[test] + /// Non-finite master volume values MUST be normalized to `1.0`. + fn audio_context_master_volume_treats_nan_as_one() { + let mut context = create_test_context(48_000, 2); + context.set_master_volume(f32::NAN); + assert_eq!(context.master_volume(), 1.0); + return; + } + fn create_test_sound_buffer( sample_rate: u32, channels: u16, @@ -442,6 +555,8 @@ mod tests { instance.pause(); instance.stop(); instance.set_looping(true); + instance.set_volume(0.25); + instance.set_pitch(0.5); assert!(command_queue.pop().is_none()); return; @@ -526,6 +641,120 @@ mod tests { return; } + /// `SoundInstance::volume` MUST default to `1.0` and normalize invalid values + /// while active. + #[test] + fn sound_instance_volume_defaults_and_clamps_when_active() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(7); + + let mut instance = SoundInstance { + instance_id: 7, + command_queue, + shared_state, + }; + + assert_eq!(instance.volume(), 1.0); + + instance.set_volume(2.0); + assert_eq!(instance.volume(), 2.0); + + instance.set_volume(-1.0); + assert_eq!(instance.volume(), 0.0); + + instance.set_volume(f32::NAN); + assert_eq!(instance.volume(), 1.0); + return; + } + + /// `SoundInstance::set_volume` MUST be a no-op when inactive and + /// `SoundInstance::volume` MUST report `1.0`. + #[test] + fn sound_instance_volume_is_noop_and_defaults_when_inactive() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(2); + + let mut inactive_instance = SoundInstance { + instance_id: 1, + command_queue: command_queue.clone(), + shared_state: shared_state.clone(), + }; + + let active_instance = SoundInstance { + instance_id: 2, + command_queue, + shared_state, + }; + + inactive_instance.set_volume(0.0); + assert_eq!(inactive_instance.volume(), 1.0); + assert_eq!(active_instance.volume(), 1.0); + return; + } + + /// `SoundInstance::pitch` MUST default to `1.0` and normalize invalid values + /// while active. + #[test] + fn sound_instance_pitch_defaults_and_clamps_when_active() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(7); + + let mut instance = SoundInstance { + instance_id: 7, + command_queue, + shared_state, + }; + + assert_eq!(instance.pitch(), 1.0); + + instance.set_pitch(2.0); + assert_eq!(instance.pitch(), 2.0); + + instance.set_pitch(0.0); + assert!(instance.pitch() > 0.0); + + instance.set_pitch(f32::NAN); + assert_eq!(instance.pitch(), 1.0); + return; + } + + /// `SoundInstance::set_pitch` MUST be a no-op when inactive and + /// `SoundInstance::pitch` MUST report `1.0`. + #[test] + fn sound_instance_pitch_is_noop_and_defaults_when_inactive() { + let command_queue: Arc = + Arc::new(CommandQueue::new()); + let shared_state = Arc::new(PlaybackSharedState::new()); + + shared_state.set_active_instance_id(2); + + let mut inactive_instance = SoundInstance { + instance_id: 1, + command_queue: command_queue.clone(), + shared_state: shared_state.clone(), + }; + + let active_instance = SoundInstance { + instance_id: 2, + command_queue, + shared_state, + }; + + inactive_instance.set_pitch(0.5); + assert_eq!(inactive_instance.pitch(), 1.0); + assert_eq!(active_instance.pitch(), 1.0); + return; + } + /// The builder MUST reject unsupported channel counts before device init. #[test] fn audio_context_builder_rejects_too_many_channels() { @@ -658,6 +887,34 @@ mod tests { return; } + /// `play_sound` MUST reset per-instance volume to `1.0` for the new instance. + #[test] + fn play_sound_resets_instance_volume() { + 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(1); + context.shared_state.set_instance_volume(0.25); + + let instance = context.play_sound(&buffer).expect("must play sound"); + assert_eq!(instance.volume(), 1.0); + return; + } + + /// `play_sound` MUST reset per-instance pitch to `1.0` for the new instance. + #[test] + fn play_sound_resets_instance_pitch() { + 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(1); + context.shared_state.set_instance_pitch(0.5); + + let instance = context.play_sound(&buffer).expect("must play sound"); + assert_eq!(instance.pitch(), 1.0); + return; + } + /// `play_sound` MUST restore previous state when the queue is full. #[test] fn play_sound_restores_state_when_queue_full_for_set_buffer() { diff --git a/crates/lambda-rs/src/audio/playback/transport.rs b/crates/lambda-rs/src/audio/playback/transport.rs index 424ec834..c903979e 100644 --- a/crates/lambda-rs/src/audio/playback/transport.rs +++ b/crates/lambda-rs/src/audio/playback/transport.rs @@ -3,6 +3,7 @@ use std::{ mem::MaybeUninit, sync::{ atomic::{ + AtomicU32, AtomicU64, AtomicU8, AtomicUsize, @@ -147,6 +148,9 @@ pub(super) type PlaybackCommandQueue = pub(super) struct PlaybackSharedState { active_instance_id: AtomicU64, state: AtomicU8, + master_volume_bits: AtomicU32, + instance_volume_bits: AtomicU32, + instance_pitch_bits: AtomicU32, } impl PlaybackSharedState { @@ -158,6 +162,9 @@ impl PlaybackSharedState { return Self { active_instance_id: AtomicU64::new(0), state: AtomicU8::new(playback_state_to_u8(PlaybackState::Stopped)), + master_volume_bits: AtomicU32::new(1.0_f32.to_bits()), + instance_volume_bits: AtomicU32::new(1.0_f32.to_bits()), + instance_pitch_bits: AtomicU32::new(1.0_f32.to_bits()), }; } @@ -205,6 +212,92 @@ impl PlaybackSharedState { let value = self.state.load(Ordering::Acquire); return playback_state_from_u8(value); } + + /// Set the global/master volume. + /// + /// # Arguments + /// - `volume`: Master volume where `1.0` is normal, `0.0` is silent, and + /// values > `1.0` amplify. + /// + /// # Returns + /// `()` after updating the master volume. + pub(super) fn set_master_volume(&self, volume: f32) { + let normalized = normalize_volume(volume); + self + .master_volume_bits + .store(normalized.to_bits(), Ordering::Release); + return; + } + + /// Return the global/master volume. + /// + /// # Returns + /// The current master volume. + pub(super) fn master_volume(&self) -> f32 { + let bits = self.master_volume_bits.load(Ordering::Acquire); + let value = f32::from_bits(bits); + return normalize_volume(value); + } + + /// Set the per-instance volume for the active playback slot. + /// + /// This is stored separately from master volume and is intended to model + /// `SoundInstance::set_volume` while the playback system supports only one + /// active sound at a time. + /// + /// # Arguments + /// - `volume`: Instance volume where `1.0` is normal, `0.0` is silent, and + /// values > `1.0` amplify. + /// + /// # Returns + /// `()` after updating the per-instance volume. + pub(super) fn set_instance_volume(&self, volume: f32) { + let normalized = normalize_volume(volume); + self + .instance_volume_bits + .store(normalized.to_bits(), Ordering::Release); + return; + } + + /// Return the per-instance volume for the active playback slot. + /// + /// # Returns + /// The current per-instance volume. + pub(super) fn instance_volume(&self) -> f32 { + let bits = self.instance_volume_bits.load(Ordering::Acquire); + let value = f32::from_bits(bits); + return normalize_volume(value); + } + + /// Set the per-instance pitch/playback speed for the active playback slot. + /// + /// Pitch is expressed as a playback rate multiplier: + /// - `1.0` is normal speed + /// - `0.5` is half speed + /// - `2.0` is double speed + /// + /// # Arguments + /// - `pitch`: Requested playback rate multiplier. + /// + /// # Returns + /// `()` after updating the per-instance pitch. + pub(super) fn set_instance_pitch(&self, pitch: f32) { + let normalized = normalize_pitch(pitch); + self + .instance_pitch_bits + .store(normalized.to_bits(), Ordering::Release); + return; + } + + /// Return the per-instance pitch/playback speed for the active playback slot. + /// + /// # Returns + /// The current playback rate multiplier. + pub(super) fn instance_pitch(&self) -> f32 { + let bits = self.instance_pitch_bits.load(Ordering::Acquire); + let value = f32::from_bits(bits); + return normalize_pitch(value); + } } fn playback_state_to_u8(state: PlaybackState) -> u8 { @@ -235,6 +328,64 @@ fn playback_state_from_u8(value: u8) -> PlaybackState { } } +/// Normalize a volume scalar into a safe, deterministic value. +/// +/// This helper is used for both per-instance and master volume normalization. +/// The public API is infallible, so invalid inputs are mapped to sensible +/// defaults. +/// +/// Normalization rules +/// - Non-finite values (NaN, +/-Inf) map to `1.0`. +/// - Negative values clamp to `0.0`. +/// - Values >= `0.0` are returned unchanged (including values > `1.0`). +/// +/// # Arguments +/// - `volume`: Candidate volume value. +/// +/// # Returns +/// A normalized volume scalar. +fn normalize_volume(volume: f32) -> f32 { + if !volume.is_finite() { + return 1.0; + } + + if volume < 0.0 { + return 0.0; + } + + return volume; +} + +/// Normalize a pitch scalar into a safe, deterministic value. +/// +/// Pitch is used as a playback rate multiplier, so non-positive values would +/// stall or reverse playback. The public API is infallible, so invalid inputs +/// are mapped to sensible defaults. +/// +/// Normalization rules +/// - Non-finite values (NaN, +/-Inf) map to `1.0`. +/// - Values <= `0.0` clamp to a small positive epsilon. +/// - Values > `0.0` are returned unchanged. +/// +/// # Arguments +/// - `pitch`: Candidate pitch value. +/// +/// # Returns +/// A normalized pitch scalar guaranteed to be finite and > `0.0`. +fn normalize_pitch(pitch: f32) -> f32 { + const PITCH_EPSILON: f32 = 0.001; + + if !pitch.is_finite() { + return 1.0; + } + + if pitch <= 0.0 { + return PITCH_EPSILON; + } + + return pitch; +} + #[cfg(test)] mod tests { use super::*; diff --git a/demos/audio/Cargo.toml b/demos/audio/Cargo.toml index 4d215962..e1d76956 100644 --- a/demos/audio/Cargo.toml +++ b/demos/audio/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] lambda-rs = { path = "../../crates/lambda-rs" } +lambda-rs-args = { path = "../../crates/lambda-rs-args" } [features] default = ["audio"] diff --git a/demos/audio/src/bin/sound_instance_gain_pitch.rs b/demos/audio/src/bin/sound_instance_gain_pitch.rs new file mode 100644 index 00000000..dc2d1ea2 --- /dev/null +++ b/demos/audio/src/bin/sound_instance_gain_pitch.rs @@ -0,0 +1,332 @@ +#![allow(clippy::needless_return)] +//! Audio demo exercising `SoundInstance` gain and pitch controls via a CLI. +//! +//! This demo validates that: +//! - `SoundInstance::set_volume` affects playback loudness +//! - `SoundInstance::set_pitch` affects playback speed and frequency +//! - `AudioContext::set_master_volume` affects all playback output +//! +//! Note: this demo intentionally uses discrete jumps (no fades) to match the +//! current feature scope. + +use std::{ + io::{ + BufRead, + Write, + }, + time::Duration, +}; + +use args::{ + ArgsError, + Argument, + ArgumentParser, + ArgumentType, +}; +use lambda::audio::{ + AudioContextBuilder, + SoundBuffer, +}; + +fn main() { + let parser = build_cli(); + let argv: Vec = std::env::args().collect(); + let usage = parser.usage(); + + if argv.len() <= 1 { + print!("{}", usage); + return; + } + + let parsed = match parser.parse(&argv) { + Ok(parsed) => parsed, + Err(ArgsError::HelpRequested(help)) => { + print!("{}", help); + return; + } + Err(error) => { + eprintln!("{}\n{}", error, usage); + return; + } + }; + + let master_volume = parsed.get_f32("--master-volume").unwrap_or(0.25); + + 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-instance-gain-pitch") + .with_sample_rate(buffer.sample_rate()) + .with_channels(buffer.channels()) + .build() + .unwrap(); + + // Start with a conservative master volume to avoid unexpectedly loud output. + context.set_master_volume(master_volume); + + let mut instance = context.play_sound(&buffer).unwrap(); + + match parsed.subcommand() { + Some(("repl", sub)) => { + let initial_volume = sub.get_f32("--volume").unwrap_or(1.0); + let initial_pitch = sub.get_f32("--pitch").unwrap_or(1.0); + let looping = sub.get_bool("--looping").unwrap_or(false); + + instance.set_volume(initial_volume); + instance.set_pitch(initial_pitch); + instance.set_looping(looping); + + run_repl(&mut context, &mut instance, looping); + } + Some(("play", sub)) => { + let volume = sub.get_f32("--volume").unwrap_or(1.0); + let pitch = sub.get_f32("--pitch").unwrap_or(1.0); + let looping = sub.get_bool("--looping").unwrap_or(false); + let duration_ms = sub.get_i64("--duration-ms").unwrap_or(2_000); + + instance.set_volume(volume); + instance.set_pitch(pitch); + instance.set_looping(looping); + + if duration_ms > 0 { + std::thread::sleep(Duration::from_millis(duration_ms as u64)); + } + } + Some(("script", _)) => { + run_script(&mut context, &mut instance); + } + None => { + eprintln!("No subcommand provided.\n{}", usage); + return; + } + Some((name, _sub)) => { + eprintln!("Unknown subcommand: {}\n{}", name, usage); + return; + } + } + + return; +} + +fn build_cli() -> ArgumentParser { + let root = ArgumentParser::new("sound_instance_gain_pitch") + .with_description( + "Play a built-in sound and interact with transport/gain/pitch controls.", + ) + .with_argument( + Argument::new("--master-volume").with_type(ArgumentType::Float), + ) + .with_subcommand( + ArgumentParser::new("script").with_description("Run a scripted sequence"), + ) + .with_subcommand( + ArgumentParser::new("play") + .with_description("Play with fixed settings for a duration") + .with_argument(Argument::new("--volume").with_type(ArgumentType::Float)) + .with_argument(Argument::new("--pitch").with_type(ArgumentType::Float)) + .with_argument( + Argument::new("--looping").with_type(ArgumentType::Boolean), + ) + .with_argument( + Argument::new("--duration-ms").with_type(ArgumentType::Integer), + ), + ) + .with_subcommand( + ArgumentParser::new("repl") + .with_description("Interactive mode (commands on stdin)") + .with_argument(Argument::new("--volume").with_type(ArgumentType::Float)) + .with_argument(Argument::new("--pitch").with_type(ArgumentType::Float)) + .with_argument( + Argument::new("--looping").with_type(ArgumentType::Boolean), + ), + ); + + return root; +} + +fn run_script( + _context: &mut lambda::audio::AudioContext, + instance: &mut lambda::audio::SoundInstance, +) { + std::thread::sleep(Duration::from_millis(300)); + + // Instance volume: silence, normal, amplified. + instance.set_volume(0.0); + std::thread::sleep(Duration::from_millis(250)); + + instance.set_volume(1.0); + std::thread::sleep(Duration::from_millis(250)); + + instance.set_volume(2.0); + std::thread::sleep(Duration::from_millis(300)); + + instance.set_volume(1.0); + std::thread::sleep(Duration::from_millis(250)); + + // Pitch: half speed then double speed. + instance.set_pitch(0.5); + std::thread::sleep(Duration::from_millis(600)); + + instance.set_pitch(2.0); + std::thread::sleep(Duration::from_millis(600)); + + instance.set_pitch(1.0); + std::thread::sleep(Duration::from_millis(250)); + + std::thread::sleep(Duration::from_millis(500)); + return; +} + +fn run_repl( + context: &mut lambda::audio::AudioContext, + instance: &mut lambda::audio::SoundInstance, + mut looping: bool, +) { + print_repl_help(); + print_status(context, instance, looping); + + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + let mut lines = stdin.lock().lines(); + + loop { + let _ = write!(stdout, "> "); + let _ = stdout.flush(); + + let Some(Ok(line)) = lines.next() else { + break; + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let mut parts = trimmed.split_whitespace(); + let Some(command) = parts.next() else { + continue; + }; + + match command { + "help" | "h" | "?" => { + print_repl_help(); + } + "status" | "s" => { + print_status(context, instance, looping); + } + "play" => { + instance.play(); + } + "pause" => { + instance.pause(); + } + "stop" => { + instance.stop(); + } + "loop" => { + let value = parts.next().unwrap_or(""); + let enabled = matches!(value, "1" | "true" | "on" | "yes"); + let disabled = matches!(value, "0" | "false" | "off" | "no"); + + if enabled { + looping = true; + instance.set_looping(true); + } else if disabled { + looping = false; + instance.set_looping(false); + } else { + eprintln!("usage: loop on|off"); + } + } + "volume" | "vol" => { + let Some(value) = parts.next() else { + eprintln!("usage: volume "); + continue; + }; + + match value.parse::() { + Ok(volume) => instance.set_volume(volume), + Err(_) => eprintln!("invalid volume: {}", value), + } + } + "pitch" => { + let Some(value) = parts.next() else { + eprintln!("usage: pitch "); + continue; + }; + + match value.parse::() { + Ok(pitch) => instance.set_pitch(pitch), + Err(_) => eprintln!("invalid pitch: {}", value), + } + } + "master" => { + let Some(value) = parts.next() else { + eprintln!("usage: master "); + continue; + }; + + match value.parse::() { + Ok(volume) => context.set_master_volume(volume), + Err(_) => eprintln!("invalid master volume: {}", value), + } + } + "sleep" => { + let Some(value) = parts.next() else { + eprintln!("usage: sleep "); + continue; + }; + + match value.parse::() { + Ok(ms) => std::thread::sleep(Duration::from_millis(ms)), + Err(_) => eprintln!("invalid sleep duration: {}", value), + } + } + "quit" | "exit" | "q" => { + break; + } + other => { + eprintln!("unknown command: {}", other); + eprintln!("type `help` for commands"); + } + } + } + + return; +} + +fn print_repl_help() { + println!("Commands:"); + println!(" help Show this help"); + println!(" status Show current settings"); + println!(" play | pause | stop Transport controls"); + println!(" loop on|off Toggle looping"); + println!(" volume Set instance volume (0.0..1.0+)"); + println!(" pitch Set instance pitch (speed) (e.g. 0.5, 1.0, 2.0)"); + println!(" master Set master volume (0.0..1.0+)"); + println!(" sleep Sleep for a duration"); + println!(" quit Exit"); + return; +} + +fn print_status( + context: &lambda::audio::AudioContext, + instance: &lambda::audio::SoundInstance, + looping: bool, +) { + println!( + "state={:?} looping={} volume={} pitch={} master={}", + instance.state(), + looping, + instance.volume(), + instance.pitch(), + context.master_volume() + ); + return; +} diff --git a/docs/specs/README.md b/docs/specs/README.md index c943a24c..d2e780b9 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,7 +3,7 @@ title: "Specifications Index" document_id: "specs-index-2026-02-07" status: "living" created: "2026-02-07T00:00:00Z" -last_updated: "2026-02-12T23:03:52Z" +last_updated: "2026-02-15T00:00:00Z" version: "0.1.3" repo_commit: "6f96052fae896a095b658f29af1eff96e5aaa348" owners: ["lambda-sh"] @@ -25,6 +25,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) +- Sound Instance Gain and Pitch Controls — [audio/sound-instance-gain-and-pitch.md](audio/sound-instance-gain-and-pitch.md) ## Runtime / Events diff --git a/docs/specs/audio/sound-instance-gain-and-pitch.md b/docs/specs/audio/sound-instance-gain-and-pitch.md new file mode 100644 index 00000000..19e67d7b --- /dev/null +++ b/docs/specs/audio/sound-instance-gain-and-pitch.md @@ -0,0 +1,270 @@ +--- +title: "Sound Instance Gain and Pitch Controls" +document_id: "audio-sound-instance-gain-pitch-2026-02-15" +status: "draft" +created: "2026-02-15T00:00:00Z" +last_updated: "2026-02-15T00:00:00Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "6f96052fae896a095b658f29af1eff96e5aaa348" +owners: ["lambda-sh"] +reviewers: ["engine"] +tags: ["spec", "audio", "lambda-rs"] +--- + +# Sound Instance Gain and Pitch 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) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Acceptance Criteria](#acceptance-criteria) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) + +## Summary + +- Add per-`SoundInstance` volume (gain) and pitch (playback speed) controls for + basic audio manipulation. +- Add global/master volume control on `AudioContext` that affects all playback. + +Rationale +- These controls are the minimum needed for comfortable iteration on demos and + gameplay (balancing loudness, simple “slow/fast” effects) without committing + to a full mixer/effects stack. +- For v1, pitch shifting via resampling is acceptable and is explicitly treated + as “speed changes affect both speed and frequency”. + +Prerequisites +- This specification depends on the baseline playback API and callback + scheduler described in `docs/specs/audio/sound-playback.md`. + +Affects +- Crates: `lambda-rs`, `lambda-rs-platform` + +## Scope + +### Goals + +- Set volume/gain per `SoundInstance` (`0.0` to `1.0`+). +- Set pitch/playback speed per `SoundInstance`. +- Support global/master volume on `AudioContext`. + +### Non-Goals + +- Audio effects (reverb, echo). +- Per-channel volume. +- Volume fading/transitions. +- Time-stretch pitch shifting (changing pitch without changing speed). +- High-quality resampling kernels (linear interpolation is sufficient for v1). + +## Terminology + +- Gain/volume: a scalar multiplier applied to the sample signal. +- Master volume: a global gain applied after per-instance gain. +- Pitch / playback speed / rate: controls the rate at which the playback cursor + advances through samples; in v1 this implies both speed and frequency change. +- Resampling: reading an audio buffer at a non-1.0 rate using a fractional + cursor and interpolation. +- Clipping: signal values exceeding the output format range (for `f32`, + typically `[-1.0, 1.0]`). + +## Architecture Overview + +This feature extends the `lambda-rs` playback scheduler to apply: + +1) per-instance gain and pitch during sample generation, and +2) a master gain at the final output stage. + +Data flow (single active instance, as in baseline playback) + +``` +application + └── AudioContext + ├── master_volume (scalar) + └── play_sound(...) -> SoundInstance + ├── instance_volume (scalar) + └── instance_pitch (rate) + └── commands -> audio callback scheduler + └── resampler -> per-instance gain -> master gain -> clip -> output +``` + +## Design + +### API Surface + +This section describes the public API surface added to `lambda-rs`. + +```rust +impl SoundInstance { + /// Set volume where 1.0 is normal, 0.0 is silent, >1.0 amplifies. + pub fn set_volume(&mut self, volume: f32); + pub fn volume(&self) -> f32; + + /// Set pitch where 1.0 is normal, 0.5 is half speed, 2.0 is double. + pub fn set_pitch(&mut self, pitch: f32); + pub fn pitch(&self) -> f32; +} + +impl AudioContext { + pub fn set_master_volume(&mut self, volume: f32); + pub fn master_volume(&self) -> f32; +} +``` + +Defaults +- New `SoundInstance` objects MUST begin with `volume == 1.0` and `pitch == 1.0`. +- New `AudioContext` objects MUST begin with `master_volume == 1.0`. + +Notes +- These APIs are intentionally scalar-only for v1 (no per-channel gains). +- Naming: “volume” is treated as synonymous with “gain” and represents a + linear multiplier. + +### Behavior + +#### Per-instance volume (gain) + +- Output signal MUST be multiplied by `instance_volume`. +- `instance_volume == 0.0` MUST produce silence for that instance. +- `instance_volume > 1.0` MUST amplify the signal before clipping/limiting. + +Clipping behavior +- The system MUST be “clipping aware”: it MUST NOT panic or produce NaNs due to + amplification, and it MUST keep the final output within the output format’s + representable range. +- v1 MAY implement either: + - hard clipping (saturating clamp), or + - soft clipping (simple saturator) for gentler distortion at >1.0 gains. +- If soft clipping is used, it MUST still guarantee bounded output. + +#### Master volume + +- Output signal MUST be multiplied by `master_volume` after per-instance gain. +- Master volume MUST affect all playback routed through an `AudioContext`, + including any future multi-sound mixing within that context. +- If multiple contexts exist, master volume is per-context, not global to the + process (unless a future design introduces a true process-wide master bus). + +Effective gain +- The effective scalar applied to samples is: + - `effective_gain = instance_volume * master_volume` + +#### Pitch / playback speed + +- Pitch MUST change both playback speed and perceived frequency. +- Pitch MUST be implemented by advancing the playback cursor by `pitch` samples + per output sample frame, using a fractional cursor and interpolation. + +Resampling (v1) +- v1 SHOULD use linear interpolation between adjacent samples. +- When `pitch == 1.0`, the implementation SHOULD take the fast path that reads + samples without interpolation (when possible), but correctness is preferred + over micro-optimizations. + +Edge cases +- When pitch causes the cursor to step past the end: + - if looping is enabled, wrap according to the looping behavior in the + baseline playback spec; + - otherwise, the instance MUST transition to stopped and output silence. + +### Validation and Errors + +The API surface does not return `Result`, so validation MUST be total and +non-panicking. + +Validation rules +- `set_volume(volume)`: + - if `volume` is not finite, treat it as `1.0`; + - if `volume < 0.0`, clamp to `0.0`; + - otherwise accept the value (including values > `1.0`). +- `set_master_volume(volume)` follows the same rules as `set_volume`. +- `set_pitch(pitch)`: + - if `pitch` is not finite, treat it as `1.0`; + - if `pitch <= 0.0`, clamp to a small positive epsilon (implementation-defined). + +Rationale +- Clamping keeps behavior deterministic and avoids introducing fallible APIs for + a v1 feature. + +## Constraints and Rules + +- The audio callback thread MUST NOT allocate and MUST remain lock-free or + bounded-lock (as required by the existing playback design). +- Updates to volume/pitch/master volume MUST be safe to issue frequently (e.g., + every frame) without causing audible glitches from lock contention. +- The applied gain and pitch MUST be sample-accurate at the callback boundary; + changes MAY take effect on the next callback buffer fill. + +## Performance Considerations + +Recommendations +- Keep resampling interpolation simple (linear) in v1. + - Rationale: predictable cost and easy to reason about. +- Apply gain as a scalar multiply in the tight loop and clip once at the end. + - Rationale: minimizes extra branches and redundant clamps. + +## Acceptance Criteria + +- [ ] Volume `0.0` produces silence +- [ ] Volume `1.0` plays at original level +- [ ] Volume `> 1.0` amplifies (with clipping awareness) +- [ ] Pitch `1.0` plays at original speed +- [ ] Pitch changes affect both speed and frequency +- [ ] Master volume affects all playing sounds + +## Requirements Checklist + +- Functionality + - [ ] Per-instance volume stored and applied + - [ ] Per-instance pitch stored and applied via resampling + - [ ] Master volume stored and applied after per-instance gain + - [ ] Clipping behavior defined and implemented for >1.0 gains +- API Surface + - [ ] `SoundInstance::{set_volume,volume,set_pitch,pitch}` exposed + - [ ] `AudioContext::{set_master_volume,master_volume}` exposed + - [ ] Defaults are `1.0` for all new values +- Validation and Errors + - [ ] Non-finite inputs handled deterministically + - [ ] Negative volume clamped to `0.0` + - [ ] Non-positive pitch clamped to epsilon +- Documentation and Examples + - [ ] Playback example updated to adjust volume/pitch/master volume + +## Verification and Testing + +Unit tests (recommended) +- Validate clamping/normalization behavior for `set_volume`, `set_pitch`, + `set_master_volume` (negative, zero, large, NaN/Inf). +- Validate pitch behavior with a small synthetic buffer: + - `pitch == 1.0` returns the original sequence, + - `pitch == 2.0` advances twice as fast (skips/interpolates accordingly), + - `pitch == 0.5` repeats/interpolates accordingly. + +Manual checks (recommended) +- In the playback demo/example: + - set volume to `0.0` and confirm silence, + - set volume to `> 1.0` and confirm audible amplification and bounded output, + - change pitch to `0.5` and `2.0` and confirm both speed and pitch change, + - change master volume and confirm it affects current playback. + +## Compatibility and Migration + +None. This is additive to the playback API. + +## Changelog + +- 2026-02-15 (v0.1.0) — Initial draft. diff --git a/docs/specs/audio/sound-playback.md b/docs/specs/audio/sound-playback.md index f81e419c..d3586c96 100644 --- a/docs/specs/audio/sound-playback.md +++ b/docs/specs/audio/sound-playback.md @@ -3,13 +3,13 @@ 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" +last_updated: "2026-02-15T00:00:00Z" +version: "0.1.2" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "e1150369fb5024e47d4b8a19c116c16f8fb9abad" +repo_commit: "6f96052fae896a095b658f29af1eff96e5aaa348" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs"] @@ -65,12 +65,12 @@ Rationale ### Non-Goals -- Volume control. -- Pitch/speed control. +- Volume control (see `docs/specs/audio/sound-instance-gain-and-pitch.md`). +- Pitch/speed control (see `docs/specs/audio/sound-instance-gain-and-pitch.md`). - Spatial audio. - Multiple simultaneous sounds. - Streaming decode (disk-backed or incremental decode). -- Resampling or general channel remapping. +- General-purpose resampling or channel remapping (beyond pitch control). ## Terminology