/** * 在 node 里**完整复刻浏览器那条采集/组帧路径**,打真实网关看能不能出字。 * * 为什么要有它(2026-09-24):用户报「进了录音状态但不出字」,控制台日志显示 * 采集在跑(48k/2048 样本一块)、音频也一帧帧推上去了(34 秒),但网关**一条结果都没回**。 * 前端侧剩下的可疑点就是「按 2048 样本分块 → 各自重采样 → 拼成 3200 字节帧」这条路径 * (node 的探针推的是**预先压好的 16k 整帧**,走不到这条路径,所以它绿不代表浏览器这条绿)。 * * 这里用同一个 `encodePcm16kMono()`(`src/asr/nls-protocol.ts`,前端同一份代码): * ① 把 TTS 音频重采样到 **48k**(模拟 48k 设备) * ② 按 **2048 样本**切块,每块过 `encodePcm16kMono(chunk, 48000)` → 约 1366 字节 * ③ 按 **42.7ms 一块** 的节奏入队(模拟 ScriptProcessor 的回调节奏) * ④ 每 50ms 把队列里的块拼进 carry,凑够 3200 字节就发一帧(模拟 nls.ts 的 flush) * * 怎么跑(先打包协议层): * 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-browser-path.mjs */ import { readAudioAsPcm16kMono, resampleTo16k, synthesizeSpeechToWav } from './nls-test-audio.mjs'; import { NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials, resolveNlsToken } from './nls-token.mjs'; import { readFileSync } from 'node:fs'; const { NLS_GATEWAY_URL, buildStartTranscription, buildStopTranscription, createNlsTranscriptState, parseNlsMessage, getNlsEventName, reduceNlsTranscript } = await import( './_nls.mjs' ); const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env }); if (!credentials.appkey) { console.error('缺 appkey(ALIYUN_NLS_APPKEY)'); process.exit(2); } const SPOKEN = process.argv.find((a) => a.startsWith('--say='))?.slice(6) || '青浦区高新技术企业奖励是多少钱'; const DEVICE_RATE = 48000; // 模拟浏览器 AudioContext 的 48k const CHUNK_SAMPLES = 2048; // 浏览器实际给的 buffer(用户日志里就是 2048) const CHUNK_MS = (CHUNK_SAMPLES / DEVICE_RATE) * 1000; // ≈42.7ms const FRAME_BYTES = 3200; // 100ms @16k/16bit/mono 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); } }; // ① TTS 造语音 → 16k PCM(工具会给 16k,这里再升到 48k 当"设备原始数据") const wav = synthesizeSpeechToWav(SPOKEN); const { pcm: pcm16k } = readAudioAsPcm16kMono(wav); const samples16k = new Int16Array(pcm16k.buffer, pcm16k.byteOffset, pcm16k.length / 2); const samples48k = (() => { const count = Math.round((samples16k.length * DEVICE_RATE) / 16000); const out = new Int16Array(count); const step = (samples16k.length - 1) / Math.max(count - 1, 1); for (let i = 0; i < count; i++) { const pos = i * step; const before = Math.floor(pos); const after = Math.min(Math.ceil(pos), samples16k.length - 1); out[i] = Math.round(samples16k[before] + (samples16k[after] - samples16k[before]) * (pos - before)); } return out; })(); const float48k = Float32Array.from(samples48k, (v) => v / 32768); // ② 按 2048 样本切块 → 每块独立重采样(与浏览器一模一样) const { encodePcm16kMono } = await import('./_nls.mjs'); const chunks = []; for (let i = 0; i < float48k.length; i += CHUNK_SAMPLES) { const slice = float48k.subarray(i, Math.min(i + CHUNK_SAMPLES, float48k.length)); if (slice.length < CHUNK_SAMPLES) break; // 尾部不足一块丢弃(浏览器也不会给半块) chunks.push(encodePcm16kMono(slice, DEVICE_RATE)); } console.log(`\n【准备】设备 ${DEVICE_RATE}Hz / 每块 ${CHUNK_SAMPLES} 采样(≈${CHUNK_MS.toFixed(1)}ms)`); console.log(` 共 ${chunks.length} 块,每块编码后约 ${chunks[0]?.length} 字节,总时长 ≈ ${((chunks.length * CHUNK_MS) / 1000).toFixed(2)}s`); const { token, source } = await resolveNlsToken(); console.log(`【连接】token 来源=${source}`); const startFrame = buildStartTranscription({ appkey: credentials.appkey }); const taskId = JSON.parse(startFrame).header.task_id; const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`); let state = createNlsTranscriptState(); const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 }; const done = 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); // ③④ 模拟浏览器:块按 42.7ms 的节奏到,flush 每 50ms 拼帧发出去 let nextChunkAt = Date.now(); let chunkIndex = 0; let carry = new Uint8Array(0); const queue = []; const pump = setInterval(() => { // 到点的块入队(模拟 onaudioprocess 回调) while (chunkIndex < chunks.length && Date.now() >= nextChunkAt) { queue.push(chunks[chunkIndex]); chunkIndex += 1; nextChunkAt += CHUNK_MS; } if (queue.length && socket.readyState === WebSocket.OPEN) { const merged = new Uint8Array(carry.length + queue.reduce((n, c) => n + c.length, 0)); merged.set(carry, 0); let offset = carry.length; for (const chunk of queue) { merged.set(chunk, offset); offset += chunk.length; } carry = merged; queue.length = 0; } let sent = 0; while (carry.length >= FRAME_BYTES && sent < 2) { socket.send(carry.subarray(0, FRAME_BYTES)); carry = carry.subarray(FRAME_BYTES); sent += 1; } if (chunkIndex >= chunks.length && queue.length === 0 && carry.length === 0) { clearInterval(pump); stop(); } }, 50); }; socket.onmessage = (event) => { const message = parseNlsMessage(event.data); const name = getNlsEventName(message); if (name === 'TranscriptionStarted') seen.started = true; if (name === 'TranscriptionCompleted') seen.completed = true; if (name === 'TaskFailed') seen.failed = String(message?.header?.status_text || message?.payload?.message || ''); const { state: next, event: payload } = reduceNlsTranscript(state, message); state = next; if (payload?.type === 'mid') seen.mids += 1; if (payload?.type === 'fin') seen.fins += 1; if (name === 'TaskFailed') console.warn('[nls] TaskFailed:', seen.failed); }; socket.onerror = () => resolve(); setTimeout(() => { try { socket.close(); } catch { /* ignore */ } resolve(); }, 40000); }); await done; console.log('\n【结果】'); check('收到 TranscriptionStarted', seen.started, seen.failed); // ⚠️ 实测:收尾后会收到一条 `Gateway:IDLE_TIMEOUT`("the last directive is 'StartTranscription'")—— // 那是**本脚本推完音频就 stop、会话随即结束**的产物(网关按"最后一条指令是 start"判空闲), // 不影响识别结果本身,所以只作提示、不算失败。真要长静音时它可能提前掐会话, // 与「浏览器不出字」是另一回事(那边一条结果都没回,也没报错)。 if (seen.failed) console.log(` ℹ️ 收尾收到 TaskFailed(预期内):${seen.failed}`); check('**收到了中间结果(说明这条路径也能出字)**', seen.mids > 0, `mids=${seen.mids}`); check('收到了定稿 SentenceEnd', seen.fins > 0, `fins=${seen.fins}`); const text = state.finalText; console.log(` (识别结果:${JSON.stringify(text)})`); check('识别文本包含关键词(青浦 / 奖励)', text.includes('青浦') && text.includes('奖励'), JSON.stringify(text)); console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);