/** * 手术室语音确认 — 浏览器端(与桌面版历史 MonitorWorker 状态机行为对齐) * WebSocket:voice_assignment / voice_pending / voice_pending_empty; * HTTP:可选 GET 轮询队首、POST resolve 上传 WAV。 */ const LS_BASE = "vc_http_base_url"; const LS_TID = "vc_terminal_id"; const LS_SEC = "vc_record_sec"; const LS_AUTO = "vc_auto_assign"; const LS_DRY = "vc_dry_run"; const LS_MANUAL_SID = "vc_manual_surgery_id"; const LS_ADVANCED = "vc_advanced_open"; /** 工程师可见的原始状态 -> 术间平板展示文案 */ const CLINICAL_STATUS = { 待机: "等待下一个确认", "待机(无待确认)": "当前没有待确认项目", "待机(等待 WebSocket 推送)": "等待系统分配任务", "播放话术(手动重播)…": "正在重播提示…", "手术号无效(需 6 位数字)": "手术号应为 6 位数字,请在高级设置中核对", "请重试录音或检查麦克风": "未听清或录音异常,请检查麦克风后点「重试」", "播报提示中…": "正在播放提示,播报结束后将自动开始录音", "播报已结束,正在打开麦克风…": "提示音已结束,正在准备录音,请稍候", "录音中…": "提示已结束,正在录制您的回答,请对着麦克风清楚说出确认内容", "无 TTS 音频(可重试)": "没有可用的提示音,请点「重试」或联系信息科", "播放或录音失败(可重试)": "播放或录音失败,请点「重试」", "待机(dry-run)": "调试模式:已录音但未上传", "上传识别…": "正在提交语音,请稍候…", "解析/上传被拒(可重试)": "未能完成确认,请点「重试」", "上传失败(可重试)": "提交失败,请检查网络后点「重试」", "监控中(手动手术号)": "已连接,等待确认任务", "监控中(手动手术号,已自动启用)": "已连接,等待确认任务", "请把 Base URL 指向 API(常见为 :38080),不要填本页 :8080": "接口地址可能填错,请通知信息科在「高级设置」中核对服务端地址", "已停止(服务端结束)": "本场手术已在系统内结束", "已关闭自动指派": "已关闭自动指派(可在高级设置重新打开)", "已停止(本地)": "本机已停止接收任务", }; function toClinicalStatus(raw) { if (raw == null || raw === "") return "等待下一个确认"; const s = String(raw); if (Object.prototype.hasOwnProperty.call(CLINICAL_STATUS, s)) return CLINICAL_STATUS[s]; return s; } function updateSurgeryDisplay() { const line = document.getElementById("surgeryIdLine"); const valEl = document.getElementById("surgeryIdValue"); if (!line || !valEl) return; if (assignedSurgeryId && /^\d{6}$/.test(assignedSurgeryId)) { valEl.textContent = assignedSurgeryId; line.classList.remove("hidden"); } else { line.classList.add("hidden"); } } function wireAdvancedPanel() { const panel = document.getElementById("advancedPanel"); if (!panel) return; try { const params = new URLSearchParams(location.search); if (params.get("debug") === "1") { panel.open = true; localStorage.setItem(LS_ADVANCED, "1"); } else if (localStorage.getItem(LS_ADVANCED) === "1") { panel.open = true; } } catch { /* */ } panel.addEventListener("toggle", () => { try { if (panel.open) localStorage.setItem(LS_ADVANCED, "1"); else localStorage.removeItem(LS_ADVANCED); } catch { /* */ } }); } /** 极短静音 WAV,用于在用户首次手势内解锁 HTMLAudioElement.play()(否则异步推送触发的播报会被浏览器静默拦截)。 */ const SILENT_UNLOCK_DATA_URL = "data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAE="; let htmlAudioPlaybackUnlocked = false; /** * 在用户已与页面交互的前提下调用:播放静音再暂停,满足 Chrome/Safari 自动播放策略。 * @returns {Promise} */ async function unlockHtmlAudioPlayback() { if (htmlAudioPlaybackUnlocked) return true; try { const a = new Audio(); a.src = SILENT_UNLOCK_DATA_URL; await a.play(); a.pause(); a.currentTime = 0; htmlAudioPlaybackUnlocked = true; return true; } catch (e) { console.warn("[voice-confirmation] 音频解锁失败(需用户手势)", e); return false; } } /** 首次 pointerdown / keydown 时尝试解锁(捕获阶段,尽早命中)。 */ function wireAudioPlaybackUnlock() { const tryOnce = async () => { const ok = await unlockHtmlAudioPlayback(); if (ok) { document.removeEventListener("pointerdown", tryOnce, true); document.removeEventListener("keydown", tryOnce, true); } }; document.addEventListener("pointerdown", tryOnce, { capture: true, passive: true }); document.addEventListener("keydown", tryOnce, { capture: true }); } function formatAudioPlayError(err) { const name = err && err.name ? String(err.name) : ""; const msg = err && err.message ? String(err.message) : String(err); if (name === "NotAllowedError" || /not allowed|user didn'?t interact/i.test(msg)) { return "浏览器拒绝了自动播放(需先在页面内点击或按键解锁音频)。若已点击仍失败,请检查标签页是否静音。"; } return msg || name || "未知错误"; } /** 静态页常见 8080,API 常见 38080;避免把 Base URL 填成页面自己的端口。 */ function inferDefaultApiBase() { try { const h = location.hostname || "127.0.0.1"; const scheme = location.protocol === "https:" ? "https" : "http"; return `${scheme}://${h}:38080`; } catch { return "http://127.0.0.1:38080"; } } function isLoopbackOrWildcardHost(hostname) { const h = String(hostname || "").trim().toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0"; } function parseHttpBaseUrl(raw) { const s = String(raw || "").trim(); if (!s) return null; try { const u = new URL(s); if (u.protocol !== "http:" && u.protocol !== "https:") return null; return u; } catch { return null; } } function apiBaseFromQuery() { try { const params = new URLSearchParams(location.search); for (const key of ["baseUrl", "base_url", "apiBaseUrl", "api_base_url"]) { const val = params.get(key); if (parseHttpBaseUrl(val)) return String(val).trim(); } } catch { /* ignore */ } return ""; } function resolveInitialApiBase(savedBase) { const queryBase = apiBaseFromQuery(); if (queryBase) { return { base: queryBase, note: "[提示] 已使用 URL 参数中的服务端 Base URL: " + queryBase, }; } const saved = String(savedBase || "").trim(); const fallback = inferDefaultApiBase(); const pageHost = location.hostname || ""; if (saved) { const savedUrl = parseHttpBaseUrl(saved); if ( savedUrl && !isLoopbackOrWildcardHost(pageHost) && isLoopbackOrWildcardHost(savedUrl.hostname) ) { return { base: fallback, note: "[提示] 当前语音页来自局域网地址,已将旧的本机 Base URL " + saved + " 自动改为 " + fallback, }; } return { base: saved, note: "" }; } return { base: fallback, note: "" }; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function httpBaseToWsRoot(httpBase) { let u; try { u = new URL(httpBase.trim()); } catch { return null; } const scheme = u.protocol === "https:" ? "wss:" : "ws:"; return `${scheme}//${u.host}`; } function buildVoiceWsUrl(httpBase, terminalId) { const root = httpBaseToWsRoot(httpBase); if (!root) return null; const path = `/client/voice-terminals/ws?terminal_id=${encodeURIComponent(terminalId.trim())}`; return root + path; } // --- WAV --- function resampleFloat32(input, inRate, outRate) { if (inRate === outRate) return input; const outLen = Math.max(1, Math.floor((input.length * outRate) / inRate)); const out = new Float32Array(outLen); for (let i = 0; i < outLen; i++) { const pos = (i * inRate) / outRate; const i0 = Math.floor(pos); const f = pos - i0; out[i] = (1 - f) * (input[i0] ?? 0) + f * (input[i0 + 1] ?? 0); } return out; } function floatToPcmS16le(floats) { const n = floats.length; const buf = new Int16Array(n); for (let i = 0; i < n; i++) { const s = Math.max(-1, Math.min(1, floats[i])); buf[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } return buf; } function pcmS16leToWav(pcm, sampleRate) { const n = pcm.byteLength; const ab = new ArrayBuffer(44 + n); const dv = new DataView(ab); const enc = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); }; enc(0, "RIFF"); dv.setUint32(4, 36 + n, true); enc(8, "WAVE"); enc(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, sampleRate, true); dv.setUint32(28, sampleRate * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); enc(36, "data"); dv.setUint32(40, n, true); new Uint8Array(ab, 44).set(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength)); return new Uint8Array(ab); } /** * 从已有 MediaStream 采集(须先 getUserMedia)。必须 resume AudioContext,否则在多数浏览器 * 中 ScriptProcessor 不跑,WAV 全为或接近静音。 */ async function recordWav16kFromStream(stream, durationSec, onStarted) { const targetRate = 16000; const chunks = []; let ac; let source; let proc; try { try { ac = new AudioContext({ sampleRate: targetRate }); } catch { ac = new AudioContext(); } const inRate = ac.sampleRate; source = ac.createMediaStreamSource(stream); const bufferSize = 4096; proc = ac.createScriptProcessor(bufferSize, 1, 1); proc.onaudioprocess = (e) => { const ch0 = e.inputBuffer.getChannelData(0); chunks.push(new Float32Array(ch0)); }; source.connect(proc); proc.connect(ac.destination); if (ac.state === "suspended") { await ac.resume(); } if (typeof onStarted === "function") { onStarted(); } await sleep(Math.max(0, durationSec) * 1000); let total = 0; for (const c of chunks) total += c.length; const merged = new Float32Array(total); let o = 0; for (const c of chunks) { merged.set(c, o); o += c.length; } const atTarget = resampleFloat32(merged, inRate, targetRate); const pcm = floatToPcmS16le(atTarget); return pcmS16leToWav(pcm, targetRate); } finally { try { proc && proc.disconnect(); } catch { /* */ } try { source && source.disconnect(); } catch { /* */ } try { stream.getTracks().forEach((t) => t.stop()); } catch { /* */ } if (ac) { try { await ac.close(); } catch { /* */ } } } } /** * 录制指定秒数 16kHz mono 16-bit PCM WAV(优先 AudioContext 16k,否则重采样) */ async function recordWav16kMono(durationSec, onStarted) { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); return await recordWav16kFromStream(stream, durationSec, onStarted); } function mp3BlobFromBase64(b64) { const raw = (b64 || "").replace(/\s/g, ""); if (!raw) throw new Error("empty prompt_audio_mp3_base64"); const binary = atob(raw); const u8 = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) u8[i] = binary.charCodeAt(i); return new Blob([u8], { type: "audio/mpeg" }); } /** 用于日志:去掉超长 base64 */ function summarizePendingForLog(obj) { if (!obj || typeof obj !== "object") return String(obj); const o = { ...obj }; const b64 = o.prompt_audio_mp3_base64; if (typeof b64 === "string" && b64.length > 72) { o.prompt_audio_mp3_base64 = `[base64 ${b64.length} chars]`; } try { return JSON.stringify(o); } catch { return "[object]"; } } function playMp3FromBase64(b64) { let blob; try { blob = mp3BlobFromBase64(b64); } catch (e) { return Promise.reject(e); } const url = URL.createObjectURL(blob); return new Promise((resolve, reject) => { const audio = new Audio(); audio.preload = "auto"; audio.onended = () => { URL.revokeObjectURL(url); resolve(); }; audio.onerror = () => { URL.revokeObjectURL(url); reject(new Error("audio play failed")); }; audio.src = url; audio.play().catch((err) => { URL.revokeObjectURL(url); reject(new Error(formatAudioPlayError(err))); }); }); } async function postResolve(baseUrl, surgeryId, confirmationId, wavBytes, onLog) { const log = typeof onLog === "function" ? onLog : () => {}; const enc = encodeURIComponent(confirmationId); const u = new URL( `client/surgeries/${surgeryId}/pending-confirmation/${enc}/resolve`, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/" ); log("POST resolve → " + u.toString() + " (WAV " + wavBytes.byteLength + " 字节)"); const form = new FormData(); form.append("audio", new Blob([wavBytes], { type: "audio/wav" }), "voice.wav"); const res = await fetch(u.toString(), { method: "POST", body: form }); const text = await res.text(); let body = text; try { body = text ? JSON.parse(text) : {}; } catch { /* keep string */ } const preview = typeof body === "string" ? body.length > 600 ? body.slice(0, 600) + "…" : body : JSON.stringify(body); log("POST resolve ← HTTP " + res.status + " " + preview); return { status: res.status, body }; } // --- 状态机(对齐 monitor_worker.MonitorWorker)--- class VoiceMonitorEngine { constructor(hooks) { this.onLog = hooks.onLog; this.onState = hooks.onState; this.onPending = hooks.onPending; this.onResolveResult = hooks.onResolveResult; this.settings = { base_url: inferDefaultApiBase(), surgery_id: "", record_seconds: 8, dry_run: false, }; this._state = { generation: 0, busy: false, spoken_cid: null, failed_resolve_cid: null, force_retry: false, last_payload: null, }; this._stop = false; this._monitoring = false; this._wsEventQueue = []; this._wakeResolvers = []; } injectWsPending(payload) { this._wsEventQueue.push(["pending", { ...payload }]); this._wake(); } injectWsPendingEmpty(surgeryId) { this._wsEventQueue.push(["empty", (surgeryId || "").trim()]); this._wake(); } _drainWsEvents(surgeryId) { const requeue = []; let lastKind = "none"; let lastBody = null; for (const [kind, data] of this._wsEventQueue) { if (kind === "pending") { const row = { ...data }; if (String(row.surgery_id || "") !== surgeryId) { requeue.push([kind, data]); continue; } delete row.type; lastKind = "payload"; lastBody = row; } else if (kind === "empty") { if (String(data) !== surgeryId) { requeue.push([kind, data]); continue; } lastKind = "empty"; lastBody = null; } } this._wsEventQueue = requeue; return [lastKind, lastBody]; } setSettings(updates) { const oldSid = this.settings.surgery_id; for (const [k, v] of Object.entries(updates)) { if (k in this.settings) this.settings[k] = v; } const sidChanged = Object.prototype.hasOwnProperty.call(updates, "surgery_id") && this.settings.surgery_id !== oldSid; this._state.generation += 1; if (sidChanged) { this._state.spoken_cid = null; this._state.failed_resolve_cid = null; this._state.last_payload = null; this._state.force_retry = false; this._emitPending(null); } this._wake(); } setMonitoring(active) { this._monitoring = !!active; if (active) { this._log("监控已开启 surgery_id=" + JSON.stringify(this.settings.surgery_id)); } else { this._log("监控已关闭"); this._state.generation += 1; } this._wake(); } stop() { this._stop = true; this._wake(); } retryFailed() { this._state.force_retry = true; this._wake(); } _log(msg) { if (this.onLog) this.onLog(msg); } _emitState(s) { if (this.onState) this.onState(s); } _onPending(p) { if (this.onPending) this.onPending(p); } _wake() { const rs = this._wakeResolvers.splice(0); rs.forEach((r) => r()); } async _waitForWake() { await new Promise((resolve) => { this._wakeResolvers.push(resolve); }); } _emitPending(p) { this._onPending(p); } /** 轮询队首时若正在播报/录音/上传,勿注入 empty 以免打乱状态机。 */ isBusy() { return !!this._state.busy; } isMonitoring() { return !!this._monitoring; } _emitResolveResult(payload) { if (this.onResolveResult) this.onResolveResult(payload); } async _replayPromptJob() { const p = this._state.last_payload; if (!p) { this._log("没有可重播的待确认数据"); return; } const b64 = p.prompt_audio_mp3_base64 || ""; if (!b64) { this._log("当前任务无 MP3 数据"); return; } this._emitState("播放话术(手动重播)…"); try { await playMp3FromBase64(b64); } catch (e) { this._log("重播失败: " + e); } finally { this._emitState("待机"); } } replayPromptOnly() { this._replayPromptJob(); } async runLoop() { while (!this._stop) { if (!this._monitoring) { await sleep(150); continue; } const cfg = { ...this.settings }; if (!/^\d{6}$/.test(cfg.surgery_id || "")) { this._emitState("手术号无效(需 6 位数字)"); await new Promise((r) => setTimeout(r, 1000)); continue; } if (this._state.busy) { await sleep(500); continue; } const genBefore = this._state.generation; const [wsKind, wsBody] = this._drainWsEvents(cfg.surgery_id); if (wsKind === "empty") { if (this._state.generation !== genBefore) continue; if (this._state.busy) continue; this._state.last_payload = null; this._state.spoken_cid = null; this._state.failed_resolve_cid = null; this._emitPending(null); this._emitState("待机(无待确认)"); await this._waitForWake(); continue; } let body = null; if (wsKind === "payload" && wsBody) body = wsBody; if (body == null) { const lp = this._state.last_payload; const failed = this._state.failed_resolve_cid; const force = this._state.force_retry; const cidLp = typeof lp === "object" && lp ? String(lp.confirmation_id || "") : ""; if (lp && typeof lp === "object" && force && failed === cidLp) { body = { ...lp }; } else if (lp && typeof lp === "object" && failed === cidLp && !force) { this._emitPending(lp); this._emitState("请重试录音或检查麦克风"); await this._waitForWake(); continue; } else if (lp && typeof lp === "object" && failed == null && !force && this._state.spoken_cid === cidLp && cidLp) { this._emitPending(lp); this._emitState("待机(等待 WebSocket 推送)"); await this._waitForWake(); continue; } } if (body == null) { this._emitState("待机(等待 WebSocket 推送)"); await this._waitForWake(); continue; } if (this._state.generation !== genBefore) continue; if (this._state.busy) continue; const cid = String(body.confirmation_id || ""); if (!cid) { await this._waitForWake(); continue; } this._state.last_payload = body; let failed = this._state.failed_resolve_cid; const force = this._state.force_retry; const spoken = this._state.spoken_cid; if (failed != null && failed !== cid) { this._state.failed_resolve_cid = null; this._state.force_retry = false; failed = null; } if (failed === cid && !force) { this._emitPending(body); await this._waitForWake(); continue; } if (spoken === cid && failed == null && !force) { this._emitPending(body); await this._waitForWake(); continue; } this._state.force_retry = false; this._state.busy = true; this._state.spoken_cid = cid; const qn = body.pending_queue_length; if (typeof qn === "number" && qn > 1) { this._log("待确认队列共 " + qn + " 条(按 FIFO 队首依次处理)"); } this._emitPending(body); let genRun; try { genRun = this._state.generation; await this._pipelinePlayRecordResolve(cfg, body, cid, genRun); } finally { this._state.busy = false; } } } async _pipelinePlayRecordResolve(cfg, body, cid, genRun) { let wav; const log = (msg) => this._log(msg); try { if (!htmlAudioPlaybackUnlocked) { log( "尚未检测到页面音频解锁:请先在本页任意处点击或按键一次,否则浏览器通常会静默拦截话术播报。", ); } const sec = Math.max(0.5, Number(cfg.record_seconds) || 0); log("流程:先完整播报 TTS,再录制 " + sec + " 秒回答音频"); this._emitState("播报提示中…"); await playMp3FromBase64(String(body.prompt_audio_mp3_base64 || "")); if (this._state.generation !== genRun) return; this._emitState("播报已结束,正在打开麦克风…"); wav = await recordWav16kMono(sec, () => { this._emitState("录音中…"); }); } catch (e) { this._log("播报/录音失败: " + e); this._state.failed_resolve_cid = cid; const msg = String(e || ""); this._emitState( msg.includes("empty") || msg.includes("empty prompt") ? "无 TTS 音频(可重试)" : "播放或录音失败(可重试)", ); return; } if (this._state.generation !== genRun) return; if (cfg.dry_run) { this._log("[dry-run] 已录音 " + wav.length + " 字节,跳过上传"); this._state.failed_resolve_cid = null; this._state.spoken_cid = null; this._state.generation += 1; this._emitState("待机(dry-run)"); this._emitResolveResult({ httpStatus: null, surgery_id: cfg.surgery_id, confirmation_id: cid, body: { note: "未请求服务端(dry-run)", recorded_bytes: wav.length }, }); return; } try { this._emitState("上传识别…"); const { status: st, body: res } = await postResolve( cfg.base_url, cfg.surgery_id, cid, wav, (msg) => this._log(msg), ); const baseMeta = { httpStatus: st, surgery_id: cfg.surgery_id, confirmation_id: cid }; if (st === 200 && typeof res === "object" && res) { const rstatus = res.status; if (rstatus === "accepted") { this._emitResolveResult({ ...baseMeta, body: res }); this._log("已确认: " + (res.message || "") + " (resolved_label=" + JSON.stringify(res.resolved_label) + ")"); this._state.failed_resolve_cid = null; this._state.spoken_cid = null; this._state.last_payload = null; this._state.generation += 1; this._emitPending(null); this._emitState("待机"); return; } if (rstatus === "failed") { this._emitResolveResult({ ...baseMeta, body: res }); const code = res.error_code || ""; const msg = String(res.message || ""); this._log("语音未通过(可重试)" + (code ? "[" + code + "] " : "") + msg); this._state.failed_resolve_cid = cid; this._emitState("请重试录音或检查麦克风"); return; } } if (st === 422 && typeof res === "object" && res && res.detail) { const d = res.detail; if (typeof d === "object" && d.code) { const c = d.code; if (c === "VOICE_ASR_FAILED" || c === "VOICE_TEXT_EMPTY" || c === "VOICE_PARSE_FAILED") { this._emitResolveResult({ ...baseMeta, body: res }); this._log("语音未通过(可重试,旧接口)[" + c + "]: " + (d.message || "")); this._state.failed_resolve_cid = cid; this._emitState("请重试录音或检查麦克风"); return; } } } this._emitResolveResult({ ...baseMeta, body: res }); this._log("resolve 未接受 HTTP " + st + ": " + JSON.stringify(res)); this._state.failed_resolve_cid = cid; this._emitState("解析/上传被拒(可重试)"); } catch (e) { this._emitResolveResult({ httpStatus: null, surgery_id: cfg.surgery_id, confirmation_id: cid, body: null, error: String(e), }); this._log("POST resolve 失败: " + e); this._state.failed_resolve_cid = cid; this._emitState("上传失败(可重试)"); } } } // --- 全局 wiring --- let ws = null; let wsManualClose = false; let assignedSurgeryId = ""; /** 避免同一待机文案刷屏 */ let lastLoggedState = ""; /** HTTP 轮询队首:与 WebSocket 并存,避免仅连上 WS 但无指派时永远是「待机」。 */ let pollTimer = null; let lastPollCid = null; const MAX_LOG_LINES = 800; function appendLog(msg) { const el = document.getElementById("log"); if (!el) return; const t = new Date().toLocaleTimeString(); const line = `[${t}] ${msg}`; const cur = el.textContent ? el.textContent + "\n" + line : line; const lines = cur.split("\n"); const trimmed = lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES).join("\n") + "\n…(更早日志已截断)" : cur; el.textContent = trimmed; el.scrollTop = el.scrollHeight; } function clearLog() { const el = document.getElementById("log"); if (!el) return; el.textContent = ""; } function setStatus(s) { const raw = s == null ? "" : String(s); const rawEl = document.getElementById("statusRaw"); if (rawEl) rawEl.textContent = raw; const clinicalEl = document.getElementById("statusClinical"); if (clinicalEl) clinicalEl.textContent = toClinicalStatus(raw); } function updateVoiceActivityBanner(state) { const banner = document.getElementById("recBanner"); if (!banner) return; const t = String(state || ""); const playingPrompt = t.includes("播报提示") || t.includes("播放话术") || t.includes("播报中") || t.includes("TTS"); const preparingRecord = t.includes("打开麦克风") || t.includes("准备录音"); const recording = t.includes("录音中"); const visible = playingPrompt || preparingRecord || recording; banner.classList.toggle("hidden", !visible); banner.classList.toggle("activity-banner-active", visible); if (!visible) return; const title = document.getElementById("activityTitle"); const desc = document.getElementById("activityDesc"); const dot = document.getElementById("activityDot"); const bars = Array.from(document.querySelectorAll("#recWave .activity-wave-bar")); let barClasses = ["bg-blue-400/90", "bg-blue-400", "bg-blue-300", "bg-blue-200", "bg-blue-300", "bg-blue-400", "bg-blue-400/90"]; if (preparingRecord) { barClasses = [ "bg-amber-400/90", "bg-amber-400", "bg-amber-300", "bg-amber-200", "bg-amber-300", "bg-amber-400", "bg-amber-400/90", ]; } if (recording) { barClasses = [ "bg-red-400/90", "bg-red-400", "bg-red-300", "bg-red-200", "bg-red-300", "bg-red-400", "bg-red-400/90", ]; } bars.forEach((bar, idx) => { bar.className = "activity-wave-bar " + (barClasses[idx] || barClasses[0]); }); if (recording) { banner.className = "activity-banner activity-banner-active mb-3 overflow-hidden rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-center"; banner.setAttribute("aria-label", "正在录音"); if (dot) dot.className = "activity-dot mr-1 inline-block text-red-600"; if (title) { title.className = "text-sm font-semibold text-red-900"; title.textContent = "正在采集语音"; if (dot) title.prepend(dot); } if (desc) { desc.className = "mt-1 text-xs leading-relaxed text-red-800"; desc.textContent = "提示音已结束,请对着麦克风清楚回答"; } return; } if (preparingRecord) { banner.className = "activity-banner activity-banner-active mb-3 overflow-hidden rounded-xl border border-amber-200 bg-amber-50 px-4 py-4 text-center"; banner.setAttribute("aria-label", "正在准备录音"); if (dot) dot.className = "activity-dot mr-1 inline-block text-amber-600"; if (title) { title.className = "text-sm font-semibold text-amber-900"; title.textContent = "正在打开麦克风"; if (dot) title.prepend(dot); } if (desc) { desc.className = "mt-1 text-xs leading-relaxed text-amber-800"; desc.textContent = "提示音已结束,系统正在准备录音,请稍候"; } return; } banner.className = "activity-banner activity-banner-active mb-3 overflow-hidden rounded-xl border border-blue-200 bg-blue-50 px-4 py-4 text-center"; banner.setAttribute("aria-label", "正在播报提示"); if (dot) dot.className = "activity-dot mr-1 inline-block text-blue-600"; if (title) { title.className = "text-sm font-semibold text-blue-900"; title.textContent = "正在播报提示"; if (dot) title.prepend(dot); } if (desc) { desc.className = "mt-1 text-xs leading-relaxed text-blue-800"; desc.textContent = "请先听完提示音,播报结束后系统才会开始录音"; } } function updateQueueHint(p) { const posEl = document.getElementById("queuePositionHint"); const cumEl = document.getElementById("cumulativeHint"); const qc = document.getElementById("queueClinical"); if (p == null) { if (posEl) posEl.textContent = "无待确认"; if (cumEl) cumEl.textContent = "无待确认"; if (qc) qc.textContent = "暂无排队信息"; return; } const n = p.pending_queue_length; const pos = p.pending_queue_position; const cum = p.pending_cumulative_ordinal; const nOk = typeof n === "number" && n >= 1; const posOk = typeof pos === "number" && pos >= 1; const cumOk = typeof cum === "number" && cum >= 1; if (posEl) { if (posOk && nOk) { posEl.textContent = `第 ${pos} / 共 ${n} 条(FIFO 内排队)`; } else if (nOk) { posEl.textContent = `共 ${n} 条待确认(服务端未给 pending_queue_position 时仅显示条数)`; } else { posEl.textContent = "待确认"; } } if (cumEl) { cumEl.textContent = cumOk ? `第 ${cum} 条(本场手术累计入队序号)` : "待确认(服务端未返回 pending_cumulative_ordinal)"; } if (qc) { let line = ""; if (posOk && nOk) { line = `当前排队:第 ${pos} 条,共 ${n} 条`; } else if (nOk) { line = `当前共有 ${n} 条待确认`; } else { line = "有待确认项目"; } if (cumOk) { line += `;本场累计第 ${cum} 条`; } qc.textContent = line; } } const RESULT_CLINICAL_BASE = "rounded-xl border px-4 py-3 text-sm leading-relaxed shadow-sm"; function setResolveResultDisplay(payload) { const el = document.getElementById("resolveResult"); const clinical = document.getElementById("resultClinical"); const emptyPayload = payload == null || (typeof payload === "object" && Object.keys(payload).length === 0); if (emptyPayload) { if (el) el.textContent = "—"; if (clinical) { clinical.textContent = "尚无确认结果"; clinical.className = RESULT_CLINICAL_BASE + " border-slate-200 bg-white text-slate-700"; } return; } if (el) el.textContent = JSON.stringify(payload, null, 2); if (!clinical) return; if (payload.error && payload.body == null) { clinical.textContent = "提交失败:" + String(payload.error); clinical.className = RESULT_CLINICAL_BASE + " border-amber-200 bg-amber-50 text-amber-950"; return; } const body = payload.body; if (body && typeof body === "object" && body.note === "未请求服务端(dry-run)") { clinical.textContent = "调试:已录音但未上传"; clinical.className = RESULT_CLINICAL_BASE + " border-slate-300 bg-slate-100 text-slate-800"; return; } const st = body && typeof body === "object" ? body.status : null; const msg = body && typeof body === "object" && body.message != null ? String(body.message) : ""; const http = payload.httpStatus; if (st === "accepted") { clinical.textContent = msg ? "成功:" + msg : "成功:已确认"; clinical.className = RESULT_CLINICAL_BASE + " border-emerald-200 bg-emerald-50 text-emerald-950"; } else if (st === "failed") { clinical.textContent = msg ? "未通过:" + msg : "未通过:请按提示重试"; clinical.className = RESULT_CLINICAL_BASE + " border-amber-200 bg-amber-50 text-amber-950"; } else if (http != null && http !== 200) { clinical.textContent = "服务器返回异常(HTTP " + http + "),请展开「高级设置」查看详情或联系信息科"; clinical.className = RESULT_CLINICAL_BASE + " border-amber-200 bg-amber-50 text-amber-950"; } else { clinical.textContent = msg || "结果已返回,详情请展开高级设置"; clinical.className = RESULT_CLINICAL_BASE + " border-slate-200 bg-white text-slate-800"; } } function stopPendingPoll() { if (pollTimer != null) { clearInterval(pollTimer); pollTimer = null; } lastPollCid = null; } async function tickPendingPoll() { const base = document.getElementById("baseUrl")?.value?.trim() || ""; const sid = assignedSurgeryId; if (!base || !sid || !/^\d{6}$/.test(sid) || !engine.isMonitoring()) { return; } if (engine.isBusy()) { return; } const u = new URL( `client/surgeries/${sid}/pending-confirmation`, base.endsWith("/") ? base : base + "/", ); let res; try { res = await fetch(u.toString(), { method: "GET", credentials: "omit" }); } catch (e) { appendLog("GET 队首失败(网络): " + e); return; } if (res.status === 200) { let data; try { data = await res.json(); } catch { appendLog("GET 队首:响应非 JSON"); return; } const cid = data && typeof data === "object" ? String(data.confirmation_id || "") : ""; if (!cid) { return; } if (cid === lastPollCid) { return; } lastPollCid = cid; engine.injectWsPending({ type: "voice_pending", surgery_id: sid, ...data }); appendLog("[HTTP 轮询] 队首 confirmation_id=" + JSON.stringify(cid)); return; } if (res.status === 404) { if (lastPollCid == null) { return; } lastPollCid = null; engine.injectWsPendingEmpty(sid); appendLog("[HTTP 轮询] 当前无队首待确认(404)"); return; } appendLog("GET 队首 HTTP " + res.status); } function startPendingPoll() { stopPendingPoll(); const tick = async () => { try { await tickPendingPoll(); } catch (e) { appendLog("轮询队首异常: " + e); } }; pollTimer = setInterval(tick, 2500); tick(); } function beginMonitoringWithSurgeryId(sid, extraLog) { if (!/^\d{6}$/.test(sid)) { return false; } assignedSurgeryId = sid; updateTitle(); applySettings(); engine.setMonitoring(true); document.getElementById("btnStop")?.removeAttribute("disabled"); startPendingPoll(); if (extraLog) { appendLog(extraLog); } return true; } function startMonitoringManual() { const raw = document.getElementById("manualSurgeryId")?.value?.trim() || ""; if (!/^\d{6}$/.test(raw)) { appendLog("手动开始:请输入 6 位数字手术号"); setStatus("手术号需为 6 位数字"); return; } saveForm(); const ok = beginMonitoringWithSurgeryId( raw, "已手动开始监控手术 " + raw + "(HTTP 轮询队首,可与 WebSocket 同时使用)", ); if (ok) { setStatus("监控中(手动手术号)"); } } /** Demo 默认 camera 绑定在 desktop-1;若仅连上 WS 未点开始监控,且已填手术号,则自动拉队首避免「只有连线、没有队列」。 */ function tryAutoStartMonitoringFromManualField() { const raw = document.getElementById("manualSurgeryId")?.value?.trim() || ""; if (!/^\d{6}$/.test(raw)) { return; } if (engine.isMonitoring()) { return; } saveForm(); beginMonitoringWithSurgeryId( raw, "[提示] WebSocket 已连接:已用手动手术号 " + raw + " 自动开始监控并拉队首。若仍无待确认,请核对与 Demo「开始手术」的手术号一致,且 OR_SITE_CONFIG 中本机终端 ID 与开录 camera 为同一绑定组。", ); setStatus("监控中(手动手术号,已自动启用)"); } function warnIfApiBaseLooksLikeStaticPage() { const base = document.getElementById("baseUrl")?.value?.trim() || ""; try { const bu = new URL(base); if (bu.origin === location.origin) { appendLog( "[提示] 「服务端 Base URL」与当前页面同源(都是 " + bu.origin + ")。静态页一般在 8080,FastAPI 一般在 38080;请把 Base URL 改成 API 地址,否则无法录音与拉取待确认。", ); setStatus("请把 Base URL 指向 API(常见为 :38080),不要填本页 :8080"); } } catch { /* ignore */ } } const engine = new VoiceMonitorEngine({ onLog: (m) => appendLog(m), onState: (s) => { if (s !== lastLoggedState) { lastLoggedState = s; appendLog("[状态] " + s); } setStatus(s); updateVoiceActivityBanner(s); }, onPending: (p) => { const el = document.getElementById("pendingJson"); if (el) { if (p == null) el.textContent = "—"; else el.textContent = JSON.stringify(p, null, 2); } updateQueueHint(p); if (p && typeof p === "object") { appendLog( "[队首队列字段] pending_queue_position=" + JSON.stringify(p.pending_queue_position) + " pending_queue_length=" + JSON.stringify(p.pending_queue_length) + " pending_cumulative_ordinal=" + JSON.stringify(p.pending_cumulative_ordinal), ); } }, onResolveResult: (payload) => setResolveResultDisplay(payload), }); function loadForm() { const resolvedBase = resolveInitialApiBase(localStorage.getItem(LS_BASE)); const base = resolvedBase.base; const tid = localStorage.getItem(LS_TID) || ""; const sec = localStorage.getItem(LS_SEC) || "8"; const auto = localStorage.getItem(LS_AUTO) !== "0"; const dry = localStorage.getItem(LS_DRY) === "1"; const manualSid = localStorage.getItem(LS_MANUAL_SID) || ""; const baseEl = document.getElementById("baseUrl"); const tidEl = document.getElementById("terminalId"); const secEl = document.getElementById("recordSec"); const autoEl = document.getElementById("autoAssign"); const dryEl = document.getElementById("dryRun"); const manualEl = document.getElementById("manualSurgeryId"); if (baseEl) baseEl.value = base; if (tidEl) tidEl.value = tid; if (secEl) secEl.value = sec; if (autoEl) autoEl.checked = auto; if (dryEl) dryEl.checked = dry; if (manualEl) manualEl.value = manualSid; applySettings(); if (resolvedBase.note) { appendLog(resolvedBase.note); } } function saveForm() { const base = document.getElementById("baseUrl")?.value?.trim() || ""; const tid = document.getElementById("terminalId")?.value?.trim() || ""; const sec = document.getElementById("recordSec")?.value || "8"; const auto = document.getElementById("autoAssign")?.checked ?? true; const dry = document.getElementById("dryRun")?.checked ?? false; const manualSid = document.getElementById("manualSurgeryId")?.value?.trim() || ""; localStorage.setItem(LS_BASE, base); localStorage.setItem(LS_TID, tid); localStorage.setItem(LS_SEC, sec); localStorage.setItem(LS_AUTO, auto ? "1" : "0"); localStorage.setItem(LS_DRY, dry ? "1" : "0"); localStorage.setItem(LS_MANUAL_SID, manualSid); } function applySettings() { saveForm(); const base = document.getElementById("baseUrl")?.value?.trim() || ""; const rawSec = document.getElementById("recordSec")?.value ?? "8"; const parsedSec = parseFloat(rawSec); const recordSec = Number.isFinite(parsedSec) ? Math.max(0, parsedSec) : 8; const dry = document.getElementById("dryRun")?.checked ?? false; engine.setSettings({ base_url: base, surgery_id: assignedSurgeryId, record_seconds: recordSec, dry_run: dry, }); } function connectWs() { if (ws) { wsManualClose = true; try { ws.close(); } catch { /* ignore */ } ws = null; } const auto = document.getElementById("autoAssign")?.checked ?? true; if (!auto) { return; } const base = document.getElementById("baseUrl")?.value?.trim() || ""; const tid = document.getElementById("terminalId")?.value?.trim() || ""; if (!tid || !base) { appendLog("未配置 Base URL 或终端 ID,跳过 WebSocket"); return; } const url = buildVoiceWsUrl(base, tid); if (!url) { appendLog("无效的 Base URL"); return; } wsManualClose = false; appendLog("正在连接 " + url); try { ws = new WebSocket(url); } catch (e) { appendLog("WebSocket 构造失败: " + e); return; } ws.onopen = () => { appendLog("WebSocket 已连接 terminal_id=" + JSON.stringify(tid)); tryAutoStartMonitoringFromManualField(); }; ws.onclose = (ev) => { appendLog("WebSocket 断开 code=" + ev.code); ws = null; if (!wsManualClose && document.getElementById("autoAssign")?.checked) { setTimeout(connectWs, 2000); } }; ws.onerror = () => appendLog("WebSocket 错误"); ws.onmessage = (ev) => { let data; try { data = JSON.parse(ev.data); } catch { appendLog("[WS] 非 JSON 帧已忽略"); return; } if (!data || typeof data !== "object") return; const t = data.type; if (t === "voice_pending") { const pcid = String(data.confirmation_id || ""); if (pcid) lastPollCid = pcid; appendLog("[WS] voice_pending " + summarizePendingForLog(data)); engine.injectWsPending(data); return; } if (t === "voice_pending_empty") { lastPollCid = null; appendLog( "[WS] voice_pending_empty surgery_id=" + JSON.stringify(data.surgery_id || ""), ); engine.injectWsPendingEmpty(String(data.surgery_id || "")); return; } if (t === "voice_assignment") { appendLog("[WS] voice_assignment " + JSON.stringify({ action: data.action, surgery_id: data.surgery_id })); const action = data.action; const sid = String(data.surgery_id || ""); if (action === "start") { if (sid.length !== 6 || !/^\d{6}$/.test(sid)) { appendLog("服务端指派无效手术号: " + JSON.stringify(sid)); return; } assignedSurgeryId = sid; updateTitle(); applySettings(); engine.setMonitoring(true); const btn = document.getElementById("btnStop"); if (btn) btn.removeAttribute("disabled"); const mid = document.getElementById("manualSurgeryId"); if (mid) mid.value = sid; saveForm(); startPendingPoll(); appendLog("服务端已指派手术 " + sid + ",已自动开始监控"); return; } if (action === "end") { stopPendingPoll(); engine.setMonitoring(false); assignedSurgeryId = ""; updateTitle(); applySettings(); setStatus("已停止(服务端结束)"); const btn = document.getElementById("btnStop"); if (btn) btn.setAttribute("disabled", "disabled"); appendLog("服务端已结束手术 " + sid + ",已自动停止监控"); } return; } appendLog("[WS] 未知类型 " + JSON.stringify(data)); }; } function updateTitle() { const t = document.getElementById("pageTitle"); if (t) t.textContent = assignedSurgeryId ? "语音确认 — 手术 " + assignedSurgeryId : "语音确认"; updateSurgeryDisplay(); } function onAutoAssignChange() { if (!document.getElementById("autoAssign")?.checked) { stopPendingPoll(); engine.setMonitoring(false); assignedSurgeryId = ""; updateTitle(); document.getElementById("btnStop")?.setAttribute("disabled", "disabled"); applySettings(); setStatus("已关闭自动指派"); if (ws) { wsManualClose = true; try { ws.close(); } catch { /* ignore */ } ws = null; } } else { connectWs(); } } function stopMonitoringLocal() { stopPendingPoll(); engine.setMonitoring(false); assignedSurgeryId = ""; updateTitle(); document.getElementById("btnStop")?.setAttribute("disabled", "disabled"); applySettings(); appendLog("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——"); setStatus("已停止(本地)"); } function init() { loadForm(); lastLoggedState = ""; updateQueueHint(null); setResolveResultDisplay(null); setStatus("待机"); wireAdvancedPanel(); engine .runLoop() .catch((e) => appendLog("runLoop: " + e)); warnIfApiBaseLooksLikeStaticPage(); if (document.getElementById("autoAssign")?.checked) { connectWs(); } else { applySettings(); } const base = document.getElementById("baseUrl"); const tid = document.getElementById("terminalId"); const rec = document.getElementById("recordSec"); const manual = document.getElementById("manualSurgeryId"); const auto = document.getElementById("autoAssign"); [base, tid, rec, manual].forEach((el) => { if (el) { el.addEventListener("change", () => { saveForm(); applySettings(); if (auto?.checked) connectWs(); }); } }); if (auto) auto.addEventListener("change", onAutoAssignChange); document.getElementById("dryRun")?.addEventListener("change", () => { saveForm(); applySettings(); }); document.getElementById("btnStop")?.addEventListener("click", stopMonitoringLocal); document.getElementById("btnStartManual")?.addEventListener("click", startMonitoringManual); document.getElementById("btnRetry")?.addEventListener("click", () => engine.retryFailed()); document.getElementById("btnReplay")?.addEventListener("click", () => engine.replayPromptOnly()); document.getElementById("btnClearLog")?.addEventListener("click", clearLog); wireAudioPlaybackUnlock(); window.addEventListener("beforeunload", () => { engine.stop(); stopPendingPoll(); wsManualClose = true; if (ws) try { ws.close(); } catch { /* */ } }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); }