Skip to content

feat: 双引擎 HUD 智能切换 — QwenASR 产出后自动接管显示#23

Merged
ZhaoChaoqun merged 5 commits into
mainfrom
feat/dual-engine-hud-switch
Mar 26, 2026
Merged

feat: 双引擎 HUD 智能切换 — QwenASR 产出后自动接管显示#23
ZhaoChaoqun merged 5 commits into
mainfrom
feat/dual-engine-hud-switch

Conversation

@ZhaoChaoqun

Copy link
Copy Markdown
Owner

Summary

  • 双引擎模式下,HUD 初始显示 Paraformer 的实时输出(~100ms 延迟),提供即时视觉反馈
  • QwenASR 产出首段文本后(约 ~2s),HUD 自动切换为 QwenASR 输出,包含 unfixedText 投机文本
  • 切换后 Paraformer 回调被跳过(仍继续接收音频,不影响引擎状态)
  • 使用 os_unfair_lock 保护跨队列的 qwenHasOutput 标志,确保线程安全

行为变化

阶段 之前 之后
录音前 2s HUD 显示 Paraformer HUD 显示 Paraformer(不变)
QwenASR 产出后 HUD 仍显示 Paraformer HUD 切换为 QwenASR
最终输出 QwenASR(不变) QwenASR(不变)

Test plan

  • 双引擎模式录音,观察 HUD 是否先显示 Paraformer 再切换到 QwenASR
  • 确认切换后 HUD 显示 unfixedText(灰色投机文本)
  • 确认最终输出文本与之前一致(QwenASR flush 结果)
  • 短录音(<2s)确认 Paraformer 仍正常工作
  • 连续多次录音确认 flag 正确重置

🤖 Generated with Claude Code

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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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() 的竞态窗口,但不阻塞合并。

Comment thread Sources/DualEngineASR.swift Outdated
Comment thread Sources/DualEngineASR.swift Outdated
…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>
Comment thread Sources/DualEngineASR.swift

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review — 第二轮

背景:第一轮提出的两个问题(reset() 竞态窗口、unfixedText! force unwrap)已在 commit 2 中修复,做得不错。但修复过程中引入了一个新 bug。


🐛 isFlushing 永不重置 — 第二次录音起 QwenASR 静默失效

isFlushingflush() 入口设为 true,但在 flush() 完成后 从未重置为 falsereset() 里有重置,但 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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review — 第四轮(Commit 4: QwenASREngine isFlushing)

背景:Commit 4 把 DualEngineASR 中已验证的 isFlushing 队列饥饿优化移植到了 QwenASREngine。思路正确——同一个问题、同一个解法,代码量小且意图清晰。但移植时有两个细节没跟上。


1. QwenASREngine.reset() 缺少队列同步(见 inline comment)

2. QwenASREngine.processAudiounfixedText! 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.

Comment thread Sources/ASREngine.swift Outdated
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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Code Review — 第五轮(Commit 5: QwenASREngine reset/unfixed 一致性修复)

背景:第四轮提出的两个小问题在 commit 5 (96f1494) 中全部修复。

问题 状态
QwenASREngine.reset() 缺少队列同步 ✅ 改为 recognitionQueue.sync,与 DualEngineASR 对称
QwenASREngineunfixedText! force unwrap ✅ 改为 !(unfixedText?.isEmpty ?? true)

两个引擎的并发保护模式现在完全一致:

  • isFlushing 通过 os_unfair_lock 跨队列安全读写 ✓
  • flush() 设置标志 → 队列内重置 ✓
  • reset() 在 serial queue 内直接操作 backing field(已在队列内,无需额外锁) ✓
  • processAudio 入口 guard isFlushing 短路 ✓

五轮 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.

@ZhaoChaoqun ZhaoChaoqun merged commit 2f31104 into main Mar 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant