From 7af24cc9e87e59ac37a810beaad585c561b10d4b Mon Sep 17 00:00:00 2001 From: Torsten Mahr <24534196+trsdn@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:30:56 +0200 Subject: [PATCH] fix(audio): give Bluetooth headsets time to settle during microphone recovery AirPods Max switch profile when capture starts and can change format several times. Recovery gave up after three attempts 0.9 s apart. Allow six attempts with growing waits, count them per burst of changes, and log each configuration decision with the device transport and sample rate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 +++ Sources/OpenWritr/AudioDiagnostics.swift | 7 +++ Sources/OpenWritr/AudioEngine.swift | 45 +++++++++++++--- .../OpenWritr/MicrophoneRecoveryPolicy.swift | 36 +++++++++++++ .../MicrophoneRecoveryPolicyTests.swift | 52 +++++++++++++++++++ 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 Sources/OpenWritr/MicrophoneRecoveryPolicy.swift create mode 100644 Tests/OpenWritrTests/MicrophoneRecoveryPolicyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 35cc20f..7e1cab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to OpenWritr will be documented in this file. Each release's notes on GitHub are generated from its section here, and the broker publication handoff fails when the section for a tag is missing or empty. +## [1.7.4] — 2026-10-13 + +### Fixed +- Dictation with Bluetooth headsets such as AirPods Max no longer fails after the audio engine restarts a few times while the headset switches to its microphone profile. The microphone recovery now waits longer between attempts, allows more of them, and counts them per burst of changes instead of per recording + +### Changed +- Microphone recovery logs each configuration change with the engine state, attempt count, and the device's transport, channels, and sample rate, so a failure can be diagnosed from the log + ## [1.7.3] — 2026-10-13 ### Fixed diff --git a/Sources/OpenWritr/AudioDiagnostics.swift b/Sources/OpenWritr/AudioDiagnostics.swift index 812aee9..5af422c 100644 --- a/Sources/OpenWritr/AudioDiagnostics.swift +++ b/Sources/OpenWritr/AudioDiagnostics.swift @@ -79,6 +79,13 @@ struct AudioDiagnostics: Equatable, Sendable { ) } + /// Transport, channels and sample rate of one device, for logs. Never includes its name or UID. + static func deviceFormatSummary(for deviceID: AudioDeviceID) -> String { + let rate = (property(of: deviceID, selector: kAudioDevicePropertyNominalSampleRate) as Float64?) + .map { String(Int($0.rounded())) } ?? "unknown" + return "transport=\(transportName(of: deviceID)) channels=\(inputChannelCount(of: deviceID)) rate=\(rate)" + } + static func transportName(forCode code: UInt32) -> String { switch code { case kAudioDeviceTransportTypeBuiltIn: return "built-in" diff --git a/Sources/OpenWritr/AudioEngine.swift b/Sources/OpenWritr/AudioEngine.swift index d702c99..1231350 100644 --- a/Sources/OpenWritr/AudioEngine.swift +++ b/Sources/OpenWritr/AudioEngine.swift @@ -241,7 +241,7 @@ protocol AudioCapturing: AnyObject, Sendable { } final class AudioEngine: AudioCapturing, @unchecked Sendable { - private static let maximumRecoveryAttempts = 3 + private static let recoveryPolicy = MicrophoneRecoveryPolicy() private let targetSampleRate: Double = 16_000 private let captureClock = ContinuousClock() private let captureBuffer = CaptureBufferState() @@ -261,6 +261,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { private var returnedGeneration: UInt64? private var activeDeviceID: AudioDeviceID? private var recoveryAttemptCount = 0 + private var lastRecoveryAt: ContinuousClock.Instant? private var pendingConfigurationWork: DispatchWorkItem? private var selectedDeviceID: AudioDeviceID? private var configObserver: Any? @@ -701,7 +702,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { returnedGeneration = nil } if case .idle = finalState { - recoveryAttemptCount = 0 + resetRecoveryTracking() } transitionOnQueue(to: finalState, generation: generation) } @@ -728,6 +729,23 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { } } + /// Recoveries are counted per burst of configuration changes: once a recovered engine has run + /// steadily, the next change starts a fresh count. + private func countedRecoveryAttemptsOnQueue() -> Int { + let elapsed = lastRecoveryAt.map { captureClock.now - $0 } + let counted = Self.recoveryPolicy.attemptsCounted(recoveryAttemptCount, sinceLastRecovery: elapsed) + if counted != recoveryAttemptCount { + recoveryAttemptCount = counted + lastRecoveryAt = nil + } + return counted + } + + private func resetRecoveryTracking() { + recoveryAttemptCount = 0 + lastRecoveryAt = nil + } + private func scheduleConfigurationDecisionOnQueue(generation: UInt64) { guard !isShuttingDown, activeGeneration == generation else { return } switch lifecycleState { @@ -742,8 +760,9 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { self?.handleConfigurationDecisionOnQueue(generation: generation) } pendingConfigurationWork = item + let delayMilliseconds = Self.recoveryPolicy.delayMilliseconds(afterAttempts: countedRecoveryAttemptsOnQueue()) audioLog.debug("debounced configuration change for generation \(generation)") - lifecycleQueue.asyncAfter(deadline: .now() + .milliseconds(250), execute: item) + lifecycleQueue.asyncAfter(deadline: .now() + .milliseconds(delayMilliseconds), execute: item) } private func handleConfigurationDecisionOnQueue(generation: UInt64) { @@ -767,12 +786,21 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { return } + let attempts = countedRecoveryAttemptsOnQueue() + audioLog.notice( + """ + configuration change for generation \(generation): \ + engineRunning=\(self.engine?.isRunning ?? false) running=\(self.isRunning) \ + attempts=\(attempts) \(AudioDiagnostics.deviceFormatSummary(for: deviceID)) + """ + ) + if let engine, isRunning, engine.isRunning { audioLog.debug("benign configuration change for generation \(generation)") return } - guard recoveryAttemptCount < Self.maximumRecoveryAttempts else { + guard Self.recoveryPolicy.allowsAnotherAttempt(after: attempts) else { failActiveGenerationOnQueue( generation, error: .engineStartFailed("The audio engine stopped repeatedly during microphone recovery.") @@ -780,13 +808,14 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { return } - recoveryAttemptCount += 1 + recoveryAttemptCount = attempts + 1 transitionOnQueue(to: .recovering, generation: generation) try recoverEngineOnQueue(generation: generation) guard !shutdownWasRequested(), activeGeneration == generation else { cleanupGenerationOnQueue(generation, discardSamples: true, finalState: .idle) return } + lastRecoveryAt = captureClock.now transitionOnQueue(to: .capturing, generation: generation) audioLog.notice( "recovered capture generation \(generation), attempt \(self.recoveryAttemptCount)" @@ -1150,7 +1179,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { let generation = nextGeneration activeGeneration = generation returnedGeneration = nil - recoveryAttemptCount = 0 + resetRecoveryTracking() transitionOnQueue(to: .starting, generation: generation) do { @@ -1250,7 +1279,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { activeGeneration = nil returnedGeneration = nil activeDeviceID = nil - recoveryAttemptCount = 0 + resetRecoveryTracking() transitionOnQueue(to: .idle, generation: handle.generation) audioLog.notice("capture generation \(handle.generation) stopped with \(samples.count) samples") continuation.resume(returning: samples) @@ -1276,7 +1305,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable { activeGeneration = nil returnedGeneration = nil activeDeviceID = nil - recoveryAttemptCount = 0 + resetRecoveryTracking() removeConfigObserverOnQueue() stopEngineOnQueue() removeDeviceListListenersOnQueue() diff --git a/Sources/OpenWritr/MicrophoneRecoveryPolicy.swift b/Sources/OpenWritr/MicrophoneRecoveryPolicy.swift new file mode 100644 index 0000000..ee24dfe --- /dev/null +++ b/Sources/OpenWritr/MicrophoneRecoveryPolicy.swift @@ -0,0 +1,36 @@ +import Foundation + +/// How long and how often the audio engine rebuilds itself after a configuration change. +/// +/// A Bluetooth headset switches to its microphone profile when capture starts, which can change +/// the format several times over a few seconds. Giving up after three quick attempts made +/// dictation fail on such devices, so attempts back off and are counted per burst of changes +/// rather than per capture. +struct MicrophoneRecoveryPolicy: Equatable, Sendable { + var maximumAttempts = 6 + var baseDelay: Duration = .milliseconds(250) + var delayStep: Duration = .milliseconds(250) + var maximumDelay: Duration = .milliseconds(1500) + /// A recovered engine that then runs this long without another change ends the burst. + var stableAfter: Duration = .seconds(3) + + /// The wait before judging a configuration change, growing with each recovery in the burst. + func delay(afterAttempts attempts: Int) -> Duration { + min(baseDelay + delayStep * max(attempts, 0), maximumDelay) + } + + func delayMilliseconds(afterAttempts attempts: Int) -> Int { + let parts = delay(afterAttempts: attempts).components + return Int(parts.seconds) * 1000 + Int(parts.attoseconds / 1_000_000_000_000_000) + } + + func allowsAnotherAttempt(after attempts: Int) -> Bool { + attempts < maximumAttempts + } + + /// The attempts still counted against the burst: none once the engine has been stable. + func attemptsCounted(_ attempts: Int, sinceLastRecovery elapsed: Duration?) -> Int { + guard let elapsed, elapsed >= stableAfter else { return attempts } + return 0 + } +} diff --git a/Tests/OpenWritrTests/MicrophoneRecoveryPolicyTests.swift b/Tests/OpenWritrTests/MicrophoneRecoveryPolicyTests.swift new file mode 100644 index 0000000..10f2ae6 --- /dev/null +++ b/Tests/OpenWritrTests/MicrophoneRecoveryPolicyTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import OpenWritr + +@Suite("Microphone recovery policy") +struct MicrophoneRecoveryPolicyTests { + private let policy = MicrophoneRecoveryPolicy() + + @Test func waitsLongerAfterEachRecoveryUpToACap() { + let delays = (0...8).map { policy.delayMilliseconds(afterAttempts: $0) } + + #expect(delays == [250, 500, 750, 1000, 1250, 1500, 1500, 1500, 1500]) + #expect(policy.delayMilliseconds(afterAttempts: -3) == 250) + } + + @Test func givesBluetoothHeadsetsSeveralSecondsToSettle() { + // The first three attempts used to be the whole budget (about 0.75 s of waiting). + let total = (0..= 5_000) + } + + @Test func allowsAttemptsOnlyBelowTheMaximum() { + #expect(policy.allowsAnotherAttempt(after: 0)) + #expect(policy.allowsAnotherAttempt(after: policy.maximumAttempts - 1)) + #expect(!policy.allowsAnotherAttempt(after: policy.maximumAttempts)) + } + + @Test func aStableEngineStartsAFreshBurst() { + #expect(policy.attemptsCounted(4, sinceLastRecovery: .seconds(3)) == 0) + #expect(policy.attemptsCounted(4, sinceLastRecovery: .seconds(10)) == 0) + } + + @Test func anUnstableEngineKeepsItsCount() { + #expect(policy.attemptsCounted(4, sinceLastRecovery: .milliseconds(900)) == 4) + #expect(policy.attemptsCounted(2, sinceLastRecovery: nil) == 2) + } + + @Test func deviceFormatSummaryNeverContainsNamesOrUIDs() { + for device in AudioEngine.availableInputDevices() { + let summary = AudioDiagnostics.deviceFormatSummary(for: device.id) + + #expect(summary.hasPrefix("transport=")) + #expect(summary.contains("channels=")) + #expect(summary.contains("rate=")) + #expect(!summary.contains(device.name)) + #expect(!summary.contains(device.uid)) + } + } +}