From e0055bf4919eb55efecc9c35bbba387f4f2e678d Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 20 Jul 2026 21:24:27 +0800 Subject: [PATCH 01/23] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=20nmcli=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=BC=BA=E5=A4=B1=E5=BC=B9=E7=AA=97=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/config.ts | 2 +- web/src/i18n/en-US.ts | 1 + web/src/i18n/zh-CN.ts | 1 + web/src/views/SettingsView.vue | 54 ++++++++++++++++++++++++---------- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/web/src/api/config.ts b/web/src/api/config.ts index b10994fa..681fbc43 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -115,7 +115,7 @@ export const otgNetworkApi = { status: () => request('/otg/network/status'), - interfaces: () => request('/devices/network'), + interfaces: () => request('/devices/network', {}, { toastOnError: false }), } export const otgConfigApi = { diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 461d6b01..348554d9 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -653,6 +653,7 @@ export default { otgNetworkDriver: 'Host Driver Mode', otgNetworkInterface: 'Bridge Interface', otgNetworkNone: 'None', + otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces', otgDescriptor: 'USB Device Descriptor', vendorId: 'Vendor ID (VID)', productId: 'Product ID (PID)', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 7d4da3b5..45982f12 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -652,6 +652,7 @@ export default { otgNetworkDriver: '目标机驱动模式', otgNetworkInterface: '桥接网卡', otgNetworkNone: '无', + otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表', otgDescriptor: 'USB 设备描述符', vendorId: '厂商 ID (VID)', productId: '产品 ID (PID)', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 45ec49f0..fc7dcd31 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -269,15 +269,12 @@ async function loadSectionData(section: SettingsSectionId) { case 'video': await Promise.all([ loadConfig(), - loadDevices(), + loadDeviceConfig(), loadBackends(), ]) return case 'hid': - await Promise.all([ - loadConfig(), - loadDevices(), - ]) + await Promise.all([loadConfig(), loadHidDeviceOptions()]) return case 'atx': await Promise.all([ @@ -689,6 +686,7 @@ const config = ref({ const otgNetworkInterfaces = ref([]) const otgNetworkInterfacesLoaded = ref(false) +const otgNetworkInterfacesError = ref('') const otgNetworkStatus = ref(null) function syncOtgNetworkInterface() { @@ -1569,20 +1567,30 @@ async function loadConfig() { } } -async function loadDevices() { +async function loadDeviceConfig() { try { - const [deviceConfig, networkInterfaces] = await Promise.all([ - configApi.listDevices(), - otgNetworkApi.interfaces().catch(() => []), - ]) - devices.value = deviceConfig - otgNetworkInterfaces.value = networkInterfaces + devices.value = await configApi.listDevices() + } catch { + } +} + +async function loadOtgNetworkInterfaces() { + otgNetworkInterfacesError.value = '' + try { + otgNetworkInterfaces.value = await otgNetworkApi.interfaces() otgNetworkInterfacesLoaded.value = true syncOtgNetworkInterface() } catch { + otgNetworkInterfaces.value = [] + otgNetworkInterfacesLoaded.value = false + otgNetworkInterfacesError.value = t('settings.otgNetworkInterfacesLoadFailed') } } +async function loadHidDeviceOptions() { + await Promise.all([loadDeviceConfig(), loadOtgNetworkInterfaces()]) +} + async function loadBackends() { try { const result = await streamApi.getCodecs() @@ -2902,7 +2910,7 @@ watch(isWindows, () => { {{ t('settings.videoSettings') }} {{ t('settings.videoSettingsDesc') }} - @@ -3041,7 +3049,7 @@ watch(isWindows, () => { {{ t('settings.hidSettings') }} {{ t('settings.hidSettingsDesc') }} - @@ -3325,8 +3333,22 @@ watch(isWindows, () => {
- - {{ t('settings.otgNetworkNone') }} + + + {{ otgNetworkInterfacesError }} + + + {{ t('settings.otgNetworkNone') }} + Date: Wed, 22 Jul 2026 22:53:21 +0800 Subject: [PATCH 02/23] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/okvm-test/README.md | 5 +- test/okvm-test/agent/main.go | 196 ++++++++++++++++++++++++++------- test/okvm-test/okvm_report.py | 12 +- test/okvm-test/okvm_testctl.py | 134 ++++++++++++++++------ 4 files changed, 264 insertions(+), 83 deletions(-) diff --git a/test/okvm-test/README.md b/test/okvm-test/README.md index b26f19ca..236db491 100644 --- a/test/okvm-test/README.md +++ b/test/okvm-test/README.md @@ -76,8 +76,8 @@ python okvm_testctl.py run ` 控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入: -- USB2.0 采集卡:测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 -- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 +- USB2.0 采集卡(MS2131 测试档位):严格测试 `1080p50 MJPEG` 和 `1080p10 YUYV`;任一格式、分辨率或帧率未申报时,立即将 `video_input_select` 标记为 `FAIL` 并中止测试,不回退到其他档位。 +- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率申报的最高帧率;如果没有 1080p YUYV,立即将 `video_input_select` 标记为 `FAIL` 并中止测试,不再回退到较低分辨率。 - CSI/MIPI:只测试一套 `1080p60 NV12`,不做输入格式切换。 每个输入配置都会跑三种输出: @@ -87,6 +87,7 @@ python okvm_testctl.py run ` - H.265 WebRTC 默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。 +每次应用视频输入配置后默认先空转 3 秒,稳定后再开始统计;可通过 `--video-config-settle-seconds <秒数>` 调整。 MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps ` 覆盖。 diff --git a/test/okvm-test/agent/main.go b/test/okvm-test/agent/main.go index f9f05127..a6cac13f 100644 --- a/test/okvm-test/agent/main.go +++ b/test/okvm-test/agent/main.go @@ -42,7 +42,6 @@ const ( wmChar = 0x0102 wmSysKeyDown = 0x0104 wmSysKeyUp = 0x0105 - wmTimer = 0x0113 wmMouseMove = 0x0200 wmLButtonDown = 0x0201 wmLButtonUp = 0x0202 @@ -75,10 +74,11 @@ const ( wmAppFocus = wmApp + 4 wmAppDynamicStart = wmApp + 5 wmAppDynamicStop = wmApp + 6 + wmAppDynamicFrame = wmApp + 7 - dynamicTimerID = 1 - - colorWindow = 5 + colorWindow = 5 + dynamicBackgroundColor = 0x00101010 + dynamicPatchSize = 384 driveUnknown = 0 driveNoRootDir = 1 @@ -111,16 +111,17 @@ var ( procShowWindow = user32.NewProc("ShowWindow") procSetForegroundWindow = user32.NewProc("SetForegroundWindow") procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procGetDC = user32.NewProc("GetDC") + procReleaseDC = user32.NewProc("ReleaseDC") procInvalidateRect = user32.NewProc("InvalidateRect") procUpdateWindow = user32.NewProc("UpdateWindow") - procSetTimer = user32.NewProc("SetTimer") - procKillTimer = user32.NewProc("KillTimer") procGetKeyState = user32.NewProc("GetKeyState") procBeginPaint = user32.NewProc("BeginPaint") procEndPaint = user32.NewProc("EndPaint") procFillRect = user32.NewProc("FillRect") procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush") procDeleteObject = gdi32.NewProc("DeleteObject") + procGetDeviceCaps = gdi32.NewProc("GetDeviceCaps") procImmAssociateContext = imm32.NewProc("ImmAssociateContext") procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") procQueryPerformanceCount = kernel32.NewProc("QueryPerformanceCounter") @@ -192,6 +193,10 @@ type appState struct { dynamicActive bool dynamicFPS int dynamicFrame int64 + dynamicGeneration uint64 + dynamicFramePending bool + dynamicStop chan struct{} + dynamicStarted time.Time events []hidEvent } @@ -519,20 +524,22 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) state.mu.Lock() color := state.bgColor + dynamic := state.dynamicActive state.mu.Unlock() - brush, _, _ := procCreateSolidBrush.Call(uintptr(color)) - r := rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())} - procFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), brush) - procDeleteObject.Call(brush) + paintRect := ps.RcPaint + if paintRect.Right <= paintRect.Left || paintRect.Bottom <= paintRect.Top { + paintRect = rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())} + } + if dynamic { + fillRect(hdc, paintRect, dynamicBackgroundColor) + if patch, ok := intersectRects(paintRect, dynamicPatchRect()); ok { + fillRect(hdc, patch, color) + } + } else { + fillRect(hdc, paintRect, color) + } procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) return 0 - case wmTimer: - if wParam == dynamicTimerID { - advanceDynamicFrame(hwnd) - return 0 - } - ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) - return ret case wmAppInvalidate: invalidateWindowNow(hwnd) return 0 @@ -547,16 +554,12 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { procSetForegroundWindow.Call(hwnd) return 0 case wmAppDynamicStart: - procKillTimer.Call(hwnd, dynamicTimerID) - interval := wParam - if interval == 0 { - interval = 16 - } - procSetTimer.Call(hwnd, dynamicTimerID, interval, 0) invalidateWindowNow(hwnd) return 0 + case wmAppDynamicFrame: + advanceDynamicFrame(hwnd, uint64(wParam)) + return 0 case wmAppDynamicStop: - procKillTimer.Call(hwnd, dynamicTimerID) invalidateWindowNow(hwnd) return 0 case wmKeyDown: @@ -590,7 +593,6 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { procShowWindow.Call(hwnd, swHide) return 0 case wmDestroy: - procKillTimer.Call(hwnd, dynamicTimerID) procPostQuitMessage.Call(0) return 0 default: @@ -704,13 +706,20 @@ func startDynamic(fps int) map[string]interface{} { fps = 120 } stopDynamic() + stop := make(chan struct{}) state.mu.Lock() state.dynamicActive = true state.dynamicFPS = fps state.dynamicFrame = 0 + state.dynamicGeneration++ + generation := state.dynamicGeneration + state.dynamicFramePending = false + state.dynamicStop = stop + state.dynamicStarted = time.Now() display := setColorStateLocked(dynamicFrameColor(0)) state.mu.Unlock() - postUIMessage(wmAppDynamicStart, uintptr(dynamicTimerIntervalMS(fps)), 0) + postUIMessage(wmAppDynamicStart, uintptr(generation), 0) + go runDynamicFrames(fps, generation, stop) display["dynamic"] = true display["fps"] = fps return display @@ -719,24 +728,51 @@ func startDynamic(fps int) map[string]interface{} { func stopDynamic() { state.mu.Lock() active := state.dynamicActive + stop := state.dynamicStop state.dynamicActive = false state.dynamicFPS = 0 state.dynamicFrame = 0 + state.dynamicFramePending = false + state.dynamicStop = nil + state.dynamicStarted = time.Time{} state.mu.Unlock() + if stop != nil { + close(stop) + } if active { postUIMessage(wmAppDynamicStop, 0, 0) } } -func dynamicTimerIntervalMS(fps int) int { - if fps < 1 { - fps = 60 +func runDynamicFrames(fps int, generation uint64, stop <-chan struct{}) { + interval := time.Second / time.Duration(fps) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + state.mu.Lock() + if !state.dynamicActive || state.dynamicGeneration != generation { + state.mu.Unlock() + return + } + if state.dynamicFramePending { + state.mu.Unlock() + continue + } + state.dynamicFramePending = true + state.mu.Unlock() + if !postUIMessage(wmAppDynamicFrame, uintptr(generation), 0) { + state.mu.Lock() + if state.dynamicGeneration == generation { + state.dynamicFramePending = false + } + state.mu.Unlock() + } + } } - interval := 1000 / fps - if interval < 1 { - return 1 - } - return interval } func dynamicFrameColor(frame int64) string { @@ -746,16 +782,17 @@ func dynamicFrameColor(frame int64) string { return fmt.Sprintf("#%02x%02x%02x", r, g, b) } -func advanceDynamicFrame(hwnd uintptr) { +func advanceDynamicFrame(hwnd uintptr, generation uint64) { state.mu.Lock() - if !state.dynamicActive { + if !state.dynamicActive || state.dynamicGeneration != generation { state.mu.Unlock() return } + state.dynamicFramePending = false state.dynamicFrame++ setColorStateLocked(dynamicFrameColor(state.dynamicFrame)) state.mu.Unlock() - invalidateWindowNow(hwnd) + invalidateDynamicPatchNow(hwnd) } func setColorStateLocked(colorHex string) map[string]interface{} { @@ -781,6 +818,13 @@ func setColorStateLocked(colorHex string) map[string]interface{} { func currentDisplayState() map[string]interface{} { state.mu.Lock() defer state.mu.Unlock() + dynamicActualFPS := 0.0 + if state.dynamicActive && !state.dynamicStarted.IsZero() { + elapsed := time.Since(state.dynamicStarted).Seconds() + if elapsed > 0 { + dynamicActualFPS = float64(state.dynamicFrame) / elapsed + } + } return map[string]interface{}{ "color": state.colorHex, "last_change_qpc": state.lastColorChangeQpc, @@ -788,6 +832,9 @@ func currentDisplayState() map[string]interface{} { "sequence": state.colorSequence, "dynamic": state.dynamicActive, "dynamic_fps": state.dynamicFPS, + "dynamic_frame": state.dynamicFrame, + "dynamic_actual_fps": dynamicActualFPS, + "display_refresh_hz": screenRefreshHz(), "qpc": qpcNow(), "unix_nano": time.Now().UnixNano(), } @@ -816,13 +863,15 @@ func focusWindow() { } } -func postUIMessage(message uint32, wParam uintptr, lParam uintptr) { +func postUIMessage(message uint32, wParam uintptr, lParam uintptr) bool { state.mu.Lock() hwnd := state.hwnd state.mu.Unlock() - if hwnd != 0 { - procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam) + if hwnd == 0 { + return false } + result, _, _ := procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam) + return result != 0 } func invalidateWindowNow(hwnd uintptr) { @@ -830,6 +879,60 @@ func invalidateWindowNow(hwnd uintptr) { procUpdateWindow.Call(hwnd) } +func invalidateDynamicPatchNow(hwnd uintptr) { + patch := dynamicPatchRect() + procInvalidateRect.Call(hwnd, uintptr(unsafe.Pointer(&patch)), 0) + procUpdateWindow.Call(hwnd) +} + +func dynamicPatchRect() rect { + width := int32(screenWidth()) + height := int32(screenHeight()) + size := int32(dynamicPatchSize) + if size > width { + size = width + } + if size > height { + size = height + } + left := (width - size) / 2 + top := (height - size) / 2 + return rect{Left: left, Top: top, Right: left + size, Bottom: top + size} +} + +func intersectRects(a, b rect) (rect, bool) { + intersection := rect{ + Left: maxInt32(a.Left, b.Left), + Top: maxInt32(a.Top, b.Top), + Right: minInt32(a.Right, b.Right), + Bottom: minInt32(a.Bottom, b.Bottom), + } + return intersection, intersection.Right > intersection.Left && intersection.Bottom > intersection.Top +} + +func fillRect(hdc uintptr, area rect, color uint32) { + brush, _, _ := procCreateSolidBrush.Call(uintptr(color)) + if brush == 0 { + return + } + procFillRect.Call(hdc, uintptr(unsafe.Pointer(&area)), brush) + procDeleteObject.Call(brush) +} + +func minInt32(a, b int32) int32 { + if a < b { + return a + } + return b +} + +func maxInt32(a, b int32) int32 { + if a > b { + return a + } + return b +} + func disableIME(hwnd uintptr) { if hwnd != 0 { procImmAssociateContext.Call(hwnd, 0) @@ -870,7 +973,18 @@ func screenSize() (int, int) { func screenInfo() map[string]int { width, height := screenSize() - return map[string]int{"width": width, "height": height} + return map[string]int{"width": width, "height": height, "refresh_hz": screenRefreshHz()} +} + +func screenRefreshHz() int { + const vRefresh = 116 + hdc, _, _ := procGetDC.Call(0) + if hdc == 0 { + return 0 + } + defer procReleaseDC.Call(0, hdc) + refresh, _, _ := procGetDeviceCaps.Call(hdc, vRefresh) + return int(refresh) } func screenWidth() int { diff --git a/test/okvm-test/okvm_report.py b/test/okvm-test/okvm_report.py index cb9e50f7..afda979f 100644 --- a/test/okvm-test/okvm_report.py +++ b/test/okvm-test/okvm_report.py @@ -165,7 +165,7 @@ class Reporter: "- 测试设备:", f"- 视频设备:{markdown_inline(self.user_video_device())}", f"- HID设备:{markdown_inline(self.user_hid_backend())}", - f"- 网络延迟:{markdown_inline(self.user_network_latency())}", + f"- HTTP 延迟:{markdown_inline(self.user_http_latency())}", "", "## 视频性能", "", @@ -229,14 +229,14 @@ class Reporter: return result return None - def user_network_latency(self) -> str: + def user_http_latency(self) -> str: result = self.find_result("network_latency") if not result: return "无数据" - tcp = result.data.get("tcp_connect") or {} - if tcp.get("samples"): - return format_latency_values(tcp) - return result.message or "无数据" + http = result.data.get("http_health") or {} + if http.get("samples"): + return format_latency_values(http) + return "无数据" def user_video_device(self) -> str: result = self.find_result("video_input_select") diff --git a/test/okvm-test/okvm_testctl.py b/test/okvm-test/okvm_testctl.py index 440e0d64..b285eab6 100755 --- a/test/okvm-test/okvm_testctl.py +++ b/test/okvm-test/okvm_testctl.py @@ -275,6 +275,24 @@ class DeviceSelector: return width, height, best return None + @staticmethod + def _pick_required_mode( + fmt: dict[str, Any] | None, + width: int, + height: int, + fps: float, + ) -> tuple[int, int, float] | None: + if not fmt: + return None + for res in fmt.get("resolutions", []): + if int(res.get("width", 0)) != width or int(res.get("height", 0)) != height: + continue + for advertised_fps in res.get("fps", []): + value = float(advertised_fps) + if abs(value - fps) <= 0.05: + return width, height, value + return None + @staticmethod def _pick_highest_1080(fmt: dict[str, Any]) -> tuple[int, int, float] | None: candidates: list[tuple[int, int, float]] = [] @@ -289,6 +307,22 @@ class DeviceSelector: return None return max(candidates, key=lambda x: (x[0] * x[1], x[2])) + @staticmethod + def _pick_highest_fps_at_resolution( + fmt: dict[str, Any] | None, + width: int, + height: int, + ) -> tuple[int, int, float] | None: + if not fmt: + return None + candidates: list[float] = [] + for res in fmt.get("resolutions", []): + if int(res.get("width", 0)) == width and int(res.get("height", 0)) == height: + candidates.extend(float(value) for value in res.get("fps", [])) + if not candidates: + return None + return width, height, max(candidates) + def select(self) -> list[VideoInputCase]: video_devices = self.devices.get("video", []) if not video_devices: @@ -310,17 +344,36 @@ class DeviceSelector: mjpeg = self._find_format(device, "MJPEG") yuyv = self._find_format(device, "YUYV") - target_fps = 60 if input_class == "usb3" else 30 + if input_class == "usb2": + required_modes = ( + ("MJPEG", mjpeg, 50.0, "usb2_mjpeg"), + ("YUYV", yuyv, 10.0, "usb2_yuyv"), + ) + missing: list[str] = [] + for fmt_name, fmt_info, required_fps, label in required_modes: + picked = self._pick_required_mode(fmt_info, 1920, 1080, required_fps) + if not picked: + missing.append(f"{fmt_name} 1920x1080@{required_fps:g}fps") + continue + width, height, fps = picked + cases.append(VideoInputCase(label, input_class, path, fmt_name, width, height, fps)) + if missing: + raise RuntimeError( + "USB 2.0 capture card is missing required mode(s): " + ", ".join(missing) + ) + return cases + + target_fps = 60 if mjpeg: picked = self._pick_exact(mjpeg, 1920, 1080, target_fps) or self._pick_highest_1080(mjpeg) if picked: w, h, f = picked cases.append(VideoInputCase(f"{input_class}_mjpeg", input_class, path, "MJPEG", w, h, f)) - if yuyv: - picked = self._pick_highest_1080(yuyv) - if picked: - w, h, f = picked - cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f)) + picked = self._pick_highest_fps_at_resolution(yuyv, 1920, 1080) + if not picked: + raise RuntimeError("USB 3.0 capture card is missing required mode: YUYV 1920x1080") + w, h, f = picked + cases.append(VideoInputCase("usb3_yuyv", input_class, path, "YUYV", w, h, f)) return cases @@ -597,7 +650,16 @@ echo "$BACKUP" def select_video_cases(self, devices: dict[str, Any]) -> list[VideoInputCase]: selector = DeviceSelector(self.lsusb_tree, devices) - cases = selector.select() + try: + cases = selector.select() + except RuntimeError as exc: + self.reporter.add( + "video_input_select", + "FAIL", + str(exc), + devices=devices.get("video", []), + ) + raise if not cases: self.reporter.add("video_input_select", "FAIL", "no suitable video input case found", devices=devices.get("video", [])) return [] @@ -692,7 +754,6 @@ echo "$BACKUP" ) continue try: - self.apply_video_case(case) if output_mode == "mjpeg": motion_started = await self.start_mjpeg_motion() try: @@ -796,6 +857,9 @@ echo "$BACKUP" "quality": self.args.jpeg_quality, }, ) + settle_seconds = max(0.0, float(self.args.video_config_settle_seconds)) + if settle_seconds > 0: + time.sleep(settle_seconds) def configure_video_case(self, case: VideoInputCase) -> None: self.apply_video_case(case) @@ -818,7 +882,7 @@ echo "$BACKUP" frame_count = 0 byte_count = 0 first_frame_s: float | None = None - for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds, video_case=case): + for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds): now = time.monotonic() if now >= deadline: break @@ -855,6 +919,7 @@ echo "$BACKUP" async def measure_webrtc(self, case: VideoInputCase, codec: str) -> dict[str, Any]: self.set_stream_mode(codec) + self.apply_video_case(case) try: from playwright.async_api import async_playwright except ImportError: @@ -885,7 +950,13 @@ echo "$BACKUP" try: page = await context.new_page() await page.goto(self.api.base) - result = await page.evaluate(js, {"seconds": self.args.sample_seconds}) + result = await page.evaluate( + js, + { + "seconds": self.args.sample_seconds, + "settleSeconds": max(0.0, float(self.args.video_config_settle_seconds)), + }, + ) except Exception as exc: text = str(exc) if codec == "h265" and is_webrtc_codec_unsupported_error(text): @@ -988,7 +1059,6 @@ echo "$BACKUP" ) continue try: - self.apply_video_case(latency_case) if output_mode == "mjpeg": await self.run_mjpeg_latency_test(latency_case, check_name=check_name, save_evidence=False) else: @@ -1010,17 +1080,8 @@ echo "$BACKUP" return cases[0] def configure_hdmi_probe_case(self, case: VideoInputCase) -> None: - self.api.patch( - "/config/video", - { - "device": case.device, - "format": case.fmt, - "width": case.width, - "height": case.height, - "fps": int(round(case.fps)), - "quality": self.args.jpeg_quality, - }, - ) + self.set_stream_mode("mjpeg") + self.apply_video_case(case) self.reporter.add( "config_video_hdmi_probe", "PASS", @@ -1044,7 +1105,6 @@ echo "$BACKUP" evidence_title=f"HDMI 纯色采集帧 {name}", expected_rgb=expected, threshold=self.args.hdmi_color_fail, - video_case=case, ) err = rgb_error(expected, stats["mean_rgb"]) closest_name, closest_error = closest_hdmi_color(stats["mean_rgb"]) @@ -1113,6 +1173,9 @@ echo "$BACKUP" if self.args.hdmi_latency_trials <= 0: self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode="mjpeg") return + self.set_stream_mode("mjpeg") + self.apply_video_case(case) + self.api.post("/stream/start", {}) offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt") trials: list[dict[str, Any]] = [] colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")] @@ -1128,7 +1191,6 @@ echo "$BACKUP" timeout=detect_timeout, threshold=self.args.hdmi_color_fail, evidence_title=f"HDMI 延迟命中帧 {case.label} #{i + 1}" if save_evidence else None, - video_case=case, ) display = await self.agent.command("display_state", {}, timeout=5) actual_agent_ns = int(display.get("last_change_unix_nano") or 0) @@ -1209,6 +1271,9 @@ echo "$BACKUP" setup = await page.evaluate(WEBRTC_LATENCY_SETUP_JS, {"timeoutMs": 15000}) if not setup.get("connected"): raise RuntimeError(f"{output_mode} WebRTC did not connect: {setup}") + settle_seconds = max(0.0, float(self.args.video_config_settle_seconds)) + if settle_seconds > 0: + await page.wait_for_timeout(settle_seconds * 1000) for i in range(self.args.hdmi_latency_trials): source, target = colors[i % len(colors)] await self.agent.command("show", {"color": source, "full": True}, timeout=5) @@ -1313,7 +1378,6 @@ echo "$BACKUP" evidence_title: str | None = None, expected_rgb: tuple[int, int, int] | None = None, threshold: float | None = None, - video_case: VideoInputCase | None = None, ) -> dict[str, Any]: threshold = self.args.hdmi_color_fail if threshold is None else threshold required_matches = max(1, int(self.args.hdmi_match_frames)) @@ -1324,7 +1388,7 @@ echo "$BACKUP" consecutive_matches = 0 frames_seen = 0 - for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout): frames_seen += 1 stats = jpeg_rgb_stats(frame) stats["wall_ns"] = wall_ns @@ -1370,12 +1434,11 @@ echo "$BACKUP" timeout: float, threshold: float, evidence_title: str | None = None, - video_case: VideoInputCase | None = None, ) -> dict[str, Any]: last: dict[str, Any] | None = None last_frame: bytes | None = None frames_seen = 0 - for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout): frames_seen += 1 stats = jpeg_rgb_stats(frame) err = rgb_error(expected_rgb, stats["mean_rgb"]) @@ -1400,10 +1463,8 @@ echo "$BACKUP" path.write_bytes(frame) self.reporter.add_evidence("HDMI 采集帧", title, path) - def iter_mjpeg_frames(self, client_label: str, timeout: float, video_case: VideoInputCase | None = None): + def iter_mjpeg_frames(self, client_label: str, timeout: float): self.set_stream_mode("mjpeg") - if video_case is not None: - self.apply_video_case(video_case) self.api.post("/stream/start", {}) client_id = f"test-{self.run_id}-{client_label}" url = f"{self.api.base}/api/stream/mjpeg?client_id={client_id}" @@ -1441,8 +1502,6 @@ echo "$BACKUP" raise time.sleep(0.5) self.set_stream_mode("mjpeg", timeout=10) - if video_case is not None: - self.apply_video_case(video_case) self.api.post("/stream/start", {}) async def run_hid_test(self) -> None: @@ -1834,7 +1893,7 @@ VIDEO_SCREENSHOT_READY_JS = r""" WEBRTC_MEASURE_JS = r""" -async ({seconds}) => { +async ({seconds, settleSeconds}) => { const api = async (path, opts = {}) => { const response = await fetch('/api' + path, { credentials: 'include', @@ -1895,6 +1954,12 @@ async ({seconds}) => { await new Promise(r => setTimeout(r, 100)); } + // Let capture, encoding, decoding, and browser rendering reach steady state. + // Samples collected during this interval are intentionally discarded. + if (settleSeconds > 0) { + await new Promise(r => setTimeout(r, settleSeconds * 1000)); + } + const samples = []; const endAt = performance.now() + seconds * 1000; while (performance.now() < endAt) { @@ -2252,6 +2317,7 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--network-latency-samples", type=int, default=7, help="controller-to-target network latency samples collected during setup") run.add_argument("--network-latency-timeout", type=float, default=3.0, help="per-sample TCP/HTTP latency timeout in seconds") run.add_argument("--sample-seconds", type=int, default=30) + run.add_argument("--video-config-settle-seconds", type=float, default=3.0, help="idle time after applying video configuration before collecting samples") run.add_argument("--jpeg-quality", type=int, default=80) run.add_argument("--mjpeg-motion-fps", type=int, default=60, help="Windows agent dynamic source fps used during MJPEG/HTTP tests") run.add_argument("--agent-host", default=None, help="Windows agent IP/hostname; omit to skip Windows-side checks") From 4cd622ff077a44ce6a15d7e63b3a3ece325cd645 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 24 Jul 2026 11:01:54 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix:=20OTG=20=E7=BD=91=E5=8D=A1=E6=A1=A5?= =?UTF-8?q?=E6=8E=A5=E6=94=B9=E7=94=B1=20NetworkManager=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=A1=A5=E6=8E=A5=E9=85=8D=E7=BD=AE=20UUID;docker=20?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20NetworkManager=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build/Dockerfile.runtime | 4 ++ build/Dockerfile.runtime-full | 4 ++ src/otg/bridge.rs | 114 +++++++++++++++++++++++++--------- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/build/Dockerfile.runtime b/build/Dockerfile.runtime index 45a7605c..56b6dd7f 100644 --- a/build/Dockerfile.runtime +++ b/build/Dockerfile.runtime @@ -19,6 +19,10 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \ ca-certificates \ libudev1 \ libasound2 \ + # OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus) + network-manager \ + iproute2 \ + iputils-ping \ # v4l2 is handled by kernel, minimal userspace needed libv4l-0 \ && \ diff --git a/build/Dockerfile.runtime-full b/build/Dockerfile.runtime-full index 27b7eab4..422f1d1b 100644 --- a/build/Dockerfile.runtime-full +++ b/build/Dockerfile.runtime-full @@ -19,6 +19,10 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \ ca-certificates \ libudev1 \ libasound2 \ + # OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus) + network-manager \ + iproute2 \ + iputils-ping \ # v4l2 is handled by kernel, minimal userspace needed libv4l-0 \ && \ diff --git a/src/otg/bridge.rs b/src/otg/bridge.rs index 3c6f018a..01a496f4 100644 --- a/src/otg/bridge.rs +++ b/src/otg/bridge.rs @@ -67,8 +67,14 @@ struct BridgeJournal { existing_bridge: bool, original_connection_uuid: Option, bridge_profile_uuid: Option, + #[serde(default)] + bridge_profile_name: Option, uplink_profile_uuid: Option, + #[serde(default)] + uplink_profile_name: Option, usb_profile_uuid: String, + #[serde(default)] + usb_profile_name: Option, } #[derive(Debug)] @@ -87,11 +93,11 @@ impl TransactionProfiles { let suffix = &transaction[..12]; Self { bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"), - bridge_uuid: Uuid::new_v4().to_string(), + bridge_uuid: String::new(), uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"), - uplink_uuid: Uuid::new_v4().to_string(), + uplink_uuid: String::new(), usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"), - usb_uuid: Uuid::new_v4().to_string(), + usb_uuid: String::new(), } } } @@ -151,15 +157,18 @@ impl NetworkBridgeRuntime { .trim() .to_string(); - let profiles = TransactionProfiles::new(); - let journal = BridgeJournal { + let mut profiles = TransactionProfiles::new(); + let mut journal = BridgeJournal { version: JOURNAL_VERSION, uplink: uplink.to_string(), existing_bridge: false, original_connection_uuid: Some(original_connection_uuid.clone()), - bridge_profile_uuid: Some(profiles.bridge_uuid.clone()), - uplink_profile_uuid: Some(profiles.uplink_uuid.clone()), - usb_profile_uuid: profiles.usb_uuid.clone(), + bridge_profile_uuid: None, + bridge_profile_name: Some(profiles.bridge_name.clone()), + uplink_profile_uuid: None, + uplink_profile_name: Some(profiles.uplink_name.clone()), + usb_profile_uuid: String::new(), + usb_profile_name: Some(profiles.usb_name.clone()), }; write_journal(&journal)?; @@ -174,9 +183,10 @@ impl NetworkBridgeRuntime { BRIDGE_IF, "con-name", &profiles.bridge_name, - "connection.uuid", - &profiles.bridge_uuid, ])?; + profiles.bridge_uuid = connection_value(&profiles.bridge_name, "connection.uuid")?; + journal.bridge_profile_uuid = Some(profiles.bridge_uuid.clone()); + write_journal(&journal)?; run_nmcli(&[ "connection", "modify", @@ -220,8 +230,6 @@ impl NetworkBridgeRuntime { uplink, "con-name", &profiles.uplink_name, - "connection.uuid", - &profiles.uplink_uuid, "master", BRIDGE_IF, "slave-type", @@ -229,6 +237,9 @@ impl NetworkBridgeRuntime { "connection.autoconnect", "no", ])?; + profiles.uplink_uuid = connection_value(&profiles.uplink_name, "connection.uuid")?; + journal.uplink_profile_uuid = Some(profiles.uplink_uuid.clone()); + write_journal(&journal)?; run_nmcli(&[ "connection", "add", @@ -238,8 +249,6 @@ impl NetworkBridgeRuntime { usb_interface, "con-name", &profiles.usb_name, - "connection.uuid", - &profiles.usb_uuid, "master", BRIDGE_IF, "slave-type", @@ -247,6 +256,9 @@ impl NetworkBridgeRuntime { "connection.autoconnect", "no", ])?; + profiles.usb_uuid = connection_value(&profiles.usb_name, "connection.uuid")?; + journal.usb_profile_uuid = profiles.usb_uuid.clone(); + write_journal(&journal)?; Ok(()) })(); if let Err(error) = prepare_result { @@ -449,17 +461,27 @@ fn select_bridge_candidate<'a>( fn restore_from_journal(journal: &BridgeJournal) -> Result<()> { let mut errors = Vec::new(); - for (kind, profile_uuid) in [ - ("USB", Some(journal.usb_profile_uuid.as_str())), - ("uplink", journal.uplink_profile_uuid.as_deref()), - ("bridge", journal.bridge_profile_uuid.as_deref()), + for (kind, profile_uuid, profile_name) in [ + ( + "USB", + (!journal.usb_profile_uuid.is_empty()).then_some(journal.usb_profile_uuid.as_str()), + journal.usb_profile_name.as_deref(), + ), + ( + "uplink", + journal.uplink_profile_uuid.as_deref(), + journal.uplink_profile_name.as_deref(), + ), + ( + "bridge", + journal.bridge_profile_uuid.as_deref(), + journal.bridge_profile_name.as_deref(), + ), ] { - let Some(profile_uuid) = profile_uuid else { - continue; - }; - if let Err(error) = delete_connection(profile_uuid) { + if let Err(error) = delete_owned_connection(profile_uuid, profile_name) { + let profile = profile_uuid.or(profile_name).unwrap_or("unknown"); errors.push(format!( - "failed to remove owned {kind} profile {profile_uuid}: {error}" + "failed to remove owned {kind} profile {profile}: {error}" )); } } @@ -627,12 +649,23 @@ fn connection_uuids() -> Result> { .collect()) } -fn delete_connection(profile_uuid: &str) -> Result<()> { - if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) { +fn delete_owned_connection(profile_uuid: Option<&str>, profile_name: Option<&str>) -> Result<()> { + if let Some(profile_uuid) = profile_uuid { + if connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) { + return run_nmcli(&["connection", "delete", "uuid", profile_uuid]).map(|_| ()); + } + } + let Some(profile_name) = profile_name else { + return Ok(()); + }; + let output = run_nmcli(&["-t", "--escape", "no", "-f", "NAME", "connection", "show"])?; + if !String::from_utf8_lossy(&output.stdout) + .lines() + .any(|name| name == profile_name) + { return Ok(()); } - run_nmcli(&["connection", "delete", "uuid", profile_uuid])?; - Ok(()) + run_nmcli(&["connection", "delete", "id", profile_name]).map(|_| ()) } fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> { @@ -834,8 +867,11 @@ mod tests { existing_bridge: false, original_connection_uuid: Some("original-uuid".to_string()), bridge_profile_uuid: Some("bridge-uuid".to_string()), + bridge_profile_name: Some("bridge-name".to_string()), uplink_profile_uuid: Some("uplink-uuid".to_string()), + uplink_profile_name: Some("uplink-name".to_string()), usb_profile_uuid: "usb-uuid".to_string(), + usb_profile_name: Some("usb-name".to_string()), }; let value = serde_json::to_string(&journal).unwrap(); let decoded: BridgeJournal = serde_json::from_str(&value).unwrap(); @@ -844,13 +880,31 @@ mod tests { } #[test] - fn transaction_profiles_use_unique_names_and_uuids() { + fn bridge_journal_accepts_legacy_entries_without_profile_names() { + let value = r#"{ + "version": 2, + "uplink": "eth0", + "existing_bridge": false, + "original_connection_uuid": "original-uuid", + "bridge_profile_uuid": "bridge-uuid", + "uplink_profile_uuid": "uplink-uuid", + "usb_profile_uuid": "usb-uuid" + }"#; + let decoded: BridgeJournal = serde_json::from_str(value).unwrap(); + assert_eq!(decoded.bridge_profile_name, None); + assert_eq!(decoded.uplink_profile_name, None); + assert_eq!(decoded.usb_profile_name, None); + } + + #[test] + fn transaction_profiles_use_unique_names() { let first = TransactionProfiles::new(); let second = TransactionProfiles::new(); assert_ne!(first.bridge_name, second.bridge_name); - assert_ne!(first.bridge_uuid, second.bridge_uuid); assert!(first.usb_name.starts_with(PROFILE_PREFIX)); - assert!(Uuid::parse_str(&first.usb_uuid).is_ok()); + assert!(first.bridge_uuid.is_empty()); + assert!(first.uplink_uuid.is_empty()); + assert!(first.usb_uuid.is_empty()); } #[test] From 32d8a62fec9a82087d628dc74664a47a39cd151b Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 24 Jul 2026 11:02:14 +0800 Subject: [PATCH 04/23] =?UTF-8?q?fix:=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E6=97=B6=E5=AE=8C=E6=95=B4=E5=BA=94=E7=94=A8=20USB=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=85=8D=E7=BD=AE=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=81=97=E6=BC=8F=20MSD=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/handlers/setup.rs | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/src/web/handlers/setup.rs b/src/web/handlers/setup.rs index b8712dc2..0179cf83 100644 --- a/src/web/handlers/setup.rs +++ b/src/web/handlers/setup.rs @@ -65,6 +65,8 @@ pub async fn setup_init( )); } + let old_config = state.config.get(); + // Create single system user state .users @@ -140,18 +142,10 @@ pub async fn setup_init( }) .await?; - // Get updated config for HID reload + // Apply the complete USB runtime configuration, including the MSD controller. let new_config = state.config.get(); - - #[cfg(unix)] - { - if let Err(e) = state - .otg_service - .apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network) - .await - { - tracing::warn!("Failed to apply OTG config during setup: {}", e); - } + if let Err(e) = config::apply::apply_usb_config(&state, &old_config, &new_config).await { + tracing::warn!("Failed to apply USB config during setup: {}", e); } tracing::info!( @@ -160,25 +154,6 @@ pub async fn setup_init( new_config.rustdesk.enabled ); - // Initialize HID backend with new config - let new_hid_backend = match new_config.hid.backend { - crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg, - crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 { - port: new_config.hid.ch9329_port.clone(), - baud_rate: new_config.hid.ch9329_baudrate, - hybrid_mouse: new_config.hid.ch9329_hybrid_mouse, - }, - crate::config::HidBackend::None => crate::hid::HidBackendType::None, - }; - - // Reload HID backend - if let Err(e) = state.hid.reload(new_hid_backend).await { - tracing::warn!("Failed to initialize HID backend during setup: {}", e); - // Don't fail setup, just warn - } else { - tracing::info!("HID backend initialized: {:?}", new_config.hid.backend); - } - // Start extensions if enabled if new_config.extensions.ttyd.enabled { if let Err(e) = state From e1b82519b98893d42c05754f4e707d93bcf950cc Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 24 Jul 2026 11:03:22 +0800 Subject: [PATCH 05/23] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/okvm-test/okvm_report.py | 41 +++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/test/okvm-test/okvm_report.py b/test/okvm-test/okvm_report.py index afda979f..b421693d 100644 --- a/test/okvm-test/okvm_report.py +++ b/test/okvm-test/okvm_report.py @@ -163,8 +163,8 @@ class Reporter: "", f"- 运行编号:`{run_id}`", "- 测试设备:", - f"- 视频设备:{markdown_inline(self.user_video_device())}", - f"- HID设备:{markdown_inline(self.user_hid_backend())}", + f"- 视频设备:{self.user_video_device()}", + f"- HID 设备:{markdown_inline(self.user_hid_backend())}", f"- HTTP 延迟:{markdown_inline(self.user_http_latency())}", "", "## 视频性能", @@ -192,7 +192,7 @@ class Reporter: lines.extend( [ "", - "## HID性能", + "## HID 性能", "", "| 输入方式 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max) |", "| --- | --- |", @@ -208,7 +208,7 @@ class Reporter: lines.extend( [ "", - "## MSD性能", + "## MSD 性能", "", "| 操作 | 数据 |", "| --- | --- |", @@ -249,7 +249,9 @@ class Reporter: continue device = str(case.get("device") or "未知设备") by_device.setdefault(device, []).append(format_video_case(case)) - return ";".join(f"{device}:{', '.join(items)}" for device, items in by_device.items()) or "无数据" + return ";".join( + f"{markdown_code(device)}({'、'.join(items)})" for device, items in by_device.items() + ) or "无数据" def user_hid_backend(self) -> str: config = self.find_result("hid_msd_config") @@ -317,11 +319,11 @@ class Reporter: verify = result.data.get("verify") or {} rows: list[tuple[str, str]] = [] if "write_mib_s" in verify: - rows.append(("写", f"{float(verify.get('write_mib_s') or 0):.2f}MiB/s")) + rows.append(("写", f"{float(verify.get('write_mib_s') or 0):.2f} MiB/s")) if "read_mib_s" in verify: - rows.append(("读", f"{float(verify.get('read_mib_s') or 0):.2f}MiB/s")) + rows.append(("读", f"{float(verify.get('read_mib_s') or 0):.2f} MiB/s")) elif "cached_read_mib_s" in verify: - rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s")) + rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f} MiB/s")) if rows: return rows return [("MSD", f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status))] @@ -589,19 +591,30 @@ def display_name(name: str) -> str: def format_video_case(case: dict[str, Any]) -> str: - fmt = str(case.get("fmt") or "").lower() + fmt = format_video_codec(case.get("fmt")) resolution = format_resolution(case) fps = format_fps_value(case.get("fps")) return " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) def format_video_latency_params(case: dict[str, Any], output_mode: str) -> str: - fmt = str(case.get("fmt") or "").lower() - output = output_mode.lower() if output_mode else "unknown" + fmt = format_video_codec(case.get("fmt")) + output = format_video_codec(output_mode) if output_mode else "UNKNOWN" resolution = format_resolution(case) fps = format_fps_value(case.get("fps")) input_part = " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) - return f"{input_part}-->{output}" if input_part else output + return f"{input_part} → {output}" if input_part else output + + +def format_video_codec(value: Any) -> str: + codec = str(value or "").strip() + normalized = codec.lower().replace(".", "").replace("-", "").replace("_", "") + names = { + "h264": "H.264", + "h265": "H.265", + "mjpeg": "MJPEG", + } + return names.get(normalized, codec.upper()) def format_resolution(case: dict[str, Any]) -> str: @@ -659,6 +672,10 @@ def markdown_inline(value: str) -> str: return str(value).replace("\n", " | ").replace("`", "'") +def markdown_code(value: str) -> str: + return f"`{markdown_inline(value)}`" + + def markdown_table_cell(value: str) -> str: return markdown_inline(value).replace("|", "\\|") From 5963dfa01a13d36054e5d297b1dfc36869dc22dc Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 26 Jul 2026 11:59:58 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20OTG=20UAC=20?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/audio/capture.rs | 156 ++++- src/audio/capture_linux.rs | 363 +++------- src/audio/capture_windows.rs | 189 +---- src/audio/controller.rs | 114 ++- src/audio/device.rs | 36 +- src/audio/device_linux.rs | 41 +- src/audio/device_windows.rs | 27 +- src/audio/encoder.rs | 6 - src/audio/mod.rs | 20 +- src/audio/monitor.rs | 4 +- src/audio/recovery.rs | 659 ++++++++++-------- src/audio/streamer.rs | 202 ++++-- src/audio/uac/decoder.rs | 59 ++ src/audio/uac/mod.rs | 9 + src/audio/uac/playback.rs | 509 ++++++++++++++ src/audio/uac/protocol.rs | 98 +++ src/audio/uac_streamer.rs | 210 ------ src/audio/uac_websocket.rs | 133 ---- src/config/schema/mod.rs | 4 +- src/config/schema/uac.rs | 78 +++ src/main.rs | 31 +- src/otg/mod.rs | 2 +- src/otg/service.rs | 77 +- src/otg/uac.rs | 9 +- src/state.rs | 6 +- src/web/handlers/config/apply.rs | 96 +-- src/web/handlers/config/mod.rs | 2 +- src/web/handlers/config/uac.rs | 30 +- src/web/handlers/config/usb_update.rs | 2 + src/web/mod.rs | 2 + src/web/routes.rs | 13 +- src/web/uac_ws.rs | 138 +++- web/package-lock.json | 26 - web/package.json | 1 - web/src/api/request.ts | 8 + web/src/components/ActionBar.vue | 25 +- web/src/components/AudioConfigPopover.vue | 305 ++++---- web/src/components/HidConfigPopover.vue | 19 +- .../components/MicrophoneTransferControls.vue | 110 +++ web/src/components/VideoConfigPopover.vue | 73 +- web/src/composables/useMicrophone.ts | 323 +++++---- web/src/i18n/en-US.ts | 41 +- web/src/i18n/zh-CN.ts | 40 +- web/src/lib/uac-microphone.ts | 328 +++++++++ web/src/views/ConsoleView.vue | 113 ++- web/src/views/SettingsView.vue | 5 +- 46 files changed, 2875 insertions(+), 1867 deletions(-) create mode 100644 src/audio/uac/decoder.rs create mode 100644 src/audio/uac/mod.rs create mode 100644 src/audio/uac/playback.rs create mode 100644 src/audio/uac/protocol.rs delete mode 100644 src/audio/uac_streamer.rs delete mode 100644 src/audio/uac_websocket.rs create mode 100644 src/config/schema/uac.rs create mode 100644 web/src/components/MicrophoneTransferControls.vue create mode 100644 web/src/lib/uac-microphone.ts diff --git a/src/audio/capture.rs b/src/audio/capture.rs index 8565763d..22b958ff 100644 --- a/src/audio/capture.rs +++ b/src/audio/capture.rs @@ -1,3 +1,15 @@ +//! Platform-neutral capture lifecycle and PCM frame types. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use bytes::Bytes; +use tokio::sync::{broadcast, watch, Mutex}; +use tracing::{debug, info}; + +use crate::error::Result; +use crate::utils::LogThrottler; + #[cfg(unix)] #[path = "capture_linux.rs"] mod imp; @@ -6,4 +18,146 @@ mod imp; #[path = "capture_windows.rs"] mod imp; -pub use imp::*; +#[derive(Debug, Clone)] +pub struct AudioConfig { + pub device_name: String, + pub sample_rate: u32, + pub channels: u32, + pub buffer_frames: u32, + pub period_frames: u32, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + device_name: String::new(), + sample_rate: 48_000, + channels: 2, + buffer_frames: 4096, + period_frames: 960, + } + } +} + +#[derive(Debug, Clone)] +pub struct AudioFrame { + pub data: Bytes, + pub sample_rate: u32, + pub channels: u32, +} + +impl AudioFrame { + pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32) -> Self { + Self { + data, + sample_rate, + channels, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureState { + Stopped, + Starting, + Running, + Error, +} + +pub struct AudioCapturer { + config: AudioConfig, + state: watch::Sender, + state_rx: watch::Receiver, + frame_tx: broadcast::Sender, + stop_flag: Arc, + task: Mutex>>, + 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 { + self.state_rx.clone() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + 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(()) + } +} diff --git a/src/audio/capture_linux.rs b/src/audio/capture_linux.rs index 89aaca03..af11b15a 100644 --- a/src/audio/capture_linux.rs +++ b/src/audio/capture_linux.rs @@ -1,271 +1,60 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + use alsa::pcm::{Access, Format, Frames, HwParams, State, IO}; use alsa::{Direction, ValueOr, PCM}; use bytes::Bytes; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::{broadcast, watch, Mutex}; -use tracing::{debug, info}; +use tokio::sync::{broadcast, watch}; +use tracing::debug; -use crate::audio::device::AudioDeviceInfo; +use super::{AudioConfig, AudioFrame, CaptureState}; use crate::error::{AppError, Result}; use crate::utils::LogThrottler; -use crate::{error_throttled, warn_throttled}; +use crate::warn_throttled; -#[derive(Debug, Clone)] -pub struct AudioConfig { - pub device_name: String, - pub sample_rate: u32, - pub channels: u32, - pub frame_size: u32, - pub buffer_frames: u32, - pub period_frames: u32, -} +const RETRY_DELAY: Duration = Duration::from_millis(5); +const MAX_CONSECUTIVE_READ_ERRORS: u32 = 10; -impl Default for AudioConfig { - fn default() -> Self { - Self { - device_name: String::new(), - sample_rate: 48000, - channels: 2, - frame_size: 960, - buffer_frames: 4096, - period_frames: 960, - } - } -} - -impl AudioConfig { - pub fn for_device(device: &AudioDeviceInfo) -> Self { - Self { - device_name: device.name.clone(), - ..Default::default() - } - } - - pub fn bytes_per_sample(&self) -> u32 { - 2 * self.channels - } - - pub fn bytes_per_frame(&self) -> usize { - (self.frame_size * self.bytes_per_sample()) as usize - } -} - -#[derive(Debug, Clone)] -pub struct AudioFrame { - pub data: Bytes, - pub sample_rate: u32, - pub channels: u32, - pub samples: u32, - pub sequence: u64, - pub timestamp: Instant, -} - -impl AudioFrame { - pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self { - let bps = 2 * channels; - Self { - samples: data.len() as u32 / bps, - data, - sample_rate, - channels, - sequence, - timestamp: Instant::now(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CaptureState { - Stopped, - Running, - Error, -} - -pub struct AudioCapturer { - config: AudioConfig, - state: Arc>, - state_rx: watch::Receiver, - frame_tx: broadcast::Sender, - stop_flag: Arc, - sequence: Arc, - capture_handle: Mutex>>, - 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 { - self.state_rx.clone() - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.frame_tx.subscribe() - } - - pub async fn start(&self) -> Result<()> { - if self.state() == CaptureState::Running { - return Ok(()); - } - - debug!( - "Starting audio capture on {} at {}Hz {}ch", - self.config.device_name, self.config.sample_rate, self.config.channels - ); - - self.stop_flag.store(false, Ordering::SeqCst); - - let config = self.config.clone(); - let state = self.state.clone(); - let frame_tx = self.frame_tx.clone(); - let stop_flag = self.stop_flag.clone(); - let sequence = self.sequence.clone(); - let log_throttler = self.log_throttler.clone(); - - let handle = tokio::task::spawn_blocking(move || { - let result = run_capture( - &config, - &state, - &frame_tx, - &stop_flag, - &sequence, - &log_throttler, - ); - - if let Err(e) = result { - error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e); - let _ = state.send(CaptureState::Error); - } else { - let _ = state.send(CaptureState::Stopped); - } - }); - - *self.capture_handle.lock().await = Some(handle); - Ok(()) - } - - pub async fn stop(&self) -> Result<()> { - info!("Stopping audio capture"); - self.stop_flag.store(true, Ordering::SeqCst); - - if let Some(handle) = self.capture_handle.lock().await.take() { - let _ = handle.await; - } - - let _ = self.state.send(CaptureState::Stopped); - Ok(()) - } - - pub fn is_running(&self) -> bool { - self.state() == CaptureState::Running - } -} - -fn run_capture( +pub(super) fn run_capture( config: &AudioConfig, state: &watch::Sender, frame_tx: &broadcast::Sender, stop_flag: &AtomicBool, - sequence: &AtomicU64, log_throttler: &LogThrottler, ) -> Result<()> { - let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| { + // Non-blocking mode guarantees that stop() can always join the worker. + let pcm = PCM::new(&config.device_name, Direction::Capture, true).map_err(|error| { AppError::AudioError(format!( "Failed to open audio device {}: {}", - config.device_name, e + config.device_name, error )) })?; - { - let hwp = HwParams::any(&pcm) - .map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?; - - hwp.set_channels(config.channels) - .map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?; - - hwp.set_rate(config.sample_rate, ValueOr::Nearest) - .map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?; - - hwp.set_format(Format::s16()) - .map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?; - - hwp.set_access(Access::RWInterleaved) - .map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?; - - hwp.set_buffer_size_near(config.buffer_frames as Frames) - .map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?; - - hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest) - .map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?; - - pcm.hw_params(&hwp) - .map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?; - } - - let hw_now = pcm.hw_params_current().map_err(|e| { - AppError::AudioError(format!("Failed to read hw_params after apply: {}", e)) - })?; - let actual_rate = hw_now - .get_rate() - .map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?; - let actual_ch = hw_now - .get_channels() - .map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?; - if actual_rate != 48_000 { - return Err(AppError::AudioError(format!( - "Audio capture requires 48000 Hz; device is {} Hz", - actual_rate - ))); - } - if actual_ch != 2 { - return Err(AppError::AudioError(format!( - "Audio capture requires 2 channels (stereo); device has {}", - actual_ch - ))); - } - debug!("Audio capture: 48000 Hz, 2 ch"); - + configure_pcm(&pcm, config)?; pcm.prepare() - .map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?; - + .map_err(|error| AppError::AudioError(format!("Failed to prepare PCM: {error}")))?; let _ = state.send(CaptureState::Running); let period_frames = pcm .hw_params_current() .ok() - .and_then(|h| h.get_period_size().ok()) - .map(|f| f as usize) - .unwrap_or(1024) + .and_then(|params| params.get_period_size().ok()) + .map(|frames| frames as usize) + .unwrap_or(config.period_frames as usize) .max(256); - let buf_frames = period_frames.saturating_mul(4).max(2048); - let bytes_per_frame = (config.channels as usize) * 2; - let mut buffer = vec![0u8; buf_frames * bytes_per_frame]; + let mut buffer = vec![0u8; period_frames * config.channels as usize * 2]; + let io: IO = pcm.io_bytes(); + let mut consecutive_errors = 0; - while !stop_flag.load(Ordering::Relaxed) { + while !stop_flag.load(Ordering::Acquire) { match pcm.state() { State::XRun => { warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering"); - let _ = pcm.prepare(); + pcm.prepare().map_err(|error| { + AppError::AudioError(format!("Failed to recover audio xrun: {error}")) + })?; + consecutive_errors = 0; continue; } State::Suspended => { @@ -274,61 +63,95 @@ fn run_capture( "suspended", "Audio device suspended, recovering" ); - let _ = pcm.resume(); + if pcm.resume().is_err() { + pcm.prepare().map_err(|error| { + AppError::AudioError(format!("Failed to resume audio capture: {error}")) + })?; + } + consecutive_errors = 0; continue; } _ => {} } - // io_bytes: USB capture often lacks mmap (io_checked requires it). - let io: IO = pcm.io_bytes(); - match io.readi(&mut buffer) { + Ok(0) => thread::sleep(RETRY_DELAY), Ok(frames_read) => { - if frames_read == 0 { - continue; - } - + consecutive_errors = 0; let byte_count = frames_read * config.channels as usize * 2; - - let seq = sequence.fetch_add(1, Ordering::Relaxed); let frame = AudioFrame::new_interleaved( Bytes::copy_from_slice(&buffer[..byte_count]), config.channels, - 48_000, - seq, + config.sample_rate, ); - if frame_tx.receiver_count() > 0 { - if let Err(e) = frame_tx.send(frame) { - debug!("No audio receivers: {}", e); - } + let _ = frame_tx.send(frame); } } - Err(e) => { - let desc = e.to_string(); - if is_device_lost_error(&desc) { + Err(error) if error.errno() == libc::EAGAIN => thread::sleep(RETRY_DELAY), + Err(error) if is_device_lost_errno(error.errno()) => { + return Err(AppError::AudioError(format!( + "Audio device lost while reading {}: {}", + config.device_name, error + ))); + } + Err(error) if error.errno() == libc::EPIPE => { + warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun"); + pcm.prepare().map_err(|prepare_error| { + AppError::AudioError(format!( + "Failed to recover after audio overrun ({error}): {prepare_error}" + )) + })?; + consecutive_errors = 0; + } + Err(error) => { + consecutive_errors += 1; + warn_throttled!(log_throttler, "read_error", "Audio read error: {}", error); + if consecutive_errors >= MAX_CONSECUTIVE_READ_ERRORS { return Err(AppError::AudioError(format!( - "Audio device lost while reading {}: {}", - config.device_name, e + "Audio capture failed {consecutive_errors} times consecutively: {error}" ))); - } else if desc.contains("EPIPE") || desc.contains("Broken pipe") { - warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun"); - let _ = pcm.prepare(); - } else { - error_throttled!(log_throttler, "read_error", "Audio read error: {}", e); } + thread::sleep(RETRY_DELAY); } } } - info!("Audio capture stopped"); + debug!("ALSA capture worker stopped"); Ok(()) } -fn is_device_lost_error(desc: &str) -> bool { - desc.contains("No such device") - || desc.contains("ENODEV") - || desc.contains("ENXIO") - || desc.contains("ESHUTDOWN") +fn configure_pcm(pcm: &PCM, config: &AudioConfig) -> Result<()> { + let params = HwParams::any(pcm) + .map_err(|error| AppError::AudioError(format!("Failed to get HwParams: {error}")))?; + params + .set_channels(config.channels) + .and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest)) + .and_then(|_| params.set_format(Format::s16())) + .and_then(|_| params.set_access(Access::RWInterleaved)) + .and_then(|_| params.set_buffer_size_near(config.buffer_frames as Frames)) + .and_then(|_| params.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)) + .and_then(|_| pcm.hw_params(¶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) } diff --git a/src/audio/capture_windows.rs b/src/audio/capture_windows.rs index 2f059df3..260d6314 100644 --- a/src/audio/capture_windows.rs +++ b/src/audio/capture_windows.rs @@ -1,198 +1,23 @@ use bytes::Bytes; use cpal::traits::{DeviceTrait, StreamTrait}; use cpal::{BufferSize, SampleFormat, StreamConfig}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::{broadcast, watch, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, watch}; use tracing::{debug, info}; -use crate::audio::device::{find_wasapi_device, AudioDeviceInfo}; +use super::{AudioConfig, AudioFrame, CaptureState}; +use crate::audio::device::find_wasapi_device; use crate::error::{AppError, Result}; -use crate::error_throttled; use crate::utils::LogThrottler; -#[derive(Debug, Clone)] -pub struct AudioConfig { - pub device_name: String, - pub sample_rate: u32, - pub channels: u32, - pub frame_size: u32, - pub buffer_frames: u32, - pub period_frames: u32, -} - -impl Default for AudioConfig { - fn default() -> Self { - Self { - device_name: String::new(), - sample_rate: 48000, - channels: 2, - frame_size: 960, - buffer_frames: 4096, - period_frames: 960, - } - } -} - -impl AudioConfig { - pub fn for_device(device: &AudioDeviceInfo) -> Self { - Self { - device_name: device.name.clone(), - ..Default::default() - } - } - - pub fn bytes_per_sample(&self) -> u32 { - 2 * self.channels - } - - pub fn bytes_per_frame(&self) -> usize { - (self.frame_size * self.bytes_per_sample()) as usize - } -} - -#[derive(Debug, Clone)] -pub struct AudioFrame { - pub data: Bytes, - pub sample_rate: u32, - pub channels: u32, - pub samples: u32, - pub sequence: u64, - pub timestamp: Instant, -} - -impl AudioFrame { - pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self { - let bps = 2 * channels; - Self { - samples: data.len() as u32 / bps, - data, - sample_rate, - channels, - sequence, - timestamp: Instant::now(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CaptureState { - Stopped, - Running, - Error, -} - -pub struct AudioCapturer { - config: AudioConfig, - state: Arc>, - state_rx: watch::Receiver, - frame_tx: broadcast::Sender, - stop_flag: Arc, - sequence: Arc, - capture_handle: Mutex>>, - 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 { - self.state_rx.clone() - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.frame_tx.subscribe() - } - - pub async fn start(&self) -> Result<()> { - if self.state() == CaptureState::Running { - return Ok(()); - } - - debug!( - "Starting WASAPI audio capture on {} at {}Hz {}ch", - self.config.device_name, self.config.sample_rate, self.config.channels - ); - - self.stop_flag.store(false, Ordering::SeqCst); - - let config = self.config.clone(); - let state = self.state.clone(); - let frame_tx = self.frame_tx.clone(); - let stop_flag = self.stop_flag.clone(); - let sequence = self.sequence.clone(); - let log_throttler = self.log_throttler.clone(); - - let handle = tokio::task::spawn_blocking(move || { - let result = run_capture( - &config, - &state, - &frame_tx, - &stop_flag, - &sequence, - &log_throttler, - ); - - if let Err(e) = result { - error_throttled!( - log_throttler, - "capture_error", - "WASAPI audio capture error: {}", - e - ); - let _ = state.send(CaptureState::Error); - } else { - let _ = state.send(CaptureState::Stopped); - } - }); - - *self.capture_handle.lock().await = Some(handle); - Ok(()) - } - - pub async fn stop(&self) -> Result<()> { - info!("Stopping WASAPI audio capture"); - self.stop_flag.store(true, Ordering::SeqCst); - - if let Some(handle) = self.capture_handle.lock().await.take() { - let _ = handle.await; - } - - let _ = self.state.send(CaptureState::Stopped); - Ok(()) - } - - pub fn is_running(&self) -> bool { - self.state() == CaptureState::Running - } -} - -fn run_capture( +pub(super) fn run_capture( config: &AudioConfig, state: &watch::Sender, frame_tx: &broadcast::Sender, stop_flag: &AtomicBool, - sequence: &AtomicU64, log_throttler: &LogThrottler, ) -> Result<()> { let device = find_wasapi_device(&config.device_name)?; @@ -272,12 +97,10 @@ fn run_capture( if samples.is_empty() { continue; } - let seq = sequence.fetch_add(1, Ordering::Relaxed); let frame = AudioFrame::new_interleaved( Bytes::copy_from_slice(bytemuck::cast_slice(&samples)), 2, 48_000, - seq, ); if frame_tx.receiver_count() > 0 { if let Err(e) = frame_tx.send(frame) { diff --git a/src/audio/controller.rs b/src/audio/controller.rs index 2c0e064c..1e2f1675 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -1,8 +1,7 @@ //! Device selection, quality presets, streaming. -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info}; use super::capture::AudioConfig; @@ -22,23 +21,37 @@ pub(super) type AudioRecoveredCallback = Arc; pub struct AudioController { config: Arc>, streamer: Arc>>>, - devices: Arc>>, event_bus: Arc>>>, monitor: Arc, - recovery_in_progress: Arc, + recovery: recovery::AudioRecovery, recovered_callback: Arc>>, + operation: Arc>, } impl AudioController { pub fn new(config: AudioControllerConfig) -> Self { + let config = Arc::new(RwLock::new(config)); + let streamer = Arc::new(RwLock::new(None)); + let event_bus = Arc::new(RwLock::new(None)); + let monitor = Arc::new(AudioHealthMonitor::new()); + let recovered_callback = Arc::new(RwLock::new(None)); + let operation = Arc::new(Mutex::new(())); + let recovery = recovery::AudioRecovery::new( + config.clone(), + streamer.clone(), + event_bus.clone(), + monitor.clone(), + recovered_callback.clone(), + operation.clone(), + ); Self { - config: Arc::new(RwLock::new(config)), - streamer: Arc::new(RwLock::new(None)), - devices: Arc::new(RwLock::new(Vec::new())), - event_bus: Arc::new(RwLock::new(None)), - monitor: Arc::new(AudioHealthMonitor::new()), - recovery_in_progress: Arc::new(AtomicBool::new(false)), - recovered_callback: Arc::new(RwLock::new(None)), + config, + streamer, + event_bus, + monitor, + recovery, + recovered_callback, + operation, } } @@ -55,31 +68,6 @@ impl AudioController { bus.mark_device_info_dirty(); } } - fn spawn_recovery_task(&self, lost_device: String, reason: String) { - recovery::spawn_recovery_task( - self.config.clone(), - self.streamer.clone(), - self.event_bus.clone(), - self.monitor.clone(), - self.recovery_in_progress.clone(), - self.recovered_callback.clone(), - lost_device, - reason, - ); - } - - fn spawn_stream_monitor(&self, streamer: Arc, 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> { let current_device = if self.is_streaming().await { @@ -88,26 +76,19 @@ impl AudioController { None }; - let devices = enumerate_audio_devices_with_current(current_device.as_deref())?; - *self.devices.write().await = devices.clone(); - Ok(devices) - } - - pub async fn get_cached_devices(&self) -> Vec { - self.devices.read().await.clone() + enumerate_audio_devices_with_current(current_device.as_deref()) } pub async fn select_device(&self, device: &str) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); let devices = self.list_devices().await?; let found = devices .iter() .any(|d| d.name == device || d.description.contains(device)); if !found { - return Err(AppError::AudioError(format!( - "Audio device not found: {}", - device - ))); + return Err(AppError::NotFound(format!("audio device {device}"))); } { @@ -118,14 +99,15 @@ impl AudioController { info!("Audio device selected: {}", device); if self.is_streaming().await { - self.stop_streaming().await?; - self.start_streaming().await?; + self.stop_streaming_inner().await?; + self.start_streaming_inner().await?; } Ok(()) } pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> { + let _operation = self.operation.lock().await; { let mut config = self.config.write().await; config.quality = quality; @@ -144,6 +126,12 @@ impl AudioController { } pub async fn start_streaming(&self) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); + self.start_streaming_inner().await + } + + async fn start_streaming_inner(&self) -> Result<()> { { let config = self.config.read().await; if !config.enabled { @@ -171,7 +159,7 @@ impl AudioController { if let Some(error_msg) = select_error { self.monitor.report_error(&error_msg, "start_failed").await; - self.spawn_recovery_task("auto".to_string(), error_msg.clone()); + self.recovery.start("auto".to_string(), error_msg.clone()); self.mark_device_info_dirty().await; return Err(AppError::AudioError(error_msg)); } @@ -194,7 +182,7 @@ impl AudioController { let error_msg = format!("Failed to start audio: {}", e); self.monitor.report_error(&error_msg, "start_failed").await; - self.spawn_recovery_task(device_name.clone(), error_msg.clone()); + self.recovery.start(device_name.clone(), error_msg.clone()); self.mark_device_info_dirty().await; @@ -203,14 +191,13 @@ impl AudioController { let streamer_for_monitor = streamer.clone(); *self.streamer.write().await = Some(streamer); - self.spawn_stream_monitor(streamer_for_monitor, device_name.clone()); + self.recovery + .monitor(streamer_for_monitor, device_name.clone()); if self.monitor.is_error().await { self.monitor.report_recovered().await; } - self.recovery_in_progress.store(false, Ordering::SeqCst); - self.mark_device_info_dirty().await; info!("Audio streaming started"); @@ -218,7 +205,12 @@ impl AudioController { } pub async fn stop_streaming(&self) -> Result<()> { - self.recovery_in_progress.store(false, Ordering::SeqCst); + let _operation = self.operation.lock().await; + self.stop_streaming_inner().await + } + + async fn stop_streaming_inner(&self) -> Result<()> { + self.recovery.cancel(); if let Some(streamer) = self.streamer.write().await.take() { streamer.stop().await?; @@ -249,7 +241,7 @@ impl AudioController { let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await { let streaming = streamer.is_running(); - let subscriber_count = streamer.stats().subscriber_count; + let subscriber_count = streamer.subscriber_count(); (streaming, subscriber_count) } else { (false, 0) @@ -278,13 +270,15 @@ impl AudioController { } pub async fn set_enabled(&self, enabled: bool) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); { let mut config = self.config.write().await; config.enabled = enabled; } if !enabled && self.is_streaming().await { - self.stop_streaming().await?; + self.stop_streaming_inner().await?; } info!("Audio enabled: {}", enabled); @@ -292,16 +286,18 @@ impl AudioController { } pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> { + let _operation = self.operation.lock().await; + self.recovery.cancel(); let was_streaming = self.is_streaming().await; if was_streaming { - self.stop_streaming().await?; + self.stop_streaming_inner().await?; } *self.config.write().await = new_config.clone(); if new_config.enabled { - self.start_streaming().await?; + self.start_streaming_inner().await?; } Ok(()) diff --git a/src/audio/device.rs b/src/audio/device.rs index e8752ac2..223b57c9 100644 --- a/src/audio/device.rs +++ b/src/audio/device.rs @@ -1,3 +1,9 @@ +//! Shared device description with platform-specific enumeration backends. + +use serde::Serialize; + +use crate::error::Result; + #[cfg(unix)] #[path = "device_linux.rs"] mod imp; @@ -6,4 +12,32 @@ mod imp; #[path = "device_windows.rs"] mod imp; -pub use imp::*; +#[derive(Debug, Clone, Serialize)] +pub struct AudioDeviceInfo { + pub name: String, + pub description: String, + pub card_index: i32, + pub device_index: i32, + pub sample_rates: Vec, + pub channels: Vec, + pub is_capture: bool, + pub is_hdmi: bool, + pub usb_bus: Option, +} + +pub fn enumerate_audio_devices() -> Result> { + imp::enumerate_audio_devices_with_current(None) +} + +pub fn enumerate_audio_devices_with_current( + current_device: Option<&str>, +) -> Result> { + imp::enumerate_audio_devices_with_current(current_device) +} + +pub(crate) fn find_best_audio_device() -> Result { + imp::find_best_audio_device() +} + +#[cfg(windows)] +pub(crate) use imp::find_wasapi_device; diff --git a/src/audio/device_linux.rs b/src/audio/device_linux.rs index 66df38cc..eac4d5a4 100644 --- a/src/audio/device_linux.rs +++ b/src/audio/device_linux.rs @@ -1,23 +1,10 @@ use alsa::pcm::HwParams; use alsa::{Direction, PCM}; -use serde::Serialize; use tracing::{debug, info, warn}; +use super::AudioDeviceInfo; use crate::error::{AppError, Result}; -#[derive(Debug, Clone, Serialize)] -pub struct AudioDeviceInfo { - pub name: String, - pub description: String, - pub card_index: i32, - pub device_index: i32, - pub sample_rates: Vec, - pub channels: Vec, - pub is_capture: bool, - pub is_hdmi: bool, - pub usb_bus: Option, -} - fn get_usb_bus_info(card_index: i32) -> Option { if card_index < 0 { return None; @@ -28,26 +15,18 @@ fn get_usb_bus_info(card_index: i32) -> Option { let link_str = link_target.to_string_lossy(); for component in link_str.split('/') { - if component.contains('-') && !component.contains(':') { - if component - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - { - return Some(component.to_string()); - } + if component.contains('-') + && !component.contains(':') + && component.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + return Some(component.to_string()); } } None } -pub fn enumerate_audio_devices() -> Result> { - enumerate_audio_devices_with_current(None) -} - -pub fn enumerate_audio_devices_with_current( +pub(super) fn enumerate_audio_devices_with_current( current_device: Option<&str>, ) -> Result> { let mut devices = Vec::new(); @@ -153,8 +132,8 @@ fn query_device_caps(pcm: &PCM) -> (Vec, Vec) { (supported_rates, supported_channels) } -pub fn find_best_audio_device() -> Result { - let devices = enumerate_audio_devices()?; +pub(super) fn find_best_audio_device() -> Result { + let devices = enumerate_audio_devices_with_current(None)?; if devices.is_empty() { return Err(AppError::AudioError( @@ -194,7 +173,7 @@ mod tests { #[test] fn test_enumerate_devices() { - let result = enumerate_audio_devices(); + let result = enumerate_audio_devices_with_current(None); println!("Audio devices: {:?}", result); assert!(result.is_ok()); } diff --git a/src/audio/device_windows.rs b/src/audio/device_windows.rs index 8ea1aaba..9c898019 100644 --- a/src/audio/device_windows.rs +++ b/src/audio/device_windows.rs @@ -1,29 +1,12 @@ use cpal::traits::{DeviceTrait, HostTrait}; use cpal::DeviceId; -use serde::Serialize; use std::str::FromStr; use tracing::{debug, info, warn}; +use super::AudioDeviceInfo; use crate::error::{AppError, Result}; -#[derive(Debug, Clone, Serialize)] -pub struct AudioDeviceInfo { - pub name: String, - pub description: String, - pub card_index: i32, - pub device_index: i32, - pub sample_rates: Vec, - pub channels: Vec, - pub is_capture: bool, - pub is_hdmi: bool, - pub usb_bus: Option, -} - -pub fn enumerate_audio_devices() -> Result> { - enumerate_audio_devices_with_current(None) -} - -pub fn enumerate_audio_devices_with_current( +pub(super) fn enumerate_audio_devices_with_current( current_device: Option<&str>, ) -> Result> { 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 { +pub(super) fn find_wasapi_device(requested_device: &str) -> Result { let host = cpal::default_host(); let trimmed = requested_device.trim(); @@ -192,8 +175,8 @@ pub(crate) fn find_wasapi_device(requested_device: &str) -> Result ))) } -pub fn find_best_audio_device() -> Result { - let devices = enumerate_audio_devices()?; +pub(super) fn find_best_audio_device() -> Result { + let devices = enumerate_audio_devices_with_current(None)?; if devices.is_empty() { return Err(AppError::AudioError( diff --git a/src/audio/encoder.rs b/src/audio/encoder.rs index 4f214677..fb908a94 100644 --- a/src/audio/encoder.rs +++ b/src/audio/encoder.rs @@ -5,7 +5,6 @@ use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate}; use bytes::Bytes; use tracing::debug; -use super::capture::AudioFrame; use crate::error::{AppError, Result}; #[derive(Debug, Clone)] @@ -154,11 +153,6 @@ impl OpusEncoder { }) } - pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result { - let samples: &[i16] = bytemuck::cast_slice(&frame.data); - self.encode(samples) - } - pub fn config(&self) -> &OpusConfig { &self.config } diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 913f16d0..eff4004c 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -1,18 +1,18 @@ //! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor. #[cfg(any(unix, windows))] -pub mod capture; -pub mod controller; +mod capture; +mod controller; #[cfg(any(unix, windows))] -pub mod device; +mod device; #[cfg(any(unix, windows))] -pub mod encoder; -pub mod monitor; -pub mod recovery; -pub mod streamer; -pub mod types; -pub mod uac_streamer; -pub mod uac_websocket; +mod encoder; +mod monitor; +mod recovery; +mod streamer; +mod types; +#[cfg(unix)] +pub mod uac; pub use capture::{AudioCapturer, AudioConfig, AudioFrame}; pub use controller::AudioController; diff --git a/src/audio/monitor.rs b/src/audio/monitor.rs index 35bb03e2..1930826c 100644 --- a/src/audio/monitor.rs +++ b/src/audio/monitor.rs @@ -71,14 +71,14 @@ impl AudioHealthMonitor { pub async fn report_recovered(&self) { let prev_status = self.status.read().await.clone(); + self.suppress_display.store(false, Ordering::Relaxed); if prev_status != AudioHealthStatus::Healthy { let retry_count = self.retry_count.load(Ordering::Relaxed); info!("Audio recovered after {} retries", retry_count); - self.suppress_display.store(false, Ordering::Relaxed); self.retry_count.store(0, Ordering::Relaxed); - self.throttler.clear("audio_"); + self.throttler.clear_all(); *self.last_error_code.write().await = None; *self.status.write().await = AudioHealthStatus::Healthy; } diff --git a/src/audio/recovery.rs b/src/audio/recovery.rs index 99c0b16a..2ec3e5c2 100644 --- a/src/audio/recovery.rs +++ b/src/audio/recovery.rs @@ -1,6 +1,9 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +//! Audio device-loss monitoring and serialized recovery. + +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::RwLock; + +use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info, warn}; use super::capture::AudioConfig; @@ -11,310 +14,392 @@ use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig}; use super::types::AudioControllerConfig; use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; -const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1); +const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1); + +struct RecoveryControl { + /// Even values are idle; the following odd value is that recovery's token. + /// A single compare-exchange therefore owns both activity and generation. + state: AtomicU64, +} + +impl RecoveryControl { + fn new() -> Self { + Self { + state: AtomicU64::new(0), + } + } + + fn begin(&self) -> Option { + let idle = self.state.load(Ordering::Acquire); + if !idle.is_multiple_of(2) { + return None; + } + let token = idle.wrapping_add(1); + self.state + .compare_exchange(idle, token, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| token) + } + + fn is_current(&self, token: u64) -> bool { + self.state.load(Ordering::Acquire) == token + } + + fn finish(&self, token: u64) { + let _ = self.state.compare_exchange( + token, + token.wrapping_add(1), + Ordering::AcqRel, + Ordering::Acquire, + ); + } + + fn cancel(&self) { + let token = self.state.load(Ordering::Acquire); + if !token.is_multiple_of(2) { + let _ = self.state.compare_exchange( + token, + token.wrapping_add(1), + Ordering::AcqRel, + Ordering::Acquire, + ); + } + } +} + +struct RecoveryLease { + control: Arc, + generation: u64, +} + +impl Drop for RecoveryLease { + fn drop(&mut self) { + self.control.finish(self.generation); + } +} + +struct RecoveryInner { + config: Arc>, + streamer: Arc>>>, + event_bus: Arc>>>, + monitor: Arc, + recovered_callback: Arc>>, + operation: Arc>, + control: Arc, +} + +#[derive(Clone)] +pub(super) struct AudioRecovery { + inner: Arc, +} + +impl AudioRecovery { + pub(super) fn new( + config: Arc>, + streamer: Arc>>>, + event_bus: Arc>>>, + monitor: Arc, + recovered_callback: Arc>>, + operation: Arc>, + ) -> 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, device: String) { + let recovery = self.clone(); + let mut state = streamer.state_watch(); + tokio::spawn(async move { + loop { + let current_state = *state.borrow(); + match current_state { + AudioStreamState::Error => {} + AudioStreamState::Stopped => return, + AudioStreamState::Starting | AudioStreamState::Running => { + if state.changed().await.is_err() { + return; + } + continue; + } + } + + // Serialize the ownership check with user-driven start/stop + // operations. If a stop already owns the operation lock, it + // removes the streamer before this monitor may start recovery. + let _operation = recovery.inner.operation.lock().await; + let is_current = recovery + .inner + .streamer + .read() + .await + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &streamer)); + if !is_current { + return; + } + + let reason = format!("Audio device lost: {device}"); + recovery + .inner + .monitor + .report_error(&reason, "device_lost") + .await; + recovery.start(device, reason); + return; + } + }); + } + + pub(super) fn start(&self, lost_device: String, reason: String) { + let Some(generation) = self.inner.control.begin() else { + debug!("Audio recovery already in progress"); + return; + }; + let recovery = self.clone(); + tokio::spawn(async move { + let _lease = RecoveryLease { + control: recovery.inner.control.clone(), + generation, + }; + recovery.run(generation, lost_device, reason).await; + }); + } + + async fn run(&self, generation: u64, lost_device: String, reason: String) { + warn!("Audio recovery started for {lost_device}: {reason}"); + self.publish_device_lost(&lost_device, &reason).await; + self.publish_state( + "device_lost", + Some(lost_device.clone()), + Some("audio_device_lost"), + Some(RETRY_DELAY.as_millis() as u64), + ) + .await; + + let mut attempt = 0u32; + while self.inner.control.is_current(generation) { + let config = self.inner.config.read().await.clone(); + if !config.enabled { + return; + } + if self + .inner + .streamer + .read() + .await + .as_ref() + .is_some_and(|streamer| streamer.is_running()) + { + return; + } + + attempt = attempt.saturating_add(1); + self.publish_reconnecting(&lost_device, attempt).await; + self.publish_state( + "device_lost", + Some(lost_device.clone()), + Some("audio_reconnecting"), + Some(RETRY_DELAY.as_millis() as u64), + ) + .await; + tokio::time::sleep(RETRY_DELAY).await; + if !self.inner.control.is_current(generation) { + return; + } + + let devices = match enumerate_audio_devices() { + Ok(devices) => devices, + Err(error) => { + debug!("Audio recovery enumeration attempt {attempt} failed: {error}"); + continue; + } + }; + let Some(device) = select_recovery_device(&devices, &config.device) else { + debug!("No audio device found on recovery attempt {attempt}"); + continue; + }; + let streamer = Arc::new(AudioStreamer::with_config(AudioStreamerConfig { + capture: AudioConfig { + device_name: device.name.clone(), + ..Default::default() + }, + opus: config.quality.to_opus_config(), + })); + + if let Err(error) = streamer.start().await { + debug!( + "Audio recovery attempt {attempt} failed with {}: {error}", + device.name + ); + continue; + } + + // Commit a recovered streamer under the same operation lock used by + // user-driven start/stop/config updates. Cancellation is rechecked + // after acquiring the lock so an old task cannot resurrect itself. + let _operation = self.inner.operation.lock().await; + if !self.inner.control.is_current(generation) || !self.inner.config.read().await.enabled + { + let _ = streamer.stop().await; + return; + } + + self.inner.config.write().await.device = device.name.clone(); + *self.inner.streamer.write().await = Some(streamer.clone()); + self.inner.monitor.report_recovered().await; + self.publish_recovered(&device.name).await; + if let Some(callback) = self.inner.recovered_callback.read().await.clone() { + callback(); + } + self.publish_state("streaming", Some(device.name.clone()), None, None) + .await; + info!( + "Audio recovered with {} after {} attempts", + device.name, attempt + ); + self.inner.control.finish(generation); + self.monitor(streamer, device.name); + drop(_operation); + return; + } + } + + async fn publish_state( + &self, + state: &str, + device: Option, + reason: Option<&str>, + next_retry_ms: Option, + ) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamStateChanged { + state: state.to_string(), + device, + reason: reason.map(str::to_string), + next_retry_ms, + }); + bus.mark_device_info_dirty(); + } + } + + async fn publish_device_lost(&self, device: &str, reason: &str) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamDeviceLost { + kind: StreamDeviceLostKind::Audio, + device: device.to_string(), + reason: reason.to_string(), + }); + } + } + + async fn publish_reconnecting(&self, device: &str, attempt: u32) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamReconnecting { + device: device.to_string(), + attempt, + }); + } + } + + async fn publish_recovered(&self, device: &str) { + if let Some(bus) = self.inner.event_bus.read().await.as_ref() { + bus.publish(SystemEvent::StreamRecovered { + device: device.to_string(), + }); + } + } +} pub(super) fn select_recovery_device( devices: &[AudioDeviceInfo], preferred: &str, ) -> Option { - if let Some(device) = devices - .iter() - .find(|d| !preferred.trim().is_empty() && d.name == preferred) - { - return Some(device.clone()); - } - devices .iter() - .find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2)) + .find(|device| !preferred.trim().is_empty() && device.name == preferred) .or_else(|| { - devices - .iter() - .find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2)) + devices.iter().find(|device| { + device.is_hdmi + && device.sample_rates.contains(&48_000) + && device.channels.contains(&2) + }) + }) + .or_else(|| { + devices.iter().find(|device| { + device.sample_rates.contains(&48_000) && device.channels.contains(&2) + }) }) .or_else(|| devices.first()) .cloned() } -async fn publish_state( - event_bus: &Arc>>>, - state: &str, - device: Option, - reason: Option<&str>, - next_retry_ms: Option, -) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamStateChanged { - state: state.to_string(), - device, - reason: reason.map(str::to_string), - next_retry_ms, - }); - bus.mark_device_info_dirty(); - } -} +#[cfg(test)] +mod tests { + use super::*; -async fn publish_device_lost( - event_bus: &Arc>>>, - device: &str, - reason: &str, -) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamDeviceLost { - kind: StreamDeviceLostKind::Audio, - device: device.to_string(), - reason: reason.to_string(), - }); - } -} - -async fn publish_reconnecting( - event_bus: &Arc>>>, - device: &str, - attempt: u32, -) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamReconnecting { - device: device.to_string(), - attempt, - }); - } -} - -async fn publish_recovered(event_bus: &Arc>>>, device: &str) { - if let Some(bus) = event_bus.read().await.as_ref() { - bus.publish(SystemEvent::StreamRecovered { - device: device.to_string(), - }); - } -} - -fn spawn_stream_monitor_from_parts( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - streamer: Arc, - device: String, -) { - let mut state_rx = streamer.state_watch(); - - tokio::spawn(async move { - loop { - if state_rx.changed().await.is_err() { - return; - } - - if *state_rx.borrow() != AudioStreamState::Error { - continue; - } - - { - let current = streamer_slot.read().await; - if !current - .as_ref() - .is_some_and(|current| Arc::ptr_eq(current, &streamer)) - { - return; - } - } - - let reason = format!("Audio device lost: {}", device); - monitor.report_error(&reason, "device_lost").await; - spawn_recovery_task_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - device, - reason, - ); - return; + fn device(name: &str, compatible: bool, hdmi: bool) -> AudioDeviceInfo { + AudioDeviceInfo { + name: name.to_string(), + description: name.to_string(), + card_index: 0, + device_index: 0, + sample_rates: if compatible { + vec![48_000] + } else { + vec![44_100] + }, + channels: vec![2], + is_capture: true, + is_hdmi: hdmi, + usb_bus: None, } - }); -} - -fn spawn_recovery_task_from_parts( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - lost_device: String, - reason: String, -) { - if recovery_in_progress.swap(true, Ordering::SeqCst) { - debug!("Audio recovery already in progress"); - return; } - tokio::spawn(async move { - warn!("Audio recovery started for {}: {}", lost_device, reason); - publish_device_lost(&event_bus, &lost_device, &reason).await; - publish_state( - &event_bus, - "device_lost", - Some(lost_device.clone()), - Some("audio_device_lost"), - Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64), - ) - .await; + #[test] + fn stale_recovery_cannot_finish_a_new_generation() { + let control = RecoveryControl::new(); + let stale = control.begin().unwrap(); + control.cancel(); + let current = control.begin().unwrap(); - let mut attempt = 0u32; + control.finish(stale); + assert!(control.is_current(current)); + } - loop { - if !recovery_in_progress.load(Ordering::SeqCst) { - debug!("Audio recovery canceled"); - return; - } + #[test] + fn completed_recovery_cannot_finish_the_next_recovery() { + let control = RecoveryControl::new(); + let completed = control.begin().unwrap(); + control.finish(completed); + let current = control.begin().unwrap(); - if streamer_slot - .read() - .await - .as_ref() - .is_some_and(|s| s.is_running()) - { - recovery_in_progress.store(false, Ordering::SeqCst); - return; - } + control.finish(completed); + assert!(control.is_current(current)); + } - let cfg: AudioControllerConfig = config.read().await.clone(); - if !cfg.enabled { - recovery_in_progress.store(false, Ordering::SeqCst); - return; - } - - attempt = attempt.saturating_add(1); - publish_reconnecting(&event_bus, &lost_device, attempt).await; - publish_state( - &event_bus, - "device_lost", - Some(lost_device.clone()), - Some("audio_reconnecting"), - Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64), - ) - .await; - - tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await; - - let devices = match enumerate_audio_devices() { - Ok(devices) => devices, - Err(e) => { - debug!( - "Audio recovery enumerate failed (attempt {}): {}", - attempt, e - ); - continue; - } - }; - - let Some(device) = select_recovery_device(&devices, &cfg.device) else { - debug!("No audio devices found during recovery attempt {}", attempt); - continue; - }; - - let streamer_config = AudioStreamerConfig { - capture: AudioConfig { - device_name: device.name.clone(), - ..Default::default() - }, - opus: cfg.quality.to_opus_config(), - }; - let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config)); - - match new_streamer.start().await { - Ok(()) => { - { - let mut cfg = config.write().await; - cfg.device = device.name.clone(); - } - *streamer_slot.write().await = Some(new_streamer.clone()); - monitor.report_recovered().await; - publish_recovered(&event_bus, &device.name).await; - if let Some(callback) = recovered_callback.read().await.clone() { - callback(); - } - publish_state( - &event_bus, - "streaming", - Some(device.name.clone()), - None, - None, - ) - .await; - recovery_in_progress.store(false, Ordering::SeqCst); - info!( - "Audio device recovered with {} after {} attempts", - device.name, attempt - ); - spawn_stream_monitor_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - new_streamer, - device.name, - ); - return; - } - Err(e) => { - debug!( - "Audio recovery start failed with {} (attempt {}): {}", - device.name, attempt, e - ); - } - } - } - }); -} - -pub(super) fn spawn_stream_monitor( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - streamer: Arc, - device: String, -) { - spawn_stream_monitor_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - streamer, - device, - ); -} - -pub(super) fn spawn_recovery_task( - config: Arc>, - streamer_slot: Arc>>>, - event_bus: Arc>>>, - monitor: Arc, - recovery_in_progress: Arc, - recovered_callback: Arc>>, - lost_device: String, - reason: String, -) { - spawn_recovery_task_from_parts( - config, - streamer_slot, - event_bus, - monitor, - recovery_in_progress, - recovered_callback, - lost_device, - reason, - ); + #[test] + fn recovery_prefers_requested_then_compatible_hdmi() { + let devices = vec![device("fallback", true, false), device("hdmi", true, true)]; + assert_eq!( + select_recovery_device(&devices, "fallback").unwrap().name, + "fallback" + ); + assert_eq!( + select_recovery_device(&devices, "missing").unwrap().name, + "hdmi" + ); + } } diff --git a/src/audio/streamer.rs b/src/audio/streamer.rs index bdc1a207..c44f69c8 100644 --- a/src/audio/streamer.rs +++ b/src/audio/streamer.rs @@ -2,15 +2,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; + use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock}; +use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; -use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState}; +use super::capture::{AudioCapturer, AudioConfig, CaptureState}; use super::encoder::{OpusConfig, OpusEncoder, OpusFrame}; use crate::error::{AppError, Result}; -use bytemuck; -use bytes::Bytes; -use std::time::Duration; /// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE). const OPUS_STEREO_SAMPLES: usize = 960 * 2; @@ -40,16 +40,6 @@ impl AudioStreamerConfig { opus: OpusConfig::default(), } } - - pub fn with_bitrate(mut self, bitrate: u32) -> Self { - self.opus.bitrate = bitrate; - self - } -} - -#[derive(Debug, Clone, Default)] -pub struct AudioStreamStats { - pub subscriber_count: usize, } pub struct AudioStreamer { @@ -60,6 +50,9 @@ pub struct AudioStreamer { encoder: Arc>>, opus_subscribers: Arc>>>>, stop_flag: Arc, + shutdown_generation: watch::Sender, + lifecycle: AsyncMutex<()>, + stream_task: AsyncMutex>>, } impl AudioStreamer { @@ -69,6 +62,7 @@ impl AudioStreamer { pub fn with_config(config: AudioStreamerConfig) -> Self { let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped); + let (shutdown_generation, _) = watch::channel(0); Self { config: RwLock::new(config), @@ -78,6 +72,9 @@ impl AudioStreamer { encoder: Arc::new(AsyncMutex::new(None)), opus_subscribers: Arc::new(Mutex::new(Vec::new())), stop_flag: Arc::new(AtomicBool::new(false)), + shutdown_generation, + lifecycle: AsyncMutex::new(()), + stream_task: AsyncMutex::new(None), } } @@ -90,7 +87,9 @@ impl AudioStreamer { } pub fn subscribe_opus(&self) -> mpsc::Receiver> { - let (tx, rx) = mpsc::channel::>(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::>(4); self.opus_subscribers.lock().unwrap().push(tx); rx } @@ -104,22 +103,6 @@ impl AudioStreamer { .count() } - pub fn stats(&self) -> AudioStreamStats { - AudioStreamStats { - subscriber_count: self.subscriber_count(), - } - } - - pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> { - if self.state() != AudioStreamState::Stopped { - return Err(AppError::AudioError( - "Cannot change config while streaming".to_string(), - )); - } - *self.config.write().await = config; - Ok(()) - } - pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> { self.config.write().await.opus.bitrate = bitrate; @@ -132,10 +115,25 @@ impl AudioStreamer { } pub async fn start(&self) -> Result<()> { - if self.state() == AudioStreamState::Running { + let _lifecycle = self.lifecycle.lock().await; + if matches!( + self.state(), + AudioStreamState::Starting | AudioStreamState::Running + ) { return Ok(()); } + // Error and stopped states may still own completed task handles. Reap + // them before installing a new capture pipeline so restart is a clean + // lifecycle transition rather than an overwrite of old resources. + if let Some(capturer) = self.capturer.write().await.take() { + let _ = capturer.stop().await; + } + if let Some(task) = self.stream_task.lock().await.take() { + let _ = task.await; + } + *self.encoder.lock().await = None; + let _ = self.state.send(AudioStreamState::Starting); self.stop_flag.store(false, Ordering::SeqCst); @@ -149,13 +147,21 @@ impl AudioStreamer { config.opus.bitrate ); - let capturer = Arc::new(AudioCapturer::new(config.capture.clone())); - *self.capturer.write().await = Some(capturer.clone()); - - let encoder = OpusEncoder::new(config.opus.clone())?; + let encoder = match OpusEncoder::new(config.opus.clone()) { + Ok(encoder) => encoder, + Err(error) => { + let _ = self.state.send(AudioStreamState::Error); + return Err(error); + } + }; *self.encoder.lock().await = Some(encoder); - capturer.start().await?; + let capturer = Arc::new(AudioCapturer::new(config.capture.clone())); + *self.capturer.write().await = Some(capturer.clone()); + if let Err(error) = capturer.start().await { + self.cleanup_failed_start(&capturer).await; + return Err(error); + } let mut capture_state = capturer.state_watch(); let startup_result = tokio::time::timeout(Duration::from_secs(2), async { @@ -168,7 +174,7 @@ impl AudioStreamer { "Audio capture failed to start".to_string(), )) } - CaptureState::Stopped => { + CaptureState::Stopped | CaptureState::Starting => { if capture_state.changed().await.is_err() { return Err(AppError::AudioError( "Audio capture stopped during startup".to_string(), @@ -183,17 +189,11 @@ impl AudioStreamer { match startup_result { Ok(Ok(())) => {} Ok(Err(e)) => { - let _ = capturer.stop().await; - *self.capturer.write().await = None; - *self.encoder.lock().await = None; - let _ = self.state.send(AudioStreamState::Error); + self.cleanup_failed_start(&capturer).await; return Err(e); } Err(_) => { - let _ = capturer.stop().await; - *self.capturer.write().await = None; - *self.encoder.lock().await = None; - let _ = self.state.send(AudioStreamState::Error); + self.cleanup_failed_start(&capturer).await; return Err(AppError::AudioError( "Timed out waiting for audio capture to start".to_string(), )); @@ -205,22 +205,27 @@ impl AudioStreamer { let opus_subscribers = self.opus_subscribers.clone(); let state = self.state.clone(); let stop_flag = self.stop_flag.clone(); + let shutdown_rx = self.shutdown_generation.subscribe(); + let _ = self.state.send(AudioStreamState::Running); - tokio::spawn(async move { + let task = tokio::spawn(async move { Self::stream_task( capturer_for_task, encoder, opus_subscribers, state, stop_flag, + shutdown_rx, ) .await; }); + *self.stream_task.lock().await = Some(task); Ok(()) } pub async fn stop(&self) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; if self.state() == AudioStreamState::Stopped { return Ok(()); } @@ -228,10 +233,16 @@ impl AudioStreamer { info!("Stopping audio stream"); self.stop_flag.store(true, Ordering::SeqCst); + self.shutdown_generation.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); if let Some(ref capturer) = *self.capturer.read().await { capturer.stop().await?; } + if let Some(task) = self.stream_task.lock().await.take() { + let _ = task.await; + } *self.capturer.write().await = None; *self.encoder.lock().await = None; @@ -242,28 +253,26 @@ impl AudioStreamer { Ok(()) } + async fn cleanup_failed_start(&self, capturer: &AudioCapturer) { + let _ = capturer.stop().await; + *self.capturer.write().await = None; + *self.encoder.lock().await = None; + let _ = self.state.send(AudioStreamState::Error); + } + pub fn is_running(&self) -> bool { self.state() == AudioStreamState::Running } - async fn fanout_opus( + fn fanout_opus( subscribers: &Arc>>>>, frame: Arc, ) { - let txs: Vec<_> = { - let g = subscribers.lock().unwrap(); - if g.is_empty() { - return; - } - g.clone() - }; - for tx in &txs { - let _ = tx.send(frame.clone()).await; - } - if txs.iter().any(|tx| tx.is_closed()) { - let mut g = subscribers.lock().unwrap(); - g.retain(|tx| !tx.is_closed()); - } + let mut subscribers = subscribers.lock().unwrap(); + subscribers.retain(|subscriber| match subscriber.try_send(frame.clone()) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + }); } async fn stream_task( @@ -272,9 +281,9 @@ impl AudioStreamer { opus_subscribers: Arc>>>>, state: watch::Sender, stop_flag: Arc, + mut shutdown_rx: watch::Receiver, ) { let mut pcm_rx = capturer.subscribe(); - let _ = state.send(AudioStreamState::Running); debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)"); @@ -291,8 +300,19 @@ impl AudioStreamer { break; } - let recv_result = - tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await; + let recv_result = tokio::select! { + biased; + changed = shutdown_rx.changed() => { + if changed.is_ok() || stop_flag.load(Ordering::Relaxed) { + break; + } + continue; + } + result = tokio::time::timeout( + std::time::Duration::from_secs(2), + pcm_rx.recv(), + ) => result, + }; match recv_result { Ok(Ok(audio_frame)) => { @@ -316,23 +336,17 @@ impl AudioStreamer { } while pending.len() >= OPUS_STEREO_SAMPLES { - let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice( - &pending[..OPUS_STEREO_SAMPLES], - )); - pending.drain(..OPUS_STEREO_SAMPLES); - - let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 0); - let opus_result = { let mut enc_guard = encoder.lock().await; (*enc_guard) .as_mut() - .map(|enc| enc.encode_frame(&frame_48k)) + .map(|enc| enc.encode(&pending[..OPUS_STEREO_SAMPLES])) }; + pending.drain(..OPUS_STEREO_SAMPLES); match opus_result { Some(Ok(opus_frame)) => { - Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await; + Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)); } Some(Err(e)) => { error!("Opus encode error: {}", e); @@ -365,6 +379,7 @@ impl AudioStreamer { let _ = state.send(AudioStreamState::Stopped); } else { opus_subscribers.lock().unwrap().clear(); + let _ = capturer.stop().await; } info!("Audio stream task ended"); } @@ -379,6 +394,7 @@ impl Default for AudioStreamer { #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; #[test] fn test_streamer_config_default() { @@ -398,4 +414,42 @@ mod tests { let streamer = AudioStreamer::new(); assert_eq!(streamer.state(), AudioStreamState::Stopped); } + + #[test] + fn slow_subscriber_does_not_block_or_grow_unbounded() { + let streamer = AudioStreamer::new(); + let mut receiver = streamer.subscribe_opus(); + for sequence in 0..20 { + AudioStreamer::fanout_opus( + &streamer.opus_subscribers, + Arc::new(OpusFrame { + data: Bytes::from_static(&[1]), + duration_ms: 20, + sequence, + }), + ); + } + + let mut received = 0; + while receiver.try_recv().is_ok() { + received += 1; + } + assert_eq!(received, 4); + } + + #[test] + fn closed_subscriber_is_pruned() { + let streamer = AudioStreamer::new(); + let receiver = streamer.subscribe_opus(); + drop(receiver); + AudioStreamer::fanout_opus( + &streamer.opus_subscribers, + Arc::new(OpusFrame { + data: Bytes::from_static(&[1]), + duration_ms: 20, + sequence: 0, + }), + ); + assert_eq!(streamer.subscriber_count(), 0); + } } diff --git a/src/audio/uac/decoder.rs b/src/audio/uac/decoder.rs new file mode 100644 index 00000000..bf33ae7d --- /dev/null +++ b/src/audio/uac/decoder.rs @@ -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, +} + +impl UacOpusDecoder { + pub fn new() -> Result { + 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()); + } +} diff --git a/src/audio/uac/mod.rs b/src/audio/uac/mod.rs new file mode 100644 index 00000000..d7ef00ad --- /dev/null +++ b/src/audio/uac/mod.rs @@ -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}; diff --git a/src/audio/uac/playback.rs b/src/audio/uac/playback.rs new file mode 100644 index 00000000..98fb9805 --- /dev/null +++ b/src/audio/uac/playback.rs @@ -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>>>, +} + +enum SessionSink { + Closed { retry_at: Option }, + 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, +} + +pub struct UacSession { + playback: UacPlayback, + runtime: Arc>, +} + +impl UacPlayback { + pub fn start(config: UacPlaybackConfig) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 + ); + } +} diff --git a/src/audio/uac/protocol.rs b/src/audio/uac/protocol.rs new file mode 100644 index 00000000..e5100ddc --- /dev/null +++ b/src/audio/uac/protocol.rs @@ -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> { + 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> { + 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 { + 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] + ); + } +} diff --git a/src/audio/uac_streamer.rs b/src/audio/uac_streamer.rs deleted file mode 100644 index 7a8edc37..00000000 --- a/src/audio/uac_streamer.rs +++ /dev/null @@ -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, - 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, - stop_tx: watch::Sender, -} - -impl UacPlaybackWriter { - pub fn start(config: UacPlaybackConfig) -> Result { - let (pcm_sender, pcm_receiver) = mpsc::channel::(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)> { - 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) { - drop(stdin); // close pipe → EOF for aplay - let _ = child.wait(); - } - - fn playback_loop( - device: &str, - rate: u32, - ch: u16, - mut receiver: mpsc::Receiver, - mut stop_rx: watch::Receiver, - ) { - let idle_timeout = Duration::from_millis(IDLE_CLOSE_TIMEOUT_MS); - let mut aplay: Option<(Child, Box)> = 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); - } - } -} diff --git a/src/audio/uac_websocket.rs b/src/audio/uac_websocket.rs deleted file mode 100644 index 6103131e..00000000 --- a/src/audio/uac_websocket.rs +++ /dev/null @@ -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, -) { - // 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 = 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"); -} diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index 5dd551eb..0828a9ff 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -10,6 +10,7 @@ mod computer_use; mod hid; mod otg_network; mod stream; +mod uac; mod watchdog; mod web; @@ -19,6 +20,7 @@ pub use computer_use::*; pub use hid::*; pub use otg_network::*; pub use stream::*; +pub use uac::*; pub use watchdog::*; pub use web::*; @@ -44,7 +46,7 @@ pub struct AppConfig { pub rtsp: RtspConfig, pub redfish: RedfishConfig, pub watchdog: WatchdogConfig, - pub uac: crate::otg::service::UacConfig, + pub uac: UacConfig, } impl AppConfig { diff --git a/src/config/schema/uac.rs b/src/config/schema/uac.rs new file mode 100644 index 00000000..86dbd1a8 --- /dev/null +++ b/src/config/schema/uac.rs @@ -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()); + } +} diff --git a/src/main.rs b/src/main.rs index adf8ac97..2be39e1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -579,20 +579,23 @@ async fn main() -> anyhow::Result<()> { data_dir.clone(), ); - // Initialize UAC playback writer if UAC is enabled - if config.uac.enabled { - let uac_cfg = one_kvm::audio::uac_streamer::UacPlaybackConfig { - sample_rate: config.uac.sample_rate, - channels: config.uac.channels as u16, - ..Default::default() - }; - match one_kvm::audio::uac_streamer::UacPlaybackWriter::start(uac_cfg) { - Ok(writer) => { - *state.uac_playback.write().await = Some(writer); - tracing::info!("UAC playback writer started"); - } - Err(e) => { - tracing::warn!("Failed to start UAC playback writer: {}", e); + #[cfg(unix)] + { + // Initialize UAC playback writer if UAC is enabled. + if config.uac.enabled { + let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig { + sample_rate: config.uac.sample_rate, + channels: config.uac.channels as u16, + ..Default::default() + }; + match one_kvm::audio::uac::UacPlayback::start(uac_cfg) { + Ok(writer) => { + *state.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started"); + } + Err(e) => { + tracing::warn!("Failed to start UAC playback writer: {}", e); + } } } } diff --git a/src/otg/mod.rs b/src/otg/mod.rs index 9af22aaf..1801e09f 100644 --- a/src/otg/mod.rs +++ b/src/otg/mod.rs @@ -28,7 +28,7 @@ pub use msd::{MsdFunction, MsdLunConfig}; #[cfg(unix)] pub use network::NetworkFunction; #[cfg(unix)] -pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService, UacConfig}; +pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService}; #[cfg(unix)] pub use uac::UacFunction; diff --git a/src/otg/service.rs b/src/otg/service.rs index 22e3224c..f520c1cc 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -9,39 +9,10 @@ use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::msd::MsdFunction; use crate::config::{ HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig, + UacConfig, }; use crate::error::{AppError, Result}; -/// Configuration for the USB Audio Class (UAC) gadget function. -#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] -#[serde(default)] -pub struct UacConfig { - /// Enable the virtual USB microphone. - pub enabled: bool, - /// Sample rate in Hz (e.g. 48000). - pub sample_rate: u32, - /// Number of channels (1=mono, 2=stereo). - pub channels: u8, -} - -impl UacConfig { - pub fn validate(&self) -> Result<()> { - if self.sample_rate != 0 && (self.sample_rate < 8000 || self.sample_rate > 384000) { - return Err(AppError::BadRequest(format!( - "UAC sample rate {} out of range (8000-384000)", - self.sample_rate - ))); - } - if self.channels != 0 && self.channels > 8 { - return Err(AppError::BadRequest(format!( - "UAC channel count {} out of range (1-8)", - self.channels - ))); - } - Ok(()) - } -} - #[derive(Debug, Clone, Default)] pub struct HidDevicePaths { pub keyboard: Option, @@ -92,7 +63,7 @@ pub(crate) struct OtgDesiredState { pub msd_enabled: bool, pub msd_lun_capacity: u8, pub network: OtgNetworkConfig, - pub uac_enabled: bool, + pub uac: UacConfig, } impl Default for OtgDesiredState { @@ -105,7 +76,7 @@ impl Default for OtgDesiredState { msd_enabled: false, msd_lun_capacity: 1, network: OtgNetworkConfig::default(), - uac_enabled: false, + uac: UacConfig::default(), } } } @@ -127,8 +98,7 @@ impl OtgDesiredState { }; hid.validate_otg_functions()?; - let needs_udc = - hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled; + let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled; let udc = if needs_udc { hid.otg_udc .as_ref() @@ -146,7 +116,11 @@ impl OtgDesiredState { msd_enabled: msd.enabled, msd_lun_capacity: 1, network: network.clone(), - uac_enabled: uac.enabled, + uac: if uac.enabled { + uac.clone() + } else { + UacConfig::default() + }, }) } @@ -169,7 +143,7 @@ struct OtgServiceState { pub msd_enabled: bool, pub msd_lun_capacity: u8, pub network: OtgNetworkConfig, - pub uac_enabled: bool, + pub uac: UacConfig, pub configured_udc: Option, pub hid_paths: Option, pub hid_functions: Option, @@ -187,7 +161,7 @@ impl Default for OtgServiceState { msd_enabled: false, msd_lun_capacity: 1, network: OtgNetworkConfig::default(), - uac_enabled: false, + uac: UacConfig::default(), configured_udc: None, hid_paths: None, hid_functions: None, @@ -349,7 +323,7 @@ impl OtgService { desired.hid_enabled(), desired.msd_enabled, desired.network_enabled(), - desired.uac_enabled, + desired.uac.enabled, desired.udc ); @@ -361,7 +335,7 @@ impl OtgService { && state.msd_enabled == desired.msd_enabled && state.msd_lun_capacity == desired.msd_lun_capacity && state.network == desired.network - && state.uac_enabled == desired.uac_enabled + && state.uac == desired.uac && state.configured_udc == desired.udc && state.hid_functions == desired.hid_functions && state.keyboard_leds_enabled == desired.keyboard_leds @@ -401,7 +375,7 @@ impl OtgService { state.msd_enabled = false; state.msd_lun_capacity = 1; state.network = OtgNetworkConfig::default(); - state.uac_enabled = false; + state.uac = UacConfig::default(); state.configured_udc = None; state.hid_paths = None; state.hid_functions = None; @@ -410,7 +384,11 @@ impl OtgService { state.error = None; } - if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() { + if !desired.hid_enabled() + && !desired.msd_enabled + && !desired.network_enabled() + && !desired.uac.enabled + { info!("OTG desired state is empty, gadget removed"); return Ok(()); } @@ -440,12 +418,12 @@ impl OtgService { // lower hardware endpoint number. DWC3 seems to have // trouble with isochronous transfers on higher-numbered // endpoints when they follow interrupt endpoints. - let _uac_func = if desired.uac_enabled { - let sample_rate: u32 = 48000; - let channels: u8 = 2; - Some(manager.add_uac(sample_rate, channels).map_err(|e| { - AppError::Internal(format!("Failed to add UAC function: {e}")) - })?) + let _uac_func = if desired.uac.enabled { + Some( + manager + .add_uac(desired.uac.sample_rate, desired.uac.channels) + .map_err(|e| AppError::Internal(format!("Failed to add UAC function: {e}")))?, + ) } else { None }; @@ -594,7 +572,7 @@ impl OtgService { state.msd_enabled = desired.msd_enabled; state.msd_lun_capacity = desired.msd_lun_capacity; state.network = desired.network.clone(); - state.uac_enabled = desired.uac_enabled; + state.uac = desired.uac.clone(); state.configured_udc = Some(udc); state.hid_paths = hid_paths; state.hid_functions = desired.hid_functions; @@ -732,7 +710,8 @@ mod tests { ..OtgNetworkConfig::default() }; - let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap(); + let desired = + OtgDesiredState::from_config(&hid, &msd, &network, &UacConfig::default()).unwrap(); assert_eq!(desired.udc.as_deref(), Some("c9040000.usb")); assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full())); diff --git a/src/otg/uac.rs b/src/otg/uac.rs index 5b60de13..b64b51a0 100644 --- a/src/otg/uac.rs +++ b/src/otg/uac.rs @@ -6,7 +6,7 @@ use super::configfs::{create_dir, create_symlink, remove_dir, write_file}; use super::function::GadgetFunction; use crate::error::{AppError, Result}; -/// USB Audio Class 2.0 (UAC1) gadget function. +/// USB Audio Class 1.0 gadget function. /// /// Creates a virtual USB microphone that the USB host sees as a standard /// USB audio input device. Audio written to the PCM playback device on the @@ -60,13 +60,18 @@ impl GadgetFunction for UacFunction { let chmask: u32 = (1u32 << self.channels) - 1; write_file(&func_path.join("p_chmask"), &chmask.to_string())?; write_file(&func_path.join("p_srate"), &self.sample_rate.to_string())?; - write_file(&func_path.join("p_ssize"), "2")?; // 16-bit S16LE + // 16-bit S16LE. + write_file(&func_path.join("p_ssize"), "2")?; + // One decibel per step. The kernel default is 1/256 dB, which creates + // 25,600 control values and triggers a UAC volume-range warning. + write_file(&func_path.join("p_volume_res"), "256")?; // UAC1 does not need p_hs_bint — Windows has native built-in // UAC1 drivers and handles isochronous streaming automatically. // Only enable playback direction (gadget → host = mic). // Disabling capture saves one isochronous endpoint. write_file(&func_path.join("c_chmask"), "0")?; + write_file(&func_path.join("c_volume_present"), "0")?; // req_number=4: explicitly allocate 4 USB requests for the // isochronous endpoint. Default (0 = auto) may not be enough diff --git a/src/state.rs b/src/state.rs index ca93141f..5d40f93b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -77,8 +77,8 @@ pub struct AppState { pub msd: Arc>>, pub atx: Arc>>, pub audio: Arc, - pub uac_playback: Arc>>, - pub uac_config: Arc>, + #[cfg(unix)] + pub uac_playback: Arc>>, pub rustdesk: Arc>>>, pub vnc: Arc>>>, pub rtsp: Arc>>>, @@ -148,8 +148,8 @@ impl AppState { revoked_sessions: Arc::new(RwLock::new(VecDeque::new())), config_apply_locks: ConfigApplyLocks::new(), data_dir, + #[cfg(unix)] uac_playback: Arc::new(RwLock::new(None)), - uac_config: Arc::new(RwLock::new(crate::otg::service::UacConfig::default())), }) } diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index 4a0f6f2b..6bc70f5b 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -76,7 +76,7 @@ async fn reconcile_otg_config( hid: &HidConfig, msd: &MsdConfig, network: &OtgNetworkConfig, - uac: &crate::otg::service::UacConfig, + uac: &UacConfig, ) -> Result<()> { #[cfg(not(unix))] { @@ -195,6 +195,7 @@ pub async fn apply_hid_config( new_config: &HidConfig, msd_config: &MsdConfig, network_config: &OtgNetworkConfig, + uac_config: &UacConfig, options: ConfigApplyOptions, ) -> Result<()> { new_config.validate_otg_functions()?; @@ -237,7 +238,7 @@ pub async fn apply_hid_config( } if otg_config_changed { - reconcile_otg_config(state, new_config, msd_config, network_config, &state.config.get().uac).await?; + reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?; } if !transitioning_away_from_otg { @@ -263,6 +264,7 @@ pub async fn apply_msd_config( new_config: &MsdConfig, hid_config: &HidConfig, network_config: &OtgNetworkConfig, + uac_config: &UacConfig, options: ConfigApplyOptions, ) -> Result<()> { let hid_backend_is_otg = hid_config.backend == HidBackend::Otg; @@ -305,7 +307,7 @@ pub async fn apply_msd_config( if new_msd_enabled { tracing::info!("(Re)initializing MSD..."); - reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?; + reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; let mut msd_guard = state.msd.write().await; if let Some(msd) = msd_guard.as_mut() { @@ -340,7 +342,7 @@ pub async fn apply_msd_config( *msd_guard = None; tracing::info!("MSD shutdown complete"); - reconcile_otg_config(state, hid_config, new_config, network_config, &state.config.get().uac).await?; + reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?; } if hid_config.backend == HidBackend::Otg @@ -367,10 +369,29 @@ pub async fn apply_usb_config( old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg; let hid_unchanged = old_config.hid == new_config.hid; - let otg_gadget_rebuilt = - old_config.msd != new_config.msd - || old_config.otg_network != new_config.otg_network - || old_config.uac != new_config.uac; + let otg_gadget_rebuilt = old_config.msd != new_config.msd + || old_config.otg_network != new_config.otg_network + || old_config.uac != new_config.uac + || old_config.hid.otg_udc != new_config.hid.otg_udc + || old_config.hid.otg_descriptor != new_config.hid.otg_descriptor + || old_config.hid.backend != new_config.hid.backend + || old_config.hid.constrained_otg_functions() + != new_config.hid.constrained_otg_functions() + || old_config.hid.effective_otg_keyboard_leds() + != new_config.hid.effective_otg_keyboard_leds(); + let restart_uac_playback = + old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt); + + // A bound ALSA handle refers to the old configfs function. Stop it + // before any gadget teardown so the worker cannot write through a + // disappearing PCM node. It is restarted only after every reconcile. + if restart_uac_playback { + let playback = state.uac_playback.write().await.take(); + if let Some(playback) = playback { + playback.stop(); + tracing::info!("UAC playback writer stopped before OTG reconcile"); + } + } if transitioning_away_from_otg { apply_hid_config( @@ -379,6 +400,7 @@ pub async fn apply_usb_config( &new_config.hid, &new_config.msd, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) .await?; @@ -397,6 +419,7 @@ pub async fn apply_usb_config( &new_config.hid, &new_config.msd, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) .await?; @@ -408,37 +431,9 @@ pub async fn apply_usb_config( if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg { tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices"); let hid_backend = hid_backend_type(&new_config.hid); - state - .hid - .reload(hid_backend) - .await - .map_err(|e| AppError::Config(format!("HID reload after gadget rebuild failed: {}", e)))?; - } - - // UAC playback writer lifecycle - if old_config.uac.enabled != new_config.uac.enabled { - let mut guard = state.uac_playback.write().await; - if new_config.uac.enabled { - let config = crate::audio::uac_streamer::UacPlaybackConfig { - sample_rate: new_config.uac.sample_rate, - channels: new_config.uac.channels as u16, - ..Default::default() - }; - match crate::audio::uac_streamer::UacPlaybackWriter::start(config) { - Ok(writer) => { - tracing::info!("UAC playback writer started"); - *guard = Some(writer); - } - Err(e) => { - tracing::warn!("Failed to start UAC playback writer: {}", e); - } - } - } else { - if let Some(writer) = guard.take() { - writer.stop(); - tracing::info!("UAC playback writer stopped"); - } - } + state.hid.reload(hid_backend).await.map_err(|e| { + AppError::Config(format!("HID reload after gadget rebuild failed: {}", e)) + })?; } apply_msd_config( @@ -447,9 +442,29 @@ pub async fn apply_usb_config( &new_config.msd, &new_config.hid, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) - .await + .await?; + + // apply_msd_config may perform a second gadget reconcile. Resolve the + // new ALSA card only after that final rebuild, then publish the worker. + if restart_uac_playback && new_config.uac.enabled { + let config = crate::audio::uac::UacPlaybackConfig { + sample_rate: new_config.uac.sample_rate, + channels: new_config.uac.channels as u16, + ..Default::default() + }; + let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| { + AppError::Config(format!("Failed to start UAC playback: {error}")) + })?; + *state.uac_playback.write().await = Some(writer); + tracing::info!("UAC playback writer started after OTG reconcile"); + } else if restart_uac_playback { + tracing::info!("UAC playback remains disabled"); + } + + Ok(()) } #[cfg(not(unix))] @@ -460,6 +475,7 @@ pub async fn apply_usb_config( &new_config.hid, &new_config.msd, &new_config.otg_network, + &new_config.uac, ConfigApplyOptions::default(), ) .await diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 5852224e..8dbfbcf8 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -42,9 +42,9 @@ pub use rustdesk::{ regenerate_device_password, start_rustdesk_service, stop_rustdesk_service, update_rustdesk_config, }; +pub use stream::{get_stream_config, update_stream_config}; #[cfg(unix)] pub use uac::{get_uac_config, update_uac_config}; -pub use stream::{get_stream_config, update_stream_config}; pub use video::{get_video_config, update_video_config}; pub use vnc::{ get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config, diff --git a/src/web/handlers/config/uac.rs b/src/web/handlers/config/uac.rs index d7a7b044..90b0ec43 100644 --- a/src/web/handlers/config/uac.rs +++ b/src/web/handlers/config/uac.rs @@ -2,11 +2,11 @@ use std::sync::Arc; use axum::{extract::State, Json}; +use crate::config::UacConfig; use crate::error::Result; -use crate::otg::service::UacConfig; use crate::state::AppState; -use super::apply::try_apply_lock; +use super::usb_update::update_usb_config; pub async fn get_uac_config(State(state): State>) -> Json { Json(state.config.get().uac.clone()) @@ -14,23 +14,13 @@ pub async fn get_uac_config(State(state): State>) -> Json>, - Json(req): Json, + Json(request): Json, ) -> Result> { - req.validate()?; - let _guard = try_apply_lock(&state.config_apply_locks.otg, "uac")?; - - let old_config = (*state.config.get()).clone(); - let mut new_config = old_config.clone(); - new_config.uac = req; - - state - .config - .update(|config| { - config.uac = new_config.uac.clone(); - }) - .await?; - - super::apply::apply_usb_config(&state, &old_config, &new_config).await?; - - Ok(Json(state.config.get().uac.clone())) + request.validate()?; + let config = update_usb_config(&state, move |staged| { + staged.uac = request; + Ok(None) + }) + .await?; + Ok(Json(config.uac)) } diff --git a/src/web/handlers/config/usb_update.rs b/src/web/handlers/config/usb_update.rs index 4ed19d2a..bce96476 100644 --- a/src/web/handlers/config/usb_update.rs +++ b/src/web/handlers/config/usb_update.rs @@ -55,6 +55,7 @@ where staged_config.otg_network.host_mac = host_mac; } staged_config.otg_network.validate()?; + staged_config.uac.validate()?; } if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await { @@ -92,6 +93,7 @@ where config.hid = staged_config.hid.clone(); config.msd = staged_config.msd.clone(); config.otg_network = staged_config.otg_network.clone(); + config.uac = staged_config.uac.clone(); config.enforce_invariants(); }) .await diff --git a/src/web/mod.rs b/src/web/mod.rs index 79998a9d..72733b11 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -3,6 +3,7 @@ mod error; mod handlers; mod routes; mod static_files; +#[cfg(unix)] mod uac_ws; mod ws; @@ -11,5 +12,6 @@ pub use error::ErrorResponse; pub use routes::create_router; #[cfg(not(debug_assertions))] pub use static_files::StaticAssets; +#[cfg(unix)] pub use uac_ws::uac_audio_ws_handler; pub use ws::ws_handler; diff --git a/src/web/routes.rs b/src/web/routes.rs index 1db95172..21e74e4e 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -13,6 +13,7 @@ use tower_http::{ use super::audio_ws::audio_ws_handler; use super::handlers; +#[cfg(unix)] use super::uac_ws::uac_audio_ws_handler; use super::ws::ws_handler; use crate::auth::auth_middleware; @@ -101,7 +102,6 @@ pub fn create_router(state: Arc) -> Router { .route("/audio/devices", get(handlers::list_audio_devices)) // Audio WebSocket endpoint .route("/ws/audio", any(audio_ws_handler)) - .route("/ws/uac-audio", any(uac_audio_ws_handler)) // Configuration management (domain-separated endpoints) .route("/config", get(handlers::config::get_all_config)) .route("/config/video", get(handlers::config::get_video_config)) @@ -264,6 +264,7 @@ pub fn create_router(state: Arc) -> Router { #[cfg(unix)] let user_routes = { user_routes + .route("/ws/uac-audio", any(uac_audio_ws_handler)) .route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/config/msd", get(handlers::config::get_msd_config)) .route("/config/msd", patch(handlers::config::update_msd_config)) @@ -280,14 +281,8 @@ pub fn create_router(state: Arc) -> Router { "/otg/network/status", get(handlers::config::get_otg_network_status), ) - .route( - "/config/uac", - get(handlers::config::get_uac_config), - ) - .route( - "/config/uac", - patch(handlers::config::update_uac_config), - ) + .route("/config/uac", get(handlers::config::get_uac_config)) + .route("/config/uac", patch(handlers::config::update_uac_config)) .route("/msd/status", get(handlers::msd_status)) .route("/msd/images", get(handlers::msd_images_list)) .route("/msd/images/download", post(handlers::msd_image_download)) diff --git a/src/web/uac_ws.rs b/src/web/uac_ws.rs index ea32e950..718f8bb7 100644 --- a/src/web/uac_ws.rs +++ b/src/web/uac_ws.rs @@ -1,33 +1,135 @@ -use axum::extract::ws::WebSocketUpgrade; +use std::borrow::Cow; +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; -use std::sync::Arc; -use tracing::warn; +use tracing::{debug, info, warn}; +use crate::audio::uac::{ + parse_audio_packet, UacAudioPacket, UacOpusDecoder, UacPlaybackState, UacSession, +}; use crate::state::AppState; -/// WebSocket endpoint for UAC microphone passthrough audio input. -/// -/// Accepts Opus-encoded audio frames (same binary protocol as audio -/// output, message type 0x03) and routes decoded PCM to the UAC -/// playback device on the USB gadget side. pub async fn uac_audio_ws_handler( ws: WebSocketUpgrade, State(state): State>, ) -> impl IntoResponse { - let playback = { - let guard = state.uac_playback.read().await; - match guard.as_ref() { - Some(p) => Arc::new(p.clone()), - None => { - warn!("UAC audio WS rejected: playback not initialized"); - return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback not initialized").into_response(); + let session = { + let playback = state.uac_playback.read().await; + let Some(playback) = playback.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback is disabled").into_response(); + }; + match playback.acquire_session() { + Ok(session) => session, + Err(error) => { + return (StatusCode::CONFLICT, error.to_string()).into_response(); } } }; - ws.on_upgrade(move |socket| { - crate::audio::uac_websocket::handle_uac_audio_ws(socket, playback) - }) + ws.on_upgrade(move |socket| handle_uac_audio(socket, session)) +} + +async fn handle_uac_audio(mut socket: WebSocket, session: UacSession) { + let mut decoder = match UacOpusDecoder::new() { + Ok(decoder) => decoder, + Err(error) => { + warn!("Unable to initialize UAC Opus decoder: {error}"); + let _ = socket.send(Message::Close(None)).await; + return; + } + }; + let mut dropped_frames = 0u64; + info!("UAC microphone WebSocket connected"); + + let mut playback_state = session.state(); + if socket + .send(playback_state_message(playback_state)) + .await + .is_err() + { + return; + } + + while let Some(message) = socket.recv().await { + let message = match message { + Ok(message) => message, + Err(error) => { + warn!("UAC microphone WebSocket failed: {error}"); + break; + } + }; + + match message { + Message::Binary(data) => { + let packet = match parse_audio_packet(&data) { + Ok(packet) => packet, + Err(error) => { + warn!("Rejected UAC audio packet: {error}"); + continue; + } + }; + let pcm: Cow<'_, [i16]> = match packet { + UacAudioPacket::Opus(payload) => match decoder.decode(payload) { + Ok(pcm) => Cow::Borrowed(pcm), + Err(error) => { + warn!("Rejected UAC Opus packet: {error}"); + continue; + } + }, + packet @ UacAudioPacket::Pcm(_) => match packet.pcm_samples() { + Ok(pcm) => Cow::Owned(pcm), + Err(error) => { + warn!("Rejected UAC PCM packet: {error}"); + continue; + } + }, + }; + + let (accepted, current_state) = match session.try_write(pcm.as_ref()) { + Ok(result) => result, + Err(error) => { + warn!("UAC playback stopped: {error}"); + break; + } + }; + if !accepted { + dropped_frames += 1; + if dropped_frames == 1 || dropped_frames.is_multiple_of(250) { + debug!( + "Dropped {dropped_frames} UAC audio frames while the target was unavailable" + ); + } + } + + if current_state != playback_state + && socket + .send(playback_state_message(current_state)) + .await + .is_err() + { + break; + } + playback_state = current_state; + } + Message::Close(_) => break, + Message::Ping(_) | Message::Pong(_) => {} + Message::Text(_) => debug!("Ignoring text message on UAC audio WebSocket"), + } + } + + info!("UAC microphone WebSocket disconnected; dropped_frames={dropped_frames}"); +} + +fn playback_state_message(state: UacPlaybackState) -> Message { + Message::Text( + serde_json::json!({ + "type": "uac_status", + "state": state.as_str(), + }) + .to_string() + .into(), + ) } diff --git a/web/package-lock.json b/web/package-lock.json index dcfd4ddb..40102e0a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,6 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.556.0", "opus-decoder": "^0.7.11", - "opus-media-recorder": "^0.8.0", "pinia": "^3.0.4", "qrcode.vue": "^3.10.0", "reka-ui": "^2.10.1", @@ -1941,12 +1940,6 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "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": { "version": "2.1.2", "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==", "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": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -2488,16 +2472,6 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", diff --git a/web/package.json b/web/package.json index 38c39bca..3dce78c5 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,6 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.556.0", "opus-decoder": "^0.7.11", - "opus-media-recorder": "^0.8.0", "pinia": "^3.0.4", "qrcode.vue": "^3.10.0", "reka-ui": "^2.10.1", diff --git a/web/src/api/request.ts b/web/src/api/request.ts index dd409398..90efc88d 100644 --- a/web/src/api/request.ts +++ b/web/src/api/request.ts @@ -55,6 +55,14 @@ function getToastKey(endpoint: string, config?: ApiRequestConfig): string { function getErrorMessage(data: unknown, fallback: string): string { if (data && typeof data === 'object') { + const code = (data as any).code + const keyByCode: Record = { + 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 if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message) } diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index 04b84371..bf7789e1 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -3,7 +3,6 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { useSystemStore } from '@/stores/system' -import { getMicrophone } from '@/composables/useMicrophone' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { @@ -32,7 +31,6 @@ import { } from '@/components/ui/sheet' import { ClipboardPaste, - Mic, HardDrive, Keyboard, Settings, @@ -75,7 +73,6 @@ const props = defineProps<{ const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg') const showPasteText = computed(() => props.showPasteText !== false) const showMic = computed(() => props.showMic === true) -const mic = getMicrophone() const emit = defineEmits<{ @@ -295,7 +292,10 @@ const hasRightOverflow = computed(() => { /> - + {
- -
- - - - - - {{ mic.error.value ? mic.error.value : (mic.active.value ? '停止传声' : '开始传声') }} - - -
diff --git a/web/src/components/AudioConfigPopover.vue b/web/src/components/AudioConfigPopover.vue index 22e22c50..b9aa9c8a 100644 --- a/web/src/components/AudioConfigPopover.vue +++ b/web/src/components/AudioConfigPopover.vue @@ -1,21 +1,25 @@