feat: UAC USB microphone passthrough

- Add UAC1 gadget function (ConfigFS) with optimized endpoint config
- Browser mic capture with Opus encoding via WebCodecs AudioEncoder
- WebSocket audio transport (Opus 64kbps, 100x bandwidth reduction vs raw PCM)
- aplay subprocess for reliable PCM playback to USB gadget
- Settings toggle + ActionBar mic button (hidden when UAC disabled)
- Dynamic PCM device resolution (/proc/asound)
- c_chmask=0 + req_number=4 fixes DWC3 composite isochronous endpoint issue
This commit is contained in:
arounyf
2026-07-23 06:56:53 +00:00
parent 4e32b05124
commit e9bed3688f
27 changed files with 977 additions and 16 deletions

View File

@@ -11,6 +11,8 @@ pub mod monitor;
pub mod recovery;
pub mod streamer;
pub mod types;
pub mod uac_streamer;
pub mod uac_websocket;
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
pub use controller::AudioController;

210
src/audio/uac_streamer.rs Normal file
View File

@@ -0,0 +1,210 @@
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);
}
}
}

133
src/audio/uac_websocket.rs Normal file
View File

@@ -0,0 +1,133 @@
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");
}