Skip to content

Drain audio engine off main queue - #655

Merged
altic-dev merged 5 commits into
mainfrom
rohith/audio-engine-recovery
Jul 20, 2026
Merged

Drain audio engine off main queue#655
altic-dev merged 5 commits into
mainfrom
rohith/audio-engine-recovery

Conversation

@grohith327

@grohith327 grohith327 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Description

Right now, whenever audio input device switches the AVAudioEngine is destroyed and created. However, sometimes, the audio events can be be sent through the destroyed engine which the ASRService doesn't realize. This results in a crash. This change fixes this by making the destruction/creation sequential and also waiting for a quiet period (if it doesn't happen instantly) before switching the engine. This should prevent crashses

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

#640, #644, #636 and #651

Testing

  • Tested on Apple Silicon Mac
  • Tested on macOS version:
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally:

Screenshots / Video

Attempted transcription and also switching between audio input devices to make sure it works as expected

CleanShot 2026-07-18 at 21 27 50@2x

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5802b15e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a crash that occurred when audio events were delivered through a destroyed AVAudioEngine during device switches, by introducing a serial retirement drain (AudioEngineRetirementDrain) that ensures dealloc always happens off the main thread and that new engine construction can await the drain's completion barrier.

  • AudioEngineRetirementDrain: new DispatchQueue-backed class with schedule, releaseAndWait, and waitForScheduledReleases methods, plus corresponding AudioEngineRetirementToken to hold the final strong reference. Two tests verify off-main-thread release and serial ordering.
  • Generation-based route recovery: scheduleAudioRouteRecovery now tracks a audioRouteRecoveryGeneration counter and pendingAudioRouteRecovery request, making it safe to queue a newer recovery while one is already running — finishAudioRouteRecovery picks up the pending request and re-arms.
  • Async start gating: startPreferredAudioCapture, startCompatibilityAudioCapture, and startEngine are now async, and capture start emits a waitForPendingStart() barrier used by hotkey paths (which now check isRunningOrStarting) to avoid tearing down a session that hasn't fully started yet.

Confidence Score: 5/5

The core drain logic is sound — serial queue ordering, off-main-thread release, and the generation counter all work correctly together. The change is safe to merge.

The retirement drain serializes AVAudioEngine deallocation correctly, the generation-based recovery handles re-entrant route changes without data races on the main actor, and the waitForPendingStart barrier closes the hotkey-during-async-start window. The two new tests verify the critical ordering invariants.

Package.resolved warrants a second look to confirm the eight new packages and swift-transformers upgrade are intentional and not a rebase side-effect.

Reviews (5): Last reviewed commit: "Fix for concurrent toggles" | Re-trigger Greptile

Comment on lines +1 to +41
@testable import FluidVoice_Debug
import Foundation
import XCTest

final class AudioEngineRetirementDrainTests: XCTestCase {
func testReleaseAndWaitCompletesAfterOffMainDeinit() async throws {
let drain = AudioEngineRetirementDrain(label: "test.audio-engine-retirement.single")
let recorder = DeinitRecorder()
var probe: DeinitProbe? = DeinitProbe(id: 1, recorder: recorder)
let token = AudioEngineRetirementToken(try XCTUnwrap(probe))
probe = nil

await drain.releaseAndWait(token)

XCTAssertEqual(
recorder.events,
[DeinitEvent(id: 1, occurredOnMainThread: false)]
)
}

func testAwaitedReleaseRunsAfterPreviouslyScheduledRelease() async throws {
let drain = AudioEngineRetirementDrain(label: "test.audio-engine-retirement.serial")
let recorder = DeinitRecorder()
var first: DeinitProbe? = DeinitProbe(id: 1, recorder: recorder)
var second: DeinitProbe? = DeinitProbe(id: 2, recorder: recorder)
let firstToken = AudioEngineRetirementToken(try XCTUnwrap(first))
let secondToken = AudioEngineRetirementToken(try XCTUnwrap(second))
first = nil
second = nil

drain.schedule(firstToken)
await drain.releaseAndWait(secondToken)

XCTAssertEqual(
recorder.events,
[
DeinitEvent(id: 1, occurredOnMainThread: false),
DeinitEvent(id: 2, occurredOnMainThread: false),
]
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing coverage for waitForScheduledReleases barrier

The two tests cover releaseAndWait alone and schedule + releaseAndWait ordering, but waitForScheduledReleases — the barrier used in startPreferredAudioCapture, startCompatibilityAudioCapture, and cancelAudioRouteRecoveryAndWait — is not exercised at all. A test that calls drain.schedule(token) then awaits drain.waitForScheduledReleases() and asserts the engine was released before the barrier returned would give confidence that the most-travelled production path (capture start after a fire-and-forget retirement) actually serializes correctly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/FluidDictationIntegrationTests/AudioEngineRetirementDrainTests.swift
Line: 1-41

Comment:
**Missing coverage for `waitForScheduledReleases` barrier**

The two tests cover `releaseAndWait` alone and `schedule` + `releaseAndWait` ordering, but `waitForScheduledReleases` — the barrier used in `startPreferredAudioCapture`, `startCompatibilityAudioCapture`, and `cancelAudioRouteRecoveryAndWait` — is not exercised at all. A test that calls `drain.schedule(token)` then awaits `drain.waitForScheduledReleases()` and asserts the engine was released before the barrier returned would give confidence that the most-travelled production path (capture start after a fire-and-forget retirement) actually serializes correctly.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 336c5351f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08bc0eec64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

self.audioRouteRecoveryTask?.cancel()
self.audioRouteRecoveryTask = nil
self.isRecoveringAudioRoute = false
await self.cancelAudioRouteRecoveryAndWait()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat startup as active in automatic hotkey routing

In automatic activation mode, this new suspension leaves isRunning false long enough for a second, different mode shortcut to take the isRunning == false branches in GlobalHotkeyManager (for example handlePromptModeKeyDown and the command/rewrite equivalents). Those branches invoke beginDictationRecording, which changes the active mode and prompt selection before its guard !asr.isRunning rejects the duplicate engine start. Consequently, a quick mode-key press while route recovery is draining can capture audio started by the first shortcut but finalize/output it using the second mode's configuration; the automatic paths need to consider isStarting just as the updated toggle paths do.

Useful? React with 👍 / 👎.

@grohith327

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@altic-dev
altic-dev merged commit b0bb11f into main Jul 20, 2026
7 checks passed
aguynamedryan added a commit to aguynamedryan/FluidVoice that referenced this pull request Jul 23, 2026
…v#655

altic-dev#655 ("Drain audio engine off main queue") landed while this branch was open
and reworked the same capture-startup path this branch guards, so the two
collided in `ASRService.startPreferredAudioCapture()`.

Both sides are additive and orthogonal — altic-dev#655 serialises AVAudioEngine
teardown against startup, this branch refuses to start when the pinned
microphone cannot be honoured — so the resolution keeps both:

- `startPreferredAudioCapture()` becomes `async throws` and awaits
  `audioEngineRetirementDrain.waitForScheduledReleases()`, per altic-dev#655.
- The FluidVoice-only guards (missing preferred device, AVAudioEngine
  fallback) are retained unchanged.
- The missing-device check runs *before* the drain await: it is a cheap
  precondition that throws, and there is no reason to wait on a teardown
  for a capture that is about to be refused.
- The compatibility-capture call becomes `try await`.

Both callers of `startPreferredAudioCapture()` already awaited it on main's
side, so no further propagation was needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5paEAboAJeNcbazuzqrQ
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.

2 participants