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
This commit is contained in:
Kevin
2026-04-27 11:21:16 +08:00
parent 4c3f9a367b
commit 6b3adb4ad8
36 changed files with 1194 additions and 162 deletions

View File

@@ -5,7 +5,7 @@
## 环境
- Python **3.13+**(与主项目一致)
- 安装可选依赖组 `**voice-client`**PySide6、httpx、numpy、sounddevice
- 安装可选依赖组 **voice-client**PySide6、httpx、numpy、sounddevice、websocket-client
```bash
cd /path/to/operation-room-monitor-server
@@ -38,7 +38,15 @@ start_voice_confirmation_client.bat
uv run --group voice-client voice-confirmation-client
```
在界面中填写 **服务端 Base URL**、**6 位手术号**,点击 **开始监控**
**术间 / 摄像头 / 语音终端对应关系**只在服务端 `**OR_SITE_CONFIG_JSON_FILE`** 里维护一份(`voice_or_room_bindings`);桌面程序不读该文件
本机要做的只有两件事:**服务端 Base URL**,以及 **本机语音终端 ID**(须等于 JSON 里某条 `voice_terminal_id`)。**手术号不在客户端输入**:勾选 **启用服务端自动指派** 后,开录/停录仅通过 **WebSocket** `voice_assignment` 下发(断线后自动重连,不用 HTTP 轮询);界面只读展示当前手术号;停录后自动停止。可用 **停止监控(本机)** 做本地紧急中断。
可选:用环境变量 `**VOICE_TERMINAL_ID`** 预填界面里的「本机语音终端 ID」仅客户端进程使用**不要**写进监控服务的 `.env`)。
## 日志loguru
客户端使用 **loguru****右侧日志区**与**启动终端 stderr** 会同时输出。开录无反应时请看是否出现「本机语音终端 ID 为空」、`WebSocket 已连接``收到 voice_assignment start`、或反复「WebSocket 断开」等行。
## 音频说明
@@ -76,5 +84,5 @@ uv run --group voice-client-build python scripts/build_voice_client.py
## 与浏览器 Demo 的差异
- 浏览器 Demo`scripts/demo_client/`)默认 **10 秒** 轮询;本客户端默认 **5 秒**,可在界面修改。
- 本客户端无「开始/结束手术」按钮;手术需由既有流程或他端调用 `POST /client/surgeries/start` 启动。
- 本客户端无「开始/结束手术」按钮;手术需由既有流程或他端调用 `POST /client/surgeries/start` 启动;若启用自动指派,开录成功后本机将自动开始待确认轮询

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
import signal
import sys
def main() -> None:
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
from voice_confirmation_client.gui.main_window import MainWindow
@@ -13,6 +15,19 @@ def main() -> None:
app = QApplication(sys.argv)
win = MainWindow()
win.show()
app.aboutToQuit.connect(win.shutdown)
# Qt 事件循环长时间跑在 native 代码里时Python 无法处理 SIGINT定时器让解释器周期性醒来。
_pulse = QTimer()
_pulse.timeout.connect(lambda: None)
_pulse.start(200)
def _on_sigint(_signum: int, _frame: object | None) -> None:
app.quit()
signal.signal(signal.SIGINT, _on_sigint)
raise SystemExit(app.exec())

View File

@@ -0,0 +1,143 @@
"""WebSocket接收服务端 voice_assignment开录/停录自动启停监控)。"""
from __future__ import annotations
import json
import os
import threading
import time
from collections.abc import Callable
from typing import Any
from urllib.parse import quote, urlparse, urlunparse
from loguru import logger
def http_base_to_ws_root(http_base: str) -> str:
p = urlparse(http_base.strip())
scheme = "wss" if p.scheme == "https" else "ws"
return urlunparse((scheme, p.netloc, "", "", "", ""))
class VoiceAssignmentListener:
"""后台线程:仅 WebSocket断线后等待一小段时间再重连。"""
def __init__(
self,
*,
http_base_url: str,
terminal_id: str,
on_start: Callable[[str], None],
on_end: Callable[[str], None],
reconnect_delay_sec: float = 2.0,
) -> None:
self._http_base = http_base_url.rstrip("/")
self._terminal_id = terminal_id.strip()
self._on_start = on_start
self._on_end = on_end
self._reconnect_delay = reconnect_delay_sec
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._last_assignment: str | None = None
@property
def terminal_id(self) -> str:
return self._terminal_id
def start(self) -> None:
if not self._terminal_id:
logger.warning("未配置 terminal_id跳过语音终端指派监听")
return
if self._thread and self._thread.is_alive():
logger.debug("指派监听线程已在运行,忽略重复 start")
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name="VoiceAssignment", daemon=True)
self._thread.start()
logger.info(
"已启动指派监听(仅 WebSocketterminal_id={!r} base={!r}",
self._terminal_id,
self._http_base,
)
def stop(self) -> None:
self._stop.set()
logger.debug("已请求停止指派监听线程")
def _run(self) -> None:
import websocket
ws_root = http_base_to_ws_root(self._http_base)
path = f"/client/voice-terminals/ws?terminal_id={quote(self._terminal_id)}"
ws_url = ws_root.rstrip("/") + path
logger.info("WebSocket 目标: {}", ws_url)
while not self._stop.is_set():
try:
def _on_open(_ws: Any) -> None:
logger.info("WebSocket 已连接,等待服务端 voice_assignment 消息")
def _on_close(_ws: Any, close_status_code: Any, close_msg: Any) -> None:
logger.warning(
"WebSocket 断开 code={} msg={!r}",
close_status_code,
close_msg,
)
def _on_error(_ws: Any, err: Any) -> None:
if err is None:
return
if type(err).__name__ == "ABNF":
logger.debug("WebSocket 内部帧回调(已忽略): {}", type(err).__name__)
return
logger.warning("WebSocket 错误: {}", err)
ws = websocket.WebSocketApp(
ws_url,
on_open=_on_open,
on_close=_on_close,
on_message=self._ws_on_message,
on_error=_on_error,
)
ws.run_forever(ping_interval=None, ping_timeout=None)
except Exception as exc:
logger.exception("WebSocket run_forever 异常: {}", exc)
if self._stop.is_set():
break
logger.info(
"{:.1f}s 后重连 WebSocket…",
self._reconnect_delay,
)
time.sleep(self._reconnect_delay)
def _ws_on_message(self, _ws: Any, message: str) -> None:
try:
data = json.loads(message)
except json.JSONDecodeError:
logger.debug("WebSocket 非 JSON 消息(已忽略): {!r}", message[:200])
return
if data.get("type") != "voice_assignment":
logger.debug("WebSocket 非 voice_assignment 消息 type={!r}", data.get("type"))
return
action = data.get("action")
sid = str(data.get("surgery_id") or "")
if not sid:
return
if action == "start":
logger.info("收到 voice_assignment start surgery_id={!r}", sid)
self._last_assignment = sid
self._on_start(sid)
elif action == "end":
logger.info("收到 voice_assignment end surgery_id={!r}", sid)
if self._last_assignment == sid:
self._last_assignment = None
self._on_end(sid)
else:
logger.debug("忽略 voice_assignment action={!r}", action)
def default_terminal_id_from_env() -> str:
return (os.environ.get("VOICE_TERMINAL_ID") or "").strip()

View File

@@ -9,6 +9,8 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from voice_confirmation_client.core.api import ConfirmationApiClient
from voice_confirmation_client.core.playback import play_mp3_from_base64
from voice_confirmation_client.core.record import record_wav_16k_mono
@@ -100,9 +102,13 @@ class MonitorWorker:
def set_monitoring(self, active: bool) -> None:
if active:
with self._settings_lock:
sid = self._settings.surgery_id
logger.info("监控已开启 surgery_id={!r}", sid)
self._monitoring.set()
self._wake.set()
else:
logger.info("监控已关闭")
self._monitoring.clear()
with self._state_lock:
self._state.generation += 1
@@ -135,6 +141,7 @@ class MonitorWorker:
self._emit_state("待机")
def _log(self, msg: str) -> None:
logger.info("{}", msg)
if self._on_log:
self._on_log(msg)

View File

@@ -3,11 +3,10 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtGui import QCloseEvent
from PySide6.QtGui import QCloseEvent, QShowEvent
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -18,7 +17,6 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QPlainTextEdit,
QSplitter,
@@ -26,13 +24,25 @@ from PySide6.QtWidgets import (
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):
@@ -40,13 +50,25 @@ class MainWindow(QMainWindow):
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)
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=lambda m: self._bridge.log_line.emit(m),
on_log=None,
on_state=lambda s: self._bridge.state_text.emit(s),
on_pending=lambda p: self._bridge.pending_payload.emit(p),
)
@@ -56,23 +78,30 @@ class MainWindow(QMainWindow):
self.setCentralWidget(central)
root = QVBoxLayout(central)
form_box = QGroupBox("连接与手术")
form_box = QGroupBox("连接与当前手术")
form = QFormLayout(form_box)
self._base_url = QLineEdit("http://127.0.0.1:38080")
self._surgery_id = QLineEdit("")
self._surgery_id.setPlaceholderText("6 位数字,如 123456")
self._interval = QDoubleSpinBox()
self._interval.setRange(1.0, 120.0)
self._interval.setValue(5.0)
self._interval.setSuffix(" s")
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)
form.addRow("手术号 surgery_id", self._surgery_id)
form.addRow("轮询间隔", self._interval)
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("音频 / 调试")
@@ -91,12 +120,10 @@ class MainWindow(QMainWindow):
root.addWidget(adv)
btn_row = QHBoxLayout()
self._btn_start = QPushButton("开始监控")
self._btn_stop = QPushButton("停止监控")
self._btn_stop = QPushButton("停止监控(本机)")
self._btn_stop.setEnabled(False)
self._btn_retry = QPushButton("重试本轮(播放+录音+上传)")
self._btn_replay = QPushButton("仅重播话术")
btn_row.addWidget(self._btn_start)
btn_row.addWidget(self._btn_stop)
btn_row.addWidget(self._btn_retry)
btn_row.addWidget(self._btn_replay)
@@ -119,13 +146,76 @@ class MainWindow(QMainWindow):
split.setSizes([360, 520])
root.addWidget(split, stretch=1)
self._btn_start.clicked.connect(self._start_monitoring)
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()
@@ -160,8 +250,8 @@ class MainWindow(QMainWindow):
dev_data = self._device_combo.currentData()
self._worker.set_settings(
base_url=self._base_url.text().strip(),
surgery_id=self._surgery_id.text().strip(),
interval_sec=float(self._interval.value()),
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(),
@@ -169,30 +259,28 @@ class MainWindow(QMainWindow):
sounddevice_device=dev_data,
)
def _start_monitoring(self) -> None:
sid = self._surgery_id.text().strip()
if len(sid) != 6 or not sid.isdigit():
QMessageBox.warning(self, "校验失败", "手术号必须为 6 位数字。")
return
self._apply_settings_silent()
self._worker.set_monitoring(True)
self._btn_start.setEnabled(False)
self._btn_stop.setEnabled(True)
self._append_log("—— 开始监控 ——")
def _stop_monitoring(self) -> None:
self._worker.set_monitoring(False)
self._btn_start.setEnabled(True)
self._assigned_surgery_id = ""
self._surgery_id_display.setText("—(等待服务端开录指派)")
self._btn_stop.setEnabled(False)
self._append_log("—— 已停止监控 ——")
self._status_label.setText("已停止")
self._apply_settings_silent()
logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——")
self._status_label.setText("已停止(本地)")
def _append_log(self, line: str) -> None:
ts = datetime.now().strftime("%H:%M:%S")
self._log.appendPlainText(f"[{ts}] {line}")
def _append_log_plain(self, line: str) -> None:
"""由 loguru GUI sink 写入,已含时间与级别,不再加前缀。"""
self._log.appendPlainText(line)
sb = self._log.verticalScrollBar()
sb.setValue(sb.maximum())
def closeEvent(self, event: QCloseEvent) -> None:
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()

View File

@@ -0,0 +1,35 @@
"""桌面客户端 loguru终端 + 可选 GUI 日志区。"""
from __future__ import annotations
import sys
from collections.abc import Callable
from loguru import logger
def setup_voice_client_logging(
*,
gui_line_sink: Callable[[str], None] | None = None,
stderr_level: str = "INFO",
gui_level: str = "DEBUG",
) -> None:
"""初始化 sink彩色 stderr + 可选一行一条写入 Qt由 sink 内 Signal 线程安全投递)。"""
logger.remove()
logger.add(
sys.stderr,
format=(
"<green>{time:HH:mm:ss}</green> | "
"<level>{level:<8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan> | "
"{message}"
),
level=stderr_level,
colorize=sys.stderr.isatty(),
)
if gui_line_sink:
logger.add(
gui_line_sink,
format="{time:HH:mm:ss.SSS} | {level:<8} | {message}",
level=gui_level,
)