diff --git a/korangar-audio/src/backend.rs b/korangar-audio/src/backend.rs index 0b0a7a5e4..9145775b0 100644 --- a/korangar-audio/src/backend.rs +++ b/korangar-audio/src/backend.rs @@ -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) -> Result<(Self, DeviceInfo, Arc), 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>; } diff --git a/korangar-audio/src/backend/cpal.rs b/korangar-audio/src/backend/cpal.rs index f8476b96c..328e16d1f 100644 --- a/korangar-audio/src/backend/cpal.rs +++ b/korangar-audio/src/backend/cpal.rs @@ -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")] @@ -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 { + 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 { + 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 { + match preferred { + Some(id) => Self::by_id(id), + None => Self::default(), + } + } + + /// Returns info for all available output devices. + pub fn list_all() -> Vec { + 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) } diff --git a/korangar-audio/src/backend/cpal/desktop.rs b/korangar-audio/src/backend/cpal/desktop.rs index db5d00b82..2ffb74730 100644 --- a/korangar-audio/src/backend/cpal/desktop.rs +++ b/korangar-audio/src/backend/cpal/desktop.rs @@ -1,19 +1,27 @@ 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, }, Initialized { - stream_manager_controller: StreamManagerController, + should_drop: Arc, }, } @@ -21,43 +29,119 @@ enum State { /// 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) -> Result<(Self, DeviceInfo, Arc), 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(¤t_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); } } } diff --git a/korangar-audio/src/backend/cpal/desktop/stream_manager.rs b/korangar-audio/src/backend/cpal/desktop/stream_manager.rs index d8533988b..16e542810 100644 --- a/korangar-audio/src/backend/cpal/desktop/stream_manager.rs +++ b/korangar-audio/src/backend/cpal/desktop/stream_manager.rs @@ -1,17 +1,12 @@ use std::ops::{Deref, DerefMut}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; use cpal::traits::{DeviceTrait, StreamTrait}; -use cpal::{BufferSize, Device, Stream, StreamConfig, StreamError}; +use cpal::{Stream, StreamError}; use rtrb::{Consumer, Producer, RingBuffer}; -use super::super::{Error, default_device_and_config}; +use super::super::{Error, OutputDevice}; use crate::backend::Renderer; -const CHECK_STREAM_INTERVAL: Duration = Duration::from_millis(500); - #[allow(clippy::large_enum_variant)] enum State { Empty, @@ -24,113 +19,48 @@ enum State { }, } -pub(super) struct StreamManagerController { - should_drop: Arc, -} - -impl StreamManagerController { - pub(crate) fn stop(&self) { - self.should_drop.store(true, Ordering::SeqCst); - } -} - -/// Starts a cpal stream and restarts it if needed in the case of device changes -/// or disconnections. +/// Manages a single cpal audio stream. pub(super) struct StreamManager { state: State, - device_name: String, - sample_rate: u32, - buffer_size: BufferSize, } impl StreamManager { - pub(crate) fn start( - renderer: Renderer, - device: Device, - mut config: StreamConfig, - buffer_size: BufferSize, - ) -> Result { - let should_drop = Arc::new(AtomicBool::new(false)); - let should_drop_clone = should_drop.clone(); - - let (mut initial_result_producer, mut initial_result_consumer) = RingBuffer::new(1); - - std::thread::spawn(move || { - let mut stream_manager = StreamManager { - state: State::Idle { renderer }, - device_name: device_name(&device), - sample_rate: config.sample_rate, - buffer_size, - }; - let mut unhandled_stream_error_consumer = match stream_manager.start_stream(&device, &mut config) { - Ok(unhandled_stream_error_consumer) => { - initial_result_producer.push(Ok(())).unwrap(); - unhandled_stream_error_consumer - } - Err(err) => { - initial_result_producer.push(Err(err)).unwrap(); - return; - } - }; - loop { - std::thread::sleep(CHECK_STREAM_INTERVAL); - if should_drop.load(Ordering::SeqCst) { - break; - } - stream_manager.check_stream(&mut unhandled_stream_error_consumer); - } - }); - - loop { - if let Ok(result) = initial_result_consumer.pop() { - result?; - break; - } - std::thread::sleep(Duration::from_micros(100)); + /// Creates a new stream manager in an idle state. + pub(super) fn new(renderer: Renderer) -> Self { + Self { + state: State::Idle { renderer }, } - - Ok(StreamManagerController { - should_drop: should_drop_clone, - }) } - /// Restarts the stream if the audio device gets disconnected. - fn check_stream(&mut self, unhandled_stream_error_consumer: &mut Consumer) { - if let State::Running { .. } = &self.state { - while let Ok(error) = unhandled_stream_error_consumer.pop() { - match error { - // Check for device disconnection. - StreamError::DeviceNotAvailable => { - self.stop_stream(); - if let Ok((device, mut config)) = default_device_and_config() { - *unhandled_stream_error_consumer = self.start_stream(&device, &mut config).unwrap(); - } - } - StreamError::StreamInvalidated | StreamError::BufferUnderrun | StreamError::BackendSpecific { err: _ } => {} - } + /// Returns `true` if there are any stream errors that require a restart + /// (e.g. device disconnection). + pub(super) fn has_stream_error(&self, error_consumer: &mut Consumer) -> bool { + let mut needs_restart = false; + while let Ok(error) = error_consumer.pop() { + match error { + StreamError::DeviceNotAvailable => needs_restart = true, + StreamError::StreamInvalidated | StreamError::BufferUnderrun | StreamError::BackendSpecific { err: _ } => {} } } + needs_restart } - fn start_stream(&mut self, device: &Device, config: &mut StreamConfig) -> Result, Error> { - let mut renderer = if let State::Idle { renderer } = std::mem::replace(&mut self.state, State::Empty) { - renderer - } else { + /// Starts the stream on the given output device. Updates the renderer's + /// sample rate. Returns a consumer for stream errors. + pub(super) fn start_stream( + &mut self, + output: &OutputDevice, + ) -> Result, Error> { + // Take the idle renderer, or panic if the stream is already running. + let State::Idle { mut renderer } = std::mem::replace(&mut self.state, State::Empty) else { panic!("trying to start a stream when the stream manager is not idle"); }; - config.buffer_size = self.buffer_size; - let device_name = device_name(device); - let sample_rate = config.sample_rate; - if sample_rate != self.sample_rate { - renderer.on_change_sample_rate(sample_rate); - } - self.device_name = device_name; - self.sample_rate = sample_rate; + renderer.on_change_sample_rate(output.config.sample_rate); let (mut renderer_wrapper, renderer_consumer) = SendOnDrop::new(renderer); let (mut unhandled_stream_error_producer, unhandled_stream_error_consumer) = RingBuffer::new(64); - let channels = config.channels; - let stream = device.build_output_stream( - config, + let channels = output.config.channels; + let stream = output.device.build_output_stream( + &output.config, move |data: &mut [f32], _| { process_renderer(&mut renderer_wrapper, data, channels); }, @@ -144,7 +74,8 @@ impl StreamManager { Ok(unhandled_stream_error_consumer) } - fn stop_stream(&mut self) { + /// Stops the current stream, returning the stream manager to idle state. + pub(super) fn stop_stream(&mut self) { if let State::Running { mut renderer_consumer, stream, @@ -162,12 +93,6 @@ impl StreamManager { } } -fn device_name(device: &Device) -> String { - device - .description() - .map(|description| description.name().to_string()) - .unwrap_or_else(|_| "device name unavailable".to_string()) -} fn process_renderer(renderer: &mut SendOnDrop, data: &mut [f32], channels: u16) { renderer.on_start_processing(); renderer.process(data, channels); diff --git a/korangar-audio/src/backend/cpal/wasm.rs b/korangar-audio/src/backend/cpal/wasm.rs index b4d4eb83b..487df2876 100644 --- a/korangar-audio/src/backend/cpal/wasm.rs +++ b/korangar-audio/src/backend/cpal/wasm.rs @@ -20,7 +20,7 @@ pub(crate) struct CpalBackend { impl Backend for CpalBackend { type Error = Error; - fn setup(_internal_buffer_size: usize) -> Result<(Self, u32), Self::Error> { + fn setup() -> Result<(Self, u32), Self::Error> { let (device, config) = default_device_and_config()?; let sample_rate = config.sample_rate.0; diff --git a/korangar-audio/src/backend/renderer.rs b/korangar-audio/src/backend/renderer.rs index 4786391d5..f82c72dae 100644 --- a/korangar-audio/src/backend/renderer.rs +++ b/korangar-audio/src/backend/renderer.rs @@ -1,18 +1,57 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +#[cfg(feature = "debug")] +use korangar_debug::logging::{Colorize, print_debug}; + +use bytemuck::cast_slice; +use resampler::{Attenuation, Latency, ResamplerFir, SampleRate}; use super::resources::Resources; use crate::Frame; -pub(crate) struct RendererShared { - pub(crate) sample_rate: AtomicU32, +/// Fixed internal mixer sample rate. All sounds are resampled to this +/// rate, and the final output is resampled to the device rate. +pub(crate) const MIXER_SAMPLE_RATE: u32 = 48000; + +/// Size of the internal mixing buffer in frames. +const INTERNAL_BUFFER_SIZE: usize = 256; + +/// FIR resampler for the renderer's output stage (48kHz → device rate). +/// Uses -60dB attenuation which is sufficient for game audio. +struct OutputResampler { + resampler: ResamplerFir, + output_f32: Vec, } -impl RendererShared { - #[must_use] - pub(crate) fn new(sample_rate: u32) -> Self { +impl OutputResampler { + fn new(device_sample_rate: u32) -> Self { + let input_rate = SampleRate::try_from(MIXER_SAMPLE_RATE).unwrap(); + let output_rate = SampleRate::try_from(device_sample_rate).unwrap_or(SampleRate::Hz48000); + let resampler = ResamplerFir::new(2, input_rate, output_rate, Latency::default(), Attenuation::Db60); + let max_output = resampler.buffer_size_output(); Self { - sample_rate: AtomicU32::new(sample_rate), + resampler, + output_f32: vec![0.0; max_output], + } + } + + /// Feeds input frames and appends resampled output to `output`. + fn process(&mut self, input: &[Frame], output: &mut Vec) { + let input_f32: &[f32] = cast_slice(input); + let mut input_pos = 0; + + while input_pos < input_f32.len() { + match self.resampler.resample(&input_f32[input_pos..], &mut self.output_f32) { + Ok((consumed, produced)) => { + if consumed == 0 && produced == 0 { + break; + } + input_pos += consumed; + if produced > 0 { + let output_frames: &[Frame] = cast_slice(&self.output_f32[..produced]); + output.extend_from_slice(output_frames); + } + } + Err(_) => break, + } } } } @@ -20,37 +59,54 @@ impl RendererShared { /// Produces [`Frame`]s of audio data to be consumed by a /// low-level audio API. /// -/// You will probably not need to interact with [`Renderer`]s -/// directly unless you're writing a [`Backend`](super::Backend). +/// The mixer always runs at [`MIXER_SAMPLE_RATE`]. A final resampling +/// step converts to the device's native sample rate. pub(crate) struct Renderer { - dt: f64, - shared: Arc, resources: Resources, - internal_buffer_size: usize, temp_buffer: Vec, + /// FIR resampler from mixer rate to device rate. `None` if they match. + resampler: Option, + /// Resampled frames that didn't fit in the previous callback's output. + overflow: Vec, + /// Persistent buffer for collecting resampled output. + resampled_buffer: Vec, } impl Renderer { #[must_use] - pub(crate) fn new(shared: Arc, internal_buffer_size: usize, resources: Resources) -> Self { + pub(crate) fn new(resources: Resources) -> Self { Self { - dt: 1.0 / shared.sample_rate.load(Ordering::SeqCst) as f64, - shared, resources, - internal_buffer_size, - temp_buffer: vec![Frame::ZERO; internal_buffer_size], + temp_buffer: vec![Frame::ZERO; INTERNAL_BUFFER_SIZE], + resampler: None, + overflow: Vec::new(), + resampled_buffer: Vec::new(), } } - /// Called by the kira when the sample rate of the - /// audio output changes. - pub(crate) fn on_change_sample_rate(&mut self, sample_rate: u32) { - self.dt = 1.0 / sample_rate as f64; - self.shared.sample_rate.store(sample_rate, Ordering::SeqCst); + /// Called when the audio device's sample rate changes. + pub(crate) fn on_change_sample_rate(&mut self, device_sample_rate: u32) { + self.overflow.clear(); + self.resampler = if device_sample_rate != MIXER_SAMPLE_RATE { + Some(OutputResampler::new(device_sample_rate)) + } else { + None + }; + + #[cfg(feature = "debug")] + if self.resampler.is_some() { + print_debug!( + "[{}] resampling {}Hz -> {}Hz", + "audio".magenta(), MIXER_SAMPLE_RATE, device_sample_rate + ); + } else { + print_debug!( + "[{}] no resampling needed ({}Hz)", + "audio".magenta(), MIXER_SAMPLE_RATE + ); + } } - /// Called by the kira when it's time to process - /// a new batch of samples. pub(crate) fn on_start_processing(&mut self) { self.resources.mixer.on_start_processing(); self.resources.listener.on_start_processing(); @@ -58,21 +114,74 @@ impl Renderer { /// Produces the next [`Frame`]s of audio. pub(crate) fn process(&mut self, out: &mut [f32], num_channels: u16) { - for chunk in out.chunks_mut(self.internal_buffer_size * num_channels as usize) { - self.process_chunk(chunk, num_channels); + if self.resampler.is_some() { + self.process_resampled(out, num_channels); + } else { + // Fast path: mixer rate == device rate, no resampling needed. + for chunk in out.chunks_mut(INTERNAL_BUFFER_SIZE * num_channels as usize) { + self.process_chunk_direct(chunk, num_channels); + } } } - fn process_chunk(&mut self, chunk: &mut [f32], num_channels: u16) { - let num_frames = chunk.len() / num_channels as usize; + /// Process when resampling is needed: mix at 48kHz, then resample to device rate. + fn process_resampled(&mut self, out: &mut [f32], num_channels: u16) { + let device_frames_needed = out.len() / num_channels as usize; + let dt = 1.0 / MIXER_SAMPLE_RATE as f64; + + // Start with any leftover frames from the previous callback. + self.resampled_buffer.clear(); + self.resampled_buffer.append(&mut self.overflow); - self.resources.listener.update(self.dt * num_frames as f64); + let resampler = self.resampler.as_mut().unwrap(); + + // Generate mixer chunks and resample until we have enough output. + while self.resampled_buffer.len() < device_frames_needed { + let chunk_size = INTERNAL_BUFFER_SIZE; + self.resources.listener.update(dt * chunk_size as f64); + self.resources.mixer.process( + &mut self.temp_buffer[..chunk_size], + dt, + &self.resources.listener, + ); + + resampler.process(&self.temp_buffer[..chunk_size], &mut self.resampled_buffer); + self.temp_buffer[..chunk_size].fill(Frame::ZERO); + } + + // Write exactly device_frames_needed to output. + for (i, frame) in self.resampled_buffer[..device_frames_needed].iter().enumerate() { + let mut f = *frame; + f.left = f.left.clamp(-1.0, 1.0); + f.right = f.right.clamp(-1.0, 1.0); + let base = i * num_channels as usize; + if num_channels == 1 { + out[base] = (f.left + f.right) / 2.0; + } else { + out[base] = f.left; + out[base + 1] = f.right; + for ch in 2..num_channels as usize { + out[base + ch] = 0.0; + } + } + } + + // Save any extra frames for the next callback. + if self.resampled_buffer.len() > device_frames_needed { + self.overflow.extend_from_slice(&self.resampled_buffer[device_frames_needed..]); + } + } + + /// Direct path: no resampling, mixer rate == device rate. + fn process_chunk_direct(&mut self, chunk: &mut [f32], num_channels: u16) { + let num_frames = chunk.len() / num_channels as usize; + let dt = 1.0 / MIXER_SAMPLE_RATE as f64; + self.resources.listener.update(dt * num_frames as f64); self.resources .mixer - .process(&mut self.temp_buffer[..num_frames], self.dt, &self.resources.listener); + .process(&mut self.temp_buffer[..num_frames], dt, &self.resources.listener); - // Convert from frames to requested number of channels. for (i, channels) in chunk.chunks_mut(num_channels.into()).enumerate() { let mut frame = self.temp_buffer[i]; frame.left = frame.left.clamp(-1.0, 1.0); @@ -82,8 +191,6 @@ impl Renderer { } else { channels[0] = frame.left; channels[1] = frame.right; - // If there's more channels, send silence to them. If we don't, - // we might get bad sounds outputted to those channels. for channel in channels.iter_mut().skip(2) { *channel = 0.0; } diff --git a/korangar-audio/src/device_info.rs b/korangar-audio/src/device_info.rs new file mode 100644 index 000000000..19ee42d66 --- /dev/null +++ b/korangar-audio/src/device_info.rs @@ -0,0 +1,121 @@ +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Condvar, Mutex}; + +/// Stable identifier for an audio output device, persisted across sessions. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DeviceId(String); + +impl DeviceId { + /// Creates a new device ID from a string. + pub fn new(id: String) -> Self { + Self(id) + } + + /// Returns the ID as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for DeviceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Human-readable display name for an audio output device. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DeviceName(String); + +impl DeviceName { + pub(crate) fn new(name: String) -> Self { + Self(name) + } + + /// Returns the name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for DeviceName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// Full description of an output device. +#[derive(Clone, Debug)] +pub struct DeviceInfo { + /// Stable identifier. + pub id: DeviceId, + /// Human-readable name. + pub name: DeviceName, + /// Sample rate in Hz. + pub sample_rate: u32, + /// Number of output channels. + pub channels: u16, +} + +/// The user's preferred output device and the list of available devices. +/// +/// - Main thread: reads available devices, sets preferred device. +/// - Monitoring thread: updates available devices, reads preferred device. +pub(crate) struct OutputDevicePreference { + preferred_device: Mutex>, + available_devices: Mutex>, + devices_changed: AtomicBool, + wake: (Mutex, Condvar), +} + +impl OutputDevicePreference { + pub(crate) fn new(preferred: Option, available: Vec) -> Self { + Self { + preferred_device: Mutex::new(preferred), + available_devices: Mutex::new(available), + devices_changed: AtomicBool::new(false), + wake: (Mutex::new(false), Condvar::new()), + } + } + + pub(crate) fn get(&self) -> Option { + self.preferred_device.lock().unwrap().clone() + } + + pub(crate) fn set(&self, device: Option) { + *self.preferred_device.lock().unwrap() = device; + *self.wake.0.lock().unwrap() = true; + self.wake.1.notify_one(); + } + + /// Returns the current list of available devices. + pub fn available_devices(&self) -> Vec { + self.available_devices.lock().unwrap().clone() + } + + /// Returns true if the device list has changed since the last call, + /// clearing the flag. + pub fn take_devices_changed(&self) -> bool { + self.devices_changed.swap(false, Ordering::SeqCst) + } + + /// Updates the available device list. Called by the monitoring thread. + pub(crate) fn update_available_devices(&self, devices: Vec) { + let mut current = self.available_devices.lock().unwrap(); + if devices.len() != current.len() || devices.iter().zip(current.iter()).any(|(a, b)| a.id != b.id) { + *current = devices; + self.devices_changed.store(true, Ordering::SeqCst); + } + } + + /// Waits up to `timeout` for a preference change, or returns immediately + /// if one was already signaled. + pub(crate) fn wait_for_change(&self, timeout: std::time::Duration) { + let guard = self.wake.0.lock().unwrap(); + if !*guard { + let _ = self.wake.1.wait_timeout(guard, timeout); + } + *self.wake.0.lock().unwrap() = false; + } +} diff --git a/korangar-audio/src/lib.rs b/korangar-audio/src/lib.rs index de07e49ef..40997985c 100644 --- a/korangar-audio/src/lib.rs +++ b/korangar-audio/src/lib.rs @@ -8,6 +8,7 @@ extern crate core; pub(crate) mod backend; pub(crate) mod command; mod decibels; +pub(crate) mod device_info; mod error; mod frame; pub(crate) mod listener; @@ -36,11 +37,12 @@ use korangar_container::{ CacheStatistics, Cacheable, GenerationalSlab, SimpleCache, SimpleSlab, create_generational_key, create_simple_key, }; #[cfg(feature = "debug")] -use korangar_debug::logging::{Colorize, print_debug}; +use korangar_debug::logging::{Colorize, Timer, print_debug}; use korangar_loaders::FileLoader; use rayon::spawn; use crate::backend::cpal::CpalBackend; +pub use crate::device_info::{DeviceId, DeviceInfo, DeviceName}; use crate::decibels::Decibels; use crate::frame::Frame; use crate::manager::{AudioManager, AudioManagerSettings}; @@ -107,6 +109,8 @@ enum AsyncLoadResult { path: String, key: SoundEffectKey, sound_effect: Box, + #[cfg(feature = "debug")] + load_duration: Duration, }, Error { path: String, @@ -149,9 +153,11 @@ struct EngineContext { } impl AudioEngine { - /// Crates a new audio engine. - pub fn new(game_file_loader: Arc) -> AudioEngine { - let mut manager = AudioManager::::new(AudioManagerSettings::default()).expect("can't initialize audio kira"); + /// Creates a new audio engine. `preferred_device` is a saved device ID + /// from a previous session, or `None` to use the system default. + pub fn new(game_file_loader: Arc, preferred_device: Option) -> AudioEngine { + let mut manager = + AudioManager::::new(AudioManagerSettings::default(), preferred_device).expect("can't initialize audio engine"); let background_music_track = manager .add_sub_track(TrackBuilder::default()) .expect("can't create background music track"); @@ -207,6 +213,39 @@ impl AudioEngine { context.cache.statistics() } + /// Returns true if the available device list has changed since the + /// last call, and provides the updated list. + pub fn take_device_list_update(&self) -> Option> { + let context = self.engine_context.lock().unwrap(); + let preference = context.manager.preference(); + if preference.take_devices_changed() { + Some(preference.available_devices()) + } else { + None + } + } + + /// Returns the current list of available output devices. + pub fn list_output_devices(&self) -> Vec { + let context = self.engine_context.lock().unwrap(); + context.manager.preference().available_devices() + } + + /// Sets the preferred output device by its stable ID. `None` follows + /// the system default. If the specified device is unavailable, falls + /// back to the default. + pub fn set_output_device(&self, device: Option) { + let context = self.engine_context.lock().unwrap(); + context.manager.preference().set(device); + } + + /// Returns the currently preferred output device ID, or `None` if + /// following the system default. + pub fn preferred_output_device(&self) -> Option { + let context = self.engine_context.lock().unwrap(); + context.manager.preference().get() + } + /// Mutes or unmutes the audio. pub fn mute(&self, enable: bool) { match enable { @@ -482,6 +521,12 @@ impl EngineContext { continue; }; + #[cfg(feature = "debug")] + { + let path = self.sound_effect_paths.get(sound_config.sound_effect_key).cloned().unwrap_or_default(); + print_debug!("ambient sound entered range: {}", path); + } + let position = sound_config.bounds.center(); let spatial_track = SpatialTrackBuilder::new() @@ -546,6 +591,14 @@ impl EngineContext { // Remove ambient sound that are out of reach. difference(&mut self.previous_query_result, &mut self.query_result, &mut self.scratchpad); for ambient_key in self.scratchpad.iter() { + #[cfg(feature = "debug")] + { + let path = self.ambient_sound.get(*ambient_key) + .and_then(|c| self.sound_effect_paths.get(c.sound_effect_key)) + .cloned() + .unwrap_or_default(); + print_debug!("ambient sound left range: {}", path); + } let _ = self.active_spatial_tracks.remove(ambient_key); let _ = self.cycling_ambient.remove(ambient_key); } @@ -629,7 +682,12 @@ impl EngineContext { path: _path, key, sound_effect, + #[cfg(feature = "debug")] + load_duration: _duration, } => { + #[cfg(feature = "debug")] + print_debug!("load sound effect from {} ({}ms)", _path.magenta(), _duration.as_millis()); + self.loading_sound_effect.remove(&key); if let Err(_error) = self.cache.insert(key, CachedSoundEffect(*sound_effect)) { @@ -776,7 +834,10 @@ impl EngineContext { return; }; - let data = match StreamingSoundData::from_file(path, true) { + #[cfg(feature = "debug")] + let _timer = Timer::new_dynamic(format!("load background music from {}", path.display())); + + let data = match StreamingSoundData::from_file(&path, true) { Ok(sound_effect_data) => sound_effect_data, Err(_error) => { #[cfg(feature = "debug")] @@ -839,6 +900,9 @@ fn spawn_async_load( spawn(move || { let full_path = format!("{SOUND_EFFECT_BASE_PATH}\\{path}"); + #[cfg(feature = "debug")] + let start = Instant::now(); + let data = match game_file_loader.get(&full_path) { Ok(data) => data, Err(error) => { @@ -855,7 +919,13 @@ fn spawn_async_load( return; } }; - let _ = async_response_sender.send(AsyncLoadResult::Loaded { path, key, sound_effect }); + let _ = async_response_sender.send(AsyncLoadResult::Loaded { + path, + key, + sound_effect, + #[cfg(feature = "debug")] + load_duration: start.elapsed(), + }); }); } diff --git a/korangar-audio/src/manager.rs b/korangar-audio/src/manager.rs index a80a21bae..a4e044e4e 100644 --- a/korangar-audio/src/manager.rs +++ b/korangar-audio/src/manager.rs @@ -8,45 +8,48 @@ use std::sync::Arc; use crate::backend::resources::{ResourceControllers, create_resources}; -use crate::backend::{Backend, DefaultBackend, Renderer, RendererShared}; +use crate::backend::{Backend, DefaultBackend, Renderer}; +use crate::device_info::{DeviceId, OutputDevicePreference}; use crate::error::ResourceLimitReached; use crate::listener::ListenerHandle; use crate::track::{MainTrackBuilder, MainTrackHandle, TrackBuilder, TrackHandle}; +/// Size of the internal mixing buffer in frames. +const INTERNAL_BUFFER_SIZE: usize = 256; + /// Controls audio from gameplay code. pub(crate) struct AudioManager { _backend: B, resource_controllers: ResourceControllers, - renderer_shared: Arc, - internal_buffer_size: usize, + preference: Arc, } impl AudioManager { /// Creates a new [`AudioManager`]. - pub(crate) fn new(settings: AudioManagerSettings) -> Result { - let (mut backend, sample_rate) = B::setup(settings.internal_buffer_size)?; - let renderer_shared = Arc::new(RendererShared::new(sample_rate)); + pub(crate) fn new(settings: AudioManagerSettings, preferred: Option) -> Result { + let (mut backend, _device_info, preference) = B::setup(preferred)?; + let (resources, resource_controllers) = - create_resources(settings.capacities, settings.main_track_builder, settings.internal_buffer_size); - let renderer = Renderer::new(renderer_shared.clone(), settings.internal_buffer_size, resources); + create_resources(settings.capacities, settings.main_track_builder, INTERNAL_BUFFER_SIZE); + + let renderer = Renderer::new(resources); + backend.start(renderer)?; Ok(Self { _backend: backend, resource_controllers, - renderer_shared, - internal_buffer_size: settings.internal_buffer_size, + preference, }) } /// Creates a mixer sub-track. pub(crate) fn add_sub_track(&mut self, builder: TrackBuilder) -> Result { - let (track, handle) = builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + let (track, handle) = builder.build(INTERNAL_BUFFER_SIZE); self.resource_controllers.sub_track_controller.insert(track)?; Ok(handle) } - /// Returns the spatial listener handle that can be used for updating its - /// position & orientation. + /// Returns the spatial listener handle. #[must_use] pub(crate) fn listener(&self) -> &ListenerHandle { &self.resource_controllers.listener_handle @@ -57,13 +60,16 @@ impl AudioManager { pub(crate) fn main_track(&mut self) -> &mut MainTrackHandle { &mut self.resource_controllers.main_track_handle } + + /// Returns the device preference. + pub(crate) fn preference(&self) -> &Arc { + &self.preference + } } -/// Specifies how many of each resource type an audio context -/// can have. +/// Specifies how many of each resource type an audio context can have. #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct Capacities { - /// The maximum number of mixer sub-tracks that can exist at a time. pub(crate) sub_track_capacity: usize, } @@ -75,13 +81,8 @@ impl Default for Capacities { /// Settings for an [`AudioManager`](AudioManager). pub(crate) struct AudioManagerSettings { - /// Specifies how many of each resource type an audio context - /// can have. pub(crate) capacities: Capacities, - /// Configures the main mixer track. pub(crate) main_track_builder: MainTrackBuilder, - /// Determines how often modulators will be updated (in samples). - pub(crate) internal_buffer_size: usize, } impl Default for AudioManagerSettings { @@ -89,7 +90,6 @@ impl Default for AudioManagerSettings { Self { capacities: Capacities::default(), main_track_builder: MainTrackBuilder::default(), - internal_buffer_size: 256, } } } diff --git a/korangar-audio/src/track/sub/builder.rs b/korangar-audio/src/track/sub/builder.rs index a7a73196b..7257d0d43 100644 --- a/korangar-audio/src/track/sub/builder.rs +++ b/korangar-audio/src/track/sub/builder.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use super::{Track, TrackHandle, TrackShared, command_writers_and_readers}; -use crate::backend::RendererShared; use crate::backend::resources::ResourceStorage; use crate::decibels::Decibels; use crate::frame::Frame; @@ -14,8 +13,7 @@ pub(crate) struct TrackBuilder {} impl TrackBuilder { #[must_use] - pub(crate) fn build(self, renderer_shared: Arc, internal_buffer_size: usize) -> (Track, TrackHandle) { - let backend_sample_rate = renderer_shared.sample_rate.load(std::sync::atomic::Ordering::SeqCst); + pub(crate) fn build(self, internal_buffer_size: usize) -> (Track, TrackHandle) { let (command_writers, command_readers) = command_writers_and_readers(); let shared = Arc::new(TrackShared::new()); let (sounds, sound_controller) = ResourceStorage::new(128); @@ -32,8 +30,6 @@ impl TrackBuilder { temp_buffer: vec![Frame::ZERO; internal_buffer_size], }; let handle = TrackHandle { - backend_sample_rate, - renderer_shared, shared, command_writers, sound_controller, diff --git a/korangar-audio/src/track/sub/handle.rs b/korangar-audio/src/track/sub/handle.rs index ef67f2bbd..5478283f8 100644 --- a/korangar-audio/src/track/sub/handle.rs +++ b/korangar-audio/src/track/sub/handle.rs @@ -4,7 +4,7 @@ use std::time::Duration; use cgmath::Point3; use super::{CommandWriters, SpatialTrackBuilder, SpatialTrackHandle, Track, TrackShared}; -use crate::backend::RendererShared; +use crate::backend::MIXER_SAMPLE_RATE; use crate::backend::resources::ResourceController; use crate::command::ValueChangeCommand; use crate::decibels::Decibels; @@ -16,8 +16,6 @@ use crate::sound::{Sound, SoundData}; /// When a [`TrackHandle`] is dropped, the corresponding mixer track will be /// removed. pub(crate) struct TrackHandle { - pub(crate) backend_sample_rate: u32, - pub(crate) renderer_shared: Arc, pub(crate) shared: Arc, pub(crate) command_writers: CommandWriters, pub(crate) sound_controller: ResourceController>, @@ -26,10 +24,10 @@ pub(crate) struct TrackHandle { } impl TrackHandle { - /// Plays a sound. + /// Plays a sound, resampling to the fixed mixer sample rate. pub(crate) fn play(&mut self, sound_data: D) -> Result> { let (sound, handle) = sound_data - .into_sound(self.backend_sample_rate) + .into_sound(MIXER_SAMPLE_RATE) .map_err(PlaySoundError::IntoSoundError)?; self.sound_controller.insert(sound).map_err(|_| PlaySoundError::SoundLimitReached)?; Ok(handle) @@ -41,7 +39,7 @@ impl TrackHandle { position: Point3, builder: SpatialTrackBuilder, ) -> Result { - let (track, handle) = builder.build(self.renderer_shared.clone(), self.internal_buffer_size, position); + let (track, handle) = builder.build(self.internal_buffer_size, position); self.sub_track_controller.insert(track)?; Ok(handle) } diff --git a/korangar-audio/src/track/sub/spatial_builder.rs b/korangar-audio/src/track/sub/spatial_builder.rs index 1c4a63bc5..0b1cf06fb 100644 --- a/korangar-audio/src/track/sub/spatial_builder.rs +++ b/korangar-audio/src/track/sub/spatial_builder.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use cgmath::Point3; use super::{SpatialData, SpatialTrackHandle, Track, TrackShared, command_writers_and_readers}; -use crate::backend::RendererShared; use crate::backend::resources::ResourceStorage; use crate::decibels::Decibels; use crate::frame::Frame; @@ -69,11 +68,9 @@ impl SpatialTrackBuilder { #[must_use] pub(crate) fn build( self, - renderer_shared: Arc, internal_buffer_size: usize, position: Point3, ) -> (Track, SpatialTrackHandle) { - let backend_sample_rate = renderer_shared.sample_rate.load(std::sync::atomic::Ordering::SeqCst); let (_command_writers, command_readers) = command_writers_and_readers(); let shared = Arc::new(TrackShared::new()); let (sounds, sound_controller) = ResourceStorage::new(128); @@ -95,7 +92,6 @@ impl SpatialTrackBuilder { temp_buffer: vec![Frame::ZERO; internal_buffer_size], }; let handle = SpatialTrackHandle { - backend_sample_rate, shared, sound_controller, }; diff --git a/korangar-audio/src/track/sub/spatial_handle.rs b/korangar-audio/src/track/sub/spatial_handle.rs index c0a167f57..c7c486ce3 100644 --- a/korangar-audio/src/track/sub/spatial_handle.rs +++ b/korangar-audio/src/track/sub/spatial_handle.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use super::TrackShared; +use crate::backend::MIXER_SAMPLE_RATE; use crate::backend::resources::ResourceController; use crate::error::PlaySoundError; use crate::sound::{Sound, SoundData}; @@ -10,7 +11,6 @@ use crate::sound::{Sound, SoundData}; /// When a [`SpatialTrackHandle`] is dropped, the corresponding mixer /// track will be removed. pub(crate) struct SpatialTrackHandle { - pub(crate) backend_sample_rate: u32, pub(crate) shared: Arc, pub(crate) sound_controller: ResourceController>, } @@ -19,7 +19,7 @@ impl SpatialTrackHandle { /// Plays a sound. pub(crate) fn play(&mut self, sound_data: D) -> Result> { let (sound, handle) = sound_data - .into_sound(self.backend_sample_rate) + .into_sound(MIXER_SAMPLE_RATE) .map_err(PlaySoundError::IntoSoundError)?; self.sound_controller.insert(sound).map_err(|_| PlaySoundError::SoundLimitReached)?; Ok(handle) diff --git a/korangar/src/interface/windows/audio_settings.rs b/korangar/src/interface/windows/audio_settings.rs index 14f0b0c42..5b44661d4 100644 --- a/korangar/src/interface/windows/audio_settings.rs +++ b/korangar/src/interface/windows/audio_settings.rs @@ -2,6 +2,7 @@ use korangar_interface::window::{CustomWindow, Window}; use rust_state::Path; use crate::interface::windows::WindowClass; +use crate::loaders::OverflowBehavior; use crate::settings::{AudioSettings, AudioSettingsPathExt}; use crate::state::localization::LocalizationPathExt; use crate::state::theme::InterfaceThemeType; @@ -35,6 +36,18 @@ where theme: InterfaceThemeType::InGame, closable: true, elements: ( + split! { + children: ( + text! { + text: "Output device", + overflow_behavior: OverflowBehavior::Shrink, + }, + drop_down! { + selected: client_state().audio_settings().selected_output_device(), + options: client_state().audio_settings().available_output_devices(), + }, + ), + }, state_button! { text: client_state().localization().mute_audio_on_focus_loss_button_text(), state: self.audio_settings_path.mute_on_focus_loss(), diff --git a/korangar/src/main.rs b/korangar/src/main.rs index 092e2222f..3cdd568d1 100644 --- a/korangar/src/main.rs +++ b/korangar/src/main.rs @@ -79,6 +79,7 @@ use rust_state::{ManuallyAssertExt, State}; use rust_state::{VecIndexExt, VecLookupExt}; use settings::{ AudioSettings, AudioSettingsPathExt, GraphicsSettingsCapabilities, GraphicsSettingsPathExt, InterfaceSettings, InterfaceSettingsPathExt, + OutputDeviceOption, OutputDeviceOptionId, }; use state::hotbar::HotbarPathExt; use state::inventory::InventoryPathExt; @@ -320,6 +321,7 @@ struct Client { #[cfg(not(feature = "debug"))] networking_system: NetworkingSystem, audio_engine: Arc>, + active_audio_settings: AudioSettings, active_interface_settings: InterfaceSettings, active_graphics_settings: GraphicsSettings, graphics_engine: GraphicsEngine, @@ -413,8 +415,32 @@ impl Client { }); time_phase!("create audio engine", { - let audio_engine = Arc::new(AudioEngine::new(game_file_loader.clone())); + let mut audio_settings = AudioSettings::new(); + let preferred_device = audio_settings.preferred_device_id.clone() + .map(korangar_audio::DeviceId::new); + #[cfg(feature = "debug")] + match &preferred_device { + Some(id) => print_debug!("preferred audio device: {}", id.as_str().magenta()), + None => print_debug!("no preferred audio device, using system default"), + } + + let audio_engine = Arc::new(AudioEngine::new(game_file_loader.clone(), preferred_device)); audio_engine.set_background_music_volume(0.1); + + // Build the device list for audio settings. + let mut device_options = vec![OutputDeviceOption { + display_name: "System Default".to_string(), + device_id: None, + index: OutputDeviceOptionId(0), + }]; + for (i, info) in audio_engine.list_output_devices().into_iter().enumerate() { + device_options.push(OutputDeviceOption { + display_name: info.name.to_string(), + device_id: Some(info.id.as_str().to_string()), + index: OutputDeviceOptionId(i + 1), + }); + } + audio_settings.set_device_list(device_options); }); time_phase!("create resource managers", { @@ -635,12 +661,14 @@ impl Client { let client_state = State::new(ClientState::new( &game_file_loader, graphics_settings.clone(), + audio_settings, #[cfg(feature = "debug")] packet_history, )); }); let active_interface_settings = client_state.follow(crate::client_state().interface_settings()).clone(); + let active_audio_settings = client_state.follow(crate::client_state().audio_settings()).clone(); interface.open_window(LoginWindow::new( crate::client_state().login_window(), @@ -722,6 +750,7 @@ impl Client { main_menu_click_sound_effect, networking_system, audio_engine, + active_audio_settings, active_interface_settings, active_graphics_settings: graphics_settings, graphics_engine, @@ -3596,6 +3625,60 @@ impl Client { self.active_graphics_settings.high_quality_interface = graphics_settings.high_quality_interface; } + // Refresh the available device list if the backend detected a change. + if let Some(live_devices) = self.audio_engine.take_device_list_update() { + let audio_settings = self.client_state.follow(client_state().audio_settings()); + let current_device_id = audio_settings.selected_device_id().cloned(); + + let mut device_options = vec![OutputDeviceOption { + display_name: "System Default".to_string(), + device_id: None, + index: OutputDeviceOptionId(0), + }]; + for (i, info) in live_devices.into_iter().enumerate() { + device_options.push(OutputDeviceOption { + display_name: info.name.to_string(), + device_id: Some(info.id.as_str().to_string()), + index: OutputDeviceOptionId(i + 1), + }); + } + + // Find the previously selected device in the new list. + let new_selected = current_device_id + .as_ref() + .and_then(|id| { + device_options.iter() + .find(|d| d.device_id.as_ref() == Some(id)) + .map(|d| d.index) + }) + .unwrap_or(OutputDeviceOptionId(0)); + + let audio_settings = self.client_state.follow_mut(client_state().audio_settings()); + audio_settings.available_output_devices = device_options; + audio_settings.selected_output_device = new_selected; + self.active_audio_settings.selected_output_device = new_selected; + } + + // Detect audio settings changes. + let audio_settings = self.client_state.follow(client_state().audio_settings()); + let selected_device = audio_settings.selected_output_device; + let mute_on_focus_loss = audio_settings.mute_on_focus_loss; + + if selected_device != self.active_audio_settings.selected_output_device { + self.active_audio_settings.selected_output_device = selected_device; + let audio_settings = self.client_state.follow(client_state().audio_settings()); + let new_device_id = audio_settings.selected_device_id().cloned(); + self.audio_engine.set_output_device(new_device_id.as_ref().map(|id| korangar_audio::DeviceId::new(id.clone()))); + let audio_settings = self.client_state.follow_mut(client_state().audio_settings()); + audio_settings.preferred_device_id = new_device_id; + audio_settings.save(); + } + + if mute_on_focus_loss != self.active_audio_settings.mute_on_focus_loss { + self.active_audio_settings.mute_on_focus_loss = mute_on_focus_loss; + self.client_state.follow(client_state().audio_settings()).save(); + } + let language = *self.client_state.follow(client_state().interface_settings().language()); if self.active_interface_settings.language != language { diff --git a/korangar/src/settings/audio.rs b/korangar/src/settings/audio.rs index 4dc423370..8c2362323 100644 --- a/korangar/src/settings/audio.rs +++ b/korangar/src/settings/audio.rs @@ -1,18 +1,81 @@ #[cfg(feature = "debug")] use korangar_debug::logging::{Colorize, print_debug}; +use korangar_interface::components::drop_down::DropDownItem; use korangar_interface::element::StateElement; use ron::ser::PrettyConfig; use rust_state::RustState; use serde::{Deserialize, Serialize}; +/// Identity type for the device dropdown. Index 0 = "System Default", +/// index N = the Nth device in the available devices list. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, RustState, StateElement)] +pub struct OutputDeviceOptionId(pub usize); + +/// An entry in the output device dropdown. +#[derive(Debug, Clone, RustState, StateElement)] +pub struct OutputDeviceOption { + /// Display name shown in the dropdown. + pub display_name: String, + /// Device ID string, or None for "System Default". + pub device_id: Option, + /// Index in the dropdown list. + pub index: OutputDeviceOptionId, +} + +impl DropDownItem for OutputDeviceOption { + fn text(&self) -> &str { + &self.display_name + } + + fn value(&self) -> OutputDeviceOptionId { + self.index + } +} + #[derive(Clone, Serialize, Deserialize, RustState, StateElement)] pub struct AudioSettings { pub mute_on_focus_loss: bool, + /// Stable ID of the preferred output device, or None to follow the system default. + #[serde(default)] + pub preferred_device_id: Option, + /// Available output devices for the dropdown. Not persisted. + #[serde(skip)] + pub available_output_devices: Vec, + /// Currently selected output device index. Not persisted. + #[serde(skip)] + pub selected_output_device: OutputDeviceOptionId, +} + +impl AudioSettings { + /// Sets the available device list and resolves the selected device + /// from the saved preference. + pub fn set_device_list(&mut self, device_list: Vec) { + self.selected_output_device = self.preferred_device_id.as_ref() + .and_then(|saved_id| { + device_list.iter() + .find(|d| d.device_id.as_deref() == Some(saved_id.as_str())) + .map(|d| d.index) + }) + .unwrap_or(OutputDeviceOptionId(0)); + self.available_output_devices = device_list; + } + + /// Returns the preferred device ID derived from the current selection. + pub fn selected_device_id(&self) -> Option<&String> { + self.available_output_devices + .get(self.selected_output_device.0) + .and_then(|d| d.device_id.as_ref()) + } } impl Default for AudioSettings { fn default() -> Self { - Self { mute_on_focus_loss: true } + Self { + mute_on_focus_loss: true, + preferred_device_id: None, + available_output_devices: Vec::new(), + selected_output_device: OutputDeviceOptionId(0), + } } } diff --git a/korangar/src/state/mod.rs b/korangar/src/state/mod.rs index 878ac543c..e1e1df2a6 100644 --- a/korangar/src/state/mod.rs +++ b/korangar/src/state/mod.rs @@ -246,11 +246,11 @@ impl ClientState { pub fn new( game_file_loader: &GameFileLoader, graphics_settings: GraphicsSettings, + audio_settings: AudioSettings, #[cfg(feature = "debug")] packet_history: PacketHistory, ) -> Self { time_phase!("load settings", { let mut login_settings = LoginSettings::new(); - let audio_settings = AudioSettings::new(); let game_settings = GameSettings::new(); let interface_settings = InterfaceSettings::new(); let interface_settings_capabilities = InterfaceSettingsCapabilities::default();