import Foundation import Speech import AVFoundation /// 「记录问诊」录音 + 端侧转写(2026-06-28)。 /// /// 与 `SpeechDictationService`(写日记口述,刻意**不落盘音频**)的区别:问诊要留一段可回放的录音, /// 所以本服务在**同一个 AVAudioEngine tap** 里做两件事: /// ① `request.append(buffer)` → SFSpeech 端侧流式转写(实时字幕,`requiresOnDeviceRecognition = true`,红线:识别不出设备); /// ② `audioFile.write(buffer)` → 落一份 m4a(AAC)到 tmp,停止后由调用方 `FileVault.importFile` 搬进加密 Vault。 /// /// 音频落盘是**尽力而为**:任何一步失败都 `try?` 吞掉,最坏情况只是没有录音文件—— /// 转写稿来自独立的 SFSpeech 流,照常返回,记录照常保存(守红线 #5:失败回退,不卡死)。 /// /// 工程默认 MainActor 隔离,本类型即 MainActor;tap 与识别回调在系统线程, /// 闭包内只碰局部捕获对象,回主线程统一走 `Task { @MainActor }`(同 SpeechDictationService)。 final class ConsultationRecorder { enum RecorderError: Error, LocalizedError { case unavailable case audioEngineStartFailed(String) var errorDescription: String? { switch self { case .unavailable: return String(appLoc: "本机不支持端侧语音识别") case .audioEngineStartFailed(let m): return String(appLoc: "录音启动失败:\(m)") } } } /// 停止后返回:最终转写稿 + 录音临时文件(可能为 nil,音频落盘失败时)。 struct Result { let transcript: String /// tmp 目录里的 m4a;调用方负责 `FileVault.importFile` 搬进 Vault 或丢弃。 let audioTempURL: URL? } /// 优先系统语言;系统语言不支持端侧时兜底中文(同 SpeechDictationService)。 private static func makeRecognizer() -> SFSpeechRecognizer? { if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition { return r } if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")), r.supportsOnDeviceRecognition { return r } return nil } /// 本机是否支持端侧识别。false(模拟器 / 老机型)时 UI 退化为手动文字录入。 static var isAvailable: Bool { makeRecognizer() != nil } private let audioEngine = AVAudioEngine() private var request: SFSpeechAudioBufferRecognitionRequest? private var task: SFSpeechRecognitionTask? /// 第一帧 buffer 到达时按其真实格式惰性建文件,保证写入格式与 tap 完全一致(不会因格式不匹配静默丢音)。 private var audioFile: AVAudioFile? private var audioTempURL: URL? private var latestText = "" private var didFinish = false private(set) var isRecording = false /// 麦克风 + 语音识别两个权限一起申请。任一被拒返回 false。 func requestAuthorization() async -> Bool { let speech = await withCheckedContinuation { (c: CheckedContinuation) in SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) } } guard speech == .authorized else { return false } return await AVAudioApplication.requestRecordPermission() } /// 开始录音 + 流式识别。partial 结果在主线程回调(实时字幕)。 func start(onPartial: @escaping (String) -> Void) throws { guard !isRecording else { return } guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else { throw RecorderError.unavailable } let session = AVAudioSession.sharedInstance() do { // .record + 默认模式(非 .measurement):问诊录音要尽量保真,不做语音增强裁剪。 try session.setCategory(.record, mode: .default, options: .duckOthers) try session.setActive(true, options: .notifyOthersOnDeactivation) } catch { throw RecorderError.audioEngineStartFailed(error.localizedDescription) } let request = SFSpeechAudioBufferRecognitionRequest() request.requiresOnDeviceRecognition = true // 红线:识别不出设备 request.shouldReportPartialResults = true request.addsPunctuation = true self.request = request latestText = "" didFinish = false audioFile = nil // 录音先落 tmp(不加密、可边录边写,不受锁屏文件保护影响);停止后再搬进加密 Vault。 let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent("consult-\(UUID().uuidString).m4a") self.audioTempURL = tempURL let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) // 录音文件在装 tap 前就按 tap 格式建好:AAC 文件的 processingFormat = 该采样率/声道的 float 格式, // 正是 tap buffer 的格式 → write 不会因格式不符静默丢音。建失败(try? 为 nil)只是没录音文件,转写照常(尽力而为)。 let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: format.sampleRate, AVNumberOfChannelsKey: format.channelCount, AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, ] let audioFile = try? AVAudioFile(forWriting: tempURL, settings: settings) self.audioFile = audioFile // 留一份引用给 stop() flush // tap 在音频线程跑:只碰**局部捕获**的 request / audioFile,绝不碰 self(避免跨线程数据竞争,同 SpeechDictationService)。 input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in request.append(buffer) try? audioFile?.write(from: buffer) } audioEngine.prepare() do { try audioEngine.start() } catch { input.removeTap(onBus: 0) deactivateSession() throw RecorderError.audioEngineStartFailed(error.localizedDescription) } task = recognizer.recognitionTask(with: request) { [weak self] result, error in Task { @MainActor in guard let self else { return } if let result { self.latestText = result.bestTranscription.formattedString onPartial(self.latestText) if result.isFinal { self.didFinish = true } } if error != nil { self.didFinish = true } } } isRecording = true } /// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回转写稿 + 录音文件。 func stop() async -> Result { guard isRecording else { return Result(transcript: latestText, audioTempURL: finishedAudioURL()) } isRecording = false audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) request?.endAudio() let deadline = Date().addingTimeInterval(1.5) while !didFinish && Date() < deadline { try? await Task.sleep(nanoseconds: 100_000_000) } task?.cancel() task = nil request = nil let url = finishedAudioURL() // 关文件(置 nil)后再取 URL,确保已 flush 落盘 deactivateSession() return Result(transcript: latestText, audioTempURL: url) } /// 用户直接关闭时的清理:不关心结果,立即停;顺手删掉半截录音临时文件。 func abort() { guard isRecording else { cleanupTempFile() return } isRecording = false audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) request?.endAudio() task?.cancel() task = nil request = nil audioFile = nil deactivateSession() cleanupTempFile() } /// 关掉写文件句柄(flush),返回有内容的录音 URL;文件没建成/为空则返回 nil。 private func finishedAudioURL() -> URL? { audioFile = nil // 释放写句柄,数据 flush 到磁盘 guard let url = audioTempURL, let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let bytes = attrs[.size] as? Int, bytes > 0 else { return nil } return url } private func cleanupTempFile() { if let url = audioTempURL { try? FileManager.default.removeItem(at: url) } audioTempURL = nil } private func deactivateSession() { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } }