feat: 完善 OTG UAC 音频支持

This commit is contained in:
mofeng-git
2026-07-26 11:59:58 +08:00
parent f86cba6ce5
commit 5963dfa01a
46 changed files with 2875 additions and 1867 deletions

View File

@@ -1,3 +1,15 @@
//! Platform-neutral capture lifecycle and PCM frame types.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, info};
use crate::error::Result;
use crate::utils::LogThrottler;
#[cfg(unix)]
#[path = "capture_linux.rs"]
mod imp;
@@ -6,4 +18,146 @@ mod imp;
#[path = "capture_windows.rs"]
mod imp;
pub use imp::*;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48_000,
channels: 2,
buffer_frames: 4096,
period_frames: 960,
}
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32) -> Self {
Self {
data,
sample_rate,
channels,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Starting,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: watch::Sender<CaptureState>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
task: Mutex<Option<tokio::task::JoinHandle<()>>>,
lifecycle: Mutex<()>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state,
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
task: Mutex::new(None),
lifecycle: Mutex::new(()),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
if matches!(self.state(), CaptureState::Starting | CaptureState::Running) {
return Ok(());
}
if let Some(previous) = self.task.lock().await.take() {
let _ = previous.await;
}
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::Release);
let _ = self.state.send(CaptureState::Starting);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = Arc::clone(&self.stop_flag);
let log_throttler = self.log_throttler.clone();
let task = tokio::task::spawn_blocking(move || {
match imp::run_capture(&config, &state, &frame_tx, &stop_flag, &log_throttler) {
Ok(()) => {
let _ = state.send(CaptureState::Stopped);
}
Err(error) => {
crate::error_throttled!(
log_throttler,
"capture_error",
"Audio capture error: {}",
error
);
let _ = state.send(CaptureState::Error);
}
}
});
*self.task.lock().await = Some(task);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
self.stop_flag.store(true, Ordering::Release);
if let Some(task) = self.task.lock().await.take() {
let _ = task.await;
}
let _ = self.state.send(CaptureState::Stopped);
info!("Audio capture stopped");
Ok(())
}
}

View File

@@ -1,271 +1,60 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
use alsa::{Direction, ValueOr, PCM};
use bytes::Bytes;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, info};
use tokio::sync::{broadcast, watch};
use tracing::debug;
use crate::audio::device::AudioDeviceInfo;
use super::{AudioConfig, AudioFrame, CaptureState};
use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::{error_throttled, warn_throttled};
use crate::warn_throttled;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub frame_size: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
const RETRY_DELAY: Duration = Duration::from_millis(5);
const MAX_CONSECUTIVE_READ_ERRORS: u32 = 10;
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48000,
channels: 2,
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
}
}
impl AudioConfig {
pub fn for_device(device: &AudioDeviceInfo) -> Self {
Self {
device_name: device.name.clone(),
..Default::default()
}
}
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
pub samples: u32,
pub sequence: u64,
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / bps,
data,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state: Arc::new(state_tx),
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
sequence: Arc::new(AtomicU64::new(0)),
capture_handle: Mutex::new(None),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = self.stop_flag.clone();
let sequence = self.sequence.clone();
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
info!("Stopping audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
fn run_capture(
pub(super) fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
frame_tx: &broadcast::Sender<AudioFrame>,
stop_flag: &AtomicBool,
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
// Non-blocking mode guarantees that stop() can always join the worker.
let pcm = PCM::new(&config.device_name, Direction::Capture, true).map_err(|error| {
AppError::AudioError(format!(
"Failed to open audio device {}: {}",
config.device_name, e
config.device_name, error
))
})?;
{
let hwp = HwParams::any(&pcm)
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
hwp.set_channels(config.channels)
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
hwp.set_format(Format::s16())
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
hwp.set_access(Access::RWInterleaved)
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
hwp.set_buffer_size_near(config.buffer_frames as Frames)
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
pcm.hw_params(&hwp)
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
}
let hw_now = pcm.hw_params_current().map_err(|e| {
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
})?;
let actual_rate = hw_now
.get_rate()
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
let actual_ch = hw_now
.get_channels()
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
if actual_rate != 48_000 {
return Err(AppError::AudioError(format!(
"Audio capture requires 48000 Hz; device is {} Hz",
actual_rate
)));
}
if actual_ch != 2 {
return Err(AppError::AudioError(format!(
"Audio capture requires 2 channels (stereo); device has {}",
actual_ch
)));
}
debug!("Audio capture: 48000 Hz, 2 ch");
configure_pcm(&pcm, config)?;
pcm.prepare()
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
.map_err(|error| AppError::AudioError(format!("Failed to prepare PCM: {error}")))?;
let _ = state.send(CaptureState::Running);
let period_frames = pcm
.hw_params_current()
.ok()
.and_then(|h| h.get_period_size().ok())
.map(|f| f as usize)
.unwrap_or(1024)
.and_then(|params| params.get_period_size().ok())
.map(|frames| frames as usize)
.unwrap_or(config.period_frames as usize)
.max(256);
let buf_frames = period_frames.saturating_mul(4).max(2048);
let bytes_per_frame = (config.channels as usize) * 2;
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
let mut buffer = vec![0u8; period_frames * config.channels as usize * 2];
let io: IO<u8> = pcm.io_bytes();
let mut consecutive_errors = 0;
while !stop_flag.load(Ordering::Relaxed) {
while !stop_flag.load(Ordering::Acquire) {
match pcm.state() {
State::XRun => {
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
let _ = pcm.prepare();
pcm.prepare().map_err(|error| {
AppError::AudioError(format!("Failed to recover audio xrun: {error}"))
})?;
consecutive_errors = 0;
continue;
}
State::Suspended => {
@@ -274,61 +63,95 @@ fn run_capture(
"suspended",
"Audio device suspended, recovering"
);
let _ = pcm.resume();
if pcm.resume().is_err() {
pcm.prepare().map_err(|error| {
AppError::AudioError(format!("Failed to resume audio capture: {error}"))
})?;
}
consecutive_errors = 0;
continue;
}
_ => {}
}
// io_bytes: USB capture often lacks mmap (io_checked requires it).
let io: IO<u8> = pcm.io_bytes();
match io.readi(&mut buffer) {
Ok(0) => thread::sleep(RETRY_DELAY),
Ok(frames_read) => {
if frames_read == 0 {
continue;
}
consecutive_errors = 0;
let byte_count = frames_read * config.channels as usize * 2;
let seq = sequence.fetch_add(1, Ordering::Relaxed);
let frame = AudioFrame::new_interleaved(
Bytes::copy_from_slice(&buffer[..byte_count]),
config.channels,
48_000,
seq,
config.sample_rate,
);
if frame_tx.receiver_count() > 0 {
if let Err(e) = frame_tx.send(frame) {
debug!("No audio receivers: {}", e);
}
let _ = frame_tx.send(frame);
}
}
Err(e) => {
let desc = e.to_string();
if is_device_lost_error(&desc) {
Err(error) if error.errno() == libc::EAGAIN => thread::sleep(RETRY_DELAY),
Err(error) if is_device_lost_errno(error.errno()) => {
return Err(AppError::AudioError(format!(
"Audio device lost while reading {}: {}",
config.device_name, error
)));
}
Err(error) if error.errno() == libc::EPIPE => {
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
pcm.prepare().map_err(|prepare_error| {
AppError::AudioError(format!(
"Failed to recover after audio overrun ({error}): {prepare_error}"
))
})?;
consecutive_errors = 0;
}
Err(error) => {
consecutive_errors += 1;
warn_throttled!(log_throttler, "read_error", "Audio read error: {}", error);
if consecutive_errors >= MAX_CONSECUTIVE_READ_ERRORS {
return Err(AppError::AudioError(format!(
"Audio device lost while reading {}: {}",
config.device_name, e
"Audio capture failed {consecutive_errors} times consecutively: {error}"
)));
} else if desc.contains("EPIPE") || desc.contains("Broken pipe") {
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
let _ = pcm.prepare();
} else {
error_throttled!(log_throttler, "read_error", "Audio read error: {}", e);
}
thread::sleep(RETRY_DELAY);
}
}
}
info!("Audio capture stopped");
debug!("ALSA capture worker stopped");
Ok(())
}
fn is_device_lost_error(desc: &str) -> bool {
desc.contains("No such device")
|| desc.contains("ENODEV")
|| desc.contains("ENXIO")
|| desc.contains("ESHUTDOWN")
fn configure_pcm(pcm: &PCM, config: &AudioConfig) -> Result<()> {
let params = HwParams::any(pcm)
.map_err(|error| AppError::AudioError(format!("Failed to get HwParams: {error}")))?;
params
.set_channels(config.channels)
.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_buffer_size_near(config.buffer_frames as Frames))
.and_then(|_| params.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest))
.and_then(|_| pcm.hw_params(&params))
.map_err(|error| AppError::AudioError(format!("Failed to configure audio PCM: {error}")))?;
let actual = pcm
.hw_params_current()
.map_err(|error| AppError::AudioError(format!("Failed to read PCM parameters: {error}")))?;
let actual_rate = actual
.get_rate()
.map_err(|error| AppError::AudioError(format!("Failed to read sample rate: {error}")))?;
let actual_channels = actual
.get_channels()
.map_err(|error| AppError::AudioError(format!("Failed to read channels: {error}")))?;
if actual_rate != config.sample_rate || actual_channels != config.channels {
return Err(AppError::AudioError(format!(
"Audio device negotiated {actual_rate} Hz/{actual_channels} ch; expected {} Hz/{} ch",
config.sample_rate, config.channels
)));
}
Ok(())
}
fn is_device_lost_errno(errno: i32) -> bool {
matches!(errno, libc::ENODEV | libc::ENXIO | libc::ESHUTDOWN)
}

View File

@@ -1,198 +1,23 @@
use bytes::Bytes;
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::{BufferSize, SampleFormat, StreamConfig};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, watch, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tracing::{debug, info};
use crate::audio::device::{find_wasapi_device, AudioDeviceInfo};
use super::{AudioConfig, AudioFrame, CaptureState};
use crate::audio::device::find_wasapi_device;
use crate::error::{AppError, Result};
use crate::error_throttled;
use crate::utils::LogThrottler;
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub frame_size: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48000,
channels: 2,
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
}
}
impl AudioConfig {
pub fn for_device(device: &AudioDeviceInfo) -> Self {
Self {
device_name: device.name.clone(),
..Default::default()
}
}
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
pub samples: u32,
pub sequence: u64,
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / bps,
data,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state: Arc::new(state_tx),
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
sequence: Arc::new(AtomicU64::new(0)),
capture_handle: Mutex::new(None),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
debug!(
"Starting WASAPI audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = self.stop_flag.clone();
let sequence = self.sequence.clone();
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(
log_throttler,
"capture_error",
"WASAPI audio capture error: {}",
e
);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
info!("Stopping WASAPI audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
fn run_capture(
pub(super) fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
frame_tx: &broadcast::Sender<AudioFrame>,
stop_flag: &AtomicBool,
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
let device = find_wasapi_device(&config.device_name)?;
@@ -272,12 +97,10 @@ fn run_capture(
if samples.is_empty() {
continue;
}
let seq = sequence.fetch_add(1, Ordering::Relaxed);
let frame = AudioFrame::new_interleaved(
Bytes::copy_from_slice(bytemuck::cast_slice(&samples)),
2,
48_000,
seq,
);
if frame_tx.receiver_count() > 0 {
if let Err(e) = frame_tx.send(frame) {

View File

@@ -1,8 +1,7 @@
//! Device selection, quality presets, streaming.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info};
use super::capture::AudioConfig;
@@ -22,23 +21,37 @@ pub(super) type AudioRecoveredCallback = Arc<dyn Fn() + Send + Sync>;
pub struct AudioController {
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
devices: Arc<RwLock<Vec<AudioDeviceInfo>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovery: recovery::AudioRecovery,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
operation: Arc<Mutex<()>>,
}
impl AudioController {
pub fn new(config: AudioControllerConfig) -> Self {
let config = Arc::new(RwLock::new(config));
let streamer = Arc::new(RwLock::new(None));
let event_bus = Arc::new(RwLock::new(None));
let monitor = Arc::new(AudioHealthMonitor::new());
let recovered_callback = Arc::new(RwLock::new(None));
let operation = Arc::new(Mutex::new(()));
let recovery = recovery::AudioRecovery::new(
config.clone(),
streamer.clone(),
event_bus.clone(),
monitor.clone(),
recovered_callback.clone(),
operation.clone(),
);
Self {
config: Arc::new(RwLock::new(config)),
streamer: Arc::new(RwLock::new(None)),
devices: Arc::new(RwLock::new(Vec::new())),
event_bus: Arc::new(RwLock::new(None)),
monitor: Arc::new(AudioHealthMonitor::new()),
recovery_in_progress: Arc::new(AtomicBool::new(false)),
recovered_callback: Arc::new(RwLock::new(None)),
config,
streamer,
event_bus,
monitor,
recovery,
recovered_callback,
operation,
}
}
@@ -55,31 +68,6 @@ impl AudioController {
bus.mark_device_info_dirty();
}
}
fn spawn_recovery_task(&self, lost_device: String, reason: String) {
recovery::spawn_recovery_task(
self.config.clone(),
self.streamer.clone(),
self.event_bus.clone(),
self.monitor.clone(),
self.recovery_in_progress.clone(),
self.recovered_callback.clone(),
lost_device,
reason,
);
}
fn spawn_stream_monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
recovery::spawn_stream_monitor(
self.config.clone(),
self.streamer.clone(),
self.event_bus.clone(),
self.monitor.clone(),
self.recovery_in_progress.clone(),
self.recovered_callback.clone(),
streamer,
device,
);
}
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
let current_device = if self.is_streaming().await {
@@ -88,26 +76,19 @@ impl AudioController {
None
};
let devices = enumerate_audio_devices_with_current(current_device.as_deref())?;
*self.devices.write().await = devices.clone();
Ok(devices)
}
pub async fn get_cached_devices(&self) -> Vec<AudioDeviceInfo> {
self.devices.read().await.clone()
enumerate_audio_devices_with_current(current_device.as_deref())
}
pub async fn select_device(&self, device: &str) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
let devices = self.list_devices().await?;
let found = devices
.iter()
.any(|d| d.name == device || d.description.contains(device));
if !found {
return Err(AppError::AudioError(format!(
"Audio device not found: {}",
device
)));
return Err(AppError::NotFound(format!("audio device {device}")));
}
{
@@ -118,14 +99,15 @@ impl AudioController {
info!("Audio device selected: {}", device);
if self.is_streaming().await {
self.stop_streaming().await?;
self.start_streaming().await?;
self.stop_streaming_inner().await?;
self.start_streaming_inner().await?;
}
Ok(())
}
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
let _operation = self.operation.lock().await;
{
let mut config = self.config.write().await;
config.quality = quality;
@@ -144,6 +126,12 @@ impl AudioController {
}
pub async fn start_streaming(&self) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
self.start_streaming_inner().await
}
async fn start_streaming_inner(&self) -> Result<()> {
{
let config = self.config.read().await;
if !config.enabled {
@@ -171,7 +159,7 @@ impl AudioController {
if let Some(error_msg) = select_error {
self.monitor.report_error(&error_msg, "start_failed").await;
self.spawn_recovery_task("auto".to_string(), error_msg.clone());
self.recovery.start("auto".to_string(), error_msg.clone());
self.mark_device_info_dirty().await;
return Err(AppError::AudioError(error_msg));
}
@@ -194,7 +182,7 @@ impl AudioController {
let error_msg = format!("Failed to start audio: {}", e);
self.monitor.report_error(&error_msg, "start_failed").await;
self.spawn_recovery_task(device_name.clone(), error_msg.clone());
self.recovery.start(device_name.clone(), error_msg.clone());
self.mark_device_info_dirty().await;
@@ -203,14 +191,13 @@ impl AudioController {
let streamer_for_monitor = streamer.clone();
*self.streamer.write().await = Some(streamer);
self.spawn_stream_monitor(streamer_for_monitor, device_name.clone());
self.recovery
.monitor(streamer_for_monitor, device_name.clone());
if self.monitor.is_error().await {
self.monitor.report_recovered().await;
}
self.recovery_in_progress.store(false, Ordering::SeqCst);
self.mark_device_info_dirty().await;
info!("Audio streaming started");
@@ -218,7 +205,12 @@ impl AudioController {
}
pub async fn stop_streaming(&self) -> Result<()> {
self.recovery_in_progress.store(false, Ordering::SeqCst);
let _operation = self.operation.lock().await;
self.stop_streaming_inner().await
}
async fn stop_streaming_inner(&self) -> Result<()> {
self.recovery.cancel();
if let Some(streamer) = self.streamer.write().await.take() {
streamer.stop().await?;
@@ -249,7 +241,7 @@ impl AudioController {
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
{
let streaming = streamer.is_running();
let subscriber_count = streamer.stats().subscriber_count;
let subscriber_count = streamer.subscriber_count();
(streaming, subscriber_count)
} else {
(false, 0)
@@ -278,13 +270,15 @@ impl AudioController {
}
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
{
let mut config = self.config.write().await;
config.enabled = enabled;
}
if !enabled && self.is_streaming().await {
self.stop_streaming().await?;
self.stop_streaming_inner().await?;
}
info!("Audio enabled: {}", enabled);
@@ -292,16 +286,18 @@ impl AudioController {
}
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
let _operation = self.operation.lock().await;
self.recovery.cancel();
let was_streaming = self.is_streaming().await;
if was_streaming {
self.stop_streaming().await?;
self.stop_streaming_inner().await?;
}
*self.config.write().await = new_config.clone();
if new_config.enabled {
self.start_streaming().await?;
self.start_streaming_inner().await?;
}
Ok(())

View File

@@ -1,3 +1,9 @@
//! Shared device description with platform-specific enumeration backends.
use serde::Serialize;
use crate::error::Result;
#[cfg(unix)]
#[path = "device_linux.rs"]
mod imp;
@@ -6,4 +12,32 @@ mod imp;
#[path = "device_windows.rs"]
mod imp;
pub use imp::*;
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
imp::enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
imp::enumerate_audio_devices_with_current(current_device)
}
pub(crate) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
imp::find_best_audio_device()
}
#[cfg(windows)]
pub(crate) use imp::find_wasapi_device;

View File

@@ -1,23 +1,10 @@
use alsa::pcm::HwParams;
use alsa::{Direction, PCM};
use serde::Serialize;
use tracing::{debug, info, warn};
use super::AudioDeviceInfo;
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
fn get_usb_bus_info(card_index: i32) -> Option<String> {
if card_index < 0 {
return None;
@@ -28,26 +15,18 @@ fn get_usb_bus_info(card_index: i32) -> Option<String> {
let link_str = link_target.to_string_lossy();
for component in link_str.split('/') {
if component.contains('-') && !component.contains(':') {
if component
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
return Some(component.to_string());
}
if component.contains('-')
&& !component.contains(':')
&& component.chars().next().is_some_and(|c| c.is_ascii_digit())
{
return Some(component.to_string());
}
}
None
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
pub(super) fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let mut devices = Vec::new();
@@ -153,8 +132,8 @@ fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
(supported_rates, supported_channels)
}
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices_with_current(None)?;
if devices.is_empty() {
return Err(AppError::AudioError(
@@ -194,7 +173,7 @@ mod tests {
#[test]
fn test_enumerate_devices() {
let result = enumerate_audio_devices();
let result = enumerate_audio_devices_with_current(None);
println!("Audio devices: {:?}", result);
assert!(result.is_ok());
}

View File

@@ -1,29 +1,12 @@
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::DeviceId;
use serde::Serialize;
use std::str::FromStr;
use tracing::{debug, info, warn};
use super::AudioDeviceInfo;
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
pub(super) fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let host = cpal::default_host();
@@ -151,7 +134,7 @@ fn device_labels(device: &cpal::Device) -> DeviceLabels {
}
}
pub(crate) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device> {
pub(super) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device> {
let host = cpal::default_host();
let trimmed = requested_device.trim();
@@ -192,8 +175,8 @@ pub(crate) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device>
)))
}
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices_with_current(None)?;
if devices.is_empty() {
return Err(AppError::AudioError(

View File

@@ -5,7 +5,6 @@ use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate};
use bytes::Bytes;
use tracing::debug;
use super::capture::AudioFrame;
use crate::error::{AppError, Result};
#[derive(Debug, Clone)]
@@ -154,11 +153,6 @@ impl OpusEncoder {
})
}
pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result<OpusFrame> {
let samples: &[i16] = bytemuck::cast_slice(&frame.data);
self.encode(samples)
}
pub fn config(&self) -> &OpusConfig {
&self.config
}

View File

@@ -1,18 +1,18 @@
//! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor.
#[cfg(any(unix, windows))]
pub mod capture;
pub mod controller;
mod capture;
mod controller;
#[cfg(any(unix, windows))]
pub mod device;
mod device;
#[cfg(any(unix, windows))]
pub mod encoder;
pub mod monitor;
pub mod recovery;
pub mod streamer;
pub mod types;
pub mod uac_streamer;
pub mod uac_websocket;
mod encoder;
mod monitor;
mod recovery;
mod streamer;
mod types;
#[cfg(unix)]
pub mod uac;
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
pub use controller::AudioController;

View File

@@ -71,14 +71,14 @@ impl AudioHealthMonitor {
pub async fn report_recovered(&self) {
let prev_status = self.status.read().await.clone();
self.suppress_display.store(false, Ordering::Relaxed);
if prev_status != AudioHealthStatus::Healthy {
let retry_count = self.retry_count.load(Ordering::Relaxed);
info!("Audio recovered after {} retries", retry_count);
self.suppress_display.store(false, Ordering::Relaxed);
self.retry_count.store(0, Ordering::Relaxed);
self.throttler.clear("audio_");
self.throttler.clear_all();
*self.last_error_code.write().await = None;
*self.status.write().await = AudioHealthStatus::Healthy;
}

View File

@@ -1,6 +1,9 @@
use std::sync::atomic::{AtomicBool, Ordering};
//! Audio device-loss monitoring and serialized recovery.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
use super::capture::AudioConfig;
@@ -11,310 +14,392 @@ use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
use super::types::AudioControllerConfig;
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
struct RecoveryControl {
/// Even values are idle; the following odd value is that recovery's token.
/// A single compare-exchange therefore owns both activity and generation.
state: AtomicU64,
}
impl RecoveryControl {
fn new() -> Self {
Self {
state: AtomicU64::new(0),
}
}
fn begin(&self) -> Option<u64> {
let idle = self.state.load(Ordering::Acquire);
if !idle.is_multiple_of(2) {
return None;
}
let token = idle.wrapping_add(1);
self.state
.compare_exchange(idle, token, Ordering::AcqRel, Ordering::Acquire)
.ok()
.map(|_| token)
}
fn is_current(&self, token: u64) -> bool {
self.state.load(Ordering::Acquire) == token
}
fn finish(&self, token: u64) {
let _ = self.state.compare_exchange(
token,
token.wrapping_add(1),
Ordering::AcqRel,
Ordering::Acquire,
);
}
fn cancel(&self) {
let token = self.state.load(Ordering::Acquire);
if !token.is_multiple_of(2) {
let _ = self.state.compare_exchange(
token,
token.wrapping_add(1),
Ordering::AcqRel,
Ordering::Acquire,
);
}
}
}
struct RecoveryLease {
control: Arc<RecoveryControl>,
generation: u64,
}
impl Drop for RecoveryLease {
fn drop(&mut self) {
self.control.finish(self.generation);
}
}
struct RecoveryInner {
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
operation: Arc<Mutex<()>>,
control: Arc<RecoveryControl>,
}
#[derive(Clone)]
pub(super) struct AudioRecovery {
inner: Arc<RecoveryInner>,
}
impl AudioRecovery {
pub(super) fn new(
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
operation: Arc<Mutex<()>>,
) -> Self {
Self {
inner: Arc::new(RecoveryInner {
config,
streamer,
event_bus,
monitor,
recovered_callback,
operation,
control: Arc::new(RecoveryControl::new()),
}),
}
}
pub(super) fn cancel(&self) {
self.inner.control.cancel();
}
pub(super) fn monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
let recovery = self.clone();
let mut state = streamer.state_watch();
tokio::spawn(async move {
loop {
let current_state = *state.borrow();
match current_state {
AudioStreamState::Error => {}
AudioStreamState::Stopped => return,
AudioStreamState::Starting | AudioStreamState::Running => {
if state.changed().await.is_err() {
return;
}
continue;
}
}
// Serialize the ownership check with user-driven start/stop
// operations. If a stop already owns the operation lock, it
// removes the streamer before this monitor may start recovery.
let _operation = recovery.inner.operation.lock().await;
let is_current = recovery
.inner
.streamer
.read()
.await
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, &streamer));
if !is_current {
return;
}
let reason = format!("Audio device lost: {device}");
recovery
.inner
.monitor
.report_error(&reason, "device_lost")
.await;
recovery.start(device, reason);
return;
}
});
}
pub(super) fn start(&self, lost_device: String, reason: String) {
let Some(generation) = self.inner.control.begin() else {
debug!("Audio recovery already in progress");
return;
};
let recovery = self.clone();
tokio::spawn(async move {
let _lease = RecoveryLease {
control: recovery.inner.control.clone(),
generation,
};
recovery.run(generation, lost_device, reason).await;
});
}
async fn run(&self, generation: u64, lost_device: String, reason: String) {
warn!("Audio recovery started for {lost_device}: {reason}");
self.publish_device_lost(&lost_device, &reason).await;
self.publish_state(
"device_lost",
Some(lost_device.clone()),
Some("audio_device_lost"),
Some(RETRY_DELAY.as_millis() as u64),
)
.await;
let mut attempt = 0u32;
while self.inner.control.is_current(generation) {
let config = self.inner.config.read().await.clone();
if !config.enabled {
return;
}
if self
.inner
.streamer
.read()
.await
.as_ref()
.is_some_and(|streamer| streamer.is_running())
{
return;
}
attempt = attempt.saturating_add(1);
self.publish_reconnecting(&lost_device, attempt).await;
self.publish_state(
"device_lost",
Some(lost_device.clone()),
Some("audio_reconnecting"),
Some(RETRY_DELAY.as_millis() as u64),
)
.await;
tokio::time::sleep(RETRY_DELAY).await;
if !self.inner.control.is_current(generation) {
return;
}
let devices = match enumerate_audio_devices() {
Ok(devices) => devices,
Err(error) => {
debug!("Audio recovery enumeration attempt {attempt} failed: {error}");
continue;
}
};
let Some(device) = select_recovery_device(&devices, &config.device) else {
debug!("No audio device found on recovery attempt {attempt}");
continue;
};
let streamer = Arc::new(AudioStreamer::with_config(AudioStreamerConfig {
capture: AudioConfig {
device_name: device.name.clone(),
..Default::default()
},
opus: config.quality.to_opus_config(),
}));
if let Err(error) = streamer.start().await {
debug!(
"Audio recovery attempt {attempt} failed with {}: {error}",
device.name
);
continue;
}
// Commit a recovered streamer under the same operation lock used by
// user-driven start/stop/config updates. Cancellation is rechecked
// after acquiring the lock so an old task cannot resurrect itself.
let _operation = self.inner.operation.lock().await;
if !self.inner.control.is_current(generation) || !self.inner.config.read().await.enabled
{
let _ = streamer.stop().await;
return;
}
self.inner.config.write().await.device = device.name.clone();
*self.inner.streamer.write().await = Some(streamer.clone());
self.inner.monitor.report_recovered().await;
self.publish_recovered(&device.name).await;
if let Some(callback) = self.inner.recovered_callback.read().await.clone() {
callback();
}
self.publish_state("streaming", Some(device.name.clone()), None, None)
.await;
info!(
"Audio recovered with {} after {} attempts",
device.name, attempt
);
self.inner.control.finish(generation);
self.monitor(streamer, device.name);
drop(_operation);
return;
}
}
async fn publish_state(
&self,
state: &str,
device: Option<String>,
reason: Option<&str>,
next_retry_ms: Option<u64>,
) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamStateChanged {
state: state.to_string(),
device,
reason: reason.map(str::to_string),
next_retry_ms,
});
bus.mark_device_info_dirty();
}
}
async fn publish_device_lost(&self, device: &str, reason: &str) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: device.to_string(),
reason: reason.to_string(),
});
}
}
async fn publish_reconnecting(&self, device: &str, attempt: u32) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamReconnecting {
device: device.to_string(),
attempt,
});
}
}
async fn publish_recovered(&self, device: &str) {
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamRecovered {
device: device.to_string(),
});
}
}
}
pub(super) fn select_recovery_device(
devices: &[AudioDeviceInfo],
preferred: &str,
) -> Option<AudioDeviceInfo> {
if let Some(device) = devices
.iter()
.find(|d| !preferred.trim().is_empty() && d.name == preferred)
{
return Some(device.clone());
}
devices
.iter()
.find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2))
.find(|device| !preferred.trim().is_empty() && device.name == preferred)
.or_else(|| {
devices
.iter()
.find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2))
devices.iter().find(|device| {
device.is_hdmi
&& device.sample_rates.contains(&48_000)
&& device.channels.contains(&2)
})
})
.or_else(|| {
devices.iter().find(|device| {
device.sample_rates.contains(&48_000) && device.channels.contains(&2)
})
})
.or_else(|| devices.first())
.cloned()
}
async fn publish_state(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
state: &str,
device: Option<String>,
reason: Option<&str>,
next_retry_ms: Option<u64>,
) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamStateChanged {
state: state.to_string(),
device,
reason: reason.map(str::to_string),
next_retry_ms,
});
bus.mark_device_info_dirty();
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn publish_device_lost(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
device: &str,
reason: &str,
) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: device.to_string(),
reason: reason.to_string(),
});
}
}
async fn publish_reconnecting(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
device: &str,
attempt: u32,
) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamReconnecting {
device: device.to_string(),
attempt,
});
}
}
async fn publish_recovered(event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>, device: &str) {
if let Some(bus) = event_bus.read().await.as_ref() {
bus.publish(SystemEvent::StreamRecovered {
device: device.to_string(),
});
}
}
fn spawn_stream_monitor_from_parts(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
streamer: Arc<AudioStreamer>,
device: String,
) {
let mut state_rx = streamer.state_watch();
tokio::spawn(async move {
loop {
if state_rx.changed().await.is_err() {
return;
}
if *state_rx.borrow() != AudioStreamState::Error {
continue;
}
{
let current = streamer_slot.read().await;
if !current
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, &streamer))
{
return;
}
}
let reason = format!("Audio device lost: {}", device);
monitor.report_error(&reason, "device_lost").await;
spawn_recovery_task_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
device,
reason,
);
return;
fn device(name: &str, compatible: bool, hdmi: bool) -> AudioDeviceInfo {
AudioDeviceInfo {
name: name.to_string(),
description: name.to_string(),
card_index: 0,
device_index: 0,
sample_rates: if compatible {
vec![48_000]
} else {
vec![44_100]
},
channels: vec![2],
is_capture: true,
is_hdmi: hdmi,
usb_bus: None,
}
});
}
fn spawn_recovery_task_from_parts(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
lost_device: String,
reason: String,
) {
if recovery_in_progress.swap(true, Ordering::SeqCst) {
debug!("Audio recovery already in progress");
return;
}
tokio::spawn(async move {
warn!("Audio recovery started for {}: {}", lost_device, reason);
publish_device_lost(&event_bus, &lost_device, &reason).await;
publish_state(
&event_bus,
"device_lost",
Some(lost_device.clone()),
Some("audio_device_lost"),
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
)
.await;
#[test]
fn stale_recovery_cannot_finish_a_new_generation() {
let control = RecoveryControl::new();
let stale = control.begin().unwrap();
control.cancel();
let current = control.begin().unwrap();
let mut attempt = 0u32;
control.finish(stale);
assert!(control.is_current(current));
}
loop {
if !recovery_in_progress.load(Ordering::SeqCst) {
debug!("Audio recovery canceled");
return;
}
#[test]
fn completed_recovery_cannot_finish_the_next_recovery() {
let control = RecoveryControl::new();
let completed = control.begin().unwrap();
control.finish(completed);
let current = control.begin().unwrap();
if streamer_slot
.read()
.await
.as_ref()
.is_some_and(|s| s.is_running())
{
recovery_in_progress.store(false, Ordering::SeqCst);
return;
}
control.finish(completed);
assert!(control.is_current(current));
}
let cfg: AudioControllerConfig = config.read().await.clone();
if !cfg.enabled {
recovery_in_progress.store(false, Ordering::SeqCst);
return;
}
attempt = attempt.saturating_add(1);
publish_reconnecting(&event_bus, &lost_device, attempt).await;
publish_state(
&event_bus,
"device_lost",
Some(lost_device.clone()),
Some("audio_reconnecting"),
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
)
.await;
tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await;
let devices = match enumerate_audio_devices() {
Ok(devices) => devices,
Err(e) => {
debug!(
"Audio recovery enumerate failed (attempt {}): {}",
attempt, e
);
continue;
}
};
let Some(device) = select_recovery_device(&devices, &cfg.device) else {
debug!("No audio devices found during recovery attempt {}", attempt);
continue;
};
let streamer_config = AudioStreamerConfig {
capture: AudioConfig {
device_name: device.name.clone(),
..Default::default()
},
opus: cfg.quality.to_opus_config(),
};
let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config));
match new_streamer.start().await {
Ok(()) => {
{
let mut cfg = config.write().await;
cfg.device = device.name.clone();
}
*streamer_slot.write().await = Some(new_streamer.clone());
monitor.report_recovered().await;
publish_recovered(&event_bus, &device.name).await;
if let Some(callback) = recovered_callback.read().await.clone() {
callback();
}
publish_state(
&event_bus,
"streaming",
Some(device.name.clone()),
None,
None,
)
.await;
recovery_in_progress.store(false, Ordering::SeqCst);
info!(
"Audio device recovered with {} after {} attempts",
device.name, attempt
);
spawn_stream_monitor_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
new_streamer,
device.name,
);
return;
}
Err(e) => {
debug!(
"Audio recovery start failed with {} (attempt {}): {}",
device.name, attempt, e
);
}
}
}
});
}
pub(super) fn spawn_stream_monitor(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
streamer: Arc<AudioStreamer>,
device: String,
) {
spawn_stream_monitor_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
streamer,
device,
);
}
pub(super) fn spawn_recovery_task(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
lost_device: String,
reason: String,
) {
spawn_recovery_task_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
lost_device,
reason,
);
#[test]
fn recovery_prefers_requested_then_compatible_hdmi() {
let devices = vec![device("fallback", true, false), device("hdmi", true, true)];
assert_eq!(
select_recovery_device(&devices, "fallback").unwrap().name,
"fallback"
);
assert_eq!(
select_recovery_device(&devices, "missing").unwrap().name,
"hdmi"
);
}
}

View File

@@ -2,15 +2,15 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
use crate::error::{AppError, Result};
use bytemuck;
use bytes::Bytes;
use std::time::Duration;
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
@@ -40,16 +40,6 @@ impl AudioStreamerConfig {
opus: OpusConfig::default(),
}
}
pub fn with_bitrate(mut self, bitrate: u32) -> Self {
self.opus.bitrate = bitrate;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct AudioStreamStats {
pub subscriber_count: usize,
}
pub struct AudioStreamer {
@@ -60,6 +50,9 @@ pub struct AudioStreamer {
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
stop_flag: Arc<AtomicBool>,
shutdown_generation: watch::Sender<u64>,
lifecycle: AsyncMutex<()>,
stream_task: AsyncMutex<Option<JoinHandle<()>>>,
}
impl AudioStreamer {
@@ -69,6 +62,7 @@ impl AudioStreamer {
pub fn with_config(config: AudioStreamerConfig) -> Self {
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
let (shutdown_generation, _) = watch::channel(0);
Self {
config: RwLock::new(config),
@@ -78,6 +72,9 @@ impl AudioStreamer {
encoder: Arc::new(AsyncMutex::new(None)),
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
stop_flag: Arc::new(AtomicBool::new(false)),
shutdown_generation,
lifecycle: AsyncMutex::new(()),
stream_task: AsyncMutex::new(None),
}
}
@@ -90,7 +87,9 @@ impl AudioStreamer {
}
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(128);
// Keep latency bounded for real-time consumers. Slow receivers lose
// new frames instead of accumulating seconds of stale audio.
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(4);
self.opus_subscribers.lock().unwrap().push(tx);
rx
}
@@ -104,22 +103,6 @@ impl AudioStreamer {
.count()
}
pub fn stats(&self) -> AudioStreamStats {
AudioStreamStats {
subscriber_count: self.subscriber_count(),
}
}
pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> {
if self.state() != AudioStreamState::Stopped {
return Err(AppError::AudioError(
"Cannot change config while streaming".to_string(),
));
}
*self.config.write().await = config;
Ok(())
}
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
self.config.write().await.opus.bitrate = bitrate;
@@ -132,10 +115,25 @@ impl AudioStreamer {
}
pub async fn start(&self) -> Result<()> {
if self.state() == AudioStreamState::Running {
let _lifecycle = self.lifecycle.lock().await;
if matches!(
self.state(),
AudioStreamState::Starting | AudioStreamState::Running
) {
return Ok(());
}
// Error and stopped states may still own completed task handles. Reap
// them before installing a new capture pipeline so restart is a clean
// lifecycle transition rather than an overwrite of old resources.
if let Some(capturer) = self.capturer.write().await.take() {
let _ = capturer.stop().await;
}
if let Some(task) = self.stream_task.lock().await.take() {
let _ = task.await;
}
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Starting);
self.stop_flag.store(false, Ordering::SeqCst);
@@ -149,13 +147,21 @@ impl AudioStreamer {
config.opus.bitrate
);
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
*self.capturer.write().await = Some(capturer.clone());
let encoder = OpusEncoder::new(config.opus.clone())?;
let encoder = match OpusEncoder::new(config.opus.clone()) {
Ok(encoder) => encoder,
Err(error) => {
let _ = self.state.send(AudioStreamState::Error);
return Err(error);
}
};
*self.encoder.lock().await = Some(encoder);
capturer.start().await?;
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
*self.capturer.write().await = Some(capturer.clone());
if let Err(error) = capturer.start().await {
self.cleanup_failed_start(&capturer).await;
return Err(error);
}
let mut capture_state = capturer.state_watch();
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
@@ -168,7 +174,7 @@ impl AudioStreamer {
"Audio capture failed to start".to_string(),
))
}
CaptureState::Stopped => {
CaptureState::Stopped | CaptureState::Starting => {
if capture_state.changed().await.is_err() {
return Err(AppError::AudioError(
"Audio capture stopped during startup".to_string(),
@@ -183,17 +189,11 @@ impl AudioStreamer {
match startup_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
self.cleanup_failed_start(&capturer).await;
return Err(e);
}
Err(_) => {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
self.cleanup_failed_start(&capturer).await;
return Err(AppError::AudioError(
"Timed out waiting for audio capture to start".to_string(),
));
@@ -205,22 +205,27 @@ impl AudioStreamer {
let opus_subscribers = self.opus_subscribers.clone();
let state = self.state.clone();
let stop_flag = self.stop_flag.clone();
let shutdown_rx = self.shutdown_generation.subscribe();
let _ = self.state.send(AudioStreamState::Running);
tokio::spawn(async move {
let task = tokio::spawn(async move {
Self::stream_task(
capturer_for_task,
encoder,
opus_subscribers,
state,
stop_flag,
shutdown_rx,
)
.await;
});
*self.stream_task.lock().await = Some(task);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
if self.state() == AudioStreamState::Stopped {
return Ok(());
}
@@ -228,10 +233,16 @@ impl AudioStreamer {
info!("Stopping audio stream");
self.stop_flag.store(true, Ordering::SeqCst);
self.shutdown_generation.send_modify(|generation| {
*generation = generation.wrapping_add(1);
});
if let Some(ref capturer) = *self.capturer.read().await {
capturer.stop().await?;
}
if let Some(task) = self.stream_task.lock().await.take() {
let _ = task.await;
}
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
@@ -242,28 +253,26 @@ impl AudioStreamer {
Ok(())
}
async fn cleanup_failed_start(&self, capturer: &AudioCapturer) {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
}
pub fn is_running(&self) -> bool {
self.state() == AudioStreamState::Running
}
async fn fanout_opus(
fn fanout_opus(
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
frame: Arc<OpusFrame>,
) {
let txs: Vec<_> = {
let g = subscribers.lock().unwrap();
if g.is_empty() {
return;
}
g.clone()
};
for tx in &txs {
let _ = tx.send(frame.clone()).await;
}
if txs.iter().any(|tx| tx.is_closed()) {
let mut g = subscribers.lock().unwrap();
g.retain(|tx| !tx.is_closed());
}
let mut subscribers = subscribers.lock().unwrap();
subscribers.retain(|subscriber| match subscriber.try_send(frame.clone()) {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
});
}
async fn stream_task(
@@ -272,9 +281,9 @@ impl AudioStreamer {
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
state: watch::Sender<AudioStreamState>,
stop_flag: Arc<AtomicBool>,
mut shutdown_rx: watch::Receiver<u64>,
) {
let mut pcm_rx = capturer.subscribe();
let _ = state.send(AudioStreamState::Running);
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
@@ -291,8 +300,19 @@ impl AudioStreamer {
break;
}
let recv_result =
tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await;
let recv_result = tokio::select! {
biased;
changed = shutdown_rx.changed() => {
if changed.is_ok() || stop_flag.load(Ordering::Relaxed) {
break;
}
continue;
}
result = tokio::time::timeout(
std::time::Duration::from_secs(2),
pcm_rx.recv(),
) => result,
};
match recv_result {
Ok(Ok(audio_frame)) => {
@@ -316,23 +336,17 @@ impl AudioStreamer {
}
while pending.len() >= OPUS_STEREO_SAMPLES {
let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice(
&pending[..OPUS_STEREO_SAMPLES],
));
pending.drain(..OPUS_STEREO_SAMPLES);
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 0);
let opus_result = {
let mut enc_guard = encoder.lock().await;
(*enc_guard)
.as_mut()
.map(|enc| enc.encode_frame(&frame_48k))
.map(|enc| enc.encode(&pending[..OPUS_STEREO_SAMPLES]))
};
pending.drain(..OPUS_STEREO_SAMPLES);
match opus_result {
Some(Ok(opus_frame)) => {
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await;
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame));
}
Some(Err(e)) => {
error!("Opus encode error: {}", e);
@@ -365,6 +379,7 @@ impl AudioStreamer {
let _ = state.send(AudioStreamState::Stopped);
} else {
opus_subscribers.lock().unwrap().clear();
let _ = capturer.stop().await;
}
info!("Audio stream task ended");
}
@@ -379,6 +394,7 @@ impl Default for AudioStreamer {
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
#[test]
fn test_streamer_config_default() {
@@ -398,4 +414,42 @@ mod tests {
let streamer = AudioStreamer::new();
assert_eq!(streamer.state(), AudioStreamState::Stopped);
}
#[test]
fn slow_subscriber_does_not_block_or_grow_unbounded() {
let streamer = AudioStreamer::new();
let mut receiver = streamer.subscribe_opus();
for sequence in 0..20 {
AudioStreamer::fanout_opus(
&streamer.opus_subscribers,
Arc::new(OpusFrame {
data: Bytes::from_static(&[1]),
duration_ms: 20,
sequence,
}),
);
}
let mut received = 0;
while receiver.try_recv().is_ok() {
received += 1;
}
assert_eq!(received, 4);
}
#[test]
fn closed_subscriber_is_pruned() {
let streamer = AudioStreamer::new();
let receiver = streamer.subscribe_opus();
drop(receiver);
AudioStreamer::fanout_opus(
&streamer.opus_subscribers,
Arc::new(OpusFrame {
data: Bytes::from_static(&[1]),
duration_ms: 20,
sequence: 0,
}),
);
assert_eq!(streamer.subscriber_count(), 0);
}
}

59
src/audio/uac/decoder.rs Normal file
View File

@@ -0,0 +1,59 @@
use audiopus::coder::Decoder;
use audiopus::{Channels, SampleRate};
use crate::error::{AppError, Result};
const CHANNELS: usize = 2;
const MAX_PACKET_BYTES: usize = 1275;
const MAX_SAMPLES_PER_CHANNEL: usize = 5760;
pub struct UacOpusDecoder {
decoder: Decoder,
buffer: Vec<i16>,
}
impl UacOpusDecoder {
pub fn new() -> Result<Self> {
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Stereo)
.map_err(|error| AppError::AudioError(format!("Opus decoder init failed: {error}")))?;
Ok(Self {
decoder,
buffer: vec![0; MAX_SAMPLES_PER_CHANNEL * CHANNELS],
})
}
pub fn decode(&mut self, packet: &[u8]) -> Result<&[i16]> {
if packet.is_empty() || packet.len() > MAX_PACKET_BYTES {
return Err(AppError::BadRequest(format!(
"invalid Opus packet length {}",
packet.len()
)));
}
let frames = self
.decoder
.decode(Some(packet), &mut self.buffer, false)
.map_err(|error| AppError::AudioError(format!("Opus decode failed: {error}")))?;
Ok(&self.buffer[..frames * CHANNELS])
}
}
#[cfg(test)]
mod tests {
use super::*;
use audiopus::coder::Encoder;
use audiopus::Application;
#[test]
fn decode_preserves_all_stereo_samples() {
let encoder =
Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Audio).unwrap();
let pcm = vec![0i16; 960 * CHANNELS];
let mut packet = vec![0u8; MAX_PACKET_BYTES];
let packet_len = encoder.encode(&pcm, &mut packet).unwrap();
let mut decoder = UacOpusDecoder::new().unwrap();
let decoded = decoder.decode(&packet[..packet_len]).unwrap();
assert_eq!(decoded.len(), pcm.len());
}
}

9
src/audio/uac/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Browser-to-USB microphone audio pipeline.
mod decoder;
mod playback;
mod protocol;
pub use decoder::UacOpusDecoder;
pub use playback::{UacPlayback, UacPlaybackConfig, UacPlaybackState, UacSession};
pub use protocol::{parse_audio_packet, UacAudioPacket};

509
src/audio/uac/playback.rs Normal file
View File

@@ -0,0 +1,509 @@
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 = 960;
const BUFFER_FRAMES: Frames = 4_800;
const START_THRESHOLD_PERIODS: Frames = 4;
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<Option<Arc<Mutex<SessionRuntime>>>>,
}
enum SessionSink {
Closed { retry_at: Option<Instant> },
Probing { pcm: PCM, stalled: bool },
Active { pcm: PCM, 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,
}
impl SessionRuntime {
fn new() -> Self {
Self {
sink: SessionSink::Closed { retry_at: None },
}
}
fn state(&self) -> UacPlaybackState {
self.sink.state()
}
fn close(&mut self) {
self.sink = SessionSink::Closed { retry_at: 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 {
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<PlaybackInner>,
}
pub struct UacSession {
playback: UacPlayback,
runtime: Arc<Mutex<SessionRuntime>>,
}
impl UacPlayback {
pub fn start(config: UacPlaybackConfig) -> Result<Self> {
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<UacSession> {
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(|pcm| {
prime_pcm_with_silence(&pcm, 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(
pcm: PCM,
stalled: bool,
config: &UacPlaybackConfig,
samples: &[i16],
) -> (SessionSink, bool) {
match sink_is_consuming(&pcm) {
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
Ok(true) => {
if let Err(error) = reset_pcm_buffer(&pcm) {
warn!("Failed to activate UAC playback; retrying later: {error}");
return retry_later();
}
info!("UAC target started consuming microphone audio");
drive_active(pcm, Instant::now(), config, samples)
}
Err(error) => {
warn!("Failed to probe UAC playback; retrying later: {error}");
retry_later()
}
}
}
fn drive_active(
pcm: PCM,
last_progress: Instant,
config: &UacPlaybackConfig,
samples: &[i16],
) -> (SessionSink, bool) {
match write_pcm_nonblocking(&pcm, samples, config.channels as usize) {
Ok(WriteOutcome::Progress) => (
SessionSink::Active {
pcm,
last_progress: Instant::now(),
},
true,
),
Ok(WriteOutcome::Recovered) => (
SessionSink::Active {
pcm,
last_progress: Instant::now(),
},
false,
),
Ok(WriteOutcome::Blocked) if last_progress.elapsed() < SINK_STALL_TIMEOUT => {
(SessionSink::Active { pcm, last_progress }, false)
}
Ok(WriteOutcome::Blocked) => {
if let Err(error) = reset_pcm_buffer(&pcm)
.and_then(|_| prime_pcm_with_silence(&pcm, 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");
(SessionSink::Probing { pcm, stalled: true }, false)
}
Err(error) => {
warn!("UAC playback write failed; retrying 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<PCM> {
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(&params))
.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}"))
})?;
let start_threshold =
(period_frames as Frames * START_THRESHOLD_PERIODS).min(buffer_frames as Frames);
params
.set_start_threshold(start_threshold)
.and_then(|_| params.set_avail_min(period_frames as Frames))
.and_then(|_| pcm.sw_params(&params))
.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(pcm)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteOutcome {
Progress,
Blocked,
Recovered,
}
fn write_pcm_nonblocking(pcm: &PCM, samples: &[i16], channels: usize) -> Result<WriteOutcome> {
let total_frames = samples.len() / channels;
match pcm.avail() {
Ok(available) if available < total_frames as Frames => return Ok(WriteOutcome::Blocked),
Ok(_) => {}
Err(error) => {
recover_pcm(pcm, error)?;
return Ok(WriteOutcome::Recovered);
}
}
let io = pcm
.io_i16()
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
match io.writei(samples) {
Ok(0) => Ok(WriteOutcome::Blocked),
Ok(_) => Ok(WriteOutcome::Progress),
Err(error) if error.errno() == libc::EAGAIN => Ok(WriteOutcome::Blocked),
Err(error) => {
recover_pcm(pcm, error)?;
Ok(WriteOutcome::Recovered)
}
}
}
/// Once a full playback buffer gains at least one period of free space, the
/// USB host has enabled the UAC streaming interface and is consuming samples.
fn sink_is_consuming(pcm: &PCM) -> Result<bool> {
if pcm.state() == State::XRun {
return Ok(true);
}
match pcm.avail() {
Ok(available) => Ok(available >= PERIOD_FRAMES),
Err(error) if error.errno() == libc::EPIPE => Ok(true),
Err(error) => Err(AppError::AudioError(format!(
"Failed to query UAC playback availability: {error}"
))),
}
}
fn recover_pcm(pcm: &PCM, error: alsa::Error) -> Result<()> {
let errno = error.errno();
pcm.try_recover(error, true).map_err(|recover_error| {
AppError::AudioError(format!("Failed to recover UAC playback: {recover_error}"))
})?;
if matches!(errno, libc::EPIPE | libc::ESTRPIPE) {
warn!("Recovered UAC playback after ALSA error {errno}");
}
Ok(())
}
fn reset_pcm_buffer(pcm: &PCM) -> Result<()> {
pcm.drop()
.and_then(|_| pcm.prepare())
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))
}
/// Prime the non-blocking ALSA buffer with silence. Subsequent WebSocket
/// frames inspect buffer progress to detect when the USB host starts reading.
fn prime_pcm_with_silence(pcm: &PCM, channels: usize) -> Result<()> {
let silence = vec![0i16; BUFFER_FRAMES as usize * channels];
let io = pcm
.io_i16()
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
let mut frame_offset = 0usize;
while frame_offset < BUFFER_FRAMES as usize {
match io.writei(&silence[frame_offset * channels..]) {
Ok(0) => break,
Ok(written) => frame_offset += written,
Err(error) if error.errno() == libc::EAGAIN => break,
Err(error) => {
return Err(AppError::AudioError(format!(
"Failed to prime UAC PCM with silence: {error}"
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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
);
}
}

98
src/audio/uac/protocol.rs Normal file
View File

@@ -0,0 +1,98 @@
use crate::error::{AppError, Result};
const HEADER_SIZE: usize = 15;
const OPUS_MESSAGE: u8 = 0x03;
const PCM_MESSAGE: u8 = 0x04;
const CHANNELS: usize = 2;
const MAX_PCM_SAMPLES: usize = 5760 * CHANNELS;
#[derive(Debug, PartialEq, Eq)]
pub enum UacAudioPacket<'a> {
Opus(&'a [u8]),
Pcm(&'a [u8]),
}
impl UacAudioPacket<'_> {
pub fn pcm_samples(&self) -> Result<Vec<i16>> {
let Self::Pcm(bytes) = self else {
return Err(AppError::BadRequest("packet is not raw PCM".to_string()));
};
if bytes.is_empty() || bytes.len() % (CHANNELS * 2) != 0 {
return Err(AppError::BadRequest(format!(
"invalid stereo PCM byte length {}",
bytes.len()
)));
}
if bytes.len() / 2 > MAX_PCM_SAMPLES {
return Err(AppError::BadRequest("PCM frame exceeds 120 ms".to_string()));
}
Ok(bytes
.chunks_exact(2)
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
.collect())
}
}
pub fn parse_audio_packet(data: &[u8]) -> Result<UacAudioPacket<'_>> {
if data.len() < HEADER_SIZE {
return Err(AppError::BadRequest(
"UAC frame is shorter than its header".to_string(),
));
}
let payload_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
let expected_len = HEADER_SIZE
.checked_add(payload_len)
.ok_or_else(|| AppError::BadRequest("UAC payload length overflow".to_string()))?;
if data.len() != expected_len {
return Err(AppError::BadRequest(
"UAC payload length does not match its header".to_string(),
));
}
let payload = &data[HEADER_SIZE..];
match data[0] {
OPUS_MESSAGE => Ok(UacAudioPacket::Opus(payload)),
PCM_MESSAGE => Ok(UacAudioPacket::Pcm(payload)),
message_type => Err(AppError::BadRequest(format!(
"unsupported UAC message type 0x{message_type:02x}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn message(message_type: u8, payload: &[u8]) -> Vec<u8> {
let mut data = vec![0; HEADER_SIZE + payload.len()];
data[0] = message_type;
data[11..15].copy_from_slice(&(payload.len() as u32).to_le_bytes());
data[HEADER_SIZE..].copy_from_slice(payload);
data
}
#[test]
fn requires_exact_payload_length() {
let valid = message(OPUS_MESSAGE, &[1, 2, 3]);
assert_eq!(
parse_audio_packet(&valid).unwrap(),
UacAudioPacket::Opus(&[1, 2, 3])
);
let mut trailing = valid.clone();
trailing.push(4);
assert!(parse_audio_packet(&trailing).is_err());
assert!(parse_audio_packet(&valid[..valid.len() - 1]).is_err());
}
#[test]
fn converts_little_endian_stereo_pcm() {
let data = message(PCM_MESSAGE, &[1, 0, 255, 255]);
assert_eq!(
parse_audio_packet(&data).unwrap().pcm_samples().unwrap(),
vec![1, -1]
);
}
}

View File

@@ -1,210 +0,0 @@
use std::io::Write;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{info, warn};
use crate::error::{AppError, Result};
/// Kill aplay after this much idle time (no incoming audio frames).
const IDLE_CLOSE_TIMEOUT_MS: u64 = 2000;
/// Configuration for the UAC playback stream.
#[derive(Debug, Clone)]
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: 48000,
channels: 2,
}
}
}
#[derive(Debug, Clone)]
pub struct UacPcmFrame {
pub data: Vec<u8>,
pub duration_ms: u32,
}
/// Writes PCM to the UAC gadget via `aplay` subprocess — same
/// mechanism as the successful manual test: `ffmpeg | aplay hw:0,0`.
#[derive(Clone)]
pub struct UacPlaybackWriter {
pcm_sender: mpsc::Sender<UacPcmFrame>,
stop_tx: watch::Sender<bool>,
}
impl UacPlaybackWriter {
pub fn start(config: UacPlaybackConfig) -> Result<Self> {
let (pcm_sender, pcm_receiver) = mpsc::channel::<UacPcmFrame>(64);
let (stop_tx, stop_rx) = watch::channel(false);
let device = config.device_name;
let rate = config.sample_rate;
let ch = config.channels;
let thread_device = device.clone();
std::thread::Builder::new()
.name("uac-aplay".into())
.spawn(move || {
Self::playback_loop(&thread_device, rate, ch, pcm_receiver, stop_rx);
info!("UAC aplay thread stopped");
})
.map_err(|e| AppError::Internal(format!("spawn: {e}")))?;
info!("UAC aplay writer started on {device}");
Ok(Self { pcm_sender, stop_tx })
}
pub async fn write(&self, frame: UacPcmFrame) -> Result<()> {
self.pcm_sender.send(frame).await
.map_err(|_| AppError::Internal("UAC channel closed".into()))
}
pub fn stop(&self) {
let _ = self.stop_tx.send(true);
}
// ── internals ──────────────────────────────────────────
fn spawn_aplay(device: &str, rate: u32, ch: u16) -> Option<(Child, Box<dyn Write + Send>)> {
let mut cmd = Command::new("aplay");
cmd.arg("-D").arg(device)
.arg("-f").arg("S16_LE")
.arg("-r").arg(rate.to_string())
.arg("-c").arg(ch.to_string())
.arg("-") // stdin
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit()); // → journalctl
match cmd.spawn() {
Ok(mut child) => {
let stdin = child.stdin.take()?;
info!("aplay spawned pid={}", child.id());
Some((child, Box::new(stdin)))
}
Err(e) => {
warn!("aplay spawn failed: {e}");
None
}
}
}
fn kill_aplay(mut child: Child, stdin: Box<dyn Write + Send>) {
drop(stdin); // close pipe → EOF for aplay
let _ = child.wait();
}
fn playback_loop(
device: &str,
rate: u32,
ch: u16,
mut receiver: mpsc::Receiver<UacPcmFrame>,
mut stop_rx: watch::Receiver<bool>,
) {
let idle_timeout = Duration::from_millis(IDLE_CLOSE_TIMEOUT_MS);
let mut aplay: Option<(Child, Box<dyn Write + Send>)> = None;
let mut last_write = std::time::Instant::now();
let mut frame_count: u64 = 0;
let mut byte_count: u64 = 0;
loop {
// ── wait for frame ──────────────────────────
let need_timeout = aplay.is_some()
&& last_write.elapsed() >= idle_timeout;
let deadline = if need_timeout || aplay.is_none() {
Some(std::time::Instant::now() + Duration::from_millis(200))
} else {
None
};
let frame = loop {
if *stop_rx.borrow() { break None; }
match receiver.try_recv() {
Ok(f) => break Some(f),
Err(mpsc::error::TryRecvError::Disconnected) => break None,
Err(mpsc::error::TryRecvError::Empty) => {}
}
if let Some(dl) = deadline {
if std::time::Instant::now() >= dl {
break None;
}
}
std::thread::sleep(Duration::from_millis(20));
};
if *stop_rx.borrow() {
break;
}
match frame {
Some(f) => {
last_write = std::time::Instant::now();
// Ensure aplay is alive
if aplay.is_none() {
aplay = Self::spawn_aplay(device, rate, ch);
}
if let Some((child, stdin)) = aplay.as_mut() {
// Check child health
match child.try_wait() {
Ok(Some(status)) => {
warn!("aplay died: {status}");
aplay = Self::spawn_aplay(device, rate, ch);
if aplay.is_none() { continue; }
}
Ok(None) => {} // alive
Err(e) => {
warn!("aplay wait error: {e}");
aplay = None;
continue;
}
}
}
if let Some((child, stdin)) = aplay.as_mut() {
match stdin.write_all(&f.data) {
Ok(()) => {
let _ = stdin.flush();
frame_count += 1;
byte_count += f.data.len() as u64;
}
Err(e) => {
warn!("aplay write error: {e}");
// aplay died — reap and restart
if let Some((c, s)) = aplay.take() {
Self::kill_aplay(c, s);
}
}
}
}
}
None => {
// Timeout — kill aplay
if let Some((c, s)) = aplay.take() {
Self::kill_aplay(c, s);
}
if *stop_rx.borrow() {
break;
}
}
}
}
// Cleanup
if let Some((c, s)) = aplay.take() {
Self::kill_aplay(c, s);
}
}
}

View File

@@ -1,133 +0,0 @@
use audiopus::coder::Decoder as OpusDecoder;
use audiopus::{Channels, SampleRate};
use axum::extract::ws::{Message, WebSocket};
use tracing::{debug, error, info, warn};
use super::uac_streamer::{UacPcmFrame, UacPlaybackWriter};
use std::sync::Arc;
/// Binary protocol header for UAC audio input.
///
/// 0x03 — message type (reverse audio / microphone passthrough)
/// timestamp — u32 LE (milliseconds, for future sync)
/// duration — u16 LE (frame duration in ms, typically 20)
/// sequence — u32 LE (frame counter, for loss detection)
/// data_len — u32 LE (Opus payload length in bytes)
const UAC_AUDIO_HEADER_SIZE: usize = 15;
const UAC_AUDIO_MSG_TYPE: u8 = 0x03;
/// Accept incoming Opus audio frames over WebSocket and route them
/// to the UAC playback writer.
pub async fn handle_uac_audio_ws(
mut ws: WebSocket,
playback: Arc<UacPlaybackWriter>,
) {
// Create an Opus decoder: 48kHz stereo → PCM S16LE.
let mut decoder = match OpusDecoder::new(SampleRate::Hz48000, Channels::Stereo) {
Ok(d) => d,
Err(e) => {
error!("Failed to create Opus decoder: {}", e);
let _ = ws.send(Message::Close(None)).await;
return;
}
};
info!("UAC audio WebSocket connected (mic passthrough)");
while let Some(msg) = ws.recv().await {
let msg = match msg {
Ok(m) => m,
Err(e) => {
warn!("UAC WebSocket error: {}", e);
break;
}
};
match msg {
Message::Binary(data) => {
if data.len() < UAC_AUDIO_HEADER_SIZE {
warn!(
"UAC audio frame too short: {} bytes (min {})",
data.len(),
UAC_AUDIO_HEADER_SIZE
);
continue;
}
let msg_type = data[0];
if msg_type == 0x04 {
// Raw PCM passthrough — no Opus decode needed.
// Useful for testing and for clients that encode locally.
let duration = u16::from_le_bytes([data[5], data[6]]);
let data_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
if data.len() < UAC_AUDIO_HEADER_SIZE + data_len {
warn!("UAC PCM frame truncated");
continue;
}
let pcm_bytes = &data[UAC_AUDIO_HEADER_SIZE..UAC_AUDIO_HEADER_SIZE + data_len];
if let Err(e) = playback
.write(super::uac_streamer::UacPcmFrame {
data: pcm_bytes.to_vec(),
duration_ms: duration as u32,
})
.await
{
error!("Failed to send UAC PCM frame: {}", e);
break;
}
continue;
}
if msg_type != UAC_AUDIO_MSG_TYPE {
warn!("UAC unknown msg type: 0x{msg_type:02x}");
continue;
}
let duration = u16::from_le_bytes([data[5], data[6]]);
let data_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
if data.len() < UAC_AUDIO_HEADER_SIZE + data_len {
warn!("UAC audio frame truncated");
continue;
}
let opus_payload = &data[UAC_AUDIO_HEADER_SIZE..UAC_AUDIO_HEADER_SIZE + data_len];
let frame_samples = (48000u32 * duration as u32 / 1000) as usize * 2; // 2 channels
let mut pcm_i16 = vec![0i16; frame_samples];
match decoder.decode(Some(opus_payload), &mut pcm_i16, false) {
Ok(decoded) => {
// Convert i16 → bytes (S16LE interleaved)
let pcm_bytes: Vec<u8> = pcm_i16[..decoded]
.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
if let Err(e) = playback
.write(UacPcmFrame {
data: pcm_bytes,
duration_ms: duration as u32,
})
.await
{
error!("Failed to send UAC PCM frame: {}", e);
break;
}
}
Err(e) => {
warn!("Opus decode error: {}", e);
}
}
}
Message::Ping(_) | Message::Pong(_) => {}
Message::Close(_) => {
debug!("UAC audio WebSocket closing");
break;
}
Message::Text(_) => {
// Ignore text messages
}
}
}
info!("UAC audio WebSocket disconnected");
}

View File

@@ -10,6 +10,7 @@ mod computer_use;
mod hid;
mod otg_network;
mod stream;
mod uac;
mod watchdog;
mod web;
@@ -19,6 +20,7 @@ pub use computer_use::*;
pub use hid::*;
pub use otg_network::*;
pub use stream::*;
pub use uac::*;
pub use watchdog::*;
pub use web::*;
@@ -44,7 +46,7 @@ pub struct AppConfig {
pub rtsp: RtspConfig,
pub redfish: RedfishConfig,
pub watchdog: WatchdogConfig,
pub uac: crate::otg::service::UacConfig,
pub uac: UacConfig,
}
impl AppConfig {

78
src/config/schema/uac.rs Normal file
View File

@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::error::{AppError, Result};
/// Configuration for the USB Audio Class microphone gadget.
#[typeshare]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UacConfig {
pub enabled: bool,
pub sample_rate: u32,
pub channels: u8,
}
impl Default for UacConfig {
fn default() -> Self {
Self {
enabled: false,
sample_rate: 48_000,
channels: 2,
}
}
}
impl UacConfig {
pub fn validate(&self) -> Result<()> {
// Older configurations stored zero-valued placeholders while UAC was
// disabled. Accept them until the feature is enabled and normalized.
if !self.enabled {
return Ok(());
}
if self.sample_rate != 48_000 {
return Err(AppError::BadRequest(format!(
"unsupported UAC sample rate {} (expected 48000)",
self.sample_rate
)));
}
if self.channels != 2 {
return Err(AppError::BadRequest(format!(
"unsupported UAC channel count {} (expected 2)",
self.channels
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_audio_transport() {
let config = UacConfig::default();
assert_eq!(config.sample_rate, 48_000);
assert_eq!(config.channels, 2);
assert!(config.validate().is_ok());
}
#[test]
fn rejects_formats_the_transport_cannot_convert() {
assert!(UacConfig {
enabled: true,
sample_rate: 44_100,
..Default::default()
}
.validate()
.is_err());
assert!(UacConfig {
enabled: true,
channels: 1,
..Default::default()
}
.validate()
.is_err());
}
}

View File

@@ -579,20 +579,23 @@ async fn main() -> anyhow::Result<()> {
data_dir.clone(),
);
// Initialize UAC playback writer if UAC is enabled
if config.uac.enabled {
let uac_cfg = one_kvm::audio::uac_streamer::UacPlaybackConfig {
sample_rate: config.uac.sample_rate,
channels: config.uac.channels as u16,
..Default::default()
};
match one_kvm::audio::uac_streamer::UacPlaybackWriter::start(uac_cfg) {
Ok(writer) => {
*state.uac_playback.write().await = Some(writer);
tracing::info!("UAC playback writer started");
}
Err(e) => {
tracing::warn!("Failed to start UAC playback writer: {}", e);
#[cfg(unix)]
{
// Initialize UAC playback writer if UAC is enabled.
if config.uac.enabled {
let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig {
sample_rate: config.uac.sample_rate,
channels: config.uac.channels as u16,
..Default::default()
};
match one_kvm::audio::uac::UacPlayback::start(uac_cfg) {
Ok(writer) => {
*state.uac_playback.write().await = Some(writer);
tracing::info!("UAC playback writer started");
}
Err(e) => {
tracing::warn!("Failed to start UAC playback writer: {}", e);
}
}
}
}

View File

@@ -28,7 +28,7 @@ pub use msd::{MsdFunction, MsdLunConfig};
#[cfg(unix)]
pub use network::NetworkFunction;
#[cfg(unix)]
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService, UacConfig};
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
#[cfg(unix)]
pub use uac::UacFunction;

View File

@@ -9,39 +9,10 @@ use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::MsdFunction;
use crate::config::{
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
UacConfig,
};
use crate::error::{AppError, Result};
/// Configuration for the USB Audio Class (UAC) gadget function.
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct UacConfig {
/// Enable the virtual USB microphone.
pub enabled: bool,
/// Sample rate in Hz (e.g. 48000).
pub sample_rate: u32,
/// Number of channels (1=mono, 2=stereo).
pub channels: u8,
}
impl UacConfig {
pub fn validate(&self) -> Result<()> {
if self.sample_rate != 0 && (self.sample_rate < 8000 || self.sample_rate > 384000) {
return Err(AppError::BadRequest(format!(
"UAC sample rate {} out of range (8000-384000)",
self.sample_rate
)));
}
if self.channels != 0 && self.channels > 8 {
return Err(AppError::BadRequest(format!(
"UAC channel count {} out of range (1-8)",
self.channels
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct HidDevicePaths {
pub keyboard: Option<PathBuf>,
@@ -92,7 +63,7 @@ pub(crate) struct OtgDesiredState {
pub msd_enabled: bool,
pub msd_lun_capacity: u8,
pub network: OtgNetworkConfig,
pub uac_enabled: bool,
pub uac: UacConfig,
}
impl Default for OtgDesiredState {
@@ -105,7 +76,7 @@ impl Default for OtgDesiredState {
msd_enabled: false,
msd_lun_capacity: 1,
network: OtgNetworkConfig::default(),
uac_enabled: false,
uac: UacConfig::default(),
}
}
}
@@ -127,8 +98,7 @@ impl OtgDesiredState {
};
hid.validate_otg_functions()?;
let needs_udc =
hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled;
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled;
let udc = if needs_udc {
hid.otg_udc
.as_ref()
@@ -146,7 +116,11 @@ impl OtgDesiredState {
msd_enabled: msd.enabled,
msd_lun_capacity: 1,
network: network.clone(),
uac_enabled: uac.enabled,
uac: if uac.enabled {
uac.clone()
} else {
UacConfig::default()
},
})
}
@@ -169,7 +143,7 @@ struct OtgServiceState {
pub msd_enabled: bool,
pub msd_lun_capacity: u8,
pub network: OtgNetworkConfig,
pub uac_enabled: bool,
pub uac: UacConfig,
pub configured_udc: Option<String>,
pub hid_paths: Option<HidDevicePaths>,
pub hid_functions: Option<OtgHidFunctions>,
@@ -187,7 +161,7 @@ impl Default for OtgServiceState {
msd_enabled: false,
msd_lun_capacity: 1,
network: OtgNetworkConfig::default(),
uac_enabled: false,
uac: UacConfig::default(),
configured_udc: None,
hid_paths: None,
hid_functions: None,
@@ -349,7 +323,7 @@ impl OtgService {
desired.hid_enabled(),
desired.msd_enabled,
desired.network_enabled(),
desired.uac_enabled,
desired.uac.enabled,
desired.udc
);
@@ -361,7 +335,7 @@ impl OtgService {
&& state.msd_enabled == desired.msd_enabled
&& state.msd_lun_capacity == desired.msd_lun_capacity
&& state.network == desired.network
&& state.uac_enabled == desired.uac_enabled
&& state.uac == desired.uac
&& state.configured_udc == desired.udc
&& state.hid_functions == desired.hid_functions
&& state.keyboard_leds_enabled == desired.keyboard_leds
@@ -401,7 +375,7 @@ impl OtgService {
state.msd_enabled = false;
state.msd_lun_capacity = 1;
state.network = OtgNetworkConfig::default();
state.uac_enabled = false;
state.uac = UacConfig::default();
state.configured_udc = None;
state.hid_paths = None;
state.hid_functions = None;
@@ -410,7 +384,11 @@ impl OtgService {
state.error = None;
}
if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() {
if !desired.hid_enabled()
&& !desired.msd_enabled
&& !desired.network_enabled()
&& !desired.uac.enabled
{
info!("OTG desired state is empty, gadget removed");
return Ok(());
}
@@ -440,12 +418,12 @@ impl OtgService {
// lower hardware endpoint number. DWC3 seems to have
// trouble with isochronous transfers on higher-numbered
// endpoints when they follow interrupt endpoints.
let _uac_func = if desired.uac_enabled {
let sample_rate: u32 = 48000;
let channels: u8 = 2;
Some(manager.add_uac(sample_rate, channels).map_err(|e| {
AppError::Internal(format!("Failed to add UAC function: {e}"))
})?)
let _uac_func = if desired.uac.enabled {
Some(
manager
.add_uac(desired.uac.sample_rate, desired.uac.channels)
.map_err(|e| AppError::Internal(format!("Failed to add UAC function: {e}")))?,
)
} else {
None
};
@@ -594,7 +572,7 @@ impl OtgService {
state.msd_enabled = desired.msd_enabled;
state.msd_lun_capacity = desired.msd_lun_capacity;
state.network = desired.network.clone();
state.uac_enabled = desired.uac_enabled;
state.uac = desired.uac.clone();
state.configured_udc = Some(udc);
state.hid_paths = hid_paths;
state.hid_functions = desired.hid_functions;
@@ -732,7 +710,8 @@ mod tests {
..OtgNetworkConfig::default()
};
let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap();
let desired =
OtgDesiredState::from_config(&hid, &msd, &network, &UacConfig::default()).unwrap();
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));

View File

@@ -6,7 +6,7 @@ use super::configfs::{create_dir, create_symlink, remove_dir, write_file};
use super::function::GadgetFunction;
use crate::error::{AppError, Result};
/// USB Audio Class 2.0 (UAC1) gadget function.
/// USB Audio Class 1.0 gadget function.
///
/// Creates a virtual USB microphone that the USB host sees as a standard
/// USB audio input device. Audio written to the PCM playback device on the
@@ -60,13 +60,18 @@ impl GadgetFunction for UacFunction {
let chmask: u32 = (1u32 << self.channels) - 1;
write_file(&func_path.join("p_chmask"), &chmask.to_string())?;
write_file(&func_path.join("p_srate"), &self.sample_rate.to_string())?;
write_file(&func_path.join("p_ssize"), "2")?; // 16-bit S16LE
// 16-bit S16LE.
write_file(&func_path.join("p_ssize"), "2")?;
// One decibel per step. The kernel default is 1/256 dB, which creates
// 25,600 control values and triggers a UAC volume-range warning.
write_file(&func_path.join("p_volume_res"), "256")?;
// UAC1 does not need p_hs_bint — Windows has native built-in
// UAC1 drivers and handles isochronous streaming automatically.
// Only enable playback direction (gadget → host = mic).
// Disabling capture saves one isochronous endpoint.
write_file(&func_path.join("c_chmask"), "0")?;
write_file(&func_path.join("c_volume_present"), "0")?;
// req_number=4: explicitly allocate 4 USB requests for the
// isochronous endpoint. Default (0 = auto) may not be enough

View File

@@ -77,8 +77,8 @@ pub struct AppState {
pub msd: Arc<RwLock<Option<MsdController>>>,
pub atx: Arc<RwLock<Option<AtxController>>>,
pub audio: Arc<AudioController>,
pub uac_playback: Arc<RwLock<Option<crate::audio::uac_streamer::UacPlaybackWriter>>>,
pub uac_config: Arc<RwLock<crate::otg::service::UacConfig>>,
#[cfg(unix)]
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
@@ -148,8 +148,8 @@ impl AppState {
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
config_apply_locks: ConfigApplyLocks::new(),
data_dir,
#[cfg(unix)]
uac_playback: Arc::new(RwLock::new(None)),
uac_config: Arc::new(RwLock::new(crate::otg::service::UacConfig::default())),
})
}

View File

@@ -76,7 +76,7 @@ async fn reconcile_otg_config(
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &crate::otg::service::UacConfig,
uac: &UacConfig,
) -> Result<()> {
#[cfg(not(unix))]
{
@@ -195,6 +195,7 @@ pub async fn apply_hid_config(
new_config: &HidConfig,
msd_config: &MsdConfig,
network_config: &OtgNetworkConfig,
uac_config: &UacConfig,
options: ConfigApplyOptions,
) -> Result<()> {
new_config.validate_otg_functions()?;
@@ -237,7 +238,7 @@ pub async fn apply_hid_config(
}
if otg_config_changed {
reconcile_otg_config(state, new_config, msd_config, network_config, &state.config.get().uac).await?;
reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?;
}
if !transitioning_away_from_otg {
@@ -263,6 +264,7 @@ pub async fn apply_msd_config(
new_config: &MsdConfig,
hid_config: &HidConfig,
network_config: &OtgNetworkConfig,
uac_config: &UacConfig,
options: ConfigApplyOptions,
) -> Result<()> {
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
@@ -305,7 +307,7 @@ pub async fn apply_msd_config(
if new_msd_enabled {
tracing::info!("(Re)initializing MSD...");
reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?;
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
@@ -340,7 +342,7 @@ pub async fn apply_msd_config(
*msd_guard = None;
tracing::info!("MSD shutdown complete");
reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?;
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
}
if hid_config.backend == HidBackend::Otg
@@ -367,10 +369,29 @@ pub async fn apply_usb_config(
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
let hid_unchanged = old_config.hid == new_config.hid;
let otg_gadget_rebuilt =
old_config.msd != new_config.msd
|| old_config.otg_network != new_config.otg_network
|| old_config.uac != new_config.uac;
let otg_gadget_rebuilt = old_config.msd != new_config.msd
|| old_config.otg_network != new_config.otg_network
|| old_config.uac != new_config.uac
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|| old_config.hid.backend != new_config.hid.backend
|| old_config.hid.constrained_otg_functions()
!= new_config.hid.constrained_otg_functions()
|| old_config.hid.effective_otg_keyboard_leds()
!= new_config.hid.effective_otg_keyboard_leds();
let restart_uac_playback =
old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt);
// A bound ALSA handle refers to the old configfs function. Stop it
// before any gadget teardown so the worker cannot write through a
// disappearing PCM node. It is restarted only after every reconcile.
if restart_uac_playback {
let playback = state.uac_playback.write().await.take();
if let Some(playback) = playback {
playback.stop();
tracing::info!("UAC playback writer stopped before OTG reconcile");
}
}
if transitioning_away_from_otg {
apply_hid_config(
@@ -379,6 +400,7 @@ pub async fn apply_usb_config(
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await?;
@@ -397,6 +419,7 @@ pub async fn apply_usb_config(
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await?;
@@ -408,37 +431,9 @@ pub async fn apply_usb_config(
if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg {
tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices");
let hid_backend = hid_backend_type(&new_config.hid);
state
.hid
.reload(hid_backend)
.await
.map_err(|e| AppError::Config(format!("HID reload after gadget rebuild failed: {}", e)))?;
}
// UAC playback writer lifecycle
if old_config.uac.enabled != new_config.uac.enabled {
let mut guard = state.uac_playback.write().await;
if new_config.uac.enabled {
let config = crate::audio::uac_streamer::UacPlaybackConfig {
sample_rate: new_config.uac.sample_rate,
channels: new_config.uac.channels as u16,
..Default::default()
};
match crate::audio::uac_streamer::UacPlaybackWriter::start(config) {
Ok(writer) => {
tracing::info!("UAC playback writer started");
*guard = Some(writer);
}
Err(e) => {
tracing::warn!("Failed to start UAC playback writer: {}", e);
}
}
} else {
if let Some(writer) = guard.take() {
writer.stop();
tracing::info!("UAC playback writer stopped");
}
}
state.hid.reload(hid_backend).await.map_err(|e| {
AppError::Config(format!("HID reload after gadget rebuild failed: {}", e))
})?;
}
apply_msd_config(
@@ -447,9 +442,29 @@ pub async fn apply_usb_config(
&new_config.msd,
&new_config.hid,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await
.await?;
// apply_msd_config may perform a second gadget reconcile. Resolve the
// new ALSA card only after that final rebuild, then publish the worker.
if restart_uac_playback && new_config.uac.enabled {
let config = crate::audio::uac::UacPlaybackConfig {
sample_rate: new_config.uac.sample_rate,
channels: new_config.uac.channels as u16,
..Default::default()
};
let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| {
AppError::Config(format!("Failed to start UAC playback: {error}"))
})?;
*state.uac_playback.write().await = Some(writer);
tracing::info!("UAC playback writer started after OTG reconcile");
} else if restart_uac_playback {
tracing::info!("UAC playback remains disabled");
}
Ok(())
}
#[cfg(not(unix))]
@@ -460,6 +475,7 @@ pub async fn apply_usb_config(
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
ConfigApplyOptions::default(),
)
.await

View File

@@ -42,9 +42,9 @@ pub use rustdesk::{
regenerate_device_password, start_rustdesk_service, stop_rustdesk_service,
update_rustdesk_config,
};
pub use stream::{get_stream_config, update_stream_config};
#[cfg(unix)]
pub use uac::{get_uac_config, update_uac_config};
pub use stream::{get_stream_config, update_stream_config};
pub use video::{get_video_config, update_video_config};
pub use vnc::{
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,

View File

@@ -2,11 +2,11 @@ use std::sync::Arc;
use axum::{extract::State, Json};
use crate::config::UacConfig;
use crate::error::Result;
use crate::otg::service::UacConfig;
use crate::state::AppState;
use super::apply::try_apply_lock;
use super::usb_update::update_usb_config;
pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfig> {
Json(state.config.get().uac.clone())
@@ -14,23 +14,13 @@ pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfi
pub async fn update_uac_config(
State(state): State<Arc<AppState>>,
Json(req): Json<UacConfig>,
Json(request): Json<UacConfig>,
) -> Result<Json<UacConfig>> {
req.validate()?;
let _guard = try_apply_lock(&state.config_apply_locks.otg, "uac")?;
let old_config = (*state.config.get()).clone();
let mut new_config = old_config.clone();
new_config.uac = req;
state
.config
.update(|config| {
config.uac = new_config.uac.clone();
})
.await?;
super::apply::apply_usb_config(&state, &old_config, &new_config).await?;
Ok(Json(state.config.get().uac.clone()))
request.validate()?;
let config = update_usb_config(&state, move |staged| {
staged.uac = request;
Ok(None)
})
.await?;
Ok(Json(config.uac))
}

View File

@@ -55,6 +55,7 @@ where
staged_config.otg_network.host_mac = host_mac;
}
staged_config.otg_network.validate()?;
staged_config.uac.validate()?;
}
if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await {
@@ -92,6 +93,7 @@ where
config.hid = staged_config.hid.clone();
config.msd = staged_config.msd.clone();
config.otg_network = staged_config.otg_network.clone();
config.uac = staged_config.uac.clone();
config.enforce_invariants();
})
.await

View File

@@ -3,6 +3,7 @@ mod error;
mod handlers;
mod routes;
mod static_files;
#[cfg(unix)]
mod uac_ws;
mod ws;
@@ -11,5 +12,6 @@ pub use error::ErrorResponse;
pub use routes::create_router;
#[cfg(not(debug_assertions))]
pub use static_files::StaticAssets;
#[cfg(unix)]
pub use uac_ws::uac_audio_ws_handler;
pub use ws::ws_handler;

View File

@@ -13,6 +13,7 @@ use tower_http::{
use super::audio_ws::audio_ws_handler;
use super::handlers;
#[cfg(unix)]
use super::uac_ws::uac_audio_ws_handler;
use super::ws::ws_handler;
use crate::auth::auth_middleware;
@@ -101,7 +102,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/audio/devices", get(handlers::list_audio_devices))
// Audio WebSocket endpoint
.route("/ws/audio", any(audio_ws_handler))
.route("/ws/uac-audio", any(uac_audio_ws_handler))
// Configuration management (domain-separated endpoints)
.route("/config", get(handlers::config::get_all_config))
.route("/config/video", get(handlers::config::get_video_config))
@@ -264,6 +264,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
#[cfg(unix)]
let user_routes = {
user_routes
.route("/ws/uac-audio", any(uac_audio_ws_handler))
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
.route("/config/msd", get(handlers::config::get_msd_config))
.route("/config/msd", patch(handlers::config::update_msd_config))
@@ -280,14 +281,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/otg/network/status",
get(handlers::config::get_otg_network_status),
)
.route(
"/config/uac",
get(handlers::config::get_uac_config),
)
.route(
"/config/uac",
patch(handlers::config::update_uac_config),
)
.route("/config/uac", get(handlers::config::get_uac_config))
.route("/config/uac", patch(handlers::config::update_uac_config))
.route("/msd/status", get(handlers::msd_status))
.route("/msd/images", get(handlers::msd_images_list))
.route("/msd/images/download", post(handlers::msd_image_download))

View File

@@ -1,33 +1,135 @@
use axum::extract::ws::WebSocketUpgrade;
use std::borrow::Cow;
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use std::sync::Arc;
use tracing::warn;
use tracing::{debug, info, warn};
use crate::audio::uac::{
parse_audio_packet, UacAudioPacket, UacOpusDecoder, UacPlaybackState, UacSession,
};
use crate::state::AppState;
/// WebSocket endpoint for UAC microphone passthrough audio input.
///
/// Accepts Opus-encoded audio frames (same binary protocol as audio
/// output, message type 0x03) and routes decoded PCM to the UAC
/// playback device on the USB gadget side.
pub async fn uac_audio_ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let playback = {
let guard = state.uac_playback.read().await;
match guard.as_ref() {
Some(p) => Arc::new(p.clone()),
None => {
warn!("UAC audio WS rejected: playback not initialized");
return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback not initialized").into_response();
let session = {
let playback = state.uac_playback.read().await;
let Some(playback) = playback.as_ref() else {
return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback is disabled").into_response();
};
match playback.acquire_session() {
Ok(session) => session,
Err(error) => {
return (StatusCode::CONFLICT, error.to_string()).into_response();
}
}
};
ws.on_upgrade(move |socket| {
crate::audio::uac_websocket::handle_uac_audio_ws(socket, playback)
})
ws.on_upgrade(move |socket| handle_uac_audio(socket, session))
}
async fn handle_uac_audio(mut socket: WebSocket, session: UacSession) {
let mut decoder = match UacOpusDecoder::new() {
Ok(decoder) => decoder,
Err(error) => {
warn!("Unable to initialize UAC Opus decoder: {error}");
let _ = socket.send(Message::Close(None)).await;
return;
}
};
let mut dropped_frames = 0u64;
info!("UAC microphone WebSocket connected");
let mut playback_state = session.state();
if socket
.send(playback_state_message(playback_state))
.await
.is_err()
{
return;
}
while let Some(message) = socket.recv().await {
let message = match message {
Ok(message) => message,
Err(error) => {
warn!("UAC microphone WebSocket failed: {error}");
break;
}
};
match message {
Message::Binary(data) => {
let packet = match parse_audio_packet(&data) {
Ok(packet) => packet,
Err(error) => {
warn!("Rejected UAC audio packet: {error}");
continue;
}
};
let pcm: Cow<'_, [i16]> = match packet {
UacAudioPacket::Opus(payload) => match decoder.decode(payload) {
Ok(pcm) => Cow::Borrowed(pcm),
Err(error) => {
warn!("Rejected UAC Opus packet: {error}");
continue;
}
},
packet @ UacAudioPacket::Pcm(_) => match packet.pcm_samples() {
Ok(pcm) => Cow::Owned(pcm),
Err(error) => {
warn!("Rejected UAC PCM packet: {error}");
continue;
}
},
};
let (accepted, current_state) = match session.try_write(pcm.as_ref()) {
Ok(result) => result,
Err(error) => {
warn!("UAC playback stopped: {error}");
break;
}
};
if !accepted {
dropped_frames += 1;
if dropped_frames == 1 || dropped_frames.is_multiple_of(250) {
debug!(
"Dropped {dropped_frames} UAC audio frames while the target was unavailable"
);
}
}
if current_state != playback_state
&& socket
.send(playback_state_message(current_state))
.await
.is_err()
{
break;
}
playback_state = current_state;
}
Message::Close(_) => break,
Message::Ping(_) | Message::Pong(_) => {}
Message::Text(_) => debug!("Ignoring text message on UAC audio WebSocket"),
}
}
info!("UAC microphone WebSocket disconnected; dropped_frames={dropped_frames}");
}
fn playback_state_message(state: UacPlaybackState) -> Message {
Message::Text(
serde_json::json!({
"type": "uac_status",
"state": state.as_str(),
})
.to_string()
.into(),
)
}