@@ -40,6 +40,9 @@ final class AudioEngine {
4040 static let bluetoothHFPSampleRateThreshold : Double = 44100
4141 static let startupConfigurationChangeRecoveryWindow : TimeInterval = 1.0
4242 static let idleEngineReleaseDelay : TimeInterval = 3.0
43+ /// If Core Audio starts but never delivers a buffer, fail instead of leaving
44+ /// Test Dictation or a hotkey recording stuck until the user stops it.
45+ static let noAudioInputTimeout : TimeInterval = 3.0
4346
4447 var isCurrentlyRecording : Bool {
4548 lifecycleQueue. sync { _isCurrentlyRecording }
@@ -61,6 +64,7 @@ final class AudioEngine {
6164 private var silenceDuration : Double = 2.0
6265 private var maxDuration : TimeInterval = 60.0
6366 private var recordingStartTime : Date = Date ( )
67+ private var recordingGeneration : UInt64 = 0
6468
6569 // Audio level throttling
6670 private var lastLevelReportTime : Date = Date ( )
@@ -90,6 +94,9 @@ final class AudioEngine {
9094 /// AppState should use this to recover from a stuck recording state.
9195 var onAudioDeviceChanged : ( ( ) -> Void ) ?
9296
97+ /// Called when the engine reports that it started but delivers no audio buffers.
98+ var onAudioCaptureUnavailable : ( ( ) -> Void ) ?
99+
93100 // MARK: - Initialization
94101
95102 init ( ) {
@@ -382,8 +389,16 @@ final class AudioEngine {
382389 let exception = VocaObjCExceptionCatcher . catchException { [ weak self] in
383390 guard let self = self else { return }
384391
385- inputNode. installTap ( onBus: 0 , bufferSize: 4096 , format: inputFormat) { [ weak self] buffer, _ in
386- self ? . processAudioBuffer ( buffer, inputFormat: inputFormat)
392+ // Let AVAudioEngine bind the tap to the live hardware format.
393+ // Supplying the format queried above can race a Core Audio
394+ // route/format change and raise "Input HW format and tap
395+ // format not matching" for USB and virtual input devices.
396+ inputNode. installTap (
397+ onBus: 0 ,
398+ bufferSize: 4096 ,
399+ format: Self . inputTapFormat ( for: inputFormat)
400+ ) { [ weak self] buffer, _ in
401+ self ? . processAudioBuffer ( buffer, inputFormat: buffer. format)
387402 }
388403
389404 engine. prepare ( )
@@ -429,6 +444,7 @@ final class AudioEngine {
429444 recordingStartTime = Date ( )
430445 lastSoundTime = Date ( )
431446 _isCurrentlyRecording = true
447+ scheduleNoAudioInputWatchdog ( )
432448 return true
433449 }
434450
@@ -486,13 +502,51 @@ final class AudioEngine {
486502
487503 /// Resets per-recording state before a new capture attempt.
488504 private func resetRecordingState( ) {
505+ recordingGeneration &+= 1
489506 clearAudioBuffer ( )
490507 lastSoundTime = Date ( )
491508 recordingStartTime = Date ( )
492509 silenceCallbackFired = false
493510 maxDurationCallbackFired = false
494511 }
495512
513+ /// Stops a recording that started successfully but receives no callbacks
514+ /// from Core Audio. Silence detection and the normal duration limit run from
515+ /// the audio callback, so neither can recover this specific dead-route state.
516+ private func scheduleNoAudioInputWatchdog( ) {
517+ let generation = recordingGeneration
518+ lifecycleQueue. asyncAfter ( deadline: . now( ) + Self. noAudioInputTimeout) { [ weak self] in
519+ guard let self,
520+ self . _isCurrentlyRecording,
521+ self . recordingGeneration == generation else { return }
522+ let hasCapturedSamples = self . bufferQueue. sync { !self . audioBuffer. isEmpty }
523+ guard Self . shouldFailNoAudioInputStartup (
524+ isRecording: self . _isCurrentlyRecording,
525+ hasCapturedSamples: hasCapturedSamples
526+ ) else { return }
527+
528+ VocaLogger . warning ( . audioEngine, " Audio engine started but delivered no input buffers " )
529+ self . recoverFromStartFailure ( notifyAppState: false )
530+ DispatchQueue . main. async { [ weak self] in
531+ self ? . onAudioCaptureUnavailable ? ( )
532+ }
533+ }
534+ }
535+
536+ static func shouldFailNoAudioInputStartup(
537+ isRecording: Bool ,
538+ hasCapturedSamples: Bool
539+ ) -> Bool {
540+ isRecording && !hasCapturedSamples
541+ }
542+
543+ /// A nil tap format asks AVAudioEngine to use the input node's live format.
544+ /// This avoids binding the tap to a snapshot that can become stale while
545+ /// Core Audio finishes applying an input route.
546+ static func inputTapFormat( for _: AVAudioFormat ) -> AVAudioFormat ? {
547+ nil
548+ }
549+
496550 private func setIsPreparingRecording( _ isPreparing: Bool ) {
497551 recordingPreparationLock. lock ( )
498552 _isPreparingRecording = isPreparing
@@ -545,8 +599,12 @@ final class AudioEngine {
545599 var startError : Error ?
546600 let exception = VocaObjCExceptionCatcher . catchException { [ weak self] in
547601 guard let self else { return }
548- inputNode. installTap ( onBus: 0 , bufferSize: 4096 , format: inputFormat) { [ weak self] buffer, _ in
549- self ? . processAudioBuffer ( buffer, inputFormat: inputFormat)
602+ inputNode. installTap (
603+ onBus: 0 ,
604+ bufferSize: 4096 ,
605+ format: Self . inputTapFormat ( for: inputFormat)
606+ ) { [ weak self] buffer, _ in
607+ self ? . processAudioBuffer ( buffer, inputFormat: buffer. format)
550608 }
551609 engine. prepare ( )
552610 do {
0 commit comments