Files
operating-room-monitor-server/voice_confirmation_client/gui/main_window.py
Kevin 4c3f9a367b 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
2026-04-27 09:52:10 +08:00

199 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()