Skip to main content

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}