Преглед изворни кода

fix(nls): 修「进录音态但不出字」(音频可能一帧都发不出)+ 补诊断日志

用户反馈:「进了录音状态但是不出字」。那说明连接与握手是成的(否则会弹提示并退出录音态),
问题在音频没送到或送错了。

**找到一处明确的脆弱实现**(startFlushTimer):合并条件写成「carry + 下一块 <= 两帧(6400B)
才合并」—— 而 ScriptProcessorNode 的 bufferSize 是浏览器自选的(传 0 = 让它挑),
一旦某块比两帧还大,那块就**永远合并不进 carry**,于是:
  · 缓存里一直有数据,但 carry 始终为空
  · 发送循环条件 carry.length >= 3200 永不成立
  · ⇒ **一帧音频都发不出去**,而界面看着仍在正常录音(正是用户报的形态)
改成**不设上限地合并**(先把 pendingAudio 全并进 carry,再按帧发)。

**补诊断日志**(这类"静默不出声"不能再靠猜):
- 采集端:`[nls] 开始采集:AudioContext=… 采样率=…Hz processor 缓冲=…`
  与「收到第一块音频:输入 N 采样 → 编码后 M 字节」(第一块就能看出重采样对不对)
- 采集端看门狗:suspended 时主动 resume;1.5s 没采到任何数据 → 明确 warn
- 发送端:每 20 帧打一条「已推音频 Nms」;录着音却 2 秒一帧没发出去 → 明确 warn
- 下行:网关事件名 + 结果(中间结果每 4 条打一条)、TaskFailed 用 warn 并带错误原文

验证:vue-tsc 无错;npm run build 通过;verify-inline-scripts 6/6;verify-nls-realtime 44/44。
⚠️ 需要用户复现一次并把 Console 贴出来,以确认是"音频没上去"还是"服务端没回"。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 1 месец
родитељ
комит
f73d835c96
1 измењених фајлова са 73 додато и 8 уклоњено
  1. 73 8
      src/asr/nls.ts

+ 73 - 8
src/asr/nls.ts

@@ -109,11 +109,35 @@ export default class NlsAsr extends BaseEvent {
     this.processor.connect(this.audioContext.destination);
     this.source.connect(this.processor);
     const sampleRate = this.audioContext.sampleRate;
+    let capturedChunks = 0;
+    console.log(
+      `[nls] 开始采集:AudioContext=${this.audioContext.state} 采样率=${sampleRate}Hz ` +
+        `processor 缓冲=${this.processor.bufferSize}`
+    );
     this.processor.onaudioprocess = (event: AudioProcessingEvent) => {
       if (!this.processing || !this.stream || !this.stream.active) return;
       const channel = event.inputBuffer.getChannelData(0);
-      this.pendingAudio.push(encodePcm16kMono(channel, sampleRate));
+      const encoded = encodePcm16kMono(channel, sampleRate);
+      capturedChunks += 1;
+      if (capturedChunks === 1) {
+        console.log(`[nls] 收到第一块音频:输入 ${channel.length} 采样 → 编码后 ${encoded.length} 字节`);
+      }
+      this.pendingAudio.push(encoded);
     };
+
+    // 诊断:AudioContext 有时会以 suspended 起步(那样 onaudioprocess 根本不会回调),
+    // 这里主动 resume 一次,并在 1.5s 后仍没采到任何数据时把状态打出来
+    if (this.audioContext.state === "suspended") {
+      void this.audioContext.resume().catch(() => undefined);
+    }
+    window.setTimeout(() => {
+      if (capturedChunks === 0 && this.processing) {
+        console.warn(
+          `[nls] ⚠️ 1.5s 内没有采集到任何音频(AudioContext=${this.audioContext?.state},` +
+            `stream.active=${this.stream?.active})—— 麦克风可能被别的程序占用或未真正启动`
+        );
+      }
+    }, 1500);
   }
 
   /** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
@@ -126,6 +150,8 @@ export default class NlsAsr extends BaseEvent {
     await new Promise<void>((resolve, reject) => {
       this.startResolve = resolve;
       this.startReject = reject;
+      /** 诊断用:中间结果事件计数(日志里每 4 条打一条,避免刷屏) */
+      let midEvents = 0;
 
       const url = `${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`;
       const socket = new WebSocket(url);
@@ -147,6 +173,19 @@ export default class NlsAsr extends BaseEvent {
         const name = getNlsEventName(message);
         if (!name) return;
 
+        // 诊断日志:把网关下行事件打出来(ResultChanged 很密,每 4 条打一条)。
+        // 排查「进录音态但不出字」时,这几行能直接区分「音频没上去」和「服务端没回/回了错」。
+        if (name === "TaskFailed") {
+          console.warn(`[nls] ← TaskFailed:${describeNlsFailure(message)}`);
+        } else if (name !== "TranscriptionResultChanged" && name !== "SentenceBegin") {
+          console.log(`[nls] ← ${name}${message?.payload?.result ? ` ${JSON.stringify(message.payload.result)}` : ""}`);
+        } else if (name === "TranscriptionResultChanged") {
+          midEvents += 1;
+          if (midEvents % 4 === 1) {
+            console.log(`[nls] ← 中间结果:${JSON.stringify(message?.payload?.result ?? "")}`);
+          }
+        }
+
         if (name === "TranscriptionStarted") {
           settle();
           this.startFlushTimer();
@@ -290,27 +329,53 @@ export default class NlsAsr extends BaseEvent {
     this.stopSendTimer();
     const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节 = 100ms
     let carry = new Uint8Array(0);
+    let sentFrames = 0;
+    let lastLoggedFrames = 0;
+    let stallWarnedAt = 0;
+    const flushStartedAt = Date.now();
 
     this.sendTimer = window.setInterval(() => {
-      if (!this.processing && this.pendingAudio.length === 0) return;
       if (this.socket?.readyState !== WebSocket.OPEN) return;
 
-      // 先把能凑整帧的缓存合并进 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);
+      // 1) 把采集到的块**全部**并进 carry。
+      // ⚠️ 这里曾经写成「只在 carry + 下一块 ≤ 两帧时才合并」—— 一旦浏览器给的 buffer
+      //    比两帧还大(bufferSize 是浏览器自选的),那块就永远合并不进来,
+      //    结果**一帧音频都发不出去**,而界面看着仍在正常录音(用户 2026-09-24 报的
+      //    「进了录音状态但是不出字」就是这个形态)。合并**不设上限**才安全。
+      if (this.pendingAudio.length) {
+        const pending = this.pendingAudio;
+        this.pendingAudio = [];
+        const merged = new Uint8Array(carry.length + pending.reduce((n, c) => n + c.length, 0));
         merged.set(carry, 0);
-        merged.set(next, carry.length);
+        let offset = carry.length;
+        for (const chunk of pending) {
+          merged.set(chunk, offset);
+          offset += chunk.length;
+        }
         carry = merged;
       }
 
-      // 一个 tick 内最多发 NLS_MAX_FRAMES_PER_TICK 帧(有积压就多发烧掉)
+      // 2) 按帧发(有积压时一个 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;
+        sentFrames += 1;
+      }
+      if (sentFrames - lastLoggedFrames >= 20) {
+        lastLoggedFrames = sentFrames;
+        console.log(`[nls] 已推音频 ${Math.round((sentFrames * bytesPerFrame) / 32)}ms(${sentFrames} 帧)`);
+      }
+
+      // 3) 诊断:录着音却 >=2 秒一帧都没发出去 → 明确报出来,别再"静默不出声"
+      if (this.processing && sentFrames === 0 && Date.now() - flushStartedAt > 2000 && Date.now() - stallWarnedAt > 5000) {
+        stallWarnedAt = Date.now();
+        console.warn(
+          `[nls] ⚠️ 录音中但音频一帧都没发出去(carry=${carry.length}B,缓存块=${this.pendingAudio.length})` +
+            ` —— 若持续如此,检查采集端是否真的在出数据(见上一条「采集开始」日志里的采样率)`
+        );
       }
     }, NLS_FLUSH_TICK_MS);
   }