/** * 验证「实时语音识别换成阿里云 NLS」这条链路。 * * 分两段: * 【1】纯协议(不需要网络/凭据):帧结构、事件折叠、16k PCM 编码 * 【2】真实端到端(需要凭据 + Windows TTS):说一句话 → 看识别结果 * —— 用的是**前端同一份协议代码**(`src/three-libs/asr/nls-protocol.ts`), * 所以它绿了就意味着前端那套帧真的被网关接受 * * 怎么跑(先打包协议层): * npx esbuild harness/tools/_entry-nls.ts --bundle --format=esm \ * --outfile=harness/tools/_nls.mjs --alias:@=./src --define:import.meta.env='{}' * node harness/tools/verify-nls-realtime.mjs * * 凭据从 `.env.development.local` 读(见 harness/tools/nls-token.mjs); * 没有凭据或没有 TTS 时,【2】会**明确跳过**而不是假装通过。 */ import { existsSync } from 'node:fs'; import { NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials, resolveNlsToken } from './nls-token.mjs'; import { canSynthesizeSpeech, readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs'; const { NLS_FRAME_MS, NLS_FLUSH_TICK_MS, NLS_MAX_FRAMES_PER_TICK, NLS_DRAIN_RATE, NLS_GATEWAY_URL, NLS_SAMPLE_RATE, buildStartTranscription, buildStopTranscription, createNlsTranscriptState, describeNlsFailure, encodePcm16kMono, getNlsEventName, parseNlsMessage, reduceNlsTranscript, } = await import('./_nls.mjs'); let pass = 0; let fail = 0; const check = (name, ok, extra = '') => { if (ok) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; // ── 1. 纯协议 ─────────────────────────────────────────────────────────────── console.log('\n【1】帧结构与事件折叠(纯函数,用前端同一份实现)'); { const start = JSON.parse(buildStartTranscription({ appkey: 'APPKEY', taskId: 'TASK', messageId: 'MSG' })); check('StartTranscription:namespace 固定 SpeechTranscriber', start.header.namespace === 'SpeechTranscriber' && start.header.name === 'StartTranscription'); check('StartTranscription:header 带 appkey(网关要求)', start.header.appkey === 'APPKEY'); check('StartTranscription:task=start,显式传入的 message_id/task_id 原样带上', start.header.task === 'start' && start.header.message_id === 'MSG' && start.header.task_id === 'TASK'); // **不传 task_id 时必须自己生成**(实测:空串会被网关拒 // `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!`) const auto = JSON.parse(buildStartTranscription({ appkey: 'APPKEY' })); check('不传 id 时自动生成 32 位十六进制的 message_id / task_id(不能是空串)', /^[0-9a-f]{32}$/.test(auto.header.message_id) && /^[0-9a-f]{32}$/.test(auto.header.task_id), JSON.stringify([auto.header.message_id, auto.header.task_id])); check('两次生成的 task_id 不重复', auto.header.task_id !== JSON.parse(buildStartTranscription({ appkey: 'APPKEY' })).header.task_id); check('payload:16k / pcm / 开中间结果 / 开标点 / 开 VAD', start.payload.format === 'pcm' && start.payload.sample_rate === 16000 && start.payload.enable_intermediate_result === true && start.payload.enable_punctuation_prediction === true && start.payload.enable_voice_detection === true); check('payload:max_sentence_silence 落在网关允许的 200~2000', start.payload.max_sentence_silence >= 200 && start.payload.max_sentence_silence <= 2000, String(start.payload.max_sentence_silence)); const stop = JSON.parse(buildStopTranscription({ appkey: 'APPKEY', taskId: 'TASK', messageId: 'MSG2' })); // 实测踩过的坑:只有 start 带 appkey 时,stop 会回 // TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey! check('**StopTranscription 也带 appkey**(漏了网关会 40000002)', stop.header.appkey === 'APPKEY'); check('StopTranscription:task=stop + task_id 与 start 一致', stop.header.task === 'stop' && stop.header.task_id === 'TASK'); // 事件折叠:载荷取自探针打真实网关抓到的原文 let state = createNlsTranscriptState(); const events = []; const feed = (raw) => { const { state: next, event } = reduceNlsTranscript(state, parseNlsMessage(raw)); state = next; if (event) events.push(event); }; feed(JSON.stringify({ header: { name: 'TranscriptionStarted', status: 20000000 } })); feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 1, result: '青浦区高新' } })); feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 1, result: '青浦区高新技术企' } })); feed(JSON.stringify({ header: { name: 'SentenceEnd' }, payload: { index: 1, result: '青浦区高新技术企业奖励是多少钱?' } })); feed(JSON.stringify({ header: { name: 'TranscriptionResultChanged' }, payload: { index: 2, result: '我想问' } })); feed(JSON.stringify({ header: { name: 'TranscriptionCompleted', status: 20000000 } })); // 事件序列:mid(本句中间) → mid(本句中间) → fin(定稿) → mid(下一句中间) check('TranscriptionStarted / Completed 不产出文本事件(4 条文本事件 = 2 mid + 1 fin + 1 mid)', events.length === 4 && events.map((e) => e.type).join(',') === 'mid,mid,fin,mid', JSON.stringify(events.map((e) => e.type))); check('中间结果 → type=mid,transformText = 已定稿全文 + 本句中间结果(边说边出字)', events[0].type === 'mid' && events[0].transformText === '青浦区高新', JSON.stringify(events[0])); check('中间结果会被后续结果**覆盖**(不是追加)', events[1].type === 'mid' && events[1].transformText === '青浦区高新技术企', JSON.stringify(events[1])); check('SentenceEnd(定稿)→ type=fin,且**追加**不覆盖', events[2].type === 'fin' && events[2].transformText === '青浦区高新技术企业奖励是多少钱?', JSON.stringify(events[2])); check('下一句的中间结果前缀带上已定稿的那句', events[3].type === 'mid' && events[3].transformText === '青浦区高新技术企业奖励是多少钱?我想问', JSON.stringify(events[3])); check('最终定稿全文只由 SentenceEnd 拼成(中间结果不污染)', state.finalText === '青浦区高新技术企业奖励是多少钱?', JSON.stringify(state.finalText)); check('TaskFailed 的错误描述带 status 与 status_text(能直接给用户看)', describeNlsFailure(parseNlsMessage(JSON.stringify({ header: { name: 'TaskFailed', status: 40000002, status_text: 'Gateway:MESSAGE_INVALID:Missing message appkey!' }, }))).includes('40000002') && describeNlsFailure(parseNlsMessage('{"header":{"status":40000002}}')).length > 0); check('非法 JSON 不抛异常(忽略即可)', parseNlsMessage('not json') === null && getNlsEventName(null) === ''); } console.log('\n【2】16k / 16bit / 单声道 编码'); { // 100ms 的 48k 输入 → 应为 1600 采样 = 3200 字节(旧 SDK 写死 44100,这里是按实际采样率算的) const input48k = new Float32Array(4800); const encoded48k = encodePcm16kMono(input48k, 48000); check('48k → 16k:100ms 得到 3200 字节(1600 采样 × 2)', encoded48k.length === 3200, String(encoded48k.length)); const encoded44k = encodePcm16kMono(new Float32Array(4410), 44100); check('44.1k → 16k:同样 100ms 得到 3200 字节(不再写死 44100 也能对)', encoded44k.length === 3200, String(encoded44k.length)); // 值域与字节序:满幅正弦的第一个正峰应是 0x7FFF(小端 0xFF 0x7F) const half = new Float32Array(1600); for (let i = 0; i < half.length; i++) half[i] = Math.sin((2 * Math.PI * i) / 100); const pcm = encodePcm16kMono(half, NLS_SAMPLE_RATE); const view = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); const firstPeak = view.getInt16(2 * 24, true); // 第 25 个采样 ≈ sin(π/2)=1 附近 check('int16 小端写入(正值取 0x7fff 附近)', firstPeak > 32000, String(firstPeak)); check('超出 ±1 的采样被钳位(不溢出成噪声)', encodePcm16kMono(new Float32Array([2, -2]), NLS_SAMPLE_RATE).length === 4); check('帧长与实时节奏自洽:NLS_FRAME_MS × 16k × 2 字节 = 3200', (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000 === 3200); check('网关地址是上海地域的 ws/v1 端点', NLS_GATEWAY_URL.endsWith('/ws/v1') && NLS_GATEWAY_URL.startsWith('wss://')); // ⚠️ 用户 2026-09-23 反馈「出字延迟高」后加的:**排空速率必须 > 1× 实时**。 // 连上网关前(取 token + 握手)麦克风已在采集,若只按 1× 排空那段积压永远排不掉 // ⇒ 整体永久慢半拍(旧厂商 SDK 是 2× 排空,所以它能追上,这是切过来后退化的一处)。 check('排空速率 > 1× 实时(否则握手期间的积压永远排不掉 → 永久慢半拍)', NLS_DRAIN_RATE > 1, `实测 NLS_DRAIN_RATE=${NLS_DRAIN_RATE}×`); check('排空速率也不至于把网关灌爆(≤ 8× 实时)', NLS_DRAIN_RATE <= 8, `${NLS_DRAIN_RATE}×`); check('断句阈值在网关允许区间 200~2000,且比网关默认 800 更早出定稿', JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence >= 200 && JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence <= 2000 && JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence <= 800, String(JSON.parse(buildStartTranscription({ appkey: 'A' })).payload.max_sentence_silence)); } // ── 3. 真实端到端(打真实网关)─────────────────────────────────────────────── console.log('\n【3】打真实网关:说一句话 → 看识别结果'); const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env }); if (!credentials.ak || !credentials.sk || !credentials.appkey) { console.log(' ⏭ 跳过:没配 NLS 凭据(.env.development.local 的 ALIYUN_NLS_AK / SK / APPKEY)'); } else if (!canSynthesizeSpeech()) { console.log(' ⏭ 跳过:真实识别这一段需要 Windows 自带 TTS 造语音'); } else { const SPOKEN = '青浦区高新技术企业奖励是多少钱'; const wav = synthesizeSpeechToWav(SPOKEN); const { pcm, note } = readAudioAsPcm16kMono(wav); console.log(` (TTS 音频 ${pcm.length} 字节 ≈ ${(pcm.length / 32000).toFixed(2)}s;${note})`); const { token, source } = await resolveNlsToken(); console.log(` (token 来源:${source})`); const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`); const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 }; let state = createNlsTranscriptState(); const feed = (raw) => { const message = parseNlsMessage(raw); const name = getNlsEventName(message); if (name === 'TranscriptionStarted') seen.started = true; if (name === 'TranscriptionCompleted') seen.completed = true; if (name === 'TaskFailed') seen.failed = describeNlsFailure(message); const { state: next, event } = reduceNlsTranscript(state, message); state = next; if (event) { if (event.type === 'mid') seen.mids++; if (event.type === 'fin') seen.fins++; } }; // 用**前端同一份**帧构建器:发出去的就是产品会发的那一帧 const startFrame = buildStartTranscription({ appkey: credentials.appkey }); const taskId = JSON.parse(startFrame).header.task_id; const finished = new Promise((resolve) => { let stopSent = false; const stop = () => { if (stopSent) return; stopSent = true; socket.send(buildStopTranscription({ appkey: credentials.appkey, taskId })); }; socket.onopen = () => { socket.send(startFrame); let offset = 0; const frame = 3200; // 100ms const pump = setInterval(() => { if (offset >= pcm.length) { clearInterval(pump); stop(); return; } if (socket.readyState !== WebSocket.OPEN) return; socket.send(pcm.subarray(offset, Math.min(offset + frame, pcm.length))); offset += frame; }, NLS_FRAME_MS); }; socket.onmessage = (event) => { feed(event.data); if (seen.completed || seen.failed) { socket.close(); resolve(); } }; socket.onerror = () => resolve(); setTimeout(() => { try { socket.close(); } catch { /* ignore */ } resolve(); }, 30000); }); await finished; check('收到 TranscriptionStarted(token/appkey 有效)', seen.started, seen.failed || '未收到'); check('没有 TaskFailed', !seen.failed, seen.failed); check('**识别期间就收到中间结果(边说边出字)**', seen.mids > 0, `mids=${seen.mids}`); check('收到定稿的 SentenceEnd', seen.fins > 0, `fins=${seen.fins}`); check('收到 TranscriptionCompleted(收尾干净)', seen.completed, seen.failed || '未收到'); const text = state.finalText; console.log(` (识别结果:${JSON.stringify(text)})`); check('识别出的文本包含关键内容(青浦 / 奖励)', text.includes('青浦') && text.includes('奖励'), JSON.stringify(text)); check('识别结果长度合理(≥ 8 字)', text.length >= 8, String(text.length)); } // ── 4. 接线(源码级:这段是 Vue 壳组件里的事,node 跑不起来)──────────────── console.log('\n【4】两个壳组件已切到 NLS 实现(源码级断言)'); { const { readFileSync } = await import('node:fs'); for (const file of [ 'src/components/BusinessAssistantPC.vue', 'src/components/BusinessAssistantMobile.vue', ]) { const source = readFileSync(file, 'utf8'); check(`${file}:import 的是 NLS 实现`, /import ASR from '@\/three-libs\/asr\/nls';/.test(source)); check(`${file}:不再 import 旧的厂商 SDK`, !/import ASR from '@\/three-libs\/asr\/index';/.test(source)); check(`${file}:不再硬编码 appKey(改由端点下发)`, !/new ASR\("/.test(source) && /new ASR\(undefined/.test(source)); // 契约没变:这几个调用点必须还在(换了实现就得跟着改) check(`${file}:openMicroPhone / startRecord / openSocket / transform 调用点齐全`, /openMicroPhone\(\)/.test(source) && /startRecord\(\)/.test(source) && /openSocket\(\)/.test(source) && /\.transform\(\)/.test(source) && /\.close\(\)/.test(source) && /\.clear\(\)/.test(source)); } // 2026-09-24 用户要求「B 全部删掉」:旧厂商 SDK 连同 VITE_ASR / VITE_XF_ASR / /asr 代理一起删了 // —— 所以这条断言从「旧实现仍在」反过来:**确认它已经不在**(免得哪天有人误加回来) check('旧的厂商 SDK 已删除(2026-09-24 用户要求)', !existsSync('src/three-libs/asr/index.ts')); check('旧 ASR 的 env 也删干净了(VITE_ASR / VITE_XF_ASR)', !readFileSync('.env.development', 'utf8').includes('VITE_ASR') && !readFileSync('.env.development', 'utf8').includes('VITE_XF_ASR')); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);