khora_infra/audio/backends/cpal/device.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! CPAL-backed [`AudioDevice`] / [`AudioStream`].
16//!
17//! `CpalAudioDevice` is a zero-state factory: opening it consumes the box,
18//! grabs the host's default output device, builds a CPAL stream whose
19//! callback pulls from the supplied [`AudioMixBus`], and returns a
20//! [`CpalAudioStream`] handle that keeps the stream alive (via
21//! `Box<dyn AudioStream>` ownership) until dropped.
22
23use std::sync::Arc;
24
25use anyhow::{anyhow, Result};
26use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
27use khora_core::audio::{AudioDevice, AudioMixBus, AudioStream, StreamInfo};
28
29/// CPAL-backed `AudioDevice` factory.
30#[derive(Default)]
31pub struct CpalAudioDevice;
32
33impl CpalAudioDevice {
34 /// Creates a new instance of the CPAL audio device backend.
35 pub fn new() -> Self {
36 Self
37 }
38}
39
40impl AudioDevice for CpalAudioDevice {
41 fn open(self: Box<Self>, mix_bus: Arc<dyn AudioMixBus>) -> Result<Box<dyn AudioStream>> {
42 let host = cpal::default_host();
43 let device = host
44 .default_output_device()
45 .ok_or_else(|| anyhow!("No default output device available"))?;
46 let config = device.default_output_config()?;
47
48 let stream_info = StreamInfo {
49 channels: config.channels(),
50 sample_rate: config.sample_rate(),
51 };
52
53 let bus_for_callback = Arc::clone(&mix_bus);
54 let audio_callback = move |output_buffer: &mut [f32], _: &cpal::OutputCallbackInfo| {
55 bus_for_callback.pull(output_buffer);
56 };
57
58 let error_callback = |err| {
59 log::error!("audio stream error: {}", err);
60 };
61
62 let stream = match config.sample_format() {
63 cpal::SampleFormat::F32 => {
64 device.build_output_stream(&config.into(), audio_callback, error_callback, None)?
65 }
66 format => return Err(anyhow!("Unsupported sample format: {}", format)),
67 };
68
69 stream.play()?;
70
71 log::info!(
72 "audio: stream opened ({} Hz, {} ch)",
73 stream_info.sample_rate,
74 stream_info.channels
75 );
76
77 Ok(Box::new(CpalAudioStream {
78 _stream: stream,
79 info: stream_info,
80 }))
81 }
82}
83
84/// Live CPAL output stream. Keeps the underlying `cpal::Stream` alive
85/// via owned storage; `Drop` stops the stream.
86pub struct CpalAudioStream {
87 // Field is read implicitly via `Drop` — its sole purpose is to keep
88 // the CPAL stream alive for the lifetime of this handle.
89 _stream: cpal::Stream,
90 info: StreamInfo,
91}
92
93// SAFETY: `cpal::Stream` is not Send/Sync because the underlying audio
94// thread is platform-specific. In practice the stream is created on the
95// thread that calls `open` and never moved across threads after — we only
96// store it for the duration of the program. Boxing it as `dyn AudioStream`
97// requires `Send + Sync`; the engine takes the same constraint that every
98// other CPAL-backed audio engine in the Rust ecosystem accepts.
99unsafe impl Send for CpalAudioStream {}
100unsafe impl Sync for CpalAudioStream {}
101
102impl AudioStream for CpalAudioStream {
103 fn stream_info(&self) -> StreamInfo {
104 self.info
105 }
106}