verify-nls-realtime.mjs 14 KB

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