probe-nls-realtime.mjs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /**
  2. * 探针:**直接连阿里云 NLS 实时语音识别网关**,把上下行的原始帧打出来看。
  3. *
  4. * 为什么要有它:NLS 的实时协议(`ws/v1`)细节以**实测**为准 —— 官方文档在
  5. * `help.aliyun.com`(本机 WebFetch 被策略挡掉),而自述的帧结构一旦有一处不对,
  6. * 网关返回的是 `TaskFailed` / 直接断连,光看封装层代码排查不出来
  7. * (本项目在别处踩过同样的坑:协议问题必须读原始 SSE/WS 载荷)。
  8. *
  9. * 怎么跑(凭据从 .env.development.local 读,见 harness/tools/nls-token.mjs):
  10. * node harness/tools/probe-nls-realtime.mjs
  11. * node harness/tools/probe-nls-realtime.mjs --audio=<16k单声道16bit的pcm/raw文件>
  12. *
  13. * 默认不发音频(只发 start → 等待 → stop),用于确认**握手与事件名**;
  14. * 带 `--audio=` 时按 ~100ms 一帧推流,用于确认**识别结果**。
  15. */
  16. import { existsSync } from 'node:fs';
  17. import { randomUUID } from 'node:crypto';
  18. import {
  19. NLS_LOCAL_ENV_FILE,
  20. createNlsToken,
  21. maskCredential,
  22. readDotEnvFile,
  23. readNlsCredentials,
  24. } from './nls-token.mjs';
  25. import { readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs';
  26. const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
  27. if (!credentials.ak || !credentials.sk || !credentials.appkey) {
  28. console.error('缺凭据:ALIYUN_NLS_AK / ALIYUN_NLS_SK / ALIYUN_NLS_APPKEY');
  29. process.exit(2);
  30. }
  31. const GATEWAY = process.env.NLS_GATEWAY || 'wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1';
  32. const audioArg = process.argv.find((a) => a.startsWith('--audio='));
  33. const sayArg = process.argv.find((a) => a.startsWith('--say='));
  34. const waitMs = Number((process.argv.find((a) => a.startsWith('--wait=')) || '').slice(7)) || 4000;
  35. const stamp = () => new Date().toISOString().slice(11, 23);
  36. const log = (...args) => console.log(`[${stamp()}]`, ...args);
  37. const raw = (value) => (typeof value === 'string' ? value : `(binary ${value?.byteLength ?? value?.length ?? '?'} bytes)`);
  38. let audioFile = audioArg ? audioArg.slice('--audio='.length) : '';
  39. if (sayArg) {
  40. const text = sayArg.slice('--say='.length);
  41. log(`用系统 TTS 合成语音:「${text}」`);
  42. audioFile = synthesizeSpeechToWav(text);
  43. }
  44. if (audioFile && !existsSync(audioFile)) {
  45. console.error(`音频文件不存在:${audioFile}`);
  46. process.exit(2);
  47. }
  48. let audioPcm = null;
  49. if (audioFile) {
  50. const { pcm, note } = readAudioAsPcm16kMono(audioFile);
  51. audioPcm = pcm;
  52. log(`音频:${audioFile}(${audioPcm.length} 字节 = ${(audioPcm.length / 32000).toFixed(2)} 秒)${note}`);
  53. }
  54. const { token, expireAt } = await createNlsToken(credentials);
  55. log(`token 到手(有效期至 ${new Date(expireAt).toLocaleString('zh-CN')})AK=${maskCredential(credentials.ak)}`);
  56. const url = `${GATEWAY}?token=${encodeURIComponent(token)}`;
  57. log('连接', url.replace(/token=[^&]+/, 'token=****'));
  58. const ws = new WebSocket(url);
  59. const newId = () => randomUUID().replace(/-/g, ''); // 文档要求 32 位十六进制
  60. const startMessageId = newId();
  61. const taskId = newId();
  62. let stopSent = false;
  63. const send = (payload) => {
  64. const text = JSON.stringify(payload);
  65. log('→ 发送', text.length > 400 ? `${text.slice(0, 400)}…` : text);
  66. ws.send(text);
  67. };
  68. /**
  69. * 发一条指令。⚠️ **每条指令都要带 appkey**(实测踩过:只有 start 带 appkey 时,
  70. * stop 会回 `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`),
  71. * 所以这里统一注入 header,杜绝漏字段。
  72. */
  73. const sendCommand = ({ name, task, payload }) => {
  74. send({
  75. header: {
  76. appkey: credentials.appkey,
  77. message_id: newId(),
  78. task_id: taskId,
  79. namespace: 'SpeechTranscriber',
  80. task,
  81. name,
  82. },
  83. ...(payload ? { payload } : {}),
  84. });
  85. };
  86. const finish = (code) => {
  87. try {
  88. ws.close();
  89. } catch {
  90. /* ignore */
  91. }
  92. setTimeout(() => process.exit(code), 200);
  93. };
  94. const timer = setTimeout(() => {
  95. log('⚠️ 超时退出(网关未在预期时间内给终态)');
  96. finish(1);
  97. }, 25000);
  98. ws.onopen = () => {
  99. log('✅ WebSocket 已连接');
  100. sendCommand({
  101. name: 'StartTranscription',
  102. task: 'start',
  103. payload: {
  104. format: 'pcm',
  105. sample_rate: 16000,
  106. enable_intermediate_result: true,
  107. enable_punctuation_prediction: true,
  108. enable_inverse_text_normalization: true,
  109. enable_voice_detection: true,
  110. max_sentence_silence: 800,
  111. },
  112. });
  113. if (audioPcm) {
  114. // 按 100ms 一帧推流(16k/16bit/mono → 3200 字节 = 1600 采样)
  115. const pcm = audioPcm;
  116. const frame = 3200;
  117. let offset = 0;
  118. const pump = setInterval(() => {
  119. if (offset >= pcm.length) {
  120. clearInterval(pump);
  121. log(`音频推完(${pcm.length} 字节)`);
  122. if (!stopSent) {
  123. stopSent = true;
  124. sendCommand({ name: 'StopTranscription', task: 'stop' });
  125. }
  126. return;
  127. }
  128. const slice = pcm.subarray(offset, Math.min(offset + frame, pcm.length));
  129. offset += slice.length;
  130. if (ws.readyState === WebSocket.OPEN) ws.send(slice);
  131. }, 100);
  132. } else {
  133. setTimeout(() => {
  134. if (stopSent) return;
  135. stopSent = true;
  136. sendCommand({ name: 'StopTranscription', task: 'stop' });
  137. }, waitMs);
  138. }
  139. };
  140. ws.onmessage = (event) => {
  141. const text = typeof event.data === 'string' ? event.data : '';
  142. log('← 收到', raw(event.data));
  143. if (text) {
  144. try {
  145. const body = JSON.parse(text);
  146. const name = body?.header?.name || '(无 header.name)';
  147. log(` 事件=${name} status=${body?.header?.status ?? body?.payload?.status ?? ''} 结果=${JSON.stringify(body?.payload?.result ?? '')}`);
  148. if (name === 'TranscriptionCompleted') finish(0);
  149. if (name === 'TaskFailed') finish(3);
  150. } catch {
  151. log(' (不是 JSON)');
  152. }
  153. }
  154. };
  155. ws.onerror = (event) => log('❌ WebSocket 错误', event?.message || event?.type || '');
  156. ws.onclose = (event) => {
  157. clearTimeout(timer);
  158. log(`连接关闭 code=${event.code} reason=${event.reason || ''}`);
  159. };