|
|
@@ -0,0 +1,241 @@
|
|
|
+/**
|
|
|
+ * 验证「实时语音识别换成阿里云 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));
|
|
|
+}
|
|
|
+
|
|
|
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
|
|
|
+process.exit(fail ? 1 : 0);
|