feat(voice-client): PySide6 desktop client and Windows build scripts

Add voice_confirmation_client (poll, TTS MP3 playback, mic WAV resolve),
PyInstaller spec, start/build helpers, and API unit tests.

Pending manual testing: end-to-end on OR workstations and packaged exe.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 09:52:10 +08:00
parent e4c6127619
commit 4c3f9a367b
19 changed files with 1324 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""PySide6 desktop GUI."""

View File

@@ -0,0 +1,198 @@
"""Main PySide6 window for the voice confirmation client."""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QPlainTextEdit,
QSplitter,
QVBoxLayout,
QWidget,
)
from voice_confirmation_client.core.monitor_worker import MonitorWorker
class _Bridge(QObject):
log_line = Signal(str)
state_text = Signal(str)
pending_payload = Signal(object)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("手术室耗材语音确认客户端")
self.resize(920, 640)
self._bridge = _Bridge()
self._bridge.log_line.connect(self._append_log)
self._bridge.pending_payload.connect(self._show_pending)
self._worker = MonitorWorker(
on_log=lambda m: self._bridge.log_line.emit(m),
on_state=lambda s: self._bridge.state_text.emit(s),
on_pending=lambda p: self._bridge.pending_payload.emit(p),
)
self._worker.start_thread()
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
form_box = QGroupBox("连接与手术")
form = QFormLayout(form_box)
self._base_url = QLineEdit("http://127.0.0.1:38080")
self._surgery_id = QLineEdit("")
self._surgery_id.setPlaceholderText("6 位数字,如 123456")
self._interval = QDoubleSpinBox()
self._interval.setRange(1.0, 120.0)
self._interval.setValue(5.0)
self._interval.setSuffix(" s")
self._record_sec = QDoubleSpinBox()
self._record_sec.setRange(2.0, 60.0)
self._record_sec.setValue(8.0)
self._record_sec.setSuffix(" s")
form.addRow("服务端 Base URL", self._base_url)
form.addRow("手术号 surgery_id", self._surgery_id)
form.addRow("轮询间隔", self._interval)
form.addRow("录音时长", self._record_sec)
root.addWidget(form_box)
adv = QGroupBox("音频 / 调试")
adv_l = QFormLayout(adv)
self._device_combo = QComboBox()
self._device_combo.addItem("系统默认麦克风", None)
self._populate_input_devices()
self._prefer_ffmpeg = QCheckBox("优先使用 ffmpeg 录音(需本机 ffmpeg 且设备参数可用)")
self._hide_404 = QCheckBox("隐藏 404 轮询日志(推荐)")
self._hide_404.setChecked(True)
self._dry_run = QCheckBox("Dry-run录音后不上传")
adv_l.addRow("输入设备", self._device_combo)
adv_l.addRow(self._prefer_ffmpeg)
adv_l.addRow(self._hide_404)
adv_l.addRow(self._dry_run)
root.addWidget(adv)
btn_row = QHBoxLayout()
self._btn_start = QPushButton("开始监控")
self._btn_stop = QPushButton("停止监控")
self._btn_stop.setEnabled(False)
self._btn_retry = QPushButton("重试本轮(播放+录音+上传)")
self._btn_replay = QPushButton("仅重播话术")
btn_row.addWidget(self._btn_start)
btn_row.addWidget(self._btn_stop)
btn_row.addWidget(self._btn_retry)
btn_row.addWidget(self._btn_replay)
btn_row.addStretch()
root.addLayout(btn_row)
self._status_label = QLabel("待机")
root.addWidget(self._status_label)
self._bridge.state_text.connect(self._status_label.setText)
split = QSplitter(Qt.Orientation.Horizontal)
self._pending_view = QPlainTextEdit()
self._pending_view.setReadOnly(True)
self._pending_view.setPlaceholderText("待确认内容将显示在这里…")
self._log = QPlainTextEdit()
self._log.setReadOnly(True)
self._log.setPlaceholderText("日志…")
split.addWidget(self._pending_view)
split.addWidget(self._log)
split.setSizes([360, 520])
root.addWidget(split, stretch=1)
self._btn_start.clicked.connect(self._start_monitoring)
self._btn_stop.clicked.connect(self._stop_monitoring)
self._btn_retry.clicked.connect(self._worker.retry_failed)
self._btn_replay.clicked.connect(self._worker.replay_prompt_only)
self._apply_settings_silent()
def _show_pending(self, payload: object) -> None:
if payload is None:
self._pending_view.clear()
return
if not isinstance(payload, dict):
self._pending_view.setPlainText(str(payload))
return
try:
text = json.dumps(payload, ensure_ascii=False, indent=2)
except (TypeError, ValueError):
text = str(payload)
self._pending_view.setPlainText(text)
def _populate_input_devices(self) -> None:
try:
import sounddevice as sd
except ImportError:
return
try:
devices = sd.query_devices()
hostapis = sd.query_hostapis()
except Exception:
return
for i, d in enumerate(devices):
if d.get("max_input_channels", 0) <= 0:
continue
ha = hostapis[d["hostapi"]]["name"] if d.get("hostapi") is not None else ""
label = f"{i}: {d.get('name', '')} ({ha})"
self._device_combo.addItem(label, i)
def _apply_settings_silent(self) -> None:
dev_data = self._device_combo.currentData()
self._worker.set_settings(
base_url=self._base_url.text().strip(),
surgery_id=self._surgery_id.text().strip(),
interval_sec=float(self._interval.value()),
record_seconds=float(self._record_sec.value()),
dry_run=self._dry_run.isChecked(),
hide_404_logs=self._hide_404.isChecked(),
prefer_ffmpeg_record=self._prefer_ffmpeg.isChecked(),
sounddevice_device=dev_data,
)
def _start_monitoring(self) -> None:
sid = self._surgery_id.text().strip()
if len(sid) != 6 or not sid.isdigit():
QMessageBox.warning(self, "校验失败", "手术号必须为 6 位数字。")
return
self._apply_settings_silent()
self._worker.set_monitoring(True)
self._btn_start.setEnabled(False)
self._btn_stop.setEnabled(True)
self._append_log("—— 开始监控 ——")
def _stop_monitoring(self) -> None:
self._worker.set_monitoring(False)
self._btn_start.setEnabled(True)
self._btn_stop.setEnabled(False)
self._append_log("—— 已停止监控 ——")
self._status_label.setText("已停止")
def _append_log(self, line: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
self._log.appendPlainText(f"[{ts}] {line}")
sb = self._log.verticalScrollBar()
sb.setValue(sb.maximum())
def closeEvent(self, event: QCloseEvent) -> None:
self._worker.stop_thread()
event.accept()