From a42f9c7bb05a173d8a585efaaeea4f3a1daf7ca7 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 25 Aug 2026 20:47:11 +0800 Subject: [PATCH] refactor: separate macOS speech audio input --- Termind/App/AppModel.swift | 4 +- .../AppleSpeechTranscriptionService.swift | 118 +++++------------- .../Speech/SpeechAudioInput.swift | 16 +++ .../macOS/MacOSSpeechAudioInput.swift | 114 +++++++++++++++++ 4 files changed, 166 insertions(+), 86 deletions(-) rename Termind/{Platform/macOS => Infrastructure/Speech}/AppleSpeechTranscriptionService.swift (60%) create mode 100644 Termind/Infrastructure/Speech/SpeechAudioInput.swift create mode 100644 Termind/Platform/macOS/MacOSSpeechAudioInput.swift diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 21fde98d..6284aabf 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -99,7 +99,9 @@ final class AppModel { self.ai = ai self.speechTranscription = speechTranscription - ?? SpeechTranscriptionCoordinator(service: AppleSpeechTranscriptionService()) + ?? SpeechTranscriptionCoordinator( + service: AppleSpeechTranscriptionService(audioInput: MacOSSpeechAudioInput()) + ) outboxRunner = dependencies.outboxRunner agentHosts.configureTemporaryTargets( targets: { [weak self] in self?.temporaryAgentTargets() ?? [] }, diff --git a/Termind/Platform/macOS/AppleSpeechTranscriptionService.swift b/Termind/Infrastructure/Speech/AppleSpeechTranscriptionService.swift similarity index 60% rename from Termind/Platform/macOS/AppleSpeechTranscriptionService.swift rename to Termind/Infrastructure/Speech/AppleSpeechTranscriptionService.swift index 0be20ab2..21279ee7 100644 --- a/Termind/Platform/macOS/AppleSpeechTranscriptionService.swift +++ b/Termind/Infrastructure/Speech/AppleSpeechTranscriptionService.swift @@ -4,15 +4,18 @@ import Foundation @MainActor final class AppleSpeechTranscriptionService: SpeechTranscriptionService { - private let audioEngine = AVAudioEngine() + private let audioInput: any SpeechAudioInput private var recognizer: SFSpeechRecognizer? private var recognitionTask: SFSpeechRecognitionTask? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var sessionToken: UUID? - private var inputTapInstalled = false + + init(audioInput: any SpeechAudioInput) { + self.audioInput = audioInput + } func requestAuthorization() async throws { - let microphoneAllowed = await requestMicrophoneAuthorization() + let microphoneAllowed = await audioInput.requestAuthorization() guard microphoneAllowed else { throw SpeechTranscriptionFailure(.microphonePermissionDenied) } @@ -37,12 +40,6 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { // MainActor-inherited closure being invoked on the expected queue. recognizer.queue = .main - let inputNode = audioEngine.inputNode - let format = inputNode.outputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { - throw SpeechTranscriptionFailure(.audioInputUnavailable) - } - let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true @@ -59,21 +56,14 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { ) ) - // Build the tap block in a nonisolated context. A closure created - // directly in this @MainActor method inherits main-actor isolation, - // but AVAudioEngine invokes it on its realtime audio queue. In Swift 6 - // that mismatch traps with a libdispatch main-queue precondition. - inputNode.installTap( - onBus: 0, - bufferSize: 1_024, - format: format, - block: Self.makeAudioTapHandler(request: request, eventHandler: eventHandler) - ) - inputTapInstalled = true - - audioEngine.prepare() do { - try audioEngine.start() + try audioInput.start( + bufferHandler: Self.makeAudioBufferHandler(request: request), + levelHandler: Self.makeAudioLevelHandler(eventHandler: eventHandler) + ) + } catch let failure as SpeechTranscriptionFailure { + cancel() + throw failure } catch { cancel() throw SpeechTranscriptionFailure(.audioInputUnavailable, detail: error.localizedDescription) @@ -81,13 +71,13 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { } func finish() { - stopCapturingAudio() + audioInput.stop() recognitionRequest?.endAudio() } func cancel() { sessionToken = nil - stopCapturingAudio() + audioInput.stop() recognitionRequest?.endAudio() recognitionTask?.cancel() recognitionTask = nil @@ -95,31 +85,6 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { recognizer = nil } - private func stopCapturingAudio() { - if audioEngine.isRunning { audioEngine.stop() } - if inputTapInstalled { - audioEngine.inputNode.removeTap(onBus: 0) - inputTapInstalled = false - } - } - - private func requestMicrophoneAuthorization() async -> Bool { - switch AVCaptureDevice.authorizationStatus(for: .audio) { - case .authorized: - return true - case .notDetermined: - return await withCheckedContinuation { continuation in - AVCaptureDevice.requestAccess(for: .audio) { allowed in - continuation.resume(returning: allowed) - } - } - case .denied, .restricted: - return false - @unknown default: - return false - } - } - private func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { let status = SFSpeechRecognizer.authorizationStatus() guard status == .notDetermined else { return status } @@ -157,16 +122,17 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { } } - nonisolated private static func makeAudioTapHandler( - request: SFSpeechAudioBufferRecognitionRequest, - eventHandler: @escaping @MainActor (SpeechTranscriptionEvent) -> Void - ) -> AVAudioNodeTapBlock { - let levelGate = AudioLevelDeliveryGate(maximumUpdatesPerSecond: 30) - return { buffer, _ in - request.append(buffer) + nonisolated private static func makeAudioBufferHandler( + request: SFSpeechAudioBufferRecognitionRequest + ) -> SpeechAudioBufferHandler { + let sink = SpeechRecognitionBufferSink(request: request) + return { buffer in sink.append(buffer) } + } - let level = normalizedLevel(from: buffer) - guard levelGate.shouldDeliver() else { return } + nonisolated private static func makeAudioLevelHandler( + eventHandler: @escaping @MainActor (SpeechTranscriptionEvent) -> Void + ) -> SpeechAudioLevelHandler { + { level in DispatchQueue.main.async { MainActor.assumeIsolated { eventHandler(.level(level)) @@ -183,14 +149,14 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { ) { if let transcript { if isFinal { - stopCapturingAudio() + audioInput.stop() eventHandler(.final(transcript)) } else { eventHandler(.partial(transcript)) } } if let failure { - stopCapturingAudio() + audioInput.stop() eventHandler(.failure(failure)) } } @@ -207,34 +173,16 @@ final class AppleSpeechTranscriptionService: SpeechTranscriptionService { ) } - nonisolated private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Float { - guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { return 0.04 } - let samples = channels[0] - var sum: Float = 0 - for index in 0.. Bool { - lock.lock() - defer { lock.unlock() } - guard now - lastDeliveryTime >= minimumInterval else { return false } - lastDeliveryTime = now - return true + func append(_ buffer: AVAudioPCMBuffer) { + request.append(buffer) } } diff --git a/Termind/Infrastructure/Speech/SpeechAudioInput.swift b/Termind/Infrastructure/Speech/SpeechAudioInput.swift new file mode 100644 index 00000000..5912840e --- /dev/null +++ b/Termind/Infrastructure/Speech/SpeechAudioInput.swift @@ -0,0 +1,16 @@ +@preconcurrency import AVFoundation + +typealias SpeechAudioBufferHandler = @Sendable (AVAudioPCMBuffer) -> Void +typealias SpeechAudioLevelHandler = @Sendable (Float) -> Void + +/// Apple Speech consumes PCM buffers, while each supported OS owns the +/// permission and audio-device lifecycle used to produce them. +@MainActor +protocol SpeechAudioInput: AnyObject { + func requestAuthorization() async -> Bool + func start( + bufferHandler: @escaping SpeechAudioBufferHandler, + levelHandler: @escaping SpeechAudioLevelHandler + ) throws + func stop() +} diff --git a/Termind/Platform/macOS/MacOSSpeechAudioInput.swift b/Termind/Platform/macOS/MacOSSpeechAudioInput.swift new file mode 100644 index 00000000..38ddcbb4 --- /dev/null +++ b/Termind/Platform/macOS/MacOSSpeechAudioInput.swift @@ -0,0 +1,114 @@ +@preconcurrency import AVFoundation +import Foundation + +/// macOS microphone permission and AVAudioEngine lifecycle for Apple Speech. +@MainActor +final class MacOSSpeechAudioInput: SpeechAudioInput { + private let audioEngine = AVAudioEngine() + private var inputTapInstalled = false + + func requestAuthorization() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return true + case .notDetermined: + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { allowed in + continuation.resume(returning: allowed) + } + } + case .denied, .restricted: + return false + @unknown default: + return false + } + } + + func start( + bufferHandler: @escaping SpeechAudioBufferHandler, + levelHandler: @escaping SpeechAudioLevelHandler + ) throws { + stop() + + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { + throw SpeechTranscriptionFailure(.audioInputUnavailable) + } + + // AVAudioEngine invokes this tap on its realtime audio queue, so the + // closure must be created outside MainActor isolation. + inputNode.installTap( + onBus: 0, + bufferSize: 1_024, + format: format, + block: Self.makeAudioTapHandler( + bufferHandler: bufferHandler, + levelHandler: levelHandler + ) + ) + inputTapInstalled = true + + audioEngine.prepare() + do { + try audioEngine.start() + } catch { + stop() + throw SpeechTranscriptionFailure( + .audioInputUnavailable, + detail: error.localizedDescription + ) + } + } + + func stop() { + if audioEngine.isRunning { audioEngine.stop() } + if inputTapInstalled { + audioEngine.inputNode.removeTap(onBus: 0) + inputTapInstalled = false + } + } + + nonisolated private static func makeAudioTapHandler( + bufferHandler: @escaping SpeechAudioBufferHandler, + levelHandler: @escaping SpeechAudioLevelHandler + ) -> AVAudioNodeTapBlock { + let levelGate = AudioLevelDeliveryGate(maximumUpdatesPerSecond: 30) + return { buffer, _ in + bufferHandler(buffer) + guard levelGate.shouldDeliver() else { return } + levelHandler(normalizedLevel(from: buffer)) + } + } + + nonisolated private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Float { + guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { return 0.04 } + let samples = channels[0] + var sum: Float = 0 + for index in 0.. Bool { + lock.lock() + defer { lock.unlock() } + guard now - lastDeliveryTime >= minimumInterval else { return false } + lastDeliveryTime = now + return true + } +}