- machine_config:系统级 + 用户级 voice_client.json 合并,界面失焦保存至用户目录 - 移除「当前手术号」表单项与占位文案;指派后仅在窗口标题显示手术号 - WebSocket 连接日志附带绑定/开录路径排查说明 - 开录未推送时服务端 WARNING(无站点绑定或 camera_ids 不匹配) - 测试、README、.env.example 同步 Made-with: Cursor
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""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()
|