Skip to content

Commit aa150c3

Browse files
committed
[add] basic cpal implementation for enumerating devices and audio output.
1 parent 2f76e0a commit aa150c3

2 files changed

Lines changed: 348 additions & 0 deletions

File tree

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,332 @@
11
#![allow(clippy::needless_return)]
2+
3+
//! Audio output device discovery and stream initialization.
4+
//!
5+
//! This module defines a backend-agnostic surface that `lambda-rs` can use to
6+
//! enumerate and initialize audio output devices. The implementation is
7+
//! expected to be backed by a platform dependency (for example, `cpal`) behind
8+
//! feature flags.
9+
//!
10+
//! This surface MUST NOT expose backend or vendor types (including `cpal`
11+
//! types) in its public API.
12+
13+
use std::{
14+
error::Error,
15+
fmt,
16+
};
17+
18+
/// Output sample format used by the platform stream callback.
19+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20+
pub enum AudioSampleFormat {
21+
/// 32-bit floating point samples in the nominal range `[-1.0, 1.0]`.
22+
F32,
23+
/// Signed 16-bit integer samples mapped from normalized `f32`.
24+
I16,
25+
/// Unsigned 16-bit integer samples mapped from normalized `f32`.
26+
U16,
27+
}
28+
29+
/// Information available to audio output callbacks.
30+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31+
pub struct AudioCallbackInfo {
32+
/// Audio frames per second.
33+
pub sample_rate: u32,
34+
/// Interleaved output channel count.
35+
pub channels: u16,
36+
/// The selected stream sample format.
37+
pub sample_format: AudioSampleFormat,
38+
}
39+
40+
/// Real-time writer for audio output buffers.
41+
///
42+
/// This writer MUST be implemented without allocation and MUST write into the
43+
/// underlying device output buffer for the current callback invocation.
44+
pub trait AudioOutputWriter {
45+
/// Return the output channel count for the current callback invocation.
46+
fn channels(&self) -> u16;
47+
/// Return the number of frames in the output buffer for the current callback
48+
/// invocation.
49+
fn frames(&self) -> usize;
50+
/// Clear the entire output buffer to silence.
51+
fn clear(&mut self);
52+
53+
/// Write a normalized sample in the range `[-1.0, 1.0]`.
54+
///
55+
/// Implementations MUST clamp values outside `[-1.0, 1.0]`. Implementations
56+
/// MUST NOT panic for out-of-range indices and MUST perform no write in that
57+
/// case.
58+
fn set_sample(
59+
&mut self,
60+
frame_index: usize,
61+
channel_index: usize,
62+
sample: f32,
63+
);
64+
}
65+
66+
/// Metadata describing an available audio output device.
67+
#[derive(Clone, Debug, PartialEq, Eq)]
68+
pub struct AudioDeviceInfo {
69+
/// Human-readable device name.
70+
pub name: String,
71+
/// Whether this device is the current default output device.
72+
pub is_default: bool,
73+
}
74+
75+
/// Actionable errors produced by the platform audio layer.
76+
///
77+
/// This error type is internal to `lambda-rs-platform` and MUST NOT expose
78+
/// backend-specific types in its public API.
79+
#[derive(Clone, Debug, PartialEq, Eq)]
80+
pub enum AudioError {
81+
/// The requested sample rate was invalid.
82+
InvalidSampleRate { requested: u32 },
83+
/// The requested channel count was invalid.
84+
InvalidChannels { requested: u16 },
85+
/// No audio host is available.
86+
HostUnavailable { details: String },
87+
/// No default audio output device is available.
88+
NoDefaultDevice,
89+
/// The device name could not be retrieved.
90+
DeviceNameUnavailable { details: String },
91+
/// Device enumeration failed.
92+
DeviceEnumerationFailed { details: String },
93+
/// Supported output configurations could not be retrieved.
94+
SupportedConfigsUnavailable { details: String },
95+
/// No supported output configuration satisfied the request.
96+
UnsupportedConfig {
97+
requested_sample_rate: Option<u32>,
98+
requested_channels: Option<u16>,
99+
},
100+
/// The selected output sample format is unsupported by this abstraction.
101+
UnsupportedSampleFormat { details: String },
102+
/// A backend-specific failure occurred.
103+
Platform { details: String },
104+
/// Building an output stream failed.
105+
StreamBuildFailed { details: String },
106+
/// Starting an output stream failed.
107+
StreamPlayFailed { details: String },
108+
}
109+
110+
impl fmt::Display for AudioError {
111+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112+
match self {
113+
Self::InvalidSampleRate { requested } => {
114+
return write!(formatter, "invalid sample rate: {requested}");
115+
}
116+
Self::InvalidChannels { requested } => {
117+
return write!(formatter, "invalid channel count: {requested}");
118+
}
119+
Self::HostUnavailable { details } => {
120+
return write!(formatter, "audio host unavailable: {details}");
121+
}
122+
Self::NoDefaultDevice => {
123+
return write!(formatter, "no default audio output device available");
124+
}
125+
Self::DeviceNameUnavailable { details } => {
126+
return write!(formatter, "device name unavailable: {details}");
127+
}
128+
Self::DeviceEnumerationFailed { details } => {
129+
return write!(formatter, "device enumeration failed: {details}");
130+
}
131+
Self::SupportedConfigsUnavailable { details } => {
132+
return write!(
133+
formatter,
134+
"supported output configs unavailable: {details}"
135+
);
136+
}
137+
Self::UnsupportedConfig {
138+
requested_sample_rate,
139+
requested_channels,
140+
} => {
141+
return write!(
142+
formatter,
143+
"unsupported output config: sample_rate={requested_sample_rate:?} channels={requested_channels:?}",
144+
);
145+
}
146+
Self::UnsupportedSampleFormat { details } => {
147+
return write!(
148+
formatter,
149+
"unsupported output sample format: {details}"
150+
);
151+
}
152+
Self::Platform { details } => {
153+
return write!(formatter, "platform audio error: {details}");
154+
}
155+
Self::StreamBuildFailed { details } => {
156+
return write!(formatter, "stream build failed: {details}");
157+
}
158+
Self::StreamPlayFailed { details } => {
159+
return write!(formatter, "stream play failed: {details}");
160+
}
161+
}
162+
}
163+
}
164+
165+
impl Error for AudioError {}
166+
167+
/// An initialized audio output device.
168+
///
169+
/// This type is an opaque platform wrapper. It MUST NOT expose backend types.
170+
pub struct AudioDevice {
171+
_private: (),
172+
}
173+
174+
/// Builder for creating an [`AudioDevice`].
175+
#[derive(Debug, Clone)]
176+
pub struct AudioDeviceBuilder {
177+
sample_rate: Option<u32>,
178+
channels: Option<u16>,
179+
label: Option<String>,
180+
}
181+
182+
impl AudioDeviceBuilder {
183+
/// Create a builder with engine defaults.
184+
pub fn new() -> Self {
185+
return Self {
186+
sample_rate: None,
187+
channels: None,
188+
label: None,
189+
};
190+
}
191+
192+
/// Request a specific sample rate (Hz).
193+
pub fn with_sample_rate(mut self, rate: u32) -> Self {
194+
self.sample_rate = Some(rate);
195+
return self;
196+
}
197+
198+
/// Request a specific channel count.
199+
pub fn with_channels(mut self, channels: u16) -> Self {
200+
self.channels = Some(channels);
201+
return self;
202+
}
203+
204+
/// Attach a label for diagnostics.
205+
pub fn with_label(mut self, label: &str) -> Self {
206+
self.label = Some(label.to_string());
207+
return self;
208+
}
209+
210+
/// Initialize the default audio output device using the requested
211+
/// configuration.
212+
pub fn build(self) -> Result<AudioDevice, AudioError> {
213+
if let Some(sample_rate) = self.sample_rate {
214+
if sample_rate == 0 {
215+
return Err(AudioError::InvalidSampleRate {
216+
requested: sample_rate,
217+
});
218+
}
219+
}
220+
221+
if let Some(channels) = self.channels {
222+
if channels == 0 {
223+
return Err(AudioError::InvalidChannels {
224+
requested: channels,
225+
});
226+
}
227+
}
228+
229+
return Err(AudioError::HostUnavailable {
230+
details: "audio backend not wired".to_string(),
231+
});
232+
}
233+
234+
/// Initialize the default audio output device and play audio via a callback.
235+
pub fn build_with_output_callback<Callback>(
236+
self,
237+
callback: Callback,
238+
) -> Result<AudioDevice, AudioError>
239+
where
240+
Callback:
241+
'static + Send + FnMut(&mut dyn AudioOutputWriter, AudioCallbackInfo),
242+
{
243+
let _ = callback;
244+
return self.build();
245+
}
246+
}
247+
248+
/// Enumerate available audio output devices.
249+
pub fn enumerate_devices() -> Result<Vec<AudioDeviceInfo>, AudioError> {
250+
return Err(AudioError::HostUnavailable {
251+
details: "audio backend not wired".to_string(),
252+
});
253+
}
254+
255+
#[cfg(test)]
256+
mod tests {
257+
use super::*;
258+
259+
#[test]
260+
fn build_rejects_zero_sample_rate() {
261+
let result = AudioDeviceBuilder::new().with_sample_rate(0).build();
262+
assert!(matches!(
263+
result,
264+
Err(AudioError::InvalidSampleRate { requested: 0 })
265+
));
266+
}
267+
268+
#[test]
269+
fn build_rejects_zero_channels() {
270+
let result = AudioDeviceBuilder::new().with_channels(0).build();
271+
assert!(matches!(
272+
result,
273+
Err(AudioError::InvalidChannels { requested: 0 })
274+
));
275+
}
276+
277+
#[test]
278+
fn build_returns_host_unavailable_until_backend_is_wired() {
279+
let result = AudioDeviceBuilder::new().build();
280+
match result {
281+
Err(AudioError::HostUnavailable { details }) => {
282+
assert_eq!(details, "audio backend not wired");
283+
return;
284+
}
285+
Ok(_device) => {
286+
panic!("expected host unavailable error, got Ok");
287+
}
288+
Err(error) => {
289+
panic!("expected host unavailable error, got {error}");
290+
}
291+
}
292+
}
293+
294+
#[test]
295+
fn enumerate_devices_returns_host_unavailable_until_backend_is_wired() {
296+
let result = enumerate_devices();
297+
match result {
298+
Err(AudioError::HostUnavailable { details }) => {
299+
assert_eq!(details, "audio backend not wired");
300+
return;
301+
}
302+
Ok(_devices) => {
303+
panic!("expected host unavailable error, got Ok");
304+
}
305+
Err(error) => {
306+
panic!("expected host unavailable error, got {error}");
307+
}
308+
}
309+
}
310+
311+
#[test]
312+
fn build_with_output_callback_returns_host_unavailable_until_backend_is_wired(
313+
) {
314+
let result = AudioDeviceBuilder::new().build_with_output_callback(
315+
|_writer, _callback_info| {
316+
return;
317+
},
318+
);
319+
match result {
320+
Err(AudioError::HostUnavailable { details }) => {
321+
assert_eq!(details, "audio backend not wired");
322+
return;
323+
}
324+
Ok(_device) => {
325+
panic!("expected host unavailable error, got Ok");
326+
}
327+
Err(error) => {
328+
panic!("expected host unavailable error, got {error}");
329+
}
330+
}
331+
}
332+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
11
#![allow(clippy::needless_return)]
22

3+
//! Internal audio backend abstractions used by `lambda-rs`.
4+
//!
5+
//! Applications MUST NOT depend on `lambda-rs-platform` directly. The types
6+
//! exposed from this module are intended to support `lambda-rs` implementations
7+
//! and MAY change between releases.
8+
39
pub mod device;
10+
11+
pub use device::{
12+
enumerate_devices,
13+
AudioCallbackInfo,
14+
AudioDevice,
15+
AudioDeviceBuilder,
16+
AudioDeviceInfo,
17+
AudioError,
18+
AudioOutputWriter,
19+
AudioSampleFormat,
20+
};

0 commit comments

Comments
 (0)