import AVFoundation import Foundation import Speech /// T-iOS-31 · `VoiceDictating` 的真机实现(Speech + AVAudioEngine)。协议、状态机 /// 与匹配器在 `VoicePTT.swift`;从那里拆出是为了守住 800 行硬上限(plan §4)。 /// 真机识别器。**隐私**:支持时强制设备端识别(`requiresOnDeviceRecognition`), /// 音频不落盘、不出设备;不支持设备端识别的机型才走 Apple 的服务端识别(等价 /// web 的 SEC-L2 披露)。转写只在内存里,注入前还要过 `VoiceTranscript.sanitize`。 /// /// PTT 语义:松手即取**当前最佳假设**(不等最终结果),否则确认要多等 1–2 秒。 @MainActor final class SpeechDictation: VoiceDictating { enum DictationError: LocalizedError { case recognizerUnavailable case microphoneUnavailable var errorDescription: String? { switch self { case .recognizerUnavailable: "设备上的语音识别当前不可用。" case .microphoneUnavailable: "拿不到麦克风输入。" } } } /// 音频 tap 的缓冲帧数(Apple 示例值)。 private static let tapBufferSize: AVAudioFrameCount = 1024 private lazy var audioEngine = AVAudioEngine() private lazy var recognizer = SFSpeechRecognizer(locale: .current) ?? SFSpeechRecognizer() private var request: SFSpeechAudioBufferRecognitionRequest? private var task: SFSpeechRecognitionTask? private var onPartial: (@MainActor (String) -> Void)? private var isTapInstalled = false private var latest = VoiceDictationResult(transcript: "", confidence: nil) func requestAuthorization() async -> VoiceAuthorization { let speech = await Self.requestSpeechAuthorization() switch speech { case .authorized: break case .restricted: return .restricted case .denied, .notDetermined: return .deniedSpeech @unknown default: return .deniedSpeech } return await Self.requestMicrophoneAuthorization() ? .granted : .deniedMicrophone } func start(onPartial: @escaping @MainActor (String) -> Void) async throws { guard let recognizer, recognizer.isAvailable else { throw DictationError.recognizerUnavailable } self.onPartial = onPartial latest = VoiceDictationResult(transcript: "", confidence: nil) let session = AVAudioSession.sharedInstance() try session.setCategory(.record, mode: .measurement, options: .duckOthers) try session.setActive(true, options: .notifyOthersOnDeactivation) let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition self.request = request let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) // 没有可用输入时 installTap 会抛 ObjC 异常(直接崩)—— 先自己拦住。 guard format.sampleRate > 0, format.channelCount > 0 else { teardown() throw DictationError.microphoneUnavailable } input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: format) { buffer, _ in request.append(buffer) } isTapInstalled = true audioEngine.prepare() try audioEngine.start() task = recognizer.recognitionTask(with: request) { [weak self] result, error in // 非 Sendable 的 result 绝不跨隔离域 —— 先抽出标量再回主 actor。 let transcript = result?.bestTranscription.formattedString let confidence = result.flatMap { Self.averageConfidence(of: $0.bestTranscription) } let isFinal = result?.isFinal ?? false let didFail = error != nil Task { @MainActor in self?.ingest( transcript: transcript, confidence: confidence, isFinal: isFinal, didFail: didFail ) } } } func stop() async -> VoiceDictationResult { teardown() return latest } // MARK: Internals private func ingest( transcript: String?, confidence: Double?, isFinal: Bool, didFail: Bool ) { if let transcript, !transcript.isEmpty { latest = VoiceDictationResult(transcript: transcript, confidence: confidence) onPartial?(transcript) } guard isFinal || didFail else { return } teardown() } /// 幂等收尾:结束音频、撤 tap、停引擎、放掉音频会话。 private func teardown() { request?.endAudio() task?.cancel() task = nil request = nil onPartial = nil if isTapInstalled { audioEngine.inputNode.removeTap(onBus: 0) isTapInstalled = false } if audioEngine.isRunning { audioEngine.stop() } try? AVAudioSession.sharedInstance() .setActive(false, options: .notifyOthersOnDeactivation) } private static func averageConfidence(of transcription: SFTranscription) -> Double? { let values = transcription.segments.map { Double($0.confidence) }.filter { $0 > 0 } guard !values.isEmpty else { return nil } return values.reduce(0, +) / Double(values.count) } private static func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { status in continuation.resume(returning: status) } } } private static func requestMicrophoneAuthorization() async -> Bool { await withCheckedContinuation { continuation in AVAudioApplication.requestRecordPermission { granted in continuation.resume(returning: granted) } } } }