verify-nls-realtime.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. /**
  2. * 验证「实时语音识别换成阿里云 NLS」这条链路。
  3. *
  4. * 分两段:
  5. * 【1】纯协议(不需要网络/凭据):帧结构、事件折叠、16k PCM 编码
  6. * 【2】真实端到端(需要凭据 + Windows TTS):说一句话 → 看识别结果
  7. * —— 用的是**前端同一份协议代码**(`src/asr/nls-protocol.ts`),
  8. * 所以它绿了就意味着前端那套帧真的被网关接受
  9. *
  10. * 怎么跑(先打包协议层):
  11. * npx esbuild harness/tools/_entry-nls.ts --bundle --format=esm \
  12. * --outfile=harness/tools/_nls.mjs --alias:@=./src --define:import.meta.env='{}'
  13. * node harness/tools/verify-nls-realtime.mjs
  14. *
  15. * appkey 从**代码内置值**读(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`)——
  16. * 2026-09-28 起磁盘上不再保存任何阿里云凭据;没有 appkey 或没有 TTS 时,
  17. * 【2】会**明确跳过**而不是假装通过。
  18. */
  19. import { existsSync } from 'node:fs';
  20. import { NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials, resolveNlsToken } from './nls-token.mjs';
  21. import { canSynthesizeSpeech, readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs';
  22. const {
  23. NLS_FRAME_MS,
  24. NLS_FLUSH_TICK_MS,
  25. NLS_MAX_FRAMES_PER_TICK,
  26. NLS_DRAIN_RATE,
  27. NLS_GATEWAY_URL,
  28. NLS_SAMPLE_RATE,
  29. buildStartTranscription,
  30. buildStopTranscription,
  31. createNlsTranscriptState,
  32. describeNlsFailure,
  33. encodePcm16kMono,
  34. getNlsEventName,
  35. parseNlsMessage,
  36. reduceNlsTranscript,
  37. } = await import('./_nls.mjs');
  38. let pass = 0;
  39. let fail = 0;
  40. const check = (name, ok, extra = '') => {
  41. if (ok) {
  42. pass++;
  43. console.log(' ok ' + name);
  44. } else {
  45. fail++;
  46. console.log(' FAIL ' + name + ' ' + extra);
  47. }
  48. };
  49. // ── 1. 纯协议 ───────────────────────────────────────────────────────────────
  50. console.log('\n【1】帧结构与事件折叠(纯函数,用前端同一份实现)');
  51. {
  52. const start = JSON.parse(buildStartTranscription({ appkey: 'APPKEY', taskId: 'TASK', messageId: 'MSG' }));
  53. check('StartTranscription:namespace 固定 SpeechTranscriber',
  54. start.header.namespace === 'SpeechTranscriber' && start.header.name === 'StartTranscription');
  55. check('StartTranscription:header 带 appkey(网关要求)', start.header.appkey === 'APPKEY');
  56. check('StartTranscription:task=start,显式传入的 message_id/task_id 原样带上',
  57. start.header.task === 'start' && start.header.message_id === 'MSG' && start.header.task_id === 'TASK');
  58. // **不传 task_id 时必须自己生成**(实测:空串会被网关拒
  59. // `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!`)
  60. const auto = JSON.parse(buildStartTranscription({ appkey: 'APPKEY' }));
  61. check('不传 id 时自动生成 32 位十六进制的 message_id / task_id(不能是空串)',
  62. /^[0-9a-f]{32}$/.test(auto.header.message_id) && /^[0-9a-f]{32}$/.test(auto.header.task_id),
  63. JSON.stringify([auto.header.message_id, auto.header.task_id]));
  64. check('两次生成的 task_id 不重复', auto.header.task_id !== JSON.parse(buildStartTranscription({ appkey: 'APPKEY' })).header.task_id);
  65. check('payload:16k / pcm / 开中间结果 / 开标点 / 开 VAD',
  66. start.payload.format === 'pcm' &&
  67. start.payload.sample_rate === 16000 &&
  68. start.payload.enable_intermediate_result === true &&
  69. start.payload.enable_punctuation_prediction === true &&
  70. start.payload.enable_voice_detection === true);
  71. check('payload:max_sentence_silence 落在网关允许的 200~2000',
  72. start.payload.max_sentence_silence >= 200 && start.payload.max_sentence_silence <= 2000,
  73. String(start.payload.max_sentence_silence));
  74. const stop = JSON.parse(buildStopTranscription({ appkey: 'APPKEY', taskId: 'TASK', messageId: 'MSG2' }));
  75. // 实测踩过的坑:只有 start 带 appkey 时,stop 会回
  76. // TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!
  77. check('**StopTranscription 也带 appkey**(漏了网关会 40000002)', stop.header.appkey === 'APPKEY');
  78. check('StopTranscription:task=stop + task_id 与 start 一致', stop.header.task === 'stop' && stop.header.task_id === 'TASK');
  79. // 事件折叠:载荷取自探针打真实网关抓到的原文
  80. let state = createNlsTranscriptState();
  81. const events = [];
  82. const feed = (raw) => {
  83. const { state: next, event } = reduceNlsTranscript(state, parseNlsMessage(raw));
  84. state = next;
  85. if (event) events.push(event);
  86. };
  87. feed(JSON.stringify({ header: { name: 'TranscriptionStarted', status: 20000000 } }));
  88. feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 1, result: '青浦区高新' } }));
  89. feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 1, result: '青浦区高新技术企' } }));
  90. feed(JSON.stringify({ header: { name: 'SentenceEnd' }, payload: { index: 1, result: '青浦区高新技术企业奖励是多少钱?' } }));
  91. feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 2, result: '我想问' } }));
  92. feed(JSON.stringify({ header: { name: 'TranscriptionCompleted', status: 20000000 } }));
  93. // 事件序列:mid(本句中间) → mid(本句中间) → fin(定稿) → mid(下一句中间)
  94. check('TranscriptionStarted / Completed 不产出文本事件(4 条文本事件 = 2 mid + 1 fin + 1 mid)',
  95. events.length === 4 && events.map((e) => e.type).join(',') === 'mid,mid,fin,mid',
  96. JSON.stringify(events.map((e) => e.type)));
  97. check('中间结果 → type=mid,transformText = 已定稿全文 + 本句中间结果(边说边出字)',
  98. events[0].type === 'mid' && events[0].transformText === '青浦区高新',
  99. JSON.stringify(events[0]));
  100. check('中间结果会被后续结果**覆盖**(不是追加)',
  101. events[1].type === 'mid' && events[1].transformText === '青浦区高新技术企',
  102. JSON.stringify(events[1]));
  103. check('SentenceEnd(定稿)→ type=fin,且**追加**不覆盖',
  104. events[2].type === 'fin' && events[2].transformText === '青浦区高新技术企业奖励是多少钱?',
  105. JSON.stringify(events[2]));
  106. check('下一句的中间结果前缀带上已定稿的那句',
  107. events[3].type === 'mid' && events[3].transformText === '青浦区高新技术企业奖励是多少钱?我想问',
  108. JSON.stringify(events[3]));
  109. check('最终定稿全文只由 SentenceEnd 拼成(中间结果不污染)',
  110. state.finalText === '青浦区高新技术企业奖励是多少钱?', JSON.stringify(state.finalText));
  111. check('TaskFailed 的错误描述带 status 与 status_text(能直接给用户看)',
  112. describeNlsFailure(parseNlsMessage(JSON.stringify({
  113. header: { name: 'TaskFailed', status: 40000002, status_text: 'Gateway:MESSAGE_INVALID:Missing message appkey!' },
  114. }))).includes('40000002') &&
  115. describeNlsFailure(parseNlsMessage('{"header":{"status":40000002}}')).length > 0);
  116. check('非法 JSON 不抛异常(忽略即可)', parseNlsMessage('not json') === null && getNlsEventName(null) === '');
  117. }
  118. console.log('\n【2】16k / 16bit / 单声道 编码');
  119. {
  120. // 100ms 的 48k 输入 → 应为 1600 采样 = 3200 字节(旧 SDK 写死 44100,这里是按实际采样率算的)
  121. const input48k = new Float32Array(4800);
  122. const encoded48k = encodePcm16kMono(input48k, 48000);
  123. check('48k → 16k:100ms 得到 3200 字节(1600 采样 × 2)', encoded48k.length === 3200, String(encoded48k.length));
  124. const encoded44k = encodePcm16kMono(new Float32Array(4410), 44100);
  125. check('44.1k → 16k:同样 100ms 得到 3200 字节(不再写死 44100 也能对)',
  126. encoded44k.length === 3200, String(encoded44k.length));
  127. // 值域与字节序:满幅正弦的第一个正峰应是 0x7FFF(小端 0xFF 0x7F)
  128. const half = new Float32Array(1600);
  129. for (let i = 0; i < half.length; i++) half[i] = Math.sin((2 * Math.PI * i) / 100);
  130. const pcm = encodePcm16kMono(half, NLS_SAMPLE_RATE);
  131. const view = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
  132. const firstPeak = view.getInt16(2 * 24, true); // 第 25 个采样 ≈ sin(π/2)=1 附近
  133. check('int16 小端写入(正值取 0x7fff 附近)', firstPeak > 32000, String(firstPeak));
  134. check('超出 ±1 的采样被钳位(不溢出成噪声)',
  135. encodePcm16kMono(new Float32Array([2, -2]), NLS_SAMPLE_RATE).length === 4);
  136. check('帧长与实时节奏自洽:NLS_FRAME_MS × 16k × 2 字节 = 3200',
  137. (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000 === 3200);
  138. check('网关地址是上海地域的 ws/v1 端点', NLS_GATEWAY_URL.endsWith('/ws/v1') && NLS_GATEWAY_URL.startsWith('wss://'));
  139. // ⚠️ 用户 2026-09-23 反馈「出字延迟高」后加的:**排空速率必须 > 1× 实时**。
  140. // 连上网关前(取 token + 握手)麦克风已在采集,若只按 1× 排空那段积压永远排不掉
  141. // ⇒ 整体永久慢半拍(旧厂商 SDK 是 2× 排空,所以它能追上,这是切过来后退化的一处)。
  142. check('排空速率 > 1× 实时(否则握手期间的积压永远排不掉 → 永久慢半拍)',
  143. NLS_DRAIN_RATE > 1, `实测 NLS_DRAIN_RATE=${NLS_DRAIN_RATE}×`);
  144. check('排空速率也不至于把网关灌爆(≤ 8× 实时)', NLS_DRAIN_RATE <= 8, `${NLS_DRAIN_RATE}×`);
  145. check('断句阈值在网关允许区间 200~2000,且比网关默认 800 更早出定稿',
  146. JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence >= 200 &&
  147. JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence <= 2000 &&
  148. JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence <= 800,
  149. String(JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence));
  150. }
  151. // ── 3. 真实端到端(打真实网关)───────────────────────────────────────────────
  152. console.log('\n【3】打真实网关:说一句话 → 看识别结果');
  153. const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
  154. if (!credentials.appkey) {
  155. console.log(' ⏭ 跳过:读不到 appkey(src/utils/runtime-config.ts 的 NLS_APPKEY_BUILTIN)');
  156. } else if (!canSynthesizeSpeech()) {
  157. console.log(' ⏭ 跳过:真实识别这一段需要 Windows 自带 TTS 造语音');
  158. } else {
  159. const SPOKEN = '青浦区高新技术企业奖励是多少钱';
  160. const wav = synthesizeSpeechToWav(SPOKEN);
  161. const { pcm, note } = readAudioAsPcm16kMono(wav);
  162. console.log(` (TTS 音频 ${pcm.length} 字节 ≈ ${(pcm.length / 32000).toFixed(2)}s;${note})`);
  163. const { token, source } = await resolveNlsToken();
  164. console.log(` (token 来源:${source})`);
  165. const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`);
  166. const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 };
  167. let state = createNlsTranscriptState();
  168. const feed = (raw) => {
  169. const message = parseNlsMessage(raw);
  170. const name = getNlsEventName(message);
  171. if (name === 'TranscriptionStarted') seen.started = true;
  172. if (name === 'TranscriptionCompleted') seen.completed = true;
  173. if (name === 'TaskFailed') seen.failed = describeNlsFailure(message);
  174. const { state: next, event } = reduceNlsTranscript(state, message);
  175. state = next;
  176. if (event) {
  177. if (event.type === 'mid') seen.mids++;
  178. if (event.type === 'fin') seen.fins++;
  179. }
  180. };
  181. // 用**前端同一份**帧构建器:发出去的就是产品会发的那一帧
  182. const startFrame = buildStartTranscription({ appkey: credentials.appkey });
  183. const taskId = JSON.parse(startFrame).header.task_id;
  184. const finished = new Promise((resolve) => {
  185. let stopSent = false;
  186. const stop = () => {
  187. if (stopSent) return;
  188. stopSent = true;
  189. socket.send(buildStopTranscription({ appkey: credentials.appkey, taskId }));
  190. };
  191. socket.onopen = () => {
  192. socket.send(startFrame);
  193. let offset = 0;
  194. const frame = 3200; // 100ms
  195. const pump = setInterval(() => {
  196. if (offset >= pcm.length) {
  197. clearInterval(pump);
  198. stop();
  199. return;
  200. }
  201. if (socket.readyState !== WebSocket.OPEN) return;
  202. socket.send(pcm.subarray(offset, Math.min(offset + frame, pcm.length)));
  203. offset += frame;
  204. }, NLS_FRAME_MS);
  205. };
  206. socket.onmessage = (event) => {
  207. feed(event.data);
  208. if (seen.completed || seen.failed) {
  209. socket.close();
  210. resolve();
  211. }
  212. };
  213. socket.onerror = () => resolve();
  214. setTimeout(() => {
  215. try {
  216. socket.close();
  217. } catch {
  218. /* ignore */
  219. }
  220. resolve();
  221. }, 30000);
  222. });
  223. await finished;
  224. check('收到 TranscriptionStarted(token/appkey 有效)', seen.started, seen.failed || '未收到');
  225. check('没有 TaskFailed', !seen.failed, seen.failed);
  226. check('**识别期间就收到中间结果(边说边出字)**', seen.mids > 0, `mids=${seen.mids}`);
  227. check('收到定稿的 SentenceEnd', seen.fins > 0, `fins=${seen.fins}`);
  228. check('收到 TranscriptionCompleted(收尾干净)', seen.completed, seen.failed || '未收到');
  229. const text = state.finalText;
  230. console.log(` (识别结果:${JSON.stringify(text)})`);
  231. check('识别出的文本包含关键内容(青浦 / 奖励)',
  232. text.includes('青浦') && text.includes('奖励'), JSON.stringify(text));
  233. check('识别结果长度合理(≥ 8 字)', text.length >= 8, String(text.length));
  234. }
  235. // ── 4. 接线(源码级:这段是 Vue 壳组件里的事,node 跑不起来)────────────────
  236. console.log('\n【4】两个壳组件已切到 NLS 实现(源码级断言)');
  237. {
  238. const { readFileSync } = await import('node:fs');
  239. for (const file of [
  240. 'src/components/BusinessAssistantPC.vue',
  241. 'src/components/BusinessAssistantMobile.vue',
  242. ]) {
  243. const source = readFileSync(file, 'utf8');
  244. check(`${file}:import 的是 NLS 实现(2026-09-24 起在 @/asr/nls)`,
  245. /import ASR from '@\/asr\/nls';/.test(source));
  246. check(`${file}:不再 import 旧的厂商 SDK`,
  247. !/import ASR from '@\/three-libs\/asr\/index';/.test(source) &&
  248. !/import ASR from '@\/asr\/index';/.test(source));
  249. check(`${file}:不再硬编码 appKey(由 runtime-config 统一提供:env/注入 → 内置值)`,
  250. !/new ASR\("/.test(source) && /new ASR\(undefined/.test(source));
  251. // 契约没变:这几个调用点必须还在(换了实现就得跟着改)
  252. check(`${file}:openMicroPhone / startRecord / openSocket / transform 调用点齐全`,
  253. /openMicroPhone\(\)/.test(source) && /startRecord\(\)/.test(source) &&
  254. /openSocket\(\)/.test(source) && /\.transform\(\)/.test(source) &&
  255. /\.close\(\)/.test(source) && /\.clear\(\)/.test(source));
  256. }
  257. // 2026-09-24 用户要求「B 全部删掉」:旧厂商 SDK 连同 VITE_ASR / VITE_XF_ASR / /asr 代理一起删了
  258. // —— 所以这条断言从「旧实现仍在」反过来:**确认它已经不在**(免得哪天有人误加回来)。
  259. // 同日又按用户要求把 asr 目录移出 three-libs(`src/asr/`)并删掉 three-libs —— 一并钉住。
  260. check('旧的厂商 SDK 已删除(2026-09-24 用户要求)',
  261. !existsSync('src/three-libs/asr/index.ts') && !existsSync('src/asr/index.ts'));
  262. check('three-libs 目录已删除,asr 现在在 src/asr/', !existsSync('src/three-libs') && existsSync('src/asr/nls.ts'));
  263. check('旧 ASR 的 env 也删干净了(VITE_ASR / VITE_XF_ASR)',
  264. !readFileSync('.env.development', 'utf8').includes('VITE_ASR') &&
  265. !readFileSync('.env.development', 'utf8').includes('VITE_XF_ASR'));
  266. }
  267. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  268. process.exit(fail ? 1 : 0);