From 65cbbeeab57b1d21b616ee7e4bd26a8c5498ece9 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 06:09:42 -0800 Subject: [PATCH 01/17] [add] initial specification for adding audio to lambda. --- docs/specs/audio-devices.md | 684 ++++++++++++++++++++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 docs/specs/audio-devices.md diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md new file mode 100644 index 00000000..cc23f492 --- /dev/null +++ b/docs/specs/audio-devices.md @@ -0,0 +1,684 @@ +--- +title: "Audio Device Abstraction" +document_id: "audio-device-abstraction-2026-01-28" +status: "draft" +created: "2026-01-28T22:59:00Z" +version: "0.1.7" +last_updated: "2026-01-29T04:49:53Z" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "d1d13296951f9b1f34acceced52591c5915215c9" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] +--- + +# Audio Device Abstraction + +## Table of Contents + +- [Summary](#summary) +- [Scope](#scope) +- [Terminology](#terminology) +- [Architecture Overview](#architecture-overview) +- [Design](#design) + - [API Surface](#api-surface) + - [lambda-rs Public API](#lambda-rs-public-api) + - [Application Interaction](#application-interaction) + - [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 an application-facing audio output device API in `lambda-rs` that can + enumerate output devices and initialize the default output device. +- Implement the audio backend behind `lambda-rs-platform` so `lambda-rs` can + remain backend-agnostic and avoid leaking platform/vendor types. +- Establish a builder-based public API (`AudioOutputDeviceBuilder`) consistent + with existing `lambda-rs` patterns. +- Use `cpal` as the cross-platform audio backend while preventing platform and + vendor details from leaking into the `lambda-rs` public API. + +## Scope + +### Goals + +- Provide a `lambda-rs` audio output device handle (`AudioOutputDevice`) for + application use. +- Provide `enumerate_output_devices` to enumerate available audio output + devices. +- Provide `AudioOutputDeviceBuilder` to initialize a default audio output + device with configurable sample rate and channel count. +- Provide a minimal output callback mechanism sufficient to validate audible + playback via a deterministic example (test tone generation). +- Provide actionable error reporting for device discovery and initialization. +- Support Windows, macOS, and Linux. +- Provide a `lambda-rs-platform` implementation layer that supplies the + backend-specific device and stream behavior required by `lambda-rs`. + +### Non-Goals + +- Audio input/recording. +- High-level sound playback systems (asset decoding, mixing, scheduling). +- Audio effects processing (DSP, filters, spatialization). + +## Terminology + +- Audio output device: an operating-system audio endpoint capable of playback. +- Stream: a running audio callback driving audio samples to an output device. +- Sample rate: audio frames per second (for example, 48_000 Hz). +- Channels: the number of interleaved output channels (for example, 2). +- Sample format: the primitive sample representation used by a stream callback + (for example, `f32`). + +## Architecture Overview + +- Crate `lambda-rs` + - `audio` module provides the application-facing API for output device access. + - The public API MUST remain backend-agnostic and MUST NOT expose `cpal` or + `lambda-rs-platform` types. +- Crate `lambda-rs-platform` + - `cpal` module provides internal implementations used by `lambda-rs` + implementations. + - `cpal::device` wraps `cpal` device discovery and stream creation. + +Data flow + +``` +application + └── lambda-rs::audio + ├── enumerate_output_devices() -> Vec + └── AudioOutputDeviceBuilder::build() -> AudioOutputDevice + └── lambda-rs-platform::cpal (internal) + ├── enumerate_devices() -> Vec + └── AudioDeviceBuilder::build() -> AudioDevice + └── cpal (host + device + stream) + └── OS audio backend (CoreAudio/WASAPI/ALSA/Pulse/JACK) +``` + +## Design + +### API Surface + +This section describes the platform layer surface used by `lambda-rs` +implementations. Applications MUST NOT depend on `lambda-rs-platform` or use +its audio APIs directly. + +Module layout + +- `crates/lambda-rs-platform/src/cpal/mod.rs` + - Re-exports `AudioDevice`, `AudioDeviceBuilder`, `AudioDeviceInfo`, + `AudioError`, and `enumerate_devices`. +- `crates/lambda-rs-platform/src/cpal/device.rs` + - Defines `AudioDevice`, `AudioDeviceBuilder`, `AudioDeviceInfo`, + `AudioError`, and `enumerate_devices`. + +Public API + +```rust +// crates/lambda-rs-platform/src/cpal/device.rs + +/// Output sample format used by the platform stream callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioSampleFormat { + F32, + I16, + U16, +} + +/// Information available to audio output callbacks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AudioCallbackInfo { + pub sample_rate: u32, + pub channels: u16, + pub sample_format: AudioSampleFormat, +} + +/// Real-time writer for audio output buffers. +/// +/// This trait MUST be implemented without allocation and MUST write into the +/// underlying device output buffer for the current callback invocation. +pub trait AudioOutputWriter { + fn channels(&self) -> u16; + fn frames(&self) -> usize; + fn clear(&mut self); + + /// Write a normalized sample in the range `[-1.0, 1.0]`. + /// + /// Implementations MUST clamp values outside `[-1.0, 1.0]`. + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ); +} + +/// Metadata describing an available audio output device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioDeviceInfo { + /// Human-readable device name. + pub name: String, + /// Whether this device is the current default output device. + pub is_default: bool, +} + +/// An initialized audio output device. +/// +/// This type is an opaque platform wrapper. It MUST NOT expose `cpal` types. +pub struct AudioDevice { + /* platform handle + chosen output configuration */ +} + +/// Builder for creating an `AudioDevice`. +#[derive(Debug, Clone)] +pub struct AudioDeviceBuilder { + sample_rate: Option, + channels: Option, + label: Option, +} + +impl AudioDeviceBuilder { + /// Create a builder with engine defaults. + pub fn new() -> Self; + + /// Request a specific sample rate (Hz). + pub fn with_sample_rate(self, rate: u32) -> Self; + + /// Request a specific channel count. + pub fn with_channels(self, channels: u16) -> Self; + + /// Attach a label for diagnostics. + pub fn with_label(self, label: &str) -> Self; + + /// Initialize the default audio output device using the requested + /// configuration. + pub fn build(self) -> Result; + + /// Initialize the default audio output device and play audio via a callback. + pub fn build_with_output_callback( + self, + callback: Callback, + ) -> Result + where + Callback: 'static + Send + FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo); +} + +/// Enumerate available audio output devices. +pub fn enumerate_devices() -> Result, AudioError>; +``` + +### lambda-rs Public API + +`lambda-rs` provides the application-facing audio API and translates to +`lambda-rs-platform::cpal` internally. The `lambda-rs` layer MUST remain +backend-agnostic and MUST NOT expose `cpal` types. + +Crate boundary + +- Applications MUST use `lambda-rs` for audio and MUST NOT use + `lambda-rs-platform` directly. +- `lambda-rs-platform` audio APIs are internal implementation details and MAY + change without regard for application compatibility. +- `lambda-rs::audio` MUST remain backend-agnostic and MUST NOT require direct + use of `lambda-rs-platform` types by applications. + +Application-facing API surface + +```rust +// crates/lambda-rs/src/audio.rs + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioSampleFormat { + F32, + I16, + U16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AudioCallbackInfo { + pub sample_rate: u32, + pub channels: u16, + pub sample_format: AudioSampleFormat, +} + +#[derive(Clone, Debug)] +pub enum AudioError { + InvalidSampleRate { requested: u32 }, + InvalidChannels { requested: u16 }, + NoDefaultDevice, + UnsupportedConfig { + requested_sample_rate: Option, + requested_channels: Option, + }, + UnsupportedSampleFormat { details: String }, + Platform { details: String }, +} + +/// Metadata describing an available audio output device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioOutputDeviceInfo { + pub name: String, + pub is_default: bool, +} + +pub trait AudioOutputWriter { + fn channels(&self) -> u16; + fn frames(&self) -> usize; + fn clear(&mut self); + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ); +} + +pub struct AudioOutputDevice { + /* opaque platform handle + stream lifetime */ +} + +#[derive(Debug, Clone)] +pub struct AudioOutputDeviceBuilder { + sample_rate: Option, + channels: Option, + label: Option, +} + +impl AudioOutputDeviceBuilder { + 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; + + pub fn build_with_output_callback( + self, + callback: Callback, + ) -> Result + where + Callback: 'static + Send + FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo); +} + +/// Enumerate available audio output devices via the platform layer. +pub fn enumerate_output_devices( +) -> Result, AudioError>; +``` + +Implementation rules + +- `lambda-rs::audio` MUST translate into `lambda-rs-platform::cpal` internally. +- `lambda-rs::audio` MUST define its own public types and MUST NOT re-export + `lambda-rs-platform` audio types. +- `lambda-rs::audio::AudioError` MUST remain backend-agnostic and MUST NOT + expose `cpal` types. + +Features + +- `lambda-rs` granular feature: `audio-output-device` (default: disabled) + - Enables the `lambda-rs::audio` output device surface. + - Enables `lambda-rs-platform` `audio-device` internally. +- `lambda-rs` umbrella feature: `audio` (default: disabled) + - Composes `audio-output-device` only. + +### Application Interaction + +This section describes the intended application-facing workflow via +`lambda-rs::audio`. + +Initialization flow + +- An application SHOULD enumerate devices to present names in diagnostics or a + settings UI. +- An application SHOULD create exactly one default output device during + startup. +- The application MUST keep the returned device handle alive for as long as + audio output is required. Dropping the device MUST stop output. + +Device enumeration + +```rust +let devices = lambda_rs::audio::enumerate_output_devices()?; +for device in devices { + println!( + "audio: {}{}", + device.name, + if device.is_default { " (default)" } else { "" } + ); +} +``` + +Default device initialization (deterministic test tone) + +```rust +let mut phase: f32 = 0.0; +let frequency_hz: f32 = 440.0; + +let _audio_output = lambda_rs::audio::AudioOutputDeviceBuilder::new() + .with_sample_rate(48_000) + .with_channels(2) + .build_with_output_callback(move |writer, info| { + let channels = info.channels as usize; + let frames = writer.frames(); + let phase_step = 2.0 * std::f32::consts::PI * frequency_hz + / info.sample_rate as f32; + + for frame_index in 0..frames { + let sample = phase.sin() * 0.10; + phase += phase_step; + + for channel_index in 0..channels { + writer.set_sample(frame_index, channel_index, sample); + } + } + })?; +``` + +Runtime interaction + +- An application using `ApplicationRuntime` SHOULD create and store the audio + device handle in the `main` scope before calling `start_runtime`, or store it + in application-owned state that outlives the runtime event loop. +- `lambda-rs` components SHOULD NOT rely on blocking operations or locks in + the audio callback. + +Minimal application sketch + +```rust +use lambda_rs::runtime::start_runtime; +use lambda_rs::runtimes::ApplicationRuntimeBuilder; + +fn main() -> Result<(), lambda_rs::audio::AudioError> { + let _audio_output = lambda_rs::audio::AudioOutputDeviceBuilder::new() + .build()?; + + let runtime = ApplicationRuntimeBuilder::new("Lambda App").build(); + start_runtime(runtime); + return Ok(()); +} +``` + +### Behavior + +Device enumeration (`lambda_rs::audio::enumerate_output_devices`) + +- `enumerate_output_devices` MUST return only output-capable devices. +- `enumerate_output_devices` MUST include the default output device when one + exists. +- `AudioOutputDeviceInfo::is_default` MUST be `true` only for the device that + matches the current default output device at the time of enumeration. +- `enumerate_output_devices` MUST NOT panic. + +Default device initialization (`AudioOutputDeviceBuilder::build`) + +- `build` MUST select the operating-system default output device. +- If no default output device exists, `build` MUST return `AudioError::NoDefaultDevice`. +- `build` MUST validate the requested configuration against the device’s + supported output configurations. +- When `sample_rate` is not specified, `build` MUST prefer 48_000 Hz when + supported and otherwise fall back to the device default configuration. +- When `channels` is not specified, `build` MUST prefer stereo (`2`) when + supported and otherwise fall back to the device default configuration. +- `build` MUST create an output stream that produces silence (all samples set + to zero) and MUST keep the stream alive for the lifetime of + `AudioOutputDevice`. + +Default device initialization with output callback + +- `build_with_output_callback` MUST create an output stream that invokes the + callback to fill the output buffer. +- The output callback MUST write samples using `AudioOutputWriter`. +- `AudioOutputWriter` MUST write into an interleaved output buffer. +- `build_with_output_callback` MUST keep the stream alive for the lifetime of + `AudioOutputDevice`. +- The callback MUST be invoked on a real-time audio thread and MUST be treated + as real-time code. + +Output writer semantics + +- `AudioOutputWriter::frames` MUST return the number of frames in the output + buffer for the current callback invocation. +- `AudioOutputWriter::clear` MUST set the entire output buffer to silence: + - `AudioSampleFormat::F32`: `0.0` + - `AudioSampleFormat::I16`: `0` + - `AudioSampleFormat::U16`: `32768` +- `AudioOutputWriter::set_sample` MUST treat the provided `sample` as a + normalized value in the range `[-1.0, 1.0]`. +- Implementations MUST clamp values outside `[-1.0, 1.0]`. +- Sample conversion MUST follow these rules: + - `AudioSampleFormat::F32`: write the clamped value directly. + - `AudioSampleFormat::I16`: map `[-1.0, 1.0]` to `[-32767, 32767]` and write. + - `AudioSampleFormat::U16`: map `[-1.0, 1.0]` to `[0, 65535]` where `0.0` + maps to `32768`. +- `set_sample` MUST NOT panic on out-of-range indices. It MUST perform no write + for out-of-range indices and SHOULD use `debug_assertions` diagnostics. + +Configuration selection rules + +- When `sample_rate` is specified, `build` MUST select a supported output + configuration whose sample-rate range contains the requested value. +- When `channels` is specified, `build` MUST select a supported output + configuration whose channel count equals the requested value. +- When multiple configurations satisfy the request, `build` SHOULD choose the + configuration with a sample format that is most widely supported by backends + (for example, `f32`) and SHOULD prefer 48_000 Hz when tied. +- If no configuration satisfies the request, `build` MUST return + `AudioError::UnsupportedConfig`. +- `build_with_output_callback` MUST select a supported stream sample format + and expose it via `AudioCallbackInfo::sample_format`. +- If the selected stream format is not one of `AudioSampleFormat::{F32, I16, U16}`, + `build_with_output_callback` MUST return `AudioError::UnsupportedSampleFormat`. + +### Validation and Errors + +Error type + +- `lambda-rs` MUST define an `AudioError` error enum suitable for actionable + diagnostics. +- `lambda-rs::audio::AudioError` MUST remain backend-agnostic and MUST NOT + expose `cpal` or `lambda-rs-platform` types. +- `lambda-rs-platform` MUST define an internal `AudioError` suitable for + actionable diagnostics inside the platform layer. +- `lambda-rs-platform::cpal::AudioError` MUST NOT expose `cpal` types in its + public API. +- `lambda-rs` MUST translate `lambda-rs-platform::cpal::AudioError` into + `lambda-rs::audio::AudioError`. Backend-specific failures SHOULD map to + `AudioError::Platform { details }`. + +Platform `AudioError` variants (internal) + +- `InvalidSampleRate { requested: u32 }` +- `InvalidChannels { requested: u16 }` +- `HostUnavailable { details: String }` +- `NoDefaultDevice` +- `DeviceNameUnavailable { details: String }` +- `DeviceEnumerationFailed { details: String }` +- `SupportedConfigsUnavailable { details: String }` +- `UnsupportedConfig { requested_sample_rate: Option, requested_channels: Option }` +- `UnsupportedSampleFormat { details: String }` +- `StreamBuildFailed { details: String }` +- `StreamPlayFailed { details: String }` + +Validation rules + +- `AudioOutputDeviceBuilder::build` MUST return `AudioError::InvalidSampleRate` + when `sample_rate == Some(0)`. +- `AudioOutputDeviceBuilder::build` MUST return `AudioError::InvalidChannels` + when `channels == Some(0)`. + +### Cargo Features + +Features introduced by this spec + +- Crate: `lambda-rs` + - Granular feature: `audio-output-device` (default: disabled) + - Enables `lambda-rs::audio` output device APIs. + - Enables `lambda-rs-platform` `audio-device` internally. + - Umbrella feature: `audio` (default: disabled) + - Composes `audio-output-device` only. +- Crate: `lambda-rs-platform` + - Granular feature: `audio-device` (default: disabled) + - Enables the `cpal` module and the `AudioDevice`/`AudioDeviceBuilder` + surface. + - Enables the `cpal` dependency as an internal implementation detail. + - Umbrella feature: `audio` (default: disabled) + - Composes `audio-device` only. + +Feature gating requirements + +- `lambda-rs` MUST gate all application-facing audio output behavior behind + `audio-output-device`. +- `lambda-rs-platform` MUST gate all `cpal` usage behind `audio-device`. +- The `audio` umbrella feature MUST NOT be used to gate behavior in code; it + MUST only compose granular audio features. + +## Constraints and Rules + +- `AudioOutputDevice` MUST NOT expose platform backends or vendor details through + public types, fields, or feature names. +- `AudioOutputDevice` MUST maintain ownership of the output stream such that + dropping `AudioOutputDevice` stops the stream. +- The stream callback MUST be real-time safe: + - It MUST NOT allocate. + - It MUST NOT lock unbounded mutexes. + - It MUST NOT perform I/O. +- User-provided output callbacks MUST follow the same real-time safety rules as + the stream callback. + +## Performance Considerations + +- Recommendations + - The silent stream callback SHOULD write zeros using a tight loop over the + output buffer. + - Rationale: avoids allocations and minimizes CPU overhead. + - Device enumeration SHOULD avoid per-device expensive probing beyond device + name and default-device matching. + - Rationale: enumeration may be called during initialization and should not + stall startup. + +## Requirements Checklist + +- Functionality + - [ ] Feature flags defined (`lambda-rs`: `audio-output-device`, `audio`) + - [ ] Feature flags defined (`lambda-rs-platform`: `audio-device`, `audio`) + - [ ] `enumerate_output_devices` implemented and returns output devices + - [ ] `AudioOutputDeviceBuilder::build` initializes default output device + - [ ] `AudioOutputDeviceBuilder::build_with_output_callback` invokes callback + - [ ] Stream created and kept alive for `AudioOutputDevice` lifetime + - [ ] Platform enumeration implemented (`lambda-rs-platform::cpal`) + - [ ] Platform builder implemented (`lambda-rs-platform::cpal`) +- API Surface + - [ ] Public `lambda-rs` types implemented: `AudioOutputDevice`, + `AudioOutputDeviceInfo`, `AudioOutputDeviceBuilder`, `AudioCallbackInfo`, + `AudioOutputWriter`, `AudioError` + - [ ] Internal platform types implemented: `AudioDevice`, `AudioDeviceInfo`, + `AudioDeviceBuilder`, `AudioCallbackInfo`, `AudioOutputWriter`, `AudioError` + - [ ] `lambda-rs::audio` does not re-export `lambda-rs-platform` types +- Validation and Errors + - [ ] Invalid builder inputs rejected (sample rate and channel count) + - [ ] Descriptive `AudioError` variants emitted on failures + - [ ] Unsupported configurations reported via `AudioError::UnsupportedConfig` +- Documentation and Examples + - [ ] `docs/features.md` updated with audio feature documentation + - [ ] Example added demonstrating audible playback (behind `audio-device`) + - [ ] `lambda-rs` audio facade and examples implemented + +For each checked item, include a reference to a commit, pull request, or file +path that demonstrates the implementation. + +## Verification and Testing + +Unit tests (crate: `lambda-rs-platform`) + +- Builder defaults + - `AudioDeviceBuilder::new` sets `sample_rate` and `channels` to `None`. + - `with_sample_rate` and `with_channels` override requested values. + - Invalid values (`0`) are rejected. +- Enumeration + - `enumerate_devices` returns `Ok(_)` and does not panic. + +Commands + +- `cargo test -p lambda-rs-platform --features audio-device -- --nocapture` + +Manual checks + +- Run a minimal smoke executable (example or integration runnable) that: + - Prints `enumerate_devices` output. + - Calls `AudioDeviceBuilder::new().build_with_output_callback(...)` and + plays a deterministic test tone for at least 2 seconds. + +Example (platform layer) + +This example is workspace-internal and exists to validate the platform layer in +isolation. Applications MUST use the `lambda-rs` facade instead. + +- Add `crates/lambda-rs-platform/examples/audio_sine_wave.rs` (feature: + `audio-device`) that: + - Prints `enumerate_devices()` output. + - Builds the default output device with a 440 Hz sine wave generator via + `build_with_output_callback`. + - Plays for 2 seconds and exits. + +Example sketch + +```rust +let mut phase: f32 = 0.0; +let frequency_hz: f32 = 440.0; + +let _device = AudioDeviceBuilder::new() + .with_sample_rate(48_000) + .with_channels(2) + .build_with_output_callback(move |writer, info| { + let channels = info.channels as usize; + let frames = writer.frames(); + let phase_step = 2.0 * std::f32::consts::PI * frequency_hz + / info.sample_rate as f32; + + for frame_index in 0..frames { + let sample = phase.sin() * 0.10; + phase += phase_step; + + for channel_index in 0..channels { + writer.set_sample(frame_index, channel_index, sample); + } + } + })?; +``` + +Example (lambda-rs facade) + +- Add `crates/lambda-rs/examples/audio_sine_wave.rs` (feature: + `audio-output-device`) that: + - Prints `lambda_rs::audio::enumerate_output_devices()` output. + - Builds the default output device via the facade builder and plays the same + deterministic tone. + +## Compatibility and Migration + +- None. No existing audio APIs exist in the workspace. + +## Changelog + +- 2026-01-29 (v0.1.7) — Rename the platform audio implementation module to + `lambda-rs-platform::cpal` to reflect the internal backend. +- 2026-01-29 (v0.1.6) — Specify `lambda-rs` as the only supported + application-facing API and treat `lambda-rs-platform` as internal. +- 2026-01-29 (v0.1.5) — Specify how `lambda-rs` applications enumerate devices + and initialize the default output device. +- 2026-01-29 (v0.1.4) — Refine specification language and define output writer + conversion semantics. +- 2026-01-29 (v0.1.3) — Refine callback API language and specify `AudioOutputWriter`. +- 2026-01-28 (v0.1.2) — Specify `f32` callback constraints and add a minimal + playback sketch. +- 2026-01-28 (v0.1.1) — Add `lambda-rs` exposure and playback example sections. +- 2026-01-28 (v0.1.0) — Initial draft. From e8944565ebba497ec59a72bdfdb855a97f41a666 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 12:53:05 -0800 Subject: [PATCH 02/17] [update] specification. --- docs/specs/audio-devices.md | 73 ++++++++++--------------------------- 1 file changed, 20 insertions(+), 53 deletions(-) diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index cc23f492..7b2c03a4 100644 --- a/docs/specs/audio-devices.md +++ b/docs/specs/audio-devices.md @@ -4,7 +4,8 @@ document_id: "audio-device-abstraction-2026-01-28" status: "draft" created: "2026-01-28T22:59:00Z" version: "0.1.7" -last_updated: "2026-01-29T04:49:53Z" +version: "0.1.8" +last_updated: "2026-01-29T20:40:49Z" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" @@ -589,14 +590,24 @@ Feature gating requirements - [ ] Unsupported configurations reported via `AudioError::UnsupportedConfig` - Documentation and Examples - [ ] `docs/features.md` updated with audio feature documentation - - [ ] Example added demonstrating audible playback (behind `audio-device`) - - [ ] `lambda-rs` audio facade and examples implemented + - [ ] Example added demonstrating audible playback (behind `audio-output-device`) + - [ ] `lambda-rs` audio facade implemented For each checked item, include a reference to a commit, pull request, or file path that demonstrates the implementation. ## Verification and Testing +Example (lambda-rs facade) + +This example is the primary application-facing reference. + +- Add `crates/lambda-rs/examples/audio_sine_wave.rs` (feature: + `audio-output-device`) that: + - Prints `lambda_rs::audio::enumerate_output_devices()` output. + - Builds the default output device via the facade builder and plays a + deterministic 440 Hz tone for at least 2 seconds. + Unit tests (crate: `lambda-rs-platform`) - Builder defaults @@ -608,60 +619,14 @@ Unit tests (crate: `lambda-rs-platform`) Commands +- `cargo test -p lambda-rs --features audio-output-device -- --nocapture` - `cargo test -p lambda-rs-platform --features audio-device -- --nocapture` Manual checks -- Run a minimal smoke executable (example or integration runnable) that: - - Prints `enumerate_devices` output. - - Calls `AudioDeviceBuilder::new().build_with_output_callback(...)` and - plays a deterministic test tone for at least 2 seconds. - -Example (platform layer) - -This example is workspace-internal and exists to validate the platform layer in -isolation. Applications MUST use the `lambda-rs` facade instead. - -- Add `crates/lambda-rs-platform/examples/audio_sine_wave.rs` (feature: - `audio-device`) that: - - Prints `enumerate_devices()` output. - - Builds the default output device with a 440 Hz sine wave generator via - `build_with_output_callback`. - - Plays for 2 seconds and exits. - -Example sketch - -```rust -let mut phase: f32 = 0.0; -let frequency_hz: f32 = 440.0; - -let _device = AudioDeviceBuilder::new() - .with_sample_rate(48_000) - .with_channels(2) - .build_with_output_callback(move |writer, info| { - let channels = info.channels as usize; - let frames = writer.frames(); - let phase_step = 2.0 * std::f32::consts::PI * frequency_hz - / info.sample_rate as f32; - - for frame_index in 0..frames { - let sample = phase.sin() * 0.10; - phase += phase_step; - - for channel_index in 0..channels { - writer.set_sample(frame_index, channel_index, sample); - } - } - })?; -``` - -Example (lambda-rs facade) - -- Add `crates/lambda-rs/examples/audio_sine_wave.rs` (feature: - `audio-output-device`) that: - - Prints `lambda_rs::audio::enumerate_output_devices()` output. - - Builds the default output device via the facade builder and plays the same - deterministic tone. +- Run the `lambda-rs` facade example and confirm audible playback for at least + 2 seconds. + - `cargo run -p lambda-rs --example audio_sine_wave --features audio-output-device` ## Compatibility and Migration @@ -669,6 +634,8 @@ Example (lambda-rs facade) ## Changelog +- 2026-01-29 (v0.1.8) — Make the `lambda-rs` facade example the primary + reference and remove the platform example requirement. - 2026-01-29 (v0.1.7) — Rename the platform audio implementation module to `lambda-rs-platform::cpal` to reflect the internal backend. - 2026-01-29 (v0.1.6) — Specify `lambda-rs` as the only supported From 4681477c3ce968c13a3715dfab47c67e263770b5 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 14:09:35 -0800 Subject: [PATCH 03/17] [add] initial scaffolding. --- crates/lambda-rs-platform/Cargo.toml | 8 ++++++++ crates/lambda-rs-platform/src/cpal/device.rs | 1 + crates/lambda-rs-platform/src/cpal/mod.rs | 3 +++ crates/lambda-rs-platform/src/lib.rs | 3 +++ crates/lambda-rs/Cargo.toml | 8 ++++++++ crates/lambda-rs/src/audio.rs | 1 + crates/lambda-rs/src/lib.rs | 3 +++ 7 files changed, 27 insertions(+) create mode 100644 crates/lambda-rs-platform/src/cpal/device.rs create mode 100644 crates/lambda-rs-platform/src/cpal/mod.rs create mode 100644 crates/lambda-rs/src/audio.rs diff --git a/crates/lambda-rs-platform/Cargo.toml b/crates/lambda-rs-platform/Cargo.toml index 98888a0a..71cba33a 100644 --- a/crates/lambda-rs-platform/Cargo.toml +++ b/crates/lambda-rs-platform/Cargo.toml @@ -48,3 +48,11 @@ wgpu-with-vulkan = ["wgpu"] wgpu-with-metal = ["wgpu", "wgpu/metal"] wgpu-with-dx12 = ["wgpu", "wgpu/dx12"] wgpu-with-gl = ["wgpu", "wgpu/webgl"] + +# ---------------------------------- AUDIO ------------------------------------ + +# Umbrella features (disabled by default) +audio = ["audio-device"] + +# Granular feature flags (disabled by default) +audio-device = [] diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/cpal/device.rs new file mode 100644 index 00000000..978a0f15 --- /dev/null +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -0,0 +1 @@ +#![allow(clippy::needless_return)] diff --git a/crates/lambda-rs-platform/src/cpal/mod.rs b/crates/lambda-rs-platform/src/cpal/mod.rs new file mode 100644 index 00000000..24a7e723 --- /dev/null +++ b/crates/lambda-rs-platform/src/cpal/mod.rs @@ -0,0 +1,3 @@ +#![allow(clippy::needless_return)] + +pub mod device; diff --git a/crates/lambda-rs-platform/src/lib.rs b/crates/lambda-rs-platform/src/lib.rs index 2fe95071..6949deb7 100644 --- a/crates/lambda-rs-platform/src/lib.rs +++ b/crates/lambda-rs-platform/src/lib.rs @@ -15,3 +15,6 @@ pub mod shader; #[cfg(feature = "wgpu")] pub mod wgpu; pub mod winit; + +#[cfg(feature = "audio-device")] +pub mod cpal; diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index 800dad08..ce87e32d 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -32,6 +32,14 @@ with-wgpu-metal=["with-wgpu", "lambda-rs-platform/wgpu-with-metal"] with-wgpu-dx12=["with-wgpu", "lambda-rs-platform/wgpu-with-dx12"] with-wgpu-gl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] +# ---------------------------------- AUDIO ------------------------------------ + +# Umbrella features (disabled by default) +audio = ["audio-output-device"] + +# Granular feature flags (disabled by default) +audio-output-device = [] + # ------------------------------ RENDER VALIDATION ----------------------------- # Granular, opt-in validation flags for release builds. Debug builds enable # all validations unconditionally via `debug_assertions`. diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio.rs new file mode 100644 index 00000000..978a0f15 --- /dev/null +++ b/crates/lambda-rs/src/audio.rs @@ -0,0 +1 @@ +#![allow(clippy::needless_return)] diff --git a/crates/lambda-rs/src/lib.rs b/crates/lambda-rs/src/lib.rs index 83d25f79..bfea5c6d 100644 --- a/crates/lambda-rs/src/lib.rs +++ b/crates/lambda-rs/src/lib.rs @@ -20,6 +20,9 @@ pub mod runtime; pub mod runtimes; pub mod util; +#[cfg(feature = "audio-output-device")] +pub mod audio; + /// The logging module provides a simple logging interface for Lambda /// applications. pub use logging; From 2f76e0a7c170dd621c41d89ef144428068cd5658 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 14:10:06 -0800 Subject: [PATCH 04/17] [update] specification. --- docs/specs/audio-devices.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index 7b2c03a4..2b6ddb72 100644 --- a/docs/specs/audio-devices.md +++ b/docs/specs/audio-devices.md @@ -3,14 +3,13 @@ title: "Audio Device Abstraction" document_id: "audio-device-abstraction-2026-01-28" status: "draft" created: "2026-01-28T22:59:00Z" -version: "0.1.7" -version: "0.1.8" -last_updated: "2026-01-29T20:40:49Z" +last_updated: "2026-01-29T21:58:43Z" +version: "0.1.9" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "d1d13296951f9b1f34acceced52591c5915215c9" +repo_commit: "e8944565ebba497ec59a72bdfdb855a97f41a666" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -634,6 +633,7 @@ Manual checks ## Changelog +- 2026-01-29 (v0.1.9) — Fix YAML front matter to use a single `version` field. - 2026-01-29 (v0.1.8) — Make the `lambda-rs` facade example the primary reference and remove the platform example requirement. - 2026-01-29 (v0.1.7) — Rename the platform audio implementation module to From aa150c335642700d6e502a112121b932bb094665 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 14:26:13 -0800 Subject: [PATCH 05/17] [add] basic cpal implementation for enumerating devices and audio output. --- crates/lambda-rs-platform/src/cpal/device.rs | 331 +++++++++++++++++++ crates/lambda-rs-platform/src/cpal/mod.rs | 17 + 2 files changed, 348 insertions(+) diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/cpal/device.rs index 978a0f15..7862c422 100644 --- a/crates/lambda-rs-platform/src/cpal/device.rs +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -1 +1,332 @@ #![allow(clippy::needless_return)] + +//! Audio output device discovery and stream initialization. +//! +//! This module defines a backend-agnostic surface that `lambda-rs` can use to +//! enumerate and initialize audio output devices. The implementation is +//! expected to be backed by a platform dependency (for example, `cpal`) behind +//! feature flags. +//! +//! This surface MUST NOT expose backend or vendor types (including `cpal` +//! types) in its public API. + +use std::{ + error::Error, + fmt, +}; + +/// Output sample format used by the platform stream callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioSampleFormat { + /// 32-bit floating point samples in the nominal range `[-1.0, 1.0]`. + F32, + /// Signed 16-bit integer samples mapped from normalized `f32`. + I16, + /// Unsigned 16-bit integer samples mapped from normalized `f32`. + U16, +} + +/// Information available to audio output callbacks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AudioCallbackInfo { + /// Audio frames per second. + pub sample_rate: u32, + /// Interleaved output channel count. + pub channels: u16, + /// The selected stream sample format. + pub sample_format: AudioSampleFormat, +} + +/// Real-time writer for audio output buffers. +/// +/// This writer MUST be implemented without allocation and MUST write into the +/// underlying device output buffer for the current callback invocation. +pub trait AudioOutputWriter { + /// Return the output channel count for the current callback invocation. + fn channels(&self) -> u16; + /// Return the number of frames in the output buffer for the current callback + /// invocation. + fn frames(&self) -> usize; + /// Clear the entire output buffer to silence. + fn clear(&mut self); + + /// Write a normalized sample in the range `[-1.0, 1.0]`. + /// + /// Implementations MUST clamp values outside `[-1.0, 1.0]`. Implementations + /// MUST NOT panic for out-of-range indices and MUST perform no write in that + /// case. + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ); +} + +/// Metadata describing an available audio output device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioDeviceInfo { + /// Human-readable device name. + pub name: String, + /// Whether this device is the current default output device. + pub is_default: bool, +} + +/// Actionable errors produced by the platform audio layer. +/// +/// This error type is internal to `lambda-rs-platform` and MUST NOT expose +/// backend-specific types in its public API. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AudioError { + /// The requested sample rate was invalid. + InvalidSampleRate { requested: u32 }, + /// The requested channel count was invalid. + InvalidChannels { requested: u16 }, + /// No audio host is available. + HostUnavailable { details: String }, + /// No default audio output device is available. + NoDefaultDevice, + /// The device name could not be retrieved. + DeviceNameUnavailable { details: String }, + /// Device enumeration failed. + DeviceEnumerationFailed { details: String }, + /// Supported output configurations could not be retrieved. + SupportedConfigsUnavailable { details: String }, + /// No supported output configuration satisfied the request. + UnsupportedConfig { + requested_sample_rate: Option, + requested_channels: Option, + }, + /// The selected output sample format is unsupported by this abstraction. + UnsupportedSampleFormat { details: String }, + /// A backend-specific failure occurred. + Platform { details: String }, + /// Building an output stream failed. + StreamBuildFailed { details: String }, + /// Starting an output stream failed. + StreamPlayFailed { details: String }, +} + +impl fmt::Display for AudioError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSampleRate { requested } => { + return write!(formatter, "invalid sample rate: {requested}"); + } + Self::InvalidChannels { requested } => { + return write!(formatter, "invalid channel count: {requested}"); + } + Self::HostUnavailable { details } => { + return write!(formatter, "audio host unavailable: {details}"); + } + Self::NoDefaultDevice => { + return write!(formatter, "no default audio output device available"); + } + Self::DeviceNameUnavailable { details } => { + return write!(formatter, "device name unavailable: {details}"); + } + Self::DeviceEnumerationFailed { details } => { + return write!(formatter, "device enumeration failed: {details}"); + } + Self::SupportedConfigsUnavailable { details } => { + return write!( + formatter, + "supported output configs unavailable: {details}" + ); + } + Self::UnsupportedConfig { + requested_sample_rate, + requested_channels, + } => { + return write!( + formatter, + "unsupported output config: sample_rate={requested_sample_rate:?} channels={requested_channels:?}", + ); + } + Self::UnsupportedSampleFormat { details } => { + return write!( + formatter, + "unsupported output sample format: {details}" + ); + } + Self::Platform { details } => { + return write!(formatter, "platform audio error: {details}"); + } + Self::StreamBuildFailed { details } => { + return write!(formatter, "stream build failed: {details}"); + } + Self::StreamPlayFailed { details } => { + return write!(formatter, "stream play failed: {details}"); + } + } + } +} + +impl Error for AudioError {} + +/// An initialized audio output device. +/// +/// This type is an opaque platform wrapper. It MUST NOT expose backend types. +pub struct AudioDevice { + _private: (), +} + +/// Builder for creating an [`AudioDevice`]. +#[derive(Debug, Clone)] +pub struct AudioDeviceBuilder { + sample_rate: Option, + channels: Option, + label: Option, +} + +impl AudioDeviceBuilder { + /// Create a builder with engine defaults. + pub fn new() -> Self { + return Self { + sample_rate: None, + channels: None, + label: None, + }; + } + + /// Request a specific sample rate (Hz). + pub fn with_sample_rate(mut self, rate: u32) -> Self { + self.sample_rate = Some(rate); + return self; + } + + /// Request a specific channel count. + pub fn with_channels(mut self, channels: u16) -> Self { + self.channels = Some(channels); + return self; + } + + /// Attach a label for diagnostics. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Initialize the default audio output device using the requested + /// configuration. + pub fn build(self) -> Result { + if let Some(sample_rate) = self.sample_rate { + if sample_rate == 0 { + return Err(AudioError::InvalidSampleRate { + requested: sample_rate, + }); + } + } + + if let Some(channels) = self.channels { + if channels == 0 { + return Err(AudioError::InvalidChannels { + requested: channels, + }); + } + } + + return Err(AudioError::HostUnavailable { + details: "audio backend not wired".to_string(), + }); + } + + /// Initialize the default audio output device and play audio via a callback. + pub fn build_with_output_callback( + self, + callback: Callback, + ) -> Result + where + Callback: + 'static + Send + FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo), + { + let _ = callback; + return self.build(); + } +} + +/// Enumerate available audio output devices. +pub fn enumerate_devices() -> Result, AudioError> { + return Err(AudioError::HostUnavailable { + details: "audio backend not wired".to_string(), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_rejects_zero_sample_rate() { + let result = AudioDeviceBuilder::new().with_sample_rate(0).build(); + assert!(matches!( + result, + Err(AudioError::InvalidSampleRate { requested: 0 }) + )); + } + + #[test] + fn build_rejects_zero_channels() { + let result = AudioDeviceBuilder::new().with_channels(0).build(); + assert!(matches!( + result, + Err(AudioError::InvalidChannels { requested: 0 }) + )); + } + + #[test] + fn build_returns_host_unavailable_until_backend_is_wired() { + let result = AudioDeviceBuilder::new().build(); + match result { + Err(AudioError::HostUnavailable { details }) => { + assert_eq!(details, "audio backend not wired"); + return; + } + Ok(_device) => { + panic!("expected host unavailable error, got Ok"); + } + Err(error) => { + panic!("expected host unavailable error, got {error}"); + } + } + } + + #[test] + fn enumerate_devices_returns_host_unavailable_until_backend_is_wired() { + let result = enumerate_devices(); + match result { + Err(AudioError::HostUnavailable { details }) => { + assert_eq!(details, "audio backend not wired"); + return; + } + Ok(_devices) => { + panic!("expected host unavailable error, got Ok"); + } + Err(error) => { + panic!("expected host unavailable error, got {error}"); + } + } + } + + #[test] + fn build_with_output_callback_returns_host_unavailable_until_backend_is_wired( + ) { + let result = AudioDeviceBuilder::new().build_with_output_callback( + |_writer, _callback_info| { + return; + }, + ); + match result { + Err(AudioError::HostUnavailable { details }) => { + assert_eq!(details, "audio backend not wired"); + return; + } + Ok(_device) => { + panic!("expected host unavailable error, got Ok"); + } + Err(error) => { + panic!("expected host unavailable error, got {error}"); + } + } + } +} diff --git a/crates/lambda-rs-platform/src/cpal/mod.rs b/crates/lambda-rs-platform/src/cpal/mod.rs index 24a7e723..8e3fa9b7 100644 --- a/crates/lambda-rs-platform/src/cpal/mod.rs +++ b/crates/lambda-rs-platform/src/cpal/mod.rs @@ -1,3 +1,20 @@ #![allow(clippy::needless_return)] +//! Internal audio backend abstractions used by `lambda-rs`. +//! +//! Applications MUST NOT depend on `lambda-rs-platform` directly. The types +//! exposed from this module are intended to support `lambda-rs` implementations +//! and MAY change between releases. + pub mod device; + +pub use device::{ + enumerate_devices, + AudioCallbackInfo, + AudioDevice, + AudioDeviceBuilder, + AudioDeviceInfo, + AudioError, + AudioOutputWriter, + AudioSampleFormat, +}; From 3116004e45d76f82a77b141c73972fae202b16eb Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 14:27:18 -0800 Subject: [PATCH 06/17] [update] lambda to define audio output devices and to implement a high level implemenation for audio playback. --- crates/lambda-rs/Cargo.toml | 2 +- crates/lambda-rs/src/audio.rs | 302 ++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 1 deletion(-) diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index ce87e32d..599abaff 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -38,7 +38,7 @@ with-wgpu-gl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] audio = ["audio-output-device"] # Granular feature flags (disabled by default) -audio-output-device = [] +audio-output-device = ["lambda-rs-platform/audio-device"] # ------------------------------ RENDER VALIDATION ----------------------------- # Granular, opt-in validation flags for release builds. Debug builds enable diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio.rs index 978a0f15..cef6204b 100644 --- a/crates/lambda-rs/src/audio.rs +++ b/crates/lambda-rs/src/audio.rs @@ -1 +1,303 @@ #![allow(clippy::needless_return)] + +//! Application-facing audio output devices. +//! +//! This module provides a backend-agnostic audio output device API for Lambda +//! applications. Platform and vendor details are implemented in +//! `lambda-rs-platform` and MUST NOT be exposed through the `lambda-rs` public +//! API. + +use lambda_platform::cpal as platform_audio; + +/// Output sample format used by an audio stream callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioSampleFormat { + /// 32-bit floating point samples in the nominal range `[-1.0, 1.0]`. + F32, + /// Signed 16-bit integer samples mapped from normalized `f32`. + I16, + /// Unsigned 16-bit integer samples mapped from normalized `f32`. + U16, +} + +impl AudioSampleFormat { + fn from_platform(value: platform_audio::AudioSampleFormat) -> Self { + match value { + platform_audio::AudioSampleFormat::F32 => { + return Self::F32; + } + platform_audio::AudioSampleFormat::I16 => { + return Self::I16; + } + platform_audio::AudioSampleFormat::U16 => { + return Self::U16; + } + } + } +} + +/// Information available to audio output callbacks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AudioCallbackInfo { + /// Audio frames per second. + pub sample_rate: u32, + /// Interleaved output channel count. + pub channels: u16, + /// The selected stream sample format. + pub sample_format: AudioSampleFormat, +} + +impl AudioCallbackInfo { + fn from_platform(value: platform_audio::AudioCallbackInfo) -> Self { + return Self { + sample_rate: value.sample_rate, + channels: value.channels, + sample_format: AudioSampleFormat::from_platform(value.sample_format), + }; + } +} + +/// Actionable errors produced by the `lambda-rs` audio facade. +/// +/// This error type MUST remain backend-agnostic and MUST NOT expose platform or +/// vendor types. +#[derive(Clone, Debug)] +pub enum AudioError { + /// The requested sample rate was invalid. + InvalidSampleRate { requested: u32 }, + /// The requested channel count was invalid. + InvalidChannels { requested: u16 }, + /// No default audio output device is available. + NoDefaultDevice, + /// No supported output configuration satisfied the request. + UnsupportedConfig { + requested_sample_rate: Option, + requested_channels: Option, + }, + /// The selected output sample format is unsupported by this abstraction. + UnsupportedSampleFormat { details: String }, + /// A platform or backend specific error occurred. + Platform { details: String }, +} + +fn map_platform_error(error: platform_audio::AudioError) -> AudioError { + match error { + platform_audio::AudioError::InvalidSampleRate { requested } => { + return AudioError::InvalidSampleRate { requested }; + } + platform_audio::AudioError::InvalidChannels { requested } => { + return AudioError::InvalidChannels { requested }; + } + platform_audio::AudioError::NoDefaultDevice => { + return AudioError::NoDefaultDevice; + } + platform_audio::AudioError::UnsupportedConfig { + requested_sample_rate, + requested_channels, + } => { + return AudioError::UnsupportedConfig { + requested_sample_rate, + requested_channels, + }; + } + platform_audio::AudioError::UnsupportedSampleFormat { details } => { + return AudioError::UnsupportedSampleFormat { details }; + } + other => { + return AudioError::Platform { + details: other.to_string(), + }; + } + } +} + +/// Metadata describing an available audio output device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioOutputDeviceInfo { + /// Human-readable device name. + pub name: String, + /// Whether this device is the current default output device. + pub is_default: bool, +} + +/// Real-time writer for audio output buffers. +/// +/// This writer MUST be implemented without allocation and MUST write into the +/// underlying device output buffer for the current callback invocation. +pub trait AudioOutputWriter { + /// Return the output channel count for the current callback invocation. + fn channels(&self) -> u16; + /// Return the number of frames in the output buffer for the current callback + /// invocation. + fn frames(&self) -> usize; + /// Clear the entire output buffer to silence. + fn clear(&mut self); + + /// Write a normalized sample in the range `[-1.0, 1.0]`. + /// + /// Implementations MUST clamp values outside `[-1.0, 1.0]`. Implementations + /// MUST NOT panic for out-of-range indices and MUST perform no write in that + /// case. + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ); +} + +struct OutputWriterAdapter<'writer> { + writer: &'writer mut dyn platform_audio::AudioOutputWriter, +} + +impl<'writer> AudioOutputWriter for OutputWriterAdapter<'writer> { + fn channels(&self) -> u16 { + return self.writer.channels(); + } + + fn frames(&self) -> usize { + return self.writer.frames(); + } + + fn clear(&mut self) { + self.writer.clear(); + return; + } + + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ) { + self.writer.set_sample(frame_index, channel_index, sample); + return; + } +} + +/// An initialized audio output device. +/// +/// The returned handle MUST be kept alive for as long as audio output is +/// required. Dropping the handle MUST stop output. +pub struct AudioOutputDevice { + _platform: platform_audio::AudioDevice, +} + +/// Builder for creating an [`AudioOutputDevice`]. +#[derive(Debug, Clone)] +pub struct AudioOutputDeviceBuilder { + sample_rate: Option, + channels: Option, + label: Option, +} + +impl AudioOutputDeviceBuilder { + /// Create a builder with engine defaults. + pub fn new() -> Self { + return Self { + sample_rate: None, + channels: None, + label: None, + }; + } + + /// Request a specific sample rate (Hz). + pub fn with_sample_rate(mut self, rate: u32) -> Self { + self.sample_rate = Some(rate); + return self; + } + + /// Request a specific channel count. + pub fn with_channels(mut self, channels: u16) -> Self { + self.channels = Some(channels); + return self; + } + + /// Attach a label for diagnostics. + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_string()); + return self; + } + + /// Initialize the default audio output device using the requested + /// configuration. + pub fn build(self) -> Result { + let mut platform_builder = platform_audio::AudioDeviceBuilder::new(); + + if let Some(sample_rate) = self.sample_rate { + platform_builder = platform_builder.with_sample_rate(sample_rate); + } + + if let Some(channels) = self.channels { + platform_builder = platform_builder.with_channels(channels); + } + + if let Some(label) = self.label { + platform_builder = platform_builder.with_label(&label); + } + + let platform_device = + platform_builder.build().map_err(map_platform_error)?; + + return Ok(AudioOutputDevice { + _platform: platform_device, + }); + } + + /// Initialize the default audio output device and play audio via a callback. + pub fn build_with_output_callback( + self, + callback: Callback, + ) -> Result + where + Callback: + 'static + Send + FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo), + { + let mut platform_builder = platform_audio::AudioDeviceBuilder::new(); + + if let Some(sample_rate) = self.sample_rate { + platform_builder = platform_builder.with_sample_rate(sample_rate); + } + + if let Some(channels) = self.channels { + platform_builder = platform_builder.with_channels(channels); + } + + if let Some(label) = self.label { + platform_builder = platform_builder.with_label(&label); + } + + let mut callback = callback; + let platform_device = platform_builder + .build_with_output_callback(move |writer, callback_info| { + let mut adapter = OutputWriterAdapter { writer }; + callback( + &mut adapter, + AudioCallbackInfo::from_platform(callback_info), + ); + return; + }) + .map_err(map_platform_error)?; + + return Ok(AudioOutputDevice { + _platform: platform_device, + }); + } +} + +/// Enumerate available audio output devices via the platform layer. +pub fn enumerate_output_devices( +) -> Result, AudioError> { + let devices = + platform_audio::enumerate_devices().map_err(map_platform_error)?; + + let devices = devices + .into_iter() + .map(|device| AudioOutputDeviceInfo { + name: device.name, + is_default: device.is_default, + }) + .collect(); + + return Ok(devices); +} From 67591466169559aea1f3d35e896c7f80c8a57ea9 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Thu, 29 Jan 2026 14:39:45 -0800 Subject: [PATCH 07/17] [add] error types and audio output writing. --- crates/lambda-rs-platform/src/cpal/device.rs | 256 +++++++++++++++++++ crates/lambda-rs/src/audio.rs | 28 ++ 2 files changed, 284 insertions(+) diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/cpal/device.rs index 7862c422..48f62016 100644 --- a/crates/lambda-rs-platform/src/cpal/device.rs +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -63,6 +63,184 @@ pub trait AudioOutputWriter { ); } +/// A typed view of an interleaved output buffer for a single callback. +/// +/// This type is internal and exists to support backend callback adapters. +#[allow(dead_code)] +pub(crate) enum AudioOutputBuffer<'buffer> { + /// Interleaved `f32` samples. + F32(&'buffer mut [f32]), + /// Interleaved `i16` samples. + I16(&'buffer mut [i16]), + /// Interleaved `u16` samples. + U16(&'buffer mut [u16]), +} + +impl<'buffer> AudioOutputBuffer<'buffer> { + #[allow(dead_code)] + fn len(&self) -> usize { + match self { + Self::F32(buffer) => { + return buffer.len(); + } + Self::I16(buffer) => { + return buffer.len(); + } + Self::U16(buffer) => { + return buffer.len(); + } + } + } + + fn sample_format(&self) -> AudioSampleFormat { + match self { + Self::F32(_) => { + return AudioSampleFormat::F32; + } + Self::I16(_) => { + return AudioSampleFormat::I16; + } + Self::U16(_) => { + return AudioSampleFormat::U16; + } + } + } +} + +/// An [`AudioOutputWriter`] implementation for interleaved buffers. +/// +/// This type is internal and exists to support backend callback adapters. +#[allow(dead_code)] +pub(crate) struct InterleavedAudioOutputWriter<'buffer> { + channels: u16, + frames: usize, + buffer: AudioOutputBuffer<'buffer>, +} + +impl<'buffer> InterleavedAudioOutputWriter<'buffer> { + #[allow(dead_code)] + pub fn new(channels: u16, buffer: AudioOutputBuffer<'buffer>) -> Self { + let channels_usize = channels as usize; + let frames = if channels_usize == 0 { + 0 + } else { + buffer.len() / channels_usize + }; + + return Self { + channels, + frames, + buffer, + }; + } + + #[allow(dead_code)] + pub fn sample_format(&self) -> AudioSampleFormat { + return self.buffer.sample_format(); + } +} + +#[allow(dead_code)] +fn clamp_normalized_sample(sample: f32) -> f32 { + if sample > 1.0 { + return 1.0; + } + + if sample < -1.0 { + return -1.0; + } + + return sample; +} + +impl<'buffer> AudioOutputWriter for InterleavedAudioOutputWriter<'buffer> { + fn channels(&self) -> u16 { + return self.channels; + } + + fn frames(&self) -> usize { + return self.frames; + } + + fn clear(&mut self) { + match &mut self.buffer { + AudioOutputBuffer::F32(buffer) => { + buffer.fill(0.0); + return; + } + AudioOutputBuffer::I16(buffer) => { + buffer.fill(0); + return; + } + AudioOutputBuffer::U16(buffer) => { + buffer.fill(32768); + return; + } + } + } + + fn set_sample( + &mut self, + frame_index: usize, + channel_index: usize, + sample: f32, + ) { + let channels = self.channels as usize; + if channels == 0 { + return; + } + + if channel_index >= channels { + if cfg!(all(debug_assertions, not(test))) { + eprintln!( + "audio: set_sample channel_index out of range (channel_index={channel_index} channels={channels})" + ); + } + return; + } + + if frame_index >= self.frames { + if cfg!(all(debug_assertions, not(test))) { + eprintln!( + "audio: set_sample frame_index out of range (frame_index={frame_index} frames={})", + self.frames + ); + } + return; + } + + let sample_index = frame_index * channels + channel_index; + if sample_index >= self.buffer.len() { + if cfg!(all(debug_assertions, not(test))) { + eprintln!( + "audio: set_sample buffer index out of range (sample_index={sample_index} len={})", + self.buffer.len() + ); + } + return; + } + + let sample = clamp_normalized_sample(sample); + + match &mut self.buffer { + AudioOutputBuffer::F32(buffer) => { + buffer[sample_index] = sample; + return; + } + AudioOutputBuffer::I16(buffer) => { + let scaled = (sample * 32767.0).round(); + buffer[sample_index] = scaled as i16; + return; + } + AudioOutputBuffer::U16(buffer) => { + let scaled = ((sample + 1.0) * 0.5 * 65535.0).round(); + buffer[sample_index] = scaled as u16; + return; + } + } + } +} + /// Metadata describing an available audio output device. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AudioDeviceInfo { @@ -329,4 +507,82 @@ mod tests { } } } + + #[test] + fn writer_clear_sets_silence_for_all_formats() { + let mut buffer_f32 = [1.0, -1.0, 0.5, -0.5]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::F32(&mut buffer_f32), + ); + writer.clear(); + assert_eq!(buffer_f32, [0.0, 0.0, 0.0, 0.0]); + + let mut buffer_i16 = [1, -1, 200, -200]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::I16(&mut buffer_i16), + ); + writer.clear(); + assert_eq!(buffer_i16, [0, 0, 0, 0]); + + let mut buffer_u16 = [0, 1, 65535, 12345]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::U16(&mut buffer_u16), + ); + writer.clear(); + assert_eq!(buffer_u16, [32768, 32768, 32768, 32768]); + } + + #[test] + fn writer_set_sample_clamps_and_converts() { + let mut buffer_f32 = [0.0, 0.0, 0.0, 0.0]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::F32(&mut buffer_f32), + ); + writer.set_sample(0, 0, 2.0); + writer.set_sample(0, 1, -2.0); + assert_eq!(buffer_f32[0], 1.0); + assert_eq!(buffer_f32[1], -1.0); + + let mut buffer_i16 = [0, 0, 0, 0]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::I16(&mut buffer_i16), + ); + writer.set_sample(0, 0, 1.0); + writer.set_sample(0, 1, -1.0); + writer.set_sample(1, 0, 0.0); + assert_eq!(buffer_i16[0], 32767); + assert_eq!(buffer_i16[1], -32767); + assert_eq!(buffer_i16[2], 0); + + let mut buffer_u16 = [0, 0, 0, 0]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::U16(&mut buffer_u16), + ); + writer.set_sample(0, 0, -1.0); + writer.set_sample(0, 1, 0.0); + writer.set_sample(1, 0, 1.0); + assert_eq!(buffer_u16[0], 0); + assert_eq!(buffer_u16[1], 32768); + assert_eq!(buffer_u16[2], 65535); + } + + #[test] + fn writer_set_sample_is_noop_for_out_of_range_indices() { + let mut buffer_f32 = [0.25, 0.25, 0.25, 0.25]; + let mut writer = InterleavedAudioOutputWriter::new( + 2, + AudioOutputBuffer::F32(&mut buffer_f32), + ); + + writer.set_sample(10, 0, 1.0); + writer.set_sample(0, 10, 1.0); + + assert_eq!(buffer_f32, [0.25, 0.25, 0.25, 0.25]); + } } diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio.rs index cef6204b..2fb40f40 100644 --- a/crates/lambda-rs/src/audio.rs +++ b/crates/lambda-rs/src/audio.rs @@ -301,3 +301,31 @@ pub fn enumerate_output_devices( return Ok(devices); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_map_without_leaking_platform_types() { + let result = AudioOutputDeviceBuilder::new().with_sample_rate(0).build(); + assert!(matches!( + result, + Err(AudioError::InvalidSampleRate { requested: 0 }) + )); + + let result = enumerate_output_devices(); + match result { + Err(AudioError::Platform { details }) => { + assert_eq!(details, "audio host unavailable: audio backend not wired"); + return; + } + Ok(_devices) => { + panic!("expected platform error, got Ok"); + } + Err(error) => { + panic!("expected platform error, got {error:?}"); + } + } + } +} From 92ac531da3c3c6c50e5efb64d8b5bddba26d9f18 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 13:31:59 -0800 Subject: [PATCH 08/17] [add] cpal as a dependency and implement the actual device enumeration + audio streams. --- Cargo.lock | 229 ++++++++++- crates/lambda-rs-platform/Cargo.toml | 3 +- crates/lambda-rs-platform/src/cpal/device.rs | 390 ++++++++++++++++--- crates/lambda-rs-platform/src/cpal/mod.rs | 4 - crates/lambda-rs/src/audio.rs | 21 +- 5 files changed, 577 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32c407ba..685e297f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.9.0", + "cfg-if 1.0.0", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.5.2" @@ -123,7 +145,7 @@ dependencies = [ "jni-sys", "libc", "log", - "ndk", + "ndk 0.8.0", "ndk-context", "ndk-sys 0.5.0+25.2.9519653", "num_enum", @@ -283,7 +305,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" dependencies = [ "block-sys", - "objc2", + "objc2 0.4.1", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", ] [[package]] @@ -624,6 +655,20 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "coveralls-api" version = "0.5.0" @@ -638,6 +683,36 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.3", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + [[package]] name = "crates-index" version = "0.17.0" @@ -757,6 +832,12 @@ dependencies = [ "syn 1.0.102", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "deflate" version = "0.8.6" @@ -774,6 +855,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.3", +] + [[package]] name = "dlib" version = "0.5.2" @@ -1186,9 +1277,9 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" dependencies = [ - "block2", + "block2 0.3.0", "dispatch", - "objc2", + "objc2 0.4.1", ] [[package]] @@ -1324,6 +1415,7 @@ version = "2023.1.30" name = "lambda-rs-platform" version = "2023.1.30" dependencies = [ + "cpal", "lambda-rs-logging", "mockall", "naga", @@ -1448,6 +1540,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1618,6 +1719,20 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -1666,6 +1781,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -1750,7 +1876,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" dependencies = [ "objc-sys", - "objc2-encode", + "objc2-encode 3.0.0", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode 4.1.0", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.9.0", + "libc", + "objc2 0.6.3", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.3", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.3", ] [[package]] @@ -1759,6 +1955,25 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.27.1" @@ -3616,9 +3831,9 @@ dependencies = [ "libc", "log", "memmap2", - "ndk", + "ndk 0.8.0", "ndk-sys 0.5.0+25.2.9519653", - "objc2", + "objc2 0.4.1", "once_cell", "orbclient", "percent-encoding", diff --git a/crates/lambda-rs-platform/Cargo.toml b/crates/lambda-rs-platform/Cargo.toml index 71cba33a..3a2e2a82 100644 --- a/crates/lambda-rs-platform/Cargo.toml +++ b/crates/lambda-rs-platform/Cargo.toml @@ -22,6 +22,7 @@ obj-rs = "=0.7.4" wgpu = { version = "=28.0.0", optional = true, features = ["wgsl", "spirv"] } pollster = { version = "=0.4.0", optional = true } lambda-rs-logging = { path = "../lambda-rs-logging", version = "2023.1.30" } +cpal = { version = "=0.17.1", optional = true } # Force windows crate to 0.62 to unify wgpu-hal and gpu-allocator dependencies. # Both crates support this version range, but Cargo may resolve to different @@ -55,4 +56,4 @@ wgpu-with-gl = ["wgpu", "wgpu/webgl"] audio = ["audio-device"] # Granular feature flags (disabled by default) -audio-device = [] +audio-device = ["dep:cpal"] diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/cpal/device.rs index 48f62016..aa69c4af 100644 --- a/crates/lambda-rs-platform/src/cpal/device.rs +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -15,6 +15,13 @@ use std::{ fmt, }; +use ::cpal as cpal_backend; +use cpal_backend::traits::{ + DeviceTrait, + HostTrait, + StreamTrait, +}; + /// Output sample format used by the platform stream callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AudioSampleFormat { @@ -346,7 +353,13 @@ impl Error for AudioError {} /// /// This type is an opaque platform wrapper. It MUST NOT expose backend types. pub struct AudioDevice { - _private: (), + _stream: cpal_backend::Stream, + #[allow(dead_code)] + sample_rate: u32, + #[allow(dead_code)] + channels: u16, + #[allow(dead_code)] + sample_format: AudioSampleFormat, } /// Builder for creating an [`AudioDevice`]. @@ -404,8 +417,107 @@ impl AudioDeviceBuilder { } } - return Err(AudioError::HostUnavailable { - details: "audio backend not wired".to_string(), + let host = cpal_backend::default_host(); + + let device = host + .default_output_device() + .ok_or(AudioError::NoDefaultDevice)?; + + let supported_configs = + device.supported_output_configs().map_err(|error| { + AudioError::SupportedConfigsUnavailable { + details: error.to_string(), + } + })?; + + let supported_configs: Vec = + supported_configs.collect(); + + let selected_config = select_output_stream_config( + &supported_configs, + self.sample_rate, + self.channels, + )?; + + let stream_config = selected_config.config(); + let sample_rate = stream_config.sample_rate; + let channels = stream_config.channels; + + let sample_format = match selected_config.sample_format() { + cpal_backend::SampleFormat::F32 => AudioSampleFormat::F32, + cpal_backend::SampleFormat::I16 => AudioSampleFormat::I16, + cpal_backend::SampleFormat::U16 => AudioSampleFormat::U16, + other => { + return Err(AudioError::UnsupportedSampleFormat { + details: format!("{other:?}"), + }); + } + }; + + let stream = match selected_config.sample_format() { + cpal_backend::SampleFormat::F32 => device + .build_output_stream( + &stream_config, + |data: &mut [f32], _info| { + data.fill(0.0); + return; + }, + |_error| { + return; + }, + None, + ) + .map_err(|error| AudioError::StreamBuildFailed { + details: error.to_string(), + })?, + cpal_backend::SampleFormat::I16 => device + .build_output_stream( + &stream_config, + |data: &mut [i16], _info| { + data.fill(0); + return; + }, + |_error| { + return; + }, + None, + ) + .map_err(|error| AudioError::StreamBuildFailed { + details: error.to_string(), + })?, + cpal_backend::SampleFormat::U16 => device + .build_output_stream( + &stream_config, + |data: &mut [u16], _info| { + data.fill(32768); + return; + }, + |_error| { + return; + }, + None, + ) + .map_err(|error| AudioError::StreamBuildFailed { + details: error.to_string(), + })?, + other => { + return Err(AudioError::UnsupportedSampleFormat { + details: format!("{other:?}"), + }); + } + }; + + stream + .play() + .map_err(|error| AudioError::StreamPlayFailed { + details: error.to_string(), + })?; + + return Ok(AudioDevice { + _stream: stream, + sample_rate, + channels, + sample_format, }); } @@ -423,15 +535,160 @@ impl AudioDeviceBuilder { } } +impl Default for AudioDeviceBuilder { + fn default() -> Self { + return Self::new(); + } +} + +fn sample_format_priority(sample_format: cpal_backend::SampleFormat) -> u8 { + match sample_format { + cpal_backend::SampleFormat::F32 => { + return 3; + } + cpal_backend::SampleFormat::I16 => { + return 2; + } + cpal_backend::SampleFormat::U16 => { + return 1; + } + _ => { + return 0; + } + } +} + +fn select_output_stream_config( + supported_configs: &[cpal_backend::SupportedStreamConfigRange], + requested_sample_rate: Option, + requested_channels: Option, +) -> Result { + let mut best_config: Option = None; + let mut best_priority = 0u8; + let mut best_sample_rate_distance = u32::MAX; + + for range in supported_configs.iter().copied() { + if let Some(channels) = requested_channels { + if range.channels() != channels { + continue; + } + } + + if sample_format_priority(range.sample_format()) == 0 { + continue; + } + + let min_sample_rate = range.min_sample_rate(); + let max_sample_rate = range.max_sample_rate(); + + let sample_rate = if let Some(requested_sample_rate) = requested_sample_rate + { + if requested_sample_rate < min_sample_rate + || max_sample_rate < requested_sample_rate + { + continue; + } + + requested_sample_rate + } else { + let target_sample_rate = 48_000; + if target_sample_rate < min_sample_rate { + min_sample_rate + } else if max_sample_rate < target_sample_rate { + max_sample_rate + } else { + target_sample_rate + } + }; + + let config = match range.try_with_sample_rate(sample_rate) { + Some(config) => config, + None => { + continue; + } + }; + + let priority = sample_format_priority(config.sample_format()); + let sample_rate_distance = if config.sample_rate() < 48_000 { + 48_000 - config.sample_rate() + } else { + config.sample_rate() - 48_000 + }; + + if priority < best_priority { + continue; + } + + if priority == best_priority + && best_config.is_some() + && best_sample_rate_distance < sample_rate_distance + { + continue; + } + + best_priority = priority; + best_sample_rate_distance = sample_rate_distance; + best_config = Some(config); + } + + if let Some(config) = best_config { + return Ok(config); + } + + if supported_configs + .iter() + .all(|config| sample_format_priority(config.sample_format()) == 0) + { + return Err(AudioError::UnsupportedSampleFormat { + details: "no supported sample format among f32/i16/u16".to_string(), + }); + } + + return Err(AudioError::UnsupportedConfig { + requested_sample_rate, + requested_channels, + }); +} + /// Enumerate available audio output devices. pub fn enumerate_devices() -> Result, AudioError> { - return Err(AudioError::HostUnavailable { - details: "audio backend not wired".to_string(), - }); + let host = cpal_backend::default_host(); + + let default_device_id = host + .default_output_device() + .and_then(|device| device.id().ok()); + + let devices = host.output_devices().map_err(|error| { + return AudioError::DeviceEnumerationFailed { + details: error.to_string(), + }; + })?; + + let mut output_devices = Vec::new(); + for device in devices { + let name = device + .description() + .map(|description| description.name().to_string()) + .map_err(|error| { + return AudioError::DeviceNameUnavailable { + details: error.to_string(), + }; + })?; + + let is_default = default_device_id + .as_ref() + .is_some_and(|default_id| device.id().ok().as_ref() == Some(default_id)); + + output_devices.push(AudioDeviceInfo { name, is_default }); + } + + return Ok(output_devices); } #[cfg(test)] mod tests { + use cpal_backend::SupportedBufferSize; + use super::*; #[test] @@ -454,36 +711,14 @@ mod tests { #[test] fn build_returns_host_unavailable_until_backend_is_wired() { - let result = AudioDeviceBuilder::new().build(); - match result { - Err(AudioError::HostUnavailable { details }) => { - assert_eq!(details, "audio backend not wired"); - return; - } - Ok(_device) => { - panic!("expected host unavailable error, got Ok"); - } - Err(error) => { - panic!("expected host unavailable error, got {error}"); - } - } + let _result = AudioDeviceBuilder::new().build(); + return; } #[test] - fn enumerate_devices_returns_host_unavailable_until_backend_is_wired() { - let result = enumerate_devices(); - match result { - Err(AudioError::HostUnavailable { details }) => { - assert_eq!(details, "audio backend not wired"); - return; - } - Ok(_devices) => { - panic!("expected host unavailable error, got Ok"); - } - Err(error) => { - panic!("expected host unavailable error, got {error}"); - } - } + fn enumerate_devices_does_not_panic() { + let _result = enumerate_devices(); + return; } #[test] @@ -494,18 +729,8 @@ mod tests { return; }, ); - match result { - Err(AudioError::HostUnavailable { details }) => { - assert_eq!(details, "audio backend not wired"); - return; - } - Ok(_device) => { - panic!("expected host unavailable error, got Ok"); - } - Err(error) => { - panic!("expected host unavailable error, got {error}"); - } - } + let _ = result; + return; } #[test] @@ -585,4 +810,79 @@ mod tests { assert_eq!(buffer_f32, [0.25, 0.25, 0.25, 0.25]); } + + #[test] + fn select_output_stream_config_prefers_f32_when_available() { + let supported_configs = [ + cpal_backend::SupportedStreamConfigRange::new( + 2, + 44_100, + 48_000, + SupportedBufferSize::Unknown, + cpal_backend::SampleFormat::I16, + ), + cpal_backend::SupportedStreamConfigRange::new( + 2, + 44_100, + 48_000, + SupportedBufferSize::Unknown, + cpal_backend::SampleFormat::F32, + ), + ]; + + let selected = + select_output_stream_config(&supported_configs, None, None).unwrap(); + assert_eq!(selected.sample_format(), cpal_backend::SampleFormat::F32); + assert_eq!(selected.sample_rate(), 48_000); + } + + #[test] + fn select_output_stream_config_respects_requested_channels() { + let supported_configs = [cpal_backend::SupportedStreamConfigRange::new( + 2, + 44_100, + 48_000, + SupportedBufferSize::Unknown, + cpal_backend::SampleFormat::F32, + )]; + + let selected = + select_output_stream_config(&supported_configs, None, Some(2)).unwrap(); + assert_eq!(selected.channels(), 2); + + let result = select_output_stream_config(&supported_configs, None, Some(1)); + assert!(matches!( + result, + Err(AudioError::UnsupportedConfig { + requested_sample_rate: None, + requested_channels: Some(1), + }) + )); + } + + #[test] + fn select_output_stream_config_respects_requested_sample_rate() { + let supported_configs = [cpal_backend::SupportedStreamConfigRange::new( + 2, + 44_100, + 48_000, + SupportedBufferSize::Unknown, + cpal_backend::SampleFormat::F32, + )]; + + let selected = + select_output_stream_config(&supported_configs, Some(44_100), None) + .unwrap(); + assert_eq!(selected.sample_rate(), 44_100); + + let result = + select_output_stream_config(&supported_configs, Some(10), None); + assert!(matches!( + result, + Err(AudioError::UnsupportedConfig { + requested_sample_rate: Some(10), + requested_channels: None, + }) + )); + } } diff --git a/crates/lambda-rs-platform/src/cpal/mod.rs b/crates/lambda-rs-platform/src/cpal/mod.rs index 8e3fa9b7..dbb959cf 100644 --- a/crates/lambda-rs-platform/src/cpal/mod.rs +++ b/crates/lambda-rs-platform/src/cpal/mod.rs @@ -1,10 +1,6 @@ #![allow(clippy::needless_return)] //! Internal audio backend abstractions used by `lambda-rs`. -//! -//! Applications MUST NOT depend on `lambda-rs-platform` directly. The types -//! exposed from this module are intended to support `lambda-rs` implementations -//! and MAY change between releases. pub mod device; diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio.rs index 2fb40f40..a290168a 100644 --- a/crates/lambda-rs/src/audio.rs +++ b/crates/lambda-rs/src/audio.rs @@ -285,6 +285,12 @@ impl AudioOutputDeviceBuilder { } } +impl Default for AudioOutputDeviceBuilder { + fn default() -> Self { + return Self::new(); + } +} + /// Enumerate available audio output devices via the platform layer. pub fn enumerate_output_devices( ) -> Result, AudioError> { @@ -314,18 +320,7 @@ mod tests { Err(AudioError::InvalidSampleRate { requested: 0 }) )); - let result = enumerate_output_devices(); - match result { - Err(AudioError::Platform { details }) => { - assert_eq!(details, "audio host unavailable: audio backend not wired"); - return; - } - Ok(_devices) => { - panic!("expected platform error, got Ok"); - } - Err(error) => { - panic!("expected platform error, got {error:?}"); - } - } + let _result = enumerate_output_devices(); + return; } } From 59c548c489843b699e08cbc9520d1bba9679ad8b Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 13:54:56 -0800 Subject: [PATCH 09/17] [update] build_with_output_callback to create and play the output stream --- crates/lambda-rs-platform/src/cpal/device.rs | 246 ++++++++++++++++++- 1 file changed, 244 insertions(+), 2 deletions(-) diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/cpal/device.rs index aa69c4af..e1424535 100644 --- a/crates/lambda-rs-platform/src/cpal/device.rs +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -530,8 +530,147 @@ impl AudioDeviceBuilder { Callback: 'static + Send + FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo), { - let _ = callback; - return self.build(); + if let Some(sample_rate) = self.sample_rate { + if sample_rate == 0 { + return Err(AudioError::InvalidSampleRate { + requested: sample_rate, + }); + } + } + + if let Some(channels) = self.channels { + if channels == 0 { + return Err(AudioError::InvalidChannels { + requested: channels, + }); + } + } + + let host = cpal_backend::default_host(); + + let device = host + .default_output_device() + .ok_or(AudioError::NoDefaultDevice)?; + + let supported_configs = + device.supported_output_configs().map_err(|error| { + AudioError::SupportedConfigsUnavailable { + details: error.to_string(), + } + })?; + + let supported_configs: Vec = + supported_configs.collect(); + + let selected_config = select_output_stream_config( + &supported_configs, + self.sample_rate, + self.channels, + )?; + + let stream_config = selected_config.config(); + let sample_rate = stream_config.sample_rate; + let channels = stream_config.channels; + + let sample_format = match selected_config.sample_format() { + cpal_backend::SampleFormat::F32 => AudioSampleFormat::F32, + cpal_backend::SampleFormat::I16 => AudioSampleFormat::I16, + cpal_backend::SampleFormat::U16 => AudioSampleFormat::U16, + other => { + return Err(AudioError::UnsupportedSampleFormat { + details: format!("{other:?}"), + }); + } + }; + + let callback_info = AudioCallbackInfo { + sample_rate, + channels, + sample_format, + }; + + let mut callback = callback; + + let stream = match selected_config.sample_format() { + cpal_backend::SampleFormat::F32 => device + .build_output_stream( + &stream_config, + move |data: &mut [f32], _info| { + invoke_output_callback_on_buffer( + channels, + AudioOutputBuffer::F32(data), + callback_info, + &mut callback, + ); + return; + }, + |_error| { + return; + }, + None, + ) + .map_err(|error| AudioError::StreamBuildFailed { + details: error.to_string(), + })?, + cpal_backend::SampleFormat::I16 => device + .build_output_stream( + &stream_config, + move |data: &mut [i16], _info| { + invoke_output_callback_on_buffer( + channels, + AudioOutputBuffer::I16(data), + callback_info, + &mut callback, + ); + return; + }, + |_error| { + return; + }, + None, + ) + .map_err(|error| AudioError::StreamBuildFailed { + details: error.to_string(), + })?, + cpal_backend::SampleFormat::U16 => device + .build_output_stream( + &stream_config, + move |data: &mut [u16], _info| { + invoke_output_callback_on_buffer( + channels, + AudioOutputBuffer::U16(data), + callback_info, + &mut callback, + ); + return; + }, + |_error| { + return; + }, + None, + ) + .map_err(|error| AudioError::StreamBuildFailed { + details: error.to_string(), + })?, + other => { + return Err(AudioError::UnsupportedSampleFormat { + details: format!("{other:?}"), + }); + } + }; + + stream + .play() + .map_err(|error| AudioError::StreamPlayFailed { + details: error.to_string(), + })?; + + return Ok(AudioDevice { + _stream: stream, + sample_rate, + channels, + sample_format, + }); } } @@ -541,6 +680,20 @@ impl Default for AudioDeviceBuilder { } } +fn invoke_output_callback_on_buffer( + channels: u16, + buffer: AudioOutputBuffer<'_>, + callback_info: AudioCallbackInfo, + callback: &mut Callback, +) where + Callback: FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo), +{ + let mut writer = InterleavedAudioOutputWriter::new(channels, buffer); + writer.clear(); + callback(&mut writer, callback_info); + return; +} + fn sample_format_priority(sample_format: cpal_backend::SampleFormat) -> u8 { match sample_format { cpal_backend::SampleFormat::F32 => { @@ -733,6 +886,95 @@ mod tests { return; } + #[test] + fn invoke_output_callback_on_buffer_clears_and_invokes_callback_f32() { + let mut buffer_f32 = [1.0, -1.0, 0.5, -0.5]; + let callback_info = AudioCallbackInfo { + sample_rate: 48_000, + channels: 2, + sample_format: AudioSampleFormat::F32, + }; + + let mut callback_called = false; + let mut callback = |writer: &mut dyn AudioOutputWriter, + info: AudioCallbackInfo| { + callback_called = true; + assert_eq!(info, callback_info); + assert_eq!(writer.channels(), 2); + assert_eq!(writer.frames(), 2); + writer.set_sample(0, 0, 0.5); + return; + }; + + invoke_output_callback_on_buffer( + 2, + AudioOutputBuffer::F32(&mut buffer_f32), + callback_info, + &mut callback, + ); + + assert!(callback_called); + assert_eq!(buffer_f32, [0.5, 0.0, 0.0, 0.0]); + } + + #[test] + fn invoke_output_callback_on_buffer_clears_and_invokes_callback_i16() { + let mut buffer_i16 = [1, -1, 200, -200]; + let callback_info = AudioCallbackInfo { + sample_rate: 48_000, + channels: 2, + sample_format: AudioSampleFormat::I16, + }; + + let mut callback_called = false; + let mut callback = |writer: &mut dyn AudioOutputWriter, + info: AudioCallbackInfo| { + callback_called = true; + assert_eq!(info, callback_info); + writer.set_sample(0, 0, 1.0); + return; + }; + + invoke_output_callback_on_buffer( + 2, + AudioOutputBuffer::I16(&mut buffer_i16), + callback_info, + &mut callback, + ); + + assert!(callback_called); + assert_eq!(buffer_i16, [32767, 0, 0, 0]); + } + + #[test] + fn invoke_output_callback_on_buffer_clears_and_invokes_callback_u16() { + let mut buffer_u16 = [0, 1, 65535, 12345]; + let callback_info = AudioCallbackInfo { + sample_rate: 48_000, + channels: 2, + sample_format: AudioSampleFormat::U16, + }; + + let mut callback_called = false; + let mut callback = |writer: &mut dyn AudioOutputWriter, + info: AudioCallbackInfo| { + callback_called = true; + assert_eq!(info, callback_info); + writer.set_sample(0, 0, -1.0); + return; + }; + + invoke_output_callback_on_buffer( + 2, + AudioOutputBuffer::U16(&mut buffer_u16), + callback_info, + &mut callback, + ); + + assert!(callback_called); + assert_eq!(buffer_u16, [0, 32768, 32768, 32768]); + } + #[test] fn writer_clear_sets_silence_for_all_formats() { let mut buffer_f32 = [1.0, -1.0, 0.5, -0.5]; From 939c07cf5c7ff583559c60ea7bb571961579f12b Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 14:02:51 -0800 Subject: [PATCH 10/17] [add] example audio sine wave example. --- crates/lambda-rs/examples/audio_sine_wave.rs | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 crates/lambda-rs/examples/audio_sine_wave.rs diff --git a/crates/lambda-rs/examples/audio_sine_wave.rs b/crates/lambda-rs/examples/audio_sine_wave.rs new file mode 100644 index 00000000..77bcf8fd --- /dev/null +++ b/crates/lambda-rs/examples/audio_sine_wave.rs @@ -0,0 +1,78 @@ +#![allow(clippy::needless_return)] +//! Audio output example that plays a short sine wave tone. +//! +//! This example is application-facing and uses only the `lambda-rs` API surface. + +#[cfg(feature = "audio-output-device")] +use std::{ + thread, + time::Duration, +}; + +#[cfg(feature = "audio-output-device")] +use lambda::audio::{ + enumerate_output_devices, + AudioOutputDeviceBuilder, +}; + +#[cfg(not(feature = "audio-output-device"))] +fn main() { + eprintln!( + "This example requires `lambda-rs` feature `audio-output-device`.\n\n\ +Run:\n cargo run -p lambda-rs --example audio_sine_wave --features audio-output-device" + ); +} + +#[cfg(feature = "audio-output-device")] +fn main() { + let devices = match enumerate_output_devices() { + Ok(devices) => devices, + Err(error) => { + eprintln!("Failed to enumerate audio output devices: {error:?}"); + return; + } + }; + + println!("Available output devices:"); + for device in devices { + let default_marker = if device.is_default { " (default)" } else { "" }; + println!(" - {}{default_marker}", device.name); + } + + let frequency_hz = 440.0f32; + let amplitude = 0.10f32; + let mut phase_radians = 0.0f32; + + let device = AudioOutputDeviceBuilder::new() + .with_label("audio_sine_wave") + .build_with_output_callback(move |writer, callback_info| { + let phase_delta = std::f32::consts::TAU * frequency_hz + / (callback_info.sample_rate as f32); + + for frame_index in 0..writer.frames() { + let sample = (phase_radians.sin()) * amplitude; + for channel_index in 0..(writer.channels() as usize) { + writer.set_sample(frame_index, channel_index, sample); + } + + phase_radians += phase_delta; + if phase_radians > std::f32::consts::TAU { + phase_radians -= std::f32::consts::TAU; + } + } + + return; + }); + + let _device = match device { + Ok(device) => device, + Err(error) => { + eprintln!("Failed to initialize audio output device: {error:?}"); + return; + } + }; + + println!("Playing 440 Hz sine wave for 2 seconds..."); + thread::sleep(Duration::from_secs(2)); + println!("Done."); +} From b71ba576f02da70c958013614b7df8086a82ecf6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 14:12:46 -0800 Subject: [PATCH 11/17] [update] feature format and document audio flags. --- docs/features.md | 80 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/docs/features.md b/docs/features.md index f5f0492d..cb97dcb8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,44 +3,69 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2026-01-25T00:00:00Z" -version: "0.1.6" +last_updated: "2026-01-30T22:10:39Z" +version: "0.1.7" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "229960fd426cf605c7513002b36e3942f14a3140" +repo_commit: "939c07cf5c7ff583559c60ea7bb571961579f12b" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] -tags: ["guide", "features", "validation", "cargo"] +tags: ["guide", "features", "validation", "cargo", "audio"] --- ## Overview -This document enumerates the primary Cargo features exposed by the workspace relevant to rendering and validation behavior. It defines defaults, relationships, and expected behavior in debug and release builds. +This document enumerates the primary Cargo features exposed by the workspace +relevant to rendering, validation, and audio behavior. It defines defaults, +relationships, and expected behavior in debug and release builds. ## Table of Contents - [Overview](#overview) - [Defaults](#defaults) -- [Rendering Backends](#rendering-backends) -- [Shader Backends](#shader-backends) -- [Render Validation](#render-validation) +- [`lambda-rs`](#lambda-rs) +- [`lambda-rs-platform`](#lambda-rs-platform) - [Changelog](#changelog) ## Defaults - Workspace defaults prefer `wgpu` on supported platforms and `naga` for shader compilation. - Debug builds enable all validations unconditionally via `debug_assertions`. - Release builds enable only cheap safety checks by default; validation logs and per-draw checks MUST be enabled explicitly via features. +- Audio features are disabled by default and incur runtime cost only when an + audio device is initialized and kept alive. -## Rendering Backends -- `lambda-rs` - - `with-wgpu` (default): enables the `wgpu` platform backend via `lambda-rs-platform`. - - Platform specializations: `with-wgpu-vulkan`, `with-wgpu-metal`, `with-wgpu-dx12`, `with-wgpu-gl`. +## `lambda-rs` -## Shader Backends -- `lambda-rs-platform` - - `shader-backend-naga` (default): uses `naga` for shader handling. +Rendering backends +- `with-wgpu` (default): enables the `wgpu` platform backend via + `lambda-rs-platform/wgpu`. +- Platform specializations: + - `with-wgpu-vulkan`: enables the Vulkan backend via + `lambda-rs-platform/wgpu-with-vulkan`. + - `with-wgpu-metal`: enables the Metal backend via + `lambda-rs-platform/wgpu-with-metal`. + - `with-wgpu-dx12`: enables the DirectX 12 backend via + `lambda-rs-platform/wgpu-with-dx12`. + - `with-wgpu-gl`: enables the OpenGL/WebGL backend via + `lambda-rs-platform/wgpu-with-gl`. +- Convenience aliases: + - `with-vulkan`: alias for `with-wgpu` and `with-wgpu-vulkan`. + - `with-metal`: alias for `with-wgpu` and `with-wgpu-metal`. + - `with-dx12`: alias for `with-wgpu` and `with-wgpu-dx12`. + - `with-opengl`: alias for `with-wgpu` and `with-wgpu-gl`. + - `with-dx11`: alias for `with-wgpu`. -## Render Validation +Audio +- `audio` (umbrella, disabled by default): enables audio support by composing + granular audio features. This umbrella includes `audio-output-device`. +- `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 + runtime cost is proportional to the output callback workload and buffer size; + no runtime cost is incurred unless an `AudioOutputDevice` is built and kept + alive. + +Render validation Umbrella features (crate: `lambda-rs`) - `render-validation`: enables common builder/pipeline validation logs (MSAA counts, depth clear advisories, stencil format upgrades, render-target compatibility) by composing granular validation features. This umbrella includes `render-validation-msaa`, `render-validation-depth`, `render-validation-stencil`, `render-validation-pass-compat`, and `render-validation-render-targets`. @@ -84,7 +109,30 @@ Usage examples - Enable only MSAA validation in release: - `cargo test -p lambda-rs --features render-validation-msaa` +## `lambda-rs-platform` + +This crate provides platform and dependency abstractions for `lambda-rs`. +Applications MUST NOT depend on `lambda-rs-platform` directly. + +Rendering backend +- `wgpu` (default): enables the `wgpu` backend. +- `wgpu-with-vulkan`: enables Vulkan support. +- `wgpu-with-metal`: enables Metal support. +- `wgpu-with-dx12`: enables DirectX 12 support. +- `wgpu-with-gl`: enables OpenGL/WebGL support. + +Shader backends +- `shader-backend-naga` (default): uses `naga` for shader handling. + +Audio +- `audio` (umbrella, disabled by default): enables platform audio support by + composing granular platform audio features. This umbrella includes + `audio-device`. +- `audio-device` (granular, disabled by default): enables the internal audio + backend module `lambda_platform::cpal` backed by `cpal =0.17.1`. + ## Changelog +- 0.1.7 (2026-01-30): Group features by crate and document audio feature flags. - 0.1.6 (2026-01-25): Remove the deprecated legacy shader backend documentation. - 0.1.5 (2025-12-22): Align `lambda-rs` Cargo feature umbrella composition with From e179f7de3b43f9cd822b4f7ab520c095dc3c6911 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 14:13:18 -0800 Subject: [PATCH 12/17] [fix] titles. --- docs/features.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features.md b/docs/features.md index cb97dcb8..ad18fc5b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -23,8 +23,8 @@ relationships, and expected behavior in debug and release builds. ## Table of Contents - [Overview](#overview) - [Defaults](#defaults) -- [`lambda-rs`](#lambda-rs) -- [`lambda-rs-platform`](#lambda-rs-platform) +- [lambda-rs](#lambda-rs) +- [lambda-rs-platform](#lambda-rs-platform) - [Changelog](#changelog) ## Defaults @@ -34,7 +34,7 @@ relationships, and expected behavior in debug and release builds. - Audio features are disabled by default and incur runtime cost only when an audio device is initialized and kept alive. -## `lambda-rs` +## lambda-rs Rendering backends - `with-wgpu` (default): enables the `wgpu` platform backend via @@ -109,7 +109,7 @@ Usage examples - Enable only MSAA validation in release: - `cargo test -p lambda-rs --features render-validation-msaa` -## `lambda-rs-platform` +## lambda-rs-platform This crate provides platform and dependency abstractions for `lambda-rs`. Applications MUST NOT depend on `lambda-rs-platform` directly. From 7e7851da903fbc7b4aa35acd511999df659af237 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 14:52:23 -0800 Subject: [PATCH 13/17] [update] audio features to be enabled by default. --- crates/lambda-rs/Cargo.toml | 6 +++--- docs/features.md | 18 +++++++++++------- docs/specs/audio-devices.md | 21 +++++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index 599abaff..d57c476e 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -20,7 +20,7 @@ cargo-audit = "0.16.0" mockall = "0.14.0" [features] -default=["with-wgpu"] +default=["with-wgpu", "audio"] with-vulkan=["with-wgpu", "lambda-rs-platform/wgpu-with-vulkan"] with-opengl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] with-dx11=["with-wgpu"] @@ -34,10 +34,10 @@ with-wgpu-gl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] # ---------------------------------- AUDIO ------------------------------------ -# Umbrella features (disabled by default) +# Umbrella features audio = ["audio-output-device"] -# Granular feature flags (disabled by default) +# Granular feature flags audio-output-device = ["lambda-rs-platform/audio-device"] # ------------------------------ RENDER VALIDATION ----------------------------- diff --git a/docs/features.md b/docs/features.md index ad18fc5b..fb044808 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-01-30T22:10:39Z" -version: "0.1.7" +last_updated: "2026-01-30T22:48:05Z" +version: "0.1.9" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "939c07cf5c7ff583559c60ea7bb571961579f12b" +repo_commit: "e179f7de3b43f9cd822b4f7ab520c095dc3c6911" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio"] @@ -31,8 +31,8 @@ relationships, and expected behavior in debug and release builds. - Workspace defaults prefer `wgpu` on supported platforms and `naga` for shader compilation. - Debug builds enable all validations unconditionally via `debug_assertions`. - Release builds enable only cheap safety checks by default; validation logs and per-draw checks MUST be enabled explicitly via features. -- Audio features are disabled by default and incur runtime cost only when an - audio device is initialized and kept alive. +- Audio support in `lambda-rs` is enabled by default and incurs runtime cost + only when an audio device is initialized and kept alive. ## lambda-rs @@ -56,9 +56,9 @@ Rendering backends - `with-dx11`: alias for `with-wgpu`. Audio -- `audio` (umbrella, disabled by default): enables audio support by composing +- `audio` (umbrella, enabled by default): enables audio support by composing granular audio features. This umbrella includes `audio-output-device`. -- `audio-output-device` (granular, disabled by default): enables audio output +- `audio-output-device` (granular, enabled 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 runtime cost is proportional to the output callback workload and buffer size; @@ -132,6 +132,10 @@ Audio backend module `lambda_platform::cpal` backed by `cpal =0.17.1`. ## Changelog +- 0.1.9 (2026-01-30): Clarify workspace default audio behavior after enabling + `lambda-rs` audio features by default. +- 0.1.8 (2026-01-30): Enable `lambda-rs` audio features by default and update + audio feature defaults in documentation. - 0.1.7 (2026-01-30): Group features by crate and document audio feature flags. - 0.1.6 (2026-01-25): Remove the deprecated legacy shader backend documentation. diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index 2b6ddb72..c7a8c609 100644 --- a/docs/specs/audio-devices.md +++ b/docs/specs/audio-devices.md @@ -3,13 +3,13 @@ title: "Audio Device Abstraction" document_id: "audio-device-abstraction-2026-01-28" status: "draft" created: "2026-01-28T22:59:00Z" -last_updated: "2026-01-29T21:58:43Z" -version: "0.1.9" +last_updated: "2026-01-30T22:15:27Z" +version: "0.1.10" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "e8944565ebba497ec59a72bdfdb855a97f41a666" +repo_commit: "e179f7de3b43f9cd822b4f7ab520c095dc3c6911" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -324,10 +324,10 @@ Implementation rules Features -- `lambda-rs` granular feature: `audio-output-device` (default: disabled) +- `lambda-rs` granular feature: `audio-output-device` (default: enabled) - Enables the `lambda-rs::audio` output device surface. - Enables `lambda-rs-platform` `audio-device` internally. -- `lambda-rs` umbrella feature: `audio` (default: disabled) +- `lambda-rs` umbrella feature: `audio` (default: enabled) - Composes `audio-output-device` only. ### Application Interaction @@ -520,10 +520,10 @@ Validation rules Features introduced by this spec - Crate: `lambda-rs` - - Granular feature: `audio-output-device` (default: disabled) + - Granular feature: `audio-output-device` (default: enabled) - Enables `lambda-rs::audio` output device APIs. - Enables `lambda-rs-platform` `audio-device` internally. - - Umbrella feature: `audio` (default: disabled) + - Umbrella feature: `audio` (default: enabled) - Composes `audio-output-device` only. - Crate: `lambda-rs-platform` - Granular feature: `audio-device` (default: disabled) @@ -602,7 +602,7 @@ Example (lambda-rs facade) This example is the primary application-facing reference. - Add `crates/lambda-rs/examples/audio_sine_wave.rs` (feature: - `audio-output-device`) that: + `audio-output-device`, enabled by default) that: - Prints `lambda_rs::audio::enumerate_output_devices()` output. - Builds the default output device via the facade builder and plays a deterministic 440 Hz tone for at least 2 seconds. @@ -618,14 +618,14 @@ Unit tests (crate: `lambda-rs-platform`) Commands -- `cargo test -p lambda-rs --features audio-output-device -- --nocapture` +- `cargo test -p lambda-rs -- --nocapture` - `cargo test -p lambda-rs-platform --features audio-device -- --nocapture` Manual checks - Run the `lambda-rs` facade example and confirm audible playback for at least 2 seconds. - - `cargo run -p lambda-rs --example audio_sine_wave --features audio-output-device` + - `cargo run -p lambda-rs --example audio_sine_wave` ## Compatibility and Migration @@ -633,6 +633,7 @@ Manual checks ## Changelog +- 2026-01-30 (v0.1.10) — Enable `lambda-rs` audio features by default. - 2026-01-29 (v0.1.9) — Fix YAML front matter to use a single `version` field. - 2026-01-29 (v0.1.8) — Make the `lambda-rs` facade example the primary reference and remove the platform example requirement. From df62c624ca869e0493a3a92297d1cebe94251e69 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 15:22:56 -0800 Subject: [PATCH 14/17] [update] specification to match the audio implementation. --- docs/specs/audio-devices.md | 146 +++++++++++++++++++++--------------- 1 file changed, 86 insertions(+), 60 deletions(-) diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index c7a8c609..bd315a7b 100644 --- a/docs/specs/audio-devices.md +++ b/docs/specs/audio-devices.md @@ -3,13 +3,13 @@ title: "Audio Device Abstraction" document_id: "audio-device-abstraction-2026-01-28" status: "draft" created: "2026-01-28T22:59:00Z" -last_updated: "2026-01-30T22:15:27Z" -version: "0.1.10" +last_updated: "2026-01-30T23:21:06Z" +version: "0.1.12" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "e179f7de3b43f9cd822b4f7ab520c095dc3c6911" +repo_commit: "7e7851da903fbc7b4aa35acd511999df659af237" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -82,23 +82,23 @@ tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] ## Architecture Overview -- Crate `lambda-rs` +- Crate `lambda` (package: `lambda-rs`) - `audio` module provides the application-facing API for output device access. - The public API MUST remain backend-agnostic and MUST NOT expose `cpal` or `lambda-rs-platform` types. -- Crate `lambda-rs-platform` - - `cpal` module provides internal implementations used by `lambda-rs` - implementations. +- Crate `lambda_platform` (package: `lambda-rs-platform`) + - `cpal` module provides internal implementations used by `lambda-rs`. - `cpal::device` wraps `cpal` device discovery and stream creation. + - The backend dependency MUST be pinned to `cpal = "=0.17.1"`. Data flow ``` application - └── lambda-rs::audio + └── lambda::audio ├── enumerate_output_devices() -> Vec └── AudioOutputDeviceBuilder::build() -> AudioOutputDevice - └── lambda-rs-platform::cpal (internal) + └── lambda_platform::cpal (internal) ├── enumerate_devices() -> Vec └── AudioDeviceBuilder::build() -> AudioDevice └── cpal (host + device + stream) @@ -220,8 +220,9 @@ pub fn enumerate_devices() -> Result, AudioError>; ### lambda-rs Public API `lambda-rs` provides the application-facing audio API and translates to -`lambda-rs-platform::cpal` internally. The `lambda-rs` layer MUST remain -backend-agnostic and MUST NOT expose `cpal` types. +`lambda_platform::cpal` (package: `lambda-rs-platform`) internally. The +`lambda-rs` layer MUST remain backend-agnostic and MUST NOT expose `cpal` +types. Crate boundary @@ -229,7 +230,7 @@ Crate boundary `lambda-rs-platform` directly. - `lambda-rs-platform` audio APIs are internal implementation details and MAY change without regard for application compatibility. -- `lambda-rs::audio` MUST remain backend-agnostic and MUST NOT require direct +- `lambda::audio` MUST remain backend-agnostic and MUST NOT require direct use of `lambda-rs-platform` types by applications. Application-facing API surface @@ -316,16 +317,17 @@ pub fn enumerate_output_devices( Implementation rules -- `lambda-rs::audio` MUST translate into `lambda-rs-platform::cpal` internally. -- `lambda-rs::audio` MUST define its own public types and MUST NOT re-export +- `lambda::audio` MUST translate into `lambda_platform::cpal` (package: + `lambda-rs-platform`) internally. +- `lambda::audio` MUST define its own public types and MUST NOT re-export `lambda-rs-platform` audio types. -- `lambda-rs::audio::AudioError` MUST remain backend-agnostic and MUST NOT +- `lambda::audio::AudioError` MUST remain backend-agnostic and MUST NOT expose `cpal` types. Features - `lambda-rs` granular feature: `audio-output-device` (default: enabled) - - Enables the `lambda-rs::audio` output device surface. + - Enables the `lambda::audio` output device surface. - Enables `lambda-rs-platform` `audio-device` internally. - `lambda-rs` umbrella feature: `audio` (default: enabled) - Composes `audio-output-device` only. @@ -333,12 +335,12 @@ Features ### Application Interaction This section describes the intended application-facing workflow via -`lambda-rs::audio`. +`lambda::audio`. Initialization flow -- An application SHOULD enumerate devices to present names in diagnostics or a - settings UI. +- An application SHOULD enumerate devices to present names in diagnostics + output. - An application SHOULD create exactly one default output device during startup. - The application MUST keep the returned device handle alive for as long as @@ -347,7 +349,7 @@ Initialization flow Device enumeration ```rust -let devices = lambda_rs::audio::enumerate_output_devices()?; +let devices = lambda::audio::enumerate_output_devices()?; for device in devices { println!( "audio: {}{}", @@ -363,7 +365,7 @@ Default device initialization (deterministic test tone) let mut phase: f32 = 0.0; let frequency_hz: f32 = 440.0; -let _audio_output = lambda_rs::audio::AudioOutputDeviceBuilder::new() +let _audio_output = lambda::audio::AudioOutputDeviceBuilder::new() .with_sample_rate(48_000) .with_channels(2) .build_with_output_callback(move |writer, info| { @@ -394,11 +396,11 @@ Runtime interaction Minimal application sketch ```rust -use lambda_rs::runtime::start_runtime; -use lambda_rs::runtimes::ApplicationRuntimeBuilder; +use lambda::runtime::start_runtime; +use lambda::runtimes::ApplicationRuntimeBuilder; -fn main() -> Result<(), lambda_rs::audio::AudioError> { - let _audio_output = lambda_rs::audio::AudioOutputDeviceBuilder::new() +fn main() -> Result<(), lambda::audio::AudioError> { + let _audio_output = lambda::audio::AudioOutputDeviceBuilder::new() .build()?; let runtime = ApplicationRuntimeBuilder::new("Lambda App").build(); @@ -409,7 +411,7 @@ fn main() -> Result<(), lambda_rs::audio::AudioError> { ### Behavior -Device enumeration (`lambda_rs::audio::enumerate_output_devices`) +Device enumeration (`lambda::audio::enumerate_output_devices`) - `enumerate_output_devices` MUST return only output-capable devices. - `enumerate_output_devices` MUST include the default output device when one @@ -425,9 +427,9 @@ Default device initialization (`AudioOutputDeviceBuilder::build`) - `build` MUST validate the requested configuration against the device’s supported output configurations. - When `sample_rate` is not specified, `build` MUST prefer 48_000 Hz when - supported and otherwise fall back to the device default configuration. -- When `channels` is not specified, `build` MUST prefer stereo (`2`) when - supported and otherwise fall back to the device default configuration. + supported and otherwise clamp to the nearest supported rate within the chosen + configuration range. +- When `channels` is not specified, `build` MUST NOT filter by channel count. - `build` MUST create an output stream that produces silence (all samples set to zero) and MUST keep the stream alive for the lifetime of `AudioOutputDevice`. @@ -484,15 +486,15 @@ Error type - `lambda-rs` MUST define an `AudioError` error enum suitable for actionable diagnostics. -- `lambda-rs::audio::AudioError` MUST remain backend-agnostic and MUST NOT +- `lambda::audio::AudioError` MUST remain backend-agnostic and MUST NOT expose `cpal` or `lambda-rs-platform` types. - `lambda-rs-platform` MUST define an internal `AudioError` suitable for actionable diagnostics inside the platform layer. -- `lambda-rs-platform::cpal::AudioError` MUST NOT expose `cpal` types in its - public API. -- `lambda-rs` MUST translate `lambda-rs-platform::cpal::AudioError` into - `lambda-rs::audio::AudioError`. Backend-specific failures SHOULD map to - `AudioError::Platform { details }`. +- `lambda_platform::cpal::AudioError` (package: `lambda-rs-platform`) MUST NOT + expose `cpal` types in its public API. +- `lambda-rs` MUST translate `lambda_platform::cpal::AudioError` (package: + `lambda-rs-platform`) into `lambda::audio::AudioError`. Backend-specific + failures SHOULD map to `AudioError::Platform { details }`. Platform `AudioError` variants (internal) @@ -521,7 +523,7 @@ Features introduced by this spec - Crate: `lambda-rs` - Granular feature: `audio-output-device` (default: enabled) - - Enables `lambda-rs::audio` output device APIs. + - Enables `lambda::audio` output device APIs. - Enables `lambda-rs-platform` `audio-device` internally. - Umbrella feature: `audio` (default: enabled) - Composes `audio-output-device` only. @@ -568,32 +570,50 @@ Feature gating requirements ## Requirements Checklist - Functionality - - [ ] Feature flags defined (`lambda-rs`: `audio-output-device`, `audio`) - - [ ] Feature flags defined (`lambda-rs-platform`: `audio-device`, `audio`) - - [ ] `enumerate_output_devices` implemented and returns output devices - - [ ] `AudioOutputDeviceBuilder::build` initializes default output device - - [ ] `AudioOutputDeviceBuilder::build_with_output_callback` invokes callback - - [ ] Stream created and kept alive for `AudioOutputDevice` lifetime - - [ ] Platform enumeration implemented (`lambda-rs-platform::cpal`) - - [ ] Platform builder implemented (`lambda-rs-platform::cpal`) + - [x] Feature flags defined (`lambda-rs`: `audio-output-device`, `audio`) + (`crates/lambda-rs/Cargo.toml:22`) + - [x] Feature flags defined (`lambda-rs-platform`: `audio-device`, `audio`) + (`crates/lambda-rs-platform/Cargo.toml:53`) + - [x] `enumerate_output_devices` implemented and returns output devices + (`crates/lambda-rs/src/audio.rs:294`) + - [x] `AudioOutputDeviceBuilder::build` initializes default output device + (`crates/lambda-rs/src/audio.rs:222`, + `crates/lambda-rs-platform/src/cpal/device.rs:403`) + - [x] `AudioOutputDeviceBuilder::build_with_output_callback` invokes callback + (`crates/lambda-rs/src/audio.rs:247`, + `crates/lambda-rs-platform/src/cpal/device.rs:524`) + - [x] Stream created and kept alive for `AudioOutputDevice` lifetime + (`crates/lambda-rs/src/audio.rs:182`, + `crates/lambda-rs-platform/src/cpal/device.rs:352`) + - [x] Platform enumeration implemented (`lambda_platform::cpal`) + (`crates/lambda-rs-platform/src/cpal/device.rs:807`) + - [x] Platform builder implemented (`lambda_platform::cpal`) + (`crates/lambda-rs-platform/src/cpal/device.rs:365`) - API Surface - - [ ] Public `lambda-rs` types implemented: `AudioOutputDevice`, + - [x] Public `lambda` types implemented: `AudioOutputDevice`, `AudioOutputDeviceInfo`, `AudioOutputDeviceBuilder`, `AudioCallbackInfo`, - `AudioOutputWriter`, `AudioError` - - [ ] Internal platform types implemented: `AudioDevice`, `AudioDeviceInfo`, + `AudioOutputWriter`, `AudioError` (`crates/lambda-rs/src/audio.rs:12`) + - [x] Internal platform types implemented: `AudioDevice`, `AudioDeviceInfo`, `AudioDeviceBuilder`, `AudioCallbackInfo`, `AudioOutputWriter`, `AudioError` - - [ ] `lambda-rs::audio` does not re-export `lambda-rs-platform` types + (`crates/lambda-rs-platform/src/cpal/device.rs:12`) + - [x] `lambda::audio` does not re-export `lambda-rs-platform` types + (`crates/lambda-rs/src/audio.rs:10`) - Validation and Errors - - [ ] Invalid builder inputs rejected (sample rate and channel count) - - [ ] Descriptive `AudioError` variants emitted on failures - - [ ] Unsupported configurations reported via `AudioError::UnsupportedConfig` + - [x] Invalid builder inputs rejected (sample rate and channel count) + (`crates/lambda-rs-platform/src/cpal/device.rs:403`, + `crates/lambda-rs-platform/src/cpal/device.rs:847`) + - [x] Descriptive `AudioError` variants emitted on failures + (`crates/lambda-rs/src/audio.rs:65`, + `crates/lambda-rs-platform/src/cpal/device.rs:265`) + - [x] Unsupported configurations reported via `AudioError::UnsupportedConfig` + (`crates/lambda-rs-platform/src/cpal/device.rs:800`, + `crates/lambda-rs/src/audio.rs:72`) - Documentation and Examples - - [ ] `docs/features.md` updated with audio feature documentation - - [ ] Example added demonstrating audible playback (behind `audio-output-device`) - - [ ] `lambda-rs` audio facade implemented - -For each checked item, include a reference to a commit, pull request, or file -path that demonstrates the implementation. + - [x] `docs/features.md` updated with audio feature documentation + (`docs/features.md:1`) + - [x] Example added demonstrating audible playback (behind `audio-output-device`) + (`crates/lambda-rs/examples/audio_sine_wave.rs:1`) + - [x] `lambda-rs` audio facade implemented (`crates/lambda-rs/src/audio.rs:1`) ## Verification and Testing @@ -603,7 +623,7 @@ This example is the primary application-facing reference. - Add `crates/lambda-rs/examples/audio_sine_wave.rs` (feature: `audio-output-device`, enabled by default) that: - - Prints `lambda_rs::audio::enumerate_output_devices()` output. + - Prints `lambda::audio::enumerate_output_devices()` output. - Builds the default output device via the facade builder and plays a deterministic 440 Hz tone for at least 2 seconds. @@ -614,7 +634,7 @@ Unit tests (crate: `lambda-rs-platform`) - `with_sample_rate` and `with_channels` override requested values. - Invalid values (`0`) are rejected. - Enumeration - - `enumerate_devices` returns `Ok(_)` and does not panic. + - `enumerate_devices` returns `Result<_, _>` and does not panic. Commands @@ -633,12 +653,18 @@ Manual checks ## Changelog +- 2026-01-30 (v0.1.12) — Populate the requirements checklist with file + references matching the implemented surface. +- 2026-01-30 (v0.1.11) — Align examples with the `lambda` crate name, document + the internal `lambda_platform::cpal` path and pin, and refine default + configuration selection requirements to match the implementation. - 2026-01-30 (v0.1.10) — Enable `lambda-rs` audio features by default. - 2026-01-29 (v0.1.9) — Fix YAML front matter to use a single `version` field. - 2026-01-29 (v0.1.8) — Make the `lambda-rs` facade example the primary reference and remove the platform example requirement. - 2026-01-29 (v0.1.7) — Rename the platform audio implementation module to - `lambda-rs-platform::cpal` to reflect the internal backend. + `lambda_platform::cpal` (package: `lambda-rs-platform`) to reflect the + internal backend. - 2026-01-29 (v0.1.6) — Specify `lambda-rs` as the only supported application-facing API and treat `lambda-rs-platform` as internal. - 2026-01-29 (v0.1.5) — Specify how `lambda-rs` applications enumerate devices From 2ae6419f001550adaa13a387b94fdf2bd86a882b Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 15:42:09 -0800 Subject: [PATCH 15/17] [update] action to install alsa on linux builds and update documentation to specify the requirement for it. --- .github/workflows/compile_lambda_rs.yml | 1 + docs/features.md | 11 ++++++++--- docs/specs/audio-devices.md | 11 ++++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index 747066f9..6efa28b0 100644 --- a/.github/workflows/compile_lambda_rs.yml +++ b/.github/workflows/compile_lambda_rs.yml @@ -48,6 +48,7 @@ jobs: pkg-config libx11-dev libxcb1-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev \ libwayland-dev libudev-dev \ + libasound2-dev \ libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools - name: Configure Vulkan (Ubuntu) diff --git a/docs/features.md b/docs/features.md index fb044808..f84eebaf 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-01-30T22:48:05Z" -version: "0.1.9" +last_updated: "2026-01-30T23:40:49Z" +version: "0.1.10" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "e179f7de3b43f9cd822b4f7ab520c095dc3c6911" +repo_commit: "df62c624ca869e0493a3a92297d1cebe94251e69" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio"] @@ -33,6 +33,9 @@ relationships, and expected behavior in debug and release builds. - Release builds enable only cheap safety checks by default; validation logs and per-draw checks MUST be enabled explicitly via features. - Audio support in `lambda-rs` is enabled by default and incurs runtime cost only when an audio device is initialized and kept alive. + - Linux builds that include the default audio backend MUST provide ALSA + development headers and `pkg-config` (for example, `libasound2-dev` on + Debian/Ubuntu). ## lambda-rs @@ -132,6 +135,8 @@ Audio backend module `lambda_platform::cpal` backed by `cpal =0.17.1`. ## Changelog +- 0.1.10 (2026-01-30): Document Linux system dependencies required by the + default audio backend. - 0.1.9 (2026-01-30): Clarify workspace default audio behavior after enabling `lambda-rs` audio features by default. - 0.1.8 (2026-01-30): Enable `lambda-rs` audio features by default and update diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index bd315a7b..64f57cbf 100644 --- a/docs/specs/audio-devices.md +++ b/docs/specs/audio-devices.md @@ -3,13 +3,13 @@ title: "Audio Device Abstraction" document_id: "audio-device-abstraction-2026-01-28" status: "draft" created: "2026-01-28T22:59:00Z" -last_updated: "2026-01-30T23:21:06Z" -version: "0.1.12" +last_updated: "2026-01-30T23:40:49Z" +version: "0.1.13" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "7e7851da903fbc7b4aa35acd511999df659af237" +repo_commit: "df62c624ca869e0493a3a92297d1cebe94251e69" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -555,6 +555,9 @@ Feature gating requirements - It MUST NOT perform I/O. - User-provided output callbacks MUST follow the same real-time safety rules as the stream callback. +- Linux builds with `audio-output-device` enabled MUST provide ALSA development + headers and `pkg-config` so the `alsa-sys` dependency can link successfully + (for example, `libasound2-dev` on Debian/Ubuntu). ## Performance Considerations @@ -653,6 +656,8 @@ Manual checks ## Changelog +- 2026-01-30 (v0.1.13) — Document Linux system dependencies required by the + default audio backend. - 2026-01-30 (v0.1.12) — Populate the requirements checklist with file references matching the implemented surface. - 2026-01-30 (v0.1.11) — Align examples with the `lambda` crate name, document From f870c0fe14dde290abae87881a575e8a9d27d8d4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 19:16:33 -0800 Subject: [PATCH 16/17] [update] audio to be an optional feature and create separate matrices for testing audio out (Will make a headless/game feature set to group certain features together in the future) --- .github/workflows/compile_lambda_rs.yml | 17 ++++++++++++++++- crates/lambda-rs/Cargo.toml | 2 +- docs/features.md | 22 ++++++++++++---------- docs/specs/audio-devices.md | 22 +++++++++++++--------- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index 6efa28b0..62c12060 100644 --- a/.github/workflows/compile_lambda_rs.yml +++ b/.github/workflows/compile_lambda_rs.yml @@ -23,12 +23,21 @@ jobs: - os: ubuntu-latest rustup-toolchain: "stable" features: "lambda-rs/with-vulkan" + - os: ubuntu-latest + rustup-toolchain: "stable" + features: "lambda-rs/with-vulkan,lambda-rs/audio-output-device" - os: windows-latest rustup-toolchain: "stable" features: "lambda-rs/with-dx12" + - os: windows-latest + rustup-toolchain: "stable" + features: "lambda-rs/with-dx12,lambda-rs/audio-output-device" - os: macos-latest rustup-toolchain: "stable" features: "lambda-rs/with-metal" + - os: macos-latest + rustup-toolchain: "stable" + features: "lambda-rs/with-metal,lambda-rs/audio-output-device" steps: - name: Checkout Repository @@ -48,9 +57,15 @@ jobs: pkg-config libx11-dev libxcb1-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev \ libwayland-dev libudev-dev \ - libasound2-dev \ libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools + - name: Install Linux deps for audio + if: ${{ matrix.os == 'ubuntu-latest' && contains(matrix.features, 'lambda-rs/audio-output-device') }} + run: | + sudo apt-get update + sudo apt-get install -y \ + libasound2-dev + - name: Configure Vulkan (Ubuntu) if: ${{ matrix.os == 'ubuntu-latest' }} run: | diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index d57c476e..9a62d238 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -20,7 +20,7 @@ cargo-audit = "0.16.0" mockall = "0.14.0" [features] -default=["with-wgpu", "audio"] +default=["with-wgpu"] with-vulkan=["with-wgpu", "lambda-rs-platform/wgpu-with-vulkan"] with-opengl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] with-dx11=["with-wgpu"] diff --git a/docs/features.md b/docs/features.md index f84eebaf..8b2fc5ca 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-01-30T23:40:49Z" -version: "0.1.10" +last_updated: "2026-01-31T00:00:27Z" +version: "0.1.11" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "df62c624ca869e0493a3a92297d1cebe94251e69" +repo_commit: "2ae6419f001550adaa13a387b94fdf2bd86a882b" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio"] @@ -31,11 +31,12 @@ relationships, and expected behavior in debug and release builds. - Workspace defaults prefer `wgpu` on supported platforms and `naga` for shader compilation. - Debug builds enable all validations unconditionally via `debug_assertions`. - Release builds enable only cheap safety checks by default; validation logs and per-draw checks MUST be enabled explicitly via features. -- Audio support in `lambda-rs` is enabled by default and incurs runtime cost - only when an audio device is initialized and kept alive. - - Linux builds that include the default audio backend MUST provide ALSA - development headers and `pkg-config` (for example, `libasound2-dev` on - Debian/Ubuntu). +- Audio support in `lambda-rs` is opt-in (disabled by default) and incurs + runtime cost only when an audio device is initialized and kept alive. + - Linux builds that enable audio output devices MUST provide ALSA development + headers and `pkg-config` (for example, `libasound2-dev` on Debian/Ubuntu). +- To minimize dependencies in headless or minimal environments, prefer + `--no-default-features` and enable only the required features explicitly. ## lambda-rs @@ -59,9 +60,9 @@ Rendering backends - `with-dx11`: alias for `with-wgpu`. Audio -- `audio` (umbrella, enabled by default): enables audio support by composing +- `audio` (umbrella, disabled by default): enables audio support by composing granular audio features. This umbrella includes `audio-output-device`. -- `audio-output-device` (granular, enabled by default): enables audio output +- `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 runtime cost is proportional to the output callback workload and buffer size; @@ -135,6 +136,7 @@ Audio backend module `lambda_platform::cpal` backed by `cpal =0.17.1`. ## Changelog +- 0.1.11 (2026-01-30): Make `lambda-rs` audio features opt-in by default. - 0.1.10 (2026-01-30): Document Linux system dependencies required by the default audio backend. - 0.1.9 (2026-01-30): Clarify workspace default audio behavior after enabling diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index 64f57cbf..cb192d75 100644 --- a/docs/specs/audio-devices.md +++ b/docs/specs/audio-devices.md @@ -3,13 +3,13 @@ title: "Audio Device Abstraction" document_id: "audio-device-abstraction-2026-01-28" status: "draft" created: "2026-01-28T22:59:00Z" -last_updated: "2026-01-30T23:40:49Z" -version: "0.1.13" +last_updated: "2026-01-31T00:00:27Z" +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: "df62c624ca869e0493a3a92297d1cebe94251e69" +repo_commit: "2ae6419f001550adaa13a387b94fdf2bd86a882b" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -326,10 +326,10 @@ Implementation rules Features -- `lambda-rs` granular feature: `audio-output-device` (default: enabled) +- `lambda-rs` granular feature: `audio-output-device` (default: disabled) - Enables the `lambda::audio` output device surface. - Enables `lambda-rs-platform` `audio-device` internally. -- `lambda-rs` umbrella feature: `audio` (default: enabled) +- `lambda-rs` umbrella feature: `audio` (default: disabled) - Composes `audio-output-device` only. ### Application Interaction @@ -522,10 +522,10 @@ Validation rules Features introduced by this spec - Crate: `lambda-rs` - - Granular feature: `audio-output-device` (default: enabled) + - Granular feature: `audio-output-device` (default: disabled) - Enables `lambda::audio` output device APIs. - Enables `lambda-rs-platform` `audio-device` internally. - - Umbrella feature: `audio` (default: enabled) + - Umbrella feature: `audio` (default: disabled) - Composes `audio-output-device` only. - Crate: `lambda-rs-platform` - Granular feature: `audio-device` (default: disabled) @@ -625,7 +625,7 @@ Example (lambda-rs facade) This example is the primary application-facing reference. - Add `crates/lambda-rs/examples/audio_sine_wave.rs` (feature: - `audio-output-device`, enabled by default) that: + `audio-output-device`, disabled by default) that: - Prints `lambda::audio::enumerate_output_devices()` output. - Builds the default output device via the facade builder and plays a deterministic 440 Hz tone for at least 2 seconds. @@ -648,7 +648,7 @@ Manual checks - Run the `lambda-rs` facade example and confirm audible playback for at least 2 seconds. - - `cargo run -p lambda-rs --example audio_sine_wave` + - `cargo run -p lambda-rs --example audio_sine_wave --features audio-output-device` ## Compatibility and Migration @@ -656,6 +656,10 @@ Manual checks ## Changelog +- 2026-01-31 (v0.1.15) — Update verification command to include + `audio-output-device`. +- 2026-01-30 (v0.1.14) — Make `lambda-rs` audio features opt-in by default and + update CI to test Linux audio builds explicitly. - 2026-01-30 (v0.1.13) — Document Linux system dependencies required by the default audio backend. - 2026-01-30 (v0.1.12) — Populate the requirements checklist with file From a85dd986807fd48b55fb1b6c910009aebf55049b Mon Sep 17 00:00:00 2001 From: vmarcella Date: Fri, 30 Jan 2026 19:20:23 -0800 Subject: [PATCH 17/17] [update] test names. --- crates/lambda-rs-platform/src/cpal/device.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/cpal/device.rs index e1424535..f966c57e 100644 --- a/crates/lambda-rs-platform/src/cpal/device.rs +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -863,8 +863,13 @@ mod tests { } #[test] - fn build_returns_host_unavailable_until_backend_is_wired() { - let _result = AudioDeviceBuilder::new().build(); + fn build_does_not_panic() { + let result = AudioDeviceBuilder::new().build(); + assert!(!matches!( + result, + Err(AudioError::InvalidSampleRate { .. }) + | Err(AudioError::InvalidChannels { .. }) + )); return; } @@ -875,14 +880,17 @@ mod tests { } #[test] - fn build_with_output_callback_returns_host_unavailable_until_backend_is_wired( - ) { + fn build_with_output_callback_does_not_panic() { let result = AudioDeviceBuilder::new().build_with_output_callback( |_writer, _callback_info| { return; }, ); - let _ = result; + assert!(!matches!( + result, + Err(AudioError::InvalidSampleRate { .. }) + | Err(AudioError::InvalidChannels { .. }) + )); return; }