ver0.1
This commit is contained in:
36
web/voice-confirmation/README.md
Normal file
36
web/voice-confirmation/README.md
Normal 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 新版** 为优先验证目标。
|
||||
259
web/voice-confirmation/index.html
Normal file
259
web/voice-confirmation/index.html
Normal 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>
|
||||
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