ver0.1
This commit is contained in:
952
web/voice-confirmation/voice_app.js
Normal file
952
web/voice-confirmation/voice_app.js
Normal file
@@ -0,0 +1,952 @@
|
||||
/**
|
||||
* 手术室语音确认 — 浏览器端(与桌面版历史 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();
|
||||
}
|
||||
Reference in New Issue
Block a user