Skip to content

Commit 9036bb5

Browse files
ZhaoChaoqunclaude
andauthored
refactor: extract AudioEngineManager, PostProcessingPipeline, ASREngineFactory from RecordingManager (#13)
Split the RecordingManager "God Class" (530 lines, 7 responsibilities) into focused subsystems while keeping RecordingManager as a thin FSM coordinator: - AudioEngineManager: AVAudioEngine lifecycle, tap installation, buffer→samples conversion, audio level calculation - PostProcessingPipeline: coordinates TermNormalizer → ITN → CSC → Punctuation → CloudRewrite in sequence - ASREngineFactory: creates/initializes ASR engine instances (Paraformer, QwenASR, DualEngine) and loads supporting models RecordingManager is reduced from 530 to ~190 lines, owning only the FSM state machine and delegating all work to the extracted subsystems. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent acc4bc8 commit 9036bb5

5 files changed

Lines changed: 537 additions & 340 deletions

File tree

Sources/ASREngineFactory.swift

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import Foundation
2+
import os
3+
4+
private let logger = Logger(subsystem: "com.typeless.app", category: "ASREngineFactory")
5+
6+
/// Creates and initializes ASR engine instances based on the selected model type.
7+
///
8+
/// Encapsulates all model-loading logic (Streaming Paraformer, QwenASR, Dual Engine)
9+
/// and their supporting models (punctuation, CSC, ITN).
10+
///
11+
/// Thread safety:
12+
/// - `createEngine(for:pipeline:recognitionQueue:)` is async and called from a Task
13+
/// spawned on stateQueue. It writes to `pipeline` properties which are safe because
14+
/// no other code touches them during initialization (FSM is in `.initializing` state).
15+
enum ASREngineFactory {
16+
17+
/// Result of engine creation, containing the engine and a TermNormalizer.
18+
struct Result {
19+
let engine: (any ASREngine)?
20+
let termNormalizer: TermNormalizer?
21+
}
22+
23+
/// Creates an ASR engine for the given model type, also loading supporting models
24+
/// (punctuation, CSC, ITN, TermNormalizer) into the provided pipeline.
25+
///
26+
/// - Parameters:
27+
/// - modelType: Which ASR model to load.
28+
/// - pipeline: The post-processing pipeline to configure with supporting models.
29+
/// - recognitionQueue: The serial queue used by the ASR engine for decoding.
30+
/// - Returns: The created ASR engine (nil on failure) and TermNormalizer.
31+
static func createEngine(
32+
for modelType: ASRModelType,
33+
pipeline: PostProcessingPipeline,
34+
recognitionQueue: DispatchQueue
35+
) async -> Result {
36+
logger.info("开始加载语音识别模型 (\(modelType.displayName, privacy: .public))")
37+
38+
// Clear previous pipeline state
39+
pipeline.reset()
40+
41+
var engine: (any ASREngine)?
42+
43+
switch modelType {
44+
case .streamingParaformer:
45+
engine = await createStreamingParaformer(pipeline: pipeline, recognitionQueue: recognitionQueue)
46+
case .qwenASR:
47+
engine = await createQwenASR(pipeline: pipeline, recognitionQueue: recognitionQueue)
48+
case .dualEngine:
49+
engine = await createDualEngine(pipeline: pipeline, recognitionQueue: recognitionQueue)
50+
}
51+
52+
// Load TermNormalizer (shared by all engines)
53+
let termNormalizer = TermNormalizer()
54+
if termNormalizer != nil {
55+
logger.info("TermNormalizer 词典加载成功")
56+
} else {
57+
logger.warning("TermNormalizer 词典加载失败,跳过专有名词标准化")
58+
}
59+
pipeline.termNormalizer = termNormalizer
60+
61+
return Result(engine: engine, termNormalizer: termNormalizer)
62+
}
63+
64+
// MARK: - Streaming Paraformer
65+
66+
private static func createStreamingParaformer(
67+
pipeline: PostProcessingPipeline,
68+
recognitionQueue: DispatchQueue
69+
) async -> (any ASREngine)? {
70+
guard SherpaOnnxManager.shared.isStreamingParaformerDownloaded(),
71+
let paths = SherpaOnnxManager.shared.getStreamingParaformerPath() else {
72+
logger.warning("Streaming Paraformer 模型未下载")
73+
return nil
74+
}
75+
76+
let itnFstPath = await loadITNFst()
77+
78+
guard let recognizer = SherpaOnnxOnlineRecognizer(
79+
encoderPath: paths.encoderPath,
80+
decoderPath: paths.decoderPath,
81+
tokensPath: paths.tokensPath,
82+
ruleFstsPath: itnFstPath
83+
) else {
84+
logger.error("Streaming Paraformer 模型加载失败")
85+
return nil
86+
}
87+
88+
let engine = StreamingParaformerEngine(
89+
recognizer: recognizer, recognitionQueue: recognitionQueue
90+
)
91+
logger.info("Streaming Paraformer 模型加载成功")
92+
93+
// Load CSC + punctuation post-processing modules
94+
await loadPunctuation(into: pipeline)
95+
loadCSC(into: pipeline)
96+
97+
return engine
98+
}
99+
100+
// MARK: - QwenASR
101+
102+
private static func createQwenASR(
103+
pipeline: PostProcessingPipeline,
104+
recognitionQueue: DispatchQueue
105+
) async -> (any ASREngine)? {
106+
guard SherpaOnnxManager.shared.isQwenASRModelDownloaded(),
107+
let modelDir = SherpaOnnxManager.shared.getQwenASRModelDir() else {
108+
logger.warning("QwenASR 模型未下载")
109+
return nil
110+
}
111+
112+
guard let recognizer = QwenASRStreamRecognizer(modelDir: modelDir) else {
113+
logger.error("QwenASR 流式模型加载失败")
114+
return nil
115+
}
116+
117+
let engine = QwenASREngine(
118+
recognizer: recognizer, recognitionQueue: recognitionQueue
119+
)
120+
logger.info("QwenASR 流式模型加载成功")
121+
122+
// Load standalone ITN for post-processing
123+
if let itnFstPath = await loadITNFst() {
124+
pipeline.itn = SherpaOnnxITN(ruleFsts: itnFstPath)
125+
if pipeline.itn != nil {
126+
logger.info("ITN 模型加载成功")
127+
} else {
128+
logger.warning("ITN 模型初始化失败")
129+
}
130+
}
131+
132+
return engine
133+
}
134+
135+
// MARK: - Dual Engine
136+
137+
private static func createDualEngine(
138+
pipeline: PostProcessingPipeline,
139+
recognitionQueue: DispatchQueue
140+
) async -> (any ASREngine)? {
141+
// 1. Load Streaming Paraformer
142+
guard SherpaOnnxManager.shared.isStreamingParaformerDownloaded(),
143+
let paths = SherpaOnnxManager.shared.getStreamingParaformerPath() else {
144+
logger.warning("Dual Engine: Streaming Paraformer 模型未下载")
145+
return nil
146+
}
147+
148+
let itnFstPath = await loadITNFst()
149+
150+
guard let parafoRecognizer = SherpaOnnxOnlineRecognizer(
151+
encoderPath: paths.encoderPath,
152+
decoderPath: paths.decoderPath,
153+
tokensPath: paths.tokensPath,
154+
ruleFstsPath: itnFstPath
155+
) else {
156+
logger.error("Dual Engine: Streaming Paraformer 模型加载失败")
157+
return nil
158+
}
159+
160+
let paraformerEngine = StreamingParaformerEngine(
161+
recognizer: parafoRecognizer, recognitionQueue: recognitionQueue
162+
)
163+
logger.info("Dual Engine: Streaming Paraformer 加载成功")
164+
165+
// 2. Load QwenASR (for offline precise transcription)
166+
guard SherpaOnnxManager.shared.isQwenASRModelDownloaded(),
167+
let modelDir = SherpaOnnxManager.shared.getQwenASRModelDir() else {
168+
logger.warning("Dual Engine: QwenASR 模型未下载")
169+
return nil
170+
}
171+
172+
guard let qwenRecognizer = QwenASRStreamRecognizer(modelDir: modelDir) else {
173+
logger.error("Dual Engine: QwenASR 模型加载失败")
174+
return nil
175+
}
176+
logger.info("Dual Engine: QwenASR 加载成功")
177+
178+
// 3. Create dual engine
179+
let engine = DualEngineASR(
180+
paraformerEngine: paraformerEngine,
181+
qwenRecognizer: qwenRecognizer
182+
)
183+
logger.info("Dual Engine: 初始化完成")
184+
185+
// 4. Load standalone ITN for post-processing
186+
if let itnFstPathForPostProcess = await loadITNFst() {
187+
pipeline.itn = SherpaOnnxITN(ruleFsts: itnFstPathForPostProcess)
188+
if pipeline.itn != nil {
189+
logger.info("Dual Engine: ITN 模型加载成功")
190+
}
191+
}
192+
193+
return engine
194+
}
195+
196+
// MARK: - Supporting Model Loaders
197+
198+
private static func loadPunctuation(into pipeline: PostProcessingPipeline) async {
199+
if let punctPath = SherpaOnnxManager.shared.getPunctuationModelPath() {
200+
pipeline.punctuator = SherpaOnnxPunctuation(modelPath: punctPath)
201+
if pipeline.punctuator != nil {
202+
logger.info("标点模型加载成功")
203+
}
204+
} else {
205+
logger.info("标点模型未下载,正在下载...")
206+
await withCheckedContinuation { continuation in
207+
SherpaOnnxManager.shared.downloadPunctuationModel(progress: { progress in
208+
logger.debug("标点模型下载: \(progress, privacy: .public)")
209+
}, completion: { success, error in
210+
if success, let punctPath = SherpaOnnxManager.shared.getPunctuationModelPath() {
211+
pipeline.punctuator = SherpaOnnxPunctuation(modelPath: punctPath)
212+
logger.info("标点模型下载并加载成功")
213+
} else {
214+
logger.error("标点模型下载失败: \(error ?? "未知错误", privacy: .public)")
215+
}
216+
continuation.resume()
217+
})
218+
}
219+
}
220+
}
221+
222+
private static func loadCSC(into pipeline: PostProcessingPipeline) {
223+
if let paths = SherpaOnnxManager.shared.getCSCModelPath() {
224+
pipeline.corrector = ChineseSpellingCorrector(modelPath: paths.modelPath, vocabPath: paths.vocabPath)
225+
if pipeline.corrector != nil {
226+
logger.info("CSC 纠错模型加载成功")
227+
} else {
228+
logger.warning("CSC 纠错模型加载失败")
229+
}
230+
} else {
231+
logger.info("CSC 纠错模型未下载,跳过")
232+
}
233+
}
234+
235+
private static func loadITNFst() async -> String? {
236+
if let fstPath = SherpaOnnxManager.shared.getITNFstPath() {
237+
return fstPath
238+
}
239+
240+
logger.info("ITN FST 模型未下载,正在下载...")
241+
return await withCheckedContinuation { continuation in
242+
SherpaOnnxManager.shared.downloadITNFst(progress: { progress in
243+
logger.debug("ITN FST 下载: \(progress, privacy: .public)")
244+
}, completion: { success, error in
245+
if success, let fstPath = SherpaOnnxManager.shared.getITNFstPath() {
246+
continuation.resume(returning: fstPath)
247+
} else {
248+
logger.error("ITN FST 下载失败: \(error ?? "未知错误", privacy: .public)")
249+
continuation.resume(returning: nil)
250+
}
251+
})
252+
}
253+
}
254+
}

Sources/AudioEngineManager.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import Foundation
2+
import AVFoundation
3+
import os
4+
5+
private let logger = Logger(subsystem: "com.typeless.app", category: "AudioEngineManager")
6+
7+
/// Manages AVAudioEngine lifecycle: setup, tap installation, start/stop, and buffer-to-samples conversion.
8+
///
9+
/// Thread safety:
10+
/// - `start()` and `stop()` are called from `stateQueue` (via RecordingManager side effects).
11+
/// - The `installTap` callback fires on an internal Core Audio thread; the `onSamples` closure
12+
/// must handle its own synchronization.
13+
final class AudioEngineManager {
14+
15+
/// Callback invoked with 16kHz Float32 mono samples from the microphone.
16+
/// Called on a Core Audio thread — callers must dispatch to appropriate queues.
17+
var onSamples: (([Float]) -> Void)?
18+
19+
/// Callback invoked with normalized audio level (0.0–1.0).
20+
/// Called on the main queue.
21+
var onAudioLevel: ((Float) -> Void)?
22+
23+
private var audioEngine: AVAudioEngine?
24+
25+
// MARK: - Public API
26+
27+
/// Creates a new AVAudioEngine, installs a tap, and starts capturing audio.
28+
/// Called from stateQueue.
29+
func start() {
30+
let engine = AVAudioEngine()
31+
self.audioEngine = engine
32+
33+
let inputNode = engine.inputNode
34+
let inputFormat = inputNode.outputFormat(forBus: 0)
35+
36+
let targetSampleRate: Double = 16000
37+
guard let targetFormat = AVAudioFormat(
38+
commonFormat: .pcmFormatFloat32,
39+
sampleRate: targetSampleRate,
40+
channels: 1,
41+
interleaved: false
42+
) else {
43+
logger.error("无法创建目标音频格式")
44+
return
45+
}
46+
47+
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
48+
logger.error("无法创建音频转换器")
49+
return
50+
}
51+
52+
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
53+
guard let self = self else { return }
54+
guard let samples = self.extractSamples(buffer: buffer, converter: converter, targetFormat: targetFormat) else {
55+
return
56+
}
57+
58+
// Calculate RMS audio level and notify UI
59+
if let onAudioLevel = self.onAudioLevel {
60+
var sum: Float = 0
61+
for s in samples { sum += s * s }
62+
let rms = sqrt(sum / max(Float(samples.count), 1))
63+
let db = 20 * log10(max(rms, 1e-6))
64+
let normalized = max(0, min(1, (db + 50) / 50))
65+
DispatchQueue.main.async {
66+
onAudioLevel(normalized)
67+
}
68+
}
69+
70+
self.onSamples?(samples)
71+
}
72+
73+
do {
74+
try engine.start()
75+
logger.info("音频引擎已启动")
76+
} catch {
77+
logger.error("启动音频引擎失败: \(error.localizedDescription, privacy: .public)")
78+
}
79+
}
80+
81+
/// Removes the tap, stops the engine, and releases resources.
82+
/// Called from stateQueue.
83+
func stop() {
84+
audioEngine?.inputNode.removeTap(onBus: 0)
85+
audioEngine?.stop()
86+
audioEngine = nil
87+
logger.info("音频引擎已停止")
88+
}
89+
90+
// MARK: - Private
91+
92+
/// Converts an AVAudioPCMBuffer to 16kHz Float32 mono samples.
93+
private func extractSamples(buffer: AVAudioPCMBuffer, converter: AVAudioConverter, targetFormat: AVAudioFormat) -> [Float]? {
94+
let ratio = targetFormat.sampleRate / buffer.format.sampleRate
95+
let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
96+
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputFrameCapacity) else { return nil }
97+
98+
var error: NSError?
99+
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
100+
outStatus.pointee = .haveData
101+
return buffer
102+
}
103+
104+
converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
105+
106+
if let error = error {
107+
logger.error("音频转换错误: \(error.localizedDescription, privacy: .public)")
108+
return nil
109+
}
110+
111+
guard let floatData = outputBuffer.floatChannelData else { return nil }
112+
return Array(UnsafeBufferPointer(start: floatData[0], count: Int(outputBuffer.frameLength)))
113+
}
114+
}

0 commit comments

Comments
 (0)