| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- /**
- * 在 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);
|