207 lines
8.6 KiB
Swift
207 lines
8.6 KiB
Swift
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<SFSpeechRecognizerAuthorizationStatus, Never>) 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)
|
|
}
|
|
}
|