Files
kangkang/康康/Services/ConsultationRecorder.swift
link2026 e179a369f6 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
chore(config): 更新项目配置文件

- 调整开发环境配置参数
- 优化构建流程设置
- 更新依赖包版本管理
```
2026-07-01 08:03:35 +08:00

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)
}
}