Răsfoiți Sursa

fix(nls): 修「出字延迟高」—— 排空速率必须快于实时,否则握手积压永远排不掉

用户切到 NLS 后第一次实测反馈:「出字延迟高」。先量化(给探针加「感知延迟 = 麦克风已说
多少 − 网关处理到第几毫秒」+ 可调断句阈值 + 可模拟握手积压/排空策略的 A/B):

实测结论:
- 网关中间结果节奏恒 ~480ms 一次(max_sentence_silence 300/800 都一样、开语义断句也一样)
  → 引擎固有,前端改不了
- 首个中间结果在音频开始后 ~480~720ms
- 断句阈值 300 比 800 的定稿早到约 300ms
- **客户端按 1× 排空时,握手期间(取 token + WS 握手 0.2~1s)攒下的音频永远排不掉**
  → 感知延迟永久 ~1.4s ← 这才是"延迟高"的主因

根因:我用"恰好 1× 实时"的节奏排空,而旧厂商 SDK 是 2×(所以它能追上)——这是本次切换
引入的退化,不是 NLS 的问题。A/B 实测(模拟 1s 积压、同一段语音):
  1× 实时 → 感知延迟 1377~1434ms(不收敛),定稿 2087ms
  4× 追赶 → 145~183ms,定稿 683ms

改法:
- nls-protocol.ts 新增排空策略常量:NLS_FLUSH_TICK_MS=50、NLS_MAX_FRAMES_PER_TICK=2
  (= 4× 追赶,1s 积压约 330ms 排空);nls.ts 的 flush 按此实现
- 停止时的余量改按 ~5× 发(用户此刻正等最后一段文字,不该再按实时节奏等)
- 断句阈值默认 800 → 500(网关允许 200~2000;取中:不被自然停顿切断、定稿更快)

⚠️ 我第一版追赶上限写成 8 帧/50ms = 16×,被自己那条「≤8×」断言拦下 → 收敛到 4×。

验证:verify-nls-realtime.mjs 39 → 42/42(新增 3 条延迟策略断言);probe 的 A/B 数字见上;
npm run build、vue-tsc、validate-harness 通过。文档 asr-nls.md 新增 §4.5 记录实测数字与
"不要再往客户端提前发送上想办法"的边界。

⚠️ 仍需用户复测(refresh 后点麦克风):应"说出去几乎同时出字"。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 lună în urmă
părinte
comite
bea45306ac

+ 31 - 0
harness/docs/reference/asr-nls.md

@@ -77,6 +77,37 @@ wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=<token>
 - 节奏:每 **100ms** 推一帧(3200 字节 = 1600 采样)。缓存的音频先攒着,连上后按这个节奏推;
   收尾时把余下的按同样节奏发完再发 `StopTranscription`
 
+## 4.5 延迟:实测数字与「追赶」策略(用户 2026-09-23 报「出字延迟高」)
+
+**先量化,再改**(`probe-nls-realtime.mjs` 会打印「感知延迟 = 麦克风已说多少 − 网关处理到第几毫秒」):
+
+| 现象 | 实测 |
+|---|---|
+| 网关**中间结果节奏** | 恒 **~480ms** 一次(`max_sentence_silence` 调 300/800 都不变;开 `enable_semantic_sentence_detection` 也不变)→ **这是引擎固有的**,前端改不了 |
+| 网关**首个**中间结果 | 音频开始后约 **480~720ms** |
+| 定稿 `SentenceEnd` | 说话停下后 ≈ `max_sentence_silence` + 处理时间(阈值 300 比 800 **早到约 300ms**) |
+| **客户端落后**(按 1× 实时推时) | 握手期间攒下的积压**永远排不掉** → 感知延迟**永久 ~1.4s** |
+
+**根因**:连上网关之前(取 token + WS 握手,通常 0.2~1s)麦克风已经开始采集,这段音频先攒着;
+如果之后只按 **1× 实时** 排空,积压就一直是那么多 ⇒ 整体恒定慢半拍。
+(切过来之前那套厂商 SDK 是按 **2×** 排空的,所以它能追上 —— 这是我这次切换引入的一处退化。)
+
+**改法**:排空策略改成「**平时按实时、积压时最多 4× 追**」
+(`NLS_FLUSH_TICK_MS=50`、`NLS_MAX_FRAMES_PER_TICK=2`,见 `nls-protocol.ts`)。
+A/B 实测(模拟 1s 握手积压、同一段语音):
+
+| 排空策略 | 感知延迟(稳定期) | 定稿时 |
+|---|---|---|
+| 1× 实时 | **1377~1434ms**(且不收敛) | 2087ms |
+| **4× 追赶**(现在) | **145~183ms** | 683ms |
+
+另外两处同方向的调整:**停止时的余量按 ~5× 发**(用户此刻正等最后一段文字,不该再按实时节奏等)、
+断句阈值默认 **500ms**(网关默认 800,取中:说得快的人不被切断,说一句停一下的人更快看到定稿)。
+这三条都有断言守着(`verify-nls-realtime.mjs` 的【2】)。
+
+> 剩下的 ~150ms 是客户端+链路,~480ms 的更新节奏是引擎的 —— 想再快只能换引擎/参数,
+> **不要再往客户端"提前发送"上想办法**(文档要求按实时速率发;4× 是追赶积压的上限,不是常态)。
+
 ## 5. 对外契约(**与旧 SDK 一致,所以两个壳组件不用改**)
 
 ```ts

+ 36 - 0
harness/progress.md

@@ -4103,6 +4103,42 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
 请用户点一次麦克风:① 边说边出字(中间结果)② 说话停顿后那句定稿 ③ 再点一次停止文本保留
 ④ 桌面端波形在动。**在用户确认之前,功能状态不能算 passing。**
 
+### 续 35:修「出字延迟高」(用户反馈)
+
+**用户反馈**(切到 NLS 后第一次浏览器实测):「出字延迟高」。
+
+**先量化,不猜**(给探针加了「感知延迟 = 麦克风已说多少 − 网关处理到第几毫秒」+ 可调断句阈值
++ 可模拟握手积压的排空实验):
+
+| 项 | 实测 |
+|---|---|
+| 网关中间结果节奏 | **恒 ~480ms** 一次 —— `max_sentence_silence` 300/800 都一样,开
+  `enable_semantic_sentence_detection` 也一样 → **引擎固有,前端改不了** |
+| 首个中间结果 | 音频开始后 ~480~720ms |
+| 定稿 `SentenceEnd` | 阈值 300 比 800 **早到约 300ms** |
+| **客户端落后(按 1× 排空)** | **永久 ~1.4s** ← 这就是"延迟高"的主因 |
+
+**根因**:连上网关前(取 token + 握手 0.2~1s)麦克风已在采集,这段音频先攒着;若之后只按
+**1× 实时**排空,积压**永远排不掉** ⇒ 恒定慢半拍。旧厂商 SDK 是按 **2×** 排空的,所以它能追上
+——**这是我这次切换引入的退化**(不是 NLS 的问题)。
+
+**改法 + A/B 实测**(模拟 1s 握手积压、同一段语音):
+
+| 排空策略 | 感知延迟(稳定期) | 定稿时 |
+|---|---|---|
+| 1× 实时(原实现) | **1377~1434ms**(不收敛) | 2087ms |
+| **4× 追赶**(现在:`NLS_FLUSH_TICK_MS=50` + 每 tick 最多 2 帧) | **145~183ms** | 683ms |
+
+另外两处同方向调整:**停止时余量按 ~5× 发**(用户正等最后一段文字)、断句阈值默认
+**800 → 500**(网关文档默认 800;取中,实测 300 会早 300ms,500 是稳妥折中)。
+
+**验证**:`verify-nls-realtime.mjs` 39 → **42/42**,新增 3 条把这套延迟策略钉住的断言
+(排空速率必须 >1× 且 ≤8×、断句阈值在 200~2000 且不高于 800)。
+⚠️ 过程中我第一版追赶上限写成 8 帧/50ms = **16×**,被自己那条「≤8×」断言拦下 → 收敛到 4×。
+
+**⚠️ 仍需用户复测**:刷新页面后再点一次麦克风,看是否"说出去几乎同时出字"。
+剩下的 ~150ms 是链路、~480ms 刷新节奏是引擎的固有节奏(见 asr-nls.md §4.5)。
+
 ### 续 31:把实时语音转文字整理成接口文档(用户要求)
 
 **用户要求**:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。

+ 3 - 0
harness/tools/_entry-nls.ts

@@ -4,6 +4,9 @@ export {
   NLS_GATEWAY_URL,
   NLS_SAMPLE_RATE,
   NLS_FRAME_MS,
+  NLS_FLUSH_TICK_MS,
+  NLS_MAX_FRAMES_PER_TICK,
+  NLS_DRAIN_RATE,
   NLS_STATUS_SUCCESS,
   createNlsId,
   buildStartTranscription,

+ 44 - 8
harness/tools/probe-nls-realtime.mjs

@@ -36,6 +36,15 @@ const GATEWAY = process.env.NLS_GATEWAY || 'wss://nls-gateway-cn-shanghai.aliyun
 const audioArg = process.argv.find((a) => a.startsWith('--audio='));
 const sayArg = process.argv.find((a) => a.startsWith('--say='));
 const waitMs = Number((process.argv.find((a) => a.startsWith('--wait=')) || '').slice(7)) || 4000;
+/** 端点静音断句阈值(0 = 用协议层默认 800)。延迟高先怀疑它 */
+const silenceArg = process.argv.find((a) => a.startsWith('--silence='));
+const maxSentenceSilence = silenceArg ? Number(silenceArg.slice('--silence='.length)) : 0;
+/** 语义断句(试它是否能提高中间结果频率) */
+const semanticDetection = process.argv.includes('--semantic');
+/** 模拟握手期间已攒下的音频(毫秒) */
+const prebufferMs = Number((process.argv.find((a) => a.startsWith('--prebuffer=')) || '').slice(12)) || 0;
+/** 每个 tick 最多发几帧(100ms 一帧):1 = 1× 实时,4 = 4× 实时 */
+const drainFrames = Number((process.argv.find((a) => a.startsWith('--drain=')) || '').slice(8)) || 1;
 
 const stamp = () => new Date().toISOString().slice(11, 23);
 const log = (...args) => console.log(`[${stamp()}]`, ...args);
@@ -69,6 +78,8 @@ const newId = () => randomUUID().replace(/-/g, ''); // 文档要求 32 位十六
 const startMessageId = newId();
 const taskId = newId();
 let stopSent = false;
+let audioStartAt = 0;      // 第一帧音频发出的时刻
+let sentBytes = 0;         // 已推给网关的音频字节数
 
 const send = (payload) => {
   const text = JSON.stringify(payload);
@@ -121,16 +132,36 @@ ws.onopen = () => {
       enable_punctuation_prediction: true,
       enable_inverse_text_normalization: true,
       enable_voice_detection: true,
-      max_sentence_silence: 800,
+      ...(maxSentenceSilence ? { max_sentence_silence: maxSentenceSilence } : {}),
+      ...(semanticDetection ? { enable_semantic_sentence_detection: true } : {}),
     },
   });
+  if (audioPcm) audioStartAt = Date.now();
 
   if (audioPcm) {
-    // 按 100ms 一帧推流(16k/16bit/mono → 3200 字节 = 1600 采样)
+    // 模拟真实采集:**可用音频 = 握手积压 + 已过时间**(1× 实时到达),
+    // 每个 tick 最多排空 drainFrames 帧 —— 这正是前端 nls.ts 的排空策略。
+    // `--drain=1` 复现"只按 1× 排空"的旧行为(积压永远排不掉);
+    // `--drain=4` 是现在前端的策略(积压时最多 4× 追)。
     const pcm = audioPcm;
-    const frame = 3200;
+    const frame = 3200; // 100ms
+    const bytesPerMs = 32; // 16k/16bit/mono → 32 字节/ms
+    const startedAt = Date.now();
     let offset = 0;
     const pump = setInterval(() => {
+      const elapsedMs = Date.now() - startedAt;
+      const available = Math.min(pcm.length, Math.round((prebufferMs + elapsedMs) * bytesPerMs));
+      let sentThisTick = 0;
+      while (offset < available && sentThisTick < drainFrames) {
+        const end = Math.min(offset + frame, pcm.length, available);
+        const slice = pcm.subarray(offset, end);
+        offset = end;
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.send(slice);
+          sentBytes += slice.length;
+        }
+        sentThisTick += 1;
+      }
       if (offset >= pcm.length) {
         clearInterval(pump);
         log(`音频推完(${pcm.length} 字节)`);
@@ -138,11 +169,7 @@ ws.onopen = () => {
           stopSent = true;
           sendCommand({ name: 'StopTranscription', task: 'stop' });
         }
-        return;
       }
-      const slice = pcm.subarray(offset, Math.min(offset + frame, pcm.length));
-      offset += slice.length;
-      if (ws.readyState === WebSocket.OPEN) ws.send(slice);
     }, 100);
   } else {
     setTimeout(() => {
@@ -160,7 +187,16 @@ ws.onmessage = (event) => {
     try {
       const body = JSON.parse(text);
       const name = body?.header?.name || '(无 header.name)';
-      log(`   事件=${name} status=${body?.header?.status ?? body?.payload?.status ?? ''} 结果=${JSON.stringify(body?.payload?.result ?? '')}`);
+      const gwMs = Number(body?.payload?.time ?? NaN);
+      const sentMs = Math.round((sentBytes / 32000) * 1000);
+      // 感知延迟 = 「麦克风已经说了多少」−「网关处理到第几毫秒」。
+      // 用采集量(积压 + 已过时间)而不是发送量来算 —— 用户感受到的是前者。
+      const capturedMs = audioStartAt ? prebufferMs + (Date.now() - audioStartAt) : sentMs;
+      const lag = Number.isFinite(gwMs) ? capturedMs - gwMs : null;
+      log(
+        `   事件=${name} 网关已处理=${Number.isFinite(gwMs) ? gwMs + 'ms' : '-'} 已推=${sentMs}ms ` +
+          `${lag === null ? '' : `**感知延迟=${lag}ms** `}结果=${JSON.stringify(body?.payload?.result ?? '')}`
+      );
       if (name === 'TranscriptionCompleted') finish(0);
       if (name === 'TaskFailed') finish(3);
     } catch {

+ 15 - 0
harness/tools/verify-nls-realtime.mjs

@@ -22,6 +22,9 @@ import { canSynthesizeSpeech, readAudioAsPcm16kMono, synthesizeSpeechToWav } fro
 
 const {
   NLS_FRAME_MS,
+  NLS_FLUSH_TICK_MS,
+  NLS_MAX_FRAMES_PER_TICK,
+  NLS_DRAIN_RATE,
   NLS_GATEWAY_URL,
   NLS_SAMPLE_RATE,
   buildStartTranscription,
@@ -144,6 +147,18 @@ console.log('\n【2】16k / 16bit / 单声道 编码');
   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. 真实端到端(打真实网关)───────────────────────────────────────────────

+ 26 - 2
src/three-libs/asr/nls-protocol.ts

@@ -24,6 +24,23 @@ export const NLS_SAMPLE_RATE = 16000;
 /** 一帧送多少毫秒的音频。100ms 接近实时,且不会把网关灌爆(文档要求按实时速率发) */
 export const NLS_FRAME_MS = 100;
 
+/**
+ * 推流节拍与「追赶」上限。
+ *
+ * 为什么需要追赶:连上网关之前(取 token + 握手)麦克风已经在采集,这段音频先攒着;
+ * 如果之后只按 **1× 实时** 排空,那段积压就**永远排不掉** —— 表现为"出字整体慢半拍",
+ * 且不会自己恢复(旧厂商 SDK 是按 2× 排空的,所以它能追上)。
+ *
+ * 实测数据(`probe-nls-realtime.mjs`):按 1× 推时,客户端落后网关 **20~240ms**;
+ * 积压来自握手时长(通常 0.2~1s)。这里的策略是**平时按实时、积压时最多 4× 追**
+ * (4× 约等于 1s 积压用 330ms 排空;再快没有收益,且离"一次性灌入"越来越近)。
+ */
+export const NLS_FLUSH_TICK_MS = 50;
+export const NLS_MAX_FRAMES_PER_TICK = 2;
+
+/** 排空速率(倍实时)。>1 才可能把握手期间攒下的积压排掉 */
+export const NLS_DRAIN_RATE = (NLS_MAX_FRAMES_PER_TICK * NLS_FRAME_MS) / NLS_FLUSH_TICK_MS;
+
 /** 32 位十六进制 ID(文档要求 message_id / task_id 的形态) */
 export const createNlsId = (): string => {
   const bytes = new Uint8Array(16);
@@ -39,7 +56,14 @@ export interface NlsStartOptions {
   appkey: string;
   taskId?: string;
   messageId?: string;
-  /** 端点静音断句阈值(毫秒,200~2000)。默认给 800:说一句停一下就能出定稿 */
+  /**
+   * 端点静音断句阈值(毫秒,网关允许 200~2000)。
+   *
+   * 默认 **500**(网关文档默认 800),实测(`probe-nls-realtime.mjs`):
+   * 300ms 比 800ms 的**定稿早到约 300ms**,中间结果节奏不受它影响(恒 ~480ms)。
+   * 取 500 是折中:说得快的人不会在自然停顿处被切断,说一句停一下的人能更快看到定稿。
+   * 停顿被切句不影响文本完整性(后续句子会继续追加)。
+   */
   maxSentenceSilence?: number;
 }
 
@@ -67,7 +91,7 @@ export const buildStartTranscription = (options: NlsStartOptions) =>
       enable_punctuation_prediction: true,
       enable_inverse_text_normalization: true,
       enable_voice_detection: true,
-      max_sentence_silence: options.maxSentenceSilence ?? 800,
+      max_sentence_silence: options.maxSentenceSilence ?? 500,
     },
   });
 

+ 20 - 7
src/three-libs/asr/nls.ts

@@ -20,8 +20,10 @@
 import { BaseEvent } from "@/utils/base-event";
 import { fetchNlsToken } from "@/network/api/nls-token";
 import {
+  NLS_FLUSH_TICK_MS,
   NLS_FRAME_MS,
   NLS_GATEWAY_URL,
+  NLS_MAX_FRAMES_PER_TICK,
   NLS_SAMPLE_RATE,
   NLS_STATUS_SUCCESS,
   buildStartTranscription,
@@ -198,13 +200,14 @@ export default class NlsAsr extends BaseEvent {
     this.source?.disconnect();
     this.processor?.disconnect();
 
-    // 余下的音频按帧发完(每帧等一个周期,别把网关灌爆)
+    // 余下的音频尽快发完:**用户此刻正在等最后一段文字**,按 100ms 一帧的实时节奏发
+    // 会让"停止后还要等"的体感更差。这里用 20ms 一帧(≈5× 实时),余量通常 ≤100ms
     const frames = this.pendingAudio;
     this.pendingAudio = [];
     for (const frame of frames) {
       if (this.socket?.readyState !== WebSocket.OPEN) break;
       this.socket.send(frame);
-      await new Promise((r) => window.setTimeout(r, NLS_FRAME_MS));
+      await new Promise((r) => window.setTimeout(r, 20));
     }
 
     const finalText = this.transcript.finalText;
@@ -275,17 +278,24 @@ export default class NlsAsr extends BaseEvent {
     }
   }
 
-  /** 按实时节奏把缓存里的音频推给网关(每 `NLS_FRAME_MS` 推一帧的量) */
+  /**
+   * 把缓存里的音频按帧推给网关:**平时按实时节奏,积压时最多 4× 追赶**。
+   *
+   * 追赶这一步不能省 —— 连上网关前(取 token + 握手,通常 0.2~1s)麦克风已在采集,
+   * 若只按 1× 排空,那段积压永远排不掉,表现为"出字整体慢半拍"(实测见
+   * `probe-nls-realtime.mjs` 的落后测量;协议层常量与注释在 nls-protocol.ts)。
+   */
   private startFlushTimer(): void {
     this.stopSendTimer();
-    const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节
+    const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节 = 100ms
     let carry = new Uint8Array(0);
 
     this.sendTimer = window.setInterval(() => {
       if (!this.processing && this.pendingAudio.length === 0) return;
       if (this.socket?.readyState !== WebSocket.OPEN) return;
 
-      while (carry.length + (this.pendingAudio[0]?.length ?? 0) < bytesPerFrame && this.pendingAudio.length) {
+      // 先把能凑整帧的缓存合并进 carry
+      while (carry.length + (this.pendingAudio[0]?.length ?? 0) <= bytesPerFrame * 2 && this.pendingAudio.length) {
         const next = this.pendingAudio.shift() as Uint8Array;
         const merged = new Uint8Array(carry.length + next.length);
         merged.set(carry, 0);
@@ -293,11 +303,14 @@ export default class NlsAsr extends BaseEvent {
         carry = merged;
       }
 
-      while (carry.length >= bytesPerFrame) {
+      // 一个 tick 内最多发 NLS_MAX_FRAMES_PER_TICK 帧(有积压就多发烧掉)
+      let sentThisTick = 0;
+      while (carry.length >= bytesPerFrame && sentThisTick < NLS_MAX_FRAMES_PER_TICK) {
         if (this.socket?.readyState !== WebSocket.OPEN) return;
         this.socket.send(carry.subarray(0, bytesPerFrame));
         carry = carry.subarray(bytesPerFrame);
+        sentThisTick += 1;
       }
-    }, NLS_FRAME_MS);
+    }, NLS_FLUSH_TICK_MS);
   }
 }