@@ -611,15 +611,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
611611 }
612612 }
613613
614+ // S1-T3: Drive HUD animation and state transition from onMicOpen —
615+ // this fires inside startRecording() AFTER AVAudioEngine.start() returns,
616+ // guaranteeing the mic is open before any visual recording feedback appears.
617+ pipeline. onMicOpen = { [ weak self, weak hud] in
618+ flog. log ( . info, " [TIMING] Mic ready — HUD animating " )
619+ Task { @MainActor [ weak self, weak hud] in
620+ self ? . transitionToRecording ( )
621+ hud? . recordingDidStart ( )
622+ }
623+ }
624+
614625 // Wire hotkey to pipeline + state model.
626+ // S1-T3: HUD animation is sequenced AFTER mic is confirmed open.
627+ // onMicOpen fires from inside startRecording() once AVAudioEngine.start() returns.
628+ // This eliminates the ~100-150 ms leading-word dropout caused by the old pattern
629+ // of calling hud.recordingDidStart() synchronously before the async Task ran.
615630 hotkey. onStartRecording = { [ weak self, weak hud] in
616- flog. log ( . info, " Hotkey down — recording started. " )
617- Task { await self ? . pipeline. startRecording ( ) }
618- self ? . transitionToRecording ( )
619- hud? . recordingDidStart ( )
631+ flog. log ( . info, " [TIMING] Hotkey down " )
632+ Task {
633+ await self ? . pipeline. startRecording ( )
634+ // onMicOpen fires from inside startRecording() — HUD wires to it below.
635+ // transitionToRecording and recordingDidStart are driven via onMicOpen.
636+ }
620637 }
621638 hotkey. onStopRecording = { [ weak self, weak hud] in
622639 guard let self else { return }
640+ flog. log ( . info, " [TIMING] Hotkey up " )
623641 flog. log ( . info, " Hotkey up — running transcription. " )
624642 self . transitionToProcessing ( )
625643 hud? . recordingDidStop ( )
@@ -641,11 +659,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
641659
642660 dispatcher. onDefaultTranscription = nil // PTT driven by hotkey.onStartRecording above.
643661
662+ // S1-T4: Hands-free path uses mic-first ordering via a dedicated Task.
663+ // onMicOpen (wired above) fires transitionToRecording; for the hands-free path
664+ // we need transitionToHandsFree instead. We sequence this by awaiting
665+ // startRecording() and reading the result synchronously on MainActor,
666+ // then transitioning on MainActor inside the same Task — mic is already open.
644667 dispatcher. onHandsFreeTranscription = { [ weak self, weak hud] in
668+ flog. log ( . info, " [TIMING] Hotkey down " )
645669 flog. log ( . info, " Hands-free hotkey — hands-free recording started. " )
646- Task { await self ? . pipeline. startRecording ( ) }
647- self ? . transitionToHandsFree ( )
648- hud? . recordingDidStart ( )
670+ Task { [ weak self, weak hud] in
671+ await self ? . pipeline. startRecording ( )
672+ // At this point mic is open (startRecording has returned).
673+ // onMicOpen fires inside startRecording and calls transitionToRecording;
674+ // override with hands-free state on MainActor immediately after.
675+ await MainActor . run { [ weak self, weak hud] in
676+ flog. log ( . info, " [TIMING] Mic ready — HUD animating " )
677+ self ? . transitionToHandsFree ( )
678+ hud? . recordingDidStart ( )
679+ }
680+ }
649681 }
650682
651683 dispatcher. onOpenPopover = { [ weak self] in
0 commit comments