Files
operating-room-monitor-server/voice_confirmation_client/gui/main_window.py
Kevin 6b3adb4ad8 feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调
- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
2026-04-27 11:21:16 +08:00

287 lines
11 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 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()