"""Main PySide6 window for the voice confirmation client.""" from __future__ import annotations import json from typing import Any from PySide6.QtCore import Qt, QObject, QTimer, Signal from PySide6.QtGui import QCloseEvent, QFont, QShowEvent from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QPlainTextEdit, QSplitter, QVBoxLayout, QWidget, ) from loguru import logger from voice_confirmation_client.core.assignment_listener import VoiceAssignmentListener from voice_confirmation_client.core.machine_config import ( http_base_url_from_config, load_voice_client_config, machine_config_file_path, save_user_voice_client_config, user_voice_client_config_path, voice_terminal_id_from_config, ) 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._rec_banner_pulse_phase: bool = False self._rec_pulse_timer = QTimer(self) self._rec_pulse_timer.setInterval(550) self._rec_pulse_timer.timeout.connect(self._pulse_recording_banner) self._bridge = _Bridge() 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) _mc = load_voice_client_config() central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) form_box = QGroupBox("连接") form = QFormLayout(form_box) self._base_url = QLineEdit() 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._base_url.setText(http_base_url_from_config(_mc)) self._terminal_id = QLineEdit(voice_terminal_id_from_config(_mc)) 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._on_connection_fields_edited) self._base_url.editingFinished.connect(self._on_connection_fields_edited) self._auto_assign.toggled.connect(self._on_auto_assign_toggled) 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._recording_banner = QFrame() self._recording_banner.setObjectName("recordingBanner") self._recording_banner.setVisible(False) bl = QHBoxLayout(self._recording_banner) bl.setContentsMargins(14, 12, 14, 12) self._recording_banner_label = QLabel("● 正在录音 — 请对着麦克风清晰作答") self._recording_banner_label.setAlignment(Qt.AlignmentFlag.AlignCenter) bf = QFont() bf.setPointSize(15) bf.setBold(True) self._recording_banner_label.setFont(bf) self._recording_banner_label.setStyleSheet("color: #ffffff; background: transparent;") bl.addWidget(self._recording_banner_label, stretch=1) self._apply_recording_banner_style("#b71c1c") root.addWidget(self._recording_banner) self._status_label = QLabel("待机") root.addWidget(self._status_label) self._bridge.state_text.connect(self._on_worker_state) 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._bridge.log_line.connect(self._append_log_plain) setup_voice_client_logging( gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")), ) logger.info( "语音确认客户端已启动;本机终端 ID 须与 OR_SITE_CONFIG 中 voice_terminal_id 一致" ) _sys_cfg = machine_config_file_path() if _sys_cfg.is_file(): logger.info("系统级配置: {}", _sys_cfg) _user_cfg = user_voice_client_config_path() if _user_cfg.is_file(): logger.info("用户级配置(界面保存): {}", _user_cfg) 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() 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 _apply_recording_banner_style(self, bg_hex: str) -> None: self._recording_banner.setStyleSheet( f"QFrame#recordingBanner {{ background-color: {bg_hex}; border-radius: 8px; }}" ) def _pulse_recording_banner(self) -> None: self._rec_banner_pulse_phase = not self._rec_banner_pulse_phase self._apply_recording_banner_style( "#c62828" if self._rec_banner_pulse_phase else "#b71c1c" ) def _set_recording_banner_active(self, active: bool) -> None: if active: self._recording_banner.setVisible(True) self._rec_banner_pulse_phase = False self._apply_recording_banner_style("#b71c1c") if not self._rec_pulse_timer.isActive(): self._rec_pulse_timer.start() else: self._rec_pulse_timer.stop() self._recording_banner.setVisible(False) self._apply_recording_banner_style("#b71c1c") def _on_worker_state(self, s: str) -> None: self._status_label.setText(s) self._set_recording_banner_active("录音中" in s) def _refresh_window_title(self) -> None: base = "手术室耗材语音确认客户端" if self._assigned_surgery_id: self.setWindowTitle(f"{base} — 手术 {self._assigned_surgery_id}") else: self.setWindowTitle(base) 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._refresh_window_title() self._btn_stop.setEnabled(False) self._on_worker_state("已关闭自动指派") self._apply_settings_silent() self._sync_assignment_listener() def _on_connection_fields_edited(self) -> None: try: save_user_voice_client_config( voice_terminal_id=self._terminal_id.text(), http_base_url=self._base_url.text(), ) except OSError as exc: logger.warning("无法保存用户级配置: {}", exc) 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._refresh_window_title() 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」为空,无法接收开录指派;请在每机配置文件或界面中填写(须与 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._refresh_window_title() 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._refresh_window_title() self._btn_stop.setEnabled(False) self._apply_settings_silent() self._on_worker_state("已停止(服务端结束)") 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._refresh_window_title() self._btn_stop.setEnabled(False) self._apply_settings_silent() logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——") self._on_worker_state("已停止(本地)") def _append_log_plain(self, line: str) -> None: """由 loguru GUI sink 写入,已含时间与级别,不再加前缀。""" w = getattr(self, "_log", None) if w is None: return w.appendPlainText(line) sb = w.verticalScrollBar() sb.setValue(sb.maximum()) def shutdown(self) -> None: """停止后台线程;窗口关闭与 Ctrl+C(aboutToQuit)共用。""" self._rec_pulse_timer.stop() self._set_recording_banner_active(False) 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()