From 50623da120c3dc2522aeed0dbfc23f36ae00853a Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 31 Jan 2026 14:47:05 -0800 Subject: [PATCH 01/23] [update] structure to group audio dependencies together. --- crates/lambda-rs-platform/src/{ => audio}/cpal/device.rs | 2 +- crates/lambda-rs-platform/src/{ => audio}/cpal/mod.rs | 2 +- crates/lambda-rs-platform/src/audio/mod.rs | 9 +++++++++ crates/lambda-rs-platform/src/lib.rs | 2 +- crates/lambda-rs/src/audio.rs | 2 +- 5 files changed, 13 insertions(+), 4 deletions(-) rename crates/lambda-rs-platform/src/{ => audio}/cpal/device.rs (99%) rename crates/lambda-rs-platform/src/{ => audio}/cpal/mod.rs (75%) create mode 100644 crates/lambda-rs-platform/src/audio/mod.rs diff --git a/crates/lambda-rs-platform/src/cpal/device.rs b/crates/lambda-rs-platform/src/audio/cpal/device.rs similarity index 99% rename from crates/lambda-rs-platform/src/cpal/device.rs rename to crates/lambda-rs-platform/src/audio/cpal/device.rs index f966c57e..296cea26 100644 --- a/crates/lambda-rs-platform/src/cpal/device.rs +++ b/crates/lambda-rs-platform/src/audio/cpal/device.rs @@ -1,6 +1,6 @@ #![allow(clippy::needless_return)] -//! Audio output device discovery and stream initialization. +//! Audio output device discovery and stream initialization (cpal backend). //! //! This module defines a backend-agnostic surface that `lambda-rs` can use to //! enumerate and initialize audio output devices. The implementation is diff --git a/crates/lambda-rs-platform/src/cpal/mod.rs b/crates/lambda-rs-platform/src/audio/cpal/mod.rs similarity index 75% rename from crates/lambda-rs-platform/src/cpal/mod.rs rename to crates/lambda-rs-platform/src/audio/cpal/mod.rs index dbb959cf..8105c76d 100644 --- a/crates/lambda-rs-platform/src/cpal/mod.rs +++ b/crates/lambda-rs-platform/src/audio/cpal/mod.rs @@ -1,6 +1,6 @@ #![allow(clippy::needless_return)] -//! Internal audio backend abstractions used by `lambda-rs`. +//! Internal audio backend abstractions used by `lambda-rs` (cpal backend). pub mod device; diff --git a/crates/lambda-rs-platform/src/audio/mod.rs b/crates/lambda-rs-platform/src/audio/mod.rs new file mode 100644 index 00000000..e33347f6 --- /dev/null +++ b/crates/lambda-rs-platform/src/audio/mod.rs @@ -0,0 +1,9 @@ +#![allow(clippy::needless_return)] + +//! Internal audio dependency wrappers used by `lambda-rs`. +//! +//! This module is internal to the engine. Applications MUST NOT depend on +//! `lambda-rs-platform` directly. + +#[cfg(feature = "audio-device")] +pub mod cpal; diff --git a/crates/lambda-rs-platform/src/lib.rs b/crates/lambda-rs-platform/src/lib.rs index 6949deb7..899bf9f0 100644 --- a/crates/lambda-rs-platform/src/lib.rs +++ b/crates/lambda-rs-platform/src/lib.rs @@ -17,4 +17,4 @@ pub mod wgpu; pub mod winit; #[cfg(feature = "audio-device")] -pub mod cpal; +pub mod audio; diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio.rs index a290168a..c863db15 100644 --- a/crates/lambda-rs/src/audio.rs +++ b/crates/lambda-rs/src/audio.rs @@ -7,7 +7,7 @@ //! `lambda-rs-platform` and MUST NOT be exposed through the `lambda-rs` public //! API. -use lambda_platform::cpal as platform_audio; +use lambda_platform::audio::cpal as platform_audio; /// Output sample format used by an audio stream callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] From 7cf8891f861a625b989f3751fd61674d072a53fe Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 31 Jan 2026 14:48:57 -0800 Subject: [PATCH 02/23] [update] specifications to match new locations. --- docs/features.md | 8 +++--- docs/specs/audio-devices.md | 49 ++++++++++++++++++------------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/docs/features.md b/docs/features.md index 8b2fc5ca..8533a937 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-31T00:00:27Z" -version: "0.1.11" +last_updated: "2026-01-31T22:33:14Z" +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: "2ae6419f001550adaa13a387b94fdf2bd86a882b" +repo_commit: "1aaa56a242939572b6ec08eda82364c16a85e59a" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio"] @@ -133,7 +133,7 @@ Audio 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`. + backend module `lambda_platform::audio::cpal` backed by `cpal =0.17.1`. ## Changelog - 0.1.11 (2026-01-30): Make `lambda-rs` audio features opt-in by default. diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index cb192d75..e654491e 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-31T00:00:27Z" -version: "0.1.15" +last_updated: "2026-01-31T22:33:14Z" +version: "0.1.16" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "2ae6419f001550adaa13a387b94fdf2bd86a882b" +repo_commit: "1aaa56a242939572b6ec08eda82364c16a85e59a" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -98,7 +98,7 @@ application └── lambda::audio ├── enumerate_output_devices() -> Vec └── AudioOutputDeviceBuilder::build() -> AudioOutputDevice - └── lambda_platform::cpal (internal) + └── lambda_platform::audio::cpal (internal) ├── enumerate_devices() -> Vec └── AudioDeviceBuilder::build() -> AudioDevice └── cpal (host + device + stream) @@ -115,17 +115,17 @@ its audio APIs directly. Module layout -- `crates/lambda-rs-platform/src/cpal/mod.rs` +- `crates/lambda-rs-platform/src/audio/cpal/mod.rs` - Re-exports `AudioDevice`, `AudioDeviceBuilder`, `AudioDeviceInfo`, `AudioError`, and `enumerate_devices`. -- `crates/lambda-rs-platform/src/cpal/device.rs` +- `crates/lambda-rs-platform/src/audio/cpal/device.rs` - Defines `AudioDevice`, `AudioDeviceBuilder`, `AudioDeviceInfo`, `AudioError`, and `enumerate_devices`. Public API ```rust -// crates/lambda-rs-platform/src/cpal/device.rs +// crates/lambda-rs-platform/src/audio/cpal/device.rs /// Output sample format used by the platform stream callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -220,7 +220,7 @@ 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_platform::audio::cpal` (package: `lambda-rs-platform`) internally. The `lambda-rs` layer MUST remain backend-agnostic and MUST NOT expose `cpal` types. @@ -317,7 +317,7 @@ pub fn enumerate_output_devices( Implementation rules -- `lambda::audio` MUST translate into `lambda_platform::cpal` (package: +- `lambda::audio` MUST translate into `lambda_platform::audio::cpal` (package: `lambda-rs-platform`) internally. - `lambda::audio` MUST define its own public types and MUST NOT re-export `lambda-rs-platform` audio types. @@ -490,9 +490,9 @@ Error type 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 +- `lambda_platform::audio::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` MUST translate `lambda_platform::audio::cpal::AudioError` (package: `lambda-rs-platform`) into `lambda::audio::AudioError`. Backend-specific failures SHOULD map to `AudioError::Platform { details }`. @@ -581,35 +581,34 @@ Feature gating requirements (`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`) + `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] `AudioOutputDeviceBuilder::build_with_output_callback` invokes callback (`crates/lambda-rs/src/audio.rs:247`, - `crates/lambda-rs-platform/src/cpal/device.rs:524`) + `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [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`) + `crates/lambda-rs-platform/src/audio/cpal/device.rs`) + - [x] Platform enumeration implemented (`lambda_platform::audio::cpal`) + (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) + - [x] Platform builder implemented (`lambda_platform::audio::cpal`) + (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) - 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`) + (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [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`) + (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] Descriptive `AudioError` variants emitted on failures (`crates/lambda-rs/src/audio.rs:65`, - `crates/lambda-rs-platform/src/cpal/device.rs:265`) + `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] Unsupported configurations reported via `AudioError::UnsupportedConfig` - (`crates/lambda-rs-platform/src/cpal/device.rs:800`, + (`crates/lambda-rs-platform/src/audio/cpal/device.rs`, `crates/lambda-rs/src/audio.rs:72`) - Documentation and Examples - [x] `docs/features.md` updated with audio feature documentation @@ -665,14 +664,14 @@ Manual checks - 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 + the internal `lambda_platform::audio::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 + `lambda_platform::audio::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. From 536b007919ec34b5a5714b50eb0b8701223e4946 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 31 Jan 2026 15:05:39 -0800 Subject: [PATCH 03/23] [add] specification for wav and ogg decoding using symphonia. --- docs/specs/audio-file-loading.md | 343 +++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/specs/audio-file-loading.md diff --git a/docs/specs/audio-file-loading.md b/docs/specs/audio-file-loading.md new file mode 100644 index 00000000..e93449e6 --- /dev/null +++ b/docs/specs/audio-file-loading.md @@ -0,0 +1,343 @@ +--- +title: "Audio File Loading (SoundBuffer)" +document_id: "audio-file-loading-2026-01-31" +status: "draft" +created: "2026-01-31T22:07:49Z" +last_updated: "2026-01-31T23:03:17Z" +version: "0.2.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "7cf8891f861a625b989f3751fd61674d072a53fe" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "audio", "lambda-rs", "platform", "assets"] +--- + +# Audio File Loading (SoundBuffer) + +## 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) + - [Behavior](#behavior) + - [Validation and Errors](#validation-and-errors) + - [Cargo Features](#cargo-features) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) + +## Summary + +- Add the ability to load audio files from disk or in-memory bytes into a + decoded `SoundBuffer` suitable for future playback and mixing. +- Implement application-facing APIs in `lambda-rs` while placing codec + dependencies behind `lambda-rs-platform` wrappers to avoid leaking vendor + types into the public API. +- Support common formats starting with WAV and OGG Vorbis. + +## Scope + +### Goals + +- Load WAV files into decoded audio buffers. +- Load OGG Vorbis files into decoded audio buffers. +- Provide a `SoundBuffer` type holding decoded audio data (`f32` samples). +- Support loading from file path and from memory bytes. +- Provide actionable, backend-agnostic error reporting for unsupported formats + and decoding failures. + +### Non-Goals + +- MP3 support. +- Streaming large files (incremental decode, disk-backed buffers). +- Audio playback. + +## Terminology + +- SoundBuffer: a fully decoded, in-memory buffer of audio samples suitable for + immediate use by a playback or mixing system. +- Sample: a single channel value in nominal range `[-1.0, 1.0]`. +- Frame: one sample per channel at a given time (for example, stereo has 2 + samples per frame). +- Interleaved: sample order is per-frame with channels adjacent (for example, + `L0, R0, L1, R1, ...`). +- WAV: Waveform Audio File Format, typically PCM or IEEE float samples. +- OGG Vorbis: Ogg container format carrying Vorbis-compressed audio. + +## Architecture Overview + +- Crate `lambda` (package: `lambda-rs`) + - `audio` module provides the application-facing `SoundBuffer` API. + - The public API MUST remain backend-agnostic and MUST NOT expose `symphonia` + or `lambda-rs-platform` types. +- Crate `lambda_platform` (package: `lambda-rs-platform`) + - `audio::symphonia` module provides a WAV and OGG Vorbis decoding wrapper, + backed by `symphonia` 0.5.5. + - These modules are internal dependency wrappers and MAY change between + releases. + +Data flow + +``` +application + └── lambda::audio::SoundBuffer + ├── from_wav_file / from_wav_bytes + │ └── lambda_platform::audio::symphonia (internal) + └── from_ogg_file / from_ogg_bytes + └── lambda_platform::audio::symphonia (internal) +``` + +## 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 decoding APIs directly. + +Module layout (new) + +- `crates/lambda-rs-platform/src/audio/symphonia/mod.rs` + - Provides WAV and OGG Vorbis decode wrappers used by `lambda-rs`. + - The wrapper MUST use `symphonia` 0.5.5 and MUST disable non-required + decoders and demuxers via dependency feature configuration. + +Platform data model + +The platform layer MUST return decoded audio in a backend-agnostic shape that +can be converted into `lambda::audio::SoundBuffer` without exposing codec +types. + +```rust +// crates/lambda-rs-platform/src/audio_decode.rs (module name selected in +// implementation) + +#[derive(Clone, Debug, PartialEq)] +pub struct DecodedAudio { + pub samples: Vec, + pub sample_rate: u32, + pub channels: u16, +} + +#[derive(Clone, Debug)] +pub enum AudioDecodeError { + UnsupportedFormat { details: String }, + InvalidData { details: String }, + DecodeFailed { details: String }, +} +``` + +Notes + +- The implementation MAY avoid adding a shared `DecodedAudio` module and MAY + instead implement format-specific decode functions returning an equivalent + internal struct. +- The platform error type MUST implement `Display` and MUST NOT include vendor + error types in variants. + +### lambda-rs Public API + +The `SoundBuffer` API MUST be implemented in `lambda-rs`. + +Module layout (new) + +- `crates/lambda-rs/src/audio/buffer.rs` (new) + - Defines `SoundBuffer` and its file/byte loading entry points. +- `crates/lambda-rs/src/audio/mod.rs` (existing module; file layout MAY be + converted from `audio.rs` to `audio/mod.rs` to host submodules). + +Public API + +```rust +// crates/lambda-rs/src/audio/buffer.rs + +pub struct SoundBuffer { + samples: Vec, + sample_rate: u32, + channels: u16, +} + +impl SoundBuffer { + pub fn from_wav_file(path: &std::path::Path) -> Result; + pub fn from_wav_bytes(bytes: &[u8]) -> Result; + pub fn from_ogg_file(path: &std::path::Path) -> Result; + pub fn from_ogg_bytes(bytes: &[u8]) -> Result; + + pub fn sample_rate(&self) -> u32; + pub fn channels(&self) -> u16; + pub fn duration_seconds(&self) -> f32; +} +``` + +### Behavior + +- `SoundBuffer` samples MUST be interleaved `f32` samples in nominal range + `[-1.0, 1.0]`. +- `from_*_file` MUST read the entire file into memory and decode it. + - Rationale: streaming is an explicit non-goal for this work item. +- `from_*_bytes` MUST decode from the provided byte slice without attempting + any filesystem access. +- `duration_seconds` MUST be computed as: + - `frames = samples.len() / channels` + - `duration = frames as f32 / sample_rate as f32` +- WAV decoding MUST support: + - mono and stereo + - 16-bit PCM, 24-bit PCM, and 32-bit float sample representations +- OGG decoding MUST support: + - OGG Vorbis in mono and stereo + +### Validation and Errors + +The public API MUST return actionable, backend-agnostic errors. + +The existing `lambda::audio::AudioError` MUST be extended to represent decode +and I/O errors produced by sound buffer loading. + +Error behavior + +- Unsupported formats MUST return an explicit error variant indicating that + the input format is not supported (for example, a WAV with an unsupported + bit depth or a non-Vorbis Ogg stream). +- Invalid or corrupted input MUST return an explicit error variant indicating + invalid data. +- Filesystem read failures MUST return an explicit error variant indicating an + I/O failure and SHOULD include the input path in the error details. +- Errors MUST NOT panic. +- Errors MUST NOT expose vendor types. + +### Cargo Features + +Audio behavior in this workspace is opt-in and controlled via Cargo features. +This specification introduces new granular features that MUST be wired into +existing umbrella features. + +Crate `lambda-rs` (package: `lambda-rs`) + +- New granular features (disabled by default) + - `audio-sound-buffer-wav`: enables `SoundBuffer::from_wav_*`. + - `audio-sound-buffer-vorbis`: enables `SoundBuffer::from_ogg_*`. +- New umbrella feature (disabled by default) + - `audio-sound-buffer`: composes `audio-sound-buffer-wav` and + `audio-sound-buffer-vorbis`. +- Existing umbrella feature (disabled by default) + - `audio`: MUST compose `audio-output-device` and `audio-sound-buffer`. + +Crate `lambda-rs-platform` (package: `lambda-rs-platform`) + +- New granular features (disabled by default) + - `audio-decode-wav`: enables WAV decode support via the `symphonia` wrapper. + - `audio-decode-vorbis`: enables OGG Vorbis decode support via the `symphonia` + wrapper. +- Existing umbrella feature (disabled by default) + - `audio`: MUST compose `audio-device`, `audio-decode-wav`, and + `audio-decode-vorbis`. + +Feature gating rules + +- The `lambda::audio` module MUST be compiled when either `audio-output-device` + or `audio-sound-buffer` is enabled. +- Format-specific entry points SHOULD be gated behind the corresponding + granular features and MUST return a deterministic error if called when the + required feature is disabled (if the symbol remains available). +- `docs/features.md` MUST be updated in the implementation change that adds + these features. + +## Constraints and Rules + +- `SoundBuffer` MUST store decoded samples as `f32` to support future mixing + and processing without requiring format-specific sample conversions. +- `SoundBuffer` MUST store sample rate and channel count from the decoded + source. +- Loading functions MUST reject inputs with `channels == 0` or + `sample_rate == 0` with a validation error. +- Audio decode dependencies MUST only be referenced from `lambda-rs-platform` + modules located under `crates/lambda-rs-platform/src/audio/symphonia/`. + +## Performance Considerations + +Recommendations + +- Decode paths SHOULD decode directly into the destination `Vec` without + additional intermediate allocations where feasible. + - Rationale: file loading already requires full-buffer allocation; extra + copies scale linearly with file size. +- Loading functions SHOULD use `Vec::try_reserve` (or equivalent) to surface + allocation errors as `AudioError` rather than panicking. + - Rationale: buffer sizes depend on file contents and may exceed memory + availability. + +## Requirements Checklist + +- Functionality + - [ ] WAV decode implemented (16-bit PCM, 24-bit PCM, 32-bit float) + - [ ] OGG Vorbis decode implemented + - [ ] Load-from-file and load-from-bytes supported +- API Surface + - [ ] `SoundBuffer` public API implemented in `lambda-rs` + - [ ] `lambda-rs` does not expose vendor/platform decode types + - [ ] `lambda::audio` module is available when sound-buffer features enabled +- Validation and Errors + - [ ] Unsupported formats return actionable errors + - [ ] Corrupt data returns actionable errors + - [ ] File I/O errors return actionable errors +- Documentation and Examples + - [ ] `docs/features.md` updated with new features and defaults + - [ ] Minimal example loads a sound file and prints metadata +- Tests + - [ ] Unit tests cover WAV mono and stereo + - [ ] Unit tests cover OGG Vorbis mono and stereo + - [ ] Test assets are stored under `crates/lambda-rs/assets/` + +For each checked item, include a reference to a commit, pull request, or file +path that demonstrates the implementation. + +## Verification and Testing + +### Unit Tests + +Coverage targets + +- WAV + - mono 16-bit PCM + - stereo 16-bit PCM + - mono 24-bit PCM + - stereo 32-bit float +- OGG Vorbis + - mono + - stereo + +Commands + +- `cargo test -p lambda-rs -- --nocapture` +- `cargo test --workspace` + +### Example + +- Add `crates/lambda-rs/examples/sound_buffer_load.rs`. +- The example SHOULD load a file path provided via CLI args and print: + - channels + - sample rate + - duration + +## Compatibility and Migration + +- Adding decoding variants to `lambda::audio::AudioError` is a source-breaking + change for applications that match the enum exhaustively. + - Migration: add a wildcard match arm or handle the new variants explicitly. +- No other user-visible behavior changes are required. + +## Changelog + +- 2026-01-31 (v0.2.0) — Center decoding on `symphonia` 0.5.5. +- 2026-01-31 (v0.1.1) — Align spec with platform audio module layout. +- 2026-01-31 (v0.1.0) — Initial draft. From 431306443182cd06accac797d1c2adbccb02a8c6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 31 Jan 2026 15:31:06 -0800 Subject: [PATCH 04/23] [refactor] audio to be it's own module. --- crates/lambda-rs/src/{audio.rs => audio/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/lambda-rs/src/{audio.rs => audio/mod.rs} (100%) diff --git a/crates/lambda-rs/src/audio.rs b/crates/lambda-rs/src/audio/mod.rs similarity index 100% rename from crates/lambda-rs/src/audio.rs rename to crates/lambda-rs/src/audio/mod.rs From 7895a17a364ba64e16c8b06f3dcba7e6e5346acb Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 31 Jan 2026 15:40:17 -0800 Subject: [PATCH 05/23] [refactor] audio module into separate modules and add features for audio decoders. --- crates/lambda-rs/Cargo.toml | 10 +- crates/lambda-rs/src/audio/devices/mod.rs | 3 + crates/lambda-rs/src/audio/devices/output.rs | 305 +++++++++++++++++ crates/lambda-rs/src/audio/error.rs | 24 ++ crates/lambda-rs/src/audio/mod.rs | 330 +------------------ crates/lambda-rs/src/lib.rs | 7 +- 6 files changed, 357 insertions(+), 322 deletions(-) create mode 100644 crates/lambda-rs/src/audio/devices/mod.rs create mode 100644 crates/lambda-rs/src/audio/devices/output.rs create mode 100644 crates/lambda-rs/src/audio/error.rs diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index 9a62d238..fb0834a5 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -35,10 +35,18 @@ with-wgpu-gl=["with-wgpu", "lambda-rs-platform/wgpu-with-gl"] # ---------------------------------- AUDIO ------------------------------------ # Umbrella features -audio = ["audio-output-device"] +audio = ["audio-output-device", "audio-sound-buffer"] # Granular feature flags audio-output-device = ["lambda-rs-platform/audio-device"] +audio-sound-buffer-wav = [] +audio-sound-buffer-vorbis = [] + +# Umbrella feature +audio-sound-buffer = [ + "audio-sound-buffer-wav", + "audio-sound-buffer-vorbis", +] # ------------------------------ RENDER VALIDATION ----------------------------- # Granular, opt-in validation flags for release builds. Debug builds enable diff --git a/crates/lambda-rs/src/audio/devices/mod.rs b/crates/lambda-rs/src/audio/devices/mod.rs new file mode 100644 index 00000000..f01b7881 --- /dev/null +++ b/crates/lambda-rs/src/audio/devices/mod.rs @@ -0,0 +1,3 @@ +#![allow(clippy::needless_return)] + +pub mod output; diff --git a/crates/lambda-rs/src/audio/devices/output.rs b/crates/lambda-rs/src/audio/devices/output.rs new file mode 100644 index 00000000..94a3fe3c --- /dev/null +++ b/crates/lambda-rs/src/audio/devices/output.rs @@ -0,0 +1,305 @@ +#![allow(clippy::needless_return)] + +//! 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::audio::cpal as platform_audio; + +use crate::audio::AudioError; + +/// 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), + }; + } +} + +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/audio/error.rs b/crates/lambda-rs/src/audio/error.rs new file mode 100644 index 00000000..079a9528 --- /dev/null +++ b/crates/lambda-rs/src/audio/error.rs @@ -0,0 +1,24 @@ +#![allow(clippy::needless_return)] + +/// 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 }, +} diff --git a/crates/lambda-rs/src/audio/mod.rs b/crates/lambda-rs/src/audio/mod.rs index c863db15..0f6c3a5a 100644 --- a/crates/lambda-rs/src/audio/mod.rs +++ b/crates/lambda-rs/src/audio/mod.rs @@ -1,326 +1,16 @@ #![allow(clippy::needless_return)] -//! Application-facing audio output devices. +//! Application-facing audio APIs. //! -//! 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. +//! This module provides backend-agnostic audio APIs 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::audio::cpal as platform_audio; +mod error; +pub use error::AudioError; -/// 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, -} +#[cfg(feature = "audio-output-device")] +pub mod devices; -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; - } -} +#[cfg(feature = "audio-output-device")] +pub use devices::output::*; diff --git a/crates/lambda-rs/src/lib.rs b/crates/lambda-rs/src/lib.rs index bfea5c6d..75473e89 100644 --- a/crates/lambda-rs/src/lib.rs +++ b/crates/lambda-rs/src/lib.rs @@ -20,7 +20,12 @@ pub mod runtime; pub mod runtimes; pub mod util; -#[cfg(feature = "audio-output-device")] +#[cfg(any( + feature = "audio-output-device", + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] pub mod audio; /// The logging module provides a simple logging interface for Lambda From 2d0258658e5c7afcec6209f2c744714aa4b2e6c0 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sat, 31 Jan 2026 16:59:17 -0800 Subject: [PATCH 06/23] [add] symphonia and initial implementation. --- Cargo.lock | 111 ++++++++++++++++++ crates/lambda-rs-platform/Cargo.toml | 10 +- crates/lambda-rs-platform/src/audio/mod.rs | 3 + .../src/audio/symphonia/mod.rs | 6 + crates/lambda-rs-platform/src/lib.rs | 7 +- 5 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 crates/lambda-rs-platform/src/audio/symphonia/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 685e297f..774ea4b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,6 +895,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -911,6 +920,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1422,6 +1437,7 @@ dependencies = [ "obj-rs", "pollster", "rand", + "symphonia", "wgpu", "windows", "winit", @@ -2730,6 +2746,101 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.102" diff --git a/crates/lambda-rs-platform/Cargo.toml b/crates/lambda-rs-platform/Cargo.toml index 3a2e2a82..92ce76f6 100644 --- a/crates/lambda-rs-platform/Cargo.toml +++ b/crates/lambda-rs-platform/Cargo.toml @@ -23,6 +23,12 @@ 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 } +symphonia = { version = "=0.5.5", optional = true, default-features = false, features = [ + "ogg", + "vorbis", + "wav", + "pcm", +] } # 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 @@ -53,7 +59,9 @@ wgpu-with-gl = ["wgpu", "wgpu/webgl"] # ---------------------------------- AUDIO ------------------------------------ # Umbrella features (disabled by default) -audio = ["audio-device"] +audio = ["audio-device", "audio-decode-wav", "audio-decode-vorbis"] # Granular feature flags (disabled by default) audio-device = ["dep:cpal"] +audio-decode-wav = ["dep:symphonia"] +audio-decode-vorbis = ["dep:symphonia"] diff --git a/crates/lambda-rs-platform/src/audio/mod.rs b/crates/lambda-rs-platform/src/audio/mod.rs index e33347f6..d6f1a39f 100644 --- a/crates/lambda-rs-platform/src/audio/mod.rs +++ b/crates/lambda-rs-platform/src/audio/mod.rs @@ -7,3 +7,6 @@ #[cfg(feature = "audio-device")] pub mod cpal; + +#[cfg(any(feature = "audio-decode-wav", feature = "audio-decode-vorbis"))] +pub mod symphonia; diff --git a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs new file mode 100644 index 00000000..25ac9f72 --- /dev/null +++ b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs @@ -0,0 +1,6 @@ +#![allow(clippy::needless_return)] + +//! `symphonia` dependency wrapper. +//! +//! This module will provide WAV and OGG Vorbis decode helpers for `lambda-rs`. +//! It is intentionally internal and MAY change between releases. diff --git a/crates/lambda-rs-platform/src/lib.rs b/crates/lambda-rs-platform/src/lib.rs index 899bf9f0..badafa3c 100644 --- a/crates/lambda-rs-platform/src/lib.rs +++ b/crates/lambda-rs-platform/src/lib.rs @@ -16,5 +16,10 @@ pub mod shader; pub mod wgpu; pub mod winit; -#[cfg(feature = "audio-device")] +#[cfg(any( + feature = "audio", + feature = "audio-device", + feature = "audio-decode-wav", + feature = "audio-decode-vorbis" +))] pub mod audio; From 7e8beb998708bd33ba474d4a408ada070e3959d8 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 1 Feb 2026 16:26:21 -0800 Subject: [PATCH 07/23] [add] decoder implementations. --- .../src/audio/symphonia/mod.rs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs index 25ac9f72..01900f85 100644 --- a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs +++ b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs @@ -4,3 +4,129 @@ //! //! This module will provide WAV and OGG Vorbis decode helpers for `lambda-rs`. //! It is intentionally internal and MAY change between releases. + +use std::{ + fmt, + io::Cursor, +}; + +use symphonia::core::{ + errors::Error, + formats::FormatOptions, + io::MediaSourceStream, + meta::MetadataOptions, + probe::Hint, +}; + +/// Fully decoded, interleaved `f32` samples with associated metadata. +#[derive(Clone, Debug, PartialEq)] +pub struct DecodedAudio { + pub samples: Vec, + pub sample_rate: u32, + pub channels: u16, +} + +/// Vendor-free errors produced by audio decoding helpers. +#[derive(Clone, Debug)] +pub enum AudioDecodeError { + UnsupportedFormat { details: String }, + InvalidData { details: String }, + DecodeFailed { details: String }, +} + +impl fmt::Display for AudioDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedFormat { details } => { + return write!(formatter, "unsupported audio format: {details}"); + } + Self::InvalidData { details } => { + return write!(formatter, "invalid audio data: {details}"); + } + Self::DecodeFailed { details } => { + return write!(formatter, "audio decode failed: {details}"); + } + } + } +} + +impl std::error::Error for AudioDecodeError {} + +fn hint_for_decode(extensions: &[&str]) -> Hint { + let mut hint_value = Hint::new(); + for extension in extensions { + hint_value.with_extension(extension); + } + return hint_value; +} + +fn map_probe_error(source_description: &str, error: Error) -> AudioDecodeError { + match error { + Error::Unsupported(_) => { + return AudioDecodeError::UnsupportedFormat { + details: format!("unsupported or unrecognized {source_description}"), + }; + } + Error::IoError(_) => { + return AudioDecodeError::InvalidData { + details: format!("failed to read {source_description} bytes"), + }; + } + other => { + return AudioDecodeError::DecodeFailed { + details: format!("{source_description} probe error: {other}"), + }; + } + } +} + +fn probe_bytes( + bytes: &[u8], + source_description: &str, + extensions: &[&str], +) -> Result<(), AudioDecodeError> { + let hint_value = hint_for_decode(extensions); + + let cursor = Cursor::new(bytes.to_vec()); + let media_source = + MediaSourceStream::new(Box::new(cursor), Default::default()); + + let probe_result = symphonia::default::get_probe() + .format( + &hint_value, + media_source, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|error| map_probe_error(source_description, error))?; + + if probe_result.format.tracks().is_empty() { + return Err(AudioDecodeError::InvalidData { + details: "no audio tracks found".to_string(), + }); + } + + return Ok(()); +} + +/// Decode WAV bytes into interleaved `f32` samples. +#[cfg(feature = "audio-decode-wav")] +pub fn decode_wav_bytes( + bytes: &[u8], +) -> Result { + probe_bytes(bytes, "WAV", &["wav"])?; + return Err(AudioDecodeError::DecodeFailed { + details: "WAV decoding not implemented yet".to_string(), + }); +} + +/// Decode OGG Vorbis bytes into interleaved `f32` samples. +#[cfg(feature = "audio-decode-vorbis")] +pub fn decode_ogg_vorbis_bytes( + bytes: &[u8], +) -> Result { + probe_bytes(bytes, "OGG Vorbis", &["ogg", "oga"])?; + return Err(AudioDecodeError::DecodeFailed { + details: "OGG Vorbis decoding not implemented yet".to_string(), + }); +} From bdb9575ccdc3431ee9991fbb786bda017dcd3777 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 1 Feb 2026 17:09:04 -0800 Subject: [PATCH 08/23] [add] sound buffer implementation & implement wav/ogg decoding. --- .../src/audio/symphonia/mod.rs | 305 +++++++++++++++++- crates/lambda-rs/Cargo.toml | 4 +- crates/lambda-rs/src/audio/buffer.rs | 134 ++++++++ crates/lambda-rs/src/audio/error.rs | 11 + crates/lambda-rs/src/audio/mod.rs | 13 + 5 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 crates/lambda-rs/src/audio/buffer.rs diff --git a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs index 01900f85..00d97e2c 100644 --- a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs +++ b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs @@ -10,9 +10,21 @@ use std::{ io::Cursor, }; +#[cfg(feature = "audio-decode-vorbis")] +use symphonia::core::codecs::CODEC_TYPE_VORBIS; +#[cfg(feature = "audio-decode-wav")] +use symphonia::core::sample::SampleFormat; use symphonia::core::{ + audio::SampleBuffer, + codecs::{ + Decoder, + DecoderOptions, + }, errors::Error, - formats::FormatOptions, + formats::{ + FormatOptions, + FormatReader, + }, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, @@ -80,11 +92,39 @@ fn map_probe_error(source_description: &str, error: Error) -> AudioDecodeError { } } -fn probe_bytes( +fn map_read_or_decode_error( + source_description: &str, + error: Error, +) -> AudioDecodeError { + match error { + Error::Unsupported(_) => { + return AudioDecodeError::UnsupportedFormat { + details: format!("unsupported {source_description} audio codec"), + }; + } + Error::DecodeError(_) => { + return AudioDecodeError::InvalidData { + details: format!("{source_description} decode error: {error}"), + }; + } + Error::IoError(_) => { + return AudioDecodeError::InvalidData { + details: format!("{source_description} read error: {error}"), + }; + } + other => { + return AudioDecodeError::DecodeFailed { + details: format!("{source_description} decode failed: {other}"), + }; + } + } +} + +fn probe_format( bytes: &[u8], source_description: &str, extensions: &[&str], -) -> Result<(), AudioDecodeError> { +) -> Result, AudioDecodeError> { let hint_value = hint_for_decode(extensions); let cursor = Cursor::new(bytes.to_vec()); @@ -106,18 +146,204 @@ fn probe_bytes( }); } + return Ok(probe_result.format); +} + +fn try_reserve_samples( + samples: &mut Vec, + source_description: &str, + frames: Option, + channels: Option, +) -> Result<(), AudioDecodeError> { + let (frames, channels) = match (frames, channels) { + (Some(frames), Some(channels)) => (frames, channels), + _ => { + return Ok(()); + } + }; + + let total_samples = frames.saturating_mul(channels as u64); + if total_samples > usize::MAX as u64 { + return Ok(()); + } + + samples.try_reserve(total_samples as usize).map_err(|_| { + return AudioDecodeError::DecodeFailed { + details: format!("failed to allocate {source_description} sample buffer"), + }; + })?; return Ok(()); } +fn decode_track_to_interleaved_f32( + format: &mut dyn FormatReader, + track_id: u32, + decoder: &mut dyn Decoder, + source_description: &str, + reserve_frames: Option, + reserve_channels: Option, +) -> Result { + let mut samples: Vec = Vec::new(); + try_reserve_samples( + &mut samples, + source_description, + reserve_frames, + reserve_channels, + )?; + + let mut sample_rate: Option = None; + let mut channel_count: Option = None; + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(Error::IoError(error)) + if error.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(error) => { + return Err(map_read_or_decode_error(source_description, error)); + } + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(Error::ResetRequired) => { + decoder.reset(); + continue; + } + Err(error) => { + return Err(map_read_or_decode_error(source_description, error)); + } + }; + + let rate = decoded.spec().rate; + if rate == 0 { + return Err(AudioDecodeError::InvalidData { + details: format!("{source_description} decoded sample rate was 0"), + }); + } + + let channels = decoded.spec().channels.count() as u16; + if channels == 0 { + return Err(AudioDecodeError::InvalidData { + details: format!("{source_description} decoded channel count was 0"), + }); + } + + if channels != 1 && channels != 2 { + return Err(AudioDecodeError::UnsupportedFormat { + details: format!( + "unsupported {source_description} channel count: {channels}" + ), + }); + } + + if let Some(previous_rate) = sample_rate { + if previous_rate != rate { + return Err(AudioDecodeError::InvalidData { + details: format!( + "{source_description} sample rate changed during decoding" + ), + }); + } + } else { + sample_rate = Some(rate); + } + + if let Some(previous_channels) = channel_count { + if previous_channels != channels { + return Err(AudioDecodeError::InvalidData { + details: format!( + "{source_description} channel count changed during decoding" + ), + }); + } + } else { + channel_count = Some(channels); + } + + let frames = decoded.frames(); + let mut sample_buffer = + SampleBuffer::::new(frames as u64, *decoded.spec()); + sample_buffer.copy_interleaved_ref(decoded); + samples.extend_from_slice(sample_buffer.samples()); + } + + let sample_rate = sample_rate.ok_or(AudioDecodeError::InvalidData { + details: format!( + "{source_description} contained no decodable audio frames" + ), + })?; + let channels = channel_count.ok_or(AudioDecodeError::InvalidData { + details: format!( + "{source_description} contained no decodable channel configuration" + ), + })?; + + if samples.is_empty() { + return Err(AudioDecodeError::InvalidData { + details: format!("{source_description} contained no decoded samples"), + }); + } + + return Ok(DecodedAudio { + samples, + sample_rate, + channels, + }); +} + /// Decode WAV bytes into interleaved `f32` samples. #[cfg(feature = "audio-decode-wav")] pub fn decode_wav_bytes( bytes: &[u8], ) -> Result { - probe_bytes(bytes, "WAV", &["wav"])?; - return Err(AudioDecodeError::DecodeFailed { - details: "WAV decoding not implemented yet".to_string(), - }); + let mut format = probe_format(bytes, "WAV", &["wav"])?; + let (track_id, codec_params) = match format.default_track() { + Some(track) => (track.id, track.codec_params.clone()), + None => { + return Err(AudioDecodeError::InvalidData { + details: "no default audio track found".to_string(), + }); + } + }; + + let sample_format = + codec_params + .sample_format + .ok_or(AudioDecodeError::UnsupportedFormat { + details: "WAV sample format is unspecified".to_string(), + })?; + + match sample_format { + SampleFormat::S16 | SampleFormat::S24 | SampleFormat::F32 => {} + other => { + return Err(AudioDecodeError::UnsupportedFormat { + details: format!("unsupported WAV sample format: {other:?}"), + }); + } + } + + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .map_err(|error| map_read_or_decode_error("WAV", error))?; + + return decode_track_to_interleaved_f32( + &mut *format, + track_id, + &mut *decoder, + "WAV", + codec_params.n_frames, + codec_params + .channels + .map(|channels| channels.count() as u16), + ); } /// Decode OGG Vorbis bytes into interleaved `f32` samples. @@ -125,8 +351,65 @@ pub fn decode_wav_bytes( pub fn decode_ogg_vorbis_bytes( bytes: &[u8], ) -> Result { - probe_bytes(bytes, "OGG Vorbis", &["ogg", "oga"])?; - return Err(AudioDecodeError::DecodeFailed { - details: "OGG Vorbis decoding not implemented yet".to_string(), - }); + let mut format = probe_format(bytes, "OGG Vorbis", &["ogg", "oga"])?; + let (track_id, codec_params) = match format.default_track() { + Some(track) => (track.id, track.codec_params.clone()), + None => { + return Err(AudioDecodeError::InvalidData { + details: "no default audio track found".to_string(), + }); + } + }; + + if codec_params.codec != CODEC_TYPE_VORBIS { + return Err(AudioDecodeError::UnsupportedFormat { + details: "OGG stream is not Vorbis".to_string(), + }); + } + + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .map_err(|error| map_read_or_decode_error("OGG Vorbis", error))?; + + return decode_track_to_interleaved_f32( + &mut *format, + track_id, + &mut *decoder, + "OGG Vorbis", + codec_params.n_frames, + codec_params + .channels + .map(|channels| channels.count() as u16), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "audio-decode-wav")] + #[test] + fn wav_decode_rejects_invalid_bytes() { + let result = decode_wav_bytes(&[0u8, 1u8, 2u8, 3u8]); + assert!(matches!( + result, + Err(AudioDecodeError::UnsupportedFormat { .. }) + | Err(AudioDecodeError::InvalidData { .. }) + | Err(AudioDecodeError::DecodeFailed { .. }) + )); + return; + } + + #[cfg(feature = "audio-decode-vorbis")] + #[test] + fn ogg_vorbis_decode_rejects_invalid_bytes() { + let result = decode_ogg_vorbis_bytes(&[0u8, 1u8, 2u8, 3u8]); + assert!(matches!( + result, + Err(AudioDecodeError::UnsupportedFormat { .. }) + | Err(AudioDecodeError::InvalidData { .. }) + | Err(AudioDecodeError::DecodeFailed { .. }) + )); + return; + } } diff --git a/crates/lambda-rs/Cargo.toml b/crates/lambda-rs/Cargo.toml index fb0834a5..091f9488 100644 --- a/crates/lambda-rs/Cargo.toml +++ b/crates/lambda-rs/Cargo.toml @@ -39,8 +39,8 @@ audio = ["audio-output-device", "audio-sound-buffer"] # Granular feature flags audio-output-device = ["lambda-rs-platform/audio-device"] -audio-sound-buffer-wav = [] -audio-sound-buffer-vorbis = [] +audio-sound-buffer-wav = ["lambda-rs-platform/audio-decode-wav"] +audio-sound-buffer-vorbis = ["lambda-rs-platform/audio-decode-vorbis"] # Umbrella feature audio-sound-buffer = [ diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs new file mode 100644 index 00000000..071419a6 --- /dev/null +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -0,0 +1,134 @@ +#![allow(clippy::needless_return)] + +use std::path::Path; + +use crate::audio::AudioError; + +/// Fully decoded, in-memory audio samples suitable for future mixing and +/// playback. +#[derive(Clone, Debug, PartialEq)] +pub struct SoundBuffer { + samples: Vec, + sample_rate: u32, + channels: u16, +} + +impl SoundBuffer { + #[cfg(feature = "audio-sound-buffer-wav")] + pub fn from_wav_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| { + return AudioError::Io { + path: Some(path.to_path_buf()), + details: error.to_string(), + }; + })?; + + return Self::from_wav_bytes(&bytes); + } + + #[cfg(feature = "audio-sound-buffer-wav")] + pub fn from_wav_bytes(bytes: &[u8]) -> Result { + let decoded = lambda_platform::audio::symphonia::decode_wav_bytes(bytes) + .map_err(map_decode_error)?; + return Self::from_decoded(decoded); + } + + #[cfg(feature = "audio-sound-buffer-vorbis")] + pub fn from_ogg_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| { + return AudioError::Io { + path: Some(path.to_path_buf()), + details: error.to_string(), + }; + })?; + + return Self::from_ogg_bytes(&bytes); + } + + #[cfg(feature = "audio-sound-buffer-vorbis")] + pub fn from_ogg_bytes(bytes: &[u8]) -> Result { + let decoded = + lambda_platform::audio::symphonia::decode_ogg_vorbis_bytes(bytes) + .map_err(map_decode_error)?; + return Self::from_decoded(decoded); + } + + fn from_decoded( + decoded: lambda_platform::audio::symphonia::DecodedAudio, + ) -> Result { + if decoded.sample_rate == 0 { + return Err(AudioError::InvalidData { + details: "decoded sample rate was 0".to_string(), + }); + } + + if decoded.channels == 0 { + return Err(AudioError::InvalidData { + details: "decoded channel count was 0".to_string(), + }); + } + + return Ok(Self { + samples: decoded.samples, + sample_rate: decoded.sample_rate, + channels: decoded.channels, + }); + } + + pub fn sample_rate(&self) -> u32 { + return self.sample_rate; + } + + pub fn channels(&self) -> u16 { + return self.channels; + } + + pub fn duration_seconds(&self) -> f32 { + if self.channels == 0 || self.sample_rate == 0 { + return 0.0; + } + + let channels = self.channels as usize; + let frames = self.samples.len() / channels; + return frames as f32 / self.sample_rate as f32; + } +} + +fn map_decode_error( + error: lambda_platform::audio::symphonia::AudioDecodeError, +) -> AudioError { + match error { + lambda_platform::audio::symphonia::AudioDecodeError::UnsupportedFormat { + details, + } => { + return AudioError::UnsupportedFormat { details }; + } + lambda_platform::audio::symphonia::AudioDecodeError::InvalidData { + details, + } => { + return AudioError::InvalidData { details }; + } + lambda_platform::audio::symphonia::AudioDecodeError::DecodeFailed { + details, + } => { + return AudioError::DecodeFailed { details }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duration_seconds_computes_expected_value() { + let buffer = SoundBuffer { + samples: vec![0.0; 48000], + sample_rate: 48000, + channels: 1, + }; + + assert_eq!(buffer.duration_seconds(), 1.0); + return; + } +} diff --git a/crates/lambda-rs/src/audio/error.rs b/crates/lambda-rs/src/audio/error.rs index 079a9528..fed9ee3f 100644 --- a/crates/lambda-rs/src/audio/error.rs +++ b/crates/lambda-rs/src/audio/error.rs @@ -10,6 +10,17 @@ pub enum AudioError { InvalidSampleRate { requested: u32 }, /// The requested channel count was invalid. InvalidChannels { requested: u16 }, + /// An error occurred while reading audio bytes from disk. + Io { + path: Option, + details: String, + }, + /// The input format or codec is unsupported by the configured features. + UnsupportedFormat { details: String }, + /// The input bytes were invalid or corrupted. + InvalidData { details: String }, + /// An unrecoverable decoding failure occurred. + DecodeFailed { details: String }, /// No default audio output device is available. NoDefaultDevice, /// No supported output configuration satisfied the request. diff --git a/crates/lambda-rs/src/audio/mod.rs b/crates/lambda-rs/src/audio/mod.rs index 0f6c3a5a..58f22bd9 100644 --- a/crates/lambda-rs/src/audio/mod.rs +++ b/crates/lambda-rs/src/audio/mod.rs @@ -9,6 +9,19 @@ mod error; pub use error::AudioError; +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] +mod buffer; +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] +pub use buffer::SoundBuffer; + #[cfg(feature = "audio-output-device")] pub mod devices; From 850b648da82b2a6c3dda04816a032de4f2dfa3f2 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 1 Feb 2026 17:45:43 -0800 Subject: [PATCH 09/23] [add] sound files to git-lfs to avoid tracking. --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index 24a8e879..ef9ebd25 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ *.png filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text +*.oga filter=lfs diff=lfs merge=lfs -text From 5d43a864febd72111671a4fab701cb0e5d2538b6 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Sun, 1 Feb 2026 17:48:56 -0800 Subject: [PATCH 10/23] [add] tests for loading ogg/wav files with various sampling rates and output formats. --- .../audio/slash_vorbis_stereo_48000.ogg | 3 + .../assets/audio/tone_f32_stereo_44100.wav | 3 + .../assets/audio/tone_s16_mono_44100.wav | 3 + .../assets/audio/tone_s16_stereo_44100.wav | 3 + .../assets/audio/tone_s24_mono_44100.wav | 3 + .../src/audio/symphonia/mod.rs | 155 +++++++++++++++--- .../lambda-rs/examples/sound_buffer_load.rs | 88 ++++++++++ crates/lambda-rs/src/audio/buffer.rs | 56 +++++++ crates/lambda-rs/src/audio/error.rs | 60 +++++++ docs/features.md | 31 +++- 10 files changed, 381 insertions(+), 24 deletions(-) create mode 100644 crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg create mode 100644 crates/lambda-rs-platform/assets/audio/tone_f32_stereo_44100.wav create mode 100644 crates/lambda-rs-platform/assets/audio/tone_s16_mono_44100.wav create mode 100644 crates/lambda-rs-platform/assets/audio/tone_s16_stereo_44100.wav create mode 100644 crates/lambda-rs-platform/assets/audio/tone_s24_mono_44100.wav create mode 100644 crates/lambda-rs/examples/sound_buffer_load.rs diff --git a/crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg b/crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg new file mode 100644 index 00000000..98c40948 --- /dev/null +++ b/crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb635f26da26ae2dadd8560ed3e0451381b90e7b5abfbe3303ec33f27f340e43 +size 14370 diff --git a/crates/lambda-rs-platform/assets/audio/tone_f32_stereo_44100.wav b/crates/lambda-rs-platform/assets/audio/tone_f32_stereo_44100.wav new file mode 100644 index 00000000..68236615 --- /dev/null +++ b/crates/lambda-rs-platform/assets/audio/tone_f32_stereo_44100.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7241cee48cae37e67abcac3aa06fbf1c478828c896fc92d594605d9e44721476 +size 35336 diff --git a/crates/lambda-rs-platform/assets/audio/tone_s16_mono_44100.wav b/crates/lambda-rs-platform/assets/audio/tone_s16_mono_44100.wav new file mode 100644 index 00000000..f3a34531 --- /dev/null +++ b/crates/lambda-rs-platform/assets/audio/tone_s16_mono_44100.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dfea227c9ae756cf4176f2a15ce702be6e1e3bc53985997e962c3fc3c5a3d1f +size 8864 diff --git a/crates/lambda-rs-platform/assets/audio/tone_s16_stereo_44100.wav b/crates/lambda-rs-platform/assets/audio/tone_s16_stereo_44100.wav new file mode 100644 index 00000000..7a4b30b5 --- /dev/null +++ b/crates/lambda-rs-platform/assets/audio/tone_s16_stereo_44100.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fe4cb948618e2945b70edd203639092b134bcbd6d841d44488a0bb07c7a7af4 +size 17684 diff --git a/crates/lambda-rs-platform/assets/audio/tone_s24_mono_44100.wav b/crates/lambda-rs-platform/assets/audio/tone_s24_mono_44100.wav new file mode 100644 index 00000000..8789a420 --- /dev/null +++ b/crates/lambda-rs-platform/assets/audio/tone_s24_mono_44100.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2f1da451b14d054360935d78127ebc29723b7aed12809f82f259a5870034eb5 +size 13274 diff --git a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs index 00d97e2c..5358d63e 100644 --- a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs +++ b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs @@ -12,10 +12,11 @@ use std::{ #[cfg(feature = "audio-decode-vorbis")] use symphonia::core::codecs::CODEC_TYPE_VORBIS; -#[cfg(feature = "audio-decode-wav")] -use symphonia::core::sample::SampleFormat; use symphonia::core::{ - audio::SampleBuffer, + audio::{ + AudioBufferRef, + SampleBuffer, + }, codecs::{ Decoder, DecoderOptions, @@ -193,6 +194,7 @@ fn decode_track_to_interleaved_f32( let mut sample_rate: Option = None; let mut channel_count: Option = None; + let mut wav_sample_format_validated = false; loop { let packet = match format.next_packet() { @@ -269,6 +271,15 @@ fn decode_track_to_interleaved_f32( } let frames = decoded.frames(); + if frames == 0 { + continue; + } + + if source_description == "WAV" && !wav_sample_format_validated { + validate_wav_decoded_sample_format(&decoded)?; + wav_sample_format_validated = true; + } + let mut sample_buffer = SampleBuffer::::new(frames as u64, *decoded.spec()); sample_buffer.copy_interleaved_ref(decoded); @@ -299,6 +310,43 @@ fn decode_track_to_interleaved_f32( }); } +fn validate_wav_decoded_sample_format( + decoded: &AudioBufferRef<'_>, +) -> Result<(), AudioDecodeError> { + match decoded { + AudioBufferRef::S16(_) + | AudioBufferRef::S24(_) + | AudioBufferRef::F32(_) => { + return Ok(()); + } + other => { + return Err(AudioDecodeError::UnsupportedFormat { + details: format!( + "unsupported WAV decoded sample format: {}", + wav_decoded_sample_format_name(other) + ), + }); + } + } +} + +fn wav_decoded_sample_format_name( + decoded: &AudioBufferRef<'_>, +) -> &'static str { + match decoded { + AudioBufferRef::U8(_) => "U8", + AudioBufferRef::U16(_) => "U16", + AudioBufferRef::U24(_) => "U24", + AudioBufferRef::U32(_) => "U32", + AudioBufferRef::S8(_) => "S8", + AudioBufferRef::S16(_) => "S16", + AudioBufferRef::S24(_) => "S24", + AudioBufferRef::S32(_) => "S32", + AudioBufferRef::F32(_) => "F32", + AudioBufferRef::F64(_) => "F64", + } +} + /// Decode WAV bytes into interleaved `f32` samples. #[cfg(feature = "audio-decode-wav")] pub fn decode_wav_bytes( @@ -314,22 +362,6 @@ pub fn decode_wav_bytes( } }; - let sample_format = - codec_params - .sample_format - .ok_or(AudioDecodeError::UnsupportedFormat { - details: "WAV sample format is unspecified".to_string(), - })?; - - match sample_format { - SampleFormat::S16 | SampleFormat::S24 | SampleFormat::F32 => {} - other => { - return Err(AudioDecodeError::UnsupportedFormat { - details: format!("unsupported WAV sample format: {other:?}"), - }); - } - } - let mut decoder = symphonia::default::get_codecs() .make(&codec_params, &DecoderOptions::default()) .map_err(|error| map_read_or_decode_error("WAV", error))?; @@ -387,6 +419,36 @@ pub fn decode_ogg_vorbis_bytes( mod tests { use super::*; + #[cfg(feature = "audio-decode-wav")] + const TONE_S16_MONO_44100_WAV: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/audio/tone_s16_mono_44100.wav" + )); + + #[cfg(feature = "audio-decode-wav")] + const TONE_S16_STEREO_44100_WAV: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/audio/tone_s16_stereo_44100.wav" + )); + + #[cfg(feature = "audio-decode-wav")] + const TONE_S24_MONO_44100_WAV: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/audio/tone_s24_mono_44100.wav" + )); + + #[cfg(feature = "audio-decode-wav")] + const TONE_F32_STEREO_44100_WAV: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/audio/tone_f32_stereo_44100.wav" + )); + + #[cfg(feature = "audio-decode-vorbis")] + const SLASH_VORBIS_STEREO_48000_OGG: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/audio/slash_vorbis_stereo_48000.ogg" + )); + #[cfg(feature = "audio-decode-wav")] #[test] fn wav_decode_rejects_invalid_bytes() { @@ -400,6 +462,50 @@ mod tests { return; } + #[cfg(feature = "audio-decode-wav")] + #[test] + fn wav_decode_s16_mono_fixture_decodes() { + let decoded = + decode_wav_bytes(TONE_S16_MONO_44100_WAV).expect("decode failed"); + assert_eq!(decoded.sample_rate, 44100); + assert_eq!(decoded.channels, 1); + assert_eq!(decoded.samples.len(), 4410); + return; + } + + #[cfg(feature = "audio-decode-wav")] + #[test] + fn wav_decode_s16_stereo_fixture_decodes() { + let decoded = + decode_wav_bytes(TONE_S16_STEREO_44100_WAV).expect("decode failed"); + assert_eq!(decoded.sample_rate, 44100); + assert_eq!(decoded.channels, 2); + assert_eq!(decoded.samples.len(), 4410 * 2); + return; + } + + #[cfg(feature = "audio-decode-wav")] + #[test] + fn wav_decode_s24_mono_fixture_decodes() { + let decoded = + decode_wav_bytes(TONE_S24_MONO_44100_WAV).expect("decode failed"); + assert_eq!(decoded.sample_rate, 44100); + assert_eq!(decoded.channels, 1); + assert_eq!(decoded.samples.len(), 4410); + return; + } + + #[cfg(feature = "audio-decode-wav")] + #[test] + fn wav_decode_f32_stereo_fixture_decodes() { + let decoded = + decode_wav_bytes(TONE_F32_STEREO_44100_WAV).expect("decode failed"); + assert_eq!(decoded.sample_rate, 44100); + assert_eq!(decoded.channels, 2); + assert_eq!(decoded.samples.len(), 4410 * 2); + return; + } + #[cfg(feature = "audio-decode-vorbis")] #[test] fn ogg_vorbis_decode_rejects_invalid_bytes() { @@ -412,4 +518,15 @@ mod tests { )); return; } + + #[cfg(feature = "audio-decode-vorbis")] + #[test] + fn ogg_vorbis_decode_fixture_decodes() { + let decoded = decode_ogg_vorbis_bytes(SLASH_VORBIS_STEREO_48000_OGG) + .expect("decode failed"); + assert_eq!(decoded.sample_rate, 48000); + assert_eq!(decoded.channels, 2); + assert!(!decoded.samples.is_empty()); + return; + } } diff --git a/crates/lambda-rs/examples/sound_buffer_load.rs b/crates/lambda-rs/examples/sound_buffer_load.rs new file mode 100644 index 00000000..8204fff0 --- /dev/null +++ b/crates/lambda-rs/examples/sound_buffer_load.rs @@ -0,0 +1,88 @@ +#![allow(clippy::needless_return)] + +use std::path::{ + Path, + PathBuf, +}; + +use lambda::audio::{ + AudioError, + SoundBuffer, +}; + +fn main() { + let path = match parse_path_argument() { + Ok(path) => path, + Err(message) => { + eprintln!("{message}"); + std::process::exit(2); + } + }; + + let buffer = match load_sound_buffer(&path) { + Ok(buffer) => buffer, + Err(error) => { + eprintln!("failed to load sound buffer: {error}"); + std::process::exit(1); + } + }; + + println!("path: {}", path.display()); + println!("sample_rate: {}", buffer.sample_rate()); + println!("channels: {}", buffer.channels()); + println!("duration_seconds: {:.3}", buffer.duration_seconds()); + return; +} + +fn parse_path_argument() -> Result { + let mut args = std::env::args_os(); + let program_name = args + .next() + .and_then(|value| value.into_string().ok()) + .unwrap_or_else(|| "sound_buffer_load".to_string()); + + let path = args.next().ok_or_else(|| { + return format!("usage: {program_name} "); + })?; + + return Ok(PathBuf::from(path)); +} + +fn load_sound_buffer(path: &Path) -> Result { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_else(|| "".to_string()); + + match extension.as_str() { + #[cfg(feature = "audio-sound-buffer-wav")] + "wav" => { + return SoundBuffer::from_wav_file(path); + } + #[cfg(not(feature = "audio-sound-buffer-wav"))] + "wav" => { + return Err(AudioError::UnsupportedFormat { + details: "WAV support is disabled (enable `audio-sound-buffer-wav`)" + .to_string(), + }); + } + #[cfg(feature = "audio-sound-buffer-vorbis")] + "ogg" | "oga" => { + return SoundBuffer::from_ogg_file(path); + } + #[cfg(not(feature = "audio-sound-buffer-vorbis"))] + "ogg" | "oga" => { + return Err(AudioError::UnsupportedFormat { + details: + "OGG Vorbis support is disabled (enable `audio-sound-buffer-vorbis`)" + .to_string(), + }); + } + _ => { + return Err(AudioError::UnsupportedFormat { + details: format!("unsupported file extension: {extension:?}"), + }); + } + } +} diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 071419a6..628780cb 100644 --- a/crates/lambda-rs/src/audio/buffer.rs +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -131,4 +131,60 @@ mod tests { assert_eq!(buffer.duration_seconds(), 1.0); return; } + + #[cfg(feature = "audio-sound-buffer-wav")] + #[test] + fn from_wav_bytes_decodes_fixture() { + let bytes = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../lambda-rs-platform/assets/audio/tone_s16_mono_44100.wav" + )); + + let buffer = SoundBuffer::from_wav_bytes(bytes).expect("decode failed"); + assert_eq!(buffer.sample_rate(), 44100); + assert_eq!(buffer.channels(), 1); + assert!(buffer.duration_seconds() > 0.0); + return; + } + + #[cfg(feature = "audio-sound-buffer-wav")] + #[test] + fn from_wav_file_decodes_fixture() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../lambda-rs-platform/assets/audio/tone_s16_mono_44100.wav"); + + let buffer = SoundBuffer::from_wav_file(&path).expect("decode failed"); + assert_eq!(buffer.sample_rate(), 44100); + assert_eq!(buffer.channels(), 1); + assert!(buffer.duration_seconds() > 0.0); + return; + } + + #[cfg(feature = "audio-sound-buffer-vorbis")] + #[test] + fn from_ogg_bytes_decodes_fixture() { + let bytes = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg" + )); + + let buffer = SoundBuffer::from_ogg_bytes(bytes).expect("decode failed"); + assert_eq!(buffer.sample_rate(), 48000); + assert_eq!(buffer.channels(), 2); + assert!(buffer.duration_seconds() > 0.0); + return; + } + + #[cfg(feature = "audio-sound-buffer-vorbis")] + #[test] + fn from_ogg_file_decodes_fixture() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg"); + + let buffer = SoundBuffer::from_ogg_file(&path).expect("decode failed"); + assert_eq!(buffer.sample_rate(), 48000); + assert_eq!(buffer.channels(), 2); + assert!(buffer.duration_seconds() > 0.0); + return; + } } diff --git a/crates/lambda-rs/src/audio/error.rs b/crates/lambda-rs/src/audio/error.rs index fed9ee3f..578081de 100644 --- a/crates/lambda-rs/src/audio/error.rs +++ b/crates/lambda-rs/src/audio/error.rs @@ -1,5 +1,7 @@ #![allow(clippy::needless_return)] +use std::fmt; + /// Actionable errors produced by the `lambda-rs` audio facade. /// /// This error type MUST remain backend-agnostic and MUST NOT expose platform or @@ -33,3 +35,61 @@ pub enum AudioError { /// A platform or backend specific error occurred. Platform { 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::Io { path, details } => { + if let Some(path) = path { + return write!( + formatter, + "I/O error reading {}: {details}", + path.display() + ); + } + return write!(formatter, "I/O error reading audio: {details}"); + } + Self::UnsupportedFormat { details } => { + return write!(formatter, "unsupported audio format: {details}"); + } + Self::InvalidData { details } => { + return write!(formatter, "invalid audio data: {details}"); + } + Self::DecodeFailed { details } => { + return write!(formatter, "audio decode failed: {details}"); + } + Self::NoDefaultDevice => { + return write!( + formatter, + "no default audio output device is available" + ); + } + Self::UnsupportedConfig { + requested_sample_rate, + requested_channels, + } => { + return write!( + formatter, + "unsupported audio output configuration (sample_rate={requested_sample_rate:?}, channels={requested_channels:?})" + ); + } + Self::UnsupportedSampleFormat { details } => { + return write!( + formatter, + "unsupported audio output sample format: {details}" + ); + } + Self::Platform { details } => { + return write!(formatter, "platform audio error: {details}"); + } + } + } +} + +impl std::error::Error for AudioError {} diff --git a/docs/features.md b/docs/features.md index 8533a937..9151b043 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-31T22:33:14Z" -version: "0.1.12" +last_updated: "2026-02-02T01:40:30Z" +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: "1aaa56a242939572b6ec08eda82364c16a85e59a" +repo_commit: "bdb9575ccdc3431ee9991fbb786bda017dcd3777" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio"] @@ -61,13 +61,26 @@ Rendering backends Audio - `audio` (umbrella, disabled by default): enables audio support by composing - granular audio features. This umbrella includes `audio-output-device`. + granular audio features. This umbrella includes `audio-output-device` and + `audio-sound-buffer`. - `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. +- `audio-sound-buffer` (umbrella, disabled by default): enables + `lambda::audio::SoundBuffer` loading APIs by composing the granular decode + features below. This umbrella has no runtime cost unless a sound file is + decoded and loaded into memory. +- `audio-sound-buffer-wav` (granular, disabled by default): enables WAV decode + support for `SoundBuffer`. This feature enables + `lambda-rs-platform/audio-decode-wav` internally. Runtime cost is incurred at + load time only (decode + allocation). +- `audio-sound-buffer-vorbis` (granular, disabled by default): enables OGG + Vorbis decode support for `SoundBuffer`. This feature enables + `lambda-rs-platform/audio-decode-vorbis` internally. Runtime cost is incurred + at load time only (decode + allocation). Render validation @@ -131,11 +144,19 @@ Shader backends Audio - `audio` (umbrella, disabled by default): enables platform audio support by composing granular platform audio features. This umbrella includes - `audio-device`. + `audio-device`, `audio-decode-wav`, and `audio-decode-vorbis`. - `audio-device` (granular, disabled by default): enables the internal audio backend module `lambda_platform::audio::cpal` backed by `cpal =0.17.1`. +- `audio-decode-wav` (granular, disabled by default): enables internal WAV + decoding helpers in `lambda_platform::audio::symphonia` backed by + `symphonia =0.5.5`. +- `audio-decode-vorbis` (granular, disabled by default): enables internal OGG + Vorbis decoding helpers in `lambda_platform::audio::symphonia` backed by + `symphonia =0.5.5`. ## Changelog +- 0.1.13 (2026-02-02): Document `SoundBuffer` decode features for WAV and OGG + Vorbis in `lambda-rs` and the corresponding platform decode features. - 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. From f5bd0854c87c6056e5f523edf366f0448f8fc162 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 12:19:19 -0800 Subject: [PATCH 11/23] [add] example which plays our slash sound. --- Cargo.lock | 7 ++ Cargo.toml | 2 + crates/lambda-rs/examples/play_slash_sound.rs | 86 +++++++++++++++++++ crates/lambda-rs/src/audio/buffer.rs | 14 +++ docs/specs/audio-file-loading.md | 10 ++- 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 crates/lambda-rs/examples/play_slash_sound.rs diff --git a/Cargo.lock b/Cargo.lock index 774ea4b8..83b0fa05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1399,6 +1399,13 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lambda-audio-tool" +version = "2023.1.30" +dependencies = [ + "lambda-rs", +] + [[package]] name = "lambda-obj-loader" version = "2023.1.28" diff --git a/Cargo.toml b/Cargo.toml index 83aea4b2..b1a9499f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/lambda-rs-logging", "crates/lambda-rs-platform", "tools/obj_loader", + "tools/lambda_audio", ] default-members = [ @@ -16,4 +17,5 @@ default-members = [ "crates/lambda-rs-logging", "crates/lambda-rs-platform", "tools/obj_loader", + "tools/lambda_audio", ] diff --git a/crates/lambda-rs/examples/play_slash_sound.rs b/crates/lambda-rs/examples/play_slash_sound.rs new file mode 100644 index 00000000..8e8c6dff --- /dev/null +++ b/crates/lambda-rs/examples/play_slash_sound.rs @@ -0,0 +1,86 @@ +#![allow(clippy::needless_return)] + +use std::{ + sync::{ + atomic::{ + AtomicUsize, + Ordering, + }, + Arc, + }, + time::Duration, +}; + +use lambda::audio::{ + AudioOutputDeviceBuilder, + SoundBuffer, +}; + +#[cfg(all( + feature = "audio-output-device", + feature = "audio-sound-buffer-vorbis" +))] +fn main() { + const SLASH_VORBIS_STEREO_48000_OGG: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg" + )); + + let buffer = + SoundBuffer::from_ogg_bytes(SLASH_VORBIS_STEREO_48000_OGG).unwrap(); + + let cursor = Arc::new(AtomicUsize::new(0)); + let buffer = Arc::new(buffer); + + let cursor_for_callback = cursor.clone(); + let buffer_for_callback = buffer.clone(); + + let _device = AudioOutputDeviceBuilder::new() + .with_label("play-slash-sound") + .with_sample_rate(buffer.sample_rate()) + .with_channels(buffer.channels()) + .build_with_output_callback(move |writer, _info| { + let writer_channels = writer.channels() as usize; + let writer_frames = writer.frames(); + + writer.clear(); + + if writer_channels == 0 { + return; + } + + let write_samples = writer_frames.saturating_mul(writer_channels); + let start = + cursor_for_callback.fetch_add(write_samples, Ordering::Relaxed); + + let source_samples = buffer_for_callback.samples(); + + for frame in 0..writer_frames { + for channel in 0..writer_channels { + let sample_index = start + .saturating_add(frame.saturating_mul(writer_channels)) + .saturating_add(channel); + let value = source_samples.get(sample_index).copied().unwrap_or(0.0); + writer.set_sample(frame, channel, value); + } + } + + return; + }) + .unwrap(); + + std::thread::sleep(Duration::from_secs_f32(buffer.duration_seconds() + 0.20)); + drop(_device); + return; +} + +#[cfg(not(all( + feature = "audio-output-device", + feature = "audio-sound-buffer-vorbis" +)))] +fn main() { + eprintln!( + "this example requires `audio-output-device` and `audio-sound-buffer-vorbis`" + ); + return; +} diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 628780cb..53c3bd15 100644 --- a/crates/lambda-rs/src/audio/buffer.rs +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -83,6 +83,20 @@ impl SoundBuffer { return self.channels; } + /// Return interleaved `f32` samples in nominal range `[-1.0, 1.0]`. + pub fn samples(&self) -> &[f32] { + return self.samples.as_slice(); + } + + /// Return the number of frames in this buffer. + pub fn frames(&self) -> usize { + if self.channels == 0 { + return 0; + } + + return self.samples.len() / self.channels as usize; + } + pub fn duration_seconds(&self) -> f32 { if self.channels == 0 || self.sample_rate == 0 { return 0.0; diff --git a/docs/specs/audio-file-loading.md b/docs/specs/audio-file-loading.md index e93449e6..078c4c58 100644 --- a/docs/specs/audio-file-loading.md +++ b/docs/specs/audio-file-loading.md @@ -3,13 +3,13 @@ title: "Audio File Loading (SoundBuffer)" document_id: "audio-file-loading-2026-01-31" status: "draft" created: "2026-01-31T22:07:49Z" -last_updated: "2026-01-31T23:03:17Z" -version: "0.2.0" +last_updated: "2026-02-02T17:40:16Z" +version: "0.2.1" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "7cf8891f861a625b989f3751fd61674d072a53fe" +repo_commit: "5d43a864febd72111671a4fab701cb0e5d2538b6" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "assets"] @@ -175,6 +175,8 @@ impl SoundBuffer { pub fn sample_rate(&self) -> u32; pub fn channels(&self) -> u16; + pub fn samples(&self) -> &[f32]; + pub fn frames(&self) -> usize; pub fn duration_seconds(&self) -> f32; } ``` @@ -296,7 +298,7 @@ Recommendations - Tests - [ ] Unit tests cover WAV mono and stereo - [ ] Unit tests cover OGG Vorbis mono and stereo - - [ ] Test assets are stored under `crates/lambda-rs/assets/` + - [ ] Test assets are stored under `crates/lambda-rs-platform/assets/audio/` For each checked item, include a reference to a commit, pull request, or file path that demonstrates the implementation. From 07bd48ce250726a8b919abd19ab5054bce9b9787 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 12:35:57 -0800 Subject: [PATCH 12/23] [add] tool which can load & play different audio files (Primarily for testing). --- tools/lambda_audio/Cargo.toml | 11 ++ tools/lambda_audio/src/main.rs | 263 +++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 tools/lambda_audio/Cargo.toml create mode 100644 tools/lambda_audio/src/main.rs diff --git a/tools/lambda_audio/Cargo.toml b/tools/lambda_audio/Cargo.toml new file mode 100644 index 00000000..bce40a1c --- /dev/null +++ b/tools/lambda_audio/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lambda-audio-tool" +version = "2023.1.30" +edition = "2021" + +[[bin]] +name = "lambda-audio" +path = "src/main.rs" + +[dependencies] +lambda-rs = { path = "../../crates/lambda-rs", version = "2023.1.30", default-features = false, features = ["audio"] } diff --git a/tools/lambda_audio/src/main.rs b/tools/lambda_audio/src/main.rs new file mode 100644 index 00000000..bf24098c --- /dev/null +++ b/tools/lambda_audio/src/main.rs @@ -0,0 +1,263 @@ +#![allow(clippy::needless_return)] + +use std::{ + path::Path, + sync::{ + atomic::{ + AtomicUsize, + Ordering, + }, + Arc, + }, + time::Duration, +}; + +use lambda::audio::{ + enumerate_output_devices, + AudioError, + AudioOutputDeviceBuilder, + SoundBuffer, +}; + +fn main() { + let mut args = std::env::args(); + let program_name = args.next().unwrap_or_else(|| "lambda-audio".to_string()); + + let command = args.next().unwrap_or_else(|| "help".to_string()); + + let result = match command.as_str() { + "help" | "--help" | "-h" => { + print_usage(&program_name); + Ok(()) + } + "info" => cmd_info(&program_name, args.next()), + "view" => cmd_view(&program_name, args.next()), + "play" => cmd_play(&program_name, args.next()), + "list-devices" => cmd_list_devices(), + other => { + eprintln!("unknown command: {other}"); + print_usage(&program_name); + Err(ExitError::Usage) + } + }; + + match result { + Ok(()) => { + return; + } + Err(ExitError::Usage) => { + std::process::exit(2); + } + Err(ExitError::Runtime(error)) => { + eprintln!("{error}"); + std::process::exit(1); + } + } +} + +#[derive(Debug)] +enum ExitError { + Usage, + Runtime(AudioError), +} + +fn cmd_info(program_name: &str, path: Option) -> Result<(), ExitError> { + let path = require_path(program_name, "info", path)?; + let buffer = load_sound_buffer(&path).map_err(ExitError::Runtime)?; + print_info(&path, &buffer); + return Ok(()); +} + +fn cmd_view(program_name: &str, path: Option) -> Result<(), ExitError> { + let path = require_path(program_name, "view", path)?; + let buffer = load_sound_buffer(&path).map_err(ExitError::Runtime)?; + print_info(&path, &buffer); + print_waveform(&buffer); + return Ok(()); +} + +fn cmd_play(program_name: &str, path: Option) -> Result<(), ExitError> { + let path = require_path(program_name, "play", path)?; + let buffer = load_sound_buffer(&path).map_err(ExitError::Runtime)?; + print_info(&path, &buffer); + play_buffer(&buffer).map_err(ExitError::Runtime)?; + return Ok(()); +} + +fn cmd_list_devices() -> Result<(), ExitError> { + let devices = enumerate_output_devices().map_err(ExitError::Runtime)?; + + if devices.is_empty() { + println!("no output devices found"); + return Ok(()); + } + + for device in devices { + let default_marker = if device.is_default { "*" } else { " " }; + println!("{default_marker} {}", device.name); + } + + return Ok(()); +} + +fn require_path( + program_name: &str, + command: &str, + path: Option, +) -> Result { + let Some(path) = path else { + eprintln!("usage: {program_name} {command} "); + return Err(ExitError::Usage); + }; + return Ok(path); +} + +fn load_sound_buffer(path: &str) -> Result { + let path_value = Path::new(path); + let extension = path_value + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_else(|| "".to_string()); + + match extension.as_str() { + "wav" => { + return SoundBuffer::from_wav_file(path_value); + } + "ogg" | "oga" => { + return SoundBuffer::from_ogg_file(path_value); + } + _ => { + return Err(AudioError::UnsupportedFormat { + details: format!("unsupported file extension: {extension:?}"), + }); + } + } +} + +fn print_info(path: &str, buffer: &SoundBuffer) { + println!("path: {path}"); + println!("sample_rate: {}", buffer.sample_rate()); + println!("channels: {}", buffer.channels()); + println!("frames: {}", buffer.frames()); + println!("samples: {}", buffer.samples().len()); + println!("duration_seconds: {:.3}", buffer.duration_seconds()); + return; +} + +fn print_waveform(buffer: &SoundBuffer) { + let width: usize = 64; + let height: usize = 10; + + let samples = buffer.samples(); + let channels = buffer.channels() as usize; + if samples.is_empty() || channels == 0 { + println!(""); + return; + } + + let frames = buffer.frames(); + if frames == 0 { + println!(""); + return; + } + + let step = (frames / width).max(1); + let mut peaks: Vec = Vec::with_capacity(width); + + for column in 0..width { + let start_frame = column * step; + if start_frame >= frames { + break; + } + let end_frame = ((column + 1) * step).min(frames); + + let mut peak = 0.0f32; + for frame in start_frame..end_frame { + let sample_index = frame.saturating_mul(channels); + let sample = samples.get(sample_index).copied().unwrap_or(0.0); + peak = peak.max(sample.abs()); + } + + peaks.push(peak); + } + + for row in (0..height).rev() { + let threshold = (row + 1) as f32 / height as f32; + for peak in &peaks { + let mark = if *peak >= threshold { '#' } else { ' ' }; + print!("{mark}"); + } + println!(); + } + + return; +} + +fn play_buffer(buffer: &SoundBuffer) -> Result<(), AudioError> { + let samples = buffer.samples(); + let total_samples = samples.len(); + + if total_samples == 0 { + return Err(AudioError::InvalidData { + details: "sound buffer had no samples".to_string(), + }); + } + + let cursor = Arc::new(AtomicUsize::new(0)); + let buffer = Arc::new(buffer.clone()); + + let cursor_for_callback = cursor.clone(); + let buffer_for_callback = buffer.clone(); + + let _device = AudioOutputDeviceBuilder::new() + .with_label("lambda-audio") + .with_sample_rate(buffer.sample_rate()) + .with_channels(buffer.channels()) + .build_with_output_callback(move |writer, _info| { + let writer_channels = writer.channels() as usize; + let writer_frames = writer.frames(); + + writer.clear(); + + if writer_channels == 0 { + return; + } + + let write_samples = writer_frames.saturating_mul(writer_channels); + let start = + cursor_for_callback.fetch_add(write_samples, Ordering::Relaxed); + + let source_samples = buffer_for_callback.samples(); + let source_total = source_samples.len(); + + for frame in 0..writer_frames { + for channel in 0..writer_channels { + let sample_index = start + .saturating_add(frame.saturating_mul(writer_channels)) + .saturating_add(channel); + + let value = source_samples.get(sample_index).copied().unwrap_or(0.0); + if sample_index < source_total { + writer.set_sample(frame, channel, value); + } + } + } + + return; + })?; + + let wait_seconds = buffer.duration_seconds() + 0.20; + std::thread::sleep(Duration::from_secs_f32(wait_seconds)); + drop(_device); + return Ok(()); +} + +fn print_usage(program_name: &str) { + println!("usage:"); + println!(" {program_name} info "); + println!(" {program_name} view "); + println!(" {program_name} play "); + println!(" {program_name} list-devices"); + return; +} From 940240f2ab826208cefbe30881738d716175fed7 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 12:51:31 -0800 Subject: [PATCH 13/23] [add] README to showcase how to use the tool. --- tools/lambda_audio/README.md | 110 +++++++++++++++++++++++++++++++++ tools/lambda_audio/src/main.rs | 8 ++- 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tools/lambda_audio/README.md diff --git a/tools/lambda_audio/README.md b/tools/lambda_audio/README.md new file mode 100644 index 00000000..495531c6 --- /dev/null +++ b/tools/lambda_audio/README.md @@ -0,0 +1,110 @@ +# lambda-audio + +`lambda-audio` is a small CLI tool for inspecting and playing sound files using +the `lambda` audio APIs. + +Scope +- Load sound files into `lambda::audio::SoundBuffer`. +- Print decoded metadata (sample rate, channels, duration). +- Render a small ASCII waveform preview. +- Play the decoded audio via the default output device. + +## Usage + +From the repository root: + +```bash +cargo run -p lambda-audio-tool -- [path] +``` + +Commands +- `info `: decode and print metadata. +- `view `: decode, print metadata, and show an ASCII waveform preview. +- `play `: decode, print metadata, and play through the default output + device. +- `list-devices`: list available output devices (platform-dependent). + +Supported file types +- `.wav` +- `.ogg` / `.oga` (Vorbis) + +## Examples (slash fixture) + +The repository includes an OGG Vorbis fixture at: +`crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg`. + +### `info` + +```bash +cargo run -p lambda-audio-tool -- info \ + crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg +``` + +Example output: + +```text +path: crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg +sample_rate: 48000 +channels: 2 +frames: 53824 +samples: 107648 +duration_seconds: 1.121 +``` + +### `view` + +```bash +cargo run -p lambda-audio-tool -- view \ + crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg +``` + +Example output: + +```text +path: crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg +sample_rate: 48000 +channels: 2 +frames: 53824 +samples: 107648 +duration_seconds: 1.121 + + ### + ##### + ###### + ###### + ###### + ######## # + ############# + ################# + ########################### +``` + +### `play` + +```bash +cargo run -p lambda-audio-tool -- play \ + crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg +``` + +Example output (audio plays, then the process exits): + +```text +path: crates/lambda-rs-platform/assets/audio/slash_vorbis_stereo_48000.ogg +sample_rate: 48000 +channels: 2 +frames: 53824 +samples: 107648 +duration_seconds: 1.121 +``` + +### `list-devices` + +```bash +cargo run -p lambda-audio-tool -- list-devices +``` + +Example output (varies by machine and environment): + +```text +no output devices found +``` diff --git a/tools/lambda_audio/src/main.rs b/tools/lambda_audio/src/main.rs index bf24098c..47626790 100644 --- a/tools/lambda_audio/src/main.rs +++ b/tools/lambda_audio/src/main.rs @@ -21,7 +21,13 @@ use lambda::audio::{ fn main() { let mut args = std::env::args(); - let program_name = args.next().unwrap_or_else(|| "lambda-audio".to_string()); + let raw_program_name = + args.next().unwrap_or_else(|| "lambda-audio".to_string()); + let program_name = Path::new(&raw_program_name) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(raw_program_name.as_str()) + .to_string(); let command = args.next().unwrap_or_else(|| "help".to_string()); From 1a6793f4aa30aa79302645f259cb98d2e90b54a4 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 14:01:01 -0800 Subject: [PATCH 14/23] [add] documentation to audio implementation. --- .../src/audio/cpal/device.rs | 55 +++++++++++++++++++ .../lambda-rs-platform/src/audio/cpal/mod.rs | 5 ++ crates/lambda-rs-platform/src/audio/mod.rs | 4 ++ .../src/audio/symphonia/mod.rs | 49 +++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/crates/lambda-rs-platform/src/audio/cpal/device.rs b/crates/lambda-rs-platform/src/audio/cpal/device.rs index 296cea26..8706e7d3 100644 --- a/crates/lambda-rs-platform/src/audio/cpal/device.rs +++ b/crates/lambda-rs-platform/src/audio/cpal/device.rs @@ -84,6 +84,7 @@ pub(crate) enum AudioOutputBuffer<'buffer> { } impl<'buffer> AudioOutputBuffer<'buffer> { + /// Return the number of interleaved samples in the underlying buffer. #[allow(dead_code)] fn len(&self) -> usize { match self { @@ -99,6 +100,7 @@ impl<'buffer> AudioOutputBuffer<'buffer> { } } + /// Return the sample format of the underlying typed buffer. fn sample_format(&self) -> AudioSampleFormat { match self { Self::F32(_) => { @@ -125,6 +127,11 @@ pub(crate) struct InterleavedAudioOutputWriter<'buffer> { } impl<'buffer> InterleavedAudioOutputWriter<'buffer> { + /// Create a writer for an interleaved output buffer. + /// + /// `channels` MUST match the channel count encoded in the output stream + /// configuration. The frame count is derived from the buffer length and + /// channel count. #[allow(dead_code)] pub fn new(channels: u16, buffer: AudioOutputBuffer<'buffer>) -> Self { let channels_usize = channels as usize; @@ -141,12 +148,14 @@ impl<'buffer> InterleavedAudioOutputWriter<'buffer> { }; } + /// Return the sample format of the current callback buffer. #[allow(dead_code)] pub fn sample_format(&self) -> AudioSampleFormat { return self.buffer.sample_format(); } } +/// Clamp a normalized audio sample to the nominal output range `[-1.0, 1.0]`. #[allow(dead_code)] fn clamp_normalized_sample(sample: f32) -> f32 { if sample > 1.0 { @@ -680,6 +689,12 @@ impl Default for AudioDeviceBuilder { } } +/// Invoke an output callback using a typed platform buffer. +/// +/// This adapter: +/// - Wraps the typed `cpal` output slice in an [`AudioOutputWriter`]. +/// - Clears the buffer to silence before invoking the callback. +/// - Guarantees a single callback invocation per platform callback tick. fn invoke_output_callback_on_buffer( channels: u16, buffer: AudioOutputBuffer<'_>, @@ -694,6 +709,10 @@ fn invoke_output_callback_on_buffer( return; } +/// Convert a `cpal` sample format into a stable preference ordering. +/// +/// The current backend prefers `f32`, then `i16`, then `u16`. Any other format +/// is treated as unsupported by this abstraction. fn sample_format_priority(sample_format: cpal_backend::SampleFormat) -> u8 { match sample_format { cpal_backend::SampleFormat::F32 => { @@ -711,6 +730,17 @@ fn sample_format_priority(sample_format: cpal_backend::SampleFormat) -> u8 { } } +/// Select a supported output stream configuration for the default device. +/// +/// Selection rules: +/// - If `requested_channels` is set, only exact channel matches are considered. +/// - If `requested_sample_rate` is set, only ranges that contain the rate are +/// considered. +/// - If no rate is requested, the selection targets `48_000` Hz and chooses the +/// closest available rate within each range. +/// - Higher-quality sample formats are preferred (`f32` > `i16` > `u16`). +/// - When priorities tie, the configuration with sample rate closest to +/// `48_000` Hz is preferred. fn select_output_stream_config( supported_configs: &[cpal_backend::SupportedStreamConfigRange], requested_sample_rate: Option, @@ -844,6 +874,7 @@ mod tests { use super::*; + /// Builder MUST reject invalid sample rates. #[test] fn build_rejects_zero_sample_rate() { let result = AudioDeviceBuilder::new().with_sample_rate(0).build(); @@ -851,8 +882,10 @@ mod tests { result, Err(AudioError::InvalidSampleRate { requested: 0 }) )); + return; } + /// Builder MUST reject invalid channel counts. #[test] fn build_rejects_zero_channels() { let result = AudioDeviceBuilder::new().with_channels(0).build(); @@ -860,8 +893,10 @@ mod tests { result, Err(AudioError::InvalidChannels { requested: 0 }) )); + return; } + /// Builder MUST NOT panic for typical host/device states. #[test] fn build_does_not_panic() { let result = AudioDeviceBuilder::new().build(); @@ -873,12 +908,14 @@ mod tests { return; } + /// Device enumeration MUST NOT panic for typical host/device states. #[test] fn enumerate_devices_does_not_panic() { let _result = enumerate_devices(); return; } + /// Callback-based builder MUST NOT panic for typical host/device states. #[test] fn build_with_output_callback_does_not_panic() { let result = AudioDeviceBuilder::new().build_with_output_callback( @@ -894,6 +931,7 @@ mod tests { return; } + /// Callback adapter MUST clear and then invoke the callback for `f32`. #[test] fn invoke_output_callback_on_buffer_clears_and_invokes_callback_f32() { let mut buffer_f32 = [1.0, -1.0, 0.5, -0.5]; @@ -923,8 +961,10 @@ mod tests { assert!(callback_called); assert_eq!(buffer_f32, [0.5, 0.0, 0.0, 0.0]); + return; } + /// Callback adapter MUST clear and then invoke the callback for `i16`. #[test] fn invoke_output_callback_on_buffer_clears_and_invokes_callback_i16() { let mut buffer_i16 = [1, -1, 200, -200]; @@ -952,8 +992,10 @@ mod tests { assert!(callback_called); assert_eq!(buffer_i16, [32767, 0, 0, 0]); + return; } + /// Callback adapter MUST clear and then invoke the callback for `u16`. #[test] fn invoke_output_callback_on_buffer_clears_and_invokes_callback_u16() { let mut buffer_u16 = [0, 1, 65535, 12345]; @@ -981,8 +1023,10 @@ mod tests { assert!(callback_called); assert_eq!(buffer_u16, [0, 32768, 32768, 32768]); + return; } + /// Writer silence MUST match each sample format's conventions. #[test] fn writer_clear_sets_silence_for_all_formats() { let mut buffer_f32 = [1.0, -1.0, 0.5, -0.5]; @@ -1008,8 +1052,10 @@ mod tests { ); writer.clear(); assert_eq!(buffer_u16, [32768, 32768, 32768, 32768]); + return; } + /// Writer MUST clamp normalized samples and convert to output formats. #[test] fn writer_set_sample_clamps_and_converts() { let mut buffer_f32 = [0.0, 0.0, 0.0, 0.0]; @@ -1045,8 +1091,10 @@ mod tests { assert_eq!(buffer_u16[0], 0); assert_eq!(buffer_u16[1], 32768); assert_eq!(buffer_u16[2], 65535); + return; } + /// Out-of-range indices MUST be treated as no-ops. #[test] fn writer_set_sample_is_noop_for_out_of_range_indices() { let mut buffer_f32 = [0.25, 0.25, 0.25, 0.25]; @@ -1059,8 +1107,10 @@ mod tests { writer.set_sample(0, 10, 1.0); assert_eq!(buffer_f32, [0.25, 0.25, 0.25, 0.25]); + return; } + /// Config selection MUST prefer `f32` when available. #[test] fn select_output_stream_config_prefers_f32_when_available() { let supported_configs = [ @@ -1084,8 +1134,10 @@ mod tests { 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); + return; } + /// Config selection MUST honor exact requested channel counts. #[test] fn select_output_stream_config_respects_requested_channels() { let supported_configs = [cpal_backend::SupportedStreamConfigRange::new( @@ -1108,8 +1160,10 @@ mod tests { requested_channels: Some(1), }) )); + return; } + /// Config selection MUST honor requested sample rates when available. #[test] fn select_output_stream_config_respects_requested_sample_rate() { let supported_configs = [cpal_backend::SupportedStreamConfigRange::new( @@ -1134,5 +1188,6 @@ mod tests { requested_channels: None, }) )); + return; } } diff --git a/crates/lambda-rs-platform/src/audio/cpal/mod.rs b/crates/lambda-rs-platform/src/audio/cpal/mod.rs index 8105c76d..a443bc7c 100644 --- a/crates/lambda-rs-platform/src/audio/cpal/mod.rs +++ b/crates/lambda-rs-platform/src/audio/cpal/mod.rs @@ -1,9 +1,14 @@ #![allow(clippy::needless_return)] //! Internal audio backend abstractions used by `lambda-rs` (cpal backend). +//! +//! This module provides a stable, backend-agnostic surface that is implemented +//! using `cpal` when the `audio-device` feature is enabled. +/// `cpal`-backed output device discovery and stream initialization. pub mod device; +/// Re-export the backend-agnostic surface types for consumption by `lambda-rs`. pub use device::{ enumerate_devices, AudioCallbackInfo, diff --git a/crates/lambda-rs-platform/src/audio/mod.rs b/crates/lambda-rs-platform/src/audio/mod.rs index d6f1a39f..ffe0531b 100644 --- a/crates/lambda-rs-platform/src/audio/mod.rs +++ b/crates/lambda-rs-platform/src/audio/mod.rs @@ -4,6 +4,10 @@ //! //! This module is internal to the engine. Applications MUST NOT depend on //! `lambda-rs-platform` directly. +//! +//! Each dependency wrapper is gated by a granular Cargo feature in this crate. +//! Feature checks are scoped to the smallest possible surface so that +//! `lambda-rs` can compose audio features without exposing vendor details. #[cfg(feature = "audio-device")] pub mod cpal; diff --git a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs index 5358d63e..711d6bab 100644 --- a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs +++ b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs @@ -34,16 +34,22 @@ use symphonia::core::{ /// Fully decoded, interleaved `f32` samples with associated metadata. #[derive(Clone, Debug, PartialEq)] pub struct DecodedAudio { + /// Interleaved audio samples in nominal range `[-1.0, 1.0]`. pub samples: Vec, + /// Sample rate in Hz. pub sample_rate: u32, + /// Interleaved channel count (currently `1` or `2`). pub channels: u16, } /// Vendor-free errors produced by audio decoding helpers. #[derive(Clone, Debug)] pub enum AudioDecodeError { + /// The provided bytes were not a recognized container or codec. UnsupportedFormat { details: String }, + /// The provided bytes were recognized but invalid or corrupted. InvalidData { details: String }, + /// A platform or backend error prevented decoding. DecodeFailed { details: String }, } @@ -65,6 +71,7 @@ impl fmt::Display for AudioDecodeError { impl std::error::Error for AudioDecodeError {} +/// Build a `symphonia` probe hint from a list of likely filename extensions. fn hint_for_decode(extensions: &[&str]) -> Hint { let mut hint_value = Hint::new(); for extension in extensions { @@ -73,6 +80,7 @@ fn hint_for_decode(extensions: &[&str]) -> Hint { return hint_value; } +/// Map probe-time `symphonia` errors into backend-agnostic decode errors. fn map_probe_error(source_description: &str, error: Error) -> AudioDecodeError { match error { Error::Unsupported(_) => { @@ -93,6 +101,10 @@ fn map_probe_error(source_description: &str, error: Error) -> AudioDecodeError { } } +/// Map packet read or decode-time `symphonia` errors into decode errors. +/// +/// This keeps the surface area stable for `lambda-rs` and avoids leaking +/// vendor-specific error types. fn map_read_or_decode_error( source_description: &str, error: Error, @@ -121,6 +133,10 @@ fn map_read_or_decode_error( } } +/// Probe the container format for an in-memory audio buffer. +/// +/// `symphonia` expects a `MediaSourceStream`. This wrapper creates an owned +/// cursor backed by `bytes` so the probe can seek without borrowing the input. fn probe_format( bytes: &[u8], source_description: &str, @@ -150,6 +166,10 @@ fn probe_format( return Ok(probe_result.format); } +/// Pre-allocate a decoded sample buffer using optional codec metadata. +/// +/// Failure to reserve is treated as a recoverable decode error to avoid +/// panicking on large files or constrained platforms. fn try_reserve_samples( samples: &mut Vec, source_description: &str, @@ -176,6 +196,16 @@ fn try_reserve_samples( return Ok(()); } +/// Decode a single `symphonia` track into interleaved `f32` samples. +/// +/// Behavior: +/// - Reads packets until end-of-stream. +/// - Ignores packets from other tracks. +/// - Handles `ResetRequired` by resetting the decoder and continuing. +/// - Validates that sample rate and channel count remain stable across packets. +/// - Restricts channel count to mono/stereo for the current engine surface. +/// - For WAV, validates the decoded sample format on first decoded packet to +/// ensure only the supported input formats are accepted. fn decode_track_to_interleaved_f32( format: &mut dyn FormatReader, track_id: u32, @@ -310,6 +340,12 @@ fn decode_track_to_interleaved_f32( }); } +/// Validate the sample format returned by `symphonia` for WAV decoding. +/// +/// The engine surface currently supports WAV inputs that decode to: +/// - 16-bit signed integer (`S16`) +/// - 24-bit signed integer (`S24`) +/// - 32-bit float (`F32`) fn validate_wav_decoded_sample_format( decoded: &AudioBufferRef<'_>, ) -> Result<(), AudioDecodeError> { @@ -330,6 +366,7 @@ fn validate_wav_decoded_sample_format( } } +/// Return a stable string name for WAV decoded sample formats. fn wav_decoded_sample_format_name( decoded: &AudioBufferRef<'_>, ) -> &'static str { @@ -419,36 +456,42 @@ pub fn decode_ogg_vorbis_bytes( mod tests { use super::*; + /// Fixture: 44100 Hz, mono, 16-bit integer PCM. #[cfg(feature = "audio-decode-wav")] const TONE_S16_MONO_44100_WAV: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/audio/tone_s16_mono_44100.wav" )); + /// Fixture: 44100 Hz, stereo, 16-bit integer PCM. #[cfg(feature = "audio-decode-wav")] const TONE_S16_STEREO_44100_WAV: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/audio/tone_s16_stereo_44100.wav" )); + /// Fixture: 44100 Hz, mono, 24-bit integer PCM. #[cfg(feature = "audio-decode-wav")] const TONE_S24_MONO_44100_WAV: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/audio/tone_s24_mono_44100.wav" )); + /// Fixture: 44100 Hz, stereo, 32-bit float PCM. #[cfg(feature = "audio-decode-wav")] const TONE_F32_STEREO_44100_WAV: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/audio/tone_f32_stereo_44100.wav" )); + /// Fixture: 48000 Hz, stereo, OGG Vorbis. #[cfg(feature = "audio-decode-vorbis")] const SLASH_VORBIS_STEREO_48000_OGG: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/audio/slash_vorbis_stereo_48000.ogg" )); + /// Decoding invalid WAV bytes MUST return a structured error. #[cfg(feature = "audio-decode-wav")] #[test] fn wav_decode_rejects_invalid_bytes() { @@ -462,6 +505,7 @@ mod tests { return; } + /// WAV decode MUST preserve sample rate and channel metadata from the input. #[cfg(feature = "audio-decode-wav")] #[test] fn wav_decode_s16_mono_fixture_decodes() { @@ -473,6 +517,7 @@ mod tests { return; } + /// WAV decode MUST support stereo 16-bit integer PCM. #[cfg(feature = "audio-decode-wav")] #[test] fn wav_decode_s16_stereo_fixture_decodes() { @@ -484,6 +529,7 @@ mod tests { return; } + /// WAV decode MUST support mono 24-bit integer PCM. #[cfg(feature = "audio-decode-wav")] #[test] fn wav_decode_s24_mono_fixture_decodes() { @@ -495,6 +541,7 @@ mod tests { return; } + /// WAV decode MUST support stereo 32-bit float PCM. #[cfg(feature = "audio-decode-wav")] #[test] fn wav_decode_f32_stereo_fixture_decodes() { @@ -506,6 +553,7 @@ mod tests { return; } + /// Decoding invalid OGG bytes MUST return a structured error. #[cfg(feature = "audio-decode-vorbis")] #[test] fn ogg_vorbis_decode_rejects_invalid_bytes() { @@ -519,6 +567,7 @@ mod tests { return; } + /// OGG Vorbis decode MUST preserve sample rate and channel metadata. #[cfg(feature = "audio-decode-vorbis")] #[test] fn ogg_vorbis_decode_fixture_decodes() { From acaa04749e4615a85fde6624699c3e377e10bc8f Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 14:14:39 -0800 Subject: [PATCH 15/23] [update] documentation. --- .../src/audio/cpal/device.rs | 117 ++++++++++++++++++ .../src/audio/symphonia/mod.rs | 115 +++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/crates/lambda-rs-platform/src/audio/cpal/device.rs b/crates/lambda-rs-platform/src/audio/cpal/device.rs index 8706e7d3..86b474f8 100644 --- a/crates/lambda-rs-platform/src/audio/cpal/device.rs +++ b/crates/lambda-rs-platform/src/audio/cpal/device.rs @@ -50,11 +50,20 @@ pub struct AudioCallbackInfo { /// underlying device output buffer for the current callback invocation. pub trait AudioOutputWriter { /// Return the output channel count for the current callback invocation. + /// + /// # Returns + /// The number of interleaved channels in the output buffer. fn channels(&self) -> u16; /// Return the number of frames in the output buffer for the current callback /// invocation. + /// + /// # Returns + /// The number of frames in the output buffer. fn frames(&self) -> usize; /// Clear the entire output buffer to silence. + /// + /// # Returns + /// `()` after clearing the output buffer to silence. fn clear(&mut self); /// Write a normalized sample in the range `[-1.0, 1.0]`. @@ -62,6 +71,16 @@ pub trait AudioOutputWriter { /// 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. + /// + /// # Arguments + /// - `frame_index`: The target frame index within the current callback + /// buffer. + /// - `channel_index`: The target channel index within the current callback + /// buffer. + /// - `sample`: A normalized sample in nominal range `[-1.0, 1.0]`. + /// + /// # Returns + /// `()` after attempting to write the sample. fn set_sample( &mut self, frame_index: usize, @@ -132,6 +151,14 @@ impl<'buffer> InterleavedAudioOutputWriter<'buffer> { /// `channels` MUST match the channel count encoded in the output stream /// configuration. The frame count is derived from the buffer length and /// channel count. + /// + /// # Arguments + /// - `channels`: Interleaved output channel count. + /// - `buffer`: A typed interleaved output buffer view for the current audio + /// callback. + /// + /// # Returns + /// A writer that can clear and write normalized samples into `buffer`. #[allow(dead_code)] pub fn new(channels: u16, buffer: AudioOutputBuffer<'buffer>) -> Self { let channels_usize = channels as usize; @@ -149,6 +176,9 @@ impl<'buffer> InterleavedAudioOutputWriter<'buffer> { } /// Return the sample format of the current callback buffer. + /// + /// # Returns + /// The typed sample format for the current callback buffer. #[allow(dead_code)] pub fn sample_format(&self) -> AudioSampleFormat { return self.buffer.sample_format(); @@ -156,6 +186,12 @@ impl<'buffer> InterleavedAudioOutputWriter<'buffer> { } /// Clamp a normalized audio sample to the nominal output range `[-1.0, 1.0]`. +/// +/// # Arguments +/// - `sample`: A potentially out-of-range normalized sample. +/// +/// # Returns +/// The clamped sample in range `[-1.0, 1.0]`. #[allow(dead_code)] fn clamp_normalized_sample(sample: f32) -> f32 { if sample > 1.0 { @@ -381,6 +417,9 @@ pub struct AudioDeviceBuilder { impl AudioDeviceBuilder { /// Create a builder with engine defaults. + /// + /// # Returns + /// A builder with no explicit configuration requests. pub fn new() -> Self { return Self { sample_rate: None, @@ -390,18 +429,36 @@ impl AudioDeviceBuilder { } /// Request a specific sample rate (Hz). + /// + /// # Arguments + /// - `rate`: Requested sample rate in Hz. + /// + /// # Returns + /// The updated builder. pub fn with_sample_rate(mut self, rate: u32) -> Self { self.sample_rate = Some(rate); return self; } /// Request a specific channel count. + /// + /// # Arguments + /// - `channels`: Requested interleaved channel count. + /// + /// # Returns + /// The updated builder. pub fn with_channels(mut self, channels: u16) -> Self { self.channels = Some(channels); return self; } /// Attach a label for diagnostics. + /// + /// # Arguments + /// - `label`: A human-readable label used for diagnostics. + /// + /// # Returns + /// The updated builder. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); return self; @@ -409,6 +466,17 @@ impl AudioDeviceBuilder { /// Initialize the default audio output device using the requested /// configuration. + /// + /// This method selects a supported output configuration from the default + /// output device and starts an output stream that plays silence. + /// + /// # Returns + /// An initialized audio output device handle. Dropping the handle stops + /// output. + /// + /// # Errors + /// Returns an error when the host, device, or stream cannot be initialized, + /// or when no supported output configuration satisfies the request. pub fn build(self) -> Result { if let Some(sample_rate) = self.sample_rate { if sample_rate == 0 { @@ -531,6 +599,20 @@ impl AudioDeviceBuilder { } /// Initialize the default audio output device and play audio via a callback. + /// + /// The callback is invoked from the platform audio thread. The callback MUST + /// avoid blocking and MUST NOT allocate. + /// + /// # Arguments + /// - `callback`: A real-time callback invoked for each output buffer tick. + /// + /// # Returns + /// An initialized audio output device handle. Dropping the handle stops + /// output. + /// + /// # Errors + /// Returns an error when the host, device, or stream cannot be initialized, + /// or when no supported output configuration satisfies the request. pub fn build_with_output_callback( self, callback: Callback, @@ -695,6 +777,15 @@ impl Default for AudioDeviceBuilder { /// - Wraps the typed `cpal` output slice in an [`AudioOutputWriter`]. /// - Clears the buffer to silence before invoking the callback. /// - Guarantees a single callback invocation per platform callback tick. +/// +/// # Arguments +/// - `channels`: Interleaved channel count for the output buffer. +/// - `buffer`: A typed interleaved output buffer view. +/// - `callback_info`: Stream metadata for the current callback invocation. +/// - `callback`: The engine callback to invoke. +/// +/// # Returns +/// `()` after clearing the buffer and invoking the callback. fn invoke_output_callback_on_buffer( channels: u16, buffer: AudioOutputBuffer<'_>, @@ -713,6 +804,12 @@ fn invoke_output_callback_on_buffer( /// /// The current backend prefers `f32`, then `i16`, then `u16`. Any other format /// is treated as unsupported by this abstraction. +/// +/// # Arguments +/// - `sample_format`: The `cpal` sample format for a supported stream config. +/// +/// # Returns +/// A priority value where higher values are preferred. fn sample_format_priority(sample_format: cpal_backend::SampleFormat) -> u8 { match sample_format { cpal_backend::SampleFormat::F32 => { @@ -741,6 +838,19 @@ fn sample_format_priority(sample_format: cpal_backend::SampleFormat) -> u8 { /// - Higher-quality sample formats are preferred (`f32` > `i16` > `u16`). /// - When priorities tie, the configuration with sample rate closest to /// `48_000` Hz is preferred. +/// +/// # Arguments +/// - `supported_configs`: The device-provided supported config ranges. +/// - `requested_sample_rate`: Optional exact sample rate request. +/// - `requested_channels`: Optional exact channel count request. +/// +/// # Returns +/// A supported stream configuration selected from `supported_configs`. +/// +/// # Errors +/// Returns [`AudioError::UnsupportedConfig`] when no configuration satisfies +/// the request. Returns [`AudioError::UnsupportedSampleFormat`] when the device +/// does not expose any supported sample format among `f32`, `i16`, and `u16`. fn select_output_stream_config( supported_configs: &[cpal_backend::SupportedStreamConfigRange], requested_sample_rate: Option, @@ -834,6 +944,13 @@ fn select_output_stream_config( } /// Enumerate available audio output devices. +/// +/// # Returns +/// A list of available output devices with stable metadata. +/// +/// # Errors +/// Returns an error if the platform host cannot enumerate output devices or if +/// device metadata cannot be retrieved. pub fn enumerate_devices() -> Result, AudioError> { let host = cpal_backend::default_host(); diff --git a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs index 711d6bab..fc991eb0 100644 --- a/crates/lambda-rs-platform/src/audio/symphonia/mod.rs +++ b/crates/lambda-rs-platform/src/audio/symphonia/mod.rs @@ -72,6 +72,13 @@ impl fmt::Display for AudioDecodeError { impl std::error::Error for AudioDecodeError {} /// Build a `symphonia` probe hint from a list of likely filename extensions. +/// +/// # Arguments +/// - `extensions`: A list of likely filename extensions (without a leading +/// period) used to guide `symphonia`'s format probe. +/// +/// # Returns +/// A probe hint configured with all provided extensions. fn hint_for_decode(extensions: &[&str]) -> Hint { let mut hint_value = Hint::new(); for extension in extensions { @@ -81,6 +88,14 @@ fn hint_for_decode(extensions: &[&str]) -> Hint { } /// Map probe-time `symphonia` errors into backend-agnostic decode errors. +/// +/// # Arguments +/// - `source_description`: A human-readable description used to contextualize +/// error messages (for example, `"WAV"` or `"OGG Vorbis"`). +/// - `error`: The `symphonia` probe error. +/// +/// # Returns +/// A stable, vendor-free decode error. fn map_probe_error(source_description: &str, error: Error) -> AudioDecodeError { match error { Error::Unsupported(_) => { @@ -105,6 +120,14 @@ fn map_probe_error(source_description: &str, error: Error) -> AudioDecodeError { /// /// This keeps the surface area stable for `lambda-rs` and avoids leaking /// vendor-specific error types. +/// +/// # Arguments +/// - `source_description`: A human-readable description used to contextualize +/// error messages (for example, `"WAV"` or `"OGG Vorbis"`). +/// - `error`: The `symphonia` read or decode error. +/// +/// # Returns +/// A stable, vendor-free decode error. fn map_read_or_decode_error( source_description: &str, error: Error, @@ -137,6 +160,20 @@ fn map_read_or_decode_error( /// /// `symphonia` expects a `MediaSourceStream`. This wrapper creates an owned /// cursor backed by `bytes` so the probe can seek without borrowing the input. +/// +/// # Arguments +/// - `bytes`: The complete container bytes. +/// - `source_description`: A human-readable description used to contextualize +/// error messages. +/// - `extensions`: A list of filename extensions used as a probe hint. +/// +/// # Returns +/// A `FormatReader` capable of reading packets from the probed container. +/// +/// # Errors +/// Returns [`AudioDecodeError::UnsupportedFormat`] when the container cannot be +/// recognized. Returns [`AudioDecodeError::InvalidData`] when the bytes are +/// recognized but no tracks can be read. fn probe_format( bytes: &[u8], source_description: &str, @@ -170,6 +207,20 @@ fn probe_format( /// /// Failure to reserve is treated as a recoverable decode error to avoid /// panicking on large files or constrained platforms. +/// +/// # Arguments +/// - `samples`: The output sample vector to reserve capacity for. +/// - `source_description`: A human-readable description used to contextualize +/// error messages. +/// - `frames`: Optional total frame count metadata for the selected track. +/// - `channels`: Optional channel count metadata for the selected track. +/// +/// # Returns +/// `Ok(())` when reservation succeeds or cannot be estimated. +/// +/// # Errors +/// Returns [`AudioDecodeError::DecodeFailed`] if the allocator fails to reserve +/// the requested capacity. fn try_reserve_samples( samples: &mut Vec, source_description: &str, @@ -206,6 +257,29 @@ fn try_reserve_samples( /// - Restricts channel count to mono/stereo for the current engine surface. /// - For WAV, validates the decoded sample format on first decoded packet to /// ensure only the supported input formats are accepted. +/// +/// # Arguments +/// - `format`: Container reader used to fetch packets. +/// - `track_id`: The track identifier to decode. Packets from other tracks are +/// ignored. +/// - `decoder`: Codec decoder for the selected track. +/// - `source_description`: A human-readable description used to contextualize +/// error messages. +/// - `reserve_frames`: Optional frame count metadata used to pre-reserve the +/// output buffer. +/// - `reserve_channels`: Optional channel count metadata used to pre-reserve +/// the output buffer. +/// +/// # Returns +/// Fully decoded audio with interleaved `f32` samples and associated metadata. +/// +/// # Errors +/// Returns: +/// - [`AudioDecodeError::UnsupportedFormat`] for unsupported channel counts or +/// unsupported WAV decoded sample formats. +/// - [`AudioDecodeError::InvalidData`] for corrupted streams or inconsistent +/// metadata during decode. +/// - [`AudioDecodeError::DecodeFailed`] for other backend failures. fn decode_track_to_interleaved_f32( format: &mut dyn FormatReader, track_id: u32, @@ -346,6 +420,16 @@ fn decode_track_to_interleaved_f32( /// - 16-bit signed integer (`S16`) /// - 24-bit signed integer (`S24`) /// - 32-bit float (`F32`) +/// +/// # Arguments +/// - `decoded`: The decoded packet audio buffer view. +/// +/// # Returns +/// `Ok(())` when the decoded sample format is supported. +/// +/// # Errors +/// Returns [`AudioDecodeError::UnsupportedFormat`] if the decoded sample format +/// is not supported by the current engine surface. fn validate_wav_decoded_sample_format( decoded: &AudioBufferRef<'_>, ) -> Result<(), AudioDecodeError> { @@ -367,6 +451,12 @@ fn validate_wav_decoded_sample_format( } /// Return a stable string name for WAV decoded sample formats. +/// +/// # Arguments +/// - `decoded`: The decoded packet audio buffer view. +/// +/// # Returns +/// A stable name for diagnostics and error messages. fn wav_decoded_sample_format_name( decoded: &AudioBufferRef<'_>, ) -> &'static str { @@ -385,6 +475,19 @@ fn wav_decoded_sample_format_name( } /// Decode WAV bytes into interleaved `f32` samples. +/// +/// # Arguments +/// - `bytes`: Complete WAV container bytes. +/// +/// # Returns +/// Fully decoded audio with interleaved `f32` samples and associated metadata. +/// +/// # Errors +/// Returns [`AudioDecodeError::UnsupportedFormat`] if the bytes are not a WAV +/// file or use an unsupported encoding. Returns +/// [`AudioDecodeError::InvalidData`] if the bytes are a WAV container but are +/// invalid or corrupted. Returns +/// [`AudioDecodeError::DecodeFailed`] for other backend failures. #[cfg(feature = "audio-decode-wav")] pub fn decode_wav_bytes( bytes: &[u8], @@ -416,6 +519,18 @@ pub fn decode_wav_bytes( } /// Decode OGG Vorbis bytes into interleaved `f32` samples. +/// +/// # Arguments +/// - `bytes`: Complete OGG container bytes. +/// +/// # Returns +/// Fully decoded audio with interleaved `f32` samples and associated metadata. +/// +/// # Errors +/// Returns [`AudioDecodeError::UnsupportedFormat`] if the bytes are not an OGG +/// container, or if the OGG stream is not Vorbis. Returns +/// [`AudioDecodeError::InvalidData`] if the container is invalid or corrupted. +/// Returns [`AudioDecodeError::DecodeFailed`] for other backend failures. #[cfg(feature = "audio-decode-vorbis")] pub fn decode_ogg_vorbis_bytes( bytes: &[u8], From ad962e9d196380bda6e2002e8798c56102706058 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 14:41:40 -0800 Subject: [PATCH 16/23] [add] better documentation to public audio apis. --- crates/lambda-rs/src/audio/buffer.rs | 100 +++++++++++++++++++ crates/lambda-rs/src/audio/devices/mod.rs | 9 ++ crates/lambda-rs/src/audio/devices/output.rs | 90 +++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/crates/lambda-rs/src/audio/buffer.rs b/crates/lambda-rs/src/audio/buffer.rs index 53c3bd15..4e12675b 100644 --- a/crates/lambda-rs/src/audio/buffer.rs +++ b/crates/lambda-rs/src/audio/buffer.rs @@ -14,6 +14,21 @@ pub struct SoundBuffer { } impl SoundBuffer { + /// Decode a WAV file from disk into an in-memory sound buffer. + /// + /// This method is available when the `audio-sound-buffer-wav` feature is + /// enabled. + /// + /// # Arguments + /// - `path`: Path to a WAV file on disk. + /// + /// # Returns + /// A fully decoded sound buffer with interleaved `f32` samples. + /// + /// # Errors + /// Returns [`AudioError::Io`] if the file cannot be read. Returns + /// [`AudioError::UnsupportedFormat`], [`AudioError::InvalidData`], or + /// [`AudioError::DecodeFailed`] if decoding fails. #[cfg(feature = "audio-sound-buffer-wav")] pub fn from_wav_file(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(|error| { @@ -26,6 +41,20 @@ impl SoundBuffer { return Self::from_wav_bytes(&bytes); } + /// Decode WAV container bytes into an in-memory sound buffer. + /// + /// This method is available when the `audio-sound-buffer-wav` feature is + /// enabled. + /// + /// # Arguments + /// - `bytes`: Full WAV container bytes. + /// + /// # Returns + /// A fully decoded sound buffer with interleaved `f32` samples. + /// + /// # Errors + /// Returns [`AudioError::UnsupportedFormat`], [`AudioError::InvalidData`], or + /// [`AudioError::DecodeFailed`] if decoding fails. #[cfg(feature = "audio-sound-buffer-wav")] pub fn from_wav_bytes(bytes: &[u8]) -> Result { let decoded = lambda_platform::audio::symphonia::decode_wav_bytes(bytes) @@ -33,6 +62,21 @@ impl SoundBuffer { return Self::from_decoded(decoded); } + /// Decode an OGG Vorbis file from disk into an in-memory sound buffer. + /// + /// This method is available when the `audio-sound-buffer-vorbis` feature is + /// enabled. + /// + /// # Arguments + /// - `path`: Path to an OGG Vorbis file on disk. + /// + /// # Returns + /// A fully decoded sound buffer with interleaved `f32` samples. + /// + /// # Errors + /// Returns [`AudioError::Io`] if the file cannot be read. Returns + /// [`AudioError::UnsupportedFormat`], [`AudioError::InvalidData`], or + /// [`AudioError::DecodeFailed`] if decoding fails. #[cfg(feature = "audio-sound-buffer-vorbis")] pub fn from_ogg_file(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(|error| { @@ -45,6 +89,20 @@ impl SoundBuffer { return Self::from_ogg_bytes(&bytes); } + /// Decode OGG Vorbis container bytes into an in-memory sound buffer. + /// + /// This method is available when the `audio-sound-buffer-vorbis` feature is + /// enabled. + /// + /// # Arguments + /// - `bytes`: Full OGG container bytes. + /// + /// # Returns + /// A fully decoded sound buffer with interleaved `f32` samples. + /// + /// # Errors + /// Returns [`AudioError::UnsupportedFormat`], [`AudioError::InvalidData`], or + /// [`AudioError::DecodeFailed`] if decoding fails. #[cfg(feature = "audio-sound-buffer-vorbis")] pub fn from_ogg_bytes(bytes: &[u8]) -> Result { let decoded = @@ -53,6 +111,18 @@ impl SoundBuffer { return Self::from_decoded(decoded); } + /// Convert platform decoded audio into the public sound buffer + /// representation. + /// + /// # Arguments + /// - `decoded`: Decoded audio samples and associated metadata produced by the + /// platform layer. + /// + /// # Returns + /// A sound buffer containing the provided samples and validated metadata. + /// + /// # Errors + /// Returns [`AudioError::InvalidData`] if the decoded metadata is invalid. fn from_decoded( decoded: lambda_platform::audio::symphonia::DecodedAudio, ) -> Result { @@ -75,20 +145,34 @@ impl SoundBuffer { }); } + /// Return the sample rate in Hz. + /// + /// # Returns + /// The sample rate in Hz. pub fn sample_rate(&self) -> u32 { return self.sample_rate; } + /// Return the interleaved channel count. + /// + /// # Returns + /// The channel count. pub fn channels(&self) -> u16 { return self.channels; } /// Return interleaved `f32` samples in nominal range `[-1.0, 1.0]`. + /// + /// # Returns + /// A slice of interleaved samples. pub fn samples(&self) -> &[f32] { return self.samples.as_slice(); } /// Return the number of frames in this buffer. + /// + /// # Returns + /// The number of frames in the buffer. pub fn frames(&self) -> usize { if self.channels == 0 { return 0; @@ -97,6 +181,10 @@ impl SoundBuffer { return self.samples.len() / self.channels as usize; } + /// Return the duration of the buffer in seconds. + /// + /// # Returns + /// The duration in seconds. pub fn duration_seconds(&self) -> f32 { if self.channels == 0 || self.sample_rate == 0 { return 0.0; @@ -108,6 +196,13 @@ impl SoundBuffer { } } +/// Map platform decode errors into backend-agnostic public errors. +/// +/// # Arguments +/// - `error`: The platform decode error. +/// +/// # Returns +/// The equivalent public audio error. fn map_decode_error( error: lambda_platform::audio::symphonia::AudioDecodeError, ) -> AudioError { @@ -134,6 +229,7 @@ fn map_decode_error( mod tests { use super::*; + /// Duration computation MUST match frames / sample_rate. #[test] fn duration_seconds_computes_expected_value() { let buffer = SoundBuffer { @@ -146,6 +242,7 @@ mod tests { return; } + /// WAV decode from bytes MUST succeed for the bundled fixture. #[cfg(feature = "audio-sound-buffer-wav")] #[test] fn from_wav_bytes_decodes_fixture() { @@ -161,6 +258,7 @@ mod tests { return; } + /// WAV decode from file MUST succeed for the bundled fixture. #[cfg(feature = "audio-sound-buffer-wav")] #[test] fn from_wav_file_decodes_fixture() { @@ -174,6 +272,7 @@ mod tests { return; } + /// OGG Vorbis decode from bytes MUST succeed for the bundled fixture. #[cfg(feature = "audio-sound-buffer-vorbis")] #[test] fn from_ogg_bytes_decodes_fixture() { @@ -189,6 +288,7 @@ mod tests { return; } + /// OGG Vorbis decode from file MUST succeed for the bundled fixture. #[cfg(feature = "audio-sound-buffer-vorbis")] #[test] fn from_ogg_file_decodes_fixture() { diff --git a/crates/lambda-rs/src/audio/devices/mod.rs b/crates/lambda-rs/src/audio/devices/mod.rs index f01b7881..709af8d6 100644 --- a/crates/lambda-rs/src/audio/devices/mod.rs +++ b/crates/lambda-rs/src/audio/devices/mod.rs @@ -1,3 +1,12 @@ #![allow(clippy::needless_return)] +//! Audio device APIs. +//! +//! This module hosts device surfaces for audio input and output. Output is +//! implemented first; input devices are expected to be added later. +//! +//! All public types in this module MUST remain backend-agnostic. Platform and +//! vendor details are implemented in `lambda-rs-platform` and MUST NOT be +//! exposed through this surface. + pub mod output; diff --git a/crates/lambda-rs/src/audio/devices/output.rs b/crates/lambda-rs/src/audio/devices/output.rs index 94a3fe3c..1606c334 100644 --- a/crates/lambda-rs/src/audio/devices/output.rs +++ b/crates/lambda-rs/src/audio/devices/output.rs @@ -23,6 +23,13 @@ pub enum AudioSampleFormat { } impl AudioSampleFormat { + /// Map a platform sample format into the public API sample format. + /// + /// # Arguments + /// - `value`: The platform-provided sample format. + /// + /// # Returns + /// The equivalent public API sample format. fn from_platform(value: platform_audio::AudioSampleFormat) -> Self { match value { platform_audio::AudioSampleFormat::F32 => { @@ -50,6 +57,13 @@ pub struct AudioCallbackInfo { } impl AudioCallbackInfo { + /// Map platform callback metadata into the public API callback metadata. + /// + /// # Arguments + /// - `value`: The platform-provided callback metadata. + /// + /// # Returns + /// The equivalent public API callback metadata. fn from_platform(value: platform_audio::AudioCallbackInfo) -> Self { return Self { sample_rate: value.sample_rate, @@ -59,6 +73,13 @@ impl AudioCallbackInfo { } } +/// Map platform audio errors into backend-agnostic public errors. +/// +/// # Arguments +/// - `error`: The platform error. +/// +/// # Returns +/// A backend-agnostic error suitable for returning from `lambda-rs`. fn map_platform_error(error: platform_audio::AudioError) -> AudioError { match error { platform_audio::AudioError::InvalidSampleRate { requested } => { @@ -105,11 +126,20 @@ pub struct AudioOutputDeviceInfo { /// underlying device output buffer for the current callback invocation. pub trait AudioOutputWriter { /// Return the output channel count for the current callback invocation. + /// + /// # Returns + /// The number of interleaved channels in the output buffer. fn channels(&self) -> u16; /// Return the number of frames in the output buffer for the current callback /// invocation. + /// + /// # Returns + /// The number of frames in the output buffer. fn frames(&self) -> usize; /// Clear the entire output buffer to silence. + /// + /// # Returns + /// `()` after clearing the output buffer to silence. fn clear(&mut self); /// Write a normalized sample in the range `[-1.0, 1.0]`. @@ -117,6 +147,16 @@ pub trait AudioOutputWriter { /// 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. + /// + /// # Arguments + /// - `frame_index`: The target frame index within the current callback + /// buffer. + /// - `channel_index`: The target channel index within the current callback + /// buffer. + /// - `sample`: A normalized sample in nominal range `[-1.0, 1.0]`. + /// + /// # Returns + /// `()` after attempting to write the sample. fn set_sample( &mut self, frame_index: usize, @@ -172,6 +212,9 @@ pub struct AudioOutputDeviceBuilder { impl AudioOutputDeviceBuilder { /// Create a builder with engine defaults. + /// + /// # Returns + /// A builder with no explicit configuration requests. pub fn new() -> Self { return Self { sample_rate: None, @@ -181,18 +224,36 @@ impl AudioOutputDeviceBuilder { } /// Request a specific sample rate (Hz). + /// + /// # Arguments + /// - `rate`: Requested sample rate in Hz. + /// + /// # Returns + /// The updated builder. pub fn with_sample_rate(mut self, rate: u32) -> Self { self.sample_rate = Some(rate); return self; } /// Request a specific channel count. + /// + /// # Arguments + /// - `channels`: Requested interleaved channel count. + /// + /// # Returns + /// The updated builder. pub fn with_channels(mut self, channels: u16) -> Self { self.channels = Some(channels); return self; } /// Attach a label for diagnostics. + /// + /// # Arguments + /// - `label`: A human-readable label used for diagnostics. + /// + /// # Returns + /// The updated builder. pub fn with_label(mut self, label: &str) -> Self { self.label = Some(label.to_string()); return self; @@ -200,6 +261,14 @@ impl AudioOutputDeviceBuilder { /// Initialize the default audio output device using the requested /// configuration. + /// + /// # Returns + /// An initialized audio output device handle. Dropping the handle stops + /// output. + /// + /// # Errors + /// Returns an error if the platform layer cannot initialize a default output + /// device or cannot satisfy the requested configuration. pub fn build(self) -> Result { let mut platform_builder = platform_audio::AudioDeviceBuilder::new(); @@ -224,6 +293,20 @@ impl AudioOutputDeviceBuilder { } /// Initialize the default audio output device and play audio via a callback. + /// + /// The callback is invoked from the platform audio thread. The callback MUST + /// avoid blocking and MUST NOT allocate. + /// + /// # Arguments + /// - `callback`: A real-time callback invoked for each output buffer tick. + /// + /// # Returns + /// An initialized audio output device handle. Dropping the handle stops + /// output. + /// + /// # Errors + /// Returns an error if the platform layer cannot initialize a default output + /// device or cannot satisfy the requested configuration. pub fn build_with_output_callback( self, callback: Callback, @@ -271,6 +354,12 @@ impl Default for AudioOutputDeviceBuilder { } /// Enumerate available audio output devices via the platform layer. +/// +/// # Returns +/// A list of available output devices with stable metadata. +/// +/// # Errors +/// Returns an error if the platform layer cannot enumerate devices. pub fn enumerate_output_devices( ) -> Result, AudioError> { let devices = @@ -291,6 +380,7 @@ pub fn enumerate_output_devices( mod tests { use super::*; + /// Error mapping MUST remain backend-agnostic. #[test] fn errors_map_without_leaking_platform_types() { let result = AudioOutputDeviceBuilder::new().with_sample_rate(0).build(); From 6a5fd409c8097665ffd6e6a4a976206320ae4f80 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 14:53:18 -0800 Subject: [PATCH 17/23] [add] documentation to the lambda-audio tool --- tools/lambda_audio/src/main.rs | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tools/lambda_audio/src/main.rs b/tools/lambda_audio/src/main.rs index 47626790..806a9a1c 100644 --- a/tools/lambda_audio/src/main.rs +++ b/tools/lambda_audio/src/main.rs @@ -1,4 +1,17 @@ #![allow(clippy::needless_return)] +//! A small CLI for inspecting and playing sound files using the `lambda` audio +//! APIs. +//! +//! This tool is intended for quick manual validation of decoding, device +//! enumeration, and basic output playback behavior. +//! +//! # Commands +//! - `info `: Decode a sound file and print basic metadata. +//! - `view `: Decode a sound file, print metadata, and render an ASCII +//! waveform preview. +//! - `play `: Decode a sound file and play it through the default output +//! device. +//! - `list-devices`: List output devices (platform-dependent). use std::{ path::Path, @@ -19,6 +32,7 @@ use lambda::audio::{ SoundBuffer, }; +/// Runs the `lambda-audio` CLI. fn main() { let mut args = std::env::args(); let raw_program_name = @@ -62,11 +76,26 @@ fn main() { } #[derive(Debug)] +/// A CLI error type that separates usage errors from runtime failures. enum ExitError { + /// The user provided invalid arguments. Usage, + /// The command failed due to an audio/runtime error. Runtime(AudioError), } +/// Prints metadata about a decoded sound file. +/// +/// # Arguments +/// - `program_name`: The CLI program name used for usage output. +/// - `path`: A file path argument, if provided. +/// +/// # Returns +/// Returns `Ok(())` when the command completes successfully. +/// +/// # Errors +/// Returns `ExitError::Usage` when the path argument is missing. +/// Returns `ExitError::Runtime` when decoding fails. fn cmd_info(program_name: &str, path: Option) -> Result<(), ExitError> { let path = require_path(program_name, "info", path)?; let buffer = load_sound_buffer(&path).map_err(ExitError::Runtime)?; @@ -74,6 +103,18 @@ fn cmd_info(program_name: &str, path: Option) -> Result<(), ExitError> { return Ok(()); } +/// Prints metadata and renders an ASCII waveform preview. +/// +/// # Arguments +/// - `program_name`: The CLI program name used for usage output. +/// - `path`: A file path argument, if provided. +/// +/// # Returns +/// Returns `Ok(())` when the command completes successfully. +/// +/// # Errors +/// Returns `ExitError::Usage` when the path argument is missing. +/// Returns `ExitError::Runtime` when decoding fails. fn cmd_view(program_name: &str, path: Option) -> Result<(), ExitError> { let path = require_path(program_name, "view", path)?; let buffer = load_sound_buffer(&path).map_err(ExitError::Runtime)?; @@ -82,6 +123,18 @@ fn cmd_view(program_name: &str, path: Option) -> Result<(), ExitError> { return Ok(()); } +/// Plays a decoded sound file through the default output device. +/// +/// # Arguments +/// - `program_name`: The CLI program name used for usage output. +/// - `path`: A file path argument, if provided. +/// +/// # Returns +/// Returns `Ok(())` when the command completes successfully. +/// +/// # Errors +/// Returns `ExitError::Usage` when the path argument is missing. +/// Returns `ExitError::Runtime` when decoding or playback fails. fn cmd_play(program_name: &str, path: Option) -> Result<(), ExitError> { let path = require_path(program_name, "play", path)?; let buffer = load_sound_buffer(&path).map_err(ExitError::Runtime)?; @@ -90,6 +143,13 @@ fn cmd_play(program_name: &str, path: Option) -> Result<(), ExitError> { return Ok(()); } +/// Lists available output devices. +/// +/// # Returns +/// Returns `Ok(())` when the command completes successfully. +/// +/// # Errors +/// Returns `ExitError::Runtime` when device enumeration fails. fn cmd_list_devices() -> Result<(), ExitError> { let devices = enumerate_output_devices().map_err(ExitError::Runtime)?; @@ -106,6 +166,18 @@ fn cmd_list_devices() -> Result<(), ExitError> { return Ok(()); } +/// Requires a file path argument for a command. +/// +/// # Arguments +/// - `program_name`: The CLI program name used for usage output. +/// - `command`: The command name used for usage output. +/// - `path`: A file path argument, if provided. +/// +/// # Returns +/// Returns the provided path string. +/// +/// # Errors +/// Returns `ExitError::Usage` when `path` is `None`. fn require_path( program_name: &str, command: &str, @@ -118,6 +190,17 @@ fn require_path( return Ok(path); } +/// Loads a sound file into a decoded `SoundBuffer` based on its file extension. +/// +/// # Arguments +/// - `path`: A file path to a supported sound file. +/// +/// # Returns +/// Returns a decoded `SoundBuffer`. +/// +/// # Errors +/// Returns an `AudioError` if decoding fails or the file extension is not +/// supported. fn load_sound_buffer(path: &str) -> Result { let path_value = Path::new(path); let extension = path_value @@ -141,6 +224,14 @@ fn load_sound_buffer(path: &str) -> Result { } } +/// Prints basic decoded metadata for a `SoundBuffer`. +/// +/// # Arguments +/// - `path`: The decoded source path for display purposes. +/// - `buffer`: The decoded sound buffer. +/// +/// # Returns +/// Returns `()` after printing metadata to stdout. fn print_info(path: &str, buffer: &SoundBuffer) { println!("path: {path}"); println!("sample_rate: {}", buffer.sample_rate()); @@ -151,6 +242,17 @@ fn print_info(path: &str, buffer: &SoundBuffer) { return; } +/// Renders an ASCII waveform preview for a `SoundBuffer`. +/// +/// The rendering uses a single channel (the first channel) and shows a +/// peak-per-column visualization, which is intended for quick human inspection +/// rather than precise analysis. +/// +/// # Arguments +/// - `buffer`: The decoded sound buffer to visualize. +/// +/// # Returns +/// Returns `()` after printing the visualization to stdout. fn print_waveform(buffer: &SoundBuffer) { let width: usize = 64; let height: usize = 10; @@ -200,6 +302,22 @@ fn print_waveform(buffer: &SoundBuffer) { return; } +/// Plays a decoded `SoundBuffer` through the default output device. +/// +/// This performs a best-effort playback by writing sequential frames into the +/// output callback. No resampling or channel remapping is performed; instead, +/// the output device is configured to match the buffer's sample rate and +/// channel count. +/// +/// # Arguments +/// - `buffer`: The decoded sound buffer to play. +/// +/// # Returns +/// Returns `Ok(())` after playback has completed. +/// +/// # Errors +/// Returns an `AudioError` if the buffer is empty or output device creation +/// fails. fn play_buffer(buffer: &SoundBuffer) -> Result<(), AudioError> { let samples = buffer.samples(); let total_samples = samples.len(); @@ -259,6 +377,13 @@ fn play_buffer(buffer: &SoundBuffer) -> Result<(), AudioError> { return Ok(()); } +/// Prints usage text to stdout. +/// +/// # Arguments +/// - `program_name`: The CLI program name shown in examples. +/// +/// # Returns +/// Returns `()` after printing the usage text. fn print_usage(program_name: &str) { println!("usage:"); println!(" {program_name} info "); From 3260ec05be539ae587e0df4cfca0a56463f380ca Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 15:09:55 -0800 Subject: [PATCH 18/23] [update] specifications to matc implementation. --- docs/specs/audio-devices.md | 53 ++++++++++++-------- docs/specs/audio-file-loading.md | 86 +++++++++++++++++++------------- 2 files changed, 85 insertions(+), 54 deletions(-) diff --git a/docs/specs/audio-devices.md b/docs/specs/audio-devices.md index e654491e..eb0e83c9 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-31T22:33:14Z" -version: "0.1.16" +last_updated: "2026-02-02T22:57:02Z" +version: "0.1.17" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "1aaa56a242939572b6ec08eda82364c16a85e59a" +repo_commit: "6a5fd409c8097665ffd6e6a4a976206320ae4f80" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "cpal"] @@ -236,7 +236,8 @@ Crate boundary Application-facing API surface ```rust -// crates/lambda-rs/src/audio.rs +// crates/lambda-rs/src/audio/devices/output.rs +// crates/lambda-rs/src/audio/error.rs #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AudioSampleFormat { @@ -256,6 +257,13 @@ pub struct AudioCallbackInfo { pub enum AudioError { InvalidSampleRate { requested: u32 }, InvalidChannels { requested: u16 }, + Io { + path: Option, + details: String, + }, + UnsupportedFormat { details: String }, + InvalidData { details: String }, + DecodeFailed { details: String }, NoDefaultDevice, UnsupportedConfig { requested_sample_rate: Option, @@ -330,7 +338,7 @@ Features - 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. + - Composes `audio-output-device` and `audio-sound-buffer`. ### Application Interaction @@ -526,14 +534,14 @@ Features introduced by this spec - Enables `lambda::audio` output device APIs. - Enables `lambda-rs-platform` `audio-device` internally. - Umbrella feature: `audio` (default: disabled) - - Composes `audio-output-device` only. + - Composes `audio-output-device` and `audio-sound-buffer`. - 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. + - Composes `audio-device`, `audio-decode-wav`, and `audio-decode-vorbis`. Feature gating requirements @@ -574,19 +582,19 @@ Feature gating requirements - Functionality - [x] Feature flags defined (`lambda-rs`: `audio-output-device`, `audio`) - (`crates/lambda-rs/Cargo.toml:22`) + (`crates/lambda-rs/Cargo.toml`) - [x] Feature flags defined (`lambda-rs-platform`: `audio-device`, `audio`) - (`crates/lambda-rs-platform/Cargo.toml:53`) + (`crates/lambda-rs-platform/Cargo.toml`) - [x] `enumerate_output_devices` implemented and returns output devices - (`crates/lambda-rs/src/audio.rs:294`) + (`crates/lambda-rs/src/audio/devices/output.rs`) - [x] `AudioOutputDeviceBuilder::build` initializes default output device - (`crates/lambda-rs/src/audio.rs:222`, + (`crates/lambda-rs/src/audio/devices/output.rs`, `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] `AudioOutputDeviceBuilder::build_with_output_callback` invokes callback - (`crates/lambda-rs/src/audio.rs:247`, + (`crates/lambda-rs/src/audio/devices/output.rs`, `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] Stream created and kept alive for `AudioOutputDevice` lifetime - (`crates/lambda-rs/src/audio.rs:182`, + (`crates/lambda-rs/src/audio/devices/output.rs`, `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] Platform enumeration implemented (`lambda_platform::audio::cpal`) (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) @@ -595,27 +603,30 @@ Feature gating requirements - API Surface - [x] Public `lambda` types implemented: `AudioOutputDevice`, `AudioOutputDeviceInfo`, `AudioOutputDeviceBuilder`, `AudioCallbackInfo`, - `AudioOutputWriter`, `AudioError` (`crates/lambda-rs/src/audio.rs:12`) + `AudioOutputWriter`, `AudioError` + (`crates/lambda-rs/src/audio/devices/output.rs`, + `crates/lambda-rs/src/audio/error.rs`) - [x] Internal platform types implemented: `AudioDevice`, `AudioDeviceInfo`, `AudioDeviceBuilder`, `AudioCallbackInfo`, `AudioOutputWriter`, `AudioError` (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] `lambda::audio` does not re-export `lambda-rs-platform` types - (`crates/lambda-rs/src/audio.rs:10`) + (`crates/lambda-rs/src/audio/devices/output.rs`, + `crates/lambda-rs/src/audio/mod.rs`) - Validation and Errors - [x] Invalid builder inputs rejected (sample rate and channel count) (`crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] Descriptive `AudioError` variants emitted on failures - (`crates/lambda-rs/src/audio.rs:65`, + (`crates/lambda-rs/src/audio/error.rs`, `crates/lambda-rs-platform/src/audio/cpal/device.rs`) - [x] Unsupported configurations reported via `AudioError::UnsupportedConfig` (`crates/lambda-rs-platform/src/audio/cpal/device.rs`, - `crates/lambda-rs/src/audio.rs:72`) + `crates/lambda-rs/src/audio/error.rs`) - Documentation and Examples - [x] `docs/features.md` updated with audio feature documentation - (`docs/features.md:1`) + (`docs/features.md`) - [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`) + (`crates/lambda-rs/examples/audio_sine_wave.rs`) + - [x] `lambda-rs` audio facade implemented (`crates/lambda-rs/src/audio/mod.rs`) ## Verification and Testing @@ -655,6 +666,8 @@ Manual checks ## Changelog +- 2026-02-02 (v0.1.17) — Align specification file references with the current + `lambda::audio` module layout and feature composition. - 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 diff --git a/docs/specs/audio-file-loading.md b/docs/specs/audio-file-loading.md index 078c4c58..99337820 100644 --- a/docs/specs/audio-file-loading.md +++ b/docs/specs/audio-file-loading.md @@ -3,13 +3,13 @@ title: "Audio File Loading (SoundBuffer)" document_id: "audio-file-loading-2026-01-31" status: "draft" created: "2026-01-31T22:07:49Z" -last_updated: "2026-02-02T17:40:16Z" -version: "0.2.1" +last_updated: "2026-02-02T22:57:02Z" +version: "0.2.2" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "5d43a864febd72111671a4fab701cb0e5d2538b6" +repo_commit: "6a5fd409c8097665ffd6e6a4a976206320ae4f80" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["spec", "audio", "lambda-rs", "platform", "assets"] @@ -119,8 +119,7 @@ can be converted into `lambda::audio::SoundBuffer` without exposing codec types. ```rust -// crates/lambda-rs-platform/src/audio_decode.rs (module name selected in -// implementation) +// crates/lambda-rs-platform/src/audio/symphonia/mod.rs #[derive(Clone, Debug, PartialEq)] pub struct DecodedAudio { @@ -139,9 +138,8 @@ pub enum AudioDecodeError { Notes -- The implementation MAY avoid adding a shared `DecodedAudio` module and MAY - instead implement format-specific decode functions returning an equivalent - internal struct. +- This data model is internal to `lambda-rs-platform` and MAY change between + releases. - The platform error type MUST implement `Display` and MUST NOT include vendor error types in variants. @@ -168,9 +166,13 @@ pub struct SoundBuffer { } impl SoundBuffer { + #[cfg(feature = "audio-sound-buffer-wav")] pub fn from_wav_file(path: &std::path::Path) -> Result; + #[cfg(feature = "audio-sound-buffer-wav")] pub fn from_wav_bytes(bytes: &[u8]) -> Result; + #[cfg(feature = "audio-sound-buffer-vorbis")] pub fn from_ogg_file(path: &std::path::Path) -> Result; + #[cfg(feature = "audio-sound-buffer-vorbis")] pub fn from_ogg_bytes(bytes: &[u8]) -> Result; pub fn sample_rate(&self) -> u32; @@ -246,11 +248,10 @@ Crate `lambda-rs-platform` (package: `lambda-rs-platform`) Feature gating rules -- The `lambda::audio` module MUST be compiled when either `audio-output-device` - or `audio-sound-buffer` is enabled. -- Format-specific entry points SHOULD be gated behind the corresponding - granular features and MUST return a deterministic error if called when the - required feature is disabled (if the symbol remains available). +- The `lambda::audio::SoundBuffer` type MUST be compiled only when either + `audio-sound-buffer` or a granular sound buffer feature is enabled. +- Format-specific entry points MUST be compiled only when the corresponding + granular features are enabled. - `docs/features.md` MUST be updated in the implementation change that adds these features. @@ -281,24 +282,40 @@ Recommendations ## Requirements Checklist - Functionality - - [ ] WAV decode implemented (16-bit PCM, 24-bit PCM, 32-bit float) - - [ ] OGG Vorbis decode implemented - - [ ] Load-from-file and load-from-bytes supported + - [x] WAV decode implemented (16-bit PCM, 24-bit PCM, 32-bit float) + (`crates/lambda-rs-platform/src/audio/symphonia/mod.rs`) + - [x] OGG Vorbis decode implemented + (`crates/lambda-rs-platform/src/audio/symphonia/mod.rs`) + - [x] Load-from-file and load-from-bytes supported + (`crates/lambda-rs/src/audio/buffer.rs`) - API Surface - - [ ] `SoundBuffer` public API implemented in `lambda-rs` - - [ ] `lambda-rs` does not expose vendor/platform decode types - - [ ] `lambda::audio` module is available when sound-buffer features enabled + - [x] `SoundBuffer` public API implemented in `lambda-rs` + (`crates/lambda-rs/src/audio/buffer.rs`) + - [x] `lambda-rs` does not expose vendor/platform decode types + (`crates/lambda-rs/src/audio/buffer.rs`) + - [x] `lambda::audio::SoundBuffer` is available when sound-buffer features + enabled (`crates/lambda-rs/src/audio/mod.rs`) - Validation and Errors - - [ ] Unsupported formats return actionable errors - - [ ] Corrupt data returns actionable errors - - [ ] File I/O errors return actionable errors + - [x] Unsupported formats return actionable errors + (`crates/lambda-rs-platform/src/audio/symphonia/mod.rs`, + `crates/lambda-rs/src/audio/error.rs`) + - [x] Corrupt data returns actionable errors + (`crates/lambda-rs-platform/src/audio/symphonia/mod.rs`, + `crates/lambda-rs/src/audio/error.rs`) + - [x] File I/O errors return actionable errors + (`crates/lambda-rs/src/audio/buffer.rs`) - Documentation and Examples - - [ ] `docs/features.md` updated with new features and defaults - - [ ] Minimal example loads a sound file and prints metadata + - [x] `docs/features.md` updated with new features and defaults + (`docs/features.md`) + - [x] Minimal example loads a sound file and prints metadata + (`crates/lambda-rs/examples/sound_buffer_load.rs`) - Tests - - [ ] Unit tests cover WAV mono and stereo - - [ ] Unit tests cover OGG Vorbis mono and stereo - - [ ] Test assets are stored under `crates/lambda-rs-platform/assets/audio/` + - [x] Unit tests cover WAV mono and stereo + (`crates/lambda-rs-platform/src/audio/symphonia/mod.rs`) + - [x] Unit tests cover OGG Vorbis decode (stereo fixture) + (`crates/lambda-rs-platform/src/audio/symphonia/mod.rs`) + - [x] Test assets are stored under `crates/lambda-rs-platform/assets/audio/` + (`crates/lambda-rs-platform/assets/audio/`) For each checked item, include a reference to a commit, pull request, or file path that demonstrates the implementation. @@ -310,13 +327,12 @@ path that demonstrates the implementation. Coverage targets - WAV - - mono 16-bit PCM - - stereo 16-bit PCM - - mono 24-bit PCM - - stereo 32-bit float + - mono 16-bit PCM (`tone_s16_mono_44100.wav`) + - stereo 16-bit PCM (`tone_s16_stereo_44100.wav`) + - mono 24-bit PCM (`tone_s24_mono_44100.wav`) + - stereo 32-bit float (`tone_f32_stereo_44100.wav`) - OGG Vorbis - - mono - - stereo + - stereo (`slash_vorbis_stereo_48000.ogg`) Commands @@ -325,7 +341,7 @@ Commands ### Example -- Add `crates/lambda-rs/examples/sound_buffer_load.rs`. +- Provide `crates/lambda-rs/examples/sound_buffer_load.rs`. - The example SHOULD load a file path provided via CLI args and print: - channels - sample rate @@ -340,6 +356,8 @@ Commands ## Changelog +- 2026-02-02 (v0.2.2) — Align spec with feature-gated `SoundBuffer` surface and + implemented fixtures. - 2026-01-31 (v0.2.0) — Center decoding on `symphonia` 0.5.5. - 2026-01-31 (v0.1.1) — Align spec with platform audio module layout. - 2026-01-31 (v0.1.0) — Initial draft. From c1618bc5098f17a8ef597415e80fdf73823e4da8 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 18:06:32 -0800 Subject: [PATCH 19/23] [update] workflows to use lfs --- .github/workflows/compile_lambda_rs.yml | 2 ++ .github/workflows/coverage.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index 62c12060..b2e4483a 100644 --- a/.github/workflows/compile_lambda_rs.yml +++ b/.github/workflows/compile_lambda_rs.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + lfs: true - name: Cache cargo builds uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5a96b778..fc7904f4 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -30,6 +30,7 @@ jobs: with: # Fetch enough history for diff against base branch fetch-depth: 0 + lfs: true - name: Cache cargo builds uses: Swatinem/rust-cache@v2 From 68a180549669dd775014674e303af2ba014b6c88 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Mon, 2 Feb 2026 18:23:37 -0800 Subject: [PATCH 20/23] [update] the example to temporarily require audio features to work (Need new separate demo crates long term) --- .../lambda-rs/examples/sound_buffer_load.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/lambda-rs/examples/sound_buffer_load.rs b/crates/lambda-rs/examples/sound_buffer_load.rs index 8204fff0..67eebe37 100644 --- a/crates/lambda-rs/examples/sound_buffer_load.rs +++ b/crates/lambda-rs/examples/sound_buffer_load.rs @@ -1,15 +1,46 @@ #![allow(clippy::needless_return)] +//! Sound buffer loading example that decodes a WAV or OGG Vorbis file. +//! +//! This example is application-facing and uses only the `lambda-rs` API surface. +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] use std::path::{ Path, PathBuf, }; +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] use lambda::audio::{ AudioError, SoundBuffer, }; +#[cfg(not(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +)))] +fn main() { + eprintln!( + "This example requires `lambda-rs` sound buffer features.\n\n\ +Run:\n cargo run -p lambda-rs --example sound_buffer_load --features audio-sound-buffer" + ); + return; +} + +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] fn main() { let path = match parse_path_argument() { Ok(path) => path, @@ -34,6 +65,11 @@ fn main() { return; } +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] fn parse_path_argument() -> Result { let mut args = std::env::args_os(); let program_name = args @@ -48,6 +84,11 @@ fn parse_path_argument() -> Result { return Ok(PathBuf::from(path)); } +#[cfg(any( + feature = "audio-sound-buffer", + feature = "audio-sound-buffer-wav", + feature = "audio-sound-buffer-vorbis" +))] fn load_sound_buffer(path: &Path) -> Result { let extension = path .extension() From df7697f715807fa0efec289f8bddd843f61cecf5 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 3 Feb 2026 14:23:22 -0800 Subject: [PATCH 21/23] [update] the audio tool to not be part of the default workspace. --- .github/workflows/compile_lambda_rs.yml | 16 ++++++++++++++++ .github/workflows/lambda-repo-security.yml | 5 +++++ .github/workflows/release.yml | 13 ++++++++++--- Cargo.toml | 1 - 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/compile_lambda_rs.yml b/.github/workflows/compile_lambda_rs.yml index b2e4483a..0d5cc406 100644 --- a/.github/workflows/compile_lambda_rs.yml +++ b/.github/workflows/compile_lambda_rs.yml @@ -89,14 +89,30 @@ jobs: cargo +nightly-2025-09-26 fmt --all --check - name: Build workspace + if: ${{ contains(matrix.features, 'lambda-rs/audio-output-device') }} run: cargo build --workspace --features ${{ matrix.features }} + - name: Build workspace (exclude audio tools) + if: ${{ !contains(matrix.features, 'lambda-rs/audio-output-device') }} + run: | + cargo build --workspace \ + --exclude lambda-audio-tool \ + --features ${{ matrix.features }} + - name: Build examples (lambda-rs) run: cargo build -p lambda-rs --examples --features ${{ matrix.features }} - name: Test workspace + if: ${{ contains(matrix.features, 'lambda-rs/audio-output-device') }} run: cargo test --workspace --features ${{ matrix.features }} + - name: Test workspace (exclude audio tools) + if: ${{ !contains(matrix.features, 'lambda-rs/audio-output-device') }} + run: | + cargo test --workspace \ + --exclude lambda-audio-tool \ + --features ${{ matrix.features }} + - uses: actions/setup-ruby@v1 - name: Send Webhook Notification for build status. if: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/lambda-repo-security.yml b/.github/workflows/lambda-repo-security.yml index 1f9df3bc..753f8243 100644 --- a/.github/workflows/lambda-repo-security.yml +++ b/.github/workflows/lambda-repo-security.yml @@ -41,6 +41,11 @@ jobs: - name: Install dependencies for converting clippy output to SARIF run: cargo install clippy-sarif sarif-fmt + - name: Install Linux deps for audio + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev + - name: Check formatting run: | rustup component add rustfmt --toolchain nightly-2025-09-26 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b458b49c..427910d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,10 +60,17 @@ jobs: run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: | + cargo clippy --workspace \ + --exclude lambda-audio-tool \ + --all-targets \ + -- -D warnings - name: Tests - run: cargo test --workspace -- --nocapture + run: | + cargo test --workspace \ + --exclude lambda-audio-tool \ + -- --nocapture prepare_version: name: Prepare version, tag, push @@ -221,7 +228,7 @@ jobs: shell: bash run: | set -euo pipefail - cargo build --workspace --release --bins + cargo build --workspace --exclude lambda-audio-tool --release --bins - name: Stage files id: stage diff --git a/Cargo.toml b/Cargo.toml index b1a9499f..82937570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,5 +17,4 @@ default-members = [ "crates/lambda-rs-logging", "crates/lambda-rs-platform", "tools/obj_loader", - "tools/lambda_audio", ] From 632f00780061c2bc0d09a51e43ec36a0fcadf4a1 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 3 Feb 2026 14:35:43 -0800 Subject: [PATCH 22/23] [update] example to not rely on audio if the feature is not defined (Will fix in a later PR). --- crates/lambda-rs/examples/play_slash_sound.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/lambda-rs/examples/play_slash_sound.rs b/crates/lambda-rs/examples/play_slash_sound.rs index 8e8c6dff..8d5920a3 100644 --- a/crates/lambda-rs/examples/play_slash_sound.rs +++ b/crates/lambda-rs/examples/play_slash_sound.rs @@ -1,5 +1,13 @@ #![allow(clippy::needless_return)] +//! Audio example that plays the bundled "slash" OGG Vorbis fixture. +//! +//! This example validates that `SoundBuffer` decoding and audio output playback +//! can be composed together using only the `lambda-rs` API surface. +#[cfg(all( + feature = "audio-output-device", + feature = "audio-sound-buffer-vorbis" +))] use std::{ sync::{ atomic::{ @@ -11,6 +19,10 @@ use std::{ time::Duration, }; +#[cfg(all( + feature = "audio-output-device", + feature = "audio-sound-buffer-vorbis" +))] use lambda::audio::{ AudioOutputDeviceBuilder, SoundBuffer, @@ -80,7 +92,10 @@ fn main() { )))] fn main() { eprintln!( - "this example requires `audio-output-device` and `audio-sound-buffer-vorbis`" + "This example requires `lambda-rs` features `audio-output-device` and \ +`audio-sound-buffer-vorbis`.\n\n\ +Run:\n cargo run -p lambda-rs --example play_slash_sound \\\n\ + --features audio-output-device,audio-sound-buffer-vorbis" ); return; } From a4d706447fb47e7c3fa42428ca32248913630f83 Mon Sep 17 00:00:00 2001 From: vmarcella Date: Tue, 3 Feb 2026 14:46:11 -0800 Subject: [PATCH 23/23] [fix] unwrap_or_else -> unwrap_or_default --- crates/lambda-rs/examples/sound_buffer_load.rs | 2 +- tools/lambda_audio/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/lambda-rs/examples/sound_buffer_load.rs b/crates/lambda-rs/examples/sound_buffer_load.rs index 67eebe37..3e6f89b9 100644 --- a/crates/lambda-rs/examples/sound_buffer_load.rs +++ b/crates/lambda-rs/examples/sound_buffer_load.rs @@ -94,7 +94,7 @@ fn load_sound_buffer(path: &Path) -> Result { .extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) - .unwrap_or_else(|| "".to_string()); + .unwrap_or_default(); match extension.as_str() { #[cfg(feature = "audio-sound-buffer-wav")] diff --git a/tools/lambda_audio/src/main.rs b/tools/lambda_audio/src/main.rs index 806a9a1c..1efac6f4 100644 --- a/tools/lambda_audio/src/main.rs +++ b/tools/lambda_audio/src/main.rs @@ -207,7 +207,7 @@ fn load_sound_buffer(path: &str) -> Result { .extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) - .unwrap_or_else(|| "".to_string()); + .unwrap_or_default(); match extension.as_str() { "wav" => {