953 lines
28 KiB
JavaScript
953 lines
28 KiB
JavaScript
/**
|
||
* 手术室语音确认 — 浏览器端(与桌面版历史 MonitorWorker 状态机行为对齐)
|
||
* 仅 WebSocket 收 voice_pending / voice_pending_empty;HTTP 仅 POST resolve。
|
||
*/
|
||
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";
|
||
|
||
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) {
|
||
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();
|
||
}
|
||
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) {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
return await recordWav16kFromStream(stream, durationSec);
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
function getMp3DurationSecFromObjectUrl(objectUrl) {
|
||
return new Promise((resolve, reject) => {
|
||
const probe = new Audio();
|
||
probe.preload = "metadata";
|
||
const fail = (msg) => {
|
||
try {
|
||
probe.onloadedmetadata = null;
|
||
probe.onerror = null;
|
||
probe.src = "";
|
||
probe.remove?.();
|
||
} catch {
|
||
/* */
|
||
}
|
||
reject(new Error(msg));
|
||
};
|
||
probe.onloadedmetadata = () => {
|
||
const d = probe.duration;
|
||
try {
|
||
probe.onloadedmetadata = null;
|
||
probe.onerror = null;
|
||
probe.src = "";
|
||
probe.remove?.();
|
||
} catch {
|
||
/* */
|
||
}
|
||
if (!Number.isFinite(d) || d <= 0) {
|
||
fail("invalid TTS duration");
|
||
return;
|
||
}
|
||
resolve(d);
|
||
};
|
||
probe.onerror = () => fail("TTS metadata load failed");
|
||
probe.src = objectUrl;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 与 TTS 同步采音:先 await 麦克风与图,再并行 play 与采集。
|
||
* 总采集时长 = TTS 音轨时长 + postAfterTtsSec(播报结束后再多采几秒)。
|
||
* 元数据读失败时退化为先播后采(仅采 postAfterTtsSec 秒)。
|
||
*/
|
||
async function playTtsParallelRecord(b64, postAfterTtsSec) {
|
||
const post = Math.max(0, Number(postAfterTtsSec) || 0);
|
||
const blob = mp3BlobFromBase64(b64);
|
||
const url = URL.createObjectURL(blob);
|
||
let ttsSec;
|
||
try {
|
||
ttsSec = await getMp3DurationSecFromObjectUrl(url);
|
||
} catch {
|
||
URL.revokeObjectURL(url);
|
||
/* 元数据失败时无法与 TTS 对齐总时长,退化为先播后采 */
|
||
await playMp3FromBase64(b64);
|
||
return await recordWav16kMono(Math.max(2, post));
|
||
}
|
||
const totalSec = Math.max(2, ttsSec + post);
|
||
let stream;
|
||
try {
|
||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
} catch (e) {
|
||
URL.revokeObjectURL(url);
|
||
throw e;
|
||
}
|
||
/* 先挂采集 Promise(getUserMedia 已在此 await);内部 await ac.resume 在 microtask。
|
||
play() 用 setTimeout(0) 放到宏任务,避免在 resume 前执行,否则 onaudioprocess 不跑、WAV 全静音。 */
|
||
const recP = recordWav16kFromStream(stream, totalSec);
|
||
const playP = 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;
|
||
setTimeout(() => {
|
||
audio.play().catch((err) => {
|
||
URL.revokeObjectURL(url);
|
||
reject(err);
|
||
});
|
||
}, 0);
|
||
});
|
||
const settled = await Promise.allSettled([playP, recP]);
|
||
if (settled[0].status === "rejected") throw settled[0].reason;
|
||
if (settled[1].status === "rejected") throw settled[1].reason;
|
||
return settled[1].value;
|
||
}
|
||
|
||
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((e) => {
|
||
URL.revokeObjectURL(url);
|
||
reject(e);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function postResolve(baseUrl, surgeryId, confirmationId, wavBytes) {
|
||
const enc = encodeURIComponent(confirmationId);
|
||
const u = new URL(
|
||
`client/surgeries/${surgeryId}/pending-confirmation/${enc}/resolve`,
|
||
baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"
|
||
);
|
||
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 */
|
||
}
|
||
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: "http://127.0.0.1:38080",
|
||
surgery_id: "",
|
||
record_seconds: 5,
|
||
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);
|
||
}
|
||
|
||
_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;
|
||
try {
|
||
this._emitState("TTS 播报中,同时录音中…");
|
||
wav = await playTtsParallelRecord(
|
||
String(body.prompt_audio_mp3_base64 || ""),
|
||
cfg.record_seconds,
|
||
);
|
||
} 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);
|
||
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 = "";
|
||
|
||
function appendLog(msg) {
|
||
const el = document.getElementById("log");
|
||
if (!el) return;
|
||
const t = new Date().toLocaleTimeString();
|
||
el.textContent += (el.textContent ? "\n" : "") + `[${t}] ${msg}`;
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function setStatus(s) {
|
||
const el = document.getElementById("status");
|
||
if (el) el.textContent = s;
|
||
}
|
||
|
||
function updateQueueHint(p) {
|
||
const posEl = document.getElementById("queuePositionHint");
|
||
const cumEl = document.getElementById("cumulativeHint");
|
||
if (p == null) {
|
||
if (posEl) posEl.textContent = "无待确认";
|
||
if (cumEl) cumEl.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)";
|
||
}
|
||
}
|
||
|
||
function setResolveResultDisplay(payload) {
|
||
const el = document.getElementById("resolveResult");
|
||
if (!el) return;
|
||
el.textContent = JSON.stringify(payload, null, 2);
|
||
}
|
||
|
||
const engine = new VoiceMonitorEngine({
|
||
onLog: (m) => appendLog(m),
|
||
onState: (s) => {
|
||
setStatus(s);
|
||
const banner = document.getElementById("recBanner");
|
||
if (banner) {
|
||
const recording = String(s).includes("录音中");
|
||
banner.classList.toggle("hidden", !recording);
|
||
banner.classList.toggle("rec-banner-active", recording);
|
||
}
|
||
},
|
||
onPending: (p) => {
|
||
const el = document.getElementById("pendingJson");
|
||
if (el) el.textContent = p == null ? "" : JSON.stringify(p, null, 2);
|
||
updateQueueHint(p);
|
||
},
|
||
onResolveResult: (payload) => setResolveResultDisplay(payload),
|
||
});
|
||
|
||
function loadForm() {
|
||
const base = localStorage.getItem(LS_BASE) || "http://127.0.0.1:38080";
|
||
const tid = localStorage.getItem(LS_TID) || "";
|
||
const sec = localStorage.getItem(LS_SEC) || "5";
|
||
const auto = localStorage.getItem(LS_AUTO) !== "0";
|
||
const dry = localStorage.getItem(LS_DRY) === "1";
|
||
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");
|
||
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;
|
||
applySettings();
|
||
}
|
||
|
||
function saveForm() {
|
||
const base = document.getElementById("baseUrl")?.value?.trim() || "";
|
||
const tid = document.getElementById("terminalId")?.value?.trim() || "";
|
||
const sec = document.getElementById("recordSec")?.value || "5";
|
||
const auto = document.getElementById("autoAssign")?.checked ?? true;
|
||
const dry = document.getElementById("dryRun")?.checked ?? false;
|
||
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");
|
||
}
|
||
|
||
function applySettings() {
|
||
saveForm();
|
||
const base = document.getElementById("baseUrl")?.value?.trim() || "";
|
||
const recordSec = parseFloat(document.getElementById("recordSec")?.value || "5") || 5;
|
||
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) {
|
||
engine.setMonitoring(false);
|
||
assignedSurgeryId = "";
|
||
updateTitle();
|
||
document.getElementById("btnStop")?.setAttribute("disabled", "disabled");
|
||
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));
|
||
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 {
|
||
return;
|
||
}
|
||
if (!data || typeof data !== "object") return;
|
||
const t = data.type;
|
||
if (t === "voice_pending") {
|
||
engine.injectWsPending(data);
|
||
return;
|
||
}
|
||
if (t === "voice_pending_empty") {
|
||
engine.injectWsPendingEmpty(String(data.surgery_id || ""));
|
||
return;
|
||
}
|
||
if (t === "voice_assignment") {
|
||
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");
|
||
appendLog("服务端已指派手术 " + sid + ",已自动开始监控");
|
||
return;
|
||
}
|
||
if (action === "end") {
|
||
engine.setMonitoring(false);
|
||
assignedSurgeryId = "";
|
||
updateTitle();
|
||
applySettings();
|
||
setStatus("已停止(服务端结束)");
|
||
const btn = document.getElementById("btnStop");
|
||
if (btn) btn.setAttribute("disabled", "disabled");
|
||
appendLog("服务端已结束手术 " + sid + ",已自动停止监控");
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function updateTitle() {
|
||
const t = document.getElementById("pageTitle");
|
||
if (t) t.textContent = assignedSurgeryId ? "语音确认 — 手术 " + assignedSurgeryId : "语音确认";
|
||
}
|
||
|
||
function onAutoAssignChange() {
|
||
if (!document.getElementById("autoAssign")?.checked) {
|
||
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() {
|
||
engine.setMonitoring(false);
|
||
assignedSurgeryId = "";
|
||
updateTitle();
|
||
document.getElementById("btnStop")?.setAttribute("disabled", "disabled");
|
||
applySettings();
|
||
appendLog("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——");
|
||
setStatus("已停止(本地)");
|
||
}
|
||
|
||
function init() {
|
||
loadForm();
|
||
updateQueueHint(null);
|
||
engine
|
||
.runLoop()
|
||
.catch((e) => appendLog("runLoop: " + e));
|
||
if (document.getElementById("autoAssign")?.checked) {
|
||
connectWs();
|
||
} else {
|
||
applySettings();
|
||
}
|
||
const base = document.getElementById("baseUrl");
|
||
const tid = document.getElementById("terminalId");
|
||
const rec = document.getElementById("recordSec");
|
||
const auto = document.getElementById("autoAssign");
|
||
[base, tid, rec].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("btnRetry")?.addEventListener("click", () => engine.retryFailed());
|
||
document.getElementById("btnReplay")?.addEventListener("click", () => engine.replayPromptOnly());
|
||
window.addEventListener("beforeunload", () => {
|
||
engine.stop();
|
||
wsManualClose = true;
|
||
if (ws) try { ws.close(); } catch { /* */ }
|
||
});
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|