probe-nls-realtime.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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. resolveNlsToken,
  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. /** 端点静音断句阈值(0 = 用协议层默认 800)。延迟高先怀疑它 */
  36. const silenceArg = process.argv.find((a) => a.startsWith('--silence='));
  37. const maxSentenceSilence = silenceArg ? Number(silenceArg.slice('--silence='.length)) : 0;
  38. /** 语义断句(试它是否能提高中间结果频率) */
  39. const semanticDetection = process.argv.includes('--semantic');
  40. /** 模拟握手期间已攒下的音频(毫秒) */
  41. const prebufferMs = Number((process.argv.find((a) => a.startsWith('--prebuffer=')) || '').slice(12)) || 0;
  42. /** 每个 tick 最多发几帧(100ms 一帧):1 = 1× 实时,4 = 4× 实时 */
  43. const drainFrames = Number((process.argv.find((a) => a.startsWith('--drain=')) || '').slice(8)) || 1;
  44. const stamp = () => new Date().toISOString().slice(11, 23);
  45. const log = (...args) => console.log(`[${stamp()}]`, ...args);
  46. const raw = (value) => (typeof value === 'string' ? value : `(binary ${value?.byteLength ?? value?.length ?? '?'} bytes)`);
  47. let audioFile = audioArg ? audioArg.slice('--audio='.length) : '';
  48. if (sayArg) {
  49. const text = sayArg.slice('--say='.length);
  50. log(`用系统 TTS 合成语音:「${text}」`);
  51. audioFile = synthesizeSpeechToWav(text);
  52. }
  53. if (audioFile && !existsSync(audioFile)) {
  54. console.error(`音频文件不存在:${audioFile}`);
  55. process.exit(2);
  56. }
  57. let audioPcm = null;
  58. if (audioFile) {
  59. const { pcm, note } = readAudioAsPcm16kMono(audioFile);
  60. audioPcm = pcm;
  61. log(`音频:${audioFile}(${audioPcm.length} 字节 = ${(audioPcm.length / 32000).toFixed(2)} 秒)${note}`);
  62. }
  63. const { token, expireAt, source } = await resolveNlsToken();
  64. log(`token 到手(来源=${source},按 ${new Date(expireAt).toLocaleString('zh-CN')} 过期)appkey=${maskCredential(credentials.appkey)}`);
  65. const url = `${GATEWAY}?token=${encodeURIComponent(token)}`;
  66. log('连接', url.replace(/token=[^&]+/, 'token=****'));
  67. const ws = new WebSocket(url);
  68. const newId = () => randomUUID().replace(/-/g, ''); // 文档要求 32 位十六进制
  69. const startMessageId = newId();
  70. const taskId = newId();
  71. let stopSent = false;
  72. let audioStartAt = 0; // 第一帧音频发出的时刻
  73. let sentBytes = 0; // 已推给网关的音频字节数
  74. const send = (payload) => {
  75. const text = JSON.stringify(payload);
  76. log('→ 发送', text.length > 400 ? `${text.slice(0, 400)}…` : text);
  77. ws.send(text);
  78. };
  79. /**
  80. * 发一条指令。⚠️ **每条指令都要带 appkey**(实测踩过:只有 start 带 appkey 时,
  81. * stop 会回 `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`),
  82. * 所以这里统一注入 header,杜绝漏字段。
  83. */
  84. const sendCommand = ({ name, task, payload }) => {
  85. send({
  86. header: {
  87. appkey: credentials.appkey,
  88. message_id: newId(),
  89. task_id: taskId,
  90. namespace: 'SpeechTranscriber',
  91. task,
  92. name,
  93. },
  94. ...(payload ? { payload } : {}),
  95. });
  96. };
  97. const finish = (code) => {
  98. try {
  99. ws.close();
  100. } catch {
  101. /* ignore */
  102. }
  103. setTimeout(() => process.exit(code), 200);
  104. };
  105. const timer = setTimeout(() => {
  106. log('⚠️ 超时退出(网关未在预期时间内给终态)');
  107. finish(1);
  108. }, 25000);
  109. ws.onopen = () => {
  110. log('✅ WebSocket 已连接');
  111. sendCommand({
  112. name: 'StartTranscription',
  113. task: 'start',
  114. payload: {
  115. format: 'pcm',
  116. sample_rate: 16000,
  117. enable_intermediate_result: true,
  118. enable_punctuation_prediction: true,
  119. enable_inverse_text_normalization: true,
  120. enable_voice_detection: true,
  121. ...(maxSentenceSilence ? { max_sentence_silence: maxSentenceSilence } : {}),
  122. ...(semanticDetection ? { enable_semantic_sentence_detection: true } : {}),
  123. },
  124. });
  125. if (audioPcm) audioStartAt = Date.now();
  126. if (audioPcm) {
  127. // 模拟真实采集:**可用音频 = 握手积压 + 已过时间**(1× 实时到达),
  128. // 每个 tick 最多排空 drainFrames 帧 —— 这正是前端 nls.ts 的排空策略。
  129. // `--drain=1` 复现"只按 1× 排空"的旧行为(积压永远排不掉);
  130. // `--drain=4` 是现在前端的策略(积压时最多 4× 追)。
  131. const pcm = audioPcm;
  132. const frame = 3200; // 100ms
  133. const bytesPerMs = 32; // 16k/16bit/mono → 32 字节/ms
  134. const startedAt = Date.now();
  135. let offset = 0;
  136. const pump = setInterval(() => {
  137. const elapsedMs = Date.now() - startedAt;
  138. const available = Math.min(pcm.length, Math.round((prebufferMs + elapsedMs) * bytesPerMs));
  139. let sentThisTick = 0;
  140. while (offset < available && sentThisTick < drainFrames) {
  141. const end = Math.min(offset + frame, pcm.length, available);
  142. const slice = pcm.subarray(offset, end);
  143. offset = end;
  144. if (ws.readyState === WebSocket.OPEN) {
  145. ws.send(slice);
  146. sentBytes += slice.length;
  147. }
  148. sentThisTick += 1;
  149. }
  150. if (offset >= pcm.length) {
  151. clearInterval(pump);
  152. log(`音频推完(${pcm.length} 字节)`);
  153. if (!stopSent) {
  154. stopSent = true;
  155. sendCommand({ name: 'StopTranscription', task: 'stop' });
  156. }
  157. }
  158. }, 100);
  159. } else {
  160. setTimeout(() => {
  161. if (stopSent) return;
  162. stopSent = true;
  163. sendCommand({ name: 'StopTranscription', task: 'stop' });
  164. }, waitMs);
  165. }
  166. };
  167. ws.onmessage = (event) => {
  168. const text = typeof event.data === 'string' ? event.data : '';
  169. log('← 收到', raw(event.data));
  170. if (text) {
  171. try {
  172. const body = JSON.parse(text);
  173. const name = body?.header?.name || '(无 header.name)';
  174. const gwMs = Number(body?.payload?.time ?? NaN);
  175. const sentMs = Math.round((sentBytes / 32000) * 1000);
  176. // 感知延迟 = 「麦克风已经说了多少」−「网关处理到第几毫秒」。
  177. // 用采集量(积压 + 已过时间)而不是发送量来算 —— 用户感受到的是前者。
  178. const capturedMs = audioStartAt ? prebufferMs + (Date.now() - audioStartAt) : sentMs;
  179. const lag = Number.isFinite(gwMs) ? capturedMs - gwMs : null;
  180. log(
  181. ` 事件=${name} 网关已处理=${Number.isFinite(gwMs) ? gwMs + 'ms' : '-'} 已推=${sentMs}ms ` +
  182. `${lag === null ? '' : `**感知延迟=${lag}ms** `}结果=${JSON.stringify(body?.payload?.result ?? '')}`
  183. );
  184. if (name === 'TranscriptionCompleted') finish(0);
  185. if (name === 'TaskFailed') finish(3);
  186. } catch {
  187. log(' (不是 JSON)');
  188. }
  189. }
  190. };
  191. ws.onerror = (event) => log('❌ WebSocket 错误', event?.message || event?.type || '');
  192. ws.onclose = (event) => {
  193. clearTimeout(timer);
  194. log(`连接关闭 code=${event.code} reason=${event.reason || ''}`);
  195. };