|
| 1 | +# FunASR Nano LLM 跨录音会话数据泄漏 Bug |
| 2 | + |
| 3 | +## 现象 |
| 4 | + |
| 5 | +使用 FunASR Nano LLM 引擎时,连续两次录音的第二次转录结果**包含了第一次的完整内容**。 |
| 6 | + |
| 7 | +### 复现步骤 |
| 8 | + |
| 9 | +1. 按住 Fn 键录音第一句:"请先生成你的实现计划,如果有需要我进一步确认的地方,可以随时向我提问。" |
| 10 | +2. 松开 Fn 键,等待转录完成 → 结果正确 |
| 11 | +3. 再次按住 Fn 键录音第二句:"嗯,可以帮我把本地暂未提交的代码合成比较合理的commit,然后部署到远端吗?" |
| 12 | +4. 松开 Fn 键 → **结果错误**,输出为第一句 + 第二句的拼接 |
| 13 | + |
| 14 | +### 实际输出 |
| 15 | + |
| 16 | +``` |
| 17 | +请先生成你的实现计划,如果有需要我进一步确认的地方,可以随时向我提问。嗯,可以帮我把本地暂未提交的代码合成比较合理的可密的,然后部署到圆团吗? |
| 18 | +``` |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## 根因分析 |
| 23 | + |
| 24 | +### 核心问题 |
| 25 | + |
| 26 | +`FunASRNanoLLMEngine` 的 `_accumulatedText` 缓冲区和 VAD 内部状态在两次录音之间**从未被重置**。`reset()` 方法存在但从未被调用。 |
| 27 | + |
| 28 | +### 数据流追踪 |
| 29 | + |
| 30 | +``` |
| 31 | +第一次录音 |
| 32 | +───────────────────────────────────────────────────────────── |
| 33 | +Fn 按下 → .ready → .recording |
| 34 | + │ |
| 35 | + ▼ |
| 36 | + processAudio() → vad.acceptWaveform() |
| 37 | + → vad.popSegmentWithTime() |
| 38 | + → recognizer.transcribe() |
| 39 | + → _accumulatedText = "请先生成..." |
| 40 | + │ |
| 41 | +Fn 松开 → .recording → .flushing |
| 42 | + │ |
| 43 | + ▼ |
| 44 | + flush() → vad.flush() |
| 45 | + → 处理剩余 VAD 段 |
| 46 | + → 返回 _accumulatedText |
| 47 | + │ |
| 48 | + ▼ |
| 49 | + .flushing → .postProcessing → .ready |
| 50 | + │ |
| 51 | + ❌ 未调用 reset() |
| 52 | + ❌ _accumulatedText 仍 = "请先生成..." |
| 53 | + ❌ VAD 内部缓冲区未清空 |
| 54 | +
|
| 55 | +第二次录音 |
| 56 | +───────────────────────────────────────────────────────────── |
| 57 | +Fn 按下 → .ready → .recording |
| 58 | + │ |
| 59 | + ▼ |
| 60 | + processAudio() → vad.acceptWaveform() |
| 61 | + → vad.popSegmentWithTime() |
| 62 | + → recognizer.transcribe() → 新文本 |
| 63 | + → _accumulatedText += 新文本 ← BUG!旧数据仍在 |
| 64 | + │ |
| 65 | + ▼ |
| 66 | + flush() → 返回 "第一句 + 第二句" |
| 67 | +``` |
| 68 | + |
| 69 | +### 涉及代码 |
| 70 | + |
| 71 | +#### 1. `FunASRNanoLLMEngine.flush()` — 不清理状态 |
| 72 | + |
| 73 | +**文件:** `Sources/ASREngine.swift:120-143` |
| 74 | + |
| 75 | +```swift |
| 76 | +func flush(completion: @escaping (String) -> Void) { |
| 77 | + vad.flush() |
| 78 | + recognitionQueue.async { [weak self] in |
| 79 | + // ... 处理剩余 VAD 段 ... |
| 80 | + let rawText = self.internalQueue.sync { self._accumulatedText } |
| 81 | + DispatchQueue.main.async { completion(rawText) } |
| 82 | + // ❌ 此处缺少 reset() 调用 |
| 83 | + } |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +`flush()` 读取并返回 `_accumulatedText`,但**不清空它**。下次录音时旧数据仍然存在。 |
| 88 | + |
| 89 | +#### 2. `FunASRNanoLLMEngine.reset()` — 存在但从未被调用 |
| 90 | + |
| 91 | +**文件:** `Sources/ASREngine.swift:145-148` |
| 92 | + |
| 93 | +```swift |
| 94 | +func reset() { |
| 95 | + vad.reset() |
| 96 | + internalQueue.sync { _accumulatedText = "" } |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +`reset()` 实现是正确的——重置 VAD 并清空累积文本。但在整个录音生命周期中**从未被调用**。 |
| 101 | + |
| 102 | +#### 3. `RecordingManager.handleSideEffects()` — 状态转换缺失 reset |
| 103 | + |
| 104 | +**文件:** `Sources/RecordingManager.swift:132-135` |
| 105 | + |
| 106 | +```swift |
| 107 | +// 后处理完成 |
| 108 | +case (.postProcessing, .ready): |
| 109 | + if case .postProcessComplete(let finalText) = event { |
| 110 | + DispatchQueue.main.async { self.onFinalResult?(finalText) } |
| 111 | + } |
| 112 | + // ❌ 此处缺少 currentEngine?.reset() |
| 113 | +``` |
| 114 | + |
| 115 | +这是两次录音之间的唯一过渡点(`.postProcessing → .ready`),但没有调用引擎的 `reset()`。 |
| 116 | + |
| 117 | +#### 4. `startAudioEngine()` — 也不调用 reset |
| 118 | + |
| 119 | +**文件:** `Sources/RecordingManager.swift:144-176` |
| 120 | + |
| 121 | +`startAudioEngine()` 只创建新的 `AVAudioEngine` 和安装 tap,不涉及 ASR 引擎的状态重置。 |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +## 影响范围 |
| 126 | + |
| 127 | +| 引擎 | 是否受影响 | 原因 | |
| 128 | +|------|:---------:|------| |
| 129 | +| FunASR Nano LLM | **是** | `_accumulatedText` 跨会话累积 | |
| 130 | +| Qwen3-ASR 流式 | 否 | `flush()` 内部调用了 `recognizer.reset()` | |
| 131 | +| Paraformer | 否 | `flush()` 内部调用了 `recognizer.reset()` | |
| 132 | + |
| 133 | +对比 `QwenASREngine.flush()`(`ASREngine.swift:195-215`)可以看到,Qwen 引擎在 flush 末尾调用了 `self.recognizer.reset()`,所以不受此 bug 影响。 |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## 修复方案 |
| 138 | + |
| 139 | +在 `FunASRNanoLLMEngine.flush()` 的 completion 回调后调用 `self.reset()`: |
| 140 | + |
| 141 | +```swift |
| 142 | +func flush(completion: @escaping (String) -> Void) { |
| 143 | + vad.flush() |
| 144 | + recognitionQueue.async { [weak self] in |
| 145 | + guard let self = self else { ... } |
| 146 | + // ... 处理剩余 VAD 段 ... |
| 147 | + let rawText = self.internalQueue.sync { self._accumulatedText } |
| 148 | + self.reset() // ← 修复:flush 完成后重置状态 |
| 149 | + DispatchQueue.main.async { completion(rawText) } |
| 150 | + } |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +这与 `QwenASREngine` 和 `ParaformerEngine` 的行为保持一致。 |
| 155 | + |
| 156 | +--- |
| 157 | + |
| 158 | +## 时间线 |
| 159 | + |
| 160 | +- **发现时间:** 2026-03-04 18:09 |
| 161 | +- **影响版本:** FunASR Nano LLM 引擎引入以来的所有版本 |
| 162 | +- **严重程度:** 高(输出错误结果,用户可感知) |
0 commit comments