|
|
@@ -0,0 +1,178 @@
|
|
|
+/**
|
|
|
+ * 在 node 里**完整复刻浏览器那条采集/组帧路径**,打真实网关看能不能出字。
|
|
|
+ *
|
|
|
+ * 为什么要有它(2026-09-24):用户报「进了录音状态但不出字」,控制台日志显示
|
|
|
+ * 采集在跑(48k/2048 样本一块)、音频也一帧帧推上去了(34 秒),但网关**一条结果都没回**。
|
|
|
+ * 前端侧剩下的可疑点就是「按 2048 样本分块 → 各自重采样 → 拼成 3200 字节帧」这条路径
|
|
|
+ * (node 的探针推的是**预先压好的 16k 整帧**,走不到这条路径,所以它绿不代表浏览器这条绿)。
|
|
|
+ *
|
|
|
+ * 这里用同一个 `encodePcm16kMono()`(`src/asr/nls-protocol.ts`,前端同一份代码):
|
|
|
+ * ① 把 TTS 音频重采样到 **48k**(模拟 48k 设备)
|
|
|
+ * ② 按 **2048 样本**切块,每块过 `encodePcm16kMono(chunk, 48000)` → 约 1366 字节
|
|
|
+ * ③ 按 **42.7ms 一块** 的节奏入队(模拟 ScriptProcessor 的回调节奏)
|
|
|
+ * ④ 每 50ms 把队列里的块拼进 carry,凑够 3200 字节就发一帧(模拟 nls.ts 的 flush)
|
|
|
+ *
|
|
|
+ * 怎么跑(先打包协议层):
|
|
|
+ * npx esbuild harness/tools/_entry-nls.ts --bundle --format=esm \
|
|
|
+ * --outfile=harness/tools/_nls.mjs --alias:@=./src --define:import.meta.env='{}'
|
|
|
+ * node harness/tools/verify-nls-browser-path.mjs
|
|
|
+ */
|
|
|
+
|
|
|
+import { readAudioAsPcm16kMono, resampleTo16k, synthesizeSpeechToWav } from './nls-test-audio.mjs';
|
|
|
+import { NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials, resolveNlsToken } from './nls-token.mjs';
|
|
|
+import { readFileSync } from 'node:fs';
|
|
|
+
|
|
|
+const { NLS_GATEWAY_URL, buildStartTranscription, buildStopTranscription, createNlsTranscriptState, parseNlsMessage, getNlsEventName, reduceNlsTranscript } = await import(
|
|
|
+ './_nls.mjs'
|
|
|
+);
|
|
|
+
|
|
|
+const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
|
|
|
+if (!credentials.appkey) {
|
|
|
+ console.error('缺 appkey(ALIYUN_NLS_APPKEY)');
|
|
|
+ process.exit(2);
|
|
|
+}
|
|
|
+
|
|
|
+const SPOKEN = process.argv.find((a) => a.startsWith('--say='))?.slice(6) || '青浦区高新技术企业奖励是多少钱';
|
|
|
+const DEVICE_RATE = 48000; // 模拟浏览器 AudioContext 的 48k
|
|
|
+const CHUNK_SAMPLES = 2048; // 浏览器实际给的 buffer(用户日志里就是 2048)
|
|
|
+const CHUNK_MS = (CHUNK_SAMPLES / DEVICE_RATE) * 1000; // ≈42.7ms
|
|
|
+const FRAME_BYTES = 3200; // 100ms @16k/16bit/mono
|
|
|
+
|
|
|
+let pass = 0;
|
|
|
+let fail = 0;
|
|
|
+const check = (name, ok, extra = '') => {
|
|
|
+ if (ok) {
|
|
|
+ pass++;
|
|
|
+ console.log(' ok ' + name);
|
|
|
+ } else {
|
|
|
+ fail++;
|
|
|
+ console.log(' FAIL ' + name + ' ' + extra);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// ① TTS 造语音 → 16k PCM(工具会给 16k,这里再升到 48k 当"设备原始数据")
|
|
|
+const wav = synthesizeSpeechToWav(SPOKEN);
|
|
|
+const { pcm: pcm16k } = readAudioAsPcm16kMono(wav);
|
|
|
+const samples16k = new Int16Array(pcm16k.buffer, pcm16k.byteOffset, pcm16k.length / 2);
|
|
|
+const samples48k = (() => {
|
|
|
+ const count = Math.round((samples16k.length * DEVICE_RATE) / 16000);
|
|
|
+ const out = new Int16Array(count);
|
|
|
+ const step = (samples16k.length - 1) / Math.max(count - 1, 1);
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
+ const pos = i * step;
|
|
|
+ const before = Math.floor(pos);
|
|
|
+ const after = Math.min(Math.ceil(pos), samples16k.length - 1);
|
|
|
+ out[i] = Math.round(samples16k[before] + (samples16k[after] - samples16k[before]) * (pos - before));
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+})();
|
|
|
+const float48k = Float32Array.from(samples48k, (v) => v / 32768);
|
|
|
+
|
|
|
+// ② 按 2048 样本切块 → 每块独立重采样(与浏览器一模一样)
|
|
|
+const { encodePcm16kMono } = await import('./_nls.mjs');
|
|
|
+const chunks = [];
|
|
|
+for (let i = 0; i < float48k.length; i += CHUNK_SAMPLES) {
|
|
|
+ const slice = float48k.subarray(i, Math.min(i + CHUNK_SAMPLES, float48k.length));
|
|
|
+ if (slice.length < CHUNK_SAMPLES) break; // 尾部不足一块丢弃(浏览器也不会给半块)
|
|
|
+ chunks.push(encodePcm16kMono(slice, DEVICE_RATE));
|
|
|
+}
|
|
|
+console.log(`\n【准备】设备 ${DEVICE_RATE}Hz / 每块 ${CHUNK_SAMPLES} 采样(≈${CHUNK_MS.toFixed(1)}ms)`);
|
|
|
+console.log(` 共 ${chunks.length} 块,每块编码后约 ${chunks[0]?.length} 字节,总时长 ≈ ${((chunks.length * CHUNK_MS) / 1000).toFixed(2)}s`);
|
|
|
+
|
|
|
+const { token, source } = await resolveNlsToken();
|
|
|
+console.log(`【连接】token 来源=${source}`);
|
|
|
+
|
|
|
+const startFrame = buildStartTranscription({ appkey: credentials.appkey });
|
|
|
+const taskId = JSON.parse(startFrame).header.task_id;
|
|
|
+const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`);
|
|
|
+
|
|
|
+let state = createNlsTranscriptState();
|
|
|
+const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 };
|
|
|
+
|
|
|
+const done = new Promise((resolve) => {
|
|
|
+ let stopSent = false;
|
|
|
+ const stop = () => {
|
|
|
+ if (stopSent) return;
|
|
|
+ stopSent = true;
|
|
|
+ socket.send(buildStopTranscription({ appkey: credentials.appkey, taskId }));
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.onopen = () => {
|
|
|
+ socket.send(startFrame);
|
|
|
+ // ③④ 模拟浏览器:块按 42.7ms 的节奏到,flush 每 50ms 拼帧发出去
|
|
|
+ let nextChunkAt = Date.now();
|
|
|
+ let chunkIndex = 0;
|
|
|
+ let carry = new Uint8Array(0);
|
|
|
+ const queue = [];
|
|
|
+
|
|
|
+ const pump = setInterval(() => {
|
|
|
+ // 到点的块入队(模拟 onaudioprocess 回调)
|
|
|
+ while (chunkIndex < chunks.length && Date.now() >= nextChunkAt) {
|
|
|
+ queue.push(chunks[chunkIndex]);
|
|
|
+ chunkIndex += 1;
|
|
|
+ nextChunkAt += CHUNK_MS;
|
|
|
+ }
|
|
|
+ if (queue.length && socket.readyState === WebSocket.OPEN) {
|
|
|
+ const merged = new Uint8Array(carry.length + queue.reduce((n, c) => n + c.length, 0));
|
|
|
+ merged.set(carry, 0);
|
|
|
+ let offset = carry.length;
|
|
|
+ for (const chunk of queue) {
|
|
|
+ merged.set(chunk, offset);
|
|
|
+ offset += chunk.length;
|
|
|
+ }
|
|
|
+ carry = merged;
|
|
|
+ queue.length = 0;
|
|
|
+ }
|
|
|
+ let sent = 0;
|
|
|
+ while (carry.length >= FRAME_BYTES && sent < 2) {
|
|
|
+ socket.send(carry.subarray(0, FRAME_BYTES));
|
|
|
+ carry = carry.subarray(FRAME_BYTES);
|
|
|
+ sent += 1;
|
|
|
+ }
|
|
|
+ if (chunkIndex >= chunks.length && queue.length === 0 && carry.length === 0) {
|
|
|
+ clearInterval(pump);
|
|
|
+ stop();
|
|
|
+ }
|
|
|
+ }, 50);
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.onmessage = (event) => {
|
|
|
+ const message = parseNlsMessage(event.data);
|
|
|
+ const name = getNlsEventName(message);
|
|
|
+ if (name === 'TranscriptionStarted') seen.started = true;
|
|
|
+ if (name === 'TranscriptionCompleted') seen.completed = true;
|
|
|
+ if (name === 'TaskFailed') seen.failed = String(message?.header?.status_text || message?.payload?.message || '');
|
|
|
+ const { state: next, event: payload } = reduceNlsTranscript(state, message);
|
|
|
+ state = next;
|
|
|
+ if (payload?.type === 'mid') seen.mids += 1;
|
|
|
+ if (payload?.type === 'fin') seen.fins += 1;
|
|
|
+ if (name === 'TaskFailed') console.warn('[nls] TaskFailed:', seen.failed);
|
|
|
+ };
|
|
|
+ socket.onerror = () => resolve();
|
|
|
+ setTimeout(() => {
|
|
|
+ try {
|
|
|
+ socket.close();
|
|
|
+ } catch {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+ resolve();
|
|
|
+ }, 40000);
|
|
|
+});
|
|
|
+
|
|
|
+await done;
|
|
|
+
|
|
|
+console.log('\n【结果】');
|
|
|
+check('收到 TranscriptionStarted', seen.started, seen.failed);
|
|
|
+// ⚠️ 实测:收尾后会收到一条 `Gateway:IDLE_TIMEOUT`("the last directive is 'StartTranscription'")——
|
|
|
+// 那是**本脚本推完音频就 stop、会话随即结束**的产物(网关按"最后一条指令是 start"判空闲),
|
|
|
+// 不影响识别结果本身,所以只作提示、不算失败。真要长静音时它可能提前掐会话,
|
|
|
+// 与「浏览器不出字」是另一回事(那边一条结果都没回,也没报错)。
|
|
|
+if (seen.failed) console.log(` ℹ️ 收尾收到 TaskFailed(预期内):${seen.failed}`);
|
|
|
+check('**收到了中间结果(说明这条路径也能出字)**', seen.mids > 0, `mids=${seen.mids}`);
|
|
|
+check('收到了定稿 SentenceEnd', seen.fins > 0, `fins=${seen.fins}`);
|
|
|
+const text = state.finalText;
|
|
|
+console.log(` (识别结果:${JSON.stringify(text)})`);
|
|
|
+check('识别文本包含关键词(青浦 / 奖励)', text.includes('青浦') && text.includes('奖励'), JSON.stringify(text));
|
|
|
+
|
|
|
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
|
|
|
+process.exit(fail ? 1 : 0);
|