"""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()