根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
``` chore(config): 更新项目配置文件 - 调整开发环境配置参数 - 优化构建流程设置 - 更新依赖包版本管理 ```
This commit is contained in:
@@ -174,28 +174,56 @@ actor CaptureService {
|
||||
return s
|
||||
}
|
||||
|
||||
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||
/// 整份报告识别 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||
///
|
||||
/// hybrid 双路:
|
||||
/// - **云端可用(Gemini)**:图片直传 Gemini 多模态读图(真·VL,恢复 source_box 证据高亮),
|
||||
/// OCR 文本作数字「抄写员」一并注入。这是端侧 Gemma-3n(MLX 文本版,无视觉)拿不到的能力。
|
||||
/// - **离线/未开云端**:回退 Vision OCR(本地,<1s/页)→ 端侧 Gemma 文本 LLM 抽 meta+指标。
|
||||
/// 云端任何失败(离线/超时/解析失败)都静默回退端侧,绝不卡死(§3.2 失败回退红线)。
|
||||
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||
// Vision OCR 出纯文本(失败/空都视作无法识别 —— 影像类报告本就无文字,不该清空旧数据)。
|
||||
let ocr = await Self.ocrReference(for: urls)
|
||||
|
||||
// 云端优先:Gemini 多模态直读图。影像类报告 OCR 可能为空,但 Gemini 仍能读图,故不卡 OCR 门槛。
|
||||
if AIRuntime.shared.cloudAvailable {
|
||||
do {
|
||||
let raw = try await AIRuntime.shared.analyzeReportCloud(
|
||||
imageURLs: urls,
|
||||
prompt: VLPrompts.reportExtraction(ocrText: ocr),
|
||||
maxTokens: 2048
|
||||
)
|
||||
return try CaptureService.parseReportJSON(
|
||||
CaptureService.stripThink(raw), pageCount: assets.count)
|
||||
} catch {
|
||||
// 落到端侧回退,不抛 —— 保证断网/额度耗尽时仍可用。
|
||||
}
|
||||
}
|
||||
|
||||
// 端侧回退:需有 OCR 文本(端侧 Gemma 无视觉)。
|
||||
guard !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw CaptureError.inferenceFailed(String(appLoc: "未识别到文字,无法解读"))
|
||||
}
|
||||
do {
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
try await AIRuntime.shared.prepare() // 载文本 LLM(OOM 闸门处理卸载)
|
||||
} catch {
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||
// OCR 参考(Vision 本地,<1s/页):给 2B 多模态当数字「抄写员」,降低小字误读。
|
||||
// 任何失败都静默回退为空串,绝不阻断识别主流程(§3.2)。
|
||||
let ocr = await Self.ocrReference(for: urls)
|
||||
let raw: String
|
||||
var collected = ""
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: urls,
|
||||
prompt: VLPrompts.reportExtraction(ocrText: ocr)
|
||||
// 整份报告十余项,给足 token;与任何 VL/文本解码由 AIRuntime 闸门串行。
|
||||
let stream = await AIRuntime.shared.generate(
|
||||
prompt: VLPrompts.reportExtractionFromText(ocr),
|
||||
maxTokens: 2048
|
||||
)
|
||||
for try await chunk in stream { collected += chunk.text }
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
do {
|
||||
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||
return try CaptureService.parseReportJSON(cleaned, pageCount: assets.count)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
} catch {
|
||||
|
||||
206
康康/Services/ConsultationRecorder.swift
Normal file
206
康康/Services/ConsultationRecorder.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -201,4 +201,29 @@ struct DiaryAssistService {
|
||||
guard !text.isEmpty else { throw AssistError.empty }
|
||||
return (text, lastRate)
|
||||
}
|
||||
|
||||
/// 把问诊录音转写稿整理成结构化问诊小结(2026-06-28,见 ConsultationPrompts)。
|
||||
/// 与 organize 同样走 AIRuntime actor 队列、同样失败回退原话(调用方处理),只是 prompt/产物不同。
|
||||
/// maxTokens 给到 700:问诊小结按多小节分行,比日记长。
|
||||
func organizeConsultation(transcript: String) async throws -> (text: String, decodeRate: Double) {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
throw AssistError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = ConsultationPrompts.organize(transcript: transcript)
|
||||
var collected = ""
|
||||
var lastRate: Double = 0
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 700)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||
}
|
||||
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw AssistError.empty }
|
||||
return (text, lastRate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,13 +647,21 @@ struct HealthExportService {
|
||||
return d
|
||||
}
|
||||
|
||||
// diaries
|
||||
// diaries(含问诊记录:带「问诊」tag 的日记)。
|
||||
// 问诊记录标出 kind 并放宽摘录长度,让报告把它当成「既往看医生的问诊记录」重点参考。
|
||||
root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
|
||||
let excerpt = String(d.content.prefix(80))
|
||||
return [
|
||||
let limit = d.isConsultation ? 240 : 80
|
||||
let excerpt = String(d.content.prefix(limit))
|
||||
var item: [String: Any] = [
|
||||
"date": df.string(from: d.createdAt),
|
||||
"excerpt": excerpt
|
||||
]
|
||||
if d.isConsultation {
|
||||
item["kind"] = "问诊"
|
||||
} else if d.isMedicationLog {
|
||||
item["kind"] = "用药"
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// 时间窗也给 LLM 看
|
||||
|
||||
169
康康/Services/SenseVoiceASRService.swift
Normal file
169
康康/Services/SenseVoiceASRService.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// 端侧问诊语音转写服务(SenseVoice,经 sherpa-mnn 跑在 MNN 后端)。
|
||||
///
|
||||
/// 「记录问诊」录完整段录音后调它做**离线转写**(非流式,无实时字幕),
|
||||
/// 转写稿再交本地 LLM(DiaryAssistService.organizeConsultation)整理成问诊小结。
|
||||
///
|
||||
/// 守红线:
|
||||
/// - 全程本机,无任何网络(§1 / §10 #1)。
|
||||
/// - 与 LLM/VL 经 `AIRuntime.runExclusiveForASR` 闸门**互斥**:转写前卸掉常驻文本/视觉模型腾内存,
|
||||
/// 避免两个模型同时常驻冲过单 App 内存上限被 jetsam 杀(§3.1 OOM 防护,见 [[airuntime-llm-vl-oom-gate]])。
|
||||
/// - 模型或引擎未就绪时本服务抛错,调用方自动回退系统端侧识别(SFSpeech),App 不卡死(§10 #5)。
|
||||
///
|
||||
/// 模型与 LLM 的 `ModelKind` 解耦,自管 `Models/SenseVoice/` 目录(model.mnn + tokens.txt)。
|
||||
/// 转换/接入步骤见 docs/release/sensevoice-integration.md。
|
||||
struct SenseVoiceASRService {
|
||||
static let shared = SenseVoiceASRService()
|
||||
private init() {}
|
||||
|
||||
enum ASRError: Error, LocalizedError {
|
||||
case modelNotInstalled
|
||||
case engineUnavailable
|
||||
case decodeFailed
|
||||
case empty
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotInstalled: return String(appLoc: "问诊转写模型未就绪")
|
||||
case .engineUnavailable: return String(appLoc: "本机暂不支持本地语音转写")
|
||||
case .decodeFailed: return String(appLoc: "录音解码失败")
|
||||
case .empty: return String(appLoc: "没识别到语音内容")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 模型位置(独立于 LLM 的 ModelKind,问诊 ASR 自管目录)
|
||||
|
||||
/// `Application Support/Models/SenseVoice/`,与 LLM 模型同根目录、互不干扰。
|
||||
nonisolated static var modelDir: URL {
|
||||
ModelStore.shared.rootURL.appendingPathComponent("SenseVoice", isDirectory: true)
|
||||
}
|
||||
/// MNNConvert 产出的 SenseVoice 图。
|
||||
nonisolated static var modelFile: URL { modelDir.appendingPathComponent("model.mnn") }
|
||||
/// tokens.txt(id↔token 映射)。
|
||||
nonisolated static var tokensFile: URL { modelDir.appendingPathComponent("tokens.txt") }
|
||||
|
||||
/// 模型两件套是否都已就位(下载 / 旁路导入后)。
|
||||
nonisolated static var isModelInstalled: Bool {
|
||||
let fm = FileManager.default
|
||||
return fm.fileExists(atPath: modelFile.path) && fm.fileExists(atPath: tokensFile.path)
|
||||
}
|
||||
|
||||
/// 端侧 SenseVoice 是否可用 = 引擎已链接(sherpa-mnn)**且**模型已就位。
|
||||
/// false 时「记录问诊」自动回退系统端侧识别(SFSpeech)。
|
||||
nonisolated static var isAvailable: Bool {
|
||||
SenseVoiceBridge.isAvailable() && isModelInstalled
|
||||
}
|
||||
|
||||
// MARK: - 转写
|
||||
|
||||
/// 把一段录音(m4a/wav/caf 等)离线转写成文字。失败抛错,调用方回退。
|
||||
/// language:"auto" 自动判别(中英日韩粤),也可固定 "zh"。
|
||||
func transcribe(audioFileURL: URL, language: String = "auto") async throws -> String {
|
||||
guard SenseVoiceBridge.isAvailable() else { throw ASRError.engineUnavailable }
|
||||
guard Self.isModelInstalled else { throw ASRError.modelNotInstalled }
|
||||
|
||||
let modelPath = Self.modelFile.path
|
||||
let tokensPath = Self.tokensFile.path
|
||||
|
||||
// 解码 + 解码推理都重(CPU),全部放进闸门内的后台线程:与 LLM/VL 串行,且先卸常驻模型腾内存。
|
||||
let raw = try await AIRuntime.shared.runExclusiveForASR {
|
||||
try await Self.runOnBackground {
|
||||
let decoded = try Self.decodeToMonoFloat(url: audioFileURL)
|
||||
guard !decoded.samples.isEmpty else { throw ASRError.decodeFailed }
|
||||
guard let bridge = SenseVoiceBridge(modelPath: modelPath,
|
||||
tokensPath: tokensPath,
|
||||
language: language) else {
|
||||
throw ASRError.engineUnavailable
|
||||
}
|
||||
let text = decoded.samples.withUnsafeBufferPointer { buf -> String? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
return bridge.transcribeSamples(base,
|
||||
count: Int32(buf.count),
|
||||
sampleRate: Int32(decoded.sampleRate))
|
||||
}
|
||||
return text ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
let cleaned = Self.cleanTranscript(raw)
|
||||
guard !cleaned.isEmpty else { throw ASRError.empty }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// MARK: - 纯函数(单测覆盖)
|
||||
|
||||
/// 清洗 SenseVoice 输出:去掉可能残留的 `<|zh|><|NEUTRAL|><|Speech|><|woitn|>` 等标签后 trim。
|
||||
/// sherpa 多数情况下已剥成纯文本,这里再兜一层,确保给 LLM 的转写稿干净。
|
||||
nonisolated static func cleanTranscript(_ raw: String) -> String {
|
||||
let stripped = raw.replacingOccurrences(
|
||||
of: "<\\|[^|]*\\|>", with: "", options: .regularExpression)
|
||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - 音频解码(任意容器 → 16kHz 单声道 float32)
|
||||
|
||||
private struct Decoded { let samples: [Float]; let sampleRate: Int }
|
||||
|
||||
/// 用 AVAudioConverter 把录音重采样/混音成 16kHz 单声道 float32(SenseVoice 期望的输入域)。
|
||||
/// 一次性整段转换:本调用已在闸门内、LLM 已卸,瞬时内存可控。
|
||||
nonisolated private static func decodeToMonoFloat(url: URL,
|
||||
targetSampleRate: Double = 16000) throws -> Decoded {
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
let inFormat = file.processingFormat
|
||||
let frameCount = AVAudioFrameCount(file.length)
|
||||
guard frameCount > 0 else { return Decoded(samples: [], sampleRate: Int(targetSampleRate)) }
|
||||
|
||||
guard let targetFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: targetSampleRate,
|
||||
channels: 1,
|
||||
interleaved: false),
|
||||
let converter = AVAudioConverter(from: inFormat, to: targetFormat),
|
||||
let inBuffer = AVAudioPCMBuffer(pcmFormat: inFormat, frameCapacity: frameCount) else {
|
||||
throw ASRError.decodeFailed
|
||||
}
|
||||
try file.read(into: inBuffer)
|
||||
|
||||
// 目标采样率通常低于源(48k/44.1k→16k),输出更短;源若低于 16k 则 ratio>1,按比例 + 余量留足。
|
||||
let ratio = targetSampleRate / inFormat.sampleRate
|
||||
let outCapacity = AVAudioFrameCount(Double(frameCount) * ratio) + 1024
|
||||
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outCapacity) else {
|
||||
throw ASRError.decodeFailed
|
||||
}
|
||||
|
||||
var fed = false
|
||||
var convError: NSError?
|
||||
let status = converter.convert(to: outBuffer, error: &convError) { _, inStatus in
|
||||
if fed {
|
||||
inStatus.pointee = .endOfStream // 已喂完整段,通知 flush 余下重采样样本
|
||||
return nil
|
||||
}
|
||||
fed = true
|
||||
inStatus.pointee = .haveData
|
||||
return inBuffer
|
||||
}
|
||||
if let convError { throw convError }
|
||||
guard status != .error else { throw ASRError.decodeFailed }
|
||||
|
||||
guard let channel = outBuffer.floatChannelData?[0] else {
|
||||
return Decoded(samples: [], sampleRate: Int(targetSampleRate))
|
||||
}
|
||||
let n = Int(outBuffer.frameLength)
|
||||
let samples = Array(UnsafeBufferPointer(start: channel, count: n))
|
||||
return Decoded(samples: samples, sampleRate: Int(targetSampleRate))
|
||||
}
|
||||
|
||||
/// 把一段阻塞的同步工作放到后台 QoS 队列跑(转写解码同步阻塞,绝不能占住 actor/主线程)。
|
||||
nonisolated private static func runOnBackground<T: Sendable>(
|
||||
_ work: @escaping @Sendable () throws -> T
|
||||
) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do { cont.resume(returning: try work()) }
|
||||
catch { cont.resume(throwing: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user