use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use alsa::pcm::{Access, Format, Frames, HwParams, State}; use alsa::{Direction, ValueOr, PCM}; use tracing::{info, warn}; use crate::error::{AppError, Result}; const RETRY_BACKOFF: Duration = Duration::from_secs(1); const PERIOD_FRAMES: Frames = 1_024; // Request the same compatibility buffer as the known-working ALSA player. // The gadget driver may negotiate a smaller buffer; always use its result. const BUFFER_FRAMES: Frames = 32_768; const IDLE_REOPEN_TIMEOUT: Duration = Duration::from_secs(5); const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UacPlaybackState { Idle, Waiting, Active, Stalled, } impl UacPlaybackState { pub fn as_str(self) -> &'static str { match self { Self::Idle => "idle", Self::Waiting => "waiting", Self::Active => "active", Self::Stalled => "stalled", } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UacPlaybackConfig { pub device_name: String, pub sample_rate: u32, pub channels: u16, } impl Default for UacPlaybackConfig { fn default() -> Self { Self { device_name: crate::otg::uac::find_uac_pcm_device() .unwrap_or_else(crate::otg::uac::uac_pcm_device), sample_rate: 48_000, channels: 2, } } } struct PlaybackInner { config: UacPlaybackConfig, stopped: AtomicBool, active_session: Mutex>>>, } enum SessionSink { Closed { retry_at: Option, }, Probing { pcm: PlaybackPcm, stalled: bool, }, Active { pcm: PlaybackPcm, last_progress: Instant, }, } impl SessionSink { fn state(&self) -> UacPlaybackState { match self { Self::Closed { retry_at: None } => UacPlaybackState::Waiting, Self::Closed { retry_at: Some(_) } => UacPlaybackState::Stalled, Self::Probing { stalled: false, .. } => UacPlaybackState::Waiting, Self::Probing { stalled: true, .. } => UacPlaybackState::Stalled, Self::Active { .. } => UacPlaybackState::Active, } } } struct SessionRuntime { sink: SessionSink, last_frame: Option, } impl SessionRuntime { fn new() -> Self { Self { sink: SessionSink::Closed { retry_at: None }, last_frame: None, } } fn state(&self) -> UacPlaybackState { self.sink.state() } fn close(&mut self) { self.sink = SessionSink::Closed { retry_at: None }; self.last_frame = None; } /// Advance playback only when a WebSocket frame arrives. All ALSA handles /// are non-blocking, so a slow or absent USB host drops the current frame /// instead of occupying a worker thread or accumulating stale speech. fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool { // Reopen on resume without an idle timer thread. stop()/session drop // still close the PCM synchronously, even when no frames arrive. if self .last_frame .is_some_and(|last| last.elapsed() >= IDLE_REOPEN_TIMEOUT) { self.close(); } self.last_frame = Some(Instant::now()); let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None }); let (next_sink, accepted) = drive_sink(sink, config, samples); self.sink = next_sink; accepted } } #[derive(Clone)] pub struct UacPlayback { inner: Arc, } pub struct UacSession { playback: UacPlayback, runtime: Arc>, } impl UacPlayback { pub fn start(config: UacPlaybackConfig) -> Result { if config.sample_rate != 48_000 || config.channels != 2 { return Err(AppError::BadRequest( "UAC playback supports only 48000 Hz stereo".to_string(), )); } Ok(Self { inner: Arc::new(PlaybackInner { config, stopped: AtomicBool::new(false), active_session: Mutex::new(None), }), }) } pub fn acquire_session(&self) -> Result { let mut active = self.inner.active_session.lock().unwrap(); if self.inner.stopped.load(Ordering::Acquire) { return Err(AppError::ServiceUnavailable( "UAC playback is stopping".to_string(), )); } if active.is_some() { return Err(AppError::ServiceUnavailable( "another UAC microphone session is already active".to_string(), )); } let runtime = Arc::new(Mutex::new(SessionRuntime::new())); *active = Some(Arc::clone(&runtime)); Ok(UacSession { playback: self.clone(), runtime, }) } /// Stop accepting frames and synchronously close an active ALSA handle. /// This guarantees configfs may rebuild the UAC function after this call. pub fn stop(&self) { if self.inner.stopped.swap(true, Ordering::AcqRel) { return; } let runtime = self.inner.active_session.lock().unwrap().take(); if let Some(runtime) = runtime { runtime.lock().unwrap().close(); } } } impl UacSession { pub fn state(&self) -> UacPlaybackState { self.runtime.lock().unwrap().state() } /// Returns whether the frame was accepted and the resulting target state. pub fn try_write(&self, pcm: &[i16]) -> Result<(bool, UacPlaybackState)> { let channels = self.playback.inner.config.channels as usize; if pcm.is_empty() || !pcm.len().is_multiple_of(channels) { return Err(AppError::BadRequest( "UAC PCM must contain complete stereo frames".to_string(), )); } if self.playback.inner.stopped.load(Ordering::Acquire) { return Err(AppError::ServiceUnavailable( "UAC playback has stopped".to_string(), )); } let mut runtime = self.runtime.lock().unwrap(); if self.playback.inner.stopped.load(Ordering::Acquire) { runtime.close(); return Err(AppError::ServiceUnavailable( "UAC playback has stopped".to_string(), )); } let accepted = runtime.write(&self.playback.inner.config, pcm); Ok((accepted, runtime.state())) } } impl Drop for UacSession { fn drop(&mut self) { let mut active = self.playback.inner.active_session.lock().unwrap(); if active .as_ref() .is_some_and(|session| Arc::ptr_eq(session, &self.runtime)) { *active = None; } drop(active); self.runtime.lock().unwrap().close(); } } fn drive_sink( sink: SessionSink, config: &UacPlaybackConfig, samples: &[i16], ) -> (SessionSink, bool) { match sink { SessionSink::Closed { retry_at } => { if retry_at.is_some_and(|deadline| Instant::now() < deadline) { return (SessionSink::Closed { retry_at }, false); } match open_pcm(config).and_then(|mut pcm| { pcm.prime_with_silence(config.channels as usize)?; Ok(pcm) }) { Ok(pcm) => drive_probe(pcm, false, config, samples), Err(error) => { warn!("Failed to open UAC playback device; retrying later: {error}"); ( SessionSink::Closed { retry_at: Some(Instant::now() + RETRY_BACKOFF), }, false, ) } } } SessionSink::Probing { pcm, stalled } => drive_probe(pcm, stalled, config, samples), SessionSink::Active { pcm, last_progress } => { drive_active(pcm, last_progress, config, samples) } } } fn drive_probe( mut pcm: PlaybackPcm, stalled: bool, config: &UacPlaybackConfig, samples: &[i16], ) -> (SessionSink, bool) { match pcm.consumption_progress() { Ok(false) => (SessionSink::Probing { pcm, stalled }, false), Ok(true) => { // Keep the stream that has just started consuming. Dropping and // preparing it here creates another startup/underrun window. info!("UAC target started consuming microphone audio"); drive_active(pcm, Instant::now(), config, samples) } Err(error) => recover_sink(pcm, config, error), } } fn drive_active( mut pcm: PlaybackPcm, last_progress: Instant, config: &UacPlaybackConfig, samples: &[i16], ) -> (SessionSink, bool) { let last_progress = match pcm.consumption_progress() { Ok(true) => Instant::now(), Ok(false) => last_progress, Err(error) => return recover_sink(pcm, config, error), }; if last_progress.elapsed() >= SINK_STALL_TIMEOUT { // Discard queued speech before probing an unavailable host again. if let Err(error) = pcm.reset_and_prime(config.channels as usize) { warn!("Failed to reset stalled UAC playback: {error}"); return retry_later(); } info!("UAC target stopped consuming audio; waiting for playback activity"); return (SessionSink::Probing { pcm, stalled: true }, false); } match pcm.write_samples(samples, config.channels as usize) { Ok(accepted) => (SessionSink::Active { pcm, last_progress }, accepted), Err(error) => recover_sink(pcm, config, error), } } fn recover_sink( mut pcm: PlaybackPcm, config: &UacPlaybackConfig, error: alsa::Error, ) -> (SessionSink, bool) { match error.errno() { libc::EAGAIN | libc::EINTR => (SessionSink::Probing { pcm, stalled: true }, false), libc::EPIPE | libc::ESTRPIPE => { // prepare restarts after XRUN/suspend without snd_pcm_recover's // potentially unbounded resume loop. Start again with silence, // and require fresh consumption before reporting Active. match pcm.reset_and_prime(config.channels as usize) { Ok(()) => (SessionSink::Probing { pcm, stalled: true }, false), Err(error) => { warn!("Failed to recover UAC playback: {error}"); retry_later() } } } _ => { warn!("UAC playback failed; reopening later: {error}"); retry_later() } } } fn retry_later() -> (SessionSink, bool) { ( SessionSink::Closed { retry_at: Some(Instant::now() + RETRY_BACKOFF), }, false, ) } fn open_pcm(config: &UacPlaybackConfig) -> Result { let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| { AppError::AudioError(format!( "Failed to open UAC device {}: {error}", config.device_name )) })?; { let params = HwParams::any(&pcm) .map_err(|error| AppError::AudioError(format!("UAC HwParams failed: {error}")))?; params .set_channels(config.channels as u32) .and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest)) .and_then(|_| params.set_format(Format::s16())) .and_then(|_| params.set_access(Access::RWInterleaved)) .and_then(|_| params.set_period_size_near(PERIOD_FRAMES, ValueOr::Nearest)) .and_then(|_| params.set_buffer_size_near(BUFFER_FRAMES)) .and_then(|_| pcm.hw_params(¶ms)) .map_err(|error| { AppError::AudioError(format!("Failed to configure UAC playback: {error}")) })?; } let (buffer_frames, period_frames) = pcm.get_params().map_err(|error| { AppError::AudioError(format!("Failed to read UAC PCM parameters: {error}")) })?; { let params = pcm.sw_params_current().map_err(|error| { AppError::AudioError(format!("Failed to read UAC SwParams: {error}")) })?; params .set_start_threshold(buffer_frames as Frames) .and_then(|_| params.set_stop_threshold(buffer_frames as Frames)) .and_then(|_| params.set_avail_min(period_frames as Frames)) .and_then(|_| pcm.sw_params(¶ms)) .map_err(|error| { AppError::AudioError(format!("Failed to configure UAC SwParams: {error}")) })?; } pcm.prepare().map_err(|error| { AppError::AudioError(format!("Failed to prepare UAC playback: {error}")) })?; info!( "UAC playback opened on {} (buffer={} frames, period={} frames)", config.device_name, buffer_frames, period_frames ); Ok(PlaybackPcm { pcm, buffer_frames: buffer_frames as Frames, period_frames: period_frames as Frames, submitted_frames: 0, consumed_frames: 0, }) } struct PlaybackPcm { pcm: PCM, buffer_frames: Frames, period_frames: Frames, submitted_frames: u64, consumed_frames: u64, } impl PlaybackPcm { fn consumption_progress(&mut self) -> std::result::Result { // avail synchronizes the hardware pointer. Successful writes alone // only show that the ring buffer has room, not that USB is consuming. let available = self.pcm.avail()?; match self.pcm.state() { State::XRun => return Err(alsa::Error::new("UAC PCM state", libc::EPIPE)), State::Suspended => return Err(alsa::Error::new("UAC PCM state", libc::ESTRPIPE)), State::Disconnected => return Err(alsa::Error::new("UAC PCM state", libc::ENODEV)), State::Running => {} _ => return Ok(false), } let consumed = consumed_frames(self.submitted_frames, self.buffer_frames, available); let progressed = consumed > self.consumed_frames; self.consumed_frames = consumed; Ok(progressed) } fn write_samples( &mut self, samples: &[i16], channels: usize, ) -> std::result::Result { let io = self.pcm.io_i16()?; let written = write_frames(samples, channels, self.period_frames as usize, |chunk| { let written = io.writei(chunk)?; self.submitted_frames += written as u64; Ok(written) })?; Ok(written == samples.len() / channels) } fn prime_with_silence(&mut self, channels: usize) -> Result<()> { // Use the negotiated capacity, not BUFFER_FRAMES. A near request is // often clamped by u_audio's DMA buffer limit. let silence = vec![0i16; self.buffer_frames as usize * channels]; let complete = self .write_samples(&silence, channels) .map_err(|error| AppError::AudioError(format!("Failed to prime UAC PCM: {error}")))?; if !complete { return Err(AppError::AudioError( "UAC PCM priming was interrupted".into(), )); } // Most hardware starts automatically at the threshold. Some PCM // plugins remain Prepared despite accepting the complete prefill. // Start explicitly only after priming, and never restart a running PCM. if self.pcm.state() == State::Prepared { self.pcm.start().map_err(|error| { AppError::AudioError(format!("Failed to start primed UAC PCM: {error}")) })?; } Ok(()) } fn reset_and_prime(&mut self, channels: usize) -> Result<()> { self.pcm .drop() .and_then(|_| self.pcm.prepare()) .map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))?; self.submitted_frames = 0; self.consumed_frames = 0; self.prime_with_silence(channels) } } fn consumed_frames(submitted: u64, buffer: Frames, available: Frames) -> u64 { let queued = (buffer - available.clamp(0, buffer)) as u64; submitted.saturating_sub(queued) } /// Bound each write to one negotiated period and advance by actual frames, /// including short writes. Never wait for space or retain stale audio. fn write_frames( samples: &[i16], channels: usize, period_frames: usize, mut write: impl FnMut(&[i16]) -> std::result::Result, ) -> std::result::Result { let total_frames = samples.len() / channels; let mut offset = 0; while offset < total_frames { let end = (offset + period_frames).min(total_frames); match write(&samples[offset * channels..end * channels]) { Ok(0) => break, Ok(written) => offset += written, Err(error) if matches!(error.errno(), libc::EAGAIN | libc::EINTR) => break, Err(error) => return Err(error), } } Ok(offset) } #[cfg(test)] mod tests { use super::*; #[test] fn writes_short_frames_without_skipping_stereo_samples() { let samples: Vec = (0..24).collect(); let mut received = Vec::new(); let written = write_frames(&samples, 2, 4, |chunk| { assert!(chunk.len() <= 8); // Simulate a device accepting only one frame per write. received.extend_from_slice(&chunk[..2]); Ok(1) }) .unwrap(); assert_eq!(written, 12); assert_eq!(received, samples); } #[test] fn full_device_stops_writing_without_waiting_or_claiming_whole_packet() { let mut calls = 0; let written = write_frames(&[0; 24], 2, 4, |_| { calls += 1; if calls == 1 { Ok(2) } else { Err(alsa::Error::new("test write", libc::EAGAIN)) } }) .unwrap(); assert_eq!(written, 2); assert_eq!(calls, 2); } #[test] fn writing_into_free_space_is_not_host_consumption() { // A partially filled or full buffer can exist without any USB I/O. assert_eq!(consumed_frames(1024, 4096, 3072), 0); assert_eq!(consumed_frames(4096, 4096, 0), 0); // Consuming a period, followed by filling it again, preserves progress. assert_eq!(consumed_frames(4096, 4096, 1024), 1024); assert_eq!(consumed_frames(5120, 4096, 0), 1024); } fn null_config() -> UacPlaybackConfig { UacPlaybackConfig { device_name: "null".into(), sample_rate: 48_000, channels: 2, } } #[test] fn native_pcm_uses_negotiated_start_threshold_and_recovers_to_probing() { // ALSA's null plugin exercises real libasound configuration and I/O // without requiring a USB controller. It cannot verify DWC3 behavior. let config = null_config(); let mut pcm = open_pcm(&config).unwrap(); assert_eq!( pcm.pcm .sw_params_current() .unwrap() .get_start_threshold() .unwrap(), pcm.buffer_frames ); pcm.prime_with_silence(2).unwrap(); assert!(pcm.consumption_progress().unwrap()); let (sink, accepted) = recover_sink(pcm, &config, alsa::Error::new("test xrun", libc::EPIPE)); assert!(!accepted); assert!(matches!(sink, SessionSink::Probing { stalled: true, .. })); } #[test] fn stop_closes_an_open_native_pcm_before_returning() { let playback = UacPlayback::start(null_config()).unwrap(); let session = playback.acquire_session().unwrap(); session.try_write(&[0; 2048]).unwrap(); assert!(!matches!( session.runtime.lock().unwrap().sink, SessionSink::Closed { .. } )); playback.stop(); assert!(matches!( session.runtime.lock().unwrap().sink, SessionSink::Closed { retry_at: None } )); } #[test] fn idle_resume_reopens_instead_of_reusing_previous_sink() { let config = null_config(); let mut runtime = SessionRuntime::new(); runtime.sink = SessionSink::Closed { retry_at: Some(Instant::now() + Duration::from_secs(60)), }; runtime.last_frame = Some(Instant::now() - IDLE_REOPEN_TIMEOUT); runtime.write(&config, &[0; 2048]); assert!(!matches!(runtime.sink, SessionSink::Closed { .. })); } #[test] fn permits_only_one_microphone_session() { let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap(); let first = playback.acquire_session().unwrap(); assert_eq!(first.state(), UacPlaybackState::Waiting); assert!(playback.acquire_session().is_err()); drop(first); assert!(playback.acquire_session().is_ok()); playback.stop(); } #[test] fn stop_rejects_new_and_existing_session_writes() { let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap(); let session = playback.acquire_session().unwrap(); playback.stop(); assert!(session.try_write(&[0, 0]).is_err()); assert!(playback.acquire_session().is_err()); } #[test] fn rejects_incomplete_stereo_frames_before_opening_alsa() { let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap(); let session = playback.acquire_session().unwrap(); assert!(session.try_write(&[0]).is_err()); assert_eq!(session.state(), UacPlaybackState::Waiting); } #[test] fn closed_sink_state_reflects_retry_backoff() { assert_eq!( SessionSink::Closed { retry_at: None }.state(), UacPlaybackState::Waiting ); assert_eq!( SessionSink::Closed { retry_at: Some(Instant::now()) } .state(), UacPlaybackState::Stalled ); } }