mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
Merge branch 'main' into dev
# Conflicts: # src/web/handlers/setup.rs # web/src/i18n/en-US.ts # web/src/i18n/zh-CN.ts
This commit is contained in:
@@ -11,6 +11,8 @@ pub mod monitor;
|
|||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
pub mod streamer;
|
pub mod streamer;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod uac_streamer;
|
||||||
|
pub mod uac_websocket;
|
||||||
|
|
||||||
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
||||||
pub use controller::AudioController;
|
pub use controller::AudioController;
|
||||||
|
|||||||
210
src/audio/uac_streamer.rs
Normal file
210
src/audio/uac_streamer.rs
Normal 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
133
src/audio/uac_websocket.rs
Normal 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");
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ pub struct AppConfig {
|
|||||||
pub rtsp: RtspConfig,
|
pub rtsp: RtspConfig,
|
||||||
pub redfish: RedfishConfig,
|
pub redfish: RedfishConfig,
|
||||||
pub watchdog: WatchdogConfig,
|
pub watchdog: WatchdogConfig,
|
||||||
|
pub uac: crate::otg::service::UacConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -51,6 +52,7 @@ impl AppConfig {
|
|||||||
if self.hid.backend != HidBackend::Otg {
|
if self.hid.backend != HidBackend::Otg {
|
||||||
self.msd.enabled = false;
|
self.msd.enabled = false;
|
||||||
self.otg_network.enabled = false;
|
self.otg_network.enabled = false;
|
||||||
|
self.uac.enabled = false;
|
||||||
}
|
}
|
||||||
self.atx.normalize();
|
self.atx.normalize();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ impl Default for VideoConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct MsdConfig {
|
pub struct MsdConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ pub struct OtgBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HID_WRITE_TIMEOUT_MS: i32 = 20;
|
const HID_WRITE_TIMEOUT_MS: i32 = 20;
|
||||||
|
const OTG_RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
impl OtgBackend {
|
impl OtgBackend {
|
||||||
/// Gadget must already exist; paths come from `OtgService`.
|
/// Gadget must already exist; paths come from `OtgService`.
|
||||||
@@ -587,7 +588,7 @@ impl OtgBackend {
|
|||||||
path.display(),
|
path.display(),
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(OTG_RUNTIME_POLL_INTERVAL);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,6 +612,7 @@ impl OtgBackend {
|
|||||||
|
|
||||||
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
|
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
|
||||||
*file = None;
|
*file = None;
|
||||||
|
thread::sleep(OTG_RUNTIME_POLL_INTERVAL);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,8 +635,13 @@ impl OtgBackend {
|
|||||||
Ok(_) => false,
|
Ok(_) => false,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => false,
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => false,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("OTG keyboard LED listener read failed: {}", err);
|
if err.raw_os_error() == Some(libc::ESHUTDOWN) {
|
||||||
|
debug!("OTG keyboard LED listener disconnected: {}", err);
|
||||||
|
} else {
|
||||||
|
warn!("OTG keyboard LED listener read failed: {}", err);
|
||||||
|
}
|
||||||
*file = None;
|
*file = None;
|
||||||
|
thread::sleep(OTG_RUNTIME_POLL_INTERVAL);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,6 +649,7 @@ impl OtgBackend {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("OTG keyboard LED listener poll failed: {}", err);
|
warn!("OTG keyboard LED listener poll failed: {}", err);
|
||||||
*file = None;
|
*file = None;
|
||||||
|
thread::sleep(OTG_RUNTIME_POLL_INTERVAL);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,7 +684,7 @@ impl OtgBackend {
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyboard_leds_enabled {
|
if keyboard_leds_enabled && current_udc_configured {
|
||||||
if let Some(path) = keyboard_path.as_ref() {
|
if let Some(path) = keyboard_path.as_ref() {
|
||||||
changed |= Self::poll_keyboard_led_once(
|
changed |= Self::poll_keyboard_led_once(
|
||||||
&mut keyboard_led_file,
|
&mut keyboard_led_file,
|
||||||
@@ -684,10 +692,11 @@ impl OtgBackend {
|
|||||||
&led_state,
|
&led_state,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
thread::sleep(Duration::from_millis(500));
|
thread::sleep(OTG_RUNTIME_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
thread::sleep(Duration::from_millis(500));
|
keyboard_led_file = None;
|
||||||
|
thread::sleep(OTG_RUNTIME_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
|
|||||||
20
src/main.rs
20
src/main.rs
@@ -308,7 +308,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if let Err(e) = otg_service
|
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
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to apply OTG config: {}", e);
|
tracing::warn!("Failed to apply OTG config: {}", e);
|
||||||
@@ -579,6 +579,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
data_dir.clone(),
|
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 config.watchdog.enabled {
|
||||||
if let Err(error) = state.watchdog.enable().await {
|
if let Err(error) = state.watchdog.enable().await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub struct OtgGadgetManager {
|
|||||||
hid_instance: u8,
|
hid_instance: u8,
|
||||||
msd_instance: u8,
|
msd_instance: u8,
|
||||||
network_instance: u8,
|
network_instance: u8,
|
||||||
|
uac_instance: u8,
|
||||||
functions: Vec<Box<dyn GadgetFunction>>,
|
functions: Vec<Box<dyn GadgetFunction>>,
|
||||||
bound_udc: Option<String>,
|
bound_udc: Option<String>,
|
||||||
created_by_us: bool,
|
created_by_us: bool,
|
||||||
@@ -73,6 +74,7 @@ impl OtgGadgetManager {
|
|||||||
hid_instance: 0,
|
hid_instance: 0,
|
||||||
msd_instance: 0,
|
msd_instance: 0,
|
||||||
network_instance: 0,
|
network_instance: 0,
|
||||||
|
uac_instance: 0,
|
||||||
functions: Vec::with_capacity(4),
|
functions: Vec::with_capacity(4),
|
||||||
bound_udc: None,
|
bound_udc: None,
|
||||||
created_by_us: false,
|
created_by_us: false,
|
||||||
@@ -148,6 +150,14 @@ impl OtgGadgetManager {
|
|||||||
Ok(func_clone)
|
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<()> {
|
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
|
||||||
self.functions.push(func);
|
self.functions.push(func);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub mod report_desc;
|
|||||||
pub mod self_check;
|
pub mod self_check;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod uac;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
||||||
@@ -26,7 +28,9 @@ pub use msd::{MsdFunction, MsdLunConfig};
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use network::NetworkFunction;
|
pub use network::NetworkFunction;
|
||||||
#[cfg(unix)]
|
#[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.
|
/// List USB Device Controller names exposed by sysfs.
|
||||||
pub fn list_udc_devices() -> Vec<String> {
|
pub fn list_udc_devices() -> Vec<String> {
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ use crate::config::{
|
|||||||
};
|
};
|
||||||
use crate::error::{AppError, Result};
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct HidDevicePaths {
|
pub struct HidDevicePaths {
|
||||||
pub keyboard: Option<PathBuf>,
|
pub keyboard: Option<PathBuf>,
|
||||||
@@ -62,6 +92,7 @@ pub(crate) struct OtgDesiredState {
|
|||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
pub msd_lun_capacity: u8,
|
pub msd_lun_capacity: u8,
|
||||||
pub network: OtgNetworkConfig,
|
pub network: OtgNetworkConfig,
|
||||||
|
pub uac_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OtgDesiredState {
|
impl Default for OtgDesiredState {
|
||||||
@@ -74,6 +105,7 @@ impl Default for OtgDesiredState {
|
|||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
msd_lun_capacity: 1,
|
msd_lun_capacity: 1,
|
||||||
network: OtgNetworkConfig::default(),
|
network: OtgNetworkConfig::default(),
|
||||||
|
uac_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,8 +115,10 @@ impl OtgDesiredState {
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
network.validate()?;
|
network.validate()?;
|
||||||
|
uac.validate()?;
|
||||||
let hid_functions = if hid.backend == HidBackend::Otg {
|
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||||
let functions = hid.constrained_otg_functions();
|
let functions = hid.constrained_otg_functions();
|
||||||
Some(functions)
|
Some(functions)
|
||||||
@@ -93,7 +127,8 @@ impl OtgDesiredState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
hid.validate_otg_functions()?;
|
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 {
|
let udc = if needs_udc {
|
||||||
hid.otg_udc
|
hid.otg_udc
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -111,6 +146,7 @@ impl OtgDesiredState {
|
|||||||
msd_enabled: msd.enabled,
|
msd_enabled: msd.enabled,
|
||||||
msd_lun_capacity: 1,
|
msd_lun_capacity: 1,
|
||||||
network: network.clone(),
|
network: network.clone(),
|
||||||
|
uac_enabled: uac.enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +169,7 @@ struct OtgServiceState {
|
|||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
pub msd_lun_capacity: u8,
|
pub msd_lun_capacity: u8,
|
||||||
pub network: OtgNetworkConfig,
|
pub network: OtgNetworkConfig,
|
||||||
|
pub uac_enabled: bool,
|
||||||
pub configured_udc: Option<String>,
|
pub configured_udc: Option<String>,
|
||||||
pub hid_paths: Option<HidDevicePaths>,
|
pub hid_paths: Option<HidDevicePaths>,
|
||||||
pub hid_functions: Option<OtgHidFunctions>,
|
pub hid_functions: Option<OtgHidFunctions>,
|
||||||
@@ -150,6 +187,7 @@ impl Default for OtgServiceState {
|
|||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
msd_lun_capacity: 1,
|
msd_lun_capacity: 1,
|
||||||
network: OtgNetworkConfig::default(),
|
network: OtgNetworkConfig::default(),
|
||||||
|
uac_enabled: false,
|
||||||
configured_udc: None,
|
configured_udc: None,
|
||||||
hid_paths: None,
|
hid_paths: None,
|
||||||
hid_functions: None,
|
hid_functions: None,
|
||||||
@@ -215,6 +253,7 @@ impl OtgService {
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !self.recovery_checked.load(Ordering::SeqCst) {
|
if !self.recovery_checked.load(Ordering::SeqCst) {
|
||||||
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
|
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
|
||||||
@@ -226,7 +265,7 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
let previous = self.desired.read().await.clone();
|
let previous = self.desired.read().await.clone();
|
||||||
let desired = self
|
let desired = self
|
||||||
.desired_from_config_preserving_runtime(hid, msd, network)
|
.desired_from_config_preserving_runtime(hid, msd, network, uac)
|
||||||
.await?;
|
.await?;
|
||||||
{
|
{
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
@@ -269,8 +308,9 @@ impl OtgService {
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<OtgDesiredState> {
|
) -> 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;
|
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
|
||||||
Ok(desired)
|
Ok(desired)
|
||||||
}
|
}
|
||||||
@@ -305,10 +345,11 @@ impl OtgService {
|
|||||||
let desired = self.desired.read().await.clone();
|
let desired = self.desired.read().await.clone();
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}",
|
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UAC={}, UDC={:?}",
|
||||||
desired.hid_enabled(),
|
desired.hid_enabled(),
|
||||||
desired.msd_enabled,
|
desired.msd_enabled,
|
||||||
desired.network_enabled(),
|
desired.network_enabled(),
|
||||||
|
desired.uac_enabled,
|
||||||
desired.udc
|
desired.udc
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -320,6 +361,7 @@ impl OtgService {
|
|||||||
&& state.msd_enabled == desired.msd_enabled
|
&& state.msd_enabled == desired.msd_enabled
|
||||||
&& state.msd_lun_capacity == desired.msd_lun_capacity
|
&& state.msd_lun_capacity == desired.msd_lun_capacity
|
||||||
&& state.network == desired.network
|
&& state.network == desired.network
|
||||||
|
&& state.uac_enabled == desired.uac_enabled
|
||||||
&& state.configured_udc == desired.udc
|
&& state.configured_udc == desired.udc
|
||||||
&& state.hid_functions == desired.hid_functions
|
&& state.hid_functions == desired.hid_functions
|
||||||
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
||||||
@@ -359,6 +401,7 @@ impl OtgService {
|
|||||||
state.msd_enabled = false;
|
state.msd_enabled = false;
|
||||||
state.msd_lun_capacity = 1;
|
state.msd_lun_capacity = 1;
|
||||||
state.network = OtgNetworkConfig::default();
|
state.network = OtgNetworkConfig::default();
|
||||||
|
state.uac_enabled = false;
|
||||||
state.configured_udc = None;
|
state.configured_udc = None;
|
||||||
state.hid_paths = None;
|
state.hid_paths = None;
|
||||||
state.hid_functions = None;
|
state.hid_functions = None;
|
||||||
@@ -393,6 +436,20 @@ impl OtgService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut hid_paths = None;
|
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() {
|
if let Some(hid_functions) = desired.hid_functions.clone() {
|
||||||
let mut paths = HidDevicePaths {
|
let mut paths = HidDevicePaths {
|
||||||
udc: Some(udc.clone()),
|
udc: Some(udc.clone()),
|
||||||
@@ -537,6 +594,7 @@ impl OtgService {
|
|||||||
state.msd_enabled = desired.msd_enabled;
|
state.msd_enabled = desired.msd_enabled;
|
||||||
state.msd_lun_capacity = desired.msd_lun_capacity;
|
state.msd_lun_capacity = desired.msd_lun_capacity;
|
||||||
state.network = desired.network.clone();
|
state.network = desired.network.clone();
|
||||||
|
state.uac_enabled = desired.uac_enabled;
|
||||||
state.configured_udc = Some(udc);
|
state.configured_udc = Some(udc);
|
||||||
state.hid_paths = hid_paths;
|
state.hid_paths = hid_paths;
|
||||||
state.hid_functions = desired.hid_functions;
|
state.hid_functions = desired.hid_functions;
|
||||||
@@ -642,6 +700,7 @@ mod tests {
|
|||||||
&HidConfig::default(),
|
&HidConfig::default(),
|
||||||
&MsdConfig::default(),
|
&MsdConfig::default(),
|
||||||
&OtgNetworkConfig::default(),
|
&OtgNetworkConfig::default(),
|
||||||
|
&UacConfig::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
148
src/otg/uac.rs
Normal file
148
src/otg/uac.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ pub struct AppState {
|
|||||||
pub msd: Arc<RwLock<Option<MsdController>>>,
|
pub msd: Arc<RwLock<Option<MsdController>>>,
|
||||||
pub atx: Arc<RwLock<Option<AtxController>>>,
|
pub atx: Arc<RwLock<Option<AtxController>>>,
|
||||||
pub audio: Arc<AudioController>,
|
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 rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
||||||
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
||||||
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
||||||
@@ -146,6 +148,8 @@ impl AppState {
|
|||||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||||
config_apply_locks: ConfigApplyLocks::new(),
|
config_apply_locks: ConfigApplyLocks::new(),
|
||||||
data_dir,
|
data_dir,
|
||||||
|
uac_playback: Arc::new(RwLock::new(None)),
|
||||||
|
uac_config: Arc::new(RwLock::new(crate::otg::service::UacConfig::default())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,17 +76,18 @@ async fn reconcile_otg_config(
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &crate::otg::service::UacConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
{
|
{
|
||||||
let _ = (state, hid, msd, network);
|
let _ = (state, hid, msd, network, uac);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
state
|
state
|
||||||
.otg_service
|
.otg_service
|
||||||
.apply_config(hid, msd, network)
|
.apply_config(hid, msd, network, uac)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||||
}
|
}
|
||||||
@@ -236,7 +237,7 @@ pub async fn apply_hid_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if otg_config_changed {
|
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 {
|
if !transitioning_away_from_otg {
|
||||||
@@ -304,7 +305,7 @@ pub async fn apply_msd_config(
|
|||||||
if new_msd_enabled {
|
if new_msd_enabled {
|
||||||
tracing::info!("(Re)initializing MSD...");
|
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;
|
let mut msd_guard = state.msd.write().await;
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
if let Some(msd) = msd_guard.as_mut() {
|
||||||
@@ -339,7 +340,7 @@ pub async fn apply_msd_config(
|
|||||||
*msd_guard = None;
|
*msd_guard = None;
|
||||||
tracing::info!("MSD shutdown complete");
|
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
|
if hid_config.backend == HidBackend::Otg
|
||||||
@@ -365,6 +366,12 @@ pub async fn apply_usb_config(
|
|||||||
let transitioning_away_from_otg =
|
let transitioning_away_from_otg =
|
||||||
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
|
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;
|
||||||
|
|
||||||
if transitioning_away_from_otg {
|
if transitioning_away_from_otg {
|
||||||
apply_hid_config(
|
apply_hid_config(
|
||||||
state,
|
state,
|
||||||
@@ -381,6 +388,7 @@ pub async fn apply_usb_config(
|
|||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
apply_hid_config(
|
apply_hid_config(
|
||||||
@@ -394,6 +402,45 @@ pub async fn apply_usb_config(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the OTG gadget was rebuilt due to MSD or network config changes
|
||||||
|
// while HID config stayed the same, the /dev/hidg* devices are new and
|
||||||
|
// the HID backend must be reloaded to reopen them.
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
apply_msd_config(
|
apply_msd_config(
|
||||||
state,
|
state,
|
||||||
&old_config.msd,
|
&old_config.msd,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ mod redfish;
|
|||||||
mod rtsp;
|
mod rtsp;
|
||||||
mod rustdesk;
|
mod rustdesk;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod uac;
|
||||||
mod usb_update;
|
mod usb_update;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
mod vnc;
|
mod vnc;
|
||||||
@@ -40,6 +42,8 @@ pub use rustdesk::{
|
|||||||
regenerate_device_password, start_rustdesk_service, stop_rustdesk_service,
|
regenerate_device_password, start_rustdesk_service, stop_rustdesk_service,
|
||||||
update_rustdesk_config,
|
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 stream::{get_stream_config, update_stream_config};
|
||||||
pub use video::{get_video_config, update_video_config};
|
pub use video::{get_video_config, update_video_config};
|
||||||
pub use vnc::{
|
pub use vnc::{
|
||||||
|
|||||||
36
src/web/handlers/config/uac.rs
Normal file
36
src/web/handlers/config/uac.rs
Normal 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()))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod error;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
|
mod uac_ws;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
pub use audio_ws::audio_ws_handler;
|
pub use audio_ws::audio_ws_handler;
|
||||||
@@ -10,4 +11,5 @@ pub use error::ErrorResponse;
|
|||||||
pub use routes::create_router;
|
pub use routes::create_router;
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
pub use static_files::StaticAssets;
|
pub use static_files::StaticAssets;
|
||||||
|
pub use uac_ws::uac_audio_ws_handler;
|
||||||
pub use ws::ws_handler;
|
pub use ws::ws_handler;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use tower_http::{
|
|||||||
|
|
||||||
use super::audio_ws::audio_ws_handler;
|
use super::audio_ws::audio_ws_handler;
|
||||||
use super::handlers;
|
use super::handlers;
|
||||||
|
use super::uac_ws::uac_audio_ws_handler;
|
||||||
use super::ws::ws_handler;
|
use super::ws::ws_handler;
|
||||||
use crate::auth::auth_middleware;
|
use crate::auth::auth_middleware;
|
||||||
use crate::hid::websocket::ws_hid_handler;
|
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))
|
.route("/audio/devices", get(handlers::list_audio_devices))
|
||||||
// Audio WebSocket endpoint
|
// Audio WebSocket endpoint
|
||||||
.route("/ws/audio", any(audio_ws_handler))
|
.route("/ws/audio", any(audio_ws_handler))
|
||||||
|
.route("/ws/uac-audio", any(uac_audio_ws_handler))
|
||||||
// Configuration management (domain-separated endpoints)
|
// Configuration management (domain-separated endpoints)
|
||||||
.route("/config", get(handlers::config::get_all_config))
|
.route("/config", get(handlers::config::get_all_config))
|
||||||
.route("/config/video", get(handlers::config::get_video_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",
|
"/otg/network/status",
|
||||||
get(handlers::config::get_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/status", get(handlers::msd_status))
|
||||||
.route("/msd/images", get(handlers::msd_images_list))
|
.route("/msd/images", get(handlers::msd_images_list))
|
||||||
.route("/msd/images/download", post(handlers::msd_image_download))
|
.route("/msd/images/download", post(handlers::msd_image_download))
|
||||||
|
|||||||
33
src/web/uac_ws.rs
Normal file
33
src/web/uac_ws.rs
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.556.0",
|
"lucide-vue-next": "^0.556.0",
|
||||||
"opus-decoder": "^0.7.11",
|
"opus-decoder": "^0.7.11",
|
||||||
|
"opus-media-recorder": "^0.8.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"qrcode.vue": "^3.10.0",
|
"qrcode.vue": "^3.10.0",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
@@ -1940,6 +1941,12 @@
|
|||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-browser": {
|
||||||
|
"version": "4.8.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/detect-browser/-/detect-browser-4.8.0.tgz",
|
||||||
|
"integrity": "sha512-f4h2dFgzHUIpjpBLjhnDIteXv8VQiUm8XzAuzQtYUqECX/eKh67ykuiVoyb7Db7a0PUSmJa3OGXStG0CbQFUVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2041,6 +2048,15 @@
|
|||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-HK5GhnEAkm7fLy249GtF7DIuYmjLm85Ft6ssj7DhVl8Tx/z9+v0W6aiIVUdT4AXWGYy5Fc+s6gqBI49Bf0LejQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2472,6 +2488,16 @@
|
|||||||
"url": "https://github.com/sponsors/eshaz"
|
"url": "https://github.com/sponsors/eshaz"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opus-media-recorder": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/opus-media-recorder/-/opus-media-recorder-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-AIvJMpnJqZ18dFAU7Amtt5cZZp8oPzDoAOtobdTcLzwVNm/j815+GJmBupBzBZGBa4L940TEulm7Uu4tGOYDGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-browser": "^4.1.0",
|
||||||
|
"event-target-shim": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.556.0",
|
"lucide-vue-next": "^0.556.0",
|
||||||
"opus-decoder": "^0.7.11",
|
"opus-decoder": "^0.7.11",
|
||||||
|
"opus-media-recorder": "^0.8.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"qrcode.vue": "^3.10.0",
|
"qrcode.vue": "^3.10.0",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ export const otgNetworkApi = {
|
|||||||
interfaces: () => request<NetworkInterfaceInfo[]>('/devices/network', {}, { toastOnError: false }),
|
interfaces: () => request<NetworkInterfaceInfo[]>('/devices/network', {}, { toastOnError: false }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const uacApi = {
|
||||||
|
get: () => request<{enabled: boolean; sample_rate: number; channels: number}>('/config/uac'),
|
||||||
|
|
||||||
|
update: (config: {enabled: boolean; sample_rate: number; channels: number}) =>
|
||||||
|
request('/config/uac', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
export const otgConfigApi = {
|
export const otgConfigApi = {
|
||||||
update: (config: OtgConfigUpdate) =>
|
update: (config: OtgConfigUpdate) =>
|
||||||
request<OtgConfigResponse>('/config/otg', {
|
request<OtgConfigResponse>('/config/otg', {
|
||||||
|
|||||||
@@ -855,6 +855,7 @@ export {
|
|||||||
msdConfigApi,
|
msdConfigApi,
|
||||||
otgConfigApi,
|
otgConfigApi,
|
||||||
otgNetworkApi,
|
otgNetworkApi,
|
||||||
|
uacApi,
|
||||||
atxConfigApi,
|
atxConfigApi,
|
||||||
audioConfigApi,
|
audioConfigApi,
|
||||||
extensionsApi,
|
extensionsApi,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
|
import { getMicrophone } from '@/composables/useMicrophone'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ButtonGroup } from '@/components/ui/button-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
|
Mic,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -68,9 +70,13 @@ const props = defineProps<{
|
|||||||
showTerminal?: boolean
|
showTerminal?: boolean
|
||||||
showComputerUse?: boolean
|
showComputerUse?: boolean
|
||||||
showPasteText?: boolean
|
showPasteText?: boolean
|
||||||
|
showMic?: boolean
|
||||||
}>()
|
}>()
|
||||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||||
const showPasteText = computed(() => props.showPasteText !== false)
|
const showPasteText = computed(() => props.showPasteText !== false)
|
||||||
|
const showMic = computed(() => props.showMic === true)
|
||||||
|
const mic = getMicrophone()
|
||||||
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggleFullscreen'): void
|
(e: 'toggleFullscreen'): void
|
||||||
@@ -350,6 +356,24 @@ const hasRightOverflow = computed(() => {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mic button -->
|
||||||
|
<div v-if="showMic">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"
|
||||||
|
:class="mic.active.value ? 'text-destructive' : mic.error.value ? 'text-yellow-500' : ''"
|
||||||
|
@click="mic.toggle()">
|
||||||
|
<Mic class="size-4" :class="mic.active.value ? 'animate-pulse' : ''" />
|
||||||
|
<span>{{ mic.active.value ? '关闭' : '麦克风' }}</span>
|
||||||
|
<span v-if="mic.error.value" class="text-[10px]">{{ mic.error.value }}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ mic.error.value ? mic.error.value : (mic.active.value ? '停止传声' : '开始传声') }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
<!-- Right side buttons -->
|
<!-- Right side buttons -->
|
||||||
|
|||||||
161
web/src/composables/useMicrophone.ts
Normal file
161
web/src/composables/useMicrophone.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
let instance: ReturnType<typeof useMicrophone> | null = null
|
||||||
|
export function getMicrophone() {
|
||||||
|
if (!instance) instance = useMicrophone()
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_BASE = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws/uac-audio`
|
||||||
|
|
||||||
|
// Opus: 48kHz stereo, 64kbps → ~8 KB/s vs raw PCM 192 KB/s (24× reduction)
|
||||||
|
const OPUS_CONFIG: AudioEncoderConfig = {
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfChannels: 2,
|
||||||
|
bitrate: 64000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15-byte binary header matching server-side UAC_AUDIO_HEADER_SIZE
|
||||||
|
function buildHeader(msgType: number, durationMs: number, dataLen: number): Uint8Array {
|
||||||
|
const h = new Uint8Array(15)
|
||||||
|
const v = new DataView(h.buffer)
|
||||||
|
v.setUint8(0, msgType) // 0x03 = Opus
|
||||||
|
v.setUint32(1, 0, true) // timestamp (unused)
|
||||||
|
v.setUint16(5, durationMs, true)
|
||||||
|
v.setUint32(7, 0, true) // sequence
|
||||||
|
v.setUint32(11, dataLen, true)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMicrophone() {
|
||||||
|
const active = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let stream: MediaStream | null = null
|
||||||
|
let encoder: AudioEncoder | null = null
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
let frameCount = 0
|
||||||
|
let byteCount = 0
|
||||||
|
|
||||||
|
// ── AudioEncoder helper ─────────────────────────────────
|
||||||
|
function createEncoder(onOpusFrame: (data: Uint8Array, durMs: number) => void): AudioEncoder {
|
||||||
|
const enc = new AudioEncoder({
|
||||||
|
output: (chunk: EncodedAudioChunk) => {
|
||||||
|
const buf = new Uint8Array(chunk.byteLength)
|
||||||
|
chunk.copyTo(buf)
|
||||||
|
// Opus frame duration in microseconds → milliseconds
|
||||||
|
const durMs = Math.round(chunk.duration! / 1000)
|
||||||
|
onOpusFrame(buf, durMs)
|
||||||
|
},
|
||||||
|
error: (e: Error) => console.error('[mic] encoder error:', e),
|
||||||
|
})
|
||||||
|
enc.configure(OPUS_CONFIG)
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── start / stop ────────────────────────────────────────
|
||||||
|
async function start() {
|
||||||
|
error.value = null
|
||||||
|
frameCount = 0
|
||||||
|
byteCount = 0
|
||||||
|
running = true
|
||||||
|
console.log('[mic] starting...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// WebSocket
|
||||||
|
ws = new WebSocket(WS_BASE)
|
||||||
|
ws.binaryType = 'arraybuffer'
|
||||||
|
const wsReady = new Promise<void>((resolve, reject) => {
|
||||||
|
ws!.onopen = () => { console.log('[mic] WS opened'); active.value = true; resolve() }
|
||||||
|
ws!.onerror = (ev) => { console.error('[mic] WS error:', ev); reject(new Error('WebSocket failed')) }
|
||||||
|
})
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
console.log('[mic] WS closed: code=%d frames=%d bytes=%d', ev.code, frameCount, byteCount)
|
||||||
|
active.value = false
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microphone
|
||||||
|
console.log('[mic] getUserMedia...')
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { sampleRate: 48000, channelCount: 2, echoCancellation: false, noiseSuppression: false }
|
||||||
|
})
|
||||||
|
// AudioEncoder (WebCodecs) for Opus compression
|
||||||
|
encoder = createEncoder((opusData, durMs) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
const header = buildHeader(0x03, durMs, opusData.length)
|
||||||
|
const msg = new Uint8Array(15 + opusData.length)
|
||||||
|
msg.set(header)
|
||||||
|
msg.set(opusData, 15)
|
||||||
|
ws.send(msg)
|
||||||
|
frameCount++
|
||||||
|
byteCount += msg.byteLength
|
||||||
|
if (frameCount % 50 === 0) {
|
||||||
|
console.debug('[mic] frame #%d: opus=%dB dur=%dms',
|
||||||
|
frameCount, opusData.length, durMs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await wsReady
|
||||||
|
|
||||||
|
// ScriptProcessor → S16LE PCM → AudioData → AudioEncoder → Opus
|
||||||
|
const audioCtx = new AudioContext({ sampleRate: 48000 })
|
||||||
|
const source = audioCtx.createMediaStreamSource(stream)
|
||||||
|
const processor = audioCtx.createScriptProcessor(4096, 2, 2)
|
||||||
|
source.connect(processor)
|
||||||
|
processor.connect(audioCtx.destination)
|
||||||
|
|
||||||
|
processor.onaudioprocess = (e: AudioProcessingEvent) => {
|
||||||
|
if (!running || !encoder || encoder.state !== 'configured') return
|
||||||
|
if (!e.inputBuffer) return
|
||||||
|
const buf = e.inputBuffer as any
|
||||||
|
const left = buf.getChannelData(0) as Float32Array
|
||||||
|
const right = buf.getChannelData(1) as Float32Array
|
||||||
|
const samples = left.length
|
||||||
|
|
||||||
|
// Float32 → S16LE interleaved
|
||||||
|
const pcm = new Int16Array(samples * 2)
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
pcm[i * 2] = Math.max(-32768, Math.min(32767, Math.round((left[i] ?? 0) * 32767)))
|
||||||
|
pcm[i * 2 + 1] = Math.max(-32768, Math.min(32767, Math.round((right[i] ?? 0) * 32767)))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioData = new AudioData({
|
||||||
|
format: 's16',
|
||||||
|
sampleRate: 48000,
|
||||||
|
numberOfFrames: samples,
|
||||||
|
numberOfChannels: 2,
|
||||||
|
timestamp: 0,
|
||||||
|
data: pcm.buffer,
|
||||||
|
})
|
||||||
|
encoder.encode(audioData)
|
||||||
|
audioData.close()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[mic] AudioData/encode error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[mic] start error:', e)
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to start microphone'
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
console.log('[mic] stop: frames=%d bytes=%d', frameCount, byteCount)
|
||||||
|
running = false
|
||||||
|
if (encoder) { encoder.close(); encoder = null }
|
||||||
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
|
||||||
|
if (ws) { ws.close(); ws = null }
|
||||||
|
active.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (active.value) { stop() } else { start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { active, error, start, stop, toggle }
|
||||||
|
}
|
||||||
@@ -654,6 +654,8 @@ export default {
|
|||||||
otgNetworkInterface: 'Bridge Interface',
|
otgNetworkInterface: 'Bridge Interface',
|
||||||
otgNetworkNone: 'None',
|
otgNetworkNone: 'None',
|
||||||
otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces',
|
otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces',
|
||||||
|
uacMic: 'USB Microphone',
|
||||||
|
uacMicDesc: 'Creates a virtual USB microphone on the target machine. Audio from your browser is streamed to the target.',
|
||||||
otgDescriptor: 'USB Device Descriptor',
|
otgDescriptor: 'USB Device Descriptor',
|
||||||
vendorId: 'Vendor ID (VID)',
|
vendorId: 'Vendor ID (VID)',
|
||||||
productId: 'Product ID (PID)',
|
productId: 'Product ID (PID)',
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export default {
|
|||||||
},
|
},
|
||||||
actionbar: {
|
actionbar: {
|
||||||
paste: '粘贴文本',
|
paste: '粘贴文本',
|
||||||
virtualMedia: '虚拟媒体',
|
micStart: '开始传声',
|
||||||
|
micStop: '停止传声',
|
||||||
virtualMediaTip: '管理虚拟媒体设备',
|
virtualMediaTip: '管理虚拟媒体设备',
|
||||||
power: '电源',
|
power: '电源',
|
||||||
keyboard: '虚拟键盘',
|
keyboard: '虚拟键盘',
|
||||||
@@ -653,6 +654,8 @@ export default {
|
|||||||
otgNetworkInterface: '桥接网卡',
|
otgNetworkInterface: '桥接网卡',
|
||||||
otgNetworkNone: '无',
|
otgNetworkNone: '无',
|
||||||
otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表',
|
otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表',
|
||||||
|
uacMic: 'USB 麦克风',
|
||||||
|
uacMicDesc: '启用后目标机将看到一个 USB 麦克风设备,音频从浏览器传入',
|
||||||
otgDescriptor: 'USB 设备描述符',
|
otgDescriptor: 'USB 设备描述符',
|
||||||
vendorId: '厂商 ID (VID)',
|
vendorId: '厂商 ID (VID)',
|
||||||
productId: '产品 ID (PID)',
|
productId: '产品 ID (PID)',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composabl
|
|||||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||||
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi } from '@/api'
|
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
|
||||||
import type { ComputerUseScreenshot, ComputerUseSession } from '@/api'
|
import type { ComputerUseScreenshot, ComputerUseSession } from '@/api'
|
||||||
import { CanonicalKey, HidBackend } from '@/types/generated'
|
import { CanonicalKey, HidBackend } from '@/types/generated'
|
||||||
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
||||||
@@ -2961,7 +2961,15 @@ function handleToggleMouseMode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uacEnabled = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Check if UAC is enabled (show mic button only if USB mic is available)
|
||||||
|
try {
|
||||||
|
const uacCfg = await uacApi.get()
|
||||||
|
uacEnabled.value = uacCfg.enabled
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
consoleEvents.subscribe()
|
consoleEvents.subscribe()
|
||||||
|
|
||||||
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
|
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
|
||||||
@@ -3166,6 +3174,7 @@ onUnmounted(() => {
|
|||||||
:show-terminal="showTerminal"
|
:show-terminal="showTerminal"
|
||||||
:show-computer-use="showComputerUse"
|
:show-computer-use="showComputerUse"
|
||||||
:show-paste-text="showPasteText"
|
:show-paste-text="showPasteText"
|
||||||
|
:show-mic="uacEnabled"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
@toggle-stats="openStatsSheet"
|
@toggle-stats="openStatsSheet"
|
||||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
authApi,
|
authApi,
|
||||||
configApi,
|
configApi,
|
||||||
otgNetworkApi,
|
otgNetworkApi,
|
||||||
|
uacApi,
|
||||||
hidApi,
|
hidApi,
|
||||||
streamApi,
|
streamApi,
|
||||||
atxConfigApi,
|
atxConfigApi,
|
||||||
@@ -682,6 +683,7 @@ const config = ref({
|
|||||||
turn_server: '',
|
turn_server: '',
|
||||||
turn_username: '',
|
turn_username: '',
|
||||||
turn_password: '',
|
turn_password: '',
|
||||||
|
uac_enabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const otgNetworkInterfaces = ref<NetworkInterfaceInfo[]>([])
|
const otgNetworkInterfaces = ref<NetworkInterfaceInfo[]>([])
|
||||||
@@ -1477,6 +1479,12 @@ async function saveConfig() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
otgNetworkStatus.value = response.status
|
otgNetworkStatus.value = response.status
|
||||||
|
|
||||||
|
await uacApi.update({
|
||||||
|
enabled: otgEnabled && config.value.uac_enabled,
|
||||||
|
sample_rate: 48000,
|
||||||
|
channels: 2,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value !== 'hid') {
|
if (activeSection.value !== 'hid') {
|
||||||
@@ -1497,7 +1505,7 @@ async function saveConfig() {
|
|||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const [video, stream, hid, msd, otgNetwork] = await Promise.all([
|
const [video, stream, hid, msd, otgNetwork, uac] = await Promise.all([
|
||||||
configStore.refreshVideo(),
|
configStore.refreshVideo(),
|
||||||
configStore.refreshStream(),
|
configStore.refreshStream(),
|
||||||
configStore.refreshHid(),
|
configStore.refreshHid(),
|
||||||
@@ -1509,6 +1517,7 @@ async function loadConfig() {
|
|||||||
host_mac: '',
|
host_mac: '',
|
||||||
device_mac: '',
|
device_mac: '',
|
||||||
})),
|
})),
|
||||||
|
uacApi.get().catch(() => ({ enabled: false, sample_rate: 48000, channels: 2 })),
|
||||||
])
|
])
|
||||||
|
|
||||||
config.value = {
|
config.value = {
|
||||||
@@ -1534,6 +1543,7 @@ async function loadConfig() {
|
|||||||
msd_dir: msd.msd_dir || '',
|
msd_dir: msd.msd_dir || '',
|
||||||
otg_network_enabled: otgNetwork.enabled,
|
otg_network_enabled: otgNetwork.enabled,
|
||||||
otg_network_driver: otgNetwork.driver_mode,
|
otg_network_driver: otgNetwork.driver_mode,
|
||||||
|
uac_enabled: uac.enabled,
|
||||||
otg_network_interface: otgNetwork.bridge_interface,
|
otg_network_interface: otgNetwork.bridge_interface,
|
||||||
encoder_backend: stream.encoder || 'auto',
|
encoder_backend: stream.encoder || 'auto',
|
||||||
stun_server: stream.stun_server || '',
|
stun_server: stream.stun_server || '',
|
||||||
@@ -3364,6 +3374,15 @@ watch(isWindows, () => {
|
|||||||
{{ t('settings.otgRuntimeDegraded') }}: {{ otgNetworkStatus.error || t('common.error') }}
|
{{ t('settings.otgRuntimeDegraded') }}: {{ otgNetworkStatus.error || t('common.error') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>{{ t('settings.uacMic') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.uacMicDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.uac_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-warning">
|
<p class="text-xs text-warning">
|
||||||
{{ t('settings.otgProfileWarning') }}
|
{{ t('settings.otgProfileWarning') }}
|
||||||
|
|||||||
Reference in New Issue
Block a user