verify-nls-browser-path.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /**
  2. * 在 node 里**完整复刻浏览器那条采集/组帧路径**,打真实网关看能不能出字。
  3. *
  4. * 为什么要有它(2026-09-24):用户报「进了录音状态但不出字」,控制台日志显示
  5. * 采集在跑(48k/2048 样本一块)、音频也一帧帧推上去了(34 秒),但网关**一条结果都没回**。
  6. * 前端侧剩下的可疑点就是「按 2048 样本分块 → 各自重采样 → 拼成 3200 字节帧」这条路径
  7. * (node 的探针推的是**预先压好的 16k 整帧**,走不到这条路径,所以它绿不代表浏览器这条绿)。
  8. *
  9. * 这里用同一个 `encodePcm16kMono()`(`src/asr/nls-protocol.ts`,前端同一份代码):
  10. * ① 把 TTS 音频重采样到 **48k**(模拟 48k 设备)
  11. * ② 按 **2048 样本**切块,每块过 `encodePcm16kMono(chunk, 48000)` → 约 1366 字节
  12. * ③ 按 **42.7ms 一块** 的节奏入队(模拟 ScriptProcessor 的回调节奏)
  13. * ④ 每 50ms 把队列里的块拼进 carry,凑够 3200 字节就发一帧(模拟 nls.ts 的 flush)
  14. *
  15. * 怎么跑(先打包协议层):
  16. * npx esbuild harness/tools/_entry-nls.ts --bundle --format=esm \
  17. * --outfile=harness/tools/_nls.mjs --alias:@=./src --define:import.meta.env='{}'
  18. * node harness/tools/verify-nls-browser-path.mjs
  19. */
  20. import { readAudioAsPcm16kMono, resampleTo16k, synthesizeSpeechToWav } from './nls-test-audio.mjs';
  21. import { NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials, resolveNlsToken } from './nls-token.mjs';
  22. import { readFileSync } from 'node:fs';
  23. const { NLS_GATEWAY_URL, buildStartTranscription, buildStopTranscription, createNlsTranscriptState, parseNlsMessage, getNlsEventName, reduceNlsTranscript } = await import(
  24. './_nls.mjs'
  25. );
  26. const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
  27. if (!credentials.appkey) {
  28. console.error('缺 appkey(ALIYUN_NLS_APPKEY)');
  29. process.exit(2);
  30. }
  31. const SPOKEN = process.argv.find((a) => a.startsWith('--say='))?.slice(6) || '青浦区高新技术企业奖励是多少钱';
  32. const DEVICE_RATE = 48000; // 模拟浏览器 AudioContext 的 48k
  33. const CHUNK_SAMPLES = 2048; // 浏览器实际给的 buffer(用户日志里就是 2048)
  34. const CHUNK_MS = (CHUNK_SAMPLES / DEVICE_RATE) * 1000; // ≈42.7ms
  35. const FRAME_BYTES = 3200; // 100ms @16k/16bit/mono
  36. let pass = 0;
  37. let fail = 0;
  38. const check = (name, ok, extra = '') => {
  39. if (ok) {
  40. pass++;
  41. console.log(' ok ' + name);
  42. } else {
  43. fail++;
  44. console.log(' FAIL ' + name + ' ' + extra);
  45. }
  46. };
  47. // ① TTS 造语音 → 16k PCM(工具会给 16k,这里再升到 48k 当"设备原始数据")
  48. const wav = synthesizeSpeechToWav(SPOKEN);
  49. const { pcm: pcm16k } = readAudioAsPcm16kMono(wav);
  50. const samples16k = new Int16Array(pcm16k.buffer, pcm16k.byteOffset, pcm16k.length / 2);
  51. const samples48k = (() => {
  52. const count = Math.round((samples16k.length * DEVICE_RATE) / 16000);
  53. const out = new Int16Array(count);
  54. const step = (samples16k.length - 1) / Math.max(count - 1, 1);
  55. for (let i = 0; i < count; i++) {
  56. const pos = i * step;
  57. const before = Math.floor(pos);
  58. const after = Math.min(Math.ceil(pos), samples16k.length - 1);
  59. out[i] = Math.round(samples16k[before] + (samples16k[after] - samples16k[before]) * (pos - before));
  60. }
  61. return out;
  62. })();
  63. const float48k = Float32Array.from(samples48k, (v) => v / 32768);
  64. // ② 按 2048 样本切块 → 每块独立重采样(与浏览器一模一样)
  65. const { encodePcm16kMono } = await import('./_nls.mjs');
  66. const chunks = [];
  67. for (let i = 0; i < float48k.length; i += CHUNK_SAMPLES) {
  68. const slice = float48k.subarray(i, Math.min(i + CHUNK_SAMPLES, float48k.length));
  69. if (slice.length < CHUNK_SAMPLES) break; // 尾部不足一块丢弃(浏览器也不会给半块)
  70. chunks.push(encodePcm16kMono(slice, DEVICE_RATE));
  71. }
  72. console.log(`\n【准备】设备 ${DEVICE_RATE}Hz / 每块 ${CHUNK_SAMPLES} 采样(≈${CHUNK_MS.toFixed(1)}ms)`);
  73. console.log(` 共 ${chunks.length} 块,每块编码后约 ${chunks[0]?.length} 字节,总时长 ≈ ${((chunks.length * CHUNK_MS) / 1000).toFixed(2)}s`);
  74. const { token, source } = await resolveNlsToken();
  75. console.log(`【连接】token 来源=${source}`);
  76. const startFrame = buildStartTranscription({ appkey: credentials.appkey });
  77. const taskId = JSON.parse(startFrame).header.task_id;
  78. const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`);
  79. let state = createNlsTranscriptState();
  80. const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 };
  81. const done = new Promise((resolve) => {
  82. let stopSent = false;
  83. const stop = () => {
  84. if (stopSent) return;
  85. stopSent = true;
  86. socket.send(buildStopTranscription({ appkey: credentials.appkey, taskId }));
  87. };
  88. socket.onopen = () => {
  89. socket.send(startFrame);
  90. // ③④ 模拟浏览器:块按 42.7ms 的节奏到,flush 每 50ms 拼帧发出去
  91. let nextChunkAt = Date.now();
  92. let chunkIndex = 0;
  93. let carry = new Uint8Array(0);
  94. const queue = [];
  95. const pump = setInterval(() => {
  96. // 到点的块入队(模拟 onaudioprocess 回调)
  97. while (chunkIndex < chunks.length && Date.now() >= nextChunkAt) {
  98. queue.push(chunks[chunkIndex]);
  99. chunkIndex += 1;
  100. nextChunkAt += CHUNK_MS;
  101. }
  102. if (queue.length && socket.readyState === WebSocket.OPEN) {
  103. const merged = new Uint8Array(carry.length + queue.reduce((n, c) => n + c.length, 0));
  104. merged.set(carry, 0);
  105. let offset = carry.length;
  106. for (const chunk of queue) {
  107. merged.set(chunk, offset);
  108. offset += chunk.length;
  109. }
  110. carry = merged;
  111. queue.length = 0;
  112. }
  113. let sent = 0;
  114. while (carry.length >= FRAME_BYTES && sent < 2) {
  115. socket.send(carry.subarray(0, FRAME_BYTES));
  116. carry = carry.subarray(FRAME_BYTES);
  117. sent += 1;
  118. }
  119. if (chunkIndex >= chunks.length && queue.length === 0 && carry.length === 0) {
  120. clearInterval(pump);
  121. stop();
  122. }
  123. }, 50);
  124. };
  125. socket.onmessage = (event) => {
  126. const message = parseNlsMessage(event.data);
  127. const name = getNlsEventName(message);
  128. if (name === 'TranscriptionStarted') seen.started = true;
  129. if (name === 'TranscriptionCompleted') seen.completed = true;
  130. if (name === 'TaskFailed') seen.failed = String(message?.header?.status_text || message?.payload?.message || '');
  131. const { state: next, event: payload } = reduceNlsTranscript(state, message);
  132. state = next;
  133. if (payload?.type === 'mid') seen.mids += 1;
  134. if (payload?.type === 'fin') seen.fins += 1;
  135. if (name === 'TaskFailed') console.warn('[nls] TaskFailed:', seen.failed);
  136. };
  137. socket.onerror = () => resolve();
  138. setTimeout(() => {
  139. try {
  140. socket.close();
  141. } catch {
  142. /* ignore */
  143. }
  144. resolve();
  145. }, 40000);
  146. });
  147. await done;
  148. console.log('\n【结果】');
  149. check('收到 TranscriptionStarted', seen.started, seen.failed);
  150. // ⚠️ 实测:收尾后会收到一条 `Gateway:IDLE_TIMEOUT`("the last directive is 'StartTranscription'")——
  151. // 那是**本脚本推完音频就 stop、会话随即结束**的产物(网关按"最后一条指令是 start"判空闲),
  152. // 不影响识别结果本身,所以只作提示、不算失败。真要长静音时它可能提前掐会话,
  153. // 与「浏览器不出字」是另一回事(那边一条结果都没回,也没报错)。
  154. if (seen.failed) console.log(` ℹ️ 收尾收到 TaskFailed(预期内):${seen.failed}`);
  155. check('**收到了中间结果(说明这条路径也能出字)**', seen.mids > 0, `mids=${seen.mids}`);
  156. check('收到了定稿 SentenceEnd', seen.fins > 0, `fins=${seen.fins}`);
  157. const text = state.finalText;
  158. console.log(` (识别结果:${JSON.stringify(text)})`);
  159. check('识别文本包含关键词(青浦 / 奖励)', text.includes('青浦') && text.includes('奖励'), JSON.stringify(text));
  160. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  161. process.exit(fail ? 1 : 0);