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");
}

View File

@@ -44,6 +44,7 @@ pub struct AppConfig {
pub rtsp: RtspConfig,
pub redfish: RedfishConfig,
pub watchdog: WatchdogConfig,
pub uac: crate::otg::service::UacConfig,
}
impl AppConfig {
@@ -51,6 +52,7 @@ impl AppConfig {
if self.hid.backend != HidBackend::Otg {
self.msd.enabled = false;
self.otg_network.enabled = false;
self.uac.enabled = false;
}
self.atx.normalize();
}

View File

@@ -308,7 +308,7 @@ async fn main() -> anyhow::Result<()> {
#[cfg(unix)]
if let Err(e) = otg_service
.apply_config(&config.hid, &config.msd, &config.otg_network)
.apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac)
.await
{
tracing::warn!("Failed to apply OTG config: {}", e);
@@ -579,6 +579,24 @@ 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);
}
}
}
if config.watchdog.enabled {
if let Err(error) = state.watchdog.enable().await {
tracing::error!(

View File

@@ -47,6 +47,7 @@ pub struct OtgGadgetManager {
hid_instance: u8,
msd_instance: u8,
network_instance: u8,
uac_instance: u8,
functions: Vec<Box<dyn GadgetFunction>>,
bound_udc: Option<String>,
created_by_us: bool,
@@ -73,6 +74,7 @@ impl OtgGadgetManager {
hid_instance: 0,
msd_instance: 0,
network_instance: 0,
uac_instance: 0,
functions: Vec::with_capacity(4),
bound_udc: None,
created_by_us: false,
@@ -148,6 +150,14 @@ impl OtgGadgetManager {
Ok(func_clone)
}
pub fn add_uac(&mut self, sample_rate: u32, channels: u8) -> Result<super::uac::UacFunction> {
let func = super::uac::UacFunction::new(self.uac_instance, sample_rate, channels)?;
let func_clone = func.clone();
self.add_function(Box::new(func))?;
self.uac_instance += 1;
Ok(func_clone)
}
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
self.functions.push(func);
Ok(())

View File

@@ -18,6 +18,8 @@ pub mod report_desc;
pub mod self_check;
#[cfg(unix)]
pub mod service;
#[cfg(unix)]
pub mod uac;
#[cfg(unix)]
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
@@ -26,7 +28,9 @@ pub use msd::{MsdFunction, MsdLunConfig};
#[cfg(unix)]
pub use network::NetworkFunction;
#[cfg(unix)]
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService, UacConfig};
#[cfg(unix)]
pub use uac::UacFunction;
/// List USB Device Controller names exposed by sysfs.
pub fn list_udc_devices() -> Vec<String> {

View File

@@ -12,6 +12,36 @@ use crate::config::{
};
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>,
@@ -62,6 +92,7 @@ pub(crate) struct OtgDesiredState {
pub msd_enabled: bool,
pub msd_lun_capacity: u8,
pub network: OtgNetworkConfig,
pub uac_enabled: bool,
}
impl Default for OtgDesiredState {
@@ -74,6 +105,7 @@ impl Default for OtgDesiredState {
msd_enabled: false,
msd_lun_capacity: 1,
network: OtgNetworkConfig::default(),
uac_enabled: false,
}
}
}
@@ -83,8 +115,10 @@ impl OtgDesiredState {
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<Self> {
network.validate()?;
uac.validate()?;
let hid_functions = if hid.backend == HidBackend::Otg {
let functions = hid.constrained_otg_functions();
Some(functions)
@@ -93,7 +127,8 @@ impl OtgDesiredState {
};
hid.validate_otg_functions()?;
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled;
let needs_udc =
hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled;
let udc = if needs_udc {
hid.otg_udc
.as_ref()
@@ -111,6 +146,7 @@ impl OtgDesiredState {
msd_enabled: msd.enabled,
msd_lun_capacity: 1,
network: network.clone(),
uac_enabled: uac.enabled,
})
}
@@ -133,6 +169,7 @@ struct OtgServiceState {
pub msd_enabled: bool,
pub msd_lun_capacity: u8,
pub network: OtgNetworkConfig,
pub uac_enabled: bool,
pub configured_udc: Option<String>,
pub hid_paths: Option<HidDevicePaths>,
pub hid_functions: Option<OtgHidFunctions>,
@@ -150,6 +187,7 @@ impl Default for OtgServiceState {
msd_enabled: false,
msd_lun_capacity: 1,
network: OtgNetworkConfig::default(),
uac_enabled: false,
configured_udc: None,
hid_paths: None,
hid_functions: None,
@@ -215,6 +253,7 @@ impl OtgService {
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<()> {
if !self.recovery_checked.load(Ordering::SeqCst) {
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
@@ -226,7 +265,7 @@ impl OtgService {
}
let previous = self.desired.read().await.clone();
let desired = self
.desired_from_config_preserving_runtime(hid, msd, network)
.desired_from_config_preserving_runtime(hid, msd, network, uac)
.await?;
{
let mut state = self.state.write().await;
@@ -269,8 +308,9 @@ impl OtgService {
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &UacConfig,
) -> Result<OtgDesiredState> {
let mut desired = OtgDesiredState::from_config(hid, msd, network)?;
let mut desired = OtgDesiredState::from_config(hid, msd, network, uac)?;
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
Ok(desired)
}
@@ -305,10 +345,11 @@ impl OtgService {
let desired = self.desired.read().await.clone();
debug!(
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}",
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UAC={}, UDC={:?}",
desired.hid_enabled(),
desired.msd_enabled,
desired.network_enabled(),
desired.uac_enabled,
desired.udc
);
@@ -320,6 +361,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.configured_udc == desired.udc
&& state.hid_functions == desired.hid_functions
&& state.keyboard_leds_enabled == desired.keyboard_leds
@@ -359,6 +401,7 @@ impl OtgService {
state.msd_enabled = false;
state.msd_lun_capacity = 1;
state.network = OtgNetworkConfig::default();
state.uac_enabled = false;
state.configured_udc = None;
state.hid_paths = None;
state.hid_functions = None;
@@ -393,6 +436,20 @@ impl OtgService {
);
let mut hid_paths = None;
// Add UAC BEFORE HID so the isochronous endpoint gets a
// 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}"))
})?)
} else {
None
};
if let Some(hid_functions) = desired.hid_functions.clone() {
let mut paths = HidDevicePaths {
udc: Some(udc.clone()),
@@ -537,6 +594,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.configured_udc = Some(udc);
state.hid_paths = hid_paths;
state.hid_functions = desired.hid_functions;
@@ -642,6 +700,7 @@ mod tests {
&HidConfig::default(),
&MsdConfig::default(),
&OtgNetworkConfig::default(),
&UacConfig::default(),
)
.await
.unwrap();

148
src/otg/uac.rs Normal file
View File

@@ -0,0 +1,148 @@
use std::path::{Path, PathBuf};
use tracing::{debug, info};
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.
///
/// 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
/// gadget side appears as microphone input on the host.
#[derive(Debug, Clone)]
pub struct UacFunction {
name: String,
sample_rate: u32,
channels: u8,
}
impl UacFunction {
/// Create a new UAC1 function instance.
///
/// `instance` is a zero-based index to avoid name collisions
/// (e.g. `uac2.usb0`).
pub fn new(instance: u8, sample_rate: u32, channels: u8) -> Result<Self> {
if sample_rate == 0 || sample_rate > 384_000 {
return Err(AppError::BadRequest(format!(
"invalid UAC sample rate: {sample_rate}"
)));
}
if channels == 0 || channels > 8 {
return Err(AppError::BadRequest(format!(
"invalid UAC channel count: {channels}"
)));
}
Ok(Self {
name: format!("uac1.usb{instance}"),
sample_rate,
channels,
})
}
fn function_path(&self, gadget_path: &Path) -> PathBuf {
gadget_path.join("functions").join(&self.name)
}
}
impl GadgetFunction for UacFunction {
fn name(&self) -> &str {
&self.name
}
fn create(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path);
create_dir(&func_path)?;
// Playback direction (gadget → host): the controlled machine sees
// this as a microphone/line-in input.
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
// 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")?;
// req_number=4: explicitly allocate 4 USB requests for the
// isochronous endpoint. Default (0 = auto) may not be enough
// for composite gadgets on DWC3.
let _ = write_file(&func_path.join("req_number"), "4");
debug!(
"UAC1 function {} created: {}ch {}Hz",
&self.name, self.channels, self.sample_rate
);
Ok(())
}
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path);
let link_path = config_path.join(&self.name);
create_symlink(&func_path, &link_path)?;
debug!("UAC1 function {} linked into configuration", &self.name);
Ok(())
}
fn unlink(&self, config_path: &Path) -> Result<()> {
let link_path = config_path.join(&self.name);
if link_path.exists() {
std::fs::remove_file(&link_path).map_err(|e| {
AppError::Internal(format!(
"Failed to unlink UAC1 function {}: {}",
&self.name, e
))
})?;
debug!("UAC1 function {} unlinked", &self.name);
}
Ok(())
}
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
let func_path = self.function_path(gadget_path);
if func_path.exists() {
remove_dir(&func_path).map_err(|e| {
AppError::Internal(format!(
"Failed to remove UAC1 function {}: {}",
&self.name, e
))
})?;
info!("UAC1 function {} removed", &self.name);
}
Ok(())
}
}
/// Return the ALSA PCM device name that the kernel assigns to a UAC1
/// gadget after binding. The device appears as a playback-only PCM on
/// the gadget side.
pub fn uac_pcm_device() -> String {
// The kernel assigns the card name based on the gadget name.
// The PCM name is typically "playback" for UAC1.
"hw:UAC1Gadget,0".to_string()
}
/// Resolve the actual PCM device name for a UAC1 playback device
/// by scanning /proc/asound/ for the gadget audio card.
pub fn find_uac_pcm_device() -> Option<String> {
for entry in std::fs::read_dir("/proc/asound").ok()? {
let entry = entry.ok()?;
let name = entry.file_name();
let name = name.to_str()?;
if !name.starts_with("card") {
continue;
}
let card_path = entry.path().join("id");
if let Ok(id) = std::fs::read_to_string(&card_path) {
if id.trim().starts_with("UAC1Gadget") || id.trim().starts_with("gadget") {
let card_num = name.strip_prefix("card")?;
return Some(format!("hw:{card_num},0"));
}
}
}
None
}

View File

@@ -77,6 +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>>,
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
@@ -146,6 +148,8 @@ impl AppState {
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
config_apply_locks: ConfigApplyLocks::new(),
data_dir,
uac_playback: Arc::new(RwLock::new(None)),
uac_config: Arc::new(RwLock::new(crate::otg::service::UacConfig::default())),
})
}

View File

@@ -76,17 +76,18 @@ async fn reconcile_otg_config(
hid: &HidConfig,
msd: &MsdConfig,
network: &OtgNetworkConfig,
uac: &crate::otg::service::UacConfig,
) -> Result<()> {
#[cfg(not(unix))]
{
let _ = (state, hid, msd, network);
let _ = (state, hid, msd, network, uac);
Ok(())
}
#[cfg(unix)]
{
state
.otg_service
.apply_config(hid, msd, network)
.apply_config(hid, msd, network, uac)
.await
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
}
@@ -236,7 +237,7 @@ pub async fn apply_hid_config(
}
if otg_config_changed {
reconcile_otg_config(state, new_config, msd_config, network_config).await?;
reconcile_otg_config(state, new_config, msd_config, network_config, &state.config.get().uac).await?;
}
if !transitioning_away_from_otg {
@@ -304,7 +305,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).await?;
reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?;
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
@@ -339,7 +340,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).await?;
reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?;
}
if hid_config.backend == HidBackend::Otg
@@ -367,7 +368,9 @@ pub async fn apply_usb_config(
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.msd != new_config.msd
|| old_config.otg_network != new_config.otg_network
|| old_config.uac != new_config.uac;
if transitioning_away_from_otg {
apply_hid_config(
@@ -385,6 +388,7 @@ pub async fn apply_usb_config(
&new_config.hid,
&new_config.msd,
&new_config.otg_network,
&new_config.uac,
)
.await?;
apply_hid_config(
@@ -411,6 +415,32 @@ pub async fn apply_usb_config(
.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");
}
}
}
apply_msd_config(
state,
&old_config.msd,

View File

@@ -15,6 +15,8 @@ mod redfish;
mod rtsp;
mod rustdesk;
mod stream;
#[cfg(unix)]
mod uac;
mod usb_update;
pub(crate) mod video;
mod vnc;
@@ -40,6 +42,8 @@ pub use rustdesk::{
regenerate_device_password, start_rustdesk_service, stop_rustdesk_service,
update_rustdesk_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::{

View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use axum::{extract::State, Json};
use crate::error::Result;
use crate::otg::service::UacConfig;
use crate::state::AppState;
use super::apply::try_apply_lock;
pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfig> {
Json(state.config.get().uac.clone())
}
pub async fn update_uac_config(
State(state): State<Arc<AppState>>,
Json(req): 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()))
}

View File

@@ -147,7 +147,7 @@ pub async fn setup_init(
{
if let Err(e) = state
.otg_service
.apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network)
.apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network, &crate::otg::service::UacConfig::default())
.await
{
tracing::warn!("Failed to apply OTG config during setup: {}", e);

View File

@@ -3,6 +3,7 @@ mod error;
mod handlers;
mod routes;
mod static_files;
mod uac_ws;
mod ws;
pub use audio_ws::audio_ws_handler;
@@ -10,4 +11,5 @@ pub use error::ErrorResponse;
pub use routes::create_router;
#[cfg(not(debug_assertions))]
pub use static_files::StaticAssets;
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;
use super::uac_ws::uac_audio_ws_handler;
use super::ws::ws_handler;
use crate::auth::auth_middleware;
use crate::hid::websocket::ws_hid_handler;
@@ -100,6 +101,7 @@ 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))
@@ -278,6 +280,14 @@ 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("/msd/status", get(handlers::msd_status))
.route("/msd/images", get(handlers::msd_images_list))
.route("/msd/images/download", post(handlers::msd_image_download))

33
src/web/uac_ws.rs Normal file
View File

@@ -0,0 +1,33 @@
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use std::sync::Arc;
use tracing::warn;
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();
}
}
};
ws.on_upgrade(move |socket| {
crate::audio::uac_websocket::handle_uac_audio_ws(socket, playback)
})
}