Overhaul audio engine lifecycle: route-change recovery, sleep suspension, teardown fixes

Addresses the audio-engine bug cluster (#209, #218, #226, #245, #246,
#251, #252) while keeping Super Fast Mode's warm-microphone design intact.

Root problems, all variations of 'the engine outlives the world it was
built for':

1. Route/device changes while idle only *deferred* the engine rebuild to
   the next recording, leaving the stale engine running against the old
   route indefinitely. That kept coreaudiod churning while idle (#209)
   and made the first post-change recording unreliable.
2. Route/device changes *during* a recording deferred entirely: the
   recording kept 'running' while the dead engine captured nothing,
   producing empty transcripts after Bluetooth connects, A2DP<->HFP
   switches, and AirPods handoffs (#251, #252, #218, #226).
3. App-quit teardown was a fire-and-forget Task racing process exit,
   with SuperFastCaptureController.deinit as a second competing teardown
   path - the EXC_BAD_ACCESS in AVAudioEngine teardown (#245).
4. The sound-effects output engine (isAutoShutdownEnabled = false) ran
   forever once started, keeping an output IOProc alive even in standard
   mode.

Fixes:

- SuperFastCaptureController gains restartPreservingRecording(reason:),
  which rebuilds engine/tap/converter while keeping the active
  AVAudioFile open, so mid-recording route changes resume capture onto
  the same file (small gap) instead of silently losing the rest of the
  take. startIfNeeded/stop are refactored around shared
  armEngine/detachEngine helpers; detachEngine bumps the capture
  generation so in-flight tap callbacks from the old engine are ignored.
- RecordingClient rebuilds the engine immediately (debounced 250ms) on
  idle environment changes instead of deferring; failures fall back to
  the previous deferred-restart marker.
- The warm engine is suspended on screen lock / display sleep / system
  sleep and rearmed on unlock/wake. Nobody dictates at a locked screen,
  the mic indicator no longer glows while locked, and a whole class of
  stale-engine-across-sleep states disappears. Active recordings are
  never interrupted; an explicit recording request overrides suspension.
  The pre-roll ring buffer is cleared on suspend so no pre-lock audio is
  retained.
- applicationWillTerminate now blocks (3s bound) on recording.cleanup()
  via a detached task + semaphore, making teardown deterministic.
- The sound-effects engine stops 10 seconds after the last play;
  restarting on the next play costs milliseconds.

The persistent mic indicator while unlocked-and-idle (#246) remains
by design when Super Fast Mode is on - that is what keeps the 0.45s
pre-roll available - but it now clears whenever the screen is locked
or asleep.
This commit is contained in:
Kit Langton 2026-07-09 14:31:36 -04:00
commit 8739929294
5 changed files with 193 additions and 20 deletions