Skip to main content

phosphor_core/
cpal_backend.rs

1//! Real audio output via cpal.
2//!
3//! Creates a high-priority audio thread that calls our callback
4//! each buffer cycle. This is the production audio path.
5
6use anyhow::{Context, Result};
7use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
8use cpal::{SampleFormat, Stream, StreamConfig};
9use tracing;
10
11/// Real audio backend using cpal.
12pub struct CpalBackend {
13    stream: Option<Stream>,
14    sample_rate: u32,
15    channels: u16,
16}
17
18impl CpalBackend {
19    /// Create a new cpal backend. Does NOT start the stream yet.
20    pub fn new(_desired_sample_rate: u32, _desired_buffer_size: u32) -> Result<Self> {
21        let host = cpal::default_host();
22        let device = host
23            .default_output_device()
24            .context("no audio output device found")?;
25
26        let name = device.name().unwrap_or_else(|_| "unknown".into());
27        tracing::info!("Audio device: {name}");
28
29        let config = device.default_output_config()?;
30        let sample_rate = config.sample_rate().0;
31        let channels = config.channels();
32
33        tracing::info!(
34            "Audio config: {}Hz, {} channels, {:?}",
35            sample_rate, channels, config.sample_format()
36        );
37
38        Ok(Self {
39            stream: None,
40            sample_rate,
41            channels,
42        })
43    }
44
45    /// Start the audio stream, calling `callback` for each buffer.
46    /// The callback receives an interleaved f32 buffer: [L, R, L, R, ...]
47    pub fn start<F>(&mut self, mut callback: F) -> Result<()>
48    where
49        F: FnMut(&mut [f32]) + Send + 'static,
50    {
51        let host = cpal::default_host();
52        let device = host
53            .default_output_device()
54            .context("no audio output device found")?;
55
56        let supported = device.default_output_config()?;
57
58        let config = StreamConfig {
59            channels: supported.channels(),
60            sample_rate: supported.sample_rate(),
61            buffer_size: cpal::BufferSize::Default,
62        };
63
64        self.sample_rate = config.sample_rate.0;
65        self.channels = config.channels;
66
67        let stream = match supported.sample_format() {
68            SampleFormat::F32 => device.build_output_stream(
69                &config,
70                move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
71                    callback(data);
72                },
73                |err| tracing::error!("Audio stream error: {err}"),
74                None,
75            )?,
76            SampleFormat::I16 => device.build_output_stream(
77                &config,
78                move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
79                    // Convert: call callback with f32 buffer, then convert to i16
80                    let mut float_buf = vec![0.0f32; data.len()];
81                    callback(&mut float_buf);
82                    for (out, &inp) in data.iter_mut().zip(float_buf.iter()) {
83                        *out = (inp * i16::MAX as f32) as i16;
84                    }
85                },
86                |err| tracing::error!("Audio stream error: {err}"),
87                None,
88            )?,
89            format => anyhow::bail!("Unsupported sample format: {format:?}"),
90        };
91
92        stream.play()?;
93        tracing::info!("Audio stream started");
94        self.stream = Some(stream);
95        Ok(())
96    }
97
98    pub fn stop(&mut self) {
99        if let Some(stream) = self.stream.take() {
100            drop(stream);
101            tracing::info!("Audio stream stopped");
102        }
103    }
104
105    pub fn sample_rate(&self) -> u32 {
106        self.sample_rate
107    }
108
109    pub fn channels(&self) -> u16 {
110        self.channels
111    }
112}
113
114impl Drop for CpalBackend {
115    fn drop(&mut self) {
116        self.stop();
117    }
118}