diff --git a/Package.resolved b/Package.resolved index 23274af3..9358719f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "9530b211b9c06ef80f43abb5c395727661ed97308630b252b72fd176620944e6", + "originHash" : "7f808a86fcdc955b5832bd1753d25aa3fc629c44e8e2102637d2722b2517dbf8", "pins" : [ + { + "identity" : "argmax-oss-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/argmaxinc/argmax-oss-swift.git", + "state" : { + "revision" : "25c62997041c134b03ca82731ce2f6fd2cae1eb9", + "version" : "1.0.0" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", @@ -25,7 +34,7 @@ "location" : "https://github.com/ml-explore/mlx-swift-lm", "state" : { "branch" : "main", - "revision" : "40c2ff061bff5f7c5b55e4ff2067df3b7aa74fd7" + "revision" : "2c1dd13d41586f63f40ba9ce45ce201026ab52b0" } }, { @@ -33,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -136,15 +145,6 @@ "version" : "1.3.3" } }, - { - "identity" : "whisperkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/argmaxinc/WhisperKit.git", - "state" : { - "revision" : "25c62997041c134b03ca82731ce2f6fd2cae1eb9", - "version" : "1.0.0" - } - }, { "identity" : "yyjson", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 176f277f..a2bbda57 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( .executable(name: "OpenTypeCLI", targets: ["OpenTypeCLI"]), ], dependencies: [ - .package(url: "https://github.com/argmaxinc/WhisperKit.git", from: "1.0.0"), + .package(url: "https://github.com/argmaxinc/argmax-oss-swift.git", from: "1.0.0"), .package(url: "https://github.com/huggingface/swift-transformers", from: "1.3.3"), .package(url: "https://github.com/ml-explore/mlx-swift-lm", branch: "main"), ], @@ -20,7 +20,7 @@ let package = Package( .executableTarget( name: "OpenType", dependencies: [ - .product(name: "WhisperKit", package: "WhisperKit"), + .product(name: "WhisperKit", package: "argmax-oss-swift"), .product(name: "Hub", package: "swift-transformers"), .product(name: "Tokenizers", package: "swift-transformers"), .product(name: "MLXLLM", package: "mlx-swift-lm"), diff --git a/README.md b/README.md index 66d97273..bf83acad 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-M1%2FM2%2FM3%2FM4-black?style=flat-square&logo=apple&logoColor=white)](https://www.apple.com/mac/m1/) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) -[![WhisperKit](https://img.shields.io/badge/Powered%20by-WhisperKit-blue?style=flat-square)](https://github.com/argmaxinc/WhisperKit) +[![WhisperKit](https://img.shields.io/badge/Powered%20by-WhisperKit-blue?style=flat-square)](https://github.com/argmaxinc/argmax-oss-swift) [![MLX](https://img.shields.io/badge/Powered%20by-MLX--LM-orange?style=flat-square)](https://github.com/ml-explore/mlx-swift-lm) [Website](https://opentype.idevlab.dev) · [中文文档](README_zh.md) @@ -161,7 +161,7 @@ scripts/ ## Tech Stack -- [WhisperKit](https://github.com/argmaxinc/WhisperKit) — offline Whisper speech recognition +- [WhisperKit](https://github.com/argmaxinc/argmax-oss-swift) — offline Whisper speech recognition - [mlx-swift-lm](https://github.com/ml-explore/mlx-swift-lm) — local LLM inference on Apple Silicon (Qwen2.5 / Qwen3) - **SwiftUI + AppKit** — native macOS UI - **ScreenCaptureKit + Vision** — screen OCR diff --git a/README_zh.md b/README_zh.md index 309c3597..761c364f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,7 +15,7 @@ [![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-M1%2FM2%2FM3%2FM4-black?style=flat-square&logo=apple&logoColor=white)](https://www.apple.com/mac/m1/) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) -[![WhisperKit](https://img.shields.io/badge/驱动-WhisperKit-blue?style=flat-square)](https://github.com/argmaxinc/WhisperKit) +[![WhisperKit](https://img.shields.io/badge/驱动-WhisperKit-blue?style=flat-square)](https://github.com/argmaxinc/argmax-oss-swift) [![MLX](https://img.shields.io/badge/驱动-MLX--LM-orange?style=flat-square)](https://github.com/ml-explore/mlx-swift-lm) [官网](https://opentype.idevlab.dev) · [English](README.md) @@ -161,7 +161,7 @@ scripts/ ## 技术栈 -- [WhisperKit](https://github.com/argmaxinc/WhisperKit) — 离线 Whisper 语音识别 +- [WhisperKit](https://github.com/argmaxinc/argmax-oss-swift) — 离线 Whisper 语音识别 - [mlx-swift-lm](https://github.com/ml-explore/mlx-swift-lm) — Apple Silicon 本地 LLM 推理(Qwen2.5 / Qwen3) - **SwiftUI + AppKit** — macOS 原生 UI - **ScreenCaptureKit + Vision** — 屏幕 OCR diff --git a/Sources/App/VoicePipeline+Models.swift b/Sources/App/VoicePipeline+Models.swift index a6e397cc..2bc3b006 100644 --- a/Sources/App/VoicePipeline+Models.swift +++ b/Sources/App/VoicePipeline+Models.swift @@ -115,6 +115,17 @@ extension VoicePipeline { case .qwen3: let settings = appState.settings let catalog = ModelCatalog.shared + if !LocalASRRuntime.isReady(for: .qwen3), + !catalog.asrModelPath(for: settings.qwenASRModel).isEmpty { + do { + _ = try await LocalASRRuntime.ensurePythonPath( + for: .qwen3, + preferredPath: settings.localASRPythonPath + ) + } catch { + Log.error("[VoicePipeline] Qwen ASR runtime migration failed: \(error.localizedDescription)") + } + } guard localASRIsAvailable(settings.qwenASRModel) else { qwenSpeechEngine = nil markSpeechModelDownloadRequired(showInStatus: requestPermission) diff --git a/Sources/Config/AppSettings.swift b/Sources/Config/AppSettings.swift index 54b409b5..c398031d 100644 --- a/Sources/Config/AppSettings.swift +++ b/Sources/Config/AppSettings.swift @@ -357,7 +357,7 @@ final class AppSettings: ObservableObject { appIconAppearance = AppIconAppearance(rawValue: ud.string(forKey: Key.appIconAppearance.rawValue) ?? "") ?? .system volcAppKey = ud.string(forKey: Key.volcAppKey.rawValue) ?? "" volcAccessKey = ud.string(forKey: Key.volcAccessKey.rawValue) ?? "" - volcResourceId = ud.string(forKey: Key.volcResourceId.rawValue) ?? "volc.bigasr.sauc.duration" + volcResourceId = ud.string(forKey: Key.volcResourceId.rawValue) ?? VolcASRModel.recommended.rawValue localASRPythonPath = ud.string(forKey: Key.localASRPythonPath.rawValue) ?? LocalASRConfiguration.defaultPythonPath qwenASRModel = ud.string(forKey: Key.qwenASRModel.rawValue) ?? ud.string(forKey: Key.qwenASRModelPath.rawValue) diff --git a/Sources/Config/VolcASRModel.swift b/Sources/Config/VolcASRModel.swift new file mode 100644 index 00000000..81ad0b1d --- /dev/null +++ b/Sources/Config/VolcASRModel.swift @@ -0,0 +1,17 @@ +import Foundation + +enum VolcASRModel: String, CaseIterable, Identifiable { + case seedASR2 = "volc.seedasr.sauc.duration" + case bigASR1 = "volc.bigasr.sauc.duration" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .seedASR2: return L("volc.model.seed_asr_2") + case .bigASR1: return L("volc.model.big_asr_1") + } + } + + static let recommended = VolcASRModel.seedASR2 +} diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index 6e8f5b16..e2971bb4 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -339,6 +339,9 @@ /* ── Volc ASR ── */ "volc.config_hint" = "Enter your Volcengine Doubao ASR credentials. Get them from the Volcengine console."; +"volc.model" = "Recognition model"; +"volc.model.seed_asr_2" = "SeedASR 2.0 (Recommended)"; +"volc.model.big_asr_1" = "BigASR 1.0 (Legacy)"; "volc.app_key" = "App Key (App ID)"; "volc.access_key" = "Access Token"; "volc.resource_id" = "Resource ID"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index a5163377..0f380fbb 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -339,6 +339,9 @@ /* ── 豆包语音识别 ── */ "volc.config_hint" = "填写火山引擎豆包语音识别凭据,可从火山引擎控制台获取。"; +"volc.model" = "识别模型"; +"volc.model.seed_asr_2" = "SeedASR 2.0(推荐)"; +"volc.model.big_asr_1" = "BigASR 1.0(旧版)"; "volc.app_key" = "App Key(应用 ID)"; "volc.access_key" = "Access Token"; "volc.resource_id" = "Resource ID(资源 ID)"; diff --git a/Sources/Speech/AppleSpeechAnalyzer.swift b/Sources/Speech/AppleSpeechAnalyzer.swift new file mode 100644 index 00000000..266bb006 --- /dev/null +++ b/Sources/Speech/AppleSpeechAnalyzer.swift @@ -0,0 +1,79 @@ +import AVFoundation +import Foundation +@preconcurrency import Speech + +enum AppleSpeechAnalyzer { + static func prepare(locale: Locale) async throws { + let transcriber = try await makeTranscriber(locale: locale) + try await ensureModel(for: transcriber) + } + + static func transcribe(audioURL: URL, locale: Locale) async throws -> String { + let transcriber = try await makeTranscriber(locale: locale) + try await ensureModel(for: transcriber) + + let file = try AVAudioFile(forReading: audioURL) + let resultTask = Task { + var transcript = "" + for try await result in transcriber.results where result.isFinal { + transcript += String(result.text.characters) + } + return transcript + } + + let analyzer = SpeechAnalyzer( + modules: [transcriber], + options: .init(priority: .userInitiated, modelRetention: .lingering) + ) + do { + if let lastSample = try await analyzer.analyzeSequence(from: file) { + try await analyzer.finalizeAndFinish(through: lastSample) + } else { + await analyzer.cancelAndFinishNow() + } + return try await resultTask.value + } catch { + resultTask.cancel() + await analyzer.cancelAndFinishNow() + throw error + } + } + + private static func makeTranscriber(locale: Locale) async throws -> SpeechTranscriber { + guard SpeechTranscriber.isAvailable else { + throw AppleSpeechAnalyzerError.unavailable + } + guard let supported = await SpeechTranscriber.supportedLocale(equivalentTo: locale) else { + throw AppleSpeechAnalyzerError.unsupportedLocale(locale.identifier) + } + return SpeechTranscriber(locale: supported, preset: .transcription) + } + + private static func ensureModel(for transcriber: SpeechTranscriber) async throws { + let modules: [any SpeechModule] = [transcriber] + if await AssetInventory.status(forModules: modules) == .installed { return } + if let request = try await AssetInventory.assetInstallationRequest(supporting: modules) { + try await request.downloadAndInstall() + } + guard await AssetInventory.status(forModules: modules) == .installed else { + throw AppleSpeechAnalyzerError.modelUnavailable + } + } +} + +enum AppleSpeechAnalyzerError: LocalizedError { + case unavailable + case unsupportedLocale(String) + case modelUnavailable + + var errorDescription: String? { + switch self { + case .unavailable: + return "SpeechAnalyzer is unavailable on this Mac" + case .unsupportedLocale(let locale): + return "SpeechAnalyzer does not support locale \(locale)" + case .modelUnavailable: + return "SpeechAnalyzer model is not installed" + } + } +} diff --git a/Sources/Speech/AppleSpeechEngine.swift b/Sources/Speech/AppleSpeechEngine.swift index 9a8120e7..0b2f43ac 100644 --- a/Sources/Speech/AppleSpeechEngine.swift +++ b/Sources/Speech/AppleSpeechEngine.swift @@ -2,7 +2,7 @@ import Foundation @preconcurrency import Speech import AVFoundation -final class AppleSpeechEngine: SpeechEngine, @unchecked Sendable { +final class LegacyAppleSpeechEngine: SpeechEngine, @unchecked Sendable { private var recognizer: SFSpeechRecognizer private(set) var isReady = false private var recognitionTask: SFSpeechRecognitionTask? diff --git a/Sources/Speech/AppleSpeechEngineAdapter.swift b/Sources/Speech/AppleSpeechEngineAdapter.swift new file mode 100644 index 00000000..0c8c81a3 --- /dev/null +++ b/Sources/Speech/AppleSpeechEngineAdapter.swift @@ -0,0 +1,88 @@ +import AVFoundation +import Foundation + +final class AppleSpeechEngine: SpeechEngine, @unchecked Sendable { + private let locale: Locale + private let legacy: LegacyAppleSpeechEngine + + init(locale: Locale = Locale(identifier: "zh-CN")) { + self.locale = locale + self.legacy = LegacyAppleSpeechEngine(locale: locale) + } + + var isReady: Bool { legacy.isReady } + var supportsStreaming: Bool { true } + + func requestAccess() { + legacy.requestAccess() + } + + func prepare() async { + do { + try await AppleSpeechAnalyzer.prepare(locale: locale) + } catch { + Log.info("[AppleSpeech] SpeechAnalyzer preparation deferred: \(error.localizedDescription)") + } + } + + func startListening(language: String?, onPartialResult: @escaping @Sendable (String) -> Void) { + legacy.startListening(language: language, onPartialResult: onPartialResult) + } + + func appendAudioBuffer(_ buffer: AVAudioPCMBuffer) { + legacy.appendAudioBuffer(buffer) + } + + func finishListening(audioURL: URL?, language: String?) async throws -> String { + let fallbackTask = Task { + try await legacy.finishListening(audioURL: audioURL, language: language) + } + guard let audioURL else { return try await fallbackTask.value } + + do { + let text = try await AppleSpeechAnalyzer.transcribe( + audioURL: audioURL, + locale: resolvedLocale(for: language) + ) + legacy.cancelListening() + let fallback = try? await fallbackTask.value + return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? fallback ?? "" + : text + } catch { + Log.info("[AppleSpeech] SpeechAnalyzer failed, using legacy result: \(error.localizedDescription)") + return try await fallbackTask.value + } + } + + func cancelListening() { + legacy.cancelListening() + } + + func transcribe(audioURL: URL?, language: String?) async throws -> String { + guard let audioURL else { throw AppleSpeechError.noAudioFile } + do { + let text = try await AppleSpeechAnalyzer.transcribe( + audioURL: audioURL, + locale: resolvedLocale(for: language) + ) + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return text + } + } catch { + Log.info("[AppleSpeech] SpeechAnalyzer failed, using legacy recognizer: \(error.localizedDescription)") + } + return try await legacy.transcribe(audioURL: audioURL, language: language) + } + + private func resolvedLocale(for language: String?) -> Locale { + switch language { + case "zh": return Locale(identifier: "zh-CN") + case "en": return Locale(identifier: "en-US") + case "ja": return Locale(identifier: "ja-JP") + case "ko": return Locale(identifier: "ko-KR") + case "yue": return Locale(identifier: "yue-CN") + default: return locale + } + } +} diff --git a/Sources/Speech/LocalASRRuntime.swift b/Sources/Speech/LocalASRRuntime.swift index 93b9365f..68d7c8f6 100644 --- a/Sources/Speech/LocalASRRuntime.swift +++ b/Sources/Speech/LocalASRRuntime.swift @@ -2,6 +2,7 @@ import Foundation enum LocalASRRuntime { private static let qwenPackage = "qwen3-asr-mlx" + static let qwenPackageVersion = "0.1.1" private static let qwenImport = "qwen3_asr_mlx" private static let markerName = ".opentype-runtime-ready" private static let nativeMarkerName = ".opentype-native-runtime-ready" @@ -11,8 +12,8 @@ enum LocalASRRuntime { case .qwen3: let python = qwenPythonURL() return FileManager.default.isExecutableFile(atPath: python.path) && - FileManager.default.fileExists(atPath: qwenMarkerURL().path) && - FileManager.default.fileExists(atPath: qwenNativeMarkerURL().path) + qwenMarkerIsCurrent(at: qwenMarkerURL()) && + qwenMarkerIsCurrent(at: qwenNativeMarkerURL()) case .mimo: return LocalASRConfiguration.resolvePythonPath() != nil } @@ -38,14 +39,17 @@ enum LocalASRRuntime { let runtimePython = qwenPythonURL().path if isReady(for: .qwen3) { return runtimePython } - if FileManager.default.isExecutableFile(atPath: runtimePython) && - FileManager.default.fileExists(atPath: qwenMarkerURL().path) { + if FileManager.default.isExecutableFile(atPath: runtimePython) { + if !qwenMarkerIsCurrent(at: qwenMarkerURL()) { + try await installQwenPackage(using: runtimePython) + } try await prepareNativeExtensions(in: runtimeDir) try await runProcess( executable: runtimePython, arguments: ["-c", "import \(qwenImport)"] ) - try Data(qwenPackage.utf8).write(to: qwenNativeMarkerURL()) + try writeCurrentQwenMarker(to: qwenMarkerURL()) + try writeCurrentQwenMarker(to: qwenNativeMarkerURL()) return runtimePython } @@ -64,20 +68,40 @@ enum LocalASRRuntime { executable: runtimePython, arguments: ["-m", "pip", "install", "--quiet", "--upgrade", "pip", "setuptools", "wheel"] ) - try await runProcess( - executable: runtimePython, - arguments: ["-m", "pip", "install", "--quiet", qwenPackage] - ) + try await installQwenPackage(using: runtimePython) try await prepareNativeExtensions(in: runtimeDir) try await runProcess( executable: runtimePython, arguments: ["-c", "import \(qwenImport)"] ) - try Data(qwenPackage.utf8).write(to: qwenMarkerURL()) - try Data(qwenPackage.utf8).write(to: qwenNativeMarkerURL()) + try writeCurrentQwenMarker(to: qwenMarkerURL()) + try writeCurrentQwenMarker(to: qwenNativeMarkerURL()) return runtimePython } + static var qwenRequirement: String { + "\(qwenPackage)==\(qwenPackageVersion)" + } + + static func qwenMarkerIsCurrent(_ contents: String?) -> Bool { + contents?.trimmingCharacters(in: .whitespacesAndNewlines) == qwenRequirement + } + + private static func qwenMarkerIsCurrent(at url: URL) -> Bool { + qwenMarkerIsCurrent(try? String(contentsOf: url, encoding: .utf8)) + } + + private static func writeCurrentQwenMarker(to url: URL) throws { + try Data(qwenRequirement.utf8).write(to: url, options: .atomic) + } + + private static func installQwenPackage(using python: String) async throws { + try await runProcess( + executable: python, + arguments: ["-m", "pip", "install", "--quiet", "--upgrade", qwenRequirement] + ) + } + private static func qwenPythonURL() -> URL { ModelStorage.qwenASRRuntimeDir().appendingPathComponent("bin/python") } diff --git a/Sources/Speech/SpeechEngineProvider.swift b/Sources/Speech/SpeechEngineProvider.swift index 53eb96b7..13a0bc26 100644 --- a/Sources/Speech/SpeechEngineProvider.swift +++ b/Sources/Speech/SpeechEngineProvider.swift @@ -43,6 +43,17 @@ final class SpeechEngineProvider { resourceId: settings.volcResourceId ) case .qwen3: + if !LocalASRRuntime.isReady(for: .qwen3), + !ModelCatalog.shared.asrModelPath(for: settings.qwenASRModel).isEmpty { + do { + _ = try await LocalASRRuntime.ensurePythonPath( + for: .qwen3, + preferredPath: settings.localASRPythonPath + ) + } catch { + Log.error("[SpeechEngineProvider] Qwen ASR runtime migration failed: \(error.localizedDescription)") + } + } guard localASRIsAvailable(settings.qwenASRModel) else { qwenSpeechEngine = nil Log.info("[SpeechEngineProvider] Qwen ASR model requires manual download: \(settings.qwenASRModel)") diff --git a/Sources/UI/ModelManagementSections.swift b/Sources/UI/ModelManagementSections.swift index d28dd5ce..2282072d 100644 --- a/Sources/UI/ModelManagementSections.swift +++ b/Sources/UI/ModelManagementSections.swift @@ -57,6 +57,12 @@ extension ModelManagementView { .font(.system(size: 11)) .foregroundStyle(.secondary) + Picker(L("volc.model"), selection: $settings.volcResourceId) { + ForEach(VolcASRModel.allCases) { model in + Text(model.displayName).tag(model.rawValue) + } + } + TextField(L("volc.app_key"), text: $settings.volcAppKey) .textFieldStyle(.roundedBorder) SecureField(L("volc.access_key"), text: $settings.volcAccessKey) diff --git a/Tests/OpenTypeTests/AppleSpeechAnalyzerIntegrationTests.swift b/Tests/OpenTypeTests/AppleSpeechAnalyzerIntegrationTests.swift new file mode 100644 index 00000000..424daf68 --- /dev/null +++ b/Tests/OpenTypeTests/AppleSpeechAnalyzerIntegrationTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest +@testable import OpenType + +final class AppleSpeechAnalyzerIntegrationTests: XCTestCase { + func testTranscribesBundledChineseSample() async throws { + guard ProcessInfo.processInfo.environment["OPENTYPE_APPLE_SPEECH_INTEGRATION"] == "1" else { + throw XCTSkip("Set OPENTYPE_APPLE_SPEECH_INTEGRATION=1 to run this integration test") + } + + let repository = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let audioURL = repository.appendingPathComponent("docs/assets/demos/zh-sample.m4a") + let text = try await AppleSpeechAnalyzer.transcribe( + audioURL: audioURL, + locale: Locale(identifier: "zh-CN") + ) + + XCTAssertFalse(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } +} diff --git a/Tests/OpenTypeTests/LocalASRRuntimeIntegrationTests.swift b/Tests/OpenTypeTests/LocalASRRuntimeIntegrationTests.swift new file mode 100644 index 00000000..4e2d247b --- /dev/null +++ b/Tests/OpenTypeTests/LocalASRRuntimeIntegrationTests.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest +@testable import OpenType + +final class LocalASRRuntimeIntegrationTests: XCTestCase { + func testMigratesInstalledQwenRuntimeToPinnedVersion() async throws { + guard ProcessInfo.processInfo.environment["OPENTYPE_QWEN_RUNTIME_INTEGRATION"] == "1" else { + throw XCTSkip("Set OPENTYPE_QWEN_RUNTIME_INTEGRATION=1 to run this integration test") + } + + let python = try await LocalASRRuntime.ensurePythonPath(for: .qwen3, preferredPath: "python3") + XCTAssertTrue(FileManager.default.isExecutableFile(atPath: python)) + XCTAssertTrue(LocalASRRuntime.isReady(for: .qwen3)) + + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: python) + process.arguments = ["-c", "import importlib.metadata as m; print(m.version('qwen3-asr-mlx'))"] + process.standardOutput = output + try process.run() + process.waitUntilExit() + + let data = output.fileHandleForReading.readDataToEndOfFile() + let version = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual(process.terminationStatus, 0) + XCTAssertEqual(version, LocalASRRuntime.qwenPackageVersion) + } +} diff --git a/Tests/OpenTypeTests/ModelUpgradeTests.swift b/Tests/OpenTypeTests/ModelUpgradeTests.swift new file mode 100644 index 00000000..9a6d6af0 --- /dev/null +++ b/Tests/OpenTypeTests/ModelUpgradeTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import OpenType + +@Suite("Model upgrade policy") +struct ModelUpgradeTests { + @Test("Qwen runtime marker includes the pinned version") + func qwenRuntimeMarkerVersion() { + #expect(LocalASRRuntime.qwenRequirement == "qwen3-asr-mlx==0.1.1") + #expect(LocalASRRuntime.qwenMarkerIsCurrent("qwen3-asr-mlx==0.1.1")) + #expect(!LocalASRRuntime.qwenMarkerIsCurrent("qwen3-asr-mlx")) + #expect(!LocalASRRuntime.qwenMarkerIsCurrent("qwen3-asr-mlx==0.1.0")) + } + + @Test("SeedASR 2.0 is the recommended Volcengine model") + func recommendedVolcModel() { + #expect(VolcASRModel.recommended == .seedASR2) + #expect(VolcASRModel.recommended.rawValue == "volc.seedasr.sauc.duration") + } +}