feat: 双引擎 HUD 智能切换 — QwenASR 产出后自动接管显示#23
Conversation
In DualEngine mode, HUD initially shows Paraformer output for fast feedback (~100ms latency). Once QwenASR produces its first text (~2s), HUD automatically switches to QwenASR which provides better accuracy and richer partial results (including unfixedText). This gives users consistent preview-to-final output while maintaining the snappy initial response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🔍 Code Review — Linus Torvalds 风格
总评:这个 PR 解决了一个实际问题——双引擎模式下 HUD 的显示源切换。核心思路正确:用一个单向标志位控制显示源,os_unfair_lock 保护跨队列访问。代码量精简(+50/-4),没有过度工程化。
不过有两个并发相关的问题需要关注。
评分
| 维度 | 分值 | 评价 |
|---|---|---|
| 正确性 | 2/3 | reset() 存在竞态窗口,见 inline comment |
| 简洁性 | 2/2 | 变更范围最小化,没有无关改动 |
| 可读性 | 2/2 | 命名清晰,注释到位,单向状态机模型容易理解 |
| 可维护性 | 2/2 | 职责明确,qwenHasOutput 封装在 computed property 中 |
| 安全性 | 1/1 | 无敏感数据泄露 |
总分:9/10 ✅
整体方案合理,os_unfair_lock 在 class(非 struct)上使用是正确的。建议修复 reset() 的竞态窗口,但不阻塞合并。
…eASR - Add isFlushing cancellation flag (lock-protected) to skip stale processAudio blocks in qwenQueue when flush is initiated, preventing 20-30s hangs caused by pending Metal GPU inference blocking the flush - Move qwenHasOutput reset inside qwenQueue.sync in reset() to prevent race condition with in-flight processAudio blocks - Replace fragile force unwrap (unfixedText!) with optional chaining Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🔍 Code Review — 第二轮
背景:第一轮提出的两个问题(reset() 竞态窗口、unfixedText! force unwrap)已在 commit 2 中修复,做得不错。但修复过程中引入了一个新 bug。
🐛 isFlushing 永不重置 — 第二次录音起 QwenASR 静默失效
isFlushing 在 flush() 入口设为 true,但在 flush() 完成后 从未重置为 false。reset() 里有重置,但 RecordingManager 的正常生命周期(recording → flushing → postProcessing → ready → recording)根本不调用 engine.reset()。
后果:第一次录音正常,第二次录音起所有 processAudio 的 QwenASR 分支被 guard !self.isFlushing 直接跳过。用户以为双引擎在工作,实际只有 Paraformer 在跑。
这是 P0 级别的功能性 bug——用户无感知的静默降级,而且只在「连续多次录音」时触发,测试时如果只录一次根本发现不了。
评分
| 维度 | 分值 | 评价 |
|---|---|---|
| 正确性 | 1/3 | isFlushing 未重置导致第二次录音起引擎失效 |
| 简洁性 | 2/2 | 变更范围合理,isFlushing 机制本身设计简洁 |
| 可读性 | 2/2 | 命名清晰,状态语义明确 |
| 可维护性 | 1.5/2 | flush() 中用 lock-wrapped property 而 reset() 中直接访问 backing field,风格不一致(不影响正确性但增加认知负担) |
| 安全性 | 1/1 | 无问题 |
总分:7.5/10 → 向下取整 7/10
修一行就能合:在 flush() 的 qwenQueue.async 块中 self.qwenHasOutput = false 旁边加上 self._isFlushing = false。改完我就批。
isFlushing was set to true at the start of flush() but never reset to false after the flush block completed. This caused all subsequent processAudio blocks to bail out on the guard check, so QwenASR never received audio on the second recording session. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🔍 Code Review — 第三轮
背景:第二轮要求修复的 isFlushing 永不重置 bug 已在 commit 3 (b40438f) 中修复。一行改动,精准到位。
之前提出的三个问题全部已修复:
| 问题 | 状态 |
|---|---|
reset() 竞态窗口 |
✅ 移入 qwenQueue.sync,Paraformer reset 移到队列外 |
unfixedText! force unwrap |
✅ 改为 unfixedText?.isEmpty ?? true |
isFlushing 永不重置 |
✅ flush() 完成后重置 isFlushing = false |
当前代码无新问题。os_unfair_lock 在 class 上使用地址稳定,锁粒度小且无嵌套,不存在死锁风险。reset() 中直接访问 backing field 是因为已在 qwenQueue.sync 内,没有跨队列竞争——风格上不如统一使用 computed property,但正确性没问题。
评分
| 维度 | 分值 | 评价 |
|---|---|---|
| 正确性 | 3/3 | 三个 bug 全部修复,flush/reset 生命周期完整 |
| 简洁性 | 2/2 | +77/-6,没有多余的抽象层 |
| 可读性 | 2/2 | 命名清晰,状态机语义明确 |
| 可维护性 | 2/2 | 职责分明,锁保护集中在 computed property |
| 安全性 | 1/1 | 无问题 |
总分:10/10 ✅ APPROVE
Talk is cheap, show me the code. You showed me the code, and it's clean. Ship it.
…eue starvation Same flush queue starvation problem that was fixed in DualEngineASR also exists in QwenASREngine. When processAudio dispatches many blocks to the serial recognitionQueue, flush has to wait behind all of them. This adds an os_unfair_lock-protected isFlushing flag that causes queued processAudio blocks to return immediately once flush begins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🔍 Code Review — 第四轮(Commit 4: QwenASREngine isFlushing)
背景:Commit 4 把 DualEngineASR 中已验证的 isFlushing 队列饥饿优化移植到了 QwenASREngine。思路正确——同一个问题、同一个解法,代码量小且意图清晰。但移植时有两个细节没跟上。
1. QwenASREngine.reset() 缺少队列同步(见 inline comment)
2. QwenASREngine.processAudio 中 unfixedText! force unwrap 遗留
第 143 行:
let hasContent = !stableText.isEmpty || (unfixedText != nil && !unfixedText!.isEmpty)这是上一轮 review 在 DualEngineASR 中修复过的同一个问题——DualEngineASR 已改为 !(unfixedText?.isEmpty ?? true),但 QwenASREngine 移植 isFlushing 时没有顺手统一。逻辑上不会 crash(短路保护),但两个引擎同一行代码写法不一致,未来重构是个坑。
评分
| 维度 | 分值 | 评价 |
|---|---|---|
| 正确性 | 2.5/3 | reset() 未同步 + unfixedText! 遗留 |
| 简洁性 | 2/2 | 最小化变更,与 DualEngineASR 保持对称 |
| 可读性 | 2/2 | 命名、注释一致,零认知负担 |
| 可维护性 | 1.5/2 | reset() 同步策略与 DualEngineASR 不一致 |
| 安全性 | 1/1 | 无问题 |
总分:9/10 ✅ APPROVE
两个小问题不阻塞合并,建议后续统一修复。解决了真实问题,代码干净利落。Ship it.
1. reset() now uses recognitionQueue.sync to protect _isFlushing and recognizer.reset() atomically, matching DualEngineASR pattern 2. Replace force unwrap unfixedText! with safe ?(isEmpty ?? true) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🔍 Code Review — 第五轮(Commit 5: QwenASREngine reset/unfixed 一致性修复)
背景:第四轮提出的两个小问题在 commit 5 (96f1494) 中全部修复。
| 问题 | 状态 |
|---|---|
QwenASREngine.reset() 缺少队列同步 |
✅ 改为 recognitionQueue.sync,与 DualEngineASR 对称 |
QwenASREngine 中 unfixedText! force unwrap |
✅ 改为 !(unfixedText?.isEmpty ?? true) |
两个引擎的并发保护模式现在完全一致:
isFlushing通过os_unfair_lock跨队列安全读写 ✓flush()设置标志 → 队列内重置 ✓reset()在 serial queue 内直接操作 backing field(已在队列内,无需额外锁) ✓processAudio入口 guardisFlushing短路 ✓
五轮 review 下来,三个真实 bug + 两个一致性问题,全部修完。代码干净、对称、无多余抽象。
评分
| 维度 | 分值 | 评价 |
|---|---|---|
| 正确性 | 3/3 | 所有已知问题修复,flush/reset 生命周期完整且对称 |
| 简洁性 | 2/2 | +111/-9 总量,每一行都在解决真实问题 |
| 可读性 | 2/2 | 两个引擎代码模式一致,一看就懂 |
| 可维护性 | 2/2 | 对称设计降低认知负担,lock 策略统一 |
| 安全性 | 1/1 | 无问题 |
总分:10/10 ✅ APPROVE
Talk is cheap. Show me the code. 五个 commit,从功能到修 bug 到统一风格,每一步都是实打实的改进。Ship it.
Summary
unfixedText投机文本os_unfair_lock保护跨队列的qwenHasOutput标志,确保线程安全行为变化
Test plan
🤖 Generated with Claude Code