Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Sources/OpenWritr/AudioDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 37 additions & 8 deletions Sources/OpenWritr/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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?
Expand Down Expand Up @@ -701,7 +702,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable {
returnedGeneration = nil
}
if case .idle = finalState {
recoveryAttemptCount = 0
resetRecoveryTracking()
}
transitionOnQueue(to: finalState, generation: generation)
}
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -767,26 +786,36 @@ 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.")
)
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)"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -1276,7 +1305,7 @@ final class AudioEngine: AudioCapturing, @unchecked Sendable {
activeGeneration = nil
returnedGeneration = nil
activeDeviceID = nil
recoveryAttemptCount = 0
resetRecoveryTracking()
removeConfigObserverOnQueue()
stopEngineOnQueue()
removeDeviceListListenersOnQueue()
Expand Down
36 changes: 36 additions & 0 deletions Sources/OpenWritr/MicrophoneRecoveryPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
52 changes: 52 additions & 0 deletions Tests/OpenWritrTests/MicrophoneRecoveryPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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..<policy.maximumAttempts).reduce(0) {
$0 + policy.delayMilliseconds(afterAttempts: $1)
}

#expect(policy.maximumAttempts == 6)
#expect(total >= 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))
}
}
}
Loading