mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
feat: 完善 OTG UAC 音频支持
This commit is contained in:
@@ -1,3 +1,15 @@
|
|||||||
|
//! Platform-neutral capture lifecycle and PCM frame types.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use tokio::sync::{broadcast, watch, Mutex};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[path = "capture_linux.rs"]
|
#[path = "capture_linux.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
@@ -6,4 +18,146 @@ mod imp;
|
|||||||
#[path = "capture_windows.rs"]
|
#[path = "capture_windows.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|
||||||
pub use imp::*;
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
pub device_name: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u32,
|
||||||
|
pub buffer_frames: u32,
|
||||||
|
pub period_frames: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device_name: String::new(),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
buffer_frames: 4096,
|
||||||
|
period_frames: 960,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioFrame {
|
||||||
|
pub data: Bytes,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFrame {
|
||||||
|
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CaptureState {
|
||||||
|
Stopped,
|
||||||
|
Starting,
|
||||||
|
Running,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioCapturer {
|
||||||
|
config: AudioConfig,
|
||||||
|
state: watch::Sender<CaptureState>,
|
||||||
|
state_rx: watch::Receiver<CaptureState>,
|
||||||
|
frame_tx: broadcast::Sender<AudioFrame>,
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
lifecycle: Mutex<()>,
|
||||||
|
log_throttler: LogThrottler,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioCapturer {
|
||||||
|
pub fn new(config: AudioConfig) -> Self {
|
||||||
|
let (state, state_rx) = watch::channel(CaptureState::Stopped);
|
||||||
|
let (frame_tx, _) = broadcast::channel(16);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
state_rx,
|
||||||
|
frame_tx,
|
||||||
|
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
task: Mutex::new(None),
|
||||||
|
lifecycle: Mutex::new(()),
|
||||||
|
log_throttler: LogThrottler::with_secs(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> CaptureState {
|
||||||
|
*self.state_rx.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
||||||
|
self.state_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
||||||
|
self.frame_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<()> {
|
||||||
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
|
if matches!(self.state(), CaptureState::Starting | CaptureState::Running) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(previous) = self.task.lock().await.take() {
|
||||||
|
let _ = previous.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Starting audio capture on {} at {}Hz {}ch",
|
||||||
|
self.config.device_name, self.config.sample_rate, self.config.channels
|
||||||
|
);
|
||||||
|
|
||||||
|
self.stop_flag.store(false, Ordering::Release);
|
||||||
|
let _ = self.state.send(CaptureState::Starting);
|
||||||
|
|
||||||
|
let config = self.config.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
let frame_tx = self.frame_tx.clone();
|
||||||
|
let stop_flag = Arc::clone(&self.stop_flag);
|
||||||
|
let log_throttler = self.log_throttler.clone();
|
||||||
|
|
||||||
|
let task = tokio::task::spawn_blocking(move || {
|
||||||
|
match imp::run_capture(&config, &state, &frame_tx, &stop_flag, &log_throttler) {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = state.send(CaptureState::Stopped);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
crate::error_throttled!(
|
||||||
|
log_throttler,
|
||||||
|
"capture_error",
|
||||||
|
"Audio capture error: {}",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
let _ = state.send(CaptureState::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*self.task.lock().await = Some(task);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
|
self.stop_flag.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
if let Some(task) = self.task.lock().await.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.state.send(CaptureState::Stopped);
|
||||||
|
info!("Audio capture stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,271 +1,60 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
|
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
|
||||||
use alsa::{Direction, ValueOr, PCM};
|
use alsa::{Direction, ValueOr, PCM};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use tokio::sync::{broadcast, watch};
|
||||||
use std::sync::Arc;
|
use tracing::debug;
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::{broadcast, watch, Mutex};
|
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
use crate::audio::device::AudioDeviceInfo;
|
use super::{AudioConfig, AudioFrame, CaptureState};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
use crate::{error_throttled, warn_throttled};
|
use crate::warn_throttled;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
const RETRY_DELAY: Duration = Duration::from_millis(5);
|
||||||
pub struct AudioConfig {
|
const MAX_CONSECUTIVE_READ_ERRORS: u32 = 10;
|
||||||
pub device_name: String,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub frame_size: u32,
|
|
||||||
pub buffer_frames: u32,
|
|
||||||
pub period_frames: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
pub(super) fn run_capture(
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: String::new(),
|
|
||||||
sample_rate: 48000,
|
|
||||||
channels: 2,
|
|
||||||
frame_size: 960,
|
|
||||||
buffer_frames: 4096,
|
|
||||||
period_frames: 960,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioConfig {
|
|
||||||
pub fn for_device(device: &AudioDeviceInfo) -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: device.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_sample(&self) -> u32 {
|
|
||||||
2 * self.channels
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_frame(&self) -> usize {
|
|
||||||
(self.frame_size * self.bytes_per_sample()) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AudioFrame {
|
|
||||||
pub data: Bytes,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub samples: u32,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub timestamp: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame {
|
|
||||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
|
||||||
let bps = 2 * channels;
|
|
||||||
Self {
|
|
||||||
samples: data.len() as u32 / bps,
|
|
||||||
data,
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
sequence,
|
|
||||||
timestamp: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CaptureState {
|
|
||||||
Stopped,
|
|
||||||
Running,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioCapturer {
|
|
||||||
config: AudioConfig,
|
|
||||||
state: Arc<watch::Sender<CaptureState>>,
|
|
||||||
state_rx: watch::Receiver<CaptureState>,
|
|
||||||
frame_tx: broadcast::Sender<AudioFrame>,
|
|
||||||
stop_flag: Arc<AtomicBool>,
|
|
||||||
sequence: Arc<AtomicU64>,
|
|
||||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
|
||||||
log_throttler: LogThrottler,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioCapturer {
|
|
||||||
pub fn new(config: AudioConfig) -> Self {
|
|
||||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
|
||||||
let (frame_tx, _) = broadcast::channel(16);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
state: Arc::new(state_tx),
|
|
||||||
state_rx,
|
|
||||||
frame_tx,
|
|
||||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
|
||||||
sequence: Arc::new(AtomicU64::new(0)),
|
|
||||||
capture_handle: Mutex::new(None),
|
|
||||||
log_throttler: LogThrottler::with_secs(5),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state(&self) -> CaptureState {
|
|
||||||
*self.state_rx.borrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
|
||||||
self.state_rx.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
|
||||||
self.frame_tx.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
|
||||||
if self.state() == CaptureState::Running {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Starting audio capture on {} at {}Hz {}ch",
|
|
||||||
self.config.device_name, self.config.sample_rate, self.config.channels
|
|
||||||
);
|
|
||||||
|
|
||||||
self.stop_flag.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let config = self.config.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let frame_tx = self.frame_tx.clone();
|
|
||||||
let stop_flag = self.stop_flag.clone();
|
|
||||||
let sequence = self.sequence.clone();
|
|
||||||
let log_throttler = self.log_throttler.clone();
|
|
||||||
|
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
|
||||||
let result = run_capture(
|
|
||||||
&config,
|
|
||||||
&state,
|
|
||||||
&frame_tx,
|
|
||||||
&stop_flag,
|
|
||||||
&sequence,
|
|
||||||
&log_throttler,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
|
|
||||||
let _ = state.send(CaptureState::Error);
|
|
||||||
} else {
|
|
||||||
let _ = state.send(CaptureState::Stopped);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
*self.capture_handle.lock().await = Some(handle);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
|
||||||
info!("Stopping audio capture");
|
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
|
||||||
let _ = handle.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.state.send(CaptureState::Stopped);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
self.state() == CaptureState::Running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_capture(
|
|
||||||
config: &AudioConfig,
|
config: &AudioConfig,
|
||||||
state: &watch::Sender<CaptureState>,
|
state: &watch::Sender<CaptureState>,
|
||||||
frame_tx: &broadcast::Sender<AudioFrame>,
|
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||||
stop_flag: &AtomicBool,
|
stop_flag: &AtomicBool,
|
||||||
sequence: &AtomicU64,
|
|
||||||
log_throttler: &LogThrottler,
|
log_throttler: &LogThrottler,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
|
// Non-blocking mode guarantees that stop() can always join the worker.
|
||||||
|
let pcm = PCM::new(&config.device_name, Direction::Capture, true).map_err(|error| {
|
||||||
AppError::AudioError(format!(
|
AppError::AudioError(format!(
|
||||||
"Failed to open audio device {}: {}",
|
"Failed to open audio device {}: {}",
|
||||||
config.device_name, e
|
config.device_name, error
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
{
|
configure_pcm(&pcm, config)?;
|
||||||
let hwp = HwParams::any(&pcm)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_channels(config.channels)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_format(Format::s16())
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_access(Access::RWInterleaved)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_buffer_size_near(config.buffer_frames as Frames)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
|
||||||
|
|
||||||
pcm.hw_params(&hwp)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hw_now = pcm.hw_params_current().map_err(|e| {
|
|
||||||
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
|
|
||||||
})?;
|
|
||||||
let actual_rate = hw_now
|
|
||||||
.get_rate()
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
|
|
||||||
let actual_ch = hw_now
|
|
||||||
.get_channels()
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
|
|
||||||
if actual_rate != 48_000 {
|
|
||||||
return Err(AppError::AudioError(format!(
|
|
||||||
"Audio capture requires 48000 Hz; device is {} Hz",
|
|
||||||
actual_rate
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if actual_ch != 2 {
|
|
||||||
return Err(AppError::AudioError(format!(
|
|
||||||
"Audio capture requires 2 channels (stereo); device has {}",
|
|
||||||
actual_ch
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
debug!("Audio capture: 48000 Hz, 2 ch");
|
|
||||||
|
|
||||||
pcm.prepare()
|
pcm.prepare()
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
|
.map_err(|error| AppError::AudioError(format!("Failed to prepare PCM: {error}")))?;
|
||||||
|
|
||||||
let _ = state.send(CaptureState::Running);
|
let _ = state.send(CaptureState::Running);
|
||||||
|
|
||||||
let period_frames = pcm
|
let period_frames = pcm
|
||||||
.hw_params_current()
|
.hw_params_current()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|h| h.get_period_size().ok())
|
.and_then(|params| params.get_period_size().ok())
|
||||||
.map(|f| f as usize)
|
.map(|frames| frames as usize)
|
||||||
.unwrap_or(1024)
|
.unwrap_or(config.period_frames as usize)
|
||||||
.max(256);
|
.max(256);
|
||||||
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
let mut buffer = vec![0u8; period_frames * config.channels as usize * 2];
|
||||||
let bytes_per_frame = (config.channels as usize) * 2;
|
let io: IO<u8> = pcm.io_bytes();
|
||||||
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
|
let mut consecutive_errors = 0;
|
||||||
|
|
||||||
while !stop_flag.load(Ordering::Relaxed) {
|
while !stop_flag.load(Ordering::Acquire) {
|
||||||
match pcm.state() {
|
match pcm.state() {
|
||||||
State::XRun => {
|
State::XRun => {
|
||||||
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
|
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
|
||||||
let _ = pcm.prepare();
|
pcm.prepare().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to recover audio xrun: {error}"))
|
||||||
|
})?;
|
||||||
|
consecutive_errors = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
State::Suspended => {
|
State::Suspended => {
|
||||||
@@ -274,61 +63,95 @@ fn run_capture(
|
|||||||
"suspended",
|
"suspended",
|
||||||
"Audio device suspended, recovering"
|
"Audio device suspended, recovering"
|
||||||
);
|
);
|
||||||
let _ = pcm.resume();
|
if pcm.resume().is_err() {
|
||||||
|
pcm.prepare().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to resume audio capture: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
consecutive_errors = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// io_bytes: USB capture often lacks mmap (io_checked requires it).
|
|
||||||
let io: IO<u8> = pcm.io_bytes();
|
|
||||||
|
|
||||||
match io.readi(&mut buffer) {
|
match io.readi(&mut buffer) {
|
||||||
|
Ok(0) => thread::sleep(RETRY_DELAY),
|
||||||
Ok(frames_read) => {
|
Ok(frames_read) => {
|
||||||
if frames_read == 0 {
|
consecutive_errors = 0;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let byte_count = frames_read * config.channels as usize * 2;
|
let byte_count = frames_read * config.channels as usize * 2;
|
||||||
|
|
||||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let frame = AudioFrame::new_interleaved(
|
let frame = AudioFrame::new_interleaved(
|
||||||
Bytes::copy_from_slice(&buffer[..byte_count]),
|
Bytes::copy_from_slice(&buffer[..byte_count]),
|
||||||
config.channels,
|
config.channels,
|
||||||
48_000,
|
config.sample_rate,
|
||||||
seq,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if frame_tx.receiver_count() > 0 {
|
if frame_tx.receiver_count() > 0 {
|
||||||
if let Err(e) = frame_tx.send(frame) {
|
let _ = frame_tx.send(frame);
|
||||||
debug!("No audio receivers: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(error) if error.errno() == libc::EAGAIN => thread::sleep(RETRY_DELAY),
|
||||||
Err(e) => {
|
Err(error) if is_device_lost_errno(error.errno()) => {
|
||||||
let desc = e.to_string();
|
|
||||||
if is_device_lost_error(&desc) {
|
|
||||||
return Err(AppError::AudioError(format!(
|
return Err(AppError::AudioError(format!(
|
||||||
"Audio device lost while reading {}: {}",
|
"Audio device lost while reading {}: {}",
|
||||||
config.device_name, e
|
config.device_name, error
|
||||||
)));
|
)));
|
||||||
} else if desc.contains("EPIPE") || desc.contains("Broken pipe") {
|
|
||||||
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
|
|
||||||
let _ = pcm.prepare();
|
|
||||||
} else {
|
|
||||||
error_throttled!(log_throttler, "read_error", "Audio read error: {}", e);
|
|
||||||
}
|
}
|
||||||
|
Err(error) if error.errno() == libc::EPIPE => {
|
||||||
|
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
|
||||||
|
pcm.prepare().map_err(|prepare_error| {
|
||||||
|
AppError::AudioError(format!(
|
||||||
|
"Failed to recover after audio overrun ({error}): {prepare_error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
consecutive_errors = 0;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
consecutive_errors += 1;
|
||||||
|
warn_throttled!(log_throttler, "read_error", "Audio read error: {}", error);
|
||||||
|
if consecutive_errors >= MAX_CONSECUTIVE_READ_ERRORS {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Audio capture failed {consecutive_errors} times consecutively: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
thread::sleep(RETRY_DELAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Audio capture stopped");
|
debug!("ALSA capture worker stopped");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_device_lost_error(desc: &str) -> bool {
|
fn configure_pcm(pcm: &PCM, config: &AudioConfig) -> Result<()> {
|
||||||
desc.contains("No such device")
|
let params = HwParams::any(pcm)
|
||||||
|| desc.contains("ENODEV")
|
.map_err(|error| AppError::AudioError(format!("Failed to get HwParams: {error}")))?;
|
||||||
|| desc.contains("ENXIO")
|
params
|
||||||
|| desc.contains("ESHUTDOWN")
|
.set_channels(config.channels)
|
||||||
|
.and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest))
|
||||||
|
.and_then(|_| params.set_format(Format::s16()))
|
||||||
|
.and_then(|_| params.set_access(Access::RWInterleaved))
|
||||||
|
.and_then(|_| params.set_buffer_size_near(config.buffer_frames as Frames))
|
||||||
|
.and_then(|_| params.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest))
|
||||||
|
.and_then(|_| pcm.hw_params(¶ms))
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to configure audio PCM: {error}")))?;
|
||||||
|
|
||||||
|
let actual = pcm
|
||||||
|
.hw_params_current()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to read PCM parameters: {error}")))?;
|
||||||
|
let actual_rate = actual
|
||||||
|
.get_rate()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to read sample rate: {error}")))?;
|
||||||
|
let actual_channels = actual
|
||||||
|
.get_channels()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to read channels: {error}")))?;
|
||||||
|
if actual_rate != config.sample_rate || actual_channels != config.channels {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Audio device negotiated {actual_rate} Hz/{actual_channels} ch; expected {} Hz/{} ch",
|
||||||
|
config.sample_rate, config.channels
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_device_lost_errno(errno: i32) -> bool {
|
||||||
|
matches!(errno, libc::ENODEV | libc::ENXIO | libc::ESHUTDOWN)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,198 +1,23 @@
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||||
use cpal::{BufferSize, SampleFormat, StreamConfig};
|
use cpal::{BufferSize, SampleFormat, StreamConfig};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
use tokio::sync::{broadcast, watch, Mutex};
|
use tokio::sync::{broadcast, watch};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::audio::device::{find_wasapi_device, AudioDeviceInfo};
|
use super::{AudioConfig, AudioFrame, CaptureState};
|
||||||
|
use crate::audio::device::find_wasapi_device;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::error_throttled;
|
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub(super) fn run_capture(
|
||||||
pub struct AudioConfig {
|
|
||||||
pub device_name: String,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub frame_size: u32,
|
|
||||||
pub buffer_frames: u32,
|
|
||||||
pub period_frames: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: String::new(),
|
|
||||||
sample_rate: 48000,
|
|
||||||
channels: 2,
|
|
||||||
frame_size: 960,
|
|
||||||
buffer_frames: 4096,
|
|
||||||
period_frames: 960,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioConfig {
|
|
||||||
pub fn for_device(device: &AudioDeviceInfo) -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: device.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_sample(&self) -> u32 {
|
|
||||||
2 * self.channels
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_frame(&self) -> usize {
|
|
||||||
(self.frame_size * self.bytes_per_sample()) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AudioFrame {
|
|
||||||
pub data: Bytes,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub samples: u32,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub timestamp: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame {
|
|
||||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
|
||||||
let bps = 2 * channels;
|
|
||||||
Self {
|
|
||||||
samples: data.len() as u32 / bps,
|
|
||||||
data,
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
sequence,
|
|
||||||
timestamp: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CaptureState {
|
|
||||||
Stopped,
|
|
||||||
Running,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioCapturer {
|
|
||||||
config: AudioConfig,
|
|
||||||
state: Arc<watch::Sender<CaptureState>>,
|
|
||||||
state_rx: watch::Receiver<CaptureState>,
|
|
||||||
frame_tx: broadcast::Sender<AudioFrame>,
|
|
||||||
stop_flag: Arc<AtomicBool>,
|
|
||||||
sequence: Arc<AtomicU64>,
|
|
||||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
|
||||||
log_throttler: LogThrottler,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioCapturer {
|
|
||||||
pub fn new(config: AudioConfig) -> Self {
|
|
||||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
|
||||||
let (frame_tx, _) = broadcast::channel(16);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
state: Arc::new(state_tx),
|
|
||||||
state_rx,
|
|
||||||
frame_tx,
|
|
||||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
|
||||||
sequence: Arc::new(AtomicU64::new(0)),
|
|
||||||
capture_handle: Mutex::new(None),
|
|
||||||
log_throttler: LogThrottler::with_secs(5),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state(&self) -> CaptureState {
|
|
||||||
*self.state_rx.borrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
|
||||||
self.state_rx.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
|
||||||
self.frame_tx.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
|
||||||
if self.state() == CaptureState::Running {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Starting WASAPI audio capture on {} at {}Hz {}ch",
|
|
||||||
self.config.device_name, self.config.sample_rate, self.config.channels
|
|
||||||
);
|
|
||||||
|
|
||||||
self.stop_flag.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let config = self.config.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let frame_tx = self.frame_tx.clone();
|
|
||||||
let stop_flag = self.stop_flag.clone();
|
|
||||||
let sequence = self.sequence.clone();
|
|
||||||
let log_throttler = self.log_throttler.clone();
|
|
||||||
|
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
|
||||||
let result = run_capture(
|
|
||||||
&config,
|
|
||||||
&state,
|
|
||||||
&frame_tx,
|
|
||||||
&stop_flag,
|
|
||||||
&sequence,
|
|
||||||
&log_throttler,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error_throttled!(
|
|
||||||
log_throttler,
|
|
||||||
"capture_error",
|
|
||||||
"WASAPI audio capture error: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
let _ = state.send(CaptureState::Error);
|
|
||||||
} else {
|
|
||||||
let _ = state.send(CaptureState::Stopped);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
*self.capture_handle.lock().await = Some(handle);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
|
||||||
info!("Stopping WASAPI audio capture");
|
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
|
||||||
let _ = handle.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.state.send(CaptureState::Stopped);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
self.state() == CaptureState::Running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_capture(
|
|
||||||
config: &AudioConfig,
|
config: &AudioConfig,
|
||||||
state: &watch::Sender<CaptureState>,
|
state: &watch::Sender<CaptureState>,
|
||||||
frame_tx: &broadcast::Sender<AudioFrame>,
|
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||||
stop_flag: &AtomicBool,
|
stop_flag: &AtomicBool,
|
||||||
sequence: &AtomicU64,
|
|
||||||
log_throttler: &LogThrottler,
|
log_throttler: &LogThrottler,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let device = find_wasapi_device(&config.device_name)?;
|
let device = find_wasapi_device(&config.device_name)?;
|
||||||
@@ -272,12 +97,10 @@ fn run_capture(
|
|||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let frame = AudioFrame::new_interleaved(
|
let frame = AudioFrame::new_interleaved(
|
||||||
Bytes::copy_from_slice(bytemuck::cast_slice(&samples)),
|
Bytes::copy_from_slice(bytemuck::cast_slice(&samples)),
|
||||||
2,
|
2,
|
||||||
48_000,
|
48_000,
|
||||||
seq,
|
|
||||||
);
|
);
|
||||||
if frame_tx.receiver_count() > 0 {
|
if frame_tx.receiver_count() > 0 {
|
||||||
if let Err(e) = frame_tx.send(frame) {
|
if let Err(e) = frame_tx.send(frame) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Device selection, quality presets, streaming.
|
//! Device selection, quality presets, streaming.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use super::capture::AudioConfig;
|
use super::capture::AudioConfig;
|
||||||
@@ -22,23 +21,37 @@ pub(super) type AudioRecoveredCallback = Arc<dyn Fn() + Send + Sync>;
|
|||||||
pub struct AudioController {
|
pub struct AudioController {
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
config: Arc<RwLock<AudioControllerConfig>>,
|
||||||
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
||||||
devices: Arc<RwLock<Vec<AudioDeviceInfo>>>,
|
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
monitor: Arc<AudioHealthMonitor>,
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
recovery: recovery::AudioRecovery,
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
||||||
|
operation: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioController {
|
impl AudioController {
|
||||||
pub fn new(config: AudioControllerConfig) -> Self {
|
pub fn new(config: AudioControllerConfig) -> Self {
|
||||||
|
let config = Arc::new(RwLock::new(config));
|
||||||
|
let streamer = Arc::new(RwLock::new(None));
|
||||||
|
let event_bus = Arc::new(RwLock::new(None));
|
||||||
|
let monitor = Arc::new(AudioHealthMonitor::new());
|
||||||
|
let recovered_callback = Arc::new(RwLock::new(None));
|
||||||
|
let operation = Arc::new(Mutex::new(()));
|
||||||
|
let recovery = recovery::AudioRecovery::new(
|
||||||
|
config.clone(),
|
||||||
|
streamer.clone(),
|
||||||
|
event_bus.clone(),
|
||||||
|
monitor.clone(),
|
||||||
|
recovered_callback.clone(),
|
||||||
|
operation.clone(),
|
||||||
|
);
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(RwLock::new(config)),
|
config,
|
||||||
streamer: Arc::new(RwLock::new(None)),
|
streamer,
|
||||||
devices: Arc::new(RwLock::new(Vec::new())),
|
event_bus,
|
||||||
event_bus: Arc::new(RwLock::new(None)),
|
monitor,
|
||||||
monitor: Arc::new(AudioHealthMonitor::new()),
|
recovery,
|
||||||
recovery_in_progress: Arc::new(AtomicBool::new(false)),
|
recovered_callback,
|
||||||
recovered_callback: Arc::new(RwLock::new(None)),
|
operation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,31 +68,6 @@ impl AudioController {
|
|||||||
bus.mark_device_info_dirty();
|
bus.mark_device_info_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn spawn_recovery_task(&self, lost_device: String, reason: String) {
|
|
||||||
recovery::spawn_recovery_task(
|
|
||||||
self.config.clone(),
|
|
||||||
self.streamer.clone(),
|
|
||||||
self.event_bus.clone(),
|
|
||||||
self.monitor.clone(),
|
|
||||||
self.recovery_in_progress.clone(),
|
|
||||||
self.recovered_callback.clone(),
|
|
||||||
lost_device,
|
|
||||||
reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_stream_monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
|
|
||||||
recovery::spawn_stream_monitor(
|
|
||||||
self.config.clone(),
|
|
||||||
self.streamer.clone(),
|
|
||||||
self.event_bus.clone(),
|
|
||||||
self.monitor.clone(),
|
|
||||||
self.recovery_in_progress.clone(),
|
|
||||||
self.recovered_callback.clone(),
|
|
||||||
streamer,
|
|
||||||
device,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
|
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
let current_device = if self.is_streaming().await {
|
let current_device = if self.is_streaming().await {
|
||||||
@@ -88,26 +76,19 @@ impl AudioController {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let devices = enumerate_audio_devices_with_current(current_device.as_deref())?;
|
enumerate_audio_devices_with_current(current_device.as_deref())
|
||||||
*self.devices.write().await = devices.clone();
|
|
||||||
Ok(devices)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_cached_devices(&self) -> Vec<AudioDeviceInfo> {
|
|
||||||
self.devices.read().await.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_device(&self, device: &str) -> Result<()> {
|
pub async fn select_device(&self, device: &str) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
let devices = self.list_devices().await?;
|
let devices = self.list_devices().await?;
|
||||||
let found = devices
|
let found = devices
|
||||||
.iter()
|
.iter()
|
||||||
.any(|d| d.name == device || d.description.contains(device));
|
.any(|d| d.name == device || d.description.contains(device));
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
return Err(AppError::AudioError(format!(
|
return Err(AppError::NotFound(format!("audio device {device}")));
|
||||||
"Audio device not found: {}",
|
|
||||||
device
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -118,14 +99,15 @@ impl AudioController {
|
|||||||
info!("Audio device selected: {}", device);
|
info!("Audio device selected: {}", device);
|
||||||
|
|
||||||
if self.is_streaming().await {
|
if self.is_streaming().await {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming_inner().await?;
|
||||||
self.start_streaming().await?;
|
self.start_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
|
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
{
|
{
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.quality = quality;
|
config.quality = quality;
|
||||||
@@ -144,6 +126,12 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_streaming(&self) -> Result<()> {
|
pub async fn start_streaming(&self) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
|
self.start_streaming_inner().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_streaming_inner(&self) -> Result<()> {
|
||||||
{
|
{
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
if !config.enabled {
|
if !config.enabled {
|
||||||
@@ -171,7 +159,7 @@ impl AudioController {
|
|||||||
|
|
||||||
if let Some(error_msg) = select_error {
|
if let Some(error_msg) = select_error {
|
||||||
self.monitor.report_error(&error_msg, "start_failed").await;
|
self.monitor.report_error(&error_msg, "start_failed").await;
|
||||||
self.spawn_recovery_task("auto".to_string(), error_msg.clone());
|
self.recovery.start("auto".to_string(), error_msg.clone());
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
return Err(AppError::AudioError(error_msg));
|
return Err(AppError::AudioError(error_msg));
|
||||||
}
|
}
|
||||||
@@ -194,7 +182,7 @@ impl AudioController {
|
|||||||
let error_msg = format!("Failed to start audio: {}", e);
|
let error_msg = format!("Failed to start audio: {}", e);
|
||||||
|
|
||||||
self.monitor.report_error(&error_msg, "start_failed").await;
|
self.monitor.report_error(&error_msg, "start_failed").await;
|
||||||
self.spawn_recovery_task(device_name.clone(), error_msg.clone());
|
self.recovery.start(device_name.clone(), error_msg.clone());
|
||||||
|
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
|
|
||||||
@@ -203,14 +191,13 @@ impl AudioController {
|
|||||||
|
|
||||||
let streamer_for_monitor = streamer.clone();
|
let streamer_for_monitor = streamer.clone();
|
||||||
*self.streamer.write().await = Some(streamer);
|
*self.streamer.write().await = Some(streamer);
|
||||||
self.spawn_stream_monitor(streamer_for_monitor, device_name.clone());
|
self.recovery
|
||||||
|
.monitor(streamer_for_monitor, device_name.clone());
|
||||||
|
|
||||||
if self.monitor.is_error().await {
|
if self.monitor.is_error().await {
|
||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
|
|
||||||
info!("Audio streaming started");
|
info!("Audio streaming started");
|
||||||
@@ -218,7 +205,12 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_streaming(&self) -> Result<()> {
|
pub async fn stop_streaming(&self) -> Result<()> {
|
||||||
self.recovery_in_progress.store(false, Ordering::SeqCst);
|
let _operation = self.operation.lock().await;
|
||||||
|
self.stop_streaming_inner().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_streaming_inner(&self) -> Result<()> {
|
||||||
|
self.recovery.cancel();
|
||||||
|
|
||||||
if let Some(streamer) = self.streamer.write().await.take() {
|
if let Some(streamer) = self.streamer.write().await.take() {
|
||||||
streamer.stop().await?;
|
streamer.stop().await?;
|
||||||
@@ -249,7 +241,7 @@ impl AudioController {
|
|||||||
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
|
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
|
||||||
{
|
{
|
||||||
let streaming = streamer.is_running();
|
let streaming = streamer.is_running();
|
||||||
let subscriber_count = streamer.stats().subscriber_count;
|
let subscriber_count = streamer.subscriber_count();
|
||||||
(streaming, subscriber_count)
|
(streaming, subscriber_count)
|
||||||
} else {
|
} else {
|
||||||
(false, 0)
|
(false, 0)
|
||||||
@@ -278,13 +270,15 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
|
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
{
|
{
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.enabled = enabled;
|
config.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !enabled && self.is_streaming().await {
|
if !enabled && self.is_streaming().await {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Audio enabled: {}", enabled);
|
info!("Audio enabled: {}", enabled);
|
||||||
@@ -292,16 +286,18 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
let was_streaming = self.is_streaming().await;
|
let was_streaming = self.is_streaming().await;
|
||||||
|
|
||||||
if was_streaming {
|
if was_streaming {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.config.write().await = new_config.clone();
|
*self.config.write().await = new_config.clone();
|
||||||
|
|
||||||
if new_config.enabled {
|
if new_config.enabled {
|
||||||
self.start_streaming().await?;
|
self.start_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! Shared device description with platform-specific enumeration backends.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[path = "device_linux.rs"]
|
#[path = "device_linux.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
@@ -6,4 +12,32 @@ mod imp;
|
|||||||
#[path = "device_windows.rs"]
|
#[path = "device_windows.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|
||||||
pub use imp::*;
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AudioDeviceInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub card_index: i32,
|
||||||
|
pub device_index: i32,
|
||||||
|
pub sample_rates: Vec<u32>,
|
||||||
|
pub channels: Vec<u32>,
|
||||||
|
pub is_capture: bool,
|
||||||
|
pub is_hdmi: bool,
|
||||||
|
pub usb_bus: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
||||||
|
imp::enumerate_audio_devices_with_current(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_audio_devices_with_current(
|
||||||
|
current_device: Option<&str>,
|
||||||
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
|
imp::enumerate_audio_devices_with_current(current_device)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
|
imp::find_best_audio_device()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) use imp::find_wasapi_device;
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
use alsa::pcm::HwParams;
|
use alsa::pcm::HwParams;
|
||||||
use alsa::{Direction, PCM};
|
use alsa::{Direction, PCM};
|
||||||
use serde::Serialize;
|
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::AudioDeviceInfo;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct AudioDeviceInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub card_index: i32,
|
|
||||||
pub device_index: i32,
|
|
||||||
pub sample_rates: Vec<u32>,
|
|
||||||
pub channels: Vec<u32>,
|
|
||||||
pub is_capture: bool,
|
|
||||||
pub is_hdmi: bool,
|
|
||||||
pub usb_bus: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
||||||
if card_index < 0 {
|
if card_index < 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -28,26 +15,18 @@ fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
|||||||
let link_str = link_target.to_string_lossy();
|
let link_str = link_target.to_string_lossy();
|
||||||
|
|
||||||
for component in link_str.split('/') {
|
for component in link_str.split('/') {
|
||||||
if component.contains('-') && !component.contains(':') {
|
if component.contains('-')
|
||||||
if component
|
&& !component.contains(':')
|
||||||
.chars()
|
&& component.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||||
.next()
|
|
||||||
.map(|c| c.is_ascii_digit())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
return Some(component.to_string());
|
return Some(component.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
pub(super) fn enumerate_audio_devices_with_current(
|
||||||
enumerate_audio_devices_with_current(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices_with_current(
|
|
||||||
current_device: Option<&str>,
|
current_device: Option<&str>,
|
||||||
) -> Result<Vec<AudioDeviceInfo>> {
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
@@ -153,8 +132,8 @@ fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
|
|||||||
(supported_rates, supported_channels)
|
(supported_rates, supported_channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
let devices = enumerate_audio_devices()?;
|
let devices = enumerate_audio_devices_with_current(None)?;
|
||||||
|
|
||||||
if devices.is_empty() {
|
if devices.is_empty() {
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
@@ -194,7 +173,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_enumerate_devices() {
|
fn test_enumerate_devices() {
|
||||||
let result = enumerate_audio_devices();
|
let result = enumerate_audio_devices_with_current(None);
|
||||||
println!("Audio devices: {:?}", result);
|
println!("Audio devices: {:?}", result);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
use cpal::DeviceId;
|
use cpal::DeviceId;
|
||||||
use serde::Serialize;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::AudioDeviceInfo;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
pub(super) fn enumerate_audio_devices_with_current(
|
||||||
pub struct AudioDeviceInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub card_index: i32,
|
|
||||||
pub device_index: i32,
|
|
||||||
pub sample_rates: Vec<u32>,
|
|
||||||
pub channels: Vec<u32>,
|
|
||||||
pub is_capture: bool,
|
|
||||||
pub is_hdmi: bool,
|
|
||||||
pub usb_bus: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
|
||||||
enumerate_audio_devices_with_current(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices_with_current(
|
|
||||||
current_device: Option<&str>,
|
current_device: Option<&str>,
|
||||||
) -> Result<Vec<AudioDeviceInfo>> {
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
@@ -151,7 +134,7 @@ fn device_labels(device: &cpal::Device) -> DeviceLabels {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device> {
|
pub(super) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device> {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
let trimmed = requested_device.trim();
|
let trimmed = requested_device.trim();
|
||||||
|
|
||||||
@@ -192,8 +175,8 @@ pub(crate) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device>
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
let devices = enumerate_audio_devices()?;
|
let devices = enumerate_audio_devices_with_current(None)?;
|
||||||
|
|
||||||
if devices.is_empty() {
|
if devices.is_empty() {
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::capture::AudioFrame;
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -154,11 +153,6 @@ impl OpusEncoder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result<OpusFrame> {
|
|
||||||
let samples: &[i16] = bytemuck::cast_slice(&frame.data);
|
|
||||||
self.encode(samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn config(&self) -> &OpusConfig {
|
pub fn config(&self) -> &OpusConfig {
|
||||||
&self.config
|
&self.config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
//! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor.
|
//! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor.
|
||||||
|
|
||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub mod capture;
|
mod capture;
|
||||||
pub mod controller;
|
mod controller;
|
||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub mod device;
|
mod device;
|
||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub mod encoder;
|
mod encoder;
|
||||||
pub mod monitor;
|
mod monitor;
|
||||||
pub mod recovery;
|
mod recovery;
|
||||||
pub mod streamer;
|
mod streamer;
|
||||||
pub mod types;
|
mod types;
|
||||||
pub mod uac_streamer;
|
#[cfg(unix)]
|
||||||
pub mod uac_websocket;
|
pub mod uac;
|
||||||
|
|
||||||
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
||||||
pub use controller::AudioController;
|
pub use controller::AudioController;
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ impl AudioHealthMonitor {
|
|||||||
|
|
||||||
pub async fn report_recovered(&self) {
|
pub async fn report_recovered(&self) {
|
||||||
let prev_status = self.status.read().await.clone();
|
let prev_status = self.status.read().await.clone();
|
||||||
|
self.suppress_display.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
if prev_status != AudioHealthStatus::Healthy {
|
if prev_status != AudioHealthStatus::Healthy {
|
||||||
let retry_count = self.retry_count.load(Ordering::Relaxed);
|
let retry_count = self.retry_count.load(Ordering::Relaxed);
|
||||||
info!("Audio recovered after {} retries", retry_count);
|
info!("Audio recovered after {} retries", retry_count);
|
||||||
|
|
||||||
self.suppress_display.store(false, Ordering::Relaxed);
|
|
||||||
self.retry_count.store(0, Ordering::Relaxed);
|
self.retry_count.store(0, Ordering::Relaxed);
|
||||||
self.throttler.clear("audio_");
|
self.throttler.clear_all();
|
||||||
*self.last_error_code.write().await = None;
|
*self.last_error_code.write().await = None;
|
||||||
*self.status.write().await = AudioHealthStatus::Healthy;
|
*self.status.write().await = AudioHealthStatus::Healthy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
//! Audio device-loss monitoring and serialized recovery.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::capture::AudioConfig;
|
use super::capture::AudioConfig;
|
||||||
@@ -11,39 +14,277 @@ use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
|||||||
use super::types::AudioControllerConfig;
|
use super::types::AudioControllerConfig;
|
||||||
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
||||||
|
|
||||||
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
pub(super) fn select_recovery_device(
|
struct RecoveryControl {
|
||||||
devices: &[AudioDeviceInfo],
|
/// Even values are idle; the following odd value is that recovery's token.
|
||||||
preferred: &str,
|
/// A single compare-exchange therefore owns both activity and generation.
|
||||||
) -> Option<AudioDeviceInfo> {
|
state: AtomicU64,
|
||||||
if let Some(device) = devices
|
|
||||||
.iter()
|
|
||||||
.find(|d| !preferred.trim().is_empty() && d.name == preferred)
|
|
||||||
{
|
|
||||||
return Some(device.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
devices
|
impl RecoveryControl {
|
||||||
.iter()
|
fn new() -> Self {
|
||||||
.find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2))
|
Self {
|
||||||
.or_else(|| {
|
state: AtomicU64::new(0),
|
||||||
devices
|
}
|
||||||
.iter()
|
}
|
||||||
.find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2))
|
|
||||||
})
|
fn begin(&self) -> Option<u64> {
|
||||||
.or_else(|| devices.first())
|
let idle = self.state.load(Ordering::Acquire);
|
||||||
.cloned()
|
if !idle.is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = idle.wrapping_add(1);
|
||||||
|
self.state
|
||||||
|
.compare_exchange(idle, token, Ordering::AcqRel, Ordering::Acquire)
|
||||||
|
.ok()
|
||||||
|
.map(|_| token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_current(&self, token: u64) -> bool {
|
||||||
|
self.state.load(Ordering::Acquire) == token
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self, token: u64) {
|
||||||
|
let _ = self.state.compare_exchange(
|
||||||
|
token,
|
||||||
|
token.wrapping_add(1),
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self) {
|
||||||
|
let token = self.state.load(Ordering::Acquire);
|
||||||
|
if !token.is_multiple_of(2) {
|
||||||
|
let _ = self.state.compare_exchange(
|
||||||
|
token,
|
||||||
|
token.wrapping_add(1),
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecoveryLease {
|
||||||
|
control: Arc<RecoveryControl>,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RecoveryLease {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.control.finish(self.generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecoveryInner {
|
||||||
|
config: Arc<RwLock<AudioControllerConfig>>,
|
||||||
|
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
||||||
|
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
||||||
|
monitor: Arc<AudioHealthMonitor>,
|
||||||
|
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
||||||
|
operation: Arc<Mutex<()>>,
|
||||||
|
control: Arc<RecoveryControl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct AudioRecovery {
|
||||||
|
inner: Arc<RecoveryInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioRecovery {
|
||||||
|
pub(super) fn new(
|
||||||
|
config: Arc<RwLock<AudioControllerConfig>>,
|
||||||
|
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
||||||
|
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
||||||
|
monitor: Arc<AudioHealthMonitor>,
|
||||||
|
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
||||||
|
operation: Arc<Mutex<()>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RecoveryInner {
|
||||||
|
config,
|
||||||
|
streamer,
|
||||||
|
event_bus,
|
||||||
|
monitor,
|
||||||
|
recovered_callback,
|
||||||
|
operation,
|
||||||
|
control: Arc::new(RecoveryControl::new()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cancel(&self) {
|
||||||
|
self.inner.control.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
|
||||||
|
let recovery = self.clone();
|
||||||
|
let mut state = streamer.state_watch();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let current_state = *state.borrow();
|
||||||
|
match current_state {
|
||||||
|
AudioStreamState::Error => {}
|
||||||
|
AudioStreamState::Stopped => return,
|
||||||
|
AudioStreamState::Starting | AudioStreamState::Running => {
|
||||||
|
if state.changed().await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the ownership check with user-driven start/stop
|
||||||
|
// operations. If a stop already owns the operation lock, it
|
||||||
|
// removes the streamer before this monitor may start recovery.
|
||||||
|
let _operation = recovery.inner.operation.lock().await;
|
||||||
|
let is_current = recovery
|
||||||
|
.inner
|
||||||
|
.streamer
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|current| Arc::ptr_eq(current, &streamer));
|
||||||
|
if !is_current {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = format!("Audio device lost: {device}");
|
||||||
|
recovery
|
||||||
|
.inner
|
||||||
|
.monitor
|
||||||
|
.report_error(&reason, "device_lost")
|
||||||
|
.await;
|
||||||
|
recovery.start(device, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn start(&self, lost_device: String, reason: String) {
|
||||||
|
let Some(generation) = self.inner.control.begin() else {
|
||||||
|
debug!("Audio recovery already in progress");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let recovery = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _lease = RecoveryLease {
|
||||||
|
control: recovery.inner.control.clone(),
|
||||||
|
generation,
|
||||||
|
};
|
||||||
|
recovery.run(generation, lost_device, reason).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, generation: u64, lost_device: String, reason: String) {
|
||||||
|
warn!("Audio recovery started for {lost_device}: {reason}");
|
||||||
|
self.publish_device_lost(&lost_device, &reason).await;
|
||||||
|
self.publish_state(
|
||||||
|
"device_lost",
|
||||||
|
Some(lost_device.clone()),
|
||||||
|
Some("audio_device_lost"),
|
||||||
|
Some(RETRY_DELAY.as_millis() as u64),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut attempt = 0u32;
|
||||||
|
while self.inner.control.is_current(generation) {
|
||||||
|
let config = self.inner.config.read().await.clone();
|
||||||
|
if !config.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.inner
|
||||||
|
.streamer
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|streamer| streamer.is_running())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt = attempt.saturating_add(1);
|
||||||
|
self.publish_reconnecting(&lost_device, attempt).await;
|
||||||
|
self.publish_state(
|
||||||
|
"device_lost",
|
||||||
|
Some(lost_device.clone()),
|
||||||
|
Some("audio_reconnecting"),
|
||||||
|
Some(RETRY_DELAY.as_millis() as u64),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(RETRY_DELAY).await;
|
||||||
|
if !self.inner.control.is_current(generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = match enumerate_audio_devices() {
|
||||||
|
Ok(devices) => devices,
|
||||||
|
Err(error) => {
|
||||||
|
debug!("Audio recovery enumeration attempt {attempt} failed: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(device) = select_recovery_device(&devices, &config.device) else {
|
||||||
|
debug!("No audio device found on recovery attempt {attempt}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let streamer = Arc::new(AudioStreamer::with_config(AudioStreamerConfig {
|
||||||
|
capture: AudioConfig {
|
||||||
|
device_name: device.name.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
opus: config.quality.to_opus_config(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Err(error) = streamer.start().await {
|
||||||
|
debug!(
|
||||||
|
"Audio recovery attempt {attempt} failed with {}: {error}",
|
||||||
|
device.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit a recovered streamer under the same operation lock used by
|
||||||
|
// user-driven start/stop/config updates. Cancellation is rechecked
|
||||||
|
// after acquiring the lock so an old task cannot resurrect itself.
|
||||||
|
let _operation = self.inner.operation.lock().await;
|
||||||
|
if !self.inner.control.is_current(generation) || !self.inner.config.read().await.enabled
|
||||||
|
{
|
||||||
|
let _ = streamer.stop().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inner.config.write().await.device = device.name.clone();
|
||||||
|
*self.inner.streamer.write().await = Some(streamer.clone());
|
||||||
|
self.inner.monitor.report_recovered().await;
|
||||||
|
self.publish_recovered(&device.name).await;
|
||||||
|
if let Some(callback) = self.inner.recovered_callback.read().await.clone() {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
self.publish_state("streaming", Some(device.name.clone()), None, None)
|
||||||
|
.await;
|
||||||
|
info!(
|
||||||
|
"Audio recovered with {} after {} attempts",
|
||||||
|
device.name, attempt
|
||||||
|
);
|
||||||
|
self.inner.control.finish(generation);
|
||||||
|
self.monitor(streamer, device.name);
|
||||||
|
drop(_operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_state(
|
async fn publish_state(
|
||||||
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
|
&self,
|
||||||
state: &str,
|
state: &str,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
next_retry_ms: Option<u64>,
|
next_retry_ms: Option<u64>,
|
||||||
) {
|
) {
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
state: state.to_string(),
|
state: state.to_string(),
|
||||||
device,
|
device,
|
||||||
@@ -54,12 +295,8 @@ async fn publish_state(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_device_lost(
|
async fn publish_device_lost(&self, device: &str, reason: &str) {
|
||||||
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
device: &str,
|
|
||||||
reason: &str,
|
|
||||||
) {
|
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
|
||||||
bus.publish(SystemEvent::StreamDeviceLost {
|
bus.publish(SystemEvent::StreamDeviceLost {
|
||||||
kind: StreamDeviceLostKind::Audio,
|
kind: StreamDeviceLostKind::Audio,
|
||||||
device: device.to_string(),
|
device: device.to_string(),
|
||||||
@@ -68,12 +305,8 @@ async fn publish_device_lost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_reconnecting(
|
async fn publish_reconnecting(&self, device: &str, attempt: u32) {
|
||||||
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
device: &str,
|
|
||||||
attempt: u32,
|
|
||||||
) {
|
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
|
||||||
bus.publish(SystemEvent::StreamReconnecting {
|
bus.publish(SystemEvent::StreamReconnecting {
|
||||||
device: device.to_string(),
|
device: device.to_string(),
|
||||||
attempt,
|
attempt,
|
||||||
@@ -81,240 +314,92 @@ async fn publish_reconnecting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_recovered(event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>, device: &str) {
|
async fn publish_recovered(&self, device: &str) {
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
bus.publish(SystemEvent::StreamRecovered {
|
bus.publish(SystemEvent::StreamRecovered {
|
||||||
device: device.to_string(),
|
device: device.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_stream_monitor_from_parts(
|
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
streamer: Arc<AudioStreamer>,
|
|
||||||
device: String,
|
|
||||||
) {
|
|
||||||
let mut state_rx = streamer.state_watch();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
if state_rx.changed().await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if *state_rx.borrow() != AudioStreamState::Error {
|
pub(super) fn select_recovery_device(
|
||||||
continue;
|
devices: &[AudioDeviceInfo],
|
||||||
|
preferred: &str,
|
||||||
|
) -> Option<AudioDeviceInfo> {
|
||||||
|
devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| !preferred.trim().is_empty() && device.name == preferred)
|
||||||
|
.or_else(|| {
|
||||||
|
devices.iter().find(|device| {
|
||||||
|
device.is_hdmi
|
||||||
|
&& device.sample_rates.contains(&48_000)
|
||||||
|
&& device.channels.contains(&2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
devices.iter().find(|device| {
|
||||||
|
device.sample_rates.contains(&48_000) && device.channels.contains(&2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| devices.first())
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
#[cfg(test)]
|
||||||
let current = streamer_slot.read().await;
|
mod tests {
|
||||||
if !current
|
use super::*;
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|current| Arc::ptr_eq(current, &streamer))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let reason = format!("Audio device lost: {}", device);
|
fn device(name: &str, compatible: bool, hdmi: bool) -> AudioDeviceInfo {
|
||||||
monitor.report_error(&reason, "device_lost").await;
|
AudioDeviceInfo {
|
||||||
spawn_recovery_task_from_parts(
|
name: name.to_string(),
|
||||||
config,
|
description: name.to_string(),
|
||||||
streamer_slot,
|
card_index: 0,
|
||||||
event_bus,
|
device_index: 0,
|
||||||
monitor,
|
sample_rates: if compatible {
|
||||||
recovery_in_progress,
|
vec![48_000]
|
||||||
recovered_callback,
|
} else {
|
||||||
device,
|
vec![44_100]
|
||||||
reason,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_recovery_task_from_parts(
|
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
lost_device: String,
|
|
||||||
reason: String,
|
|
||||||
) {
|
|
||||||
if recovery_in_progress.swap(true, Ordering::SeqCst) {
|
|
||||||
debug!("Audio recovery already in progress");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
warn!("Audio recovery started for {}: {}", lost_device, reason);
|
|
||||||
publish_device_lost(&event_bus, &lost_device, &reason).await;
|
|
||||||
publish_state(
|
|
||||||
&event_bus,
|
|
||||||
"device_lost",
|
|
||||||
Some(lost_device.clone()),
|
|
||||||
Some("audio_device_lost"),
|
|
||||||
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut attempt = 0u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !recovery_in_progress.load(Ordering::SeqCst) {
|
|
||||||
debug!("Audio recovery canceled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if streamer_slot
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|s| s.is_running())
|
|
||||||
{
|
|
||||||
recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cfg: AudioControllerConfig = config.read().await.clone();
|
|
||||||
if !cfg.enabled {
|
|
||||||
recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
attempt = attempt.saturating_add(1);
|
|
||||||
publish_reconnecting(&event_bus, &lost_device, attempt).await;
|
|
||||||
publish_state(
|
|
||||||
&event_bus,
|
|
||||||
"device_lost",
|
|
||||||
Some(lost_device.clone()),
|
|
||||||
Some("audio_reconnecting"),
|
|
||||||
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await;
|
|
||||||
|
|
||||||
let devices = match enumerate_audio_devices() {
|
|
||||||
Ok(devices) => devices,
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"Audio recovery enumerate failed (attempt {}): {}",
|
|
||||||
attempt, e
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(device) = select_recovery_device(&devices, &cfg.device) else {
|
|
||||||
debug!("No audio devices found during recovery attempt {}", attempt);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let streamer_config = AudioStreamerConfig {
|
|
||||||
capture: AudioConfig {
|
|
||||||
device_name: device.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
opus: cfg.quality.to_opus_config(),
|
channels: vec![2],
|
||||||
};
|
is_capture: true,
|
||||||
let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config));
|
is_hdmi: hdmi,
|
||||||
|
usb_bus: None,
|
||||||
match new_streamer.start().await {
|
|
||||||
Ok(()) => {
|
|
||||||
{
|
|
||||||
let mut cfg = config.write().await;
|
|
||||||
cfg.device = device.name.clone();
|
|
||||||
}
|
}
|
||||||
*streamer_slot.write().await = Some(new_streamer.clone());
|
|
||||||
monitor.report_recovered().await;
|
|
||||||
publish_recovered(&event_bus, &device.name).await;
|
|
||||||
if let Some(callback) = recovered_callback.read().await.clone() {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
publish_state(
|
|
||||||
&event_bus,
|
|
||||||
"streaming",
|
|
||||||
Some(device.name.clone()),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
info!(
|
|
||||||
"Audio device recovered with {} after {} attempts",
|
|
||||||
device.name, attempt
|
|
||||||
);
|
|
||||||
spawn_stream_monitor_from_parts(
|
|
||||||
config,
|
|
||||||
streamer_slot,
|
|
||||||
event_bus,
|
|
||||||
monitor,
|
|
||||||
recovery_in_progress,
|
|
||||||
recovered_callback,
|
|
||||||
new_streamer,
|
|
||||||
device.name,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"Audio recovery start failed with {} (attempt {}): {}",
|
|
||||||
device.name, attempt, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn spawn_stream_monitor(
|
#[test]
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
fn stale_recovery_cannot_finish_a_new_generation() {
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
let control = RecoveryControl::new();
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
let stale = control.begin().unwrap();
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
control.cancel();
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
let current = control.begin().unwrap();
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
streamer: Arc<AudioStreamer>,
|
control.finish(stale);
|
||||||
device: String,
|
assert!(control.is_current(current));
|
||||||
) {
|
|
||||||
spawn_stream_monitor_from_parts(
|
|
||||||
config,
|
|
||||||
streamer_slot,
|
|
||||||
event_bus,
|
|
||||||
monitor,
|
|
||||||
recovery_in_progress,
|
|
||||||
recovered_callback,
|
|
||||||
streamer,
|
|
||||||
device,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn spawn_recovery_task(
|
#[test]
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
fn completed_recovery_cannot_finish_the_next_recovery() {
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
let control = RecoveryControl::new();
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
let completed = control.begin().unwrap();
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
control.finish(completed);
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
let current = control.begin().unwrap();
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
lost_device: String,
|
control.finish(completed);
|
||||||
reason: String,
|
assert!(control.is_current(current));
|
||||||
) {
|
}
|
||||||
spawn_recovery_task_from_parts(
|
|
||||||
config,
|
#[test]
|
||||||
streamer_slot,
|
fn recovery_prefers_requested_then_compatible_hdmi() {
|
||||||
event_bus,
|
let devices = vec![device("fallback", true, false), device("hdmi", true, true)];
|
||||||
monitor,
|
assert_eq!(
|
||||||
recovery_in_progress,
|
select_recovery_device(&devices, "fallback").unwrap().name,
|
||||||
recovered_callback,
|
"fallback"
|
||||||
lost_device,
|
);
|
||||||
reason,
|
assert_eq!(
|
||||||
|
select_recovery_device(&devices, "missing").unwrap().name,
|
||||||
|
"hdmi"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
|
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
|
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
|
||||||
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use bytemuck;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
|
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
|
||||||
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
|
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
|
||||||
@@ -40,16 +40,6 @@ impl AudioStreamerConfig {
|
|||||||
opus: OpusConfig::default(),
|
opus: OpusConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_bitrate(mut self, bitrate: u32) -> Self {
|
|
||||||
self.opus.bitrate = bitrate;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct AudioStreamStats {
|
|
||||||
pub subscriber_count: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioStreamer {
|
pub struct AudioStreamer {
|
||||||
@@ -60,6 +50,9 @@ pub struct AudioStreamer {
|
|||||||
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
|
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
|
||||||
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
||||||
stop_flag: Arc<AtomicBool>,
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
shutdown_generation: watch::Sender<u64>,
|
||||||
|
lifecycle: AsyncMutex<()>,
|
||||||
|
stream_task: AsyncMutex<Option<JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioStreamer {
|
impl AudioStreamer {
|
||||||
@@ -69,6 +62,7 @@ impl AudioStreamer {
|
|||||||
|
|
||||||
pub fn with_config(config: AudioStreamerConfig) -> Self {
|
pub fn with_config(config: AudioStreamerConfig) -> Self {
|
||||||
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
|
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
|
||||||
|
let (shutdown_generation, _) = watch::channel(0);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
@@ -78,6 +72,9 @@ impl AudioStreamer {
|
|||||||
encoder: Arc::new(AsyncMutex::new(None)),
|
encoder: Arc::new(AsyncMutex::new(None)),
|
||||||
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
|
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
|
||||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
shutdown_generation,
|
||||||
|
lifecycle: AsyncMutex::new(()),
|
||||||
|
stream_task: AsyncMutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +87,9 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
|
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
|
||||||
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(128);
|
// Keep latency bounded for real-time consumers. Slow receivers lose
|
||||||
|
// new frames instead of accumulating seconds of stale audio.
|
||||||
|
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(4);
|
||||||
self.opus_subscribers.lock().unwrap().push(tx);
|
self.opus_subscribers.lock().unwrap().push(tx);
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
@@ -104,22 +103,6 @@ impl AudioStreamer {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stats(&self) -> AudioStreamStats {
|
|
||||||
AudioStreamStats {
|
|
||||||
subscriber_count: self.subscriber_count(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> {
|
|
||||||
if self.state() != AudioStreamState::Stopped {
|
|
||||||
return Err(AppError::AudioError(
|
|
||||||
"Cannot change config while streaming".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
*self.config.write().await = config;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
|
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
|
||||||
self.config.write().await.opus.bitrate = bitrate;
|
self.config.write().await.opus.bitrate = bitrate;
|
||||||
|
|
||||||
@@ -132,10 +115,25 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
pub async fn start(&self) -> Result<()> {
|
||||||
if self.state() == AudioStreamState::Running {
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
|
if matches!(
|
||||||
|
self.state(),
|
||||||
|
AudioStreamState::Starting | AudioStreamState::Running
|
||||||
|
) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error and stopped states may still own completed task handles. Reap
|
||||||
|
// them before installing a new capture pipeline so restart is a clean
|
||||||
|
// lifecycle transition rather than an overwrite of old resources.
|
||||||
|
if let Some(capturer) = self.capturer.write().await.take() {
|
||||||
|
let _ = capturer.stop().await;
|
||||||
|
}
|
||||||
|
if let Some(task) = self.stream_task.lock().await.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
*self.encoder.lock().await = None;
|
||||||
|
|
||||||
let _ = self.state.send(AudioStreamState::Starting);
|
let _ = self.state.send(AudioStreamState::Starting);
|
||||||
self.stop_flag.store(false, Ordering::SeqCst);
|
self.stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -149,13 +147,21 @@ impl AudioStreamer {
|
|||||||
config.opus.bitrate
|
config.opus.bitrate
|
||||||
);
|
);
|
||||||
|
|
||||||
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
|
let encoder = match OpusEncoder::new(config.opus.clone()) {
|
||||||
*self.capturer.write().await = Some(capturer.clone());
|
Ok(encoder) => encoder,
|
||||||
|
Err(error) => {
|
||||||
let encoder = OpusEncoder::new(config.opus.clone())?;
|
let _ = self.state.send(AudioStreamState::Error);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
*self.encoder.lock().await = Some(encoder);
|
*self.encoder.lock().await = Some(encoder);
|
||||||
|
|
||||||
capturer.start().await?;
|
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
|
||||||
|
*self.capturer.write().await = Some(capturer.clone());
|
||||||
|
if let Err(error) = capturer.start().await {
|
||||||
|
self.cleanup_failed_start(&capturer).await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
let mut capture_state = capturer.state_watch();
|
let mut capture_state = capturer.state_watch();
|
||||||
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
|
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
@@ -168,7 +174,7 @@ impl AudioStreamer {
|
|||||||
"Audio capture failed to start".to_string(),
|
"Audio capture failed to start".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
CaptureState::Stopped => {
|
CaptureState::Stopped | CaptureState::Starting => {
|
||||||
if capture_state.changed().await.is_err() {
|
if capture_state.changed().await.is_err() {
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
"Audio capture stopped during startup".to_string(),
|
"Audio capture stopped during startup".to_string(),
|
||||||
@@ -183,17 +189,11 @@ impl AudioStreamer {
|
|||||||
match startup_result {
|
match startup_result {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
let _ = capturer.stop().await;
|
self.cleanup_failed_start(&capturer).await;
|
||||||
*self.capturer.write().await = None;
|
|
||||||
*self.encoder.lock().await = None;
|
|
||||||
let _ = self.state.send(AudioStreamState::Error);
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = capturer.stop().await;
|
self.cleanup_failed_start(&capturer).await;
|
||||||
*self.capturer.write().await = None;
|
|
||||||
*self.encoder.lock().await = None;
|
|
||||||
let _ = self.state.send(AudioStreamState::Error);
|
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
"Timed out waiting for audio capture to start".to_string(),
|
"Timed out waiting for audio capture to start".to_string(),
|
||||||
));
|
));
|
||||||
@@ -205,22 +205,27 @@ impl AudioStreamer {
|
|||||||
let opus_subscribers = self.opus_subscribers.clone();
|
let opus_subscribers = self.opus_subscribers.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let stop_flag = self.stop_flag.clone();
|
let stop_flag = self.stop_flag.clone();
|
||||||
|
let shutdown_rx = self.shutdown_generation.subscribe();
|
||||||
|
let _ = self.state.send(AudioStreamState::Running);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
Self::stream_task(
|
Self::stream_task(
|
||||||
capturer_for_task,
|
capturer_for_task,
|
||||||
encoder,
|
encoder,
|
||||||
opus_subscribers,
|
opus_subscribers,
|
||||||
state,
|
state,
|
||||||
stop_flag,
|
stop_flag,
|
||||||
|
shutdown_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
*self.stream_task.lock().await = Some(task);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
if self.state() == AudioStreamState::Stopped {
|
if self.state() == AudioStreamState::Stopped {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -228,10 +233,16 @@ impl AudioStreamer {
|
|||||||
info!("Stopping audio stream");
|
info!("Stopping audio stream");
|
||||||
|
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
self.stop_flag.store(true, Ordering::SeqCst);
|
||||||
|
self.shutdown_generation.send_modify(|generation| {
|
||||||
|
*generation = generation.wrapping_add(1);
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(ref capturer) = *self.capturer.read().await {
|
if let Some(ref capturer) = *self.capturer.read().await {
|
||||||
capturer.stop().await?;
|
capturer.stop().await?;
|
||||||
}
|
}
|
||||||
|
if let Some(task) = self.stream_task.lock().await.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
|
||||||
*self.capturer.write().await = None;
|
*self.capturer.write().await = None;
|
||||||
*self.encoder.lock().await = None;
|
*self.encoder.lock().await = None;
|
||||||
@@ -242,28 +253,26 @@ impl AudioStreamer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup_failed_start(&self, capturer: &AudioCapturer) {
|
||||||
|
let _ = capturer.stop().await;
|
||||||
|
*self.capturer.write().await = None;
|
||||||
|
*self.encoder.lock().await = None;
|
||||||
|
let _ = self.state.send(AudioStreamState::Error);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
self.state() == AudioStreamState::Running
|
self.state() == AudioStreamState::Running
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fanout_opus(
|
fn fanout_opus(
|
||||||
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
||||||
frame: Arc<OpusFrame>,
|
frame: Arc<OpusFrame>,
|
||||||
) {
|
) {
|
||||||
let txs: Vec<_> = {
|
let mut subscribers = subscribers.lock().unwrap();
|
||||||
let g = subscribers.lock().unwrap();
|
subscribers.retain(|subscriber| match subscriber.try_send(frame.clone()) {
|
||||||
if g.is_empty() {
|
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
|
||||||
return;
|
Err(mpsc::error::TrySendError::Closed(_)) => false,
|
||||||
}
|
});
|
||||||
g.clone()
|
|
||||||
};
|
|
||||||
for tx in &txs {
|
|
||||||
let _ = tx.send(frame.clone()).await;
|
|
||||||
}
|
|
||||||
if txs.iter().any(|tx| tx.is_closed()) {
|
|
||||||
let mut g = subscribers.lock().unwrap();
|
|
||||||
g.retain(|tx| !tx.is_closed());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_task(
|
async fn stream_task(
|
||||||
@@ -272,9 +281,9 @@ impl AudioStreamer {
|
|||||||
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
||||||
state: watch::Sender<AudioStreamState>,
|
state: watch::Sender<AudioStreamState>,
|
||||||
stop_flag: Arc<AtomicBool>,
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
mut shutdown_rx: watch::Receiver<u64>,
|
||||||
) {
|
) {
|
||||||
let mut pcm_rx = capturer.subscribe();
|
let mut pcm_rx = capturer.subscribe();
|
||||||
let _ = state.send(AudioStreamState::Running);
|
|
||||||
|
|
||||||
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
|
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
|
||||||
|
|
||||||
@@ -291,8 +300,19 @@ impl AudioStreamer {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let recv_result =
|
let recv_result = tokio::select! {
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await;
|
biased;
|
||||||
|
changed = shutdown_rx.changed() => {
|
||||||
|
if changed.is_ok() || stop_flag.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
pcm_rx.recv(),
|
||||||
|
) => result,
|
||||||
|
};
|
||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(audio_frame)) => {
|
Ok(Ok(audio_frame)) => {
|
||||||
@@ -316,23 +336,17 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while pending.len() >= OPUS_STEREO_SAMPLES {
|
while pending.len() >= OPUS_STEREO_SAMPLES {
|
||||||
let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice(
|
|
||||||
&pending[..OPUS_STEREO_SAMPLES],
|
|
||||||
));
|
|
||||||
pending.drain(..OPUS_STEREO_SAMPLES);
|
|
||||||
|
|
||||||
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 0);
|
|
||||||
|
|
||||||
let opus_result = {
|
let opus_result = {
|
||||||
let mut enc_guard = encoder.lock().await;
|
let mut enc_guard = encoder.lock().await;
|
||||||
(*enc_guard)
|
(*enc_guard)
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.map(|enc| enc.encode_frame(&frame_48k))
|
.map(|enc| enc.encode(&pending[..OPUS_STEREO_SAMPLES]))
|
||||||
};
|
};
|
||||||
|
pending.drain(..OPUS_STEREO_SAMPLES);
|
||||||
|
|
||||||
match opus_result {
|
match opus_result {
|
||||||
Some(Ok(opus_frame)) => {
|
Some(Ok(opus_frame)) => {
|
||||||
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await;
|
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame));
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
error!("Opus encode error: {}", e);
|
error!("Opus encode error: {}", e);
|
||||||
@@ -365,6 +379,7 @@ impl AudioStreamer {
|
|||||||
let _ = state.send(AudioStreamState::Stopped);
|
let _ = state.send(AudioStreamState::Stopped);
|
||||||
} else {
|
} else {
|
||||||
opus_subscribers.lock().unwrap().clear();
|
opus_subscribers.lock().unwrap().clear();
|
||||||
|
let _ = capturer.stop().await;
|
||||||
}
|
}
|
||||||
info!("Audio stream task ended");
|
info!("Audio stream task ended");
|
||||||
}
|
}
|
||||||
@@ -379,6 +394,7 @@ impl Default for AudioStreamer {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_streamer_config_default() {
|
fn test_streamer_config_default() {
|
||||||
@@ -398,4 +414,42 @@ mod tests {
|
|||||||
let streamer = AudioStreamer::new();
|
let streamer = AudioStreamer::new();
|
||||||
assert_eq!(streamer.state(), AudioStreamState::Stopped);
|
assert_eq!(streamer.state(), AudioStreamState::Stopped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slow_subscriber_does_not_block_or_grow_unbounded() {
|
||||||
|
let streamer = AudioStreamer::new();
|
||||||
|
let mut receiver = streamer.subscribe_opus();
|
||||||
|
for sequence in 0..20 {
|
||||||
|
AudioStreamer::fanout_opus(
|
||||||
|
&streamer.opus_subscribers,
|
||||||
|
Arc::new(OpusFrame {
|
||||||
|
data: Bytes::from_static(&[1]),
|
||||||
|
duration_ms: 20,
|
||||||
|
sequence,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut received = 0;
|
||||||
|
while receiver.try_recv().is_ok() {
|
||||||
|
received += 1;
|
||||||
|
}
|
||||||
|
assert_eq!(received, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_subscriber_is_pruned() {
|
||||||
|
let streamer = AudioStreamer::new();
|
||||||
|
let receiver = streamer.subscribe_opus();
|
||||||
|
drop(receiver);
|
||||||
|
AudioStreamer::fanout_opus(
|
||||||
|
&streamer.opus_subscribers,
|
||||||
|
Arc::new(OpusFrame {
|
||||||
|
data: Bytes::from_static(&[1]),
|
||||||
|
duration_ms: 20,
|
||||||
|
sequence: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(streamer.subscriber_count(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/audio/uac/decoder.rs
Normal file
59
src/audio/uac/decoder.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use audiopus::coder::Decoder;
|
||||||
|
use audiopus::{Channels, SampleRate};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const CHANNELS: usize = 2;
|
||||||
|
const MAX_PACKET_BYTES: usize = 1275;
|
||||||
|
const MAX_SAMPLES_PER_CHANNEL: usize = 5760;
|
||||||
|
|
||||||
|
pub struct UacOpusDecoder {
|
||||||
|
decoder: Decoder,
|
||||||
|
buffer: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacOpusDecoder {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Stereo)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Opus decoder init failed: {error}")))?;
|
||||||
|
Ok(Self {
|
||||||
|
decoder,
|
||||||
|
buffer: vec![0; MAX_SAMPLES_PER_CHANNEL * CHANNELS],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(&mut self, packet: &[u8]) -> Result<&[i16]> {
|
||||||
|
if packet.is_empty() || packet.len() > MAX_PACKET_BYTES {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"invalid Opus packet length {}",
|
||||||
|
packet.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let frames = self
|
||||||
|
.decoder
|
||||||
|
.decode(Some(packet), &mut self.buffer, false)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Opus decode failed: {error}")))?;
|
||||||
|
Ok(&self.buffer[..frames * CHANNELS])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use audiopus::coder::Encoder;
|
||||||
|
use audiopus::Application;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_preserves_all_stereo_samples() {
|
||||||
|
let encoder =
|
||||||
|
Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Audio).unwrap();
|
||||||
|
let pcm = vec![0i16; 960 * CHANNELS];
|
||||||
|
let mut packet = vec![0u8; MAX_PACKET_BYTES];
|
||||||
|
let packet_len = encoder.encode(&pcm, &mut packet).unwrap();
|
||||||
|
|
||||||
|
let mut decoder = UacOpusDecoder::new().unwrap();
|
||||||
|
let decoded = decoder.decode(&packet[..packet_len]).unwrap();
|
||||||
|
assert_eq!(decoded.len(), pcm.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/audio/uac/mod.rs
Normal file
9
src/audio/uac/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//! Browser-to-USB microphone audio pipeline.
|
||||||
|
|
||||||
|
mod decoder;
|
||||||
|
mod playback;
|
||||||
|
mod protocol;
|
||||||
|
|
||||||
|
pub use decoder::UacOpusDecoder;
|
||||||
|
pub use playback::{UacPlayback, UacPlaybackConfig, UacPlaybackState, UacSession};
|
||||||
|
pub use protocol::{parse_audio_packet, UacAudioPacket};
|
||||||
509
src/audio/uac/playback.rs
Normal file
509
src/audio/uac/playback.rs
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use alsa::pcm::{Access, Format, Frames, HwParams, State};
|
||||||
|
use alsa::{Direction, ValueOr, PCM};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
|
||||||
|
const PERIOD_FRAMES: Frames = 960;
|
||||||
|
const BUFFER_FRAMES: Frames = 4_800;
|
||||||
|
const START_THRESHOLD_PERIODS: Frames = 4;
|
||||||
|
const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum UacPlaybackState {
|
||||||
|
Idle,
|
||||||
|
Waiting,
|
||||||
|
Active,
|
||||||
|
Stalled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacPlaybackState {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Idle => "idle",
|
||||||
|
Self::Waiting => "waiting",
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Stalled => "stalled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UacPlaybackConfig {
|
||||||
|
pub device_name: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UacPlaybackConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device_name: crate::otg::uac::find_uac_pcm_device()
|
||||||
|
.unwrap_or_else(crate::otg::uac::uac_pcm_device),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackInner {
|
||||||
|
config: UacPlaybackConfig,
|
||||||
|
stopped: AtomicBool,
|
||||||
|
active_session: Mutex<Option<Arc<Mutex<SessionRuntime>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SessionSink {
|
||||||
|
Closed { retry_at: Option<Instant> },
|
||||||
|
Probing { pcm: PCM, stalled: bool },
|
||||||
|
Active { pcm: PCM, last_progress: Instant },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionSink {
|
||||||
|
fn state(&self) -> UacPlaybackState {
|
||||||
|
match self {
|
||||||
|
Self::Closed { retry_at: None } => UacPlaybackState::Waiting,
|
||||||
|
Self::Closed { retry_at: Some(_) } => UacPlaybackState::Stalled,
|
||||||
|
Self::Probing { stalled: false, .. } => UacPlaybackState::Waiting,
|
||||||
|
Self::Probing { stalled: true, .. } => UacPlaybackState::Stalled,
|
||||||
|
Self::Active { .. } => UacPlaybackState::Active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionRuntime {
|
||||||
|
sink: SessionSink,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRuntime {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sink: SessionSink::Closed { retry_at: None },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> UacPlaybackState {
|
||||||
|
self.sink.state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) {
|
||||||
|
self.sink = SessionSink::Closed { retry_at: None };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance playback only when a WebSocket frame arrives. All ALSA handles
|
||||||
|
/// are non-blocking, so a slow or absent USB host drops the current frame
|
||||||
|
/// instead of occupying a worker thread or accumulating stale speech.
|
||||||
|
fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool {
|
||||||
|
let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None });
|
||||||
|
let (next_sink, accepted) = drive_sink(sink, config, samples);
|
||||||
|
self.sink = next_sink;
|
||||||
|
accepted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UacPlayback {
|
||||||
|
inner: Arc<PlaybackInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UacSession {
|
||||||
|
playback: UacPlayback,
|
||||||
|
runtime: Arc<Mutex<SessionRuntime>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacPlayback {
|
||||||
|
pub fn start(config: UacPlaybackConfig) -> Result<Self> {
|
||||||
|
if config.sample_rate != 48_000 || config.channels != 2 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC playback supports only 48000 Hz stereo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: Arc::new(PlaybackInner {
|
||||||
|
config,
|
||||||
|
stopped: AtomicBool::new(false),
|
||||||
|
active_session: Mutex::new(None),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acquire_session(&self) -> Result<UacSession> {
|
||||||
|
let mut active = self.inner.active_session.lock().unwrap();
|
||||||
|
if self.inner.stopped.load(Ordering::Acquire) {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"UAC playback is stopping".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if active.is_some() {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"another UAC microphone session is already active".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime = Arc::new(Mutex::new(SessionRuntime::new()));
|
||||||
|
*active = Some(Arc::clone(&runtime));
|
||||||
|
Ok(UacSession {
|
||||||
|
playback: self.clone(),
|
||||||
|
runtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting frames and synchronously close an active ALSA handle.
|
||||||
|
/// This guarantees configfs may rebuild the UAC function after this call.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
if self.inner.stopped.swap(true, Ordering::AcqRel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let runtime = self.inner.active_session.lock().unwrap().take();
|
||||||
|
if let Some(runtime) = runtime {
|
||||||
|
runtime.lock().unwrap().close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacSession {
|
||||||
|
pub fn state(&self) -> UacPlaybackState {
|
||||||
|
self.runtime.lock().unwrap().state()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the frame was accepted and the resulting target state.
|
||||||
|
pub fn try_write(&self, pcm: &[i16]) -> Result<(bool, UacPlaybackState)> {
|
||||||
|
let channels = self.playback.inner.config.channels as usize;
|
||||||
|
if pcm.is_empty() || !pcm.len().is_multiple_of(channels) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC PCM must contain complete stereo frames".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.playback.inner.stopped.load(Ordering::Acquire) {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"UAC playback has stopped".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut runtime = self.runtime.lock().unwrap();
|
||||||
|
if self.playback.inner.stopped.load(Ordering::Acquire) {
|
||||||
|
runtime.close();
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"UAC playback has stopped".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let accepted = runtime.write(&self.playback.inner.config, pcm);
|
||||||
|
Ok((accepted, runtime.state()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UacSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut active = self.playback.inner.active_session.lock().unwrap();
|
||||||
|
if active
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|session| Arc::ptr_eq(session, &self.runtime))
|
||||||
|
{
|
||||||
|
*active = None;
|
||||||
|
}
|
||||||
|
drop(active);
|
||||||
|
self.runtime.lock().unwrap().close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_sink(
|
||||||
|
sink: SessionSink,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
samples: &[i16],
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match sink {
|
||||||
|
SessionSink::Closed { retry_at } => {
|
||||||
|
if retry_at.is_some_and(|deadline| Instant::now() < deadline) {
|
||||||
|
return (SessionSink::Closed { retry_at }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match open_pcm(config).and_then(|pcm| {
|
||||||
|
prime_pcm_with_silence(&pcm, config.channels as usize)?;
|
||||||
|
Ok(pcm)
|
||||||
|
}) {
|
||||||
|
Ok(pcm) => drive_probe(pcm, false, config, samples),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Failed to open UAC playback device; retrying later: {error}");
|
||||||
|
(
|
||||||
|
SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now() + RETRY_BACKOFF),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionSink::Probing { pcm, stalled } => drive_probe(pcm, stalled, config, samples),
|
||||||
|
SessionSink::Active { pcm, last_progress } => {
|
||||||
|
drive_active(pcm, last_progress, config, samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_probe(
|
||||||
|
pcm: PCM,
|
||||||
|
stalled: bool,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
samples: &[i16],
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match sink_is_consuming(&pcm) {
|
||||||
|
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
|
||||||
|
Ok(true) => {
|
||||||
|
if let Err(error) = reset_pcm_buffer(&pcm) {
|
||||||
|
warn!("Failed to activate UAC playback; retrying later: {error}");
|
||||||
|
return retry_later();
|
||||||
|
}
|
||||||
|
info!("UAC target started consuming microphone audio");
|
||||||
|
drive_active(pcm, Instant::now(), config, samples)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Failed to probe UAC playback; retrying later: {error}");
|
||||||
|
retry_later()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_active(
|
||||||
|
pcm: PCM,
|
||||||
|
last_progress: Instant,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
samples: &[i16],
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match write_pcm_nonblocking(&pcm, samples, config.channels as usize) {
|
||||||
|
Ok(WriteOutcome::Progress) => (
|
||||||
|
SessionSink::Active {
|
||||||
|
pcm,
|
||||||
|
last_progress: Instant::now(),
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
Ok(WriteOutcome::Recovered) => (
|
||||||
|
SessionSink::Active {
|
||||||
|
pcm,
|
||||||
|
last_progress: Instant::now(),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Ok(WriteOutcome::Blocked) if last_progress.elapsed() < SINK_STALL_TIMEOUT => {
|
||||||
|
(SessionSink::Active { pcm, last_progress }, false)
|
||||||
|
}
|
||||||
|
Ok(WriteOutcome::Blocked) => {
|
||||||
|
if let Err(error) = reset_pcm_buffer(&pcm)
|
||||||
|
.and_then(|_| prime_pcm_with_silence(&pcm, config.channels as usize))
|
||||||
|
{
|
||||||
|
warn!("Failed to reset stalled UAC playback: {error}");
|
||||||
|
return retry_later();
|
||||||
|
}
|
||||||
|
info!("UAC target stopped consuming audio; waiting for playback activity");
|
||||||
|
(SessionSink::Probing { pcm, stalled: true }, false)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!("UAC playback write failed; retrying later: {error}");
|
||||||
|
retry_later()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_later() -> (SessionSink, bool) {
|
||||||
|
(
|
||||||
|
SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now() + RETRY_BACKOFF),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
||||||
|
let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| {
|
||||||
|
AppError::AudioError(format!(
|
||||||
|
"Failed to open UAC device {}: {error}",
|
||||||
|
config.device_name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
{
|
||||||
|
let params = HwParams::any(&pcm)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("UAC HwParams failed: {error}")))?;
|
||||||
|
params
|
||||||
|
.set_channels(config.channels as u32)
|
||||||
|
.and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest))
|
||||||
|
.and_then(|_| params.set_format(Format::s16()))
|
||||||
|
.and_then(|_| params.set_access(Access::RWInterleaved))
|
||||||
|
.and_then(|_| params.set_period_size_near(PERIOD_FRAMES, ValueOr::Nearest))
|
||||||
|
.and_then(|_| params.set_buffer_size_near(BUFFER_FRAMES))
|
||||||
|
.and_then(|_| pcm.hw_params(¶ms))
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to configure UAC playback: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (buffer_frames, period_frames) = pcm.get_params().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to read UAC PCM parameters: {error}"))
|
||||||
|
})?;
|
||||||
|
{
|
||||||
|
let params = pcm.sw_params_current().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to read UAC SwParams: {error}"))
|
||||||
|
})?;
|
||||||
|
let start_threshold =
|
||||||
|
(period_frames as Frames * START_THRESHOLD_PERIODS).min(buffer_frames as Frames);
|
||||||
|
params
|
||||||
|
.set_start_threshold(start_threshold)
|
||||||
|
.and_then(|_| params.set_avail_min(period_frames as Frames))
|
||||||
|
.and_then(|_| pcm.sw_params(¶ms))
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to configure UAC SwParams: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
pcm.prepare().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to prepare UAC playback: {error}"))
|
||||||
|
})?;
|
||||||
|
info!(
|
||||||
|
"UAC playback opened on {} (buffer={} frames, period={} frames)",
|
||||||
|
config.device_name, buffer_frames, period_frames
|
||||||
|
);
|
||||||
|
Ok(pcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum WriteOutcome {
|
||||||
|
Progress,
|
||||||
|
Blocked,
|
||||||
|
Recovered,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_pcm_nonblocking(pcm: &PCM, samples: &[i16], channels: usize) -> Result<WriteOutcome> {
|
||||||
|
let total_frames = samples.len() / channels;
|
||||||
|
match pcm.avail() {
|
||||||
|
Ok(available) if available < total_frames as Frames => return Ok(WriteOutcome::Blocked),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => {
|
||||||
|
recover_pcm(pcm, error)?;
|
||||||
|
return Ok(WriteOutcome::Recovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let io = pcm
|
||||||
|
.io_i16()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
|
||||||
|
match io.writei(samples) {
|
||||||
|
Ok(0) => Ok(WriteOutcome::Blocked),
|
||||||
|
Ok(_) => Ok(WriteOutcome::Progress),
|
||||||
|
Err(error) if error.errno() == libc::EAGAIN => Ok(WriteOutcome::Blocked),
|
||||||
|
Err(error) => {
|
||||||
|
recover_pcm(pcm, error)?;
|
||||||
|
Ok(WriteOutcome::Recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once a full playback buffer gains at least one period of free space, the
|
||||||
|
/// USB host has enabled the UAC streaming interface and is consuming samples.
|
||||||
|
fn sink_is_consuming(pcm: &PCM) -> Result<bool> {
|
||||||
|
if pcm.state() == State::XRun {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
match pcm.avail() {
|
||||||
|
Ok(available) => Ok(available >= PERIOD_FRAMES),
|
||||||
|
Err(error) if error.errno() == libc::EPIPE => Ok(true),
|
||||||
|
Err(error) => Err(AppError::AudioError(format!(
|
||||||
|
"Failed to query UAC playback availability: {error}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_pcm(pcm: &PCM, error: alsa::Error) -> Result<()> {
|
||||||
|
let errno = error.errno();
|
||||||
|
pcm.try_recover(error, true).map_err(|recover_error| {
|
||||||
|
AppError::AudioError(format!("Failed to recover UAC playback: {recover_error}"))
|
||||||
|
})?;
|
||||||
|
if matches!(errno, libc::EPIPE | libc::ESTRPIPE) {
|
||||||
|
warn!("Recovered UAC playback after ALSA error {errno}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_pcm_buffer(pcm: &PCM) -> Result<()> {
|
||||||
|
pcm.drop()
|
||||||
|
.and_then(|_| pcm.prepare())
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prime the non-blocking ALSA buffer with silence. Subsequent WebSocket
|
||||||
|
/// frames inspect buffer progress to detect when the USB host starts reading.
|
||||||
|
fn prime_pcm_with_silence(pcm: &PCM, channels: usize) -> Result<()> {
|
||||||
|
let silence = vec![0i16; BUFFER_FRAMES as usize * channels];
|
||||||
|
let io = pcm
|
||||||
|
.io_i16()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
|
||||||
|
let mut frame_offset = 0usize;
|
||||||
|
while frame_offset < BUFFER_FRAMES as usize {
|
||||||
|
match io.writei(&silence[frame_offset * channels..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(written) => frame_offset += written,
|
||||||
|
Err(error) if error.errno() == libc::EAGAIN => break,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Failed to prime UAC PCM with silence: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permits_only_one_microphone_session() {
|
||||||
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
let first = playback.acquire_session().unwrap();
|
||||||
|
assert_eq!(first.state(), UacPlaybackState::Waiting);
|
||||||
|
assert!(playback.acquire_session().is_err());
|
||||||
|
|
||||||
|
drop(first);
|
||||||
|
assert!(playback.acquire_session().is_ok());
|
||||||
|
playback.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_rejects_new_and_existing_session_writes() {
|
||||||
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
let session = playback.acquire_session().unwrap();
|
||||||
|
|
||||||
|
playback.stop();
|
||||||
|
|
||||||
|
assert!(session.try_write(&[0, 0]).is_err());
|
||||||
|
assert!(playback.acquire_session().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_incomplete_stereo_frames_before_opening_alsa() {
|
||||||
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
let session = playback.acquire_session().unwrap();
|
||||||
|
|
||||||
|
assert!(session.try_write(&[0]).is_err());
|
||||||
|
assert_eq!(session.state(), UacPlaybackState::Waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_sink_state_reflects_retry_backoff() {
|
||||||
|
assert_eq!(
|
||||||
|
SessionSink::Closed { retry_at: None }.state(),
|
||||||
|
UacPlaybackState::Waiting
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now())
|
||||||
|
}
|
||||||
|
.state(),
|
||||||
|
UacPlaybackState::Stalled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/audio/uac/protocol.rs
Normal file
98
src/audio/uac/protocol.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const HEADER_SIZE: usize = 15;
|
||||||
|
const OPUS_MESSAGE: u8 = 0x03;
|
||||||
|
const PCM_MESSAGE: u8 = 0x04;
|
||||||
|
const CHANNELS: usize = 2;
|
||||||
|
const MAX_PCM_SAMPLES: usize = 5760 * CHANNELS;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum UacAudioPacket<'a> {
|
||||||
|
Opus(&'a [u8]),
|
||||||
|
Pcm(&'a [u8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacAudioPacket<'_> {
|
||||||
|
pub fn pcm_samples(&self) -> Result<Vec<i16>> {
|
||||||
|
let Self::Pcm(bytes) = self else {
|
||||||
|
return Err(AppError::BadRequest("packet is not raw PCM".to_string()));
|
||||||
|
};
|
||||||
|
if bytes.is_empty() || bytes.len() % (CHANNELS * 2) != 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"invalid stereo PCM byte length {}",
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if bytes.len() / 2 > MAX_PCM_SAMPLES {
|
||||||
|
return Err(AppError::BadRequest("PCM frame exceeds 120 ms".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_audio_packet(data: &[u8]) -> Result<UacAudioPacket<'_>> {
|
||||||
|
if data.len() < HEADER_SIZE {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC frame is shorter than its header".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
|
||||||
|
let expected_len = HEADER_SIZE
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.ok_or_else(|| AppError::BadRequest("UAC payload length overflow".to_string()))?;
|
||||||
|
if data.len() != expected_len {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC payload length does not match its header".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = &data[HEADER_SIZE..];
|
||||||
|
match data[0] {
|
||||||
|
OPUS_MESSAGE => Ok(UacAudioPacket::Opus(payload)),
|
||||||
|
PCM_MESSAGE => Ok(UacAudioPacket::Pcm(payload)),
|
||||||
|
message_type => Err(AppError::BadRequest(format!(
|
||||||
|
"unsupported UAC message type 0x{message_type:02x}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn message(message_type: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut data = vec![0; HEADER_SIZE + payload.len()];
|
||||||
|
data[0] = message_type;
|
||||||
|
data[11..15].copy_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||||
|
data[HEADER_SIZE..].copy_from_slice(payload);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_exact_payload_length() {
|
||||||
|
let valid = message(OPUS_MESSAGE, &[1, 2, 3]);
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_packet(&valid).unwrap(),
|
||||||
|
UacAudioPacket::Opus(&[1, 2, 3])
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut trailing = valid.clone();
|
||||||
|
trailing.push(4);
|
||||||
|
assert!(parse_audio_packet(&trailing).is_err());
|
||||||
|
assert!(parse_audio_packet(&valid[..valid.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_little_endian_stereo_pcm() {
|
||||||
|
let data = message(PCM_MESSAGE, &[1, 0, 255, 255]);
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_packet(&data).unwrap().pcm_samples().unwrap(),
|
||||||
|
vec![1, -1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
use std::io::Write;
|
|
||||||
use std::process::{Child, Command, Stdio};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tokio::sync::{mpsc, watch};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
|
||||||
|
|
||||||
/// Kill aplay after this much idle time (no incoming audio frames).
|
|
||||||
const IDLE_CLOSE_TIMEOUT_MS: u64 = 2000;
|
|
||||||
|
|
||||||
/// Configuration for the UAC playback stream.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UacPlaybackConfig {
|
|
||||||
pub device_name: String,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for UacPlaybackConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: crate::otg::uac::find_uac_pcm_device()
|
|
||||||
.unwrap_or_else(crate::otg::uac::uac_pcm_device),
|
|
||||||
sample_rate: 48000,
|
|
||||||
channels: 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UacPcmFrame {
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub duration_ms: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes PCM to the UAC gadget via `aplay` subprocess — same
|
|
||||||
/// mechanism as the successful manual test: `ffmpeg | aplay hw:0,0`.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct UacPlaybackWriter {
|
|
||||||
pcm_sender: mpsc::Sender<UacPcmFrame>,
|
|
||||||
stop_tx: watch::Sender<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UacPlaybackWriter {
|
|
||||||
pub fn start(config: UacPlaybackConfig) -> Result<Self> {
|
|
||||||
let (pcm_sender, pcm_receiver) = mpsc::channel::<UacPcmFrame>(64);
|
|
||||||
let (stop_tx, stop_rx) = watch::channel(false);
|
|
||||||
|
|
||||||
let device = config.device_name;
|
|
||||||
let rate = config.sample_rate;
|
|
||||||
let ch = config.channels;
|
|
||||||
|
|
||||||
let thread_device = device.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("uac-aplay".into())
|
|
||||||
.spawn(move || {
|
|
||||||
Self::playback_loop(&thread_device, rate, ch, pcm_receiver, stop_rx);
|
|
||||||
info!("UAC aplay thread stopped");
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Internal(format!("spawn: {e}")))?;
|
|
||||||
|
|
||||||
info!("UAC aplay writer started on {device}");
|
|
||||||
Ok(Self { pcm_sender, stop_tx })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn write(&self, frame: UacPcmFrame) -> Result<()> {
|
|
||||||
self.pcm_sender.send(frame).await
|
|
||||||
.map_err(|_| AppError::Internal("UAC channel closed".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
let _ = self.stop_tx.send(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── internals ──────────────────────────────────────────
|
|
||||||
|
|
||||||
fn spawn_aplay(device: &str, rate: u32, ch: u16) -> Option<(Child, Box<dyn Write + Send>)> {
|
|
||||||
let mut cmd = Command::new("aplay");
|
|
||||||
cmd.arg("-D").arg(device)
|
|
||||||
.arg("-f").arg("S16_LE")
|
|
||||||
.arg("-r").arg(rate.to_string())
|
|
||||||
.arg("-c").arg(ch.to_string())
|
|
||||||
.arg("-") // stdin
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::inherit()); // → journalctl
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok(mut child) => {
|
|
||||||
let stdin = child.stdin.take()?;
|
|
||||||
info!("aplay spawned pid={}", child.id());
|
|
||||||
Some((child, Box::new(stdin)))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("aplay spawn failed: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_aplay(mut child: Child, stdin: Box<dyn Write + Send>) {
|
|
||||||
drop(stdin); // close pipe → EOF for aplay
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn playback_loop(
|
|
||||||
device: &str,
|
|
||||||
rate: u32,
|
|
||||||
ch: u16,
|
|
||||||
mut receiver: mpsc::Receiver<UacPcmFrame>,
|
|
||||||
mut stop_rx: watch::Receiver<bool>,
|
|
||||||
) {
|
|
||||||
let idle_timeout = Duration::from_millis(IDLE_CLOSE_TIMEOUT_MS);
|
|
||||||
let mut aplay: Option<(Child, Box<dyn Write + Send>)> = None;
|
|
||||||
let mut last_write = std::time::Instant::now();
|
|
||||||
let mut frame_count: u64 = 0;
|
|
||||||
let mut byte_count: u64 = 0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// ── wait for frame ──────────────────────────
|
|
||||||
let need_timeout = aplay.is_some()
|
|
||||||
&& last_write.elapsed() >= idle_timeout;
|
|
||||||
let deadline = if need_timeout || aplay.is_none() {
|
|
||||||
Some(std::time::Instant::now() + Duration::from_millis(200))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let frame = loop {
|
|
||||||
if *stop_rx.borrow() { break None; }
|
|
||||||
match receiver.try_recv() {
|
|
||||||
Ok(f) => break Some(f),
|
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => break None,
|
|
||||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
|
||||||
}
|
|
||||||
if let Some(dl) = deadline {
|
|
||||||
if std::time::Instant::now() >= dl {
|
|
||||||
break None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
|
||||||
};
|
|
||||||
|
|
||||||
if *stop_rx.borrow() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
match frame {
|
|
||||||
Some(f) => {
|
|
||||||
last_write = std::time::Instant::now();
|
|
||||||
|
|
||||||
// Ensure aplay is alive
|
|
||||||
if aplay.is_none() {
|
|
||||||
aplay = Self::spawn_aplay(device, rate, ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((child, stdin)) = aplay.as_mut() {
|
|
||||||
// Check child health
|
|
||||||
match child.try_wait() {
|
|
||||||
Ok(Some(status)) => {
|
|
||||||
warn!("aplay died: {status}");
|
|
||||||
aplay = Self::spawn_aplay(device, rate, ch);
|
|
||||||
if aplay.is_none() { continue; }
|
|
||||||
}
|
|
||||||
Ok(None) => {} // alive
|
|
||||||
Err(e) => {
|
|
||||||
warn!("aplay wait error: {e}");
|
|
||||||
aplay = None;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((child, stdin)) = aplay.as_mut() {
|
|
||||||
match stdin.write_all(&f.data) {
|
|
||||||
Ok(()) => {
|
|
||||||
let _ = stdin.flush();
|
|
||||||
frame_count += 1;
|
|
||||||
byte_count += f.data.len() as u64;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("aplay write error: {e}");
|
|
||||||
// aplay died — reap and restart
|
|
||||||
if let Some((c, s)) = aplay.take() {
|
|
||||||
Self::kill_aplay(c, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Timeout — kill aplay
|
|
||||||
if let Some((c, s)) = aplay.take() {
|
|
||||||
Self::kill_aplay(c, s);
|
|
||||||
}
|
|
||||||
if *stop_rx.borrow() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
if let Some((c, s)) = aplay.take() {
|
|
||||||
Self::kill_aplay(c, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
use audiopus::coder::Decoder as OpusDecoder;
|
|
||||||
use audiopus::{Channels, SampleRate};
|
|
||||||
use axum::extract::ws::{Message, WebSocket};
|
|
||||||
use tracing::{debug, error, info, warn};
|
|
||||||
|
|
||||||
use super::uac_streamer::{UacPcmFrame, UacPlaybackWriter};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// Binary protocol header for UAC audio input.
|
|
||||||
///
|
|
||||||
/// 0x03 — message type (reverse audio / microphone passthrough)
|
|
||||||
/// timestamp — u32 LE (milliseconds, for future sync)
|
|
||||||
/// duration — u16 LE (frame duration in ms, typically 20)
|
|
||||||
/// sequence — u32 LE (frame counter, for loss detection)
|
|
||||||
/// data_len — u32 LE (Opus payload length in bytes)
|
|
||||||
const UAC_AUDIO_HEADER_SIZE: usize = 15;
|
|
||||||
const UAC_AUDIO_MSG_TYPE: u8 = 0x03;
|
|
||||||
|
|
||||||
/// Accept incoming Opus audio frames over WebSocket and route them
|
|
||||||
/// to the UAC playback writer.
|
|
||||||
pub async fn handle_uac_audio_ws(
|
|
||||||
mut ws: WebSocket,
|
|
||||||
playback: Arc<UacPlaybackWriter>,
|
|
||||||
) {
|
|
||||||
// Create an Opus decoder: 48kHz stereo → PCM S16LE.
|
|
||||||
let mut decoder = match OpusDecoder::new(SampleRate::Hz48000, Channels::Stereo) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to create Opus decoder: {}", e);
|
|
||||||
let _ = ws.send(Message::Close(None)).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("UAC audio WebSocket connected (mic passthrough)");
|
|
||||||
|
|
||||||
while let Some(msg) = ws.recv().await {
|
|
||||||
let msg = match msg {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("UAC WebSocket error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Message::Binary(data) => {
|
|
||||||
if data.len() < UAC_AUDIO_HEADER_SIZE {
|
|
||||||
warn!(
|
|
||||||
"UAC audio frame too short: {} bytes (min {})",
|
|
||||||
data.len(),
|
|
||||||
UAC_AUDIO_HEADER_SIZE
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg_type = data[0];
|
|
||||||
if msg_type == 0x04 {
|
|
||||||
// Raw PCM passthrough — no Opus decode needed.
|
|
||||||
// Useful for testing and for clients that encode locally.
|
|
||||||
let duration = u16::from_le_bytes([data[5], data[6]]);
|
|
||||||
let data_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
|
|
||||||
if data.len() < UAC_AUDIO_HEADER_SIZE + data_len {
|
|
||||||
warn!("UAC PCM frame truncated");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pcm_bytes = &data[UAC_AUDIO_HEADER_SIZE..UAC_AUDIO_HEADER_SIZE + data_len];
|
|
||||||
if let Err(e) = playback
|
|
||||||
.write(super::uac_streamer::UacPcmFrame {
|
|
||||||
data: pcm_bytes.to_vec(),
|
|
||||||
duration_ms: duration as u32,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send UAC PCM frame: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if msg_type != UAC_AUDIO_MSG_TYPE {
|
|
||||||
warn!("UAC unknown msg type: 0x{msg_type:02x}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let duration = u16::from_le_bytes([data[5], data[6]]);
|
|
||||||
let data_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
|
|
||||||
|
|
||||||
if data.len() < UAC_AUDIO_HEADER_SIZE + data_len {
|
|
||||||
warn!("UAC audio frame truncated");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let opus_payload = &data[UAC_AUDIO_HEADER_SIZE..UAC_AUDIO_HEADER_SIZE + data_len];
|
|
||||||
|
|
||||||
let frame_samples = (48000u32 * duration as u32 / 1000) as usize * 2; // 2 channels
|
|
||||||
let mut pcm_i16 = vec![0i16; frame_samples];
|
|
||||||
|
|
||||||
match decoder.decode(Some(opus_payload), &mut pcm_i16, false) {
|
|
||||||
Ok(decoded) => {
|
|
||||||
// Convert i16 → bytes (S16LE interleaved)
|
|
||||||
let pcm_bytes: Vec<u8> = pcm_i16[..decoded]
|
|
||||||
.iter()
|
|
||||||
.flat_map(|s| s.to_le_bytes())
|
|
||||||
.collect();
|
|
||||||
if let Err(e) = playback
|
|
||||||
.write(UacPcmFrame {
|
|
||||||
data: pcm_bytes,
|
|
||||||
duration_ms: duration as u32,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send UAC PCM frame: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Opus decode error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Message::Ping(_) | Message::Pong(_) => {}
|
|
||||||
Message::Close(_) => {
|
|
||||||
debug!("UAC audio WebSocket closing");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Message::Text(_) => {
|
|
||||||
// Ignore text messages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("UAC audio WebSocket disconnected");
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ mod computer_use;
|
|||||||
mod hid;
|
mod hid;
|
||||||
mod otg_network;
|
mod otg_network;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
mod uac;
|
||||||
mod watchdog;
|
mod watchdog;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ pub use computer_use::*;
|
|||||||
pub use hid::*;
|
pub use hid::*;
|
||||||
pub use otg_network::*;
|
pub use otg_network::*;
|
||||||
pub use stream::*;
|
pub use stream::*;
|
||||||
|
pub use uac::*;
|
||||||
pub use watchdog::*;
|
pub use watchdog::*;
|
||||||
pub use web::*;
|
pub use web::*;
|
||||||
|
|
||||||
@@ -44,7 +46,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,
|
pub uac: UacConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
|||||||
78
src/config/schema/uac.rs
Normal file
78
src/config/schema/uac.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
/// Configuration for the USB Audio Class microphone gadget.
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UacConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UacConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacConfig {
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
// Older configurations stored zero-valued placeholders while UAC was
|
||||||
|
// disabled. Accept them until the feature is enabled and normalized.
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.sample_rate != 48_000 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unsupported UAC sample rate {} (expected 48000)",
|
||||||
|
self.sample_rate
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.channels != 2 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unsupported UAC channel count {} (expected 2)",
|
||||||
|
self.channels
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_the_audio_transport() {
|
||||||
|
let config = UacConfig::default();
|
||||||
|
assert_eq!(config.sample_rate, 48_000);
|
||||||
|
assert_eq!(config.channels, 2);
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_formats_the_transport_cannot_convert() {
|
||||||
|
assert!(UacConfig {
|
||||||
|
enabled: true,
|
||||||
|
sample_rate: 44_100,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err());
|
||||||
|
assert!(UacConfig {
|
||||||
|
enabled: true,
|
||||||
|
channels: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -579,14 +579,16 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
data_dir.clone(),
|
data_dir.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize UAC playback writer if UAC is enabled
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// Initialize UAC playback writer if UAC is enabled.
|
||||||
if config.uac.enabled {
|
if config.uac.enabled {
|
||||||
let uac_cfg = one_kvm::audio::uac_streamer::UacPlaybackConfig {
|
let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig {
|
||||||
sample_rate: config.uac.sample_rate,
|
sample_rate: config.uac.sample_rate,
|
||||||
channels: config.uac.channels as u16,
|
channels: config.uac.channels as u16,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match one_kvm::audio::uac_streamer::UacPlaybackWriter::start(uac_cfg) {
|
match one_kvm::audio::uac::UacPlayback::start(uac_cfg) {
|
||||||
Ok(writer) => {
|
Ok(writer) => {
|
||||||
*state.uac_playback.write().await = Some(writer);
|
*state.uac_playback.write().await = Some(writer);
|
||||||
tracing::info!("UAC playback writer started");
|
tracing::info!("UAC playback writer started");
|
||||||
@@ -596,6 +598,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.watchdog.enabled {
|
if config.watchdog.enabled {
|
||||||
if let Err(error) = state.watchdog.enable().await {
|
if let Err(error) = state.watchdog.enable().await {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ 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, UacConfig};
|
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use uac::UacFunction;
|
pub use uac::UacFunction;
|
||||||
|
|
||||||
|
|||||||
@@ -9,39 +9,10 @@ use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
|||||||
use super::msd::MsdFunction;
|
use super::msd::MsdFunction;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
||||||
|
UacConfig,
|
||||||
};
|
};
|
||||||
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>,
|
||||||
@@ -92,7 +63,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,
|
pub uac: UacConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OtgDesiredState {
|
impl Default for OtgDesiredState {
|
||||||
@@ -105,7 +76,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,
|
uac: UacConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,8 +98,7 @@ impl OtgDesiredState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
hid.validate_otg_functions()?;
|
hid.validate_otg_functions()?;
|
||||||
let needs_udc =
|
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled;
|
||||||
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()
|
||||||
@@ -146,7 +116,11 @@ 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,
|
uac: if uac.enabled {
|
||||||
|
uac.clone()
|
||||||
|
} else {
|
||||||
|
UacConfig::default()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +143,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 uac: UacConfig,
|
||||||
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>,
|
||||||
@@ -187,7 +161,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,
|
uac: UacConfig::default(),
|
||||||
configured_udc: None,
|
configured_udc: None,
|
||||||
hid_paths: None,
|
hid_paths: None,
|
||||||
hid_functions: None,
|
hid_functions: None,
|
||||||
@@ -349,7 +323,7 @@ impl OtgService {
|
|||||||
desired.hid_enabled(),
|
desired.hid_enabled(),
|
||||||
desired.msd_enabled,
|
desired.msd_enabled,
|
||||||
desired.network_enabled(),
|
desired.network_enabled(),
|
||||||
desired.uac_enabled,
|
desired.uac.enabled,
|
||||||
desired.udc
|
desired.udc
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -361,7 +335,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.uac == desired.uac
|
||||||
&& 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
|
||||||
@@ -401,7 +375,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.uac = UacConfig::default();
|
||||||
state.configured_udc = None;
|
state.configured_udc = None;
|
||||||
state.hid_paths = None;
|
state.hid_paths = None;
|
||||||
state.hid_functions = None;
|
state.hid_functions = None;
|
||||||
@@ -410,7 +384,11 @@ impl OtgService {
|
|||||||
state.error = None;
|
state.error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() {
|
if !desired.hid_enabled()
|
||||||
|
&& !desired.msd_enabled
|
||||||
|
&& !desired.network_enabled()
|
||||||
|
&& !desired.uac.enabled
|
||||||
|
{
|
||||||
info!("OTG desired state is empty, gadget removed");
|
info!("OTG desired state is empty, gadget removed");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -440,12 +418,12 @@ impl OtgService {
|
|||||||
// lower hardware endpoint number. DWC3 seems to have
|
// lower hardware endpoint number. DWC3 seems to have
|
||||||
// trouble with isochronous transfers on higher-numbered
|
// trouble with isochronous transfers on higher-numbered
|
||||||
// endpoints when they follow interrupt endpoints.
|
// endpoints when they follow interrupt endpoints.
|
||||||
let _uac_func = if desired.uac_enabled {
|
let _uac_func = if desired.uac.enabled {
|
||||||
let sample_rate: u32 = 48000;
|
Some(
|
||||||
let channels: u8 = 2;
|
manager
|
||||||
Some(manager.add_uac(sample_rate, channels).map_err(|e| {
|
.add_uac(desired.uac.sample_rate, desired.uac.channels)
|
||||||
AppError::Internal(format!("Failed to add UAC function: {e}"))
|
.map_err(|e| AppError::Internal(format!("Failed to add UAC function: {e}")))?,
|
||||||
})?)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -594,7 +572,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.uac = desired.uac.clone();
|
||||||
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;
|
||||||
@@ -732,7 +710,8 @@ mod tests {
|
|||||||
..OtgNetworkConfig::default()
|
..OtgNetworkConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap();
|
let desired =
|
||||||
|
OtgDesiredState::from_config(&hid, &msd, &network, &UacConfig::default()).unwrap();
|
||||||
|
|
||||||
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
|
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
|
||||||
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));
|
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use super::configfs::{create_dir, create_symlink, remove_dir, write_file};
|
|||||||
use super::function::GadgetFunction;
|
use super::function::GadgetFunction;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
/// USB Audio Class 2.0 (UAC1) gadget function.
|
/// USB Audio Class 1.0 gadget function.
|
||||||
///
|
///
|
||||||
/// Creates a virtual USB microphone that the USB host sees as a standard
|
/// 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
|
/// USB audio input device. Audio written to the PCM playback device on the
|
||||||
@@ -60,13 +60,18 @@ impl GadgetFunction for UacFunction {
|
|||||||
let chmask: u32 = (1u32 << self.channels) - 1;
|
let chmask: u32 = (1u32 << self.channels) - 1;
|
||||||
write_file(&func_path.join("p_chmask"), &chmask.to_string())?;
|
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_srate"), &self.sample_rate.to_string())?;
|
||||||
write_file(&func_path.join("p_ssize"), "2")?; // 16-bit S16LE
|
// 16-bit S16LE.
|
||||||
|
write_file(&func_path.join("p_ssize"), "2")?;
|
||||||
|
// One decibel per step. The kernel default is 1/256 dB, which creates
|
||||||
|
// 25,600 control values and triggers a UAC volume-range warning.
|
||||||
|
write_file(&func_path.join("p_volume_res"), "256")?;
|
||||||
// UAC1 does not need p_hs_bint — Windows has native built-in
|
// UAC1 does not need p_hs_bint — Windows has native built-in
|
||||||
// UAC1 drivers and handles isochronous streaming automatically.
|
// UAC1 drivers and handles isochronous streaming automatically.
|
||||||
|
|
||||||
// Only enable playback direction (gadget → host = mic).
|
// Only enable playback direction (gadget → host = mic).
|
||||||
// Disabling capture saves one isochronous endpoint.
|
// Disabling capture saves one isochronous endpoint.
|
||||||
write_file(&func_path.join("c_chmask"), "0")?;
|
write_file(&func_path.join("c_chmask"), "0")?;
|
||||||
|
write_file(&func_path.join("c_volume_present"), "0")?;
|
||||||
|
|
||||||
// req_number=4: explicitly allocate 4 USB requests for the
|
// req_number=4: explicitly allocate 4 USB requests for the
|
||||||
// isochronous endpoint. Default (0 = auto) may not be enough
|
// isochronous endpoint. Default (0 = auto) may not be enough
|
||||||
|
|||||||
@@ -77,8 +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>>>,
|
#[cfg(unix)]
|
||||||
pub uac_config: Arc<RwLock<crate::otg::service::UacConfig>>,
|
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
||||||
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>>>>,
|
||||||
@@ -148,8 +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,
|
||||||
|
#[cfg(unix)]
|
||||||
uac_playback: Arc::new(RwLock::new(None)),
|
uac_playback: Arc::new(RwLock::new(None)),
|
||||||
uac_config: Arc::new(RwLock::new(crate::otg::service::UacConfig::default())),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ async fn reconcile_otg_config(
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
uac: &crate::otg::service::UacConfig,
|
uac: &UacConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
{
|
{
|
||||||
@@ -195,6 +195,7 @@ pub async fn apply_hid_config(
|
|||||||
new_config: &HidConfig,
|
new_config: &HidConfig,
|
||||||
msd_config: &MsdConfig,
|
msd_config: &MsdConfig,
|
||||||
network_config: &OtgNetworkConfig,
|
network_config: &OtgNetworkConfig,
|
||||||
|
uac_config: &UacConfig,
|
||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
new_config.validate_otg_functions()?;
|
new_config.validate_otg_functions()?;
|
||||||
@@ -237,7 +238,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, &state.config.get().uac).await?;
|
reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !transitioning_away_from_otg {
|
if !transitioning_away_from_otg {
|
||||||
@@ -263,6 +264,7 @@ pub async fn apply_msd_config(
|
|||||||
new_config: &MsdConfig,
|
new_config: &MsdConfig,
|
||||||
hid_config: &HidConfig,
|
hid_config: &HidConfig,
|
||||||
network_config: &OtgNetworkConfig,
|
network_config: &OtgNetworkConfig,
|
||||||
|
uac_config: &UacConfig,
|
||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
|
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
|
||||||
@@ -305,7 +307,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, &state.config.get().uac).await?;
|
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
|
||||||
|
|
||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
if let Some(msd) = msd_guard.as_mut() {
|
||||||
@@ -340,7 +342,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, &state.config.get().uac).await?;
|
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if hid_config.backend == HidBackend::Otg
|
if hid_config.backend == HidBackend::Otg
|
||||||
@@ -367,10 +369,29 @@ pub async fn apply_usb_config(
|
|||||||
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
|
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
|
||||||
|
|
||||||
let hid_unchanged = old_config.hid == new_config.hid;
|
let hid_unchanged = old_config.hid == new_config.hid;
|
||||||
let otg_gadget_rebuilt =
|
let otg_gadget_rebuilt = old_config.msd != new_config.msd
|
||||||
old_config.msd != new_config.msd
|
|
||||||
|| old_config.otg_network != new_config.otg_network
|
|| old_config.otg_network != new_config.otg_network
|
||||||
|| old_config.uac != new_config.uac;
|
|| old_config.uac != new_config.uac
|
||||||
|
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|
||||||
|
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|
||||||
|
|| old_config.hid.backend != new_config.hid.backend
|
||||||
|
|| old_config.hid.constrained_otg_functions()
|
||||||
|
!= new_config.hid.constrained_otg_functions()
|
||||||
|
|| old_config.hid.effective_otg_keyboard_leds()
|
||||||
|
!= new_config.hid.effective_otg_keyboard_leds();
|
||||||
|
let restart_uac_playback =
|
||||||
|
old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt);
|
||||||
|
|
||||||
|
// A bound ALSA handle refers to the old configfs function. Stop it
|
||||||
|
// before any gadget teardown so the worker cannot write through a
|
||||||
|
// disappearing PCM node. It is restarted only after every reconcile.
|
||||||
|
if restart_uac_playback {
|
||||||
|
let playback = state.uac_playback.write().await.take();
|
||||||
|
if let Some(playback) = playback {
|
||||||
|
playback.stop();
|
||||||
|
tracing::info!("UAC playback writer stopped before OTG reconcile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if transitioning_away_from_otg {
|
if transitioning_away_from_otg {
|
||||||
apply_hid_config(
|
apply_hid_config(
|
||||||
@@ -379,6 +400,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,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -397,6 +419,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,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -408,37 +431,9 @@ pub async fn apply_usb_config(
|
|||||||
if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg {
|
if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg {
|
||||||
tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices");
|
tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices");
|
||||||
let hid_backend = hid_backend_type(&new_config.hid);
|
let hid_backend = hid_backend_type(&new_config.hid);
|
||||||
state
|
state.hid.reload(hid_backend).await.map_err(|e| {
|
||||||
.hid
|
AppError::Config(format!("HID reload after gadget rebuild failed: {}", e))
|
||||||
.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(
|
||||||
@@ -447,9 +442,29 @@ pub async fn apply_usb_config(
|
|||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
// apply_msd_config may perform a second gadget reconcile. Resolve the
|
||||||
|
// new ALSA card only after that final rebuild, then publish the worker.
|
||||||
|
if restart_uac_playback && new_config.uac.enabled {
|
||||||
|
let config = crate::audio::uac::UacPlaybackConfig {
|
||||||
|
sample_rate: new_config.uac.sample_rate,
|
||||||
|
channels: new_config.uac.channels as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| {
|
||||||
|
AppError::Config(format!("Failed to start UAC playback: {error}"))
|
||||||
|
})?;
|
||||||
|
*state.uac_playback.write().await = Some(writer);
|
||||||
|
tracing::info!("UAC playback writer started after OTG reconcile");
|
||||||
|
} else if restart_uac_playback {
|
||||||
|
tracing::info!("UAC playback remains disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
@@ -460,6 +475,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,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ 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,
|
||||||
};
|
};
|
||||||
|
pub use stream::{get_stream_config, update_stream_config};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use uac::{get_uac_config, update_uac_config};
|
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 video::{get_video_config, update_video_config};
|
||||||
pub use vnc::{
|
pub use vnc::{
|
||||||
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
|
||||||
|
use crate::config::UacConfig;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::otg::service::UacConfig;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::apply::try_apply_lock;
|
use super::usb_update::update_usb_config;
|
||||||
|
|
||||||
pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfig> {
|
pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfig> {
|
||||||
Json(state.config.get().uac.clone())
|
Json(state.config.get().uac.clone())
|
||||||
@@ -14,23 +14,13 @@ pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfi
|
|||||||
|
|
||||||
pub async fn update_uac_config(
|
pub async fn update_uac_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<UacConfig>,
|
Json(request): Json<UacConfig>,
|
||||||
) -> Result<Json<UacConfig>> {
|
) -> Result<Json<UacConfig>> {
|
||||||
req.validate()?;
|
request.validate()?;
|
||||||
let _guard = try_apply_lock(&state.config_apply_locks.otg, "uac")?;
|
let config = update_usb_config(&state, move |staged| {
|
||||||
|
staged.uac = request;
|
||||||
let old_config = (*state.config.get()).clone();
|
Ok(None)
|
||||||
let mut new_config = old_config.clone();
|
|
||||||
new_config.uac = req;
|
|
||||||
|
|
||||||
state
|
|
||||||
.config
|
|
||||||
.update(|config| {
|
|
||||||
config.uac = new_config.uac.clone();
|
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(Json(config.uac))
|
||||||
super::apply::apply_usb_config(&state, &old_config, &new_config).await?;
|
|
||||||
|
|
||||||
Ok(Json(state.config.get().uac.clone()))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ where
|
|||||||
staged_config.otg_network.host_mac = host_mac;
|
staged_config.otg_network.host_mac = host_mac;
|
||||||
}
|
}
|
||||||
staged_config.otg_network.validate()?;
|
staged_config.otg_network.validate()?;
|
||||||
|
staged_config.uac.validate()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await {
|
if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await {
|
||||||
@@ -92,6 +93,7 @@ where
|
|||||||
config.hid = staged_config.hid.clone();
|
config.hid = staged_config.hid.clone();
|
||||||
config.msd = staged_config.msd.clone();
|
config.msd = staged_config.msd.clone();
|
||||||
config.otg_network = staged_config.otg_network.clone();
|
config.otg_network = staged_config.otg_network.clone();
|
||||||
|
config.uac = staged_config.uac.clone();
|
||||||
config.enforce_invariants();
|
config.enforce_invariants();
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod error;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
|
#[cfg(unix)]
|
||||||
mod uac_ws;
|
mod uac_ws;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
@@ -11,5 +12,6 @@ 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;
|
||||||
|
#[cfg(unix)]
|
||||||
pub use uac_ws::uac_audio_ws_handler;
|
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;
|
||||||
|
#[cfg(unix)]
|
||||||
use super::uac_ws::uac_audio_ws_handler;
|
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;
|
||||||
@@ -101,7 +102,6 @@ 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))
|
||||||
@@ -264,6 +264,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let user_routes = {
|
let user_routes = {
|
||||||
user_routes
|
user_routes
|
||||||
|
.route("/ws/uac-audio", any(uac_audio_ws_handler))
|
||||||
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
|
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
|
||||||
.route("/config/msd", get(handlers::config::get_msd_config))
|
.route("/config/msd", get(handlers::config::get_msd_config))
|
||||||
.route("/config/msd", patch(handlers::config::update_msd_config))
|
.route("/config/msd", patch(handlers::config::update_msd_config))
|
||||||
@@ -280,14 +281,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
"/otg/network/status",
|
"/otg/network/status",
|
||||||
get(handlers::config::get_otg_network_status),
|
get(handlers::config::get_otg_network_status),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/config/uac", get(handlers::config::get_uac_config))
|
||||||
"/config/uac",
|
.route("/config/uac", patch(handlers::config::update_uac_config))
|
||||||
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))
|
||||||
|
|||||||
@@ -1,33 +1,135 @@
|
|||||||
use axum::extract::ws::WebSocketUpgrade;
|
use std::borrow::Cow;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use std::sync::Arc;
|
use tracing::{debug, info, warn};
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
|
use crate::audio::uac::{
|
||||||
|
parse_audio_packet, UacAudioPacket, UacOpusDecoder, UacPlaybackState, UacSession,
|
||||||
|
};
|
||||||
use crate::state::AppState;
|
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(
|
pub async fn uac_audio_ws_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let playback = {
|
let session = {
|
||||||
let guard = state.uac_playback.read().await;
|
let playback = state.uac_playback.read().await;
|
||||||
match guard.as_ref() {
|
let Some(playback) = playback.as_ref() else {
|
||||||
Some(p) => Arc::new(p.clone()),
|
return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback is disabled").into_response();
|
||||||
None => {
|
};
|
||||||
warn!("UAC audio WS rejected: playback not initialized");
|
match playback.acquire_session() {
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback not initialized").into_response();
|
Ok(session) => session,
|
||||||
|
Err(error) => {
|
||||||
|
return (StatusCode::CONFLICT, error.to_string()).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.on_upgrade(move |socket| {
|
ws.on_upgrade(move |socket| handle_uac_audio(socket, session))
|
||||||
crate::audio::uac_websocket::handle_uac_audio_ws(socket, playback)
|
}
|
||||||
})
|
|
||||||
|
async fn handle_uac_audio(mut socket: WebSocket, session: UacSession) {
|
||||||
|
let mut decoder = match UacOpusDecoder::new() {
|
||||||
|
Ok(decoder) => decoder,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Unable to initialize UAC Opus decoder: {error}");
|
||||||
|
let _ = socket.send(Message::Close(None)).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut dropped_frames = 0u64;
|
||||||
|
info!("UAC microphone WebSocket connected");
|
||||||
|
|
||||||
|
let mut playback_state = session.state();
|
||||||
|
if socket
|
||||||
|
.send(playback_state_message(playback_state))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(message) = socket.recv().await {
|
||||||
|
let message = match message {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("UAC microphone WebSocket failed: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Message::Binary(data) => {
|
||||||
|
let packet = match parse_audio_packet(&data) {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Rejected UAC audio packet: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pcm: Cow<'_, [i16]> = match packet {
|
||||||
|
UacAudioPacket::Opus(payload) => match decoder.decode(payload) {
|
||||||
|
Ok(pcm) => Cow::Borrowed(pcm),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Rejected UAC Opus packet: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
packet @ UacAudioPacket::Pcm(_) => match packet.pcm_samples() {
|
||||||
|
Ok(pcm) => Cow::Owned(pcm),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Rejected UAC PCM packet: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let (accepted, current_state) = match session.try_write(pcm.as_ref()) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("UAC playback stopped: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !accepted {
|
||||||
|
dropped_frames += 1;
|
||||||
|
if dropped_frames == 1 || dropped_frames.is_multiple_of(250) {
|
||||||
|
debug!(
|
||||||
|
"Dropped {dropped_frames} UAC audio frames while the target was unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_state != playback_state
|
||||||
|
&& socket
|
||||||
|
.send(playback_state_message(current_state))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
playback_state = current_state;
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
Message::Ping(_) | Message::Pong(_) => {}
|
||||||
|
Message::Text(_) => debug!("Ignoring text message on UAC audio WebSocket"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("UAC microphone WebSocket disconnected; dropped_frames={dropped_frames}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_state_message(state: UacPlaybackState) -> Message {
|
||||||
|
Message::Text(
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "uac_status",
|
||||||
|
"state": state.as_str(),
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@@ -13,7 +13,6 @@
|
|||||||
"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",
|
||||||
@@ -1941,12 +1940,6 @@
|
|||||||
"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",
|
||||||
@@ -2048,15 +2041,6 @@
|
|||||||
"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",
|
||||||
@@ -2488,16 +2472,6 @@
|
|||||||
"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,7 +14,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ function getToastKey(endpoint: string, config?: ApiRequestConfig): string {
|
|||||||
|
|
||||||
function getErrorMessage(data: unknown, fallback: string): string {
|
function getErrorMessage(data: unknown, fallback: string): string {
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
|
const code = (data as any).code
|
||||||
|
const keyByCode: Record<string, string> = {
|
||||||
|
MSD_MEDIUM_REMOVAL_PREVENTED: 'msd.errors.mediumRemovalPrevented',
|
||||||
|
MSD_DISCONNECT_FAILED: 'msd.errors.disconnectFailed',
|
||||||
|
}
|
||||||
|
const key = typeof code === 'string' ? keyByCode[code] : undefined
|
||||||
|
if (key && hasTranslation(key)) return t(key)
|
||||||
|
|
||||||
const message = (data as any).message
|
const message = (data as any).message
|
||||||
if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message)
|
if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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 {
|
||||||
@@ -32,7 +31,6 @@ import {
|
|||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Mic,
|
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -75,7 +73,6 @@ const props = defineProps<{
|
|||||||
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 showMic = computed(() => props.showMic === true)
|
||||||
const mic = getMicrophone()
|
|
||||||
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -295,7 +292,10 @@ const hasRightOverflow = computed(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Audio Config - Always visible -->
|
<!-- Audio Config - Always visible -->
|
||||||
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
<AudioConfigPopover
|
||||||
|
v-model:open="audioPopoverOpen"
|
||||||
|
:microphone-enabled="showMic"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- HID Config - Always visible -->
|
<!-- HID Config - Always visible -->
|
||||||
<HidConfigPopover
|
<HidConfigPopover
|
||||||
@@ -357,23 +357,6 @@ const hasRightOverflow = computed(() => {
|
|||||||
</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 -->
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { onUnmounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Loader2, RefreshCw, Volume2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
import { audioApi, configApi } from '@/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||||
import { Slider } from '@/components/ui/slider'
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { audioApi, configApi } from '@/api'
|
import MicrophoneTransferControls from '@/components/MicrophoneTransferControls.vue'
|
||||||
|
import { getMicrophone } from '@/composables/useMicrophone'
|
||||||
|
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
name: string
|
name: string
|
||||||
@@ -24,18 +28,26 @@ interface AudioDevice {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
|
microphoneEnabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:open', value: boolean): void
|
(event: 'update:open', value: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const unifiedAudio = getUnifiedAudio()
|
const unifiedAudio = getUnifiedAudio()
|
||||||
|
const microphone = getMicrophone()
|
||||||
|
|
||||||
const localVolume = ref([unifiedAudio.volume.value * 100])
|
const localVolume = ref([unifiedAudio.volume.value * 100])
|
||||||
|
const devices = ref<AudioDevice[]>([])
|
||||||
|
const loadingDevices = ref(false)
|
||||||
|
const applying = ref(false)
|
||||||
|
const audioEnabled = ref(false)
|
||||||
|
const selectedDevice = ref('')
|
||||||
|
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
|
||||||
|
|
||||||
async function handleVolumeChange(value: number[] | undefined) {
|
async function handleVolumeChange(value: number[] | undefined) {
|
||||||
if (!value || value.length === 0 || value[0] === undefined) return
|
if (!value || value.length === 0 || value[0] === undefined) return
|
||||||
@@ -45,29 +57,20 @@ async function handleVolumeChange(value: number[] | undefined) {
|
|||||||
localVolume.value = value
|
localVolume.value = value
|
||||||
|
|
||||||
if (newVolume > 0 && systemStore.audio?.streaming && !unifiedAudio.connected.value) {
|
if (newVolume > 0 && systemStore.audio?.streaming && !unifiedAudio.connected.value) {
|
||||||
console.log('[Audio] User adjusted volume, connecting unified audio')
|
|
||||||
try {
|
try {
|
||||||
await unifiedAudio.connect()
|
await unifiedAudio.connect()
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.info('[Audio] Connect failed:', e)
|
console.info('[Audio] Connect failed:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = ref<AudioDevice[]>([])
|
|
||||||
const loadingDevices = ref(false)
|
|
||||||
const applying = ref(false)
|
|
||||||
|
|
||||||
const audioEnabled = ref(false)
|
|
||||||
const selectedDevice = ref('')
|
|
||||||
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
|
|
||||||
|
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
loadingDevices.value = true
|
loadingDevices.value = true
|
||||||
try {
|
try {
|
||||||
const result = await configApi.listDevices()
|
const result = await configApi.listDevices()
|
||||||
devices.value = result.audio
|
devices.value = result.audio
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.info('[AudioConfig] Failed to load devices')
|
console.info('[AudioConfig] Failed to load devices')
|
||||||
} finally {
|
} finally {
|
||||||
loadingDevices.value = false
|
loadingDevices.value = false
|
||||||
@@ -96,98 +99,78 @@ async function applyConfig() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (audioEnabled.value && selectedDevice.value) {
|
if (audioEnabled.value && selectedDevice.value) {
|
||||||
try {
|
|
||||||
if (localVolume.value[0] === 0) {
|
if (localVolume.value[0] === 0) {
|
||||||
localVolume.value = [100]
|
localVolume.value = [100]
|
||||||
unifiedAudio.setVolume(1)
|
unifiedAudio.setVolume(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
await audioApi.start()
|
await audioApi.start()
|
||||||
} catch (startError) {
|
|
||||||
console.info('[AudioConfig] Audio start failed:', startError)
|
|
||||||
}
|
|
||||||
} else if (!audioEnabled.value) {
|
} else if (!audioEnabled.value) {
|
||||||
localVolume.value = [0]
|
localVolume.value = [0]
|
||||||
unifiedAudio.setVolume(0)
|
unifiedAudio.setVolume(0)
|
||||||
try {
|
|
||||||
await audioApi.stop()
|
await audioApi.stop()
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
unifiedAudio.disconnect()
|
unifiedAudio.disconnect()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.info('[AudioConfig] Failed to apply config:', e)
|
toast.success(t('common.success'))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('common.error'), {
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
applying.value = false
|
applying.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.open, (isOpen) => {
|
watch(() => props.open, isOpen => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
|
|
||||||
if (devices.value.length === 0) {
|
if (devices.value.length === 0) {
|
||||||
loadDevices()
|
void loadDevices()
|
||||||
|
}
|
||||||
|
if (props.microphoneEnabled) {
|
||||||
|
void microphone.refreshInputDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
configStore.refreshAudio()
|
configStore.refreshAudio()
|
||||||
.then(() => {
|
.then(initializeFromCurrent)
|
||||||
initializeFromCurrent()
|
.catch(initializeFromCurrent)
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
initializeFromCurrent()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
void microphone.stop()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
|
>
|
||||||
<Volume2 class="size-3.5 sm:size-4" />
|
<Volume2 class="size-3.5 sm:size-4" />
|
||||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<!-- Playback Control (immediate effect) -->
|
<template v-if="props.microphoneEnabled">
|
||||||
<div class="space-y-3">
|
<MicrophoneTransferControls />
|
||||||
<h5 class="text-xs font-medium text-muted-foreground">
|
|
||||||
{{ t('actionbar.playbackControl') }}
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<!-- Volume -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.volume') }}</Label>
|
|
||||||
<span class="text-xs font-mono">{{ Math.round(localVolume[0] ?? 0) }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Volume2 class="size-3.5 text-muted-foreground opacity-50" />
|
|
||||||
<Slider
|
|
||||||
:model-value="localVolume"
|
|
||||||
@update:model-value="handleVolumeChange"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="1"
|
|
||||||
:disabled="!systemStore.audio?.streaming"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Volume2 class="size-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Settings (requires apply) -->
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Playback volume and capture configuration form one section. -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h5 class="text-xs font-medium text-muted-foreground">
|
<h5 class="text-xs font-medium text-muted-foreground">
|
||||||
{{ t('actionbar.audioDeviceSettings') }}
|
{{ t('actionbar.playbackControl') }}
|
||||||
</h5>
|
</h5>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -199,14 +182,33 @@ watch(() => props.open, (isOpen) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Enable Audio -->
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.volume') }}</Label>
|
||||||
|
<span class="font-mono text-xs">{{ Math.round(localVolume[0] ?? 0) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Volume2 class="size-3.5 text-muted-foreground opacity-50" />
|
||||||
|
<Slider
|
||||||
|
:model-value="localVolume"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!systemStore.audio?.streaming"
|
||||||
|
class="flex-1"
|
||||||
|
@update:model-value="handleVolumeChange"
|
||||||
|
/>
|
||||||
|
<Volume2 class="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioEnabled') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioEnabled') }}</Label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
:variant="audioEnabled ? 'default' : 'outline'"
|
:variant="audioEnabled ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 h-8 text-xs"
|
class="flex-1 text-xs"
|
||||||
@click="audioEnabled = true"
|
@click="audioEnabled = true"
|
||||||
>
|
>
|
||||||
{{ t('common.enabled') }}
|
{{ t('common.enabled') }}
|
||||||
@@ -214,7 +216,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<Button
|
<Button
|
||||||
:variant="!audioEnabled ? 'default' : 'outline'"
|
:variant="!audioEnabled ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 h-8 text-xs"
|
class="flex-1 text-xs"
|
||||||
@click="audioEnabled = false"
|
@click="audioEnabled = false"
|
||||||
>
|
>
|
||||||
{{ t('common.disabled') }}
|
{{ t('common.disabled') }}
|
||||||
@@ -222,14 +224,14 @@ watch(() => props.open, (isOpen) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device Selection -->
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioDevice') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioDevice') }}</Label>
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
:model-value="selectedDevice"
|
:model-value="selectedDevice"
|
||||||
@update:model-value="(v) => selectedDevice = v as string"
|
|
||||||
:disabled="loadingDevices || devices.length === 0"
|
:disabled="loadingDevices || devices.length === 0"
|
||||||
size="sm" class="w-full text-xs"
|
size="sm"
|
||||||
|
class="w-full text-xs"
|
||||||
|
@update:model-value="selectedDevice = $event as string"
|
||||||
>
|
>
|
||||||
<NativeSelectOption value="">{{ t('actionbar.selectAudioDevice') }}</NativeSelectOption>
|
<NativeSelectOption value="">{{ t('actionbar.selectAudioDevice') }}</NativeSelectOption>
|
||||||
<NativeSelectOption
|
<NativeSelectOption
|
||||||
@@ -243,14 +245,13 @@ watch(() => props.open, (isOpen) => {
|
|||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Audio Quality -->
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioQuality') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioQuality') }}</Label>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
:variant="selectedQuality === 'voice' ? 'default' : 'outline'"
|
:variant="selectedQuality === 'voice' ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 h-8 text-xs"
|
class="flex-1 text-xs"
|
||||||
@click="selectedQuality = 'voice'"
|
@click="selectedQuality = 'voice'"
|
||||||
>
|
>
|
||||||
{{ t('actionbar.qualityVoice') }} 32k
|
{{ t('actionbar.qualityVoice') }} 32k
|
||||||
@@ -258,7 +259,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<Button
|
<Button
|
||||||
:variant="selectedQuality === 'balanced' ? 'default' : 'outline'"
|
:variant="selectedQuality === 'balanced' ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 h-8 text-xs"
|
class="flex-1 text-xs"
|
||||||
@click="selectedQuality = 'balanced'"
|
@click="selectedQuality = 'balanced'"
|
||||||
>
|
>
|
||||||
{{ t('actionbar.qualityBalanced') }} 64k
|
{{ t('actionbar.qualityBalanced') }} 64k
|
||||||
@@ -266,7 +267,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<Button
|
<Button
|
||||||
:variant="selectedQuality === 'high' ? 'default' : 'outline'"
|
:variant="selectedQuality === 'high' ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 h-8 text-xs"
|
class="flex-1 text-xs"
|
||||||
@click="selectedQuality = 'high'"
|
@click="selectedQuality = 'high'"
|
||||||
>
|
>
|
||||||
{{ t('actionbar.qualityHigh') }} 128k
|
{{ t('actionbar.qualityHigh') }} 128k
|
||||||
@@ -274,14 +275,14 @@ watch(() => props.open, (isOpen) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Apply Button -->
|
|
||||||
<Button
|
<Button
|
||||||
class="w-full h-8 text-xs"
|
size="sm"
|
||||||
|
class="w-full text-xs"
|
||||||
:disabled="applying"
|
:disabled="applying"
|
||||||
@click="applyConfig"
|
@click="applyConfig"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="applying" class="size-3.5 mr-1.5 animate-spin" />
|
<Loader2 v-if="applying" class="size-3.5 animate-spin" />
|
||||||
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
|
{{ applying ? t('actionbar.applying') : t('common.apply') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|
||||||
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||||
import { configApi } from '@/api'
|
import { configApi } from '@/api'
|
||||||
@@ -343,22 +342,26 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<!-- Device Path (OTG or CH9329) -->
|
<!-- Device Path (OTG or CH9329) -->
|
||||||
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
|
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
|
||||||
<NativeSelect
|
<Select
|
||||||
:model-value="devicePath"
|
:model-value="devicePath"
|
||||||
@update:model-value="handleDevicePathChange"
|
@update:model-value="handleDevicePathChange"
|
||||||
:disabled="availableDevicePaths.length === 0"
|
:disabled="availableDevicePaths.length === 0"
|
||||||
size="sm" class="w-full text-xs"
|
|
||||||
>
|
>
|
||||||
<NativeSelectOption value="">{{ t('actionbar.selectDevice') }}</NativeSelectOption>
|
<SelectTrigger size="sm" class="w-full text-xs">
|
||||||
<NativeSelectOption
|
<SelectValue :placeholder="t('actionbar.selectDevice')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
|
||||||
|
<SelectItem
|
||||||
v-for="device in availableDevicePaths"
|
v-for="device in availableDevicePaths"
|
||||||
:key="device.path"
|
:key="device.path"
|
||||||
:value="device.path"
|
:value="device.path"
|
||||||
|
:text-value="device.name"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
{{ device.name }}
|
<span class="block min-w-0 truncate" :title="device.name">{{ device.name }}</span>
|
||||||
</NativeSelectOption>
|
</SelectItem>
|
||||||
</NativeSelect>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Baudrate (CH9329 only) -->
|
<!-- Baudrate (CH9329 only) -->
|
||||||
|
|||||||
110
web/src/components/MicrophoneTransferControls.vue
Normal file
110
web/src/components/MicrophoneTransferControls.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Loader2, Mic, MicOff, RefreshCw } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||||
|
import { getMicrophone } from '@/composables/useMicrophone'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const microphone = getMicrophone()
|
||||||
|
|
||||||
|
const stateText = computed(() => t(`actionbar.micState.${microphone.state.value}`))
|
||||||
|
const errorText = computed(() => {
|
||||||
|
const code = microphone.errorCode.value
|
||||||
|
return code ? t(`actionbar.micError.${code}`) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function showError() {
|
||||||
|
if (errorText.value) toast.error(errorText.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
await microphone.toggle()
|
||||||
|
showError()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPermission() {
|
||||||
|
const granted = await microphone.refreshInputDevices(true)
|
||||||
|
if (!granted) showError()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDevice(value: unknown) {
|
||||||
|
await microphone.selectInputDevice(String(value ?? ''))
|
||||||
|
showError()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h5 class="text-xs font-medium text-muted-foreground">
|
||||||
|
{{ t('actionbar.microphoneTransfer') }}
|
||||||
|
</h5>
|
||||||
|
<Button
|
||||||
|
v-if="microphone.permissionGranted.value"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
:aria-label="t('common.refresh')"
|
||||||
|
:disabled="microphone.loadingDevices.value || microphone.busy.value"
|
||||||
|
@click="microphone.refreshInputDevices()"
|
||||||
|
>
|
||||||
|
<RefreshCw :class="['size-3.5', microphone.loadingDevices.value && 'animate-spin']" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="!microphone.permissionGranted.value"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="microphone.loadingDevices.value || microphone.busy.value"
|
||||||
|
@click="requestPermission"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="microphone.loadingDevices.value" class="animate-spin" />
|
||||||
|
<Mic v-else />
|
||||||
|
{{ t('actionbar.grantMicrophonePermission') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<NativeSelect
|
||||||
|
:model-value="microphone.selectedDeviceId.value"
|
||||||
|
:disabled="microphone.loadingDevices.value || microphone.busy.value || microphone.inputDevices.value.length === 0"
|
||||||
|
size="sm"
|
||||||
|
class="w-full text-xs"
|
||||||
|
@update:model-value="selectDevice"
|
||||||
|
>
|
||||||
|
<NativeSelectOption v-if="microphone.inputDevices.value.length === 0" value="">
|
||||||
|
{{ t('actionbar.noMicrophoneDevices') }}
|
||||||
|
</NativeSelectOption>
|
||||||
|
<NativeSelectOption
|
||||||
|
v-for="device in microphone.inputDevices.value"
|
||||||
|
:key="device.deviceId"
|
||||||
|
:value="device.deviceId"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ device.label }}
|
||||||
|
</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:variant="microphone.active.value ? 'destructive' : 'default'"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="microphone.busy.value"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="microphone.busy.value" class="animate-spin" />
|
||||||
|
<MicOff v-else-if="microphone.active.value" />
|
||||||
|
<Mic v-else />
|
||||||
|
{{ microphone.busy.value
|
||||||
|
? stateText
|
||||||
|
: microphone.active.value
|
||||||
|
? t('actionbar.micStop')
|
||||||
|
: t('actionbar.micStart') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,7 +5,6 @@ import { toast } from 'vue-sonner'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -318,6 +317,16 @@ const selectedFormatInfo = computed(() =>
|
|||||||
availableFormatOptions.value.find(format => format.format === selectedFormat.value) ?? null
|
availableFormatOptions.value.find(format => format.format === selectedFormat.value) ?? null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedDeviceInfo = computed(() =>
|
||||||
|
devices.value.find(device => device.path === selectedDevice.value) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedResolutionInfo = computed(() =>
|
||||||
|
availableResolutions.value.find(
|
||||||
|
resolution => `${resolution.width}x${resolution.height}` === selectedResolution.value,
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
const selectedCodecInfo = computed(() => {
|
const selectedCodecInfo = computed(() => {
|
||||||
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
|
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
|
||||||
return codec || null
|
return codec || null
|
||||||
@@ -744,22 +753,32 @@ watch(
|
|||||||
<!-- Device Selection -->
|
<!-- Device Selection -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoDevice') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoDevice') }}</Label>
|
||||||
<NativeSelect
|
<Select
|
||||||
:model-value="selectedDevice"
|
:model-value="selectedDevice"
|
||||||
@update:model-value="handleDeviceChange"
|
@update:model-value="handleDeviceChange"
|
||||||
:disabled="loadingDevices || devices.length === 0"
|
:disabled="loadingDevices || devices.length === 0"
|
||||||
size="sm" class="w-full text-xs"
|
|
||||||
>
|
>
|
||||||
<NativeSelectOption value="">{{ loadingDevices ? t('common.loading') : t('actionbar.selectDevice') }}</NativeSelectOption>
|
<SelectTrigger size="sm" class="w-full text-xs">
|
||||||
<NativeSelectOption
|
<span v-if="selectedDeviceInfo" class="min-w-0 truncate">
|
||||||
|
{{ formatVideoDeviceLabel(selectedDeviceInfo) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted-foreground">
|
||||||
|
{{ loadingDevices ? t('common.loading') : t('actionbar.selectDevice') }}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
|
||||||
|
<SelectItem
|
||||||
v-for="device in devices"
|
v-for="device in devices"
|
||||||
:key="device.path"
|
:key="device.path"
|
||||||
:value="device.path"
|
:value="device.path"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
|
<span class="block min-w-0 truncate" :title="formatVideoDeviceLabel(device)">
|
||||||
{{ formatVideoDeviceLabel(device) }}
|
{{ formatVideoDeviceLabel(device) }}
|
||||||
</NativeSelectOption>
|
</span>
|
||||||
</NativeSelect>
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Format Selection -->
|
<!-- Format Selection -->
|
||||||
@@ -825,43 +844,55 @@ watch(
|
|||||||
<!-- Resolution Selection -->
|
<!-- Resolution Selection -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoResolution') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoResolution') }}</Label>
|
||||||
<NativeSelect
|
<Select
|
||||||
:model-value="selectedResolution"
|
:model-value="selectedResolution"
|
||||||
@update:model-value="handleResolutionChange"
|
@update:model-value="handleResolutionChange"
|
||||||
:disabled="!selectedFormat || availableResolutions.length === 0"
|
:disabled="!selectedFormat || availableResolutions.length === 0"
|
||||||
size="sm" class="w-full text-xs"
|
|
||||||
>
|
>
|
||||||
<NativeSelectOption value="">{{ t('actionbar.selectResolution') }}</NativeSelectOption>
|
<SelectTrigger size="sm" class="w-full text-xs">
|
||||||
<NativeSelectOption
|
<span v-if="selectedResolutionInfo">
|
||||||
|
{{ selectedResolutionInfo.width }} × {{ selectedResolutionInfo.height }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted-foreground">{{ t('actionbar.selectResolution') }}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
v-for="res in availableResolutions"
|
v-for="res in availableResolutions"
|
||||||
:key="`${res.width}x${res.height}`"
|
:key="`${res.width}x${res.height}`"
|
||||||
:value="`${res.width}x${res.height}`"
|
:value="`${res.width}x${res.height}`"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
{{ res.width }} x {{ res.height }}
|
{{ res.width }} × {{ res.height }}
|
||||||
</NativeSelectOption>
|
</SelectItem>
|
||||||
</NativeSelect>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FPS Selection -->
|
<!-- FPS Selection -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoFps') }}</Label>
|
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoFps') }}</Label>
|
||||||
<NativeSelect
|
<Select
|
||||||
:model-value="String(selectedFps)"
|
:model-value="String(selectedFps)"
|
||||||
@update:model-value="handleFpsChange"
|
@update:model-value="handleFpsChange"
|
||||||
:disabled="!selectedResolution || availableFps.length === 0"
|
:disabled="!selectedResolution || availableFps.length === 0"
|
||||||
size="sm" class="w-full text-xs"
|
|
||||||
>
|
>
|
||||||
<NativeSelectOption value="">{{ t('actionbar.selectFps') }}</NativeSelectOption>
|
<SelectTrigger size="sm" class="w-full text-xs">
|
||||||
<NativeSelectOption
|
<span v-if="selectedResolution && availableFps.includes(selectedFps)">
|
||||||
|
{{ formatFpsLabel(selectedFps) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted-foreground">{{ t('actionbar.selectFps') }}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
v-for="fps in availableFps"
|
v-for="fps in availableFps"
|
||||||
:key="fps"
|
:key="fps"
|
||||||
:value="String(fps)"
|
:value="String(fps)"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
{{ formatFpsLabel(fps) }}
|
{{ formatFpsLabel(fps) }}
|
||||||
</NativeSelectOption>
|
</SelectItem>
|
||||||
</NativeSelect>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Apply Button -->
|
<!-- Apply Button -->
|
||||||
|
|||||||
@@ -1,161 +1,174 @@
|
|||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
microphoneSupportError,
|
||||||
|
normalizeMicrophoneError,
|
||||||
|
UacMicrophoneSession,
|
||||||
|
type UacMicrophoneErrorCode,
|
||||||
|
type UacTargetState,
|
||||||
|
} from '@/lib/uac-microphone'
|
||||||
|
|
||||||
|
export type MicrophoneTransferState = 'idle' | 'starting' | 'streaming' | 'stopping' | 'error'
|
||||||
|
|
||||||
|
export interface MicrophoneInputDevice {
|
||||||
|
deviceId: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_ENDPOINT = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws/uac-audio`
|
||||||
|
|
||||||
|
export function useMicrophone() {
|
||||||
|
const state = ref<MicrophoneTransferState>('idle')
|
||||||
|
const targetState = ref<UacTargetState>('idle')
|
||||||
|
const errorCode = ref<UacMicrophoneErrorCode | null>(microphoneSupportError())
|
||||||
|
const inputDevices = ref<MicrophoneInputDevice[]>([])
|
||||||
|
const selectedDeviceId = ref('')
|
||||||
|
const permissionGranted = ref(false)
|
||||||
|
const loadingDevices = ref(false)
|
||||||
|
let session: UacMicrophoneSession | null = null
|
||||||
|
|
||||||
|
const active = computed(() => state.value === 'streaming')
|
||||||
|
const busy = computed(() => state.value === 'starting' || state.value === 'stopping')
|
||||||
|
|
||||||
|
async function refreshInputDevices(requestPermission = false) {
|
||||||
|
const supportError = microphoneSupportError()
|
||||||
|
if (supportError) {
|
||||||
|
errorCode.value = supportError
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingDevices.value = true
|
||||||
|
let permissionStream: MediaStream | null = null
|
||||||
|
try {
|
||||||
|
if (requestPermission) {
|
||||||
|
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
permissionGranted.value = true
|
||||||
|
if (!selectedDeviceId.value) {
|
||||||
|
selectedDeviceId.value = permissionStream.getAudioTracks()[0]?.getSettings().deviceId ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableDevices = (await navigator.mediaDevices.enumerateDevices())
|
||||||
|
.filter(device => device.kind === 'audioinput' && device.deviceId)
|
||||||
|
permissionGranted.value = requestPermission
|
||||||
|
|| active.value
|
||||||
|
|| availableDevices.some(device => device.label)
|
||||||
|
const devices = availableDevices
|
||||||
|
.map((device, index) => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
label: device.label || `Microphone ${index + 1}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
inputDevices.value = devices
|
||||||
|
if (!devices.some(device => device.deviceId === selectedDeviceId.value)) {
|
||||||
|
selectedDeviceId.value = devices[0]?.deviceId ?? ''
|
||||||
|
}
|
||||||
|
errorCode.value = null
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const microphoneError = normalizeMicrophoneError(error)
|
||||||
|
errorCode.value = microphoneError.code
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
permissionStream?.getTracks().forEach(track => track.stop())
|
||||||
|
loadingDevices.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (busy.value || active.value) return
|
||||||
|
const supportError = microphoneSupportError()
|
||||||
|
if (supportError) {
|
||||||
|
errorCode.value = supportError
|
||||||
|
state.value = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = 'starting'
|
||||||
|
targetState.value = 'waiting'
|
||||||
|
errorCode.value = null
|
||||||
|
|
||||||
|
const nextSession = new UacMicrophoneSession(
|
||||||
|
WS_ENDPOINT,
|
||||||
|
error => {
|
||||||
|
if (session !== nextSession) return
|
||||||
|
session = null
|
||||||
|
targetState.value = 'idle'
|
||||||
|
errorCode.value = error.code
|
||||||
|
state.value = 'error'
|
||||||
|
},
|
||||||
|
nextState => {
|
||||||
|
if (session !== nextSession) return
|
||||||
|
targetState.value = nextState
|
||||||
|
},
|
||||||
|
selectedDeviceId.value,
|
||||||
|
)
|
||||||
|
session = nextSession
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextSession.start()
|
||||||
|
if (session !== nextSession) {
|
||||||
|
await nextSession.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissionGranted.value = true
|
||||||
|
selectedDeviceId.value = nextSession.activeInputDeviceId || selectedDeviceId.value
|
||||||
|
state.value = 'streaming'
|
||||||
|
void refreshInputDevices()
|
||||||
|
} catch (error) {
|
||||||
|
if (session !== nextSession) return
|
||||||
|
session = null
|
||||||
|
targetState.value = 'idle'
|
||||||
|
const microphoneError = normalizeMicrophoneError(error)
|
||||||
|
errorCode.value = microphoneError.code
|
||||||
|
state.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (state.value === 'idle' || state.value === 'stopping') return
|
||||||
|
state.value = 'stopping'
|
||||||
|
const current = session
|
||||||
|
session = null
|
||||||
|
await current?.stop()
|
||||||
|
targetState.value = 'idle'
|
||||||
|
errorCode.value = microphoneSupportError()
|
||||||
|
state.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (active.value || state.value === 'starting') await stop()
|
||||||
|
else await start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectInputDevice(deviceId: string) {
|
||||||
|
if (deviceId === selectedDeviceId.value) return
|
||||||
|
const wasActive = active.value
|
||||||
|
if (wasActive) await stop()
|
||||||
|
selectedDeviceId.value = deviceId
|
||||||
|
if (wasActive) await start()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
targetState,
|
||||||
|
active,
|
||||||
|
busy,
|
||||||
|
errorCode,
|
||||||
|
inputDevices,
|
||||||
|
selectedDeviceId,
|
||||||
|
permissionGranted,
|
||||||
|
loadingDevices,
|
||||||
|
refreshInputDevices,
|
||||||
|
selectInputDevice,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
toggle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let instance: ReturnType<typeof useMicrophone> | null = null
|
let instance: ReturnType<typeof useMicrophone> | null = null
|
||||||
|
|
||||||
export function getMicrophone() {
|
export function getMicrophone() {
|
||||||
if (!instance) instance = useMicrophone()
|
if (!instance) instance = useMicrophone()
|
||||||
return instance
|
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ export default {
|
|||||||
},
|
},
|
||||||
actionbar: {
|
actionbar: {
|
||||||
paste: 'Paste Text',
|
paste: 'Paste Text',
|
||||||
|
micStart: 'Start Transfer',
|
||||||
|
micStop: 'Stop Transfer',
|
||||||
|
microphoneTransfer: 'Audio Transfer Settings',
|
||||||
|
grantMicrophonePermission: 'Allow Microphone Access',
|
||||||
|
noMicrophoneDevices: 'No audio input devices found',
|
||||||
|
micState: {
|
||||||
|
idle: 'Idle',
|
||||||
|
starting: 'Starting',
|
||||||
|
streaming: 'Streaming',
|
||||||
|
stopping: 'Stopping',
|
||||||
|
error: 'Error',
|
||||||
|
},
|
||||||
|
micError: {
|
||||||
|
'secure-context-required': 'Browsers only allow microphone access on HTTPS pages. Open the console over HTTPS.',
|
||||||
|
unsupported: 'This browser cannot encode microphone audio with WebCodecs. Use a browser with WebCodecs support.',
|
||||||
|
'permission-denied': 'Microphone access was denied. Allow it in your browser site permissions.',
|
||||||
|
'device-unavailable': 'The selected microphone is unavailable. Check the device connection or choose another input.',
|
||||||
|
'connection-failed': 'Could not connect to USB microphone transfer. Check that UAC is enabled and no other session is active.',
|
||||||
|
'encoder-failed': 'The browser audio encoder failed and transfer was stopped.',
|
||||||
|
unknown: 'Microphone transfer failed to start. Check the device and browser permissions.',
|
||||||
|
},
|
||||||
virtualMedia: 'Virtual Media',
|
virtualMedia: 'Virtual Media',
|
||||||
virtualMediaTip: 'Manage virtual media devices',
|
virtualMediaTip: 'Manage virtual media devices',
|
||||||
power: 'Power',
|
power: 'Power',
|
||||||
@@ -142,11 +163,11 @@ export default {
|
|||||||
relative: 'Relative',
|
relative: 'Relative',
|
||||||
applying: 'Applying...',
|
applying: 'Applying...',
|
||||||
audioConfig: 'Audio',
|
audioConfig: 'Audio',
|
||||||
playbackControl: 'Playback',
|
playbackControl: 'Audio Playback Settings',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
audioDeviceSettings: 'Device Settings',
|
audioDeviceSettings: 'Device Settings',
|
||||||
audioEnabled: 'Audio',
|
audioEnabled: 'Audio',
|
||||||
audioDevice: 'Device',
|
audioDevice: 'Audio Input Device',
|
||||||
audioQuality: 'Quality',
|
audioQuality: 'Quality',
|
||||||
qualityVoice: 'Voice',
|
qualityVoice: 'Voice',
|
||||||
qualityBalanced: 'Balanced',
|
qualityBalanced: 'Balanced',
|
||||||
@@ -414,6 +435,10 @@ export default {
|
|||||||
mediaCount: 'Media {count}/{capacity}',
|
mediaCount: 'Media {count}/{capacity}',
|
||||||
mediaSlotsFull: 'Media slots are full; no more media can be mounted',
|
mediaSlotsFull: 'Media slots are full; no more media can be mounted',
|
||||||
reenumerating: 'USB is re-enumerating',
|
reenumerating: 'USB is re-enumerating',
|
||||||
|
errors: {
|
||||||
|
mediumRemovalPrevented: 'The controlled computer is using this virtual medium and has prevented its removal. Eject or unmount it on the controlled computer, then try again.',
|
||||||
|
disconnectFailed: 'Virtual media could not be disconnected. Please try again or check the system logs.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -655,7 +680,6 @@ export default {
|
|||||||
otgNetworkNone: 'None',
|
otgNetworkNone: 'None',
|
||||||
otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces',
|
otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces',
|
||||||
uacMic: 'USB Microphone',
|
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)',
|
||||||
@@ -890,6 +914,17 @@ export default {
|
|||||||
networkError: 'Network Error',
|
networkError: 'Network Error',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
hidUnavailable: 'HID Unavailable',
|
hidUnavailable: 'HID Unavailable',
|
||||||
|
audioPlayback: 'Audio Playback',
|
||||||
|
audioTransfer: 'Audio Transfer',
|
||||||
|
playbackActive: 'Playing',
|
||||||
|
playbackStopped: 'Stopped',
|
||||||
|
transferIdle: 'Not Transmitting',
|
||||||
|
transferStarting: 'Starting',
|
||||||
|
transferWaiting: 'Waiting for Target',
|
||||||
|
transferActive: 'Transmitting',
|
||||||
|
transferStalled: 'Target Not Receiving',
|
||||||
|
transferStopping: 'Stopping',
|
||||||
|
transferError: 'Transfer Error',
|
||||||
quality: 'Quality',
|
quality: 'Quality',
|
||||||
streaming: 'Streaming',
|
streaming: 'Streaming',
|
||||||
off: 'Off',
|
off: 'Off',
|
||||||
|
|||||||
@@ -94,6 +94,26 @@ export default {
|
|||||||
paste: '粘贴文本',
|
paste: '粘贴文本',
|
||||||
micStart: '开始传声',
|
micStart: '开始传声',
|
||||||
micStop: '停止传声',
|
micStop: '停止传声',
|
||||||
|
microphoneTransfer: '音频传声设置',
|
||||||
|
grantMicrophonePermission: '允许访问麦克风',
|
||||||
|
noMicrophoneDevices: '未发现音频输入设备',
|
||||||
|
micState: {
|
||||||
|
idle: '未传输',
|
||||||
|
starting: '启动中',
|
||||||
|
streaming: '传输中',
|
||||||
|
stopping: '停止中',
|
||||||
|
error: '异常',
|
||||||
|
},
|
||||||
|
micError: {
|
||||||
|
'secure-context-required': '浏览器仅允许 HTTPS 页面访问麦克风,请通过 HTTPS 打开控制台。',
|
||||||
|
unsupported: '当前浏览器不支持 Opus 麦克风传输,请使用支持 WebCodecs 的浏览器。',
|
||||||
|
'permission-denied': '麦克风权限被拒绝,请在浏览器站点权限中允许访问。',
|
||||||
|
'device-unavailable': '所选麦克风不可用,请检查设备连接或选择其他输入设备。',
|
||||||
|
'connection-failed': '无法连接 USB 麦克风传输服务,请确认 UAC 已启用且没有其他会话占用。',
|
||||||
|
'encoder-failed': '浏览器音频编码器异常,传输已停止。',
|
||||||
|
unknown: '麦克风传输启动失败,请检查设备和浏览器权限。',
|
||||||
|
},
|
||||||
|
virtualMedia: '虚拟媒体',
|
||||||
virtualMediaTip: '管理虚拟媒体设备',
|
virtualMediaTip: '管理虚拟媒体设备',
|
||||||
power: '电源',
|
power: '电源',
|
||||||
keyboard: '虚拟键盘',
|
keyboard: '虚拟键盘',
|
||||||
@@ -143,11 +163,11 @@ export default {
|
|||||||
relative: '相对定位',
|
relative: '相对定位',
|
||||||
applying: '应用中...',
|
applying: '应用中...',
|
||||||
audioConfig: '音频',
|
audioConfig: '音频',
|
||||||
playbackControl: '播放控制',
|
playbackControl: '音频播放设置',
|
||||||
volume: '音量',
|
volume: '音量',
|
||||||
audioDeviceSettings: '设备配置',
|
audioDeviceSettings: '设备配置',
|
||||||
audioEnabled: '启用音频',
|
audioEnabled: '启用音频',
|
||||||
audioDevice: '音频设备',
|
audioDevice: '音频输入设备',
|
||||||
audioQuality: '音频质量',
|
audioQuality: '音频质量',
|
||||||
qualityVoice: '语音',
|
qualityVoice: '语音',
|
||||||
qualityBalanced: '均衡',
|
qualityBalanced: '均衡',
|
||||||
@@ -414,6 +434,10 @@ export default {
|
|||||||
mediaCount: '介质 {count}/{capacity}',
|
mediaCount: '介质 {count}/{capacity}',
|
||||||
mediaSlotsFull: '介质槽已满,无法挂载更多介质',
|
mediaSlotsFull: '介质槽已满,无法挂载更多介质',
|
||||||
reenumerating: 'USB 正在重新枚举',
|
reenumerating: 'USB 正在重新枚举',
|
||||||
|
errors: {
|
||||||
|
mediumRemovalPrevented: '被控机正在使用该虚拟介质,并拒绝移除。请先在被控机中弹出或卸载该介质,然后重试。',
|
||||||
|
disconnectFailed: '虚拟介质断开失败,请重试或检查系统日志。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '系统设置',
|
title: '系统设置',
|
||||||
@@ -655,7 +679,6 @@ export default {
|
|||||||
otgNetworkNone: '无',
|
otgNetworkNone: '无',
|
||||||
otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表',
|
otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表',
|
||||||
uacMic: 'USB 麦克风',
|
uacMic: 'USB 麦克风',
|
||||||
uacMicDesc: '启用后目标机将看到一个 USB 麦克风设备,音频从浏览器传入',
|
|
||||||
otgDescriptor: 'USB 设备描述符',
|
otgDescriptor: 'USB 设备描述符',
|
||||||
vendorId: '厂商 ID (VID)',
|
vendorId: '厂商 ID (VID)',
|
||||||
productId: '产品 ID (PID)',
|
productId: '产品 ID (PID)',
|
||||||
@@ -890,6 +913,17 @@ export default {
|
|||||||
networkError: '网络错误',
|
networkError: '网络错误',
|
||||||
disconnected: '已断开',
|
disconnected: '已断开',
|
||||||
hidUnavailable: 'HID不可用',
|
hidUnavailable: 'HID不可用',
|
||||||
|
audioPlayback: '音频播放',
|
||||||
|
audioTransfer: '音频传声',
|
||||||
|
playbackActive: '播放中',
|
||||||
|
playbackStopped: '已停止',
|
||||||
|
transferIdle: '未传声',
|
||||||
|
transferStarting: '正在启动',
|
||||||
|
transferWaiting: '等待目标机',
|
||||||
|
transferActive: '传声中',
|
||||||
|
transferStalled: '目标机未接收',
|
||||||
|
transferStopping: '正在停止',
|
||||||
|
transferError: '传声异常',
|
||||||
quality: '质量',
|
quality: '质量',
|
||||||
streaming: '传输中',
|
streaming: '传输中',
|
||||||
off: '关闭',
|
off: '关闭',
|
||||||
|
|||||||
328
web/src/lib/uac-microphone.ts
Normal file
328
web/src/lib/uac-microphone.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
const SAMPLE_RATE = 48_000
|
||||||
|
const CHANNELS = 2
|
||||||
|
const OPUS_BITRATE = 64_000
|
||||||
|
// Keep capture delivery close to Opus' 20 ms packet cadence instead of
|
||||||
|
// releasing several packets in an 85 ms burst.
|
||||||
|
const PROCESSOR_BUFFER_SIZE = 1024
|
||||||
|
const SOCKET_OPEN_TIMEOUT_MS = 8_000
|
||||||
|
const MAX_SOCKET_BUFFER_BYTES = 256 * 1024
|
||||||
|
const MAX_ENCODER_QUEUE_SIZE = 4
|
||||||
|
|
||||||
|
const OPUS_CONFIG: AudioEncoderConfig = {
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
numberOfChannels: CHANNELS,
|
||||||
|
bitrate: OPUS_BITRATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UacMicrophoneErrorCode =
|
||||||
|
| 'secure-context-required'
|
||||||
|
| 'unsupported'
|
||||||
|
| 'permission-denied'
|
||||||
|
| 'device-unavailable'
|
||||||
|
| 'connection-failed'
|
||||||
|
| 'encoder-failed'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export type UacTargetState = 'idle' | 'waiting' | 'active' | 'stalled'
|
||||||
|
|
||||||
|
export class UacMicrophoneError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: UacMicrophoneErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'UacMicrophoneError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function microphoneSupportError(): UacMicrophoneErrorCode | null {
|
||||||
|
if (!window.isSecureContext) return 'secure-context-required'
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia || typeof AudioEncoder === 'undefined' || typeof AudioData === 'undefined') {
|
||||||
|
return 'unsupported'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudioMessage(chunk: EncodedAudioChunk): Uint8Array {
|
||||||
|
const message = new Uint8Array(15 + chunk.byteLength)
|
||||||
|
const header = new DataView(message.buffer, 0, 15)
|
||||||
|
header.setUint8(0, 0x03)
|
||||||
|
header.setUint32(1, Math.round(chunk.timestamp / 1000), true)
|
||||||
|
header.setUint16(5, Math.round((chunk.duration ?? 0) / 1000), true)
|
||||||
|
header.setUint32(7, 0, true)
|
||||||
|
header.setUint32(11, chunk.byteLength, true)
|
||||||
|
chunk.copyTo(message.subarray(15))
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUacTargetState(value: unknown): value is UacTargetState {
|
||||||
|
return value === 'idle' || value === 'waiting' || value === 'active' || value === 'stalled'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSocket(
|
||||||
|
url: string,
|
||||||
|
onCreated: (socket: WebSocket) => void,
|
||||||
|
onTargetState: (state: UacTargetState) => void,
|
||||||
|
): Promise<WebSocket> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
onCreated(socket)
|
||||||
|
socket.binaryType = 'arraybuffer'
|
||||||
|
socket.onmessage = event => {
|
||||||
|
if (typeof event.data !== 'string') return
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as { type?: unknown, state?: unknown }
|
||||||
|
if (message.type === 'uac_status' && isUacTargetState(message.state)) {
|
||||||
|
onTargetState(message.state)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore control messages from incompatible server versions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
socket.close()
|
||||||
|
reject(new UacMicrophoneError('connection-failed', 'WebSocket connection timed out'))
|
||||||
|
}, SOCKET_OPEN_TIMEOUT_MS)
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
resolve(socket)
|
||||||
|
}
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
reject(new UacMicrophoneError('connection-failed', 'WebSocket connection failed'))
|
||||||
|
}
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
reject(new UacMicrophoneError('connection-failed', 'WebSocket was rejected'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMicrophoneError(error: unknown): UacMicrophoneError {
|
||||||
|
if (error instanceof UacMicrophoneError) return error
|
||||||
|
if (error instanceof DOMException) {
|
||||||
|
if (error.name === 'NotAllowedError' || error.name === 'SecurityError') {
|
||||||
|
return new UacMicrophoneError('permission-denied', error.message)
|
||||||
|
}
|
||||||
|
if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError' || error.name === 'NotReadableError') {
|
||||||
|
return new UacMicrophoneError('device-unavailable', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new UacMicrophoneError(
|
||||||
|
'unknown',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UacMicrophoneSession {
|
||||||
|
private socket: WebSocket | null = null
|
||||||
|
private stream: MediaStream | null = null
|
||||||
|
private encoder: AudioEncoder | null = null
|
||||||
|
private context: AudioContext | null = null
|
||||||
|
private source: MediaStreamAudioSourceNode | null = null
|
||||||
|
private processor: ScriptProcessorNode | null = null
|
||||||
|
private sink: MediaStreamAudioDestinationNode | null = null
|
||||||
|
private stopping = false
|
||||||
|
private stopped = false
|
||||||
|
private stopPromise: Promise<void> | null = null
|
||||||
|
private encodedFrames = 0
|
||||||
|
private inputFrames = 0
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly endpoint: string,
|
||||||
|
private readonly onUnexpectedEnd: (error: UacMicrophoneError) => void,
|
||||||
|
private readonly onTargetState: (state: UacTargetState) => void,
|
||||||
|
private readonly inputDeviceId = '',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get activeInputDeviceId(): string {
|
||||||
|
return this.stream?.getAudioTracks()[0]?.getSettings().deviceId ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const supportError = microphoneSupportError()
|
||||||
|
if (supportError) {
|
||||||
|
throw new UacMicrophoneError(supportError, 'Required browser audio APIs are unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const support = await AudioEncoder.isConfigSupported(OPUS_CONFIG)
|
||||||
|
if (!support.supported) {
|
||||||
|
throw new UacMicrophoneError('unsupported', 'The browser does not support Opus encoding')
|
||||||
|
}
|
||||||
|
if (this.stopping) return
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
...(this.inputDeviceId ? { deviceId: { exact: this.inputDeviceId } } : {}),
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
channelCount: CHANNELS,
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (this.stopping) {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.stream = stream
|
||||||
|
|
||||||
|
const socket = await openSocket(this.endpoint, pendingSocket => {
|
||||||
|
this.socket = pendingSocket
|
||||||
|
}, this.onTargetState)
|
||||||
|
if (this.stopping) {
|
||||||
|
socket.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.onclose = () => this.handleUnexpectedEnd('UAC audio connection closed')
|
||||||
|
socket.onerror = () => this.handleUnexpectedEnd('UAC audio connection failed')
|
||||||
|
|
||||||
|
this.encoder = new AudioEncoder({
|
||||||
|
output: chunk => this.sendEncodedChunk(chunk),
|
||||||
|
error: error => this.handleUnexpectedEnd(error.message, 'encoder-failed'),
|
||||||
|
})
|
||||||
|
this.encoder.configure(OPUS_CONFIG)
|
||||||
|
|
||||||
|
this.context = new AudioContext({ sampleRate: SAMPLE_RATE })
|
||||||
|
await this.context.resume()
|
||||||
|
if (this.stopping) return
|
||||||
|
this.source = this.context.createMediaStreamSource(this.stream)
|
||||||
|
this.processor = this.context.createScriptProcessor(PROCESSOR_BUFFER_SIZE, CHANNELS, CHANNELS)
|
||||||
|
this.sink = this.context.createMediaStreamDestination()
|
||||||
|
this.processor.onaudioprocess = event => this.encodeInput(event.inputBuffer)
|
||||||
|
this.source.connect(this.processor)
|
||||||
|
// A silent MediaStream destination keeps ScriptProcessor active without
|
||||||
|
// routing microphone input to the user's speakers.
|
||||||
|
this.processor.connect(this.sink)
|
||||||
|
} catch (error) {
|
||||||
|
await this.stop()
|
||||||
|
throw normalizeMicrophoneError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): Promise<void> {
|
||||||
|
if (this.stopPromise) return this.stopPromise
|
||||||
|
this.stopPromise = this.stopResources()
|
||||||
|
return this.stopPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeInput(input: AudioBuffer) {
|
||||||
|
if (this.stopping || this.encoder?.state !== 'configured') return
|
||||||
|
if (this.encoder.encodeQueueSize > MAX_ENCODER_QUEUE_SIZE) return
|
||||||
|
|
||||||
|
const frames = input.length
|
||||||
|
const left = input.getChannelData(0)
|
||||||
|
const right = input.numberOfChannels > 1 ? input.getChannelData(1) : left
|
||||||
|
const pcm = new Int16Array(frames * CHANNELS)
|
||||||
|
for (let i = 0; i < frames; i += 1) {
|
||||||
|
pcm[i * CHANNELS] = Math.round(Math.max(-1, Math.min(1, left[i] ?? 0)) * 32767)
|
||||||
|
pcm[i * CHANNELS + 1] = Math.round(Math.max(-1, Math.min(1, right[i] ?? 0)) * 32767)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Math.round(this.inputFrames * 1_000_000 / SAMPLE_RATE)
|
||||||
|
this.inputFrames += frames
|
||||||
|
const audioData = new AudioData({
|
||||||
|
format: 's16',
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
numberOfFrames: frames,
|
||||||
|
numberOfChannels: CHANNELS,
|
||||||
|
timestamp,
|
||||||
|
data: pcm,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
this.encoder.encode(audioData)
|
||||||
|
} finally {
|
||||||
|
audioData.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendEncodedChunk(chunk: EncodedAudioChunk) {
|
||||||
|
const socket = this.socket
|
||||||
|
if (this.stopping || socket?.readyState !== WebSocket.OPEN) return
|
||||||
|
if (socket.bufferedAmount >= MAX_SOCKET_BUFFER_BYTES) return
|
||||||
|
|
||||||
|
const message = buildAudioMessage(chunk)
|
||||||
|
new DataView(message.buffer).setUint32(7, this.encodedFrames, true)
|
||||||
|
this.encodedFrames = (this.encodedFrames + 1) >>> 0
|
||||||
|
socket.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUnexpectedEnd(message: string, code: UacMicrophoneErrorCode = 'connection-failed') {
|
||||||
|
if (this.stopping || this.stopped) return
|
||||||
|
void this.stop().finally(() => {
|
||||||
|
this.onUnexpectedEnd(new UacMicrophoneError(code, message))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopResources() {
|
||||||
|
this.stopping = true
|
||||||
|
|
||||||
|
if (this.processor) {
|
||||||
|
this.processor.onaudioprocess = null
|
||||||
|
this.processor.disconnect()
|
||||||
|
this.processor = null
|
||||||
|
}
|
||||||
|
this.source?.disconnect()
|
||||||
|
this.source = null
|
||||||
|
this.sink?.disconnect()
|
||||||
|
this.sink = null
|
||||||
|
|
||||||
|
this.stream?.getTracks().forEach(track => track.stop())
|
||||||
|
this.stream = null
|
||||||
|
|
||||||
|
const encoder = this.encoder
|
||||||
|
this.encoder = null
|
||||||
|
if (encoder && encoder.state !== 'closed') {
|
||||||
|
try {
|
||||||
|
if (encoder.state === 'configured') await encoder.flush()
|
||||||
|
} catch {
|
||||||
|
// The encoder may reject flush after a device or socket failure.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
encoder.close()
|
||||||
|
} catch {
|
||||||
|
// An asynchronous encoder error may already have closed it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = this.socket
|
||||||
|
this.socket = null
|
||||||
|
if (socket) {
|
||||||
|
if (socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
// Keep the connection promise handlers installed so closing a pending
|
||||||
|
// socket also settles start().
|
||||||
|
socket.close()
|
||||||
|
} else {
|
||||||
|
socket.onclose = null
|
||||||
|
socket.onerror = null
|
||||||
|
socket.onmessage = null
|
||||||
|
if (socket.readyState === WebSocket.OPEN) socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = this.context
|
||||||
|
this.context = null
|
||||||
|
if (context && context.state !== 'closed') {
|
||||||
|
try {
|
||||||
|
await context.close()
|
||||||
|
} catch {
|
||||||
|
// A context that failed during startup may already be unusable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +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 { getMicrophone } from '@/composables/useMicrophone'
|
||||||
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } 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'
|
||||||
@@ -76,6 +77,11 @@ const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
|
|||||||
const hidWs = useHidWebSocket()
|
const hidWs = useHidWebSocket()
|
||||||
const webrtc = useWebRTC()
|
const webrtc = useWebRTC()
|
||||||
const unifiedAudio = getUnifiedAudio()
|
const unifiedAudio = getUnifiedAudio()
|
||||||
|
const microphone = getMicrophone()
|
||||||
|
const uacEnabled = ref(false)
|
||||||
|
const microphoneTransferEnabled = computed(() => (
|
||||||
|
uacEnabled.value && configStore.hid?.backend === HidBackend.Otg
|
||||||
|
))
|
||||||
const videoSession = useVideoSession()
|
const videoSession = useVideoSession()
|
||||||
|
|
||||||
const consoleEvents = useConsoleEvents({
|
const consoleEvents = useConsoleEvents({
|
||||||
@@ -468,14 +474,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
|||||||
return details
|
return details
|
||||||
})
|
})
|
||||||
|
|
||||||
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
|
||||||
const audio = systemStore.audio
|
|
||||||
if (!audio?.available) return 'disconnected'
|
|
||||||
if (audio.error) return 'error'
|
|
||||||
if (audio.streaming) return 'connected'
|
|
||||||
return 'disconnected'
|
|
||||||
})
|
|
||||||
|
|
||||||
function translateAudioQuality(quality: string | undefined): string {
|
function translateAudioQuality(quality: string | undefined): string {
|
||||||
if (!quality) return t('common.unknown')
|
if (!quality) return t('common.unknown')
|
||||||
const qualityLower = quality.toLowerCase()
|
const qualityLower = quality.toLowerCase()
|
||||||
@@ -485,26 +483,81 @@ function translateAudioQuality(quality: string | undefined): string {
|
|||||||
return quality
|
return quality
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const microphoneTransferStatus = computed<{
|
||||||
|
text: string
|
||||||
|
card: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||||
|
detail?: StatusDetail['status']
|
||||||
|
}>(() => {
|
||||||
|
switch (microphone.state.value) {
|
||||||
|
case 'starting':
|
||||||
|
return { text: t('statusCard.transferStarting'), card: 'connecting', detail: 'warning' }
|
||||||
|
case 'streaming':
|
||||||
|
if (microphone.targetState.value === 'active') {
|
||||||
|
return { text: t('statusCard.transferActive'), card: 'connected', detail: 'ok' }
|
||||||
|
}
|
||||||
|
if (microphone.targetState.value === 'stalled') {
|
||||||
|
return { text: t('statusCard.transferStalled'), card: 'connecting', detail: 'warning' }
|
||||||
|
}
|
||||||
|
return { text: t('statusCard.transferWaiting'), card: 'connecting', detail: 'warning' }
|
||||||
|
case 'stopping':
|
||||||
|
return { text: t('statusCard.transferStopping'), card: 'connecting', detail: 'warning' }
|
||||||
|
case 'error':
|
||||||
|
return { text: t('statusCard.transferError'), card: 'error', detail: 'error' }
|
||||||
|
case 'idle':
|
||||||
|
default:
|
||||||
|
return { text: t('statusCard.transferIdle'), card: 'disconnected' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||||
|
const transfer = microphoneTransferEnabled.value ? microphoneTransferStatus.value : null
|
||||||
|
if (systemStore.audio?.error || transfer?.card === 'error') return 'error'
|
||||||
|
if (transfer?.card === 'connecting') return 'connecting'
|
||||||
|
if (systemStore.audio?.streaming || transfer?.card === 'connected') return 'connected'
|
||||||
|
return 'disconnected'
|
||||||
|
})
|
||||||
|
|
||||||
const audioQuickInfo = computed(() => {
|
const audioQuickInfo = computed(() => {
|
||||||
const audio = systemStore.audio
|
const audio = systemStore.audio
|
||||||
if (!audio?.available) return ''
|
const playback = audio?.streaming
|
||||||
if (audio.streaming) return translateAudioQuality(audio.quality)
|
? t('statusCard.playbackActive')
|
||||||
return t('statusCard.off')
|
: t('statusCard.playbackStopped')
|
||||||
|
return microphoneTransferEnabled.value
|
||||||
|
? `${playback} · ${microphoneTransferStatus.value.text}`
|
||||||
|
: playback
|
||||||
})
|
})
|
||||||
|
|
||||||
const audioErrorMessage = computed(() => {
|
const audioErrorMessage = computed(() => {
|
||||||
return systemStore.audio?.error || ''
|
if (systemStore.audio?.error) return systemStore.audio.error
|
||||||
|
if (!microphoneTransferEnabled.value) return ''
|
||||||
|
const code = microphone.errorCode.value
|
||||||
|
if (microphone.state.value === 'error' && code) {
|
||||||
|
return t(`actionbar.micError.${code}`)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const audioDetails = computed<StatusDetail[]>(() => {
|
const audioDetails = computed<StatusDetail[]>(() => {
|
||||||
const audio = systemStore.audio
|
const audio = systemStore.audio
|
||||||
if (!audio) return []
|
const details: StatusDetail[] = [
|
||||||
|
{
|
||||||
return [
|
label: t('statusCard.audioPlayback'),
|
||||||
{ label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') },
|
value: audio?.streaming ? t('statusCard.playbackActive') : t('statusCard.playbackStopped'),
|
||||||
{ label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) },
|
status: audio?.error ? 'error' : audio?.streaming ? 'ok' : undefined,
|
||||||
{ label: t('statusCard.streaming'), value: audio.streaming ? t('common.yes') : t('common.no'), status: audio.streaming ? 'ok' : undefined },
|
},
|
||||||
]
|
]
|
||||||
|
if (microphoneTransferEnabled.value) {
|
||||||
|
details.push({
|
||||||
|
label: t('statusCard.audioTransfer'),
|
||||||
|
value: microphoneTransferStatus.value.text,
|
||||||
|
status: microphoneTransferStatus.value.detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
details.push(
|
||||||
|
{ label: t('statusCard.device'), value: audio?.device || t('statusCard.defaultDevice') },
|
||||||
|
{ label: t('statusCard.quality'), value: translateAudioQuality(audio?.quality) },
|
||||||
|
)
|
||||||
|
return details
|
||||||
})
|
})
|
||||||
|
|
||||||
const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||||
@@ -2924,6 +2977,7 @@ async function activateConsoleView() {
|
|||||||
|
|
||||||
function deactivateConsoleView() {
|
function deactivateConsoleView() {
|
||||||
isConsoleActive.value = false
|
isConsoleActive.value = false
|
||||||
|
void microphone.stop()
|
||||||
handleBlur()
|
handleBlur()
|
||||||
exitPointerLock()
|
exitPointerLock()
|
||||||
unregisterInteractionListeners()
|
unregisterInteractionListeners()
|
||||||
@@ -2961,14 +3015,21 @@ function handleToggleMouseMode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uacEnabled = ref(false)
|
async function refreshUacAvailability() {
|
||||||
|
try {
|
||||||
|
const uacConfig = await uacApi.get()
|
||||||
|
uacEnabled.value = uacConfig.enabled
|
||||||
|
} catch {
|
||||||
|
uacEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(microphoneTransferEnabled, enabled => {
|
||||||
|
if (!enabled) void microphone.stop()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Check if UAC is enabled (show mic button only if USB mic is available)
|
await refreshUacAvailability()
|
||||||
try {
|
|
||||||
const uacCfg = await uacApi.get()
|
|
||||||
uacEnabled.value = uacCfg.enabled
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
consoleEvents.subscribe()
|
consoleEvents.subscribe()
|
||||||
|
|
||||||
@@ -3009,6 +3070,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
|
void refreshUacAvailability()
|
||||||
void activateConsoleView()
|
void activateConsoleView()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3096,7 +3158,6 @@ onUnmounted(() => {
|
|||||||
:details="videoDetails"
|
:details="videoDetails"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
v-if="systemStore.audio?.available"
|
|
||||||
:title="t('statusCard.audio')"
|
:title="t('statusCard.audio')"
|
||||||
type="audio"
|
type="audio"
|
||||||
:status="audioStatus"
|
:status="audioStatus"
|
||||||
@@ -3174,7 +3235,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"
|
:show-mic="microphoneTransferEnabled"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
@toggle-stats="openStatsSheet"
|
@toggle-stats="openStatsSheet"
|
||||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||||
|
|||||||
@@ -3376,10 +3376,7 @@ watch(isWindows, () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
|
||||||
<Label>{{ t('settings.uacMic') }}</Label>
|
<Label>{{ t('settings.uacMic') }}</Label>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.uacMicDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="config.uac_enabled" />
|
<Switch v-model="config.uac_enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user