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

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

170 lines
8.1 KiB
Swift

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(idtoken )
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.1k16k),; 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) }
}
}
}
}