浏览代码

fix(nls): 加采集电平日志 + 复刻浏览器采集路径的真实网关验证(定位「不出字」)

用户给的 Console 日志显示:采集在跑(48k/2048 样本一块)、音频一帧帧推出去了 34 秒、
TranscriptionStarted 也收到了,但网关**一条识别结果都没回**(也没有 TaskFailed)。

① 先排除「组帧/重采样」这条路径:新增 harness/tools/verify-nls-browser-path.mjs ——
   在 node 里用**前端同一份 encodePcm16kMono()** 复刻浏览器路径:
   TTS 音频升到 48k → 按 2048 样本分块(每块独立重采样,与 ScriptProcessor 一致)→
   按 42.7ms 节奏入队 → 每 50ms 拼成 3200 字节帧发出去(与 nls.ts 的 flush 一致)。
   **结果:识别正确**("青浦区高新技术企业奖励是多少钱?")⇒ 组帧与重采样没有问题,
   剩下唯一差异是**麦克风采到的数据本身**。
   (顺带记录:收尾后会收到 Gateway:IDLE_TIMEOUT,那是"推完就 stop"的产物,不影响识别,
   脚本里改成提示、不算失败。)

② 给采集加电平日志(每 ~2s 一条):`[nls] 采集电平:峰值=… RMS=…` ——
   峰值长期 ≈ 0 就说明麦克风没拿到声音(设备/权限问题),而不是协议问题。

验证:verify-nls-browser-path 4/4、verify-nls-realtime 44/44、verify-inline-scripts 6/6、
npm run build 通过。

⚠️ 待用户复现并回报「采集电平」那行,以判定是不是静音采集。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 月之前
父节点
当前提交
d2a477e006
共有 2 个文件被更改,包括 191 次插入 和 0 次删除
  1. 178 0
      harness/tools/verify-nls-browser-path.mjs
  2. 13 0
      src/asr/nls.ts

+ 178 - 0
harness/tools/verify-nls-browser-path.mjs

@@ -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);

+ 13 - 0
src/asr/nls.ts

@@ -122,6 +122,19 @@ export default class NlsAsr extends BaseEvent {
       if (capturedChunks === 1) {
         console.log(`[nls] 收到第一块音频:输入 ${channel.length} 采样 → 编码后 ${encoded.length} 字节`);
       }
+      // 电平日志:排查「音频在推但服务端没反应」时,先确认麦克风数据不是静音
+      // (每 ~2s 一条;峰值长期 ≈0 说明采集端没拿到声音,问题在设备/权限而不在协议)
+      if (capturedChunks % 50 === 1) {
+        let peak = 0;
+        let sum = 0;
+        for (let i = 0; i < channel.length; i++) {
+          const value = Math.abs(channel[i]);
+          if (value > peak) peak = value;
+          sum += value * value;
+        }
+        const rms = Math.sqrt(sum / channel.length);
+        console.log(`[nls] 采集电平:峰值=${peak.toFixed(4)} RMS=${rms.toFixed(4)}`);
+      }
       this.pendingAudio.push(encoded);
     };