From 4fb872dde5c8e7cc207100fd8d4185c65fe7964f Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 3 Mar 2026 08:09:30 +0000 Subject: [PATCH] fixes PR005 --- .../prometeu-host-desktop-winit/src/audio.rs | 267 ++++++++++++++++-- .../prometeu-host-desktop-winit/src/runner.rs | 6 +- 2 files changed, 245 insertions(+), 28 deletions(-) diff --git a/crates/host/prometeu-host-desktop-winit/src/audio.rs b/crates/host/prometeu-host-desktop-winit/src/audio.rs index 4f986d3c..b5942679 100644 --- a/crates/host/prometeu-host-desktop-winit/src/audio.rs +++ b/crates/host/prometeu-host-desktop-winit/src/audio.rs @@ -1,15 +1,96 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use prometeu_drivers::{AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE}; use prometeu_hal::LoopMode; -use ringbuf::HeapRb; use ringbuf::traits::{Consumer, Producer, Split}; +use ringbuf::HeapRb; +use std::error::Error; +use std::fmt; use std::sync::Arc; use std::time::Duration; +trait AudioStreamHandle {} + +impl AudioStreamHandle for cpal::Stream {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostAudioError { + NoOutputDevice, + BuildStream(String), + PlayStream(String), +} + +impl fmt::Display for HostAudioError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HostAudioError::NoOutputDevice => write!(f, "no output device available"), + HostAudioError::BuildStream(err) => write!(f, "failed to build audio stream: {}", err), + HostAudioError::PlayStream(err) => write!(f, "failed to play audio stream: {}", err), + } + } +} + +impl Error for HostAudioError {} + +trait AudioRuntime { + type Device; + type Stream: AudioStreamHandle + 'static; + + fn default_output_device(&self) -> Option; + + fn build_output_stream( + &self, + device: &Self::Device, + config: &cpal::StreamConfig, + data_callback: D, + error_callback: E, + ) -> Result + where + D: FnMut(&mut [f32]) + Send + 'static, + E: FnMut(String) + Send + 'static; + + fn play_stream(&self, stream: &Self::Stream) -> Result<(), HostAudioError>; +} + +struct CpalAudioRuntime; + +impl AudioRuntime for CpalAudioRuntime { + type Device = cpal::Device; + type Stream = cpal::Stream; + + fn default_output_device(&self) -> Option { + cpal::default_host().default_output_device() + } + + fn build_output_stream( + &self, + device: &Self::Device, + config: &cpal::StreamConfig, + mut data_callback: D, + mut error_callback: E, + ) -> Result + where + D: FnMut(&mut [f32]) + Send + 'static, + E: FnMut(String) + Send + 'static, + { + device + .build_output_stream( + config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| data_callback(data), + move |err| error_callback(err.to_string()), + None, + ) + .map_err(|err| HostAudioError::BuildStream(err.to_string())) + } + + fn play_stream(&self, stream: &Self::Stream) -> Result<(), HostAudioError> { + stream.play().map_err(|err| HostAudioError::PlayStream(err.to_string())) + } +} + pub struct HostAudio { pub producer: Option>>>, pub perf_consumer: Option>>>, - _stream: Option, + _stream: Option>, } impl HostAudio { @@ -17,9 +98,16 @@ impl HostAudio { Self { producer: None, perf_consumer: None, _stream: None } } - pub fn init(&mut self) { - let host = cpal::default_host(); - let device = host.default_output_device().expect("no output device available"); + pub fn init(&mut self) -> Result<(), HostAudioError> { + self.init_with_runtime(&CpalAudioRuntime) + } + + fn init_with_runtime(&mut self, runtime: &R) -> Result<(), HostAudioError> { + self.producer = None; + self.perf_consumer = None; + self._stream = None; + + let device = runtime.default_output_device().ok_or(HostAudioError::NoOutputDevice)?; let config = cpal::StreamConfig { channels: 2, @@ -30,35 +118,33 @@ impl HostAudio { let rb = HeapRb::::new(1024); let (prod, mut cons) = rb.split(); - self.producer = Some(prod); - let mut mixer = AudioMixer::new(); // To pass performance data from the audio thread to the main thread let audio_perf_rb = HeapRb::::new(64); let (mut perf_prod, perf_cons) = audio_perf_rb.split(); - let stream = device - .build_output_stream( - &config, - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - // Consumes commands from ringbuffer - while let Some(cmd) = cons.try_pop() { - mixer.process_command(cmd); - } - // Mixes audio - mixer.fill_buffer(data); - // Sends processing time in microseconds - let _ = perf_prod.try_push(mixer.last_processing_time.as_micros() as u64); - }, - |err| eprintln!("audio stream error: {}", err), - None, - ) - .expect("failed to build audio stream"); + let stream = runtime.build_output_stream( + &device, + &config, + move |data: &mut [f32]| { + // Consumes commands from ringbuffer + while let Some(cmd) = cons.try_pop() { + mixer.process_command(cmd); + } + // Mixes audio + mixer.fill_buffer(data); + // Sends processing time in microseconds + let _ = perf_prod.try_push(mixer.last_processing_time.as_micros() as u64); + }, + |err| eprintln!("audio stream error: {}", err), + )?; - stream.play().expect("failed to play audio stream"); - self._stream = Some(stream); + runtime.play_stream(&stream)?; + self.producer = Some(prod); self.perf_consumer = Some(perf_cons); + self._stream = Some(Box::new(stream)); + Ok(()) } pub fn send_commands(&mut self, commands: &mut Vec) { @@ -68,6 +154,8 @@ impl HostAudio { eprintln!("[HostAudio] Command ringbuffer full, dropping command."); } } + } else { + commands.clear(); } } @@ -80,6 +168,133 @@ impl HostAudio { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::HostStats; + + struct FakeStream; + + impl AudioStreamHandle for FakeStream {} + + #[derive(Clone, Copy)] + struct FakeDevice; + + struct FakeRuntime { + has_device: bool, + build_result: Result<(), HostAudioError>, + play_result: Result<(), HostAudioError>, + } + + impl AudioRuntime for FakeRuntime { + type Device = FakeDevice; + type Stream = FakeStream; + + fn default_output_device(&self) -> Option { + self.has_device.then_some(FakeDevice) + } + + fn build_output_stream( + &self, + _device: &Self::Device, + _config: &cpal::StreamConfig, + _data_callback: D, + _error_callback: E, + ) -> Result + where + D: FnMut(&mut [f32]) + Send + 'static, + E: FnMut(String) + Send + 'static, + { + self.build_result.clone().map(|_| FakeStream) + } + + fn play_stream(&self, _stream: &Self::Stream) -> Result<(), HostAudioError> { + self.play_result.clone() + } + } + + #[test] + fn test_send_commands_without_audio_drops_pending_commands() { + let mut audio = HostAudio::new(); + let mut commands = vec![AudioCommand::MasterPause, AudioCommand::MasterResume]; + + audio.send_commands(&mut commands); + + assert!(commands.is_empty()); + assert!(audio.producer.is_none()); + } + + #[test] + fn test_update_stats_without_audio_is_safe() { + let mut audio = HostAudio::new(); + let mut stats = HostStats::new(); + + audio.update_stats(&mut stats); + + assert_eq!(stats.audio_load_accum_us, 0); + assert_eq!(stats.audio_load_samples, 0); + } + + #[test] + fn test_init_returns_error_when_no_output_device_exists() { + let mut audio = HostAudio::new(); + let runtime = FakeRuntime { has_device: false, build_result: Ok(()), play_result: Ok(()) }; + + let result = audio.init_with_runtime(&runtime); + + assert_eq!(result, Err(HostAudioError::NoOutputDevice)); + assert!(audio.producer.is_none()); + assert!(audio.perf_consumer.is_none()); + assert!(audio._stream.is_none()); + } + + #[test] + fn test_init_returns_error_when_stream_build_fails() { + let mut audio = HostAudio::new(); + let runtime = FakeRuntime { + has_device: true, + build_result: Err(HostAudioError::BuildStream("simulated build failure".to_string())), + play_result: Ok(()), + }; + + let result = audio.init_with_runtime(&runtime); + + assert_eq!(result, Err(HostAudioError::BuildStream("simulated build failure".to_string()))); + assert!(audio.producer.is_none()); + assert!(audio.perf_consumer.is_none()); + assert!(audio._stream.is_none()); + } + + #[test] + fn test_init_returns_error_when_stream_play_fails() { + let mut audio = HostAudio::new(); + let runtime = FakeRuntime { + has_device: true, + build_result: Ok(()), + play_result: Err(HostAudioError::PlayStream("simulated play failure".to_string())), + }; + + let result = audio.init_with_runtime(&runtime); + + assert_eq!(result, Err(HostAudioError::PlayStream("simulated play failure".to_string()))); + assert!(audio.producer.is_none()); + assert!(audio.perf_consumer.is_none()); + assert!(audio._stream.is_none()); + } + + #[test] + fn test_init_populates_audio_state_on_success() { + let mut audio = HostAudio::new(); + let runtime = FakeRuntime { has_device: true, build_result: Ok(()), play_result: Ok(()) }; + + audio.init_with_runtime(&runtime).unwrap(); + + assert!(audio.producer.is_some()); + assert!(audio.perf_consumer.is_some()); + assert!(audio._stream.is_some()); + } +} + pub struct AudioMixer { voices: [Channel; MAX_CHANNELS], pub last_processing_time: Duration, diff --git a/crates/host/prometeu-host-desktop-winit/src/runner.rs b/crates/host/prometeu-host-desktop-winit/src/runner.rs index 336dbc79..a4ccc5e8 100644 --- a/crates/host/prometeu-host-desktop-winit/src/runner.rs +++ b/crates/host/prometeu-host-desktop-winit/src/runner.rs @@ -7,8 +7,8 @@ use crate::stats::HostStats; use crate::utilities::draw_rgb565_to_rgba8; use pixels::wgpu::PresentMode; use pixels::{Pixels, PixelsBuilder, SurfaceTexture}; -use prometeu_drivers::AudioCommand; use prometeu_drivers::hardware::Hardware; +use prometeu_drivers::AudioCommand; use prometeu_firmware::{BootTarget, Firmware}; use prometeu_hal::color::Color; use prometeu_hal::telemetry::CertificationConfig; @@ -238,7 +238,9 @@ impl ApplicationHandler for HostRunner { self.pixels = Some(pixels); - self.audio.init(); + if let Err(err) = self.audio.init() { + eprintln!("[HostAudio] Disabled: {}", err); + } event_loop.set_control_flow(ControlFlow::Poll); }