Files
operating-room-monitor-server/voice_confirmation_client/gui/main_window.py

287 lines
11 KiB
Python
Raw Normal View History

"""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+CaboutToQuit共用。"""
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()