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:
198
voice_confirmation_client/gui/main_window.py
Normal file
198
voice_confirmation_client/gui/main_window.py
Normal 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()
|
||||
Reference in New Issue
Block a user