From 6ae5a92e5338698c47e949a2f12e08f9fed74ca6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 9 Feb 2026 14:13:09 -0800 Subject: [PATCH 01/12] [add] initial specification for sound playback. --- docs/specs/README.md | 5 +- docs/specs/audio/sound-playback.md | 383 +++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 docs/specs/audio/sound-playback.md 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. From e85f7909a90dc2714ad70e69f83f394e8f3f7660 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 12:13:31 -0800 Subject: [PATCH 02/12] [add] audio-playback feature and initial SoundInstance & AudioContext implementations. --- crates/lambda-rs/Cargo.toml | 3 +- crates/lambda-rs/src/audio/mod.rs | 5 + crates/lambda-rs/src/audio/playback.rs | 228 +++++++++++++++++++++++++ demos/audio/Cargo.toml | 3 +- 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 crates/lambda-rs/src/audio/playback.rs 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/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.rs b/crates/lambda-rs/src/audio/playback.rs new file mode 100644 index 00000000..baf52b59 --- /dev/null +++ b/crates/lambda-rs/src/audio/playback.rs @@ -0,0 +1,228 @@ +#![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. + +use crate::audio::{ + AudioError, + SoundBuffer, +}; + +/// 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, +} + +/// A lightweight handle controlling the active sound playback slot. +/// +/// This type is a placeholder API surface used while sound playback is under +/// active development. It is expected to become fully functional in a +/// subsequent change set. +pub struct SoundInstance { + state: PlaybackState, + looping: bool, +} + +impl SoundInstance { + /// Begin playback, or resume if paused. + /// + /// # Returns + /// `()` after updating the requested transport state. + pub fn play(&mut self) { + self.state = PlaybackState::Playing; + return; + } + + /// Pause playback, preserving playback position. + /// + /// # Returns + /// `()` after updating the requested transport state. + pub fn pause(&mut self) { + self.state = PlaybackState::Paused; + return; + } + + /// Stop playback and reset position to the start of the buffer. + /// + /// # Returns + /// `()` after updating the requested transport state. + pub fn stop(&mut self) { + self.state = PlaybackState::Stopped; + 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) { + self.looping = looping; + return; + } + + /// Query the current state of this instance. + /// + /// # Returns + /// The current transport state. + pub fn state(&self) -> PlaybackState { + return self.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. +/// +/// This type is a placeholder API surface used while sound playback is under +/// active development. It is expected to become fully functional in a +/// subsequent change set. +pub struct AudioContext { + _requested_sample_rate: Option, + _requested_channels: Option, + _label: Option, +} + +/// 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 until sound playback is integrated with an audio output + /// device callback. + pub fn build(self) -> Result { + return Err(AudioError::InvalidData { + details: format!( + "audio context playback is not implemented in this build (requested_sample_rate={:?}, requested_channels={:?}, label={:?})", + self.sample_rate, + self.channels, + self.label + ), + }); + } +} + +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 an error until the playback implementation is integrated with an + /// audio output device callback. + pub fn play_sound( + &mut self, + buffer: &SoundBuffer, + ) -> Result { + return Err(AudioError::InvalidData { + details: format!( + "sound playback is not implemented in this build (buffer_sample_rate={}, buffer_channels={})", + buffer.sample_rate(), + buffer.channels() + ), + }); + } +} 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"] From be43f87e62d47ff1c483a7f5c25338ac0ff6b358 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 12:31:34 -0800 Subject: [PATCH 03/12] [add] playback scheduler and gain ramp --- crates/lambda-rs/src/audio/buffer.rs | 48 +++ crates/lambda-rs/src/audio/playback.rs | 487 +++++++++++++++++++++++++ 2 files changed, 535 insertions(+) diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 03e4474a..5455fe1e 100644 --- a/crates/lambda-rs/src/audio/buffer.rs +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -145,6 +145,54 @@ impl SoundBuffer { }); } + /// 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() % channels as usize != 0 { + 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, + sample_rate, + channels, + }); + } + /// Return the sample rate in Hz. /// /// # Returns diff --git a/crates/lambda-rs/src/audio/playback.rs b/crates/lambda-rs/src/audio/playback.rs index baf52b59..903aa74c 100644 --- a/crates/lambda-rs/src/audio/playback.rs +++ b/crates/lambda-rs/src/audio/playback.rs @@ -5,11 +5,305 @@ //! This module provides a minimal, backend-agnostic playback facade that //! supports one active `SoundBuffer` at a time. +use std::sync::Arc; + use crate::audio::{ AudioError, + AudioOutputWriter, SoundBuffer, }; +const DEFAULT_GAIN_RAMP_FRAMES: usize = 128; + +/// 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. +#[allow(dead_code)] +struct PlaybackScheduler { + state: PlaybackState, + looping: bool, + cursor_samples: usize, + channels: usize, + ramp_frames: usize, + gain: GainRamp, + buffer: Option>, + last_frame_samples: Vec, +} + +#[allow(dead_code)] +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. + 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: vec![0.0; 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; + } + + /// Begin playback, or resume if paused. + /// + /// # Returns + /// `()` after updating the transport state. + fn play(&mut self) { + self.state = PlaybackState::Playing; + self.gain.start(1.0, self.ramp_frames); + return; + } + + /// Pause playback, preserving the current cursor. + /// + /// # Returns + /// `()` after updating the transport state. + fn pause(&mut self) { + self.state = PlaybackState::Paused; + self.gain.start(0.0, self.ramp_frames); + return; + } + + /// Stop playback and reset the cursor 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. + 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 != self.channels { + writer.clear(); + return; + } + + if self.state != PlaybackState::Playing && self.gain.is_silent() { + writer.clear(); + return; + } + + for frame_index in 0..frames { + let frame_gain = self.gain.current; + + 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 + .get(channel_index) + .copied() + .unwrap_or(0.0); + writer.set_sample(frame_index, channel_index, sample * frame_gain); + } + + self.gain.advance_frame(); + } + + return; + } +} + /// A queryable playback state for a `SoundInstance`. /// /// This state is observable from the application thread and is intended to @@ -226,3 +520,196 @@ impl AudioContext { }); } } + +#[cfg(test)] +mod tests { + use super::*; + + 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; + } +} From d9b69eed6762a683694954dc567b077ed7eb0d93 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 13:15:46 -0800 Subject: [PATCH 04/12] [add] callback-safe transport for audio playback commands. --- crates/lambda-rs/src/audio/playback.rs | 301 ++++++++++++++++++++++++- 1 file changed, 300 insertions(+), 1 deletion(-) diff --git a/crates/lambda-rs/src/audio/playback.rs b/crates/lambda-rs/src/audio/playback.rs index 903aa74c..dfed02f2 100644 --- a/crates/lambda-rs/src/audio/playback.rs +++ b/crates/lambda-rs/src/audio/playback.rs @@ -5,7 +5,17 @@ //! This module provides a minimal, backend-agnostic playback facade that //! supports one active `SoundBuffer` at a time. -use std::sync::Arc; +use std::{ + cell::UnsafeCell, + mem::MaybeUninit, + sync::{ + atomic::{ + AtomicUsize, + Ordering, + }, + Arc, + }, +}; use crate::audio::{ AudioError, @@ -15,6 +25,107 @@ use crate::audio::{ const DEFAULT_GAIN_RAMP_FRAMES: usize = 128; +/// 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). +#[allow(dead_code)] +struct CommandQueue { + buffer: [UnsafeCell>; CAPACITY], + head: AtomicUsize, + tail: AtomicUsize, +} + +unsafe impl Send for CommandQueue {} +unsafe impl Sync for CommandQueue {} + +#[allow(dead_code)] +impl CommandQueue { + /// Create a new empty queue. + /// + /// # Returns + /// A queue with a fixed capacity. + 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. + 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`. + 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; + } +} + /// A linear gain ramp used to de-click transport transitions. #[derive(Clone, Copy, Debug, PartialEq)] struct GainRamp { @@ -304,6 +415,115 @@ impl PlaybackScheduler { } } +/// Commands produced by `SoundInstance` transport operations. +#[derive(Debug)] +#[allow(dead_code)] +enum PlaybackCommand { + SetBuffer(Arc), + SetLooping(bool), + Play, + Pause, + Stop, + SetActiveInstanceId(u64), +} + +/// A callback-safe controller that drains transport commands and renders audio. +/// +/// This type is intended to be owned by the platform audio callback closure. +#[allow(dead_code)] +struct PlaybackController { + command_queue: Arc>, + scheduler: PlaybackScheduler, + active_instance_id: u64, +} + +#[allow(dead_code)] +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. + fn new( + channels: usize, + command_queue: Arc>, + ) -> Self { + return Self::new_with_ramp_frames( + channels, + DEFAULT_GAIN_RAMP_FRAMES, + command_queue, + ); + } + + /// 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. + fn new_with_ramp_frames( + channels: usize, + ramp_frames: usize, + command_queue: Arc>, + ) -> Self { + return Self { + command_queue, + scheduler: PlaybackScheduler::new_with_ramp_frames(channels, ramp_frames), + active_instance_id: 0, + }; + } + + /// 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::SetBuffer(buffer) => { + self.scheduler.set_buffer(buffer); + } + PlaybackCommand::SetLooping(looping) => { + self.scheduler.set_looping(looping); + } + PlaybackCommand::Play => { + self.scheduler.play(); + } + PlaybackCommand::Pause => { + self.scheduler.pause(); + } + PlaybackCommand::Stop => { + self.scheduler.stop(); + } + PlaybackCommand::SetActiveInstanceId(instance_id) => { + self.active_instance_id = instance_id; + } + } + } + + 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. + fn render(&mut self, writer: &mut dyn AudioOutputWriter) { + self.drain_commands(); + self.scheduler.render(writer); + return; + } +} + /// A queryable playback state for a `SoundInstance`. /// /// This state is observable from the application thread and is intended to @@ -525,6 +745,37 @@ impl AudioContext { 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_eq!(queue.pop(), 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_eq!(queue.pop(), None); + return; + } + struct TestAudioOutput { channels: u16, frames: usize, @@ -712,4 +963,52 @@ mod tests { 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 buffer = make_test_buffer(vec![0.1, 0.2], 1); + + command_queue + .push(PlaybackCommand::SetBuffer(buffer)) + .unwrap(); + command_queue + .push(PlaybackCommand::SetLooping(true)) + .unwrap(); + command_queue.push(PlaybackCommand::Play).unwrap(); + + let mut controller = + PlaybackController::new_with_ramp_frames(1, 0, command_queue); + + 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!(controller.active_instance_id, 0); + return; + } + + /// Controllers MUST update the active instance id when commanded. + #[test] + fn controller_updates_active_instance_id() { + let command_queue: Arc> = + Arc::new(CommandQueue::new()); + + command_queue + .push(PlaybackCommand::SetActiveInstanceId(42)) + .unwrap(); + + let mut controller = + PlaybackController::new_with_ramp_frames(1, 0, command_queue); + + let mut writer = TestAudioOutput::new(1, 1); + controller.render(&mut writer); + assert_eq!(controller.active_instance_id, 42); + return; + } } From fa36b760348f7b4a924220885fa88684bded03f6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 13:59:19 -0800 Subject: [PATCH 05/12] [update] audio context builder to create an audio output device and add play sound implementation. --- crates/lambda-rs/src/audio/buffer.rs | 17 +- crates/lambda-rs/src/audio/playback.rs | 471 +++++++++++++++++++++---- 2 files changed, 412 insertions(+), 76 deletions(-) diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 5455fe1e..82d5e94b 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,7 +142,7 @@ impl SoundBuffer { } return Ok(Self { - samples: decoded.samples, + samples: decoded.samples.into(), sample_rate: decoded.sample_rate, channels: decoded.channels, }); @@ -187,7 +190,7 @@ impl SoundBuffer { } return Ok(Self { - samples, + samples: samples.into(), sample_rate, channels, }); @@ -214,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. @@ -281,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, }; @@ -294,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/playback.rs b/crates/lambda-rs/src/audio/playback.rs index dfed02f2..1f97e144 100644 --- a/crates/lambda-rs/src/audio/playback.rs +++ b/crates/lambda-rs/src/audio/playback.rs @@ -10,6 +10,8 @@ use std::{ mem::MaybeUninit, sync::{ atomic::{ + AtomicU64, + AtomicU8, AtomicUsize, Ordering, }, @@ -19,11 +21,17 @@ use std::{ use crate::audio::{ AudioError, + AudioOutputDevice, + AudioOutputDeviceBuilder, AudioOutputWriter, SoundBuffer, }; 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 fixed-capacity, single-producer/single-consumer queue. /// @@ -220,7 +228,7 @@ struct PlaybackScheduler { ramp_frames: usize, gain: GainRamp, buffer: Option>, - last_frame_samples: Vec, + last_frame_samples: [f32; MAX_PLAYBACK_CHANNELS], } #[allow(dead_code)] @@ -253,7 +261,7 @@ impl PlaybackScheduler { ramp_frames, gain: GainRamp::silent(), buffer: None, - last_frame_samples: vec![0.0; channels], + last_frame_samples: [0.0; MAX_PLAYBACK_CHANNELS], }; } @@ -344,6 +352,11 @@ impl PlaybackScheduler { return; } + if writer_channels > MAX_PLAYBACK_CHANNELS { + writer.clear(); + return; + } + if writer_channels != self.channels { writer.clear(); return; @@ -400,11 +413,7 @@ impl PlaybackScheduler { } for channel_index in 0..writer_channels { - let sample = self - .last_frame_samples - .get(channel_index) - .copied() - .unwrap_or(0.0); + let sample = self.last_frame_samples[channel_index]; writer.set_sample(frame_index, channel_index, sample * frame_gain); } @@ -419,12 +428,119 @@ impl PlaybackScheduler { #[derive(Debug)] #[allow(dead_code)] enum PlaybackCommand { - SetBuffer(Arc), - SetLooping(bool), - Play, - Pause, - Stop, - SetActiveInstanceId(u64), + 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, + }, +} + +type PlaybackCommandQueue = + CommandQueue; + +/// Shared, queryable state for the active playback slot. +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`. + 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. + 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. + 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 state. + 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. + 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; + } + } } /// A callback-safe controller that drains transport commands and renders audio. @@ -433,8 +549,8 @@ enum PlaybackCommand { #[allow(dead_code)] struct PlaybackController { command_queue: Arc>, + shared_state: Arc, scheduler: PlaybackScheduler, - active_instance_id: u64, } #[allow(dead_code)] @@ -450,11 +566,13 @@ impl PlaybackController { 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, ); } @@ -471,11 +589,12 @@ impl PlaybackController { 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), - active_instance_id: 0, }; } @@ -486,23 +605,51 @@ impl PlaybackController { fn drain_commands(&mut self) { while let Some(command) = self.command_queue.pop() { match command { - PlaybackCommand::SetBuffer(buffer) => { + 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(looping) => { + PlaybackCommand::SetLooping { + instance_id, + looping, + } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } self.scheduler.set_looping(looping); } - PlaybackCommand::Play => { + 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 => { + 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 => { + PlaybackCommand::Stop { instance_id } => { + if instance_id != self.shared_state.active_instance_id() { + continue; + } self.scheduler.stop(); - } - PlaybackCommand::SetActiveInstanceId(instance_id) => { - self.active_instance_id = instance_id; + self.shared_state.set_state(PlaybackState::Stopped); } } } @@ -520,6 +667,7 @@ impl PlaybackController { fn render(&mut self, writer: &mut dyn AudioOutputWriter) { self.drain_commands(); self.scheduler.render(writer); + self.shared_state.set_state(self.scheduler.state()); return; } } @@ -540,21 +688,32 @@ pub enum PlaybackState { /// A lightweight handle controlling the active sound playback slot. /// -/// This type is a placeholder API surface used while sound playback is under -/// active development. It is expected to become fully functional in a -/// subsequent change set. +/// 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 { - state: PlaybackState, - looping: bool, + 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) { - self.state = PlaybackState::Playing; + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::Play { + instance_id: self.instance_id, + }); return; } @@ -563,7 +722,13 @@ impl SoundInstance { /// # Returns /// `()` after updating the requested transport state. pub fn pause(&mut self) { - self.state = PlaybackState::Paused; + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::Pause { + instance_id: self.instance_id, + }); return; } @@ -572,7 +737,13 @@ impl SoundInstance { /// # Returns /// `()` after updating the requested transport state. pub fn stop(&mut self) { - self.state = PlaybackState::Stopped; + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::Stop { + instance_id: self.instance_id, + }); return; } @@ -584,7 +755,14 @@ impl SoundInstance { /// # Returns /// `()` after updating the looping flag. pub fn set_looping(&mut self, looping: bool) { - self.looping = looping; + if !self.is_active() { + return; + } + + let _result = self.command_queue.push(PlaybackCommand::SetLooping { + instance_id: self.instance_id, + looping, + }); return; } @@ -593,7 +771,11 @@ impl SoundInstance { /// # Returns /// The current transport state. pub fn state(&self) -> PlaybackState { - return self.state; + if !self.is_active() { + return PlaybackState::Stopped; + } + + return self.shared_state.state(); } /// Convenience query for `state() == PlaybackState::Playing`. @@ -601,7 +783,7 @@ impl SoundInstance { /// # Returns /// `true` if the instance state is `Playing`. pub fn is_playing(&self) -> bool { - return self.state == PlaybackState::Playing; + return self.state() == PlaybackState::Playing; } /// Convenience query for `state() == PlaybackState::Paused`. @@ -609,7 +791,7 @@ impl SoundInstance { /// # Returns /// `true` if the instance state is `Paused`. pub fn is_paused(&self) -> bool { - return self.state == PlaybackState::Paused; + return self.state() == PlaybackState::Paused; } /// Convenience query for `state() == PlaybackState::Stopped`. @@ -617,7 +799,7 @@ impl SoundInstance { /// # Returns /// `true` if the instance state is `Stopped`. pub fn is_stopped(&self) -> bool { - return self.state == PlaybackState::Stopped; + return self.state() == PlaybackState::Stopped; } } @@ -627,9 +809,12 @@ impl SoundInstance { /// active development. It is expected to become fully functional in a /// subsequent change set. pub struct AudioContext { - _requested_sample_rate: Option, - _requested_channels: Option, - _label: Option, + _output_device: AudioOutputDevice, + command_queue: Arc, + shared_state: Arc, + next_instance_id: u64, + output_sample_rate: u32, + output_channels: u16, } /// Builder for creating an `AudioContext`. @@ -695,16 +880,61 @@ impl AudioContextBuilder { /// An initialized audio context handle. /// /// # Errors - /// Returns an error until sound playback is integrated with an audio output - /// device callback. + /// Returns an error if the output device cannot be initialized or if the + /// requested configuration is invalid or unsupported. pub fn build(self) -> Result { - return Err(AudioError::InvalidData { - details: format!( - "audio context playback is not implemented in this build (requested_sample_rate={:?}, requested_channels={:?}, label={:?})", - self.sample_rate, - self.channels, - self.label - ), + 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: output_device, + command_queue, + shared_state, + next_instance_id: 1, + output_sample_rate: sample_rate, + output_channels: channels, }); } } @@ -725,18 +955,92 @@ impl AudioContext { /// A lightweight `SoundInstance` handle for controlling playback. /// /// # Errors - /// Returns an error until the playback implementation is integrated with an - /// audio output device callback. + /// 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 { - return Err(AudioError::InvalidData { - details: format!( - "sound playback is not implemented in this build (buffer_sample_rate={}, buffer_channels={})", - buffer.sample_rate(), - buffer.channels() - ), + 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(), }); } } @@ -969,18 +1273,33 @@ mod tests { 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(buffer)) + .push(PlaybackCommand::SetBuffer { + instance_id: 1, + buffer, + }) .unwrap(); command_queue - .push(PlaybackCommand::SetLooping(true)) + .push(PlaybackCommand::SetLooping { + instance_id: 1, + looping: true, + }) + .unwrap(); + command_queue + .push(PlaybackCommand::Play { instance_id: 1 }) .unwrap(); - command_queue.push(PlaybackCommand::Play).unwrap(); - let mut controller = - PlaybackController::new_with_ramp_frames(1, 0, command_queue); + 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); @@ -989,26 +1308,40 @@ mod tests { 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!(controller.active_instance_id, 0); + assert_eq!(shared_state.state(), PlaybackState::Playing); return; } - /// Controllers MUST update the active instance id when commanded. + /// Controllers MUST ignore transport commands for inactive instances. #[test] - fn controller_updates_active_instance_id() { + 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::SetActiveInstanceId(42)) + .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); let mut writer = TestAudioOutput::new(1, 1); controller.render(&mut writer); - assert_eq!(controller.active_instance_id, 42); + assert!(writer.max_abs() <= f32::EPSILON); return; } } From 1b10b95349f7e274f89bc64dfc3608350ed1caa5 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 15:35:45 -0800 Subject: [PATCH 06/12] [add] sound playback demo. --- .../audio/src/bin/sound_playback_transport.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 demos/audio/src/bin/sound_playback_transport.rs 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; +} From 15421ddd34d078699951c020755df4ca413ab73a Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 15:35:56 -0800 Subject: [PATCH 07/12] [update] features doc. --- docs/features.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 From 13f8dc6f343b43c1646d21a59e5545ecaa7240a4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 10 Feb 2026 16:33:48 -0800 Subject: [PATCH 08/12] [fix] warning. --- crates/lambda-rs/src/audio/buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 82d5e94b..f5188f73 100644 --- a/crates/lambda-rs/src/audio/buffer.rs +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -179,7 +179,7 @@ impl SoundBuffer { }); } - if samples.len() % channels as usize != 0 { + 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={})", From 09c19ccf7419ddf4dc7e24e67db6db0e72e7fef4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 12 Feb 2026 13:31:22 -0800 Subject: [PATCH 09/12] [add] tests for inactive instances, update comments, and fix dead code placement. --- crates/lambda-rs/src/audio/playback.rs | 52 ++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/lambda-rs/src/audio/playback.rs b/crates/lambda-rs/src/audio/playback.rs index 1f97e144..114d2654 100644 --- a/crates/lambda-rs/src/audio/playback.rs +++ b/crates/lambda-rs/src/audio/playback.rs @@ -42,7 +42,6 @@ const PLAYBACK_COMMAND_CAPACITY: usize = 256; /// # Safety /// This type is only sound when used as SPSC (exactly one producer thread and /// one consumer thread). -#[allow(dead_code)] struct CommandQueue { buffer: [UnsafeCell>; CAPACITY], head: AtomicUsize, @@ -52,7 +51,6 @@ struct CommandQueue { unsafe impl Send for CommandQueue {} unsafe impl Sync for CommandQueue {} -#[allow(dead_code)] impl CommandQueue { /// Create a new empty queue. /// @@ -219,7 +217,6 @@ impl GainRamp { /// /// This scheduler is designed to run inside a real-time audio callback and /// MUST NOT allocate or block while rendering audio. -#[allow(dead_code)] struct PlaybackScheduler { state: PlaybackState, looping: bool, @@ -231,7 +228,6 @@ struct PlaybackScheduler { last_frame_samples: [f32; MAX_PLAYBACK_CHANNELS], } -#[allow(dead_code)] impl PlaybackScheduler { /// Create a scheduler configured for a fixed output channel count. /// @@ -240,6 +236,7 @@ impl PlaybackScheduler { /// /// # 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); } @@ -333,6 +330,7 @@ impl PlaybackScheduler { /// /// # Returns /// The cursor position as an interleaved sample index. + #[allow(dead_code)] fn cursor_samples(&self) -> usize { return self.cursor_samples; } @@ -426,7 +424,6 @@ impl PlaybackScheduler { /// Commands produced by `SoundInstance` transport operations. #[derive(Debug)] -#[allow(dead_code)] enum PlaybackCommand { StopCurrent, SetBuffer { @@ -546,14 +543,12 @@ fn playback_state_from_u8(value: u8) -> PlaybackState { /// A callback-safe controller that drains transport commands and renders audio. /// /// This type is intended to be owned by the platform audio callback closure. -#[allow(dead_code)] struct PlaybackController { command_queue: Arc>, shared_state: Arc, scheduler: PlaybackScheduler, } -#[allow(dead_code)] impl PlaybackController { /// Create a controller configured for a fixed output channel count. /// @@ -563,6 +558,7 @@ impl PlaybackController { /// /// # Returns /// A controller initialized to `Stopped` with no active buffer. + #[allow(dead_code)] fn new( channels: usize, command_queue: Arc>, @@ -804,10 +800,6 @@ impl SoundInstance { } /// A playback context owning an output device and one active playback slot. -/// -/// This type is a placeholder API surface used while sound playback is under -/// active development. It is expected to become fully functional in a -/// subsequent change set. pub struct AudioContext { _output_device: AudioOutputDevice, command_queue: Arc, @@ -1344,4 +1336,42 @@ mod tests { assert!(writer.max_abs() <= f32::EPSILON); 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; + } } From ec2f8fbd06ff5125f93c9632c24dcb2bd98ca04e Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 12 Feb 2026 14:02:49 -0800 Subject: [PATCH 10/12] [refactor] implementation --- .../{playback.rs => playback/callback.rs} | 726 +----------------- .../lambda-rs/src/audio/playback/context.rs | 418 ++++++++++ crates/lambda-rs/src/audio/playback/mod.rs | 44 ++ .../lambda-rs/src/audio/playback/transport.rs | 272 +++++++ 4 files changed, 764 insertions(+), 696 deletions(-) rename crates/lambda-rs/src/audio/{playback.rs => playback/callback.rs} (51%) create mode 100644 crates/lambda-rs/src/audio/playback/context.rs create mode 100644 crates/lambda-rs/src/audio/playback/mod.rs create mode 100644 crates/lambda-rs/src/audio/playback/transport.rs diff --git a/crates/lambda-rs/src/audio/playback.rs b/crates/lambda-rs/src/audio/playback/callback.rs similarity index 51% rename from crates/lambda-rs/src/audio/playback.rs rename to crates/lambda-rs/src/audio/playback/callback.rs index 114d2654..f186e734 100644 --- a/crates/lambda-rs/src/audio/playback.rs +++ b/crates/lambda-rs/src/audio/playback/callback.rs @@ -1,137 +1,18 @@ -#![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. - -use std::{ - cell::UnsafeCell, - mem::MaybeUninit, - sync::{ - atomic::{ - AtomicU64, - AtomicU8, - AtomicUsize, - Ordering, - }, - Arc, - }, +use std::sync::Arc; + +use super::{ + CommandQueue, + PlaybackCommand, + PlaybackSharedState, + PlaybackState, + DEFAULT_GAIN_RAMP_FRAMES, + MAX_PLAYBACK_CHANNELS, }; - use crate::audio::{ - AudioError, - AudioOutputDevice, - AudioOutputDeviceBuilder, AudioOutputWriter, SoundBuffer, }; -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 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). -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. - 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. - 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`. - 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; - } -} - /// A linear gain ramp used to de-click transport transitions. #[derive(Clone, Copy, Debug, PartialEq)] struct GainRamp { @@ -287,27 +168,35 @@ impl PlaybackScheduler { return; } - /// Begin playback, or resume if paused. + /// 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; } - /// Pause playback, preserving the current cursor. + /// 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 the cursor to the start. + /// Stop playback and reset position to the start. /// /// # Returns /// `()` after updating the transport state. @@ -360,14 +249,14 @@ impl PlaybackScheduler { return; } - if self.state != PlaybackState::Playing && self.gain.is_silent() { - 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 { @@ -422,128 +311,10 @@ impl PlaybackScheduler { } } -/// Commands produced by `SoundInstance` transport operations. -#[derive(Debug)] -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, - }, -} - -type PlaybackCommandQueue = - CommandQueue; - -/// Shared, queryable state for the active playback slot. -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`. - 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. - 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. - 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 state. - 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. - 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; - } - } -} - /// A callback-safe controller that drains transport commands and renders audio. /// /// This type is intended to be owned by the platform audio callback closure. -struct PlaybackController { +pub(super) struct PlaybackController { command_queue: Arc>, shared_state: Arc, scheduler: PlaybackScheduler, @@ -559,7 +330,7 @@ impl PlaybackController { /// # Returns /// A controller initialized to `Stopped` with no active buffer. #[allow(dead_code)] - fn new( + pub(super) fn new( channels: usize, command_queue: Arc>, shared_state: Arc, @@ -581,7 +352,7 @@ impl PlaybackController { /// /// # Returns /// A controller initialized to `Stopped` with no active buffer. - fn new_with_ramp_frames( + pub(super) fn new_with_ramp_frames( channels: usize, ramp_frames: usize, command_queue: Arc>, @@ -660,7 +431,7 @@ impl PlaybackController { /// /// # Returns /// `()` after draining commands and writing the output buffer. - fn render(&mut self, writer: &mut dyn AudioOutputWriter) { + 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()); @@ -668,409 +439,10 @@ impl PlaybackController { } } -/// 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, -} - -/// 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: AudioOutputDevice, - 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: 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::*; - - /// 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_eq!(queue.pop(), 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_eq!(queue.pop(), None); - return; - } + use crate::audio::SoundBuffer; struct TestAudioOutput { channels: u16, @@ -1336,42 +708,4 @@ mod tests { assert!(writer.max_abs() <= f32::EPSILON); 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; - } } 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..d9891432 --- /dev/null +++ b/crates/lambda-rs/src/audio/playback/context.rs @@ -0,0 +1,418 @@ +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: AudioOutputDevice, + 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: 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::*; + + /// `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; + } +} 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; + } +} From 17827d6ac0f56b047b1745b8efa7ded56d415989 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 12 Feb 2026 14:13:44 -0800 Subject: [PATCH 11/12] [add] more tests for sound instance and audio context builder. --- .../lambda-rs/src/audio/playback/context.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/lambda-rs/src/audio/playback/context.rs b/crates/lambda-rs/src/audio/playback/context.rs index d9891432..228cd6ae 100644 --- a/crates/lambda-rs/src/audio/playback/context.rs +++ b/crates/lambda-rs/src/audio/playback/context.rs @@ -415,4 +415,112 @@ mod tests { 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; + } } From 4e8bb5c27026d93622a5b34313872b01d425559e Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 12 Feb 2026 14:40:21 -0800 Subject: [PATCH 12/12] [add] more tests. --- .../lambda-rs/src/audio/playback/context.rs | 192 +++++++++++++++++- 1 file changed, 190 insertions(+), 2 deletions(-) diff --git a/crates/lambda-rs/src/audio/playback/context.rs b/crates/lambda-rs/src/audio/playback/context.rs index 228cd6ae..0ba59615 100644 --- a/crates/lambda-rs/src/audio/playback/context.rs +++ b/crates/lambda-rs/src/audio/playback/context.rs @@ -138,7 +138,7 @@ impl SoundInstance { /// A playback context owning an output device and one active playback slot. pub struct AudioContext { - _output_device: AudioOutputDevice, + _output_device: Option, command_queue: Arc, shared_state: Arc, next_instance_id: u64, @@ -258,7 +258,7 @@ impl AudioContextBuilder { )?; return Ok(AudioContext { - _output_device: output_device, + _output_device: Some(output_device), command_queue, shared_state, next_instance_id: 1, @@ -378,6 +378,37 @@ impl AudioContext { 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() { @@ -523,4 +554,161 @@ mod tests { 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; + } }