Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions korangar-audio/src/backend.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
//! Communication between the audio engine and the low-level audio API.
use std::sync::Arc;

pub(crate) mod cpal;
mod renderer;
pub(crate) mod resources;

pub(crate) use renderer::*;
pub(crate) use renderer::{MIXER_SAMPLE_RATE, Renderer};
use crate::device_info::{DeviceId, DeviceInfo, OutputDevicePreference};

/// The default kira used by [`AudioManager`](crate::AudioManager)s.
pub(crate) type DefaultBackend = cpal::CpalBackend;

/// Connects a [`Renderer`] to a lower level audio API.
/// Connects a [`Renderer`] to a platform audio API.
pub(crate) trait Backend: Sized {
/// Errors that can occur when using this kira.
type Error;

/// Starts the kira and returns itself and the initial sample rate.
fn setup(internal_buffer_size: usize) -> Result<(Self, u32), Self::Error>;
/// Queries the platform for a suitable audio device.
fn setup(preferred: Option<DeviceId>) -> Result<(Self, DeviceInfo, Arc<OutputDevicePreference>), Self::Error>;

/// Sends the renderer to the kira to start audio playback.
/// Starts audio playback with the given renderer.
fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error>;
}
114 changes: 98 additions & 16 deletions korangar-audio/src/backend/cpal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

mod error;

use cpal::traits::HostTrait;
use cpal::{BufferSize, Device, StreamConfig};
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{Device, StreamConfig};
pub(crate) use error::Error;

use crate::device_info::{DeviceId, DeviceInfo, DeviceName};

#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(target_arch = "wasm32")]
Expand All @@ -16,18 +18,98 @@ mod desktop;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use desktop::CpalBackend;

pub(crate) fn default_device_and_config() -> Result<(Device, StreamConfig), Error> {
let host = cpal::default_host();
let device = host.default_output_device().ok_or(Error::NoDefaultOutputDevice)?;
// We don't use the default sampling rate, since if the audio device switches,
// we need to use the same configuration for it, or else the re-sampled audio
// files won't play correctly (we re-sample audio files on load, not at
// playtime). Stereo with 48 kHz should be supported by any device and is the
// standard for many operating systems.
let config = StreamConfig {
channels: 2,
sample_rate: 48000,
buffer_size: BufferSize::Fixed(1200),
};
Ok((device, config))
/// A resolved output device paired with its stream configuration.
pub(crate) struct OutputDevice {
pub device: Device,
pub config: StreamConfig,
}

impl OutputDevice {
/// Returns the display name of this device.
pub fn name(&self) -> DeviceName {
device_name(&self.device)
}

/// Returns the stable ID of this device.
pub fn id(&self) -> DeviceId {
device_id(&self.device)
}

/// Returns the full device info.
pub fn device_info(&self) -> DeviceInfo {
DeviceInfo {
id: self.id(),
name: self.name(),
sample_rate: self.config.sample_rate,
channels: self.config.channels,
}
}

/// Returns the system default output device.
pub fn default() -> Result<Self, Error> {
let host = cpal::default_host();
let device = host.default_output_device().ok_or(Error::NoDefaultOutputDevice)?;
let config: StreamConfig = device.default_output_config()?.into();
Ok(Self { device, config })
}

/// Finds a specific output device by its stable ID. Falls back to the
/// system default if the requested device is not found.
pub fn by_id(target_id: &DeviceId) -> Result<Self, Error> {
let host = cpal::default_host();
if let Ok(devices) = host.output_devices() {
for device in devices {
if device_id(&device) == *target_id {
let config: StreamConfig = device.default_output_config()?.into();
return Ok(Self { device, config });
}
}
}
Self::default()
}

/// Resolves the target device based on a preferred device ID. If a
/// preferred device is set and available, returns it. Otherwise returns
/// the system default device.
pub fn resolve(preferred: Option<&DeviceId>) -> Result<Self, Error> {
match preferred {
Some(id) => Self::by_id(id),
None => Self::default(),
}
}

/// Returns info for all available output devices.
pub fn list_all() -> Vec<DeviceInfo> {
let host = cpal::default_host();
let Ok(devices) = host.output_devices() else {
return Vec::new();
};
devices.filter_map(|d| {
let config: StreamConfig = d.default_output_config().ok()?.into();
Some(DeviceInfo {
id: device_id(&d),
name: device_name(&d),
sample_rate: config.sample_rate,
channels: config.channels,
})
}).collect()
}
}

/// Extracts the display name from a raw cpal device.
fn device_name(device: &Device) -> DeviceName {
let name = device
.description()
.map(|d| d.name().to_string())
.unwrap_or_else(|_| "Unknown".to_string());
DeviceName::new(name)
}

/// Extracts the stable ID from a raw cpal device, with a name-based fallback.
fn device_id(device: &Device) -> DeviceId {
let id_string = device
.id()
.map(|id| id.to_string())
.unwrap_or_else(|_| format!("fallback:{}", device_name(device)));
DeviceId::new(id_string)
}
128 changes: 106 additions & 22 deletions korangar-audio/src/backend/cpal/desktop.rs
Original file line number Diff line number Diff line change
@@ -1,63 +1,147 @@
mod stream_manager;

use cpal::{BufferSize, Device, StreamConfig};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use self::stream_manager::{StreamManager, StreamManagerController};
use super::{Error, default_device_and_config};
#[cfg(feature = "debug")]
use korangar_debug::logging::{Colorize, print_debug};

use self::stream_manager::StreamManager;
use super::{Error, OutputDevice};
use crate::backend::{Backend, Renderer};
use crate::device_info::{DeviceId, DeviceInfo, OutputDevicePreference};

const CHECK_STREAM_INTERVAL: Duration = Duration::from_millis(500);

enum State {
Empty,
Uninitialized {
device: Device,
config: StreamConfig,
output: OutputDevice,
preference: Arc<OutputDevicePreference>,
},
Initialized {
stream_manager_controller: StreamManagerController,
should_drop: Arc<AtomicBool>,
},
}

/// A backend that uses [cpal](https://crates.io/crates/cpal) to
/// connect a [`Renderer`] to the operating system's audio driver.
pub(crate) struct CpalBackend {
state: State,
buffer_size: BufferSize,
}

impl Backend for CpalBackend {
type Error = Error;

fn setup(_internal_buffer_size: usize) -> Result<(Self, u32), Self::Error> {
let (device, config) = default_device_and_config()?;
let sample_rate = config.sample_rate;
let buffer_size = config.buffer_size;

fn setup(preferred: Option<DeviceId>) -> Result<(Self, DeviceInfo, Arc<OutputDevicePreference>), Self::Error> {
let output = OutputDevice::resolve(preferred.as_ref())?;
let device_info = output.device_info();
#[cfg(feature = "debug")]
{
let source = match &preferred {
Some(id) if device_info.id == *id => "preferred",
Some(_) => "default (preferred not found)",
None => "default",
};
print_debug!(
"[{}] using {} device {} ({}Hz, {} ch)",
"audio".magenta(),
source,
device_info.name,
device_info.sample_rate,
device_info.channels
);
}
let available = OutputDevice::list_all();
let preference = Arc::new(OutputDevicePreference::new(preferred, available));
Ok((
Self {
state: State::Uninitialized { device, config },
buffer_size,
state: State::Uninitialized { output, preference: preference.clone() },
},
sample_rate,
device_info,
preference,
))
}

fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> {
let state = std::mem::replace(&mut self.state, State::Empty);
if let State::Uninitialized { device, config } = state {
self.state = State::Initialized {
stream_manager_controller: StreamManager::start(renderer, device, config, self.buffer_size)?,
let State::Uninitialized { output, preference } = state else {
panic!("cannot initialize the audio backend multiple times");
};

let should_drop = Arc::new(AtomicBool::new(false));
let should_drop_clone = should_drop.clone();

let (mut initial_result_producer, mut initial_result_consumer) =
rtrb::RingBuffer::new(1);

// Monitoring thread: polls for device changes and stream errors.
// Wakes immediately when the user changes the preferred device,
// or every CHECK_STREAM_INTERVAL to catch system-level changes.
std::thread::spawn(move || {
let mut manager = StreamManager::new(renderer);
let mut current_device = output;

let mut error_consumer = match manager.start_stream(&current_device) {
Ok(consumer) => {
initial_result_producer.push(Ok(())).unwrap();
consumer
}
Err(err) => {
initial_result_producer.push(Err(err)).unwrap();
return;
}
};
} else {
panic!("cannot initialize the audio backend multiple times")

loop {
preference.wait_for_change(CHECK_STREAM_INTERVAL);
if should_drop.load(Ordering::SeqCst) {
break;
}

let needs_restart = manager.has_stream_error(&mut error_consumer);

if let Ok(target) = OutputDevice::resolve(preference.get().as_ref()) {
if needs_restart || target.id() != current_device.id() {
#[cfg(feature = "debug")]
{
let info = target.device_info();
print_debug!(
"[{}] switching to device {} ({}Hz, {} ch)",
"audio".magenta(), info.name, info.sample_rate, info.channels
);
}
manager.stop_stream();
if let Ok(consumer) = manager.start_stream(&target) {
current_device = target;
error_consumer = consumer;
}
}
}

// Refresh the available device list for the UI.
preference.update_available_devices(OutputDevice::list_all());
}
});

loop {
if let Ok(result) = initial_result_consumer.pop() {
result?;
break;
}
std::thread::sleep(Duration::from_micros(100));
}

self.state = State::Initialized { should_drop: should_drop_clone };
Ok(())
}
}

impl Drop for CpalBackend {
fn drop(&mut self) {
if let State::Initialized { stream_manager_controller } = &self.state {
stream_manager_controller.stop();
if let State::Initialized { should_drop } = &self.state {
should_drop.store(true, Ordering::SeqCst);
}
}
}
Loading