From 3e3eb0f7663a0e3337c159de83aaa3be467c0ecd Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:09:31 +0800 Subject: [PATCH] fix: audio tap dispatch_assert_queue crash (nonisolated function ref) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same dispatch_assert_queue_fail as the prior SFSpeechRecognizer crash, but in a different code path: `AVAudioNode.installTap`'s callback fires on the AVAudioEngine real-time audio thread. The previous fix (`e8a0310`, extract permission callbacks to `nonisolated static func`) only addressed the once-and-done TCC callback — audio taps are *continuous*, so the runtime reached the audio thread before the user had any chance to back off the recording. Root cause: Swift 6 strict concurrency. The closure literal passed to `installTap` was defined inside a `@MainActor` method, so the compiler inferred the closure body as `@MainActor`- isolated. AVAudioEngine calls it from its real-time audio thread, not main, so `dispatch_assert_queue_fail` fires on the *very first buffer delivery*. Wrapping the inner state updates in `Task { @MainActor in ... }` (the prior fix) was not enough — the runtime checks the OUTER closure's isolation, not just the inner accesses. Fix: build the tap body inside a `nonisolated static func` that returns a function reference. Swift 6 function references never carry inferred isolation, so the dispatch runtime sees the closure as non-isolated and is happy to run it on the audio thread. State updates to `self.level` and the AsyncStream continuation hop back to main via `Task { @MainActor in … }`, which is itself safe to invoke from a non-isolated context. `makeAudioTapBlock` takes the format / converter / sample-rate values plus two `@Sendable` callbacks (`onMeter`, `onSnapshot`) and returns a `@Sendable` closure suitable for the installTap block parameter. No state escapes the audio thread; the only back-channel is through those callbacks. Build: BUILD SUCCEEDED. Tests: 21/21 pass. 🤖 Generated with Claude Code --- OSGKeyboard/Views/PreviewASRController.swift | 142 +++++++++++++------ 1 file changed, 97 insertions(+), 45 deletions(-) diff --git a/OSGKeyboard/Views/PreviewASRController.swift b/OSGKeyboard/Views/PreviewASRController.swift index 4a36c0a..e993590 100644 --- a/OSGKeyboard/Views/PreviewASRController.swift +++ b/OSGKeyboard/Views/PreviewASRController.swift @@ -163,57 +163,38 @@ final class PreviewASRController: ObservableObject { let (stream, continuation) = AsyncStream.makeStream() self.bufferContinuation = continuation - // Tap the hardware input. The closure runs on a real-time audio - // thread, so it must do the minimum work needed to produce a - // snapshot and then hand off to the main actor for state updates. - inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat) { buffer, _ in - // Downsample + extract samples + compute RMS in one pass. - let ratio = targetSampleRate / hwFormat.sampleRate - let outCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 0.5) - guard outCapacity > 0, - let converted = AVAudioPCMBuffer( - pcmFormat: targetFormat, - frameCapacity: outCapacity - ) else { return } - - var error: NSError? - var supplied = false - converter.convert(to: converted, error: &error) { _, outStatus in - if supplied { - outStatus.pointee = .endOfStream - return nil - } - supplied = true - outStatus.pointee = .haveData - return buffer - } - if error != nil { return } - - let n = Int(converted.frameLength) - var samples = [Float](repeating: 0, count: n) - var sumSquares: Float = 0 - if let channelData = converted.floatChannelData?[0] { - for i in 0.. 0 ? sqrtf(sumSquares / Float(n)) : 0 - // RMS for speech is typically 0.02-0.2; the 4x gain here - // pushes normal speech into the 0.4-0.8 range for the - // disc meter so it visibly responds. - let meter = min(Double(rms) * 4.0, 1.0) - let snapshot = AudioBufferSnapshot(samples: samples, sampleRate: targetSampleRate) - - // Hop to main for state updates. + // Tap the hardware input. The closure passed to `installTap` runs + // on the AVAudioEngine real-time audio thread. In Swift 6 strict + // concurrency, a closure literal defined inside a `@MainActor` + // method inherits `@MainActor` isolation, which would trip + // `dispatch_assert_queue_fail` on first invocation from the + // audio thread. The fix is to build the actual tap body in a + // `nonisolated` helper (`makeAudioTapBlock`) and have the + // installTap closure be a single function reference — function + // references never carry inferred isolation, so the dispatch + // runtime is happy and the body runs wherever AVAudioEngine + // wants it (the audio thread). + let onMeter: @Sendable (Double) -> Void = { [weak self] meter in Task { @MainActor [weak self] in guard let self else { return } // Lightweight smoothing so the disc ring doesn't jitter. self.level = self.level * 0.55 + meter * 0.45 - self.bufferContinuation?.yield(snapshot) } } + let onSnapshot: @Sendable (AudioBufferSnapshot) -> Void = { [weak self] snapshot in + Task { @MainActor [weak self] in + self?.bufferContinuation?.yield(snapshot) + } + } + let tap = Self.makeAudioTapBlock( + converter: converter, + targetFormat: targetFormat, + hwFormat: hwFormat, + targetSampleRate: targetSampleRate, + onMeter: onMeter, + onSnapshot: onSnapshot + ) + inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap) didInstallTap = true audioEngine.prepare() @@ -295,4 +276,75 @@ final class PreviewASRController: ObservableObject { } } } + + // MARK: - Audio tap (nonisolated, runs on AVAudioEngine render thread) + // + // `AVAudioNode.installTap`'s callback fires on the audio engine's + // real-time render thread. In Swift 6 strict concurrency, a closure + // literal defined inside a `@MainActor` method inherits `@MainActor` + // isolation — and `dispatch_assert_queue_fail` fires the moment + // the runtime tries to dispatch that closure on a non-main queue. + // + // The trick is to build the actual tap body in a `nonisolated` + // function and have the installTap closure be a *function reference* + // to that helper. Function references never carry inferred + // isolation, so the dispatch runtime is satisfied and the body + // runs wherever AVAudioEngine wants. State updates to + // `self.level` and the AsyncStream continuation hop back to the + // main actor via `Task { @MainActor in … }`, which is itself + // safe to call from a non-isolated context. + private nonisolated static func makeAudioTapBlock( + converter: AVAudioConverter, + targetFormat: AVAudioFormat, + hwFormat: AVAudioFormat, + targetSampleRate: Double, + onMeter: @Sendable @escaping (Double) -> Void, + onSnapshot: @Sendable @escaping (AudioBufferSnapshot) -> Void + ) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void { + // `@Sendable` on the returned closure makes the Sendable + // conformance explicit. `AVAudioNodeTapBlock` is declared as + // a plain escaping closure in the SDK; we cast at the call + // site via `as @Sendable`. + return { buffer, _ in + // Downsample + extract samples + compute RMS in one pass. + let ratio = targetSampleRate / hwFormat.sampleRate + let outCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 0.5) + guard outCapacity > 0, + let converted = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: outCapacity + ) else { return } + + var error: NSError? + var supplied = false + converter.convert(to: converted, error: &error) { _, outStatus in + if supplied { + outStatus.pointee = .endOfStream + return nil + } + supplied = true + outStatus.pointee = .haveData + return buffer + } + if error != nil { return } + + let n = Int(converted.frameLength) + var samples = [Float](repeating: 0, count: n) + var sumSquares: Float = 0 + if let channelData = converted.floatChannelData?[0] { + for i in 0.. 0 ? sqrtf(sumSquares / Float(n)) : 0 + // RMS for speech is typically 0.02-0.2; the 4x gain pushes + // normal speech into the 0.4-0.8 range for the disc meter. + let meter = min(Double(rms) * 4.0, 1.0) + let snapshot = AudioBufferSnapshot(samples: samples, sampleRate: targetSampleRate) + onMeter(meter) + onSnapshot(snapshot) + } + } }