This commit is contained in:
Kevin
2026-04-28 10:41:48 +08:00
parent 482b016872
commit 15884bd68e
60 changed files with 2092 additions and 1994 deletions

View File

@@ -0,0 +1,36 @@
# 手术室耗材语音确认(浏览器端)
与 FastAPI **分宿部署**的纯静态页面HTML + JavaScript + [Tailwind CSS](https://tailwindcss.com) CDN配置 **服务端 Base URL****本机语音终端 ID** 即可使用,协议与 [`docs/客户端手术通信接口说明.md`](../../docs/客户端手术通信接口说明.md) 一致WebSocket 收 `voice_assignment` / `voice_pending` / `voice_pending_empty`HTTP `POST .../resolve` 上传 WAV
## 本地预览
勿使用 `file://` 打开(麦克风与跨域行为异常)。在仓库根目录先执行 `uv sync --group dev`**livereload** 在 dev 组中)。然后:
```bash
chmod +x start_voice_confirmation_web.sh # 仅首次
./start_voice_confirmation_web.sh # 默认 8080编辑 HTML/JS 后浏览器自动刷新
# 若不需要热重载(仅 stdlib 静态服务):
./start_voice_confirmation_web.sh --plain 8080
```
Windows`start_voice_confirmation_web.bat`;无热重载时首参 `--plain` 再跟端口。亦可:
```bash
uv run --group dev python scripts/dev_static_livereload.py --root web/voice-confirmation -p 8080
```
浏览器访问 `http://127.0.0.1:8080`(或所选端口),将 **Base URL** 指向监控 API`http://127.0.0.1:38080`)。
## 生产部署
- 将本目录原样部署到任意静态托管Nginx、对象存储、CDN 等)。
- **HTTPS**:非 localhost 下浏览器通常要求安全上下文才允许麦克风API 须使用 **https****wss**,避免混合内容被拦截。
- **CORS**:后端需对浏览器 `fetch` 放行;默认可开启环境变量 **`DEMO_CORS_ENABLED=true`**,并将 **`DEMO_CORS_ORIGINS`** 设为静态页来源(或联调期 `*`,见 [`main.py`](../../main.py))。
## 与 `scripts/demo_client` 的关系
本目录与 [`scripts/demo_client/`](../../scripts/demo_client/) **相互独立**Demo 含 HTTP 轮询待确认等联调能力;本页与官方语音确认语义一致,**不轮询** `GET .../pending-confirmation`
## 推荐浏览器
录音与 `AudioContext` 行为以 **Chrome / Edge 新版** 为优先验证目标。

View File

@@ -0,0 +1,259 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>手术室耗材 — 语音确认</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
panel: { DEFAULT: "#111827", muted: "#1f2937" },
},
},
},
};
</script>
<style>
@keyframes recBarWave {
0%,
100% {
transform: scaleY(0.22);
}
50% {
transform: scaleY(1);
}
}
@keyframes recPanelGlow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.2);
}
50% {
box-shadow: 0 0 20px 3px rgba(248, 113, 113, 0.35);
}
}
@keyframes recDotPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.rec-banner-active {
animation: recPanelGlow 1.4s ease-in-out infinite;
}
.rec-banner-active .rec-wave-bar {
display: inline-block;
width: 0.45rem;
min-height: 2.5rem;
height: 2.5rem;
transform-origin: bottom center;
will-change: transform;
border-radius: 0.2rem;
animation: recBarWave 0.52s ease-in-out infinite;
}
.rec-banner-active .rec-wave-bar:nth-child(1) {
animation-delay: 0ms;
}
.rec-banner-active .rec-wave-bar:nth-child(2) {
animation-delay: 0.1s;
}
.rec-banner-active .rec-wave-bar:nth-child(3) {
animation-delay: 0.2s;
}
.rec-banner-active .rec-wave-bar:nth-child(4) {
animation-delay: 0.05s;
}
.rec-banner-active .rec-wave-bar:nth-child(5) {
animation-delay: 0.28s;
}
.rec-banner-active .rec-wave-bar:nth-child(6) {
animation-delay: 0.15s;
}
.rec-banner-active .rec-wave-bar:nth-child(7) {
animation-delay: 0.08s;
}
.rec-dot {
animation: recDotPulse 0.9s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rec-banner-active {
animation: none;
}
.rec-banner-active .rec-wave-bar {
animation: none;
transform: scaleY(0.65);
}
.rec-dot {
animation: none;
opacity: 1;
}
}
</style>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 antialiased">
<div class="mx-auto max-w-5xl p-4 md:p-6">
<header class="mb-6 border-b border-slate-800 pb-4">
<h1 id="pageTitle" class="text-xl font-semibold tracking-tight text-sky-300">
语音确认
</h1>
<p class="mt-1 text-sm text-slate-400">
独立网页客户端:仅 WebSocket 收队首、HTTP 上传答复。请通过 http(s) 服务打开本页(勿用
<code class="text-slate-500">file://</code>)。生产环境请为静态页与 API 配置 HTTPS / WSS。
</p>
</header>
<div class="grid gap-4 lg:grid-cols-2">
<section class="rounded-xl border border-slate-800 bg-slate-900/80 p-4 shadow-lg">
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-sky-400">连接</h2>
<div class="space-y-3">
<div>
<label class="mb-1 block text-xs text-slate-500" for="baseUrl">服务端 Base URL</label>
<input
id="baseUrl"
type="url"
class="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
placeholder="http://127.0.0.1:38080"
autocomplete="off"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500" for="terminalId">本机语音终端 ID</label>
<input
id="terminalId"
type="text"
class="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm"
placeholder="与 OR_SITE_CONFIG 中 voice_terminal_id 一致"
autocomplete="off"
/>
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input id="autoAssign" type="checkbox" class="rounded border-slate-600 bg-slate-950 text-sky-500" />
启用服务端自动指派(开录后 WebSocket 连接并接收 <code class="text-xs">voice_assignment</code>
</label>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="mb-1 block text-xs text-slate-500" for="recordSec"
>TTS 起同时开录;本值=播完后再多采的秒数</label
>
<input
id="recordSec"
type="number"
min="2"
max="60"
step="0.5"
value="5"
class="w-32 rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm"
/>
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
<input id="dryRun" type="checkbox" class="rounded border-slate-600 bg-slate-950" />
Dry-run录音后不上传
</label>
</div>
</div>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/80 p-4 shadow-lg">
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-sky-400">操作</h2>
<p id="status" class="mb-3 rounded-lg bg-slate-950 px-3 py-2 font-mono text-sm text-amber-200/90">
待机
</p>
<div
id="recBanner"
class="rec-banner mb-3 hidden overflow-hidden rounded-lg border-2 border-red-500/80 bg-gradient-to-b from-red-950/95 to-red-900/80 px-3 py-4 text-center shadow-lg"
role="status"
aria-live="polite"
aria-label="正在录音"
>
<div
id="recWave"
class="mb-3 flex h-14 items-end justify-center gap-1.5 sm:gap-2.5"
aria-hidden="true"
>
<span class="rec-wave-bar bg-rose-300/95 shadow-sm"></span>
<span class="rec-wave-bar bg-rose-200 shadow-sm"></span>
<span class="rec-wave-bar bg-rose-100 shadow-sm"></span>
<span class="rec-wave-bar bg-rose-50/95 shadow-sm"></span>
<span class="rec-wave-bar bg-rose-100 shadow-sm"></span>
<span class="rec-wave-bar bg-rose-200 shadow-sm"></span>
<span class="rec-wave-bar bg-rose-300/95 shadow-sm"></span>
</div>
<p class="text-sm font-bold text-red-50">
<span class="rec-dot mr-0.5 inline-block text-rose-200" aria-hidden="true"></span>
正在录音 — 请对着麦克风清晰作答
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
id="btnStop"
disabled
class="rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm font-medium text-slate-200 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
停止监控(本机)
</button>
<button
type="button"
id="btnRetry"
class="rounded-lg bg-amber-600 px-3 py-2 text-sm font-medium text-slate-950 hover:bg-amber-500"
>
重试本轮
</button>
<button
type="button"
id="btnReplay"
class="rounded-lg bg-slate-700 px-3 py-2 text-sm text-slate-100 hover:bg-slate-600"
>
仅重播话术
</button>
</div>
</section>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
<div class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3">
<h3 class="text-xs font-medium text-slate-500">排队序号(当前 FIFO</h3>
<p id="queuePositionHint" class="mt-1 text-sm font-medium text-emerald-300/95"></p>
<h3 class="mt-3 text-xs font-medium text-slate-500">累积序号(本场入队)</h3>
<p id="cumulativeHint" class="mt-1 text-sm font-medium text-cyan-300/95"></p>
<p class="mt-2 text-xs text-slate-500">
排队:<code class="text-slate-600">pending_queue_position</code> /
<code class="text-slate-600">pending_queue_length</code>;累积:<code class="text-slate-600">pending_cumulative_ordinal</code>(均由服务端在 <code class="text-slate-600">voice_pending</code> 与 GET 中下发)。
</p>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3">
<h3 class="text-xs font-medium text-slate-500">服务端语音确认结果(最近一次 HTTP 响应)</h3>
<pre
id="resolveResult"
class="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-slate-300"
></pre>
</div>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<section class="min-h-[220px] rounded-xl border border-slate-800 bg-slate-900/50 p-3">
<h3 class="mb-2 text-xs font-medium text-slate-500">队首待确认JSON</h3>
<pre
id="pendingJson"
class="max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-slate-300"
></pre>
</section>
<section class="min-h-[220px] rounded-xl border border-slate-800 bg-slate-900/50 p-3">
<h3 class="mb-2 text-xs font-medium text-slate-500">日志</h3>
<pre
id="log"
class="max-h-80 overflow-auto whitespace-pre-wrap font-mono text-xs text-slate-400"
></pre>
</section>
</div>
</div>
<script src="voice_app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,952 @@
/**
* 手术室语音确认 — 浏览器端(与桌面版历史 MonitorWorker 状态机行为对齐)
* 仅 WebSocket 收 voice_pending / voice_pending_emptyHTTP 仅 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;
}
/* 先挂采集 PromisegetUserMedia 已在此 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();
}