diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index 747066f9..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 @@ -50,6 +59,13 @@ jobs: libwayland-dev libudev-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/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 98888a0a..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 @@ -48,3 +49,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 = ["dep:cpal"] 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..f966c57e --- /dev/null +++ b/crates/lambda-rs-platform/src/cpal/device.rs @@ -0,0 +1,1138 @@ +#![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, +}; + +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 { + /// 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, + ); +} + +/// 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 { + /// 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 { + _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`]. +#[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, + }); + } + } + + 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, + }); + } + + /// 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), + { + 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, + }); + } +} + +impl Default for AudioDeviceBuilder { + fn default() -> Self { + return Self::new(); + } +} + +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 => { + 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> { + 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] + 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_does_not_panic() { + let result = AudioDeviceBuilder::new().build(); + assert!(!matches!( + result, + Err(AudioError::InvalidSampleRate { .. }) + | Err(AudioError::InvalidChannels { .. }) + )); + return; + } + + #[test] + fn enumerate_devices_does_not_panic() { + let _result = enumerate_devices(); + return; + } + + #[test] + fn build_with_output_callback_does_not_panic() { + let result = AudioDeviceBuilder::new().build_with_output_callback( + |_writer, _callback_info| { + return; + }, + ); + assert!(!matches!( + result, + Err(AudioError::InvalidSampleRate { .. }) + | Err(AudioError::InvalidChannels { .. }) + )); + 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]; + 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]); + } + + #[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 new file mode 100644 index 00000000..dbb959cf --- /dev/null +++ b/crates/lambda-rs-platform/src/cpal/mod.rs @@ -0,0 +1,16 @@ +#![allow(clippy::needless_return)] + +//! Internal audio backend abstractions used by `lambda-rs`. + +pub mod device; + +pub use device::{ + enumerate_devices, + AudioCallbackInfo, + AudioDevice, + AudioDeviceBuilder, + AudioDeviceInfo, + AudioError, + AudioOutputWriter, + AudioSampleFormat, +}; 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..9a62d238 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 +audio = ["audio-output-device"] + +# Granular feature flags +audio-output-device = ["lambda-rs-platform/audio-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/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."); +} diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio.rs new file mode 100644 index 00000000..a290168a --- /dev/null +++ b/crates/lambda-rs/src/audio.rs @@ -0,0 +1,326 @@ +#![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, + }); + } +} + +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> { + 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); +} + +#[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(); + 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; diff --git a/docs/features.md b/docs/features.md index f5f0492d..8b2fc5ca 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,44 +3,73 @@ 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-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: "229960fd426cf605c7513002b36e3942f14a3140" +repo_commit: "2ae6419f001550adaa13a387b94fdf2bd86a882b" 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 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. -## 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 +113,37 @@ 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.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 + `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. - 0.1.5 (2025-12-22): Align `lambda-rs` Cargo feature umbrella composition with diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md new file mode 100644 index 00000000..cb192d75 --- /dev/null +++ b/docs/specs/audio-devices.md @@ -0,0 +1,687 @@ +--- +title: "Audio Device Abstraction" +document_id: "audio-device-abstraction-2026-01-28" +status: "draft" +created: "2026-01-28T22:59:00Z" +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: "2ae6419f001550adaa13a387b94fdf2bd86a882b" +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` (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_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::audio + ├── enumerate_output_devices() -> Vec + └── AudioOutputDeviceBuilder::build() -> AudioOutputDevice + └── lambda_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_platform::cpal` (package: `lambda-rs-platform`) 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::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::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::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::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::audio`. + +Initialization flow + +- 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 + audio output is required. Dropping the device MUST stop output. + +Device enumeration + +```rust +let devices = lambda::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::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::runtime::start_runtime; +use lambda::runtimes::ApplicationRuntimeBuilder; + +fn main() -> Result<(), lambda::audio::AudioError> { + let _audio_output = lambda::audio::AudioOutputDeviceBuilder::new() + .build()?; + + let runtime = ApplicationRuntimeBuilder::new("Lambda App").build(); + start_runtime(runtime); + return Ok(()); +} +``` + +### Behavior + +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 + 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 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`. + +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::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_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) + +- `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::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. +- 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 + +- 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 + - [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 + - [x] Public `lambda` types implemented: `AudioOutputDevice`, + `AudioOutputDeviceInfo`, `AudioOutputDeviceBuilder`, `AudioCallbackInfo`, + `AudioOutputWriter`, `AudioError` (`crates/lambda-rs/src/audio.rs:12`) + - [x] Internal platform types implemented: `AudioDevice`, `AudioDeviceInfo`, + `AudioDeviceBuilder`, `AudioCallbackInfo`, `AudioOutputWriter`, `AudioError` + (`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 + - [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 + - [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 + +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`, 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. + +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 `Result<_, _>` and does not panic. + +Commands + +- `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` + +## Compatibility and Migration + +- None. No existing audio APIs exist in the workspace. + +## 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 + 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_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 + 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.