Files
Kevin 9941e0d131 feat(voice-client): 双层配置持久化、精简手术号 UI 与 WS/服务端排查日志
- machine_config:系统级 + 用户级 voice_client.json 合并,界面失焦保存至用户目录
- 移除「当前手术号」表单项与占位文案;指派后仅在窗口标题显示手术号
- WebSocket 连接日志附带绑定/开录路径排查说明
- 开录未推送时服务端 WARNING(无站点绑定或 camera_ids 不匹配)
- 测试、README、.env.example 同步

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

365 lines
14 KiB
Python
Raw Permalink 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, 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+CaboutToQuit共用。"""
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()