| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- /**
- * 验证「实时语音识别换成阿里云 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 { createNlsToken, NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials } from './nls-token.mjs';
- import { canSynthesizeSpeech, readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs';
- const {
- NLS_FRAME_MS,
- 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://'));
- }
- // ── 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 } = await createNlsToken(credentials);
- 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));
- }
- // 旧实现仍在仓库里(切回去的退路),别被顺手删掉
- check('旧的厂商 SDK 仍在(切回去只改 import)', existsSync('src/three-libs/asr/index.ts'));
- }
- console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
- process.exit(fail ? 1 : 0);
|