"""Main PySide6 window for the voice confirmation client.""" from __future__ import annotations import json from typing import Any from PySide6.QtCore import Qt, Signal, QObject from PySide6.QtGui import QCloseEvent, QShowEvent from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QPlainTextEdit, QSplitter, QVBoxLayout, QWidget, ) from loguru import logger from voice_confirmation_client.core.assignment_listener import ( VoiceAssignmentListener, default_terminal_id_from_env, ) from voice_confirmation_client.core.monitor_worker import MonitorWorker from voice_confirmation_client.logging_config import setup_voice_client_logging # 待确认接口仍为轮询;界面不再暴露,固定默认间隔。 _DEFAULT_PENDING_POLL_INTERVAL_SEC = 5.0 class _Bridge(QObject): log_line = Signal(str) state_text = Signal(str) pending_payload = Signal(object) voice_assign_start = Signal(str) voice_assign_end = Signal(str) class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle("手术室耗材语音确认客户端") self.resize(920, 640) self._assignment_listener: VoiceAssignmentListener | None = None self._assigned_surgery_id: str = "" self._bridge = _Bridge() self._bridge.log_line.connect(self._append_log_plain) self._bridge.pending_payload.connect(self._show_pending) self._bridge.voice_assign_start.connect(self._on_server_assign_start) self._bridge.voice_assign_end.connect(self._on_server_assign_end) setup_voice_client_logging( gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")), ) logger.info( "语音确认客户端已启动;请填写「本机语音终端 ID」与 OR_SITE_CONFIG 一致," "开录后由服务端自动填入手术号并监控" ) self._worker = MonitorWorker( on_log=None, 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_display = QLabel("—(等待服务端开录指派)") self._surgery_id_display.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) 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) self._terminal_id = QLineEdit(default_terminal_id_from_env()) self._terminal_id.setPlaceholderText("与 OR_SITE_CONFIG 中 voice_terminal_id 一致") self._auto_assign = QCheckBox("启用服务端自动指派(开录后自动监控该手术)") self._auto_assign.setChecked(True) form.addRow("本机语音终端 ID", self._terminal_id) form.addRow(self._auto_assign) self._terminal_id.editingFinished.connect(self._sync_assignment_listener) self._base_url.editingFinished.connect(self._sync_assignment_listener) self._auto_assign.toggled.connect(self._on_auto_assign_toggled) form.addRow("当前手术号(仅展示,由服务端指派)", self._surgery_id_display) form.addRow("录音时长", self._record_sec) self._record_sec.valueChanged.connect(lambda _: self._apply_settings_silent()) 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_stop = QPushButton("停止监控(本机)") self._btn_stop.setEnabled(False) self._btn_retry = QPushButton("重试本轮(播放+录音+上传)") self._btn_replay = QPushButton("仅重播话术") 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_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 showEvent(self, event: QShowEvent) -> None: super().showEvent(event) self._sync_assignment_listener() def _on_auto_assign_toggled(self, _checked: bool) -> None: if not self._auto_assign.isChecked(): self._worker.set_monitoring(False) self._assigned_surgery_id = "" self._surgery_id_display.setText("—(未启用自动指派)") self._btn_stop.setEnabled(False) self._status_label.setText("已关闭自动指派") self._apply_settings_silent() self._sync_assignment_listener() def _sync_assignment_listener(self) -> None: if self._assignment_listener: self._assignment_listener.stop() self._assignment_listener = None if not self._auto_assign.isChecked(): logger.info("未勾选「启用服务端自动指派」,不连接 WebSocket") self._worker.set_monitoring(False) self._assigned_surgery_id = "" self._surgery_id_display.setText("—(未启用自动指派)") self._btn_stop.setEnabled(False) self._apply_settings_silent() return tid = self._terminal_id.text().strip() base = self._base_url.text().strip() if not tid: logger.warning( "「本机语音终端 ID」为空,无法接收开录指派;请在环境变量 VOICE_TERMINAL_ID 或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)" ) return if not base: logger.warning("服务端 Base URL 为空,无法连接指派接口") return self._assignment_listener = VoiceAssignmentListener( http_base_url=base, terminal_id=tid, on_start=lambda s: self._bridge.voice_assign_start.emit(s), on_end=lambda s: self._bridge.voice_assign_end.emit(s), ) self._assignment_listener.start() def _on_server_assign_start(self, sid: str) -> None: if len(sid) != 6 or not sid.isdigit(): logger.warning("服务端指派无效手术号: {!r}(须为 6 位数字)", sid) return self._assigned_surgery_id = sid self._surgery_id_display.setText(sid) self._apply_settings_silent() self._worker.set_monitoring(True) self._btn_stop.setEnabled(True) logger.info("服务端已指派手术 {},已自动开始监控(WebSocket 指派)", sid) def _on_server_assign_end(self, sid: str) -> None: self._worker.set_monitoring(False) self._assigned_surgery_id = "" self._surgery_id_display.setText("—(等待服务端开录指派)") self._btn_stop.setEnabled(False) self._apply_settings_silent() self._status_label.setText("已停止(服务端结束)") logger.info("服务端已结束手术 {},已自动停止监控", sid) 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._assigned_surgery_id, interval_sec=_DEFAULT_PENDING_POLL_INTERVAL_SEC, 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 _stop_monitoring(self) -> None: self._worker.set_monitoring(False) self._assigned_surgery_id = "" self._surgery_id_display.setText("—(等待服务端开录指派)") self._btn_stop.setEnabled(False) self._apply_settings_silent() logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——") self._status_label.setText("已停止(本地)") def _append_log_plain(self, line: str) -> None: """由 loguru GUI sink 写入,已含时间与级别,不再加前缀。""" self._log.appendPlainText(line) sb = self._log.verticalScrollBar() sb.setValue(sb.maximum()) def shutdown(self) -> None: """停止后台线程;窗口关闭与 Ctrl+C(aboutToQuit)共用。""" if self._assignment_listener: self._assignment_listener.stop() self._assignment_listener = None self._worker.stop_thread() def closeEvent(self, event: QCloseEvent) -> None: self.shutdown() event.accept()