diff --git a/Sources/App/VoicePipeline+Models.swift b/Sources/App/VoicePipeline+Models.swift index 222a2d52..ad292c4c 100644 --- a/Sources/App/VoicePipeline+Models.swift +++ b/Sources/App/VoicePipeline+Models.swift @@ -164,9 +164,18 @@ extension VoicePipeline { let engine = QwenNativeASREngine(modelPath: modelPath) qwenSpeechEngine = engine Task { await engine.prepare() } - case .mimo: - appState.settings.speechEngine = .apple - await ensureEngineLoaded(requestPermission: requestPermission) + case .firered, .megaASR: + let settings = appState.settings + guard let modelID = settings.speechEngine.asrModelID else { return } + guard localASRIsAvailable(modelID) else { + mlxSTTEngine = nil + markSpeechModelDownloadRequired(showInStatus: requestPermission) + return + } + if mlxSTTEngine?.modelID == modelID { return } + let engine = MLXSTTEngine(modelID: modelID) + mlxSTTEngine = engine + Task { await engine.prepare() } } } diff --git a/Sources/App/VoicePipeline.swift b/Sources/App/VoicePipeline.swift index 1d0f811c..b683fc52 100644 --- a/Sources/App/VoicePipeline.swift +++ b/Sources/App/VoicePipeline.swift @@ -14,6 +14,7 @@ final class VoicePipeline { var appleSpeechEngine: AppleSpeechEngine? var volcSpeechEngine: VolcSpeechEngine? var qwenSpeechEngine: QwenNativeASREngine? + var mlxSTTEngine: MLXSTTEngine? var screenOCRTask: Task? var screenOCRStartedAt: CFAbsoluteTime? var processingTask: Task? @@ -29,7 +30,7 @@ final class VoicePipeline { case .apple: return appleSpeechEngine case .volc: return volcSpeechEngine case .qwen3: return qwenSpeechEngine - case .mimo: return nil + case .firered, .megaASR: return mlxSTTEngine } } diff --git a/Sources/Config/AppSettingTypes.swift b/Sources/Config/AppSettingTypes.swift index 42dbde1c..08783fb5 100644 --- a/Sources/Config/AppSettingTypes.swift +++ b/Sources/Config/AppSettingTypes.swift @@ -31,10 +31,11 @@ enum SpeechEngineType: String, Codable, CaseIterable { case apple = "apple" case volc = "volc" case qwen3 = "qwen3" - case mimo = "mimo" + case firered = "firered" + case megaASR = "megaASR" static var selectableCases: [SpeechEngineType] { - [.qwen3, .whisper, .apple, .volc] + [.qwen3, .firered, .megaASR, .whisper, .apple, .volc] } var label: String { @@ -43,7 +44,18 @@ enum SpeechEngineType: String, Codable, CaseIterable { case .apple: return L("engine.apple_speech") case .volc: return L("engine.volc_asr") case .qwen3: return L("engine.qwen3_asr") - case .mimo: return L("engine.mimo_asr") + case .firered: return L("engine.firered_asr") + case .megaASR: return L("engine.mega_asr") + } + } + + /// The ASR model ID associated with this engine, if any. + var asrModelID: String? { + switch self { + case .qwen3: return QwenASRModel.defaultID + case .firered: return "mlx-community/FireRedASR2-AED-mlx" + case .megaASR: return "mlx-community/Mega-ASR-6bit" + default: return nil } } } diff --git a/Sources/Config/AppSettings.swift b/Sources/Config/AppSettings.swift index 4e218b9a..f7dc7765 100644 --- a/Sources/Config/AppSettings.swift +++ b/Sources/Config/AppSettings.swift @@ -113,8 +113,8 @@ final class AppSettings: ObservableObject { let loadedSpeechEngine = SpeechEngineType(rawValue: savedEngine) ?? (savedEngine.contains("Whisper") || savedEngine.contains("whisper") ? .whisper : nil) ?? .apple - speechEngine = loadedSpeechEngine == .mimo ? .apple : loadedSpeechEngine - if loadedSpeechEngine == .mimo { + speechEngine = savedEngine == "mimo" ? .apple : loadedSpeechEngine + if savedEngine == "mimo" { ud.set(SpeechEngineType.apple.rawValue, forKey: Key.speechEngine.rawValue) } [ diff --git a/Sources/Config/ModelCatalogASR.swift b/Sources/Config/ModelCatalogASR.swift index 212c2cc6..42f59142 100644 --- a/Sources/Config/ModelCatalogASR.swift +++ b/Sources/Config/ModelCatalogASR.swift @@ -10,16 +10,34 @@ extension ModelCatalog { QwenASRModel.defaultID, "Qwen3-ASR 1.7B", L("model.qwen3_asr_quality") - ) + ), + ( + "mlx-community/FireRedASR2-AED-mlx", + "FireRedASR2-AED", + L("model.firered_asr") + ), + ( + "mlx-community/Mega-ASR-6bit", + "Mega-ASR 6bit", + L("model.mega_asr") + ), ] } + /// All ASR model IDs that use the generic MLX STT engine. + static let mlxSTTModelIDs: Set = [ + "mlx-community/FireRedASR2-AED-mlx", + "mlx-community/Mega-ASR-6bit", + ] + func asrModels(for engine: SpeechEngineType) -> [ModelEntry] { switch engine { case .qwen3: return asrModels.filter { $0.id == QwenASRModel.defaultID } - case .mimo: - return [] + case .firered: + return asrModels.filter { $0.id == "mlx-community/FireRedASR2-AED-mlx" } + case .megaASR: + return asrModels.filter { $0.id == "mlx-community/Mega-ASR-6bit" } default: return [] } @@ -165,7 +183,7 @@ extension ModelCatalog { } } - static func asrRequiredFiles(for id: String) -> [String] { + nonisolated static func asrRequiredFiles(for id: String) -> [String] { switch id { case QwenASRModel.defaultID: return [ @@ -177,6 +195,16 @@ extension ModelCatalog { "vocab.json", "merges.txt", ] + case "mlx-community/FireRedASR2-AED-mlx": + return [ + "config.json", + "tokenizer.json", + ] + case "mlx-community/Mega-ASR-6bit": + return [ + "config.json", + "tokenizer_config.json", + ] default: return ["config.json"] } diff --git a/Sources/Config/ModelCatalogDownloadEstimates.swift b/Sources/Config/ModelCatalogDownloadEstimates.swift index 1cc21582..006eed13 100644 --- a/Sources/Config/ModelCatalogDownloadEstimates.swift +++ b/Sources/Config/ModelCatalogDownloadEstimates.swift @@ -53,6 +53,8 @@ extension ModelCatalog { "mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit": 61_143_654_248, "mlx-community/Llama-4-Maverick-17B-128E-Instruct-4bit": 225_923_469_800, QwenASRModel.defaultID: 4_080_707_826, + "mlx-community/FireRedASR2-AED-mlx": 4_570_000_000, + "mlx-community/Mega-ASR-6bit": 2_040_000_000, ] private static let downloadEstimateRegex = try! NSRegularExpression( diff --git a/Sources/Processing/TranscriptionSanitizer.swift b/Sources/Processing/TranscriptionSanitizer.swift index 6c79cf44..f21d593d 100644 --- a/Sources/Processing/TranscriptionSanitizer.swift +++ b/Sources/Processing/TranscriptionSanitizer.swift @@ -105,6 +105,11 @@ enum TranscriptionSanitizer { audioActivity: AudioCaptureActivity? ) -> String? { guard audioActivity?.hasWeakSpeechEvidence == true else { return text } + + if weakAudioWholeTranscriptHallucinations.contains(normalizedPhrase(text)) { + return nil + } + var cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) for pattern in trailingHallucinationPatterns { let candidate = cleaned.replacingOccurrences( @@ -173,6 +178,10 @@ enum TranscriptionSanitizer { "[blank_audio]", "", ] + private static let weakAudioWholeTranscriptHallucinations: Set = [ + "do anything", + ] + private static let trailingHallucinationPatterns = [ #"\s*(?:thank you for watching|thanks for watching)[.!?。!?]*\s*$"#, #"\s*(?:感谢观看|謝謝觀看|谢谢观看|谢谢收看|感謝收看)[。.!!!??]*\s*$"#, diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index 7b12e957..defc6d8b 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -290,11 +290,13 @@ "engine.apple_short" = "Apple"; "engine.volc_short" = "Doubao"; "engine.qwen3_short" = "Qwen"; -"engine.mimo_short" = "MiMo"; +"engine.firered_short" = "FireRed"; +"engine.mega_short" = "Mega"; "engine.apple_speech" = "Apple Speech"; "engine.volc_asr" = "Doubao ASR"; "engine.qwen3_asr" = "Qwen3-ASR (Local)"; -"engine.mimo_asr" = "MiMo-V2.5-ASR (Local)"; +"engine.firered_asr" = "FireRedASR2-AED (Local)"; +"engine.mega_asr" = "Mega-ASR (Local)"; "screen_context_mode.ocr" = "OCR"; "screen_context_mode.multimodal" = "Multimodal"; "style.prompt.concise" = "Minimalist. Keep only core information, remove repetition and filler, break long sentences short."; @@ -490,6 +492,8 @@ /* ── Local ASR ── */ "qwen_asr.config_hint" = "Qwen3-ASR runs locally with native Swift and MLX. Download the model once, then recognition works offline."; "model.qwen3_asr_quality" = "Local ASR through MLX, ~4.1 GB"; +"model.firered_asr" = "Robust Chinese ASR, ~4.6 GB"; +"model.mega_asr" = "Noise-robust ASR (Qwen3-ASR + LoRA), ~2.0 GB"; "model.asr_incomplete" = "Only part of the model was downloaded. Select Resume to finish."; "error.llm_not_loaded" = "The model is stored locally but is not loaded into memory. Run the action again and Utter will retry."; "error.llm_not_downloaded" = "The model files have not been downloaded. Open Settings → Models and confirm the data usage first."; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index fc37af8b..3508cc7c 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -290,11 +290,13 @@ "engine.apple_short" = "Apple"; "engine.volc_short" = "豆包"; "engine.qwen3_short" = "Qwen"; -"engine.mimo_short" = "MiMo"; +"engine.firered_short" = "FireRed"; +"engine.mega_short" = "Mega"; "engine.apple_speech" = "Apple 语音识别"; "engine.volc_asr" = "豆包语音识别"; "engine.qwen3_asr" = "Qwen3-ASR(本地)"; -"engine.mimo_asr" = "MiMo-V2.5-ASR(本地)"; +"engine.firered_asr" = "FireRedASR2-AED(本地)"; +"engine.mega_asr" = "Mega-ASR(本地)"; "screen_context_mode.ocr" = "OCR"; "screen_context_mode.multimodal" = "多模态"; "style.prompt.concise" = "极简。只保留核心信息,删掉修饰、重复和过渡,长句拆短。"; @@ -490,6 +492,8 @@ /* ── 本地语音识别 ── */ "qwen_asr.config_hint" = "Qwen3-ASR 使用原生 Swift 和 MLX 在本机运行。模型下载一次后即可离线识别。"; "model.qwen3_asr_quality" = "本地 MLX 语音识别,约 4.1 GB"; +"model.firered_asr" = "强鲁棒中文 ASR,约 4.6 GB"; +"model.mega_asr" = "抗噪 ASR(Qwen3-ASR + LoRA),约 2.0 GB"; "model.asr_incomplete" = "模型只下载了一部分。点击“继续下载”即可接着完成"; "error.llm_not_loaded" = "模型文件已在本地,但当前尚未加载到内存。请重新执行;Utter 会再次尝试加载"; "error.llm_not_downloaded" = "模型文件尚未下载。请前往 设置 → 模型,确认流量后下载"; diff --git a/Sources/Speech/MLXSTTEngine.swift b/Sources/Speech/MLXSTTEngine.swift new file mode 100644 index 00000000..0003c789 --- /dev/null +++ b/Sources/Speech/MLXSTTEngine.swift @@ -0,0 +1,172 @@ +import Foundation +import MLXAudioCore +import MLXAudioSTT + +/// Generic speech engine backed by any mlx-audio-swift STT model. +/// Supports Qwen3-ASR, FireRedASR2-AED, Mega-ASR, SenseVoice, and others. +final class MLXSTTEngine: SpeechEngine, @unchecked Sendable { + let modelID: String + private let runtime = MLXSTTRuntime() + + init(modelID: String) { + self.modelID = modelID + } + + var isReady: Bool { + Self.checkModelReady(modelID) + } + + private static func checkModelReady(_ id: String) -> Bool { + guard let dir = ModelStorage.asrRepoDir(id) else { return false } + let requiredFiles = ModelCatalog.asrRequiredFiles(for: id) + return requiredFiles.allSatisfy { relativePath in + let file = dir.appendingPathComponent(relativePath) + let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + return size > 0 + } + } + + func prepare() async { + guard isReady else { return } + do { + try await runtime.prepare(modelID: modelID) + } catch { + Log.error("[MLXSTTEngine] model warm-up failed (\(modelID)): \(error.localizedDescription)") + } + } + + func transcribe(audioURL: URL?, language: String?) async throws -> String { + guard isReady else { throw MLXSTTError.notConfigured } + guard let audioURL else { throw MLXSTTError.noAudioFile } + + let started = CFAbsoluteTimeGetCurrent() + let result = try await QwenAudioPreprocessor.withPreparedAudio(from: audioURL) { preparedURL in + try await runtime.transcribe( + audioURL: preparedURL, + modelID: modelID, + language: language + ) + } + let elapsed = CFAbsoluteTimeGetCurrent() - started + Log.info( + "[MLXSTTEngine] \(modelID) transcribed \(result.text.count) chars in " + + "\(String(format: "%.1f", elapsed))s; model \(String(format: "%.1f", result.modelTime))s; " + + "peak \(String(format: "%.2f", result.peakMemoryGB)) GB" + ) + return result.text + } +} + +enum MLXSTTError: LocalizedError { + case notConfigured + case noAudioFile + case modelDirectoryMissing + + var errorDescription: String? { + switch self { + case .notConfigured: return L("error.local_asr_not_configured") + case .noAudioFile: return L("error.no_audio") + case .modelDirectoryMissing: return "ASR model directory not found" + } + } +} + +private actor MLXSTTRuntime { + struct Result { + let text: String + let modelTime: Double + let peakMemoryGB: Double + } + + private var model: (any STTGenerationModel)? + private var loadedModelID: String? + + func prepare(modelID: String) async throws { + _ = try await loadModel(modelID: modelID) + } + + func transcribe( + audioURL: URL, + modelID: String, + language: String? + ) async throws -> Result { + try Task.checkCancellation() + let model = try await loadModel(modelID: modelID) + let (sampleRate, audio) = try loadAudioArray( + from: audioURL, + sampleRate: Int(QwenAudioPreprocessor.sampleRate) + ) + guard sampleRate == Int(QwenAudioPreprocessor.sampleRate) else { + throw QwenAudioPreprocessorError.conversionFailed + } + + let defaults = model.defaultGenerationParameters + let finalParams = STTGenerateParameters( + maxTokens: defaults.maxTokens, + temperature: defaults.temperature, + topP: defaults.topP, + topK: defaults.topK, + verbose: defaults.verbose, + language: language, + chunkDuration: defaults.chunkDuration, + minChunkDuration: defaults.minChunkDuration, + repetitionPenalty: defaults.repetitionPenalty, + repetitionContextSize: defaults.repetitionContextSize + ) + + let startTime = CFAbsoluteTimeGetCurrent() + let output = model.generate(audio: audio, generationParameters: finalParams) + let modelTime = CFAbsoluteTimeGetCurrent() - startTime + try Task.checkCancellation() + + return Result( + text: output.text, + modelTime: modelTime, + peakMemoryGB: output.peakMemoryUsage + ) + } + + private func loadModel(modelID: String) async throws -> any STTGenerationModel { + if let model, loadedModelID == modelID { + return model + } + + guard let modelDir = ModelStorage.asrRepoDir(modelID) else { + throw MLXSTTError.modelDirectoryMissing + } + let modelType = Self.detectModelType(from: modelDir, modelID: modelID) + let loaded = try await Self.loadModelFromDirectory(modelDir, modelType: modelType) + model = loaded + loadedModelID = modelID + Log.info("[MLXSTTRuntime] loaded model: \(modelID) (type: \(modelType))") + return loaded + } + + private static func detectModelType(from dir: URL, modelID: String) -> String { + let configURL = dir.appendingPathComponent("config.json") + if let data = try? Data(contentsOf: configURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["model_type"] as? String { + return type.lowercased() + } + let lower = modelID.lowercased() + if lower.contains("firered") { return "fireredasr2" } + if lower.contains("sensevoice") { return "sensevoice" } + if lower.contains("mega-asr") || lower.contains("qwen3-asr") { return "qwen3_asr" } + return "qwen3_asr" + } + + private static func loadModelFromDirectory( + _ dir: URL, + modelType: String + ) async throws -> any STTGenerationModel { + switch modelType { + case "fireredasr2", "firered", "fire_red": + return try FireRedASR2Model.fromDirectory(dir) + case "qwen3_asr", "qwen3-asr": + return try await Qwen3ASRModel.fromModelDirectory(dir) + default: + return try await Qwen3ASRModel.fromModelDirectory(dir) + } + } +} diff --git a/Sources/Speech/SpeechEngineProvider.swift b/Sources/Speech/SpeechEngineProvider.swift index aa7863a7..718d1e29 100644 --- a/Sources/Speech/SpeechEngineProvider.swift +++ b/Sources/Speech/SpeechEngineProvider.swift @@ -6,6 +6,7 @@ final class SpeechEngineProvider { private var appleSpeechEngine: AppleSpeechEngine? private var volcSpeechEngine: VolcSpeechEngine? private var qwenSpeechEngine: QwenNativeASREngine? + private var mlxSTTEngine: MLXSTTEngine? func engine(settings: AppSettings, requestPermission: Bool = true) async -> (any SpeechEngine)? { await ensureEngineLoaded(settings: settings, requestPermission: requestPermission) @@ -18,7 +19,7 @@ final class SpeechEngineProvider { case .apple: return appleSpeechEngine case .volc: return volcSpeechEngine case .qwen3: return qwenSpeechEngine - case .mimo: return nil + case .firered, .megaASR: return mlxSTTEngine } } @@ -50,9 +51,15 @@ final class SpeechEngineProvider { let modelPath = ModelCatalog.shared.asrModelPath(for: settings.qwenASRModel) if qwenSpeechEngine?.usesModel(at: modelPath) == true { return } qwenSpeechEngine = QwenNativeASREngine(modelPath: modelPath) - case .mimo: - settings.speechEngine = .apple - await ensureEngineLoaded(settings: settings, requestPermission: requestPermission) + case .firered, .megaASR: + guard let modelID = settings.speechEngine.asrModelID else { return } + guard localASRIsAvailable(modelID) else { + mlxSTTEngine = nil + Log.info("[SpeechEngineProvider] ASR model requires manual download: \(modelID)") + return + } + if mlxSTTEngine?.modelID == modelID { return } + mlxSTTEngine = MLXSTTEngine(modelID: modelID) } } diff --git a/Sources/UI/ModelManagementEnginePicker.swift b/Sources/UI/ModelManagementEnginePicker.swift index e48e324f..5b433cf1 100644 --- a/Sources/UI/ModelManagementEnginePicker.swift +++ b/Sources/UI/ModelManagementEnginePicker.swift @@ -21,7 +21,8 @@ private extension SpeechEngineType { case .volc: return L("engine.volc_short") case .qwen3: return "\(L("engine.qwen3_short")) · \(L("common.recommended_short"))" - case .mimo: return L("engine.mimo_short") + case .firered: return L("engine.firered_short") + case .megaASR: return L("engine.mega_short") } } diff --git a/Sources/UI/ModelManagementSections.swift b/Sources/UI/ModelManagementSections.swift index a5212ea4..859d0557 100644 --- a/Sources/UI/ModelManagementSections.swift +++ b/Sources/UI/ModelManagementSections.swift @@ -120,14 +120,17 @@ extension ModelManagementView { } var qwenASRSection: some View { - VStack(alignment: .leading, spacing: 8) { + let engineType = settings.speechEngine + let models = catalog.asrModels(for: engineType) + let activeID = engineType.asrModelID ?? settings.qwenASRModel + return VStack(alignment: .leading, spacing: 8) { Text(L("qwen_asr.config_hint")) .font(.system(size: 11)) .foregroundStyle(.secondary) modelList( - catalog.asrModels(for: .qwen3), - activeID: settings.qwenASRModel, + models, + activeID: activeID, type: .asr ) } diff --git a/Sources/UI/ModelManagementView.swift b/Sources/UI/ModelManagementView.swift index 77227f8e..26ffbbf9 100644 --- a/Sources/UI/ModelManagementView.swift +++ b/Sources/UI/ModelManagementView.swift @@ -73,10 +73,8 @@ struct ModelManagementView: View { volcSection case .qwen3: qwenASRSection - case .mimo: - Text(L("model.apple_managed_by_system")) - .font(.caption) - .foregroundStyle(.secondary) + case .firered, .megaASR: + qwenASRSection case .apple: Text(L("model.apple_managed_by_system")) .font(.caption) diff --git a/Tests/OpenTypeTests/ConfigurationTests.swift b/Tests/OpenTypeTests/ConfigurationTests.swift index 508faeac..f6e655d6 100644 --- a/Tests/OpenTypeTests/ConfigurationTests.swift +++ b/Tests/OpenTypeTests/ConfigurationTests.swift @@ -56,10 +56,10 @@ final class ConfigurationTests: XCTestCase { func testSpeechEngineCasesIncludeNativeLocalEngines() { XCTAssertEqual(SpeechEngineType.allCases.map(\.rawValue), [ - "whisper", "apple", "volc", "qwen3", "mimo", + "whisper", "apple", "volc", "qwen3", "firered", "megaASR", ]) XCTAssertEqual(SpeechEngineType.selectableCases.map(\.rawValue), [ - "qwen3", "whisper", "apple", "volc", + "qwen3", "firered", "megaASR", "whisper", "apple", "volc", ]) } @@ -220,7 +220,11 @@ final class ConfigurationTests: XCTestCase { @MainActor func testOnlyReleasedNativeASRModelsAreListed() { let models = ModelCatalog.defaultASRModels - XCTAssertEqual(models.map(\.id), [QwenASRModel.defaultID]) + XCTAssertEqual(models.map(\.id), [ + QwenASRModel.defaultID, + "mlx-community/FireRedASR2-AED-mlx", + "mlx-community/Mega-ASR-6bit", + ]) } func testUILanguageDisplayNames() { diff --git a/Tests/OpenTypeTests/QwenNativeASREngineTests.swift b/Tests/OpenTypeTests/QwenNativeASREngineTests.swift index 4fa10a30..93a18361 100644 --- a/Tests/OpenTypeTests/QwenNativeASREngineTests.swift +++ b/Tests/OpenTypeTests/QwenNativeASREngineTests.swift @@ -7,7 +7,11 @@ final class QwenNativeASREngineTests: XCTestCase { func testNativeQwenRemainsTheReleasedLocalASREngine() { XCTAssertEqual( ModelCatalog.defaultASRModels.map(\.id), - [QwenASRModel.defaultID] + [ + QwenASRModel.defaultID, + "mlx-community/FireRedASR2-AED-mlx", + "mlx-community/Mega-ASR-6bit", + ] ) } diff --git a/Tests/OpenTypeTests/TranscriptionSanitizerTests.swift b/Tests/OpenTypeTests/TranscriptionSanitizerTests.swift index d7cc2544..92a725c5 100644 --- a/Tests/OpenTypeTests/TranscriptionSanitizerTests.swift +++ b/Tests/OpenTypeTests/TranscriptionSanitizerTests.swift @@ -98,4 +98,26 @@ final class TranscriptionSanitizerTests: XCTestCase { "Thank you for watching." ) } + + func testDropsDoAnythingHallucinationOnWeakAudio() { + var weakAudio = AudioCaptureActivity() + weakAudio.record(rms: 0.002, frameCount: 16_000) + + XCTAssertNil( + TranscriptionSanitizer.prepare("Do anything.", audioActivity: weakAudio) + ) + XCTAssertNil( + TranscriptionSanitizer.prepare("do anything", audioActivity: weakAudio) + ) + } + + func testKeepsDoAnythingWhenAudioIsStrong() { + var strongAudio = AudioCaptureActivity() + strongAudio.record(rms: 0.02, frameCount: 16_000) + + XCTAssertEqual( + TranscriptionSanitizer.prepare("Do anything.", audioActivity: strongAudio), + "Do anything." + ) + } }