diff --git a/Package.resolved b/Package.resolved index 9358719f..76592587 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7f808a86fcdc955b5832bd1753d25aa3fc629c44e8e2102637d2722b2517dbf8", + "originHash" : "a272f2b5fa2a7a226c6e9fdcf766dbda0afb1e7004e1b2f15f107c99cd5b662b", "pins" : [ { "identity" : "argmax-oss-swift", @@ -19,6 +19,15 @@ "version" : "1.4.1" } }, + { + "identity" : "mlx-audio-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Blaizzy/mlx-audio-swift.git", + "state" : { + "revision" : "d302a5c6080d2bb97bae38c7418f82abb76013b6", + "version" : "0.1.3" + } + }, { "identity" : "mlx-swift", "kind" : "remoteSourceControl", @@ -33,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ml-explore/mlx-swift-lm", "state" : { - "branch" : "main", - "revision" : "2c1dd13d41586f63f40ba9ce45ce201026ab52b0" + "revision" : "bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57", + "version" : "3.31.4" } }, { diff --git a/Package.swift b/Package.swift index e46d467a..16d901a4 100644 --- a/Package.swift +++ b/Package.swift @@ -13,14 +13,17 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/argmaxinc/argmax-oss-swift.git", from: "1.0.0"), + .package(url: "https://github.com/Blaizzy/mlx-audio-swift.git", exact: "0.1.3"), .package(url: "https://github.com/huggingface/swift-transformers", from: "1.3.3"), - .package(url: "https://github.com/ml-explore/mlx-swift-lm", branch: "main"), + .package(url: "https://github.com/ml-explore/mlx-swift-lm", exact: "3.31.4"), ], targets: [ .executableTarget( name: "OpenType", dependencies: [ .product(name: "WhisperKit", package: "argmax-oss-swift"), + .product(name: "MLXAudioCore", package: "mlx-audio-swift"), + .product(name: "MLXAudioSTT", package: "mlx-audio-swift"), .product(name: "Hub", package: "swift-transformers"), .product(name: "Tokenizers", package: "swift-transformers"), .product(name: "MLXLLM", package: "mlx-swift-lm"), @@ -39,7 +42,6 @@ let package = Package( .copy("Resources/SettingsStyleIllustration.png"), .copy("Resources/SettingsIntegrationsIllustration.png"), .copy("Resources/SettingsAboutIllustration.png"), - .copy("Resources/Scripts"), .copy("Resources/Sounds"), .copy("Resources/AppIcon.icns"), .copy("Resources/AppIconLight.icns"), diff --git a/README.md b/README.md index 6fd56872..16fa9fe8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Three output modes are available: | Feature | Description | |---|---| -| **Multiple Speech Engines** | Apple Speech, WhisperKit, Doubao ASR, Qwen3-ASR, or MiMo-V2.5-ASR | +| **Multiple Speech Engines** | Apple Speech, WhisperKit, Doubao ASR, or Qwen3-ASR | | **Smart Text Processing** | Local MLX Qwen2.5/Qwen3 or remote LLM infers spoken intent — contextual cleanup, "scratch that" restarts, self-correction handling, spoken punctuation, technical terms, numbers/ranges/units, and structured formatting | | **LLM-Owned Spoken Formatting** | Spoken casing, no-space dictation, identifiers, file paths, shortcuts, emoji, Markdown tasks, dates/times, quantities, units, formulas, fractions, and digit sequences are handled by the Smart Format / Voice Command prompts instead of local hardcoded rewrite rules | | **Voice Edit Commands** | In Voice Command mode, an LLM classifies safe structured actions for replacing, undoing, proofreading, titling, summarizing, drafting replies, making meeting notes, extracting key points/decisions/questions/risks/deadlines/owners/action items, rewriting tone, expanding, making tables/lists, or deleting the previous Utter insertion or selected text | @@ -131,10 +131,9 @@ Utter supports both **OpenAI-compatible** and **Anthropic** API formats: | Provider | Local runtime | Default model | |---|---|---| -| Qwen3-ASR | `qwen3-asr-mlx` + MLX on Apple Silicon | `mlx-community/Qwen3-ASR-1.7B-bf16` | -| MiMo-V2.5-ASR | Xiaomi's local Python runtime files + local model folders | `XiaomiMiMo/MiMo-V2.5-ASR` + `XiaomiMiMo/MiMo-Audio-Tokenizer` | +| Qwen3-ASR | Native Swift + MLX on Apple Silicon | `mlx-community/Qwen3-ASR-1.7B-bf16` | -These engines do not call hosted ASR APIs. The app downloads the selected model into the same model storage used by WhisperKit/MLX, prepares the Qwen Python runtime in an app-managed virtual environment, downloads MiMo runtime files when needed, finds an available Python 3 executable, then invokes the bundled local runner script. +Qwen3-ASR does not call a hosted ASR API. The app downloads the selected model into the same model storage used by WhisperKit and runs inference locally through native Swift and MLX. ## Project Structure diff --git a/Sources/App/VoicePipeline+Models.swift b/Sources/App/VoicePipeline+Models.swift index b0ca06b1..dfb5a803 100644 --- a/Sources/App/VoicePipeline+Models.swift +++ b/Sources/App/VoicePipeline+Models.swift @@ -25,7 +25,6 @@ extension VoicePipeline { func unloadLocalASR() { qwenSpeechEngine = nil - mimoSpeechEngine = nil } func loadLLM() { @@ -99,32 +98,14 @@ extension VoicePipeline { markSpeechModelDownloadRequired(showInStatus: requestPermission) return } - let engine = LocalASREngine(configuration: LocalASRConfiguration( - provider: .qwen3, - pythonPath: settings.localASRPythonPath, - modelPath: catalog.asrModelPath(for: settings.qwenASRModel), - tokenizerPath: "", - repoPath: "" - )) + let modelPath = catalog.asrModelPath(for: settings.qwenASRModel) + if qwenSpeechEngine?.usesModel(at: modelPath) == true { return } + let engine = QwenNativeASREngine(modelPath: modelPath) qwenSpeechEngine = engine Task { await engine.prepare() } case .mimo: - let settings = appState.settings - let catalog = ModelCatalog.shared - guard localASRIsAvailable(settings.mimoASRModel) else { - mimoSpeechEngine = nil - markSpeechModelDownloadRequired(showInStatus: requestPermission) - return - } - let engine = LocalASREngine(configuration: LocalASRConfiguration( - provider: .mimo, - pythonPath: settings.localASRPythonPath, - modelPath: catalog.asrModelPath(for: settings.mimoASRModel), - tokenizerPath: catalog.mimoTokenizerPath(), - repoPath: catalog.mimoRepositoryPath() - )) - mimoSpeechEngine = engine - Task { await engine.prepare() } + appState.settings.speechEngine = .apple + await ensureEngineLoaded(requestPermission: requestPermission) } } diff --git a/Sources/App/VoicePipeline+Status.swift b/Sources/App/VoicePipeline+Status.swift index 7445db08..ad23b7c7 100644 --- a/Sources/App/VoicePipeline+Status.swift +++ b/Sources/App/VoicePipeline+Status.swift @@ -87,9 +87,7 @@ extension VoicePipeline { return error.localizedDescription case let error as AppleSpeechError: return error.localizedDescription - case let error as LocalASRError: - return error.localizedDescription - case let error as LocalASRRuntimeError: + case let error as QwenNativeASRError: return error.localizedDescription case is URLError: return L("error.network_request_failed") diff --git a/Sources/App/VoicePipeline.swift b/Sources/App/VoicePipeline.swift index 1c553bc2..a12a0c5c 100644 --- a/Sources/App/VoicePipeline.swift +++ b/Sources/App/VoicePipeline.swift @@ -13,8 +13,7 @@ final class VoicePipeline { var whisperEngine: WhisperEngine? var appleSpeechEngine: AppleSpeechEngine? var volcSpeechEngine: VolcSpeechEngine? - var qwenSpeechEngine: LocalASREngine? - var mimoSpeechEngine: LocalASREngine? + var qwenSpeechEngine: QwenNativeASREngine? var screenOCRTask: Task? var screenOCRStartedAt: CFAbsoluteTime? var processingTask: Task? @@ -28,7 +27,7 @@ final class VoicePipeline { case .apple: return appleSpeechEngine case .volc: return volcSpeechEngine case .qwen3: return qwenSpeechEngine - case .mimo: return mimoSpeechEngine + case .mimo: return nil } } diff --git a/Sources/Config/ASRDownloadError.swift b/Sources/Config/ASRDownloadError.swift deleted file mode 100644 index 55c5eb8e..00000000 --- a/Sources/Config/ASRDownloadError.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -enum ASRDownloadError: LocalizedError { - case incompleteRuntime - case processFailed(String) - - var errorDescription: String? { - switch self { - case .incompleteRuntime: - return L("model.asr_runtime_incomplete") - case .processFailed(let message): - return message.isEmpty ? L("model.asr_runtime_download_failed") : message - } - } -} diff --git a/Sources/Config/AppSettings.swift b/Sources/Config/AppSettings.swift index 134d0b85..fd57ab00 100644 --- a/Sources/Config/AppSettings.swift +++ b/Sources/Config/AppSettings.swift @@ -35,6 +35,10 @@ enum SpeechEngineType: String, Codable, CaseIterable { case qwen3 = "qwen3" case mimo = "mimo" + static var selectableCases: [SpeechEngineType] { + allCases.filter { $0 != .mimo } + } + var label: String { switch self { case .whisper: return "WhisperKit" @@ -288,10 +292,7 @@ final class AppSettings: ObservableObject { @Published var volcAppKey: String @Published var volcAccessKey: String @Published var volcResourceId: String - @Published var localASRPythonPath: String @Published var qwenASRModel: String - @Published var mimoASRRepoPath: String - @Published var mimoASRModel: String @Published var preloadSpeechModelOnLaunch: Bool @Published var preloadFormattingModelOnLaunch: Bool @Published var modelStoragePath: String @@ -315,9 +316,7 @@ final class AppSettings: ObservableObject { case useRemoteLLM, remoteProvider, remoteAPIKey, remoteBaseURL, remoteModel case menuBarIcon, appIconAppearance case volcAppKey, volcAccessKey, volcResourceId - case localASRPythonPath case qwenASRModel, qwenASRModelPath - case mimoASRRepoPath, mimoASRModel, mimoASRModelPath, mimoASRTokenizerPath case preloadSpeechModelOnLaunch, preloadFormattingModelOnLaunch case modelStoragePath, localWhisperModelPaths, localLLMModelPaths case developerInterfaceEnabled, developerHTTPPort, developerHTTPToken @@ -342,9 +341,20 @@ final class AppSettings: ObservableObject { ?? .longPress tapInterval = ud.double(forKey: Key.tapInterval.rawValue).nonZero ?? 0.4 let savedEngine = ud.string(forKey: Key.speechEngine.rawValue) ?? "" - speechEngine = SpeechEngineType(rawValue: savedEngine) + let loadedSpeechEngine = SpeechEngineType(rawValue: savedEngine) ?? (savedEngine.contains("Whisper") || savedEngine.contains("whisper") ? .whisper : nil) ?? .apple + speechEngine = loadedSpeechEngine == .mimo ? .apple : loadedSpeechEngine + if loadedSpeechEngine == .mimo { + ud.set(SpeechEngineType.apple.rawValue, forKey: Key.speechEngine.rawValue) + } + [ + "localASRPythonPath", + "mimoASRRepoPath", + "mimoASRModel", + "mimoASRModelPath", + "mimoASRTokenizerPath", + ].forEach(ud.removeObject(forKey:)) whisperModel = ud.string(forKey: Key.whisperModel.rawValue) ?? "large-v3" llmModel = ud.string(forKey: Key.llmModel.rawValue) ?? Self.defaultLLMModelID microphoneID = ud.string(forKey: Key.microphoneID.rawValue) @@ -387,14 +397,9 @@ final class AppSettings: ObservableObject { volcAppKey = ud.string(forKey: Key.volcAppKey.rawValue) ?? "" volcAccessKey = ud.string(forKey: Key.volcAccessKey.rawValue) ?? "" 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) - ?? LocalASRConfiguration.qwen3DefaultModel - mimoASRRepoPath = ud.string(forKey: Key.mimoASRRepoPath.rawValue) ?? "" - mimoASRModel = ud.string(forKey: Key.mimoASRModel.rawValue) - ?? ud.string(forKey: Key.mimoASRModelPath.rawValue) - ?? LocalASRConfiguration.mimoDefaultModel + ?? QwenASRModel.defaultID preloadSpeechModelOnLaunch = ud.object(forKey: Key.preloadSpeechModelOnLaunch.rawValue) as? Bool ?? true preloadFormattingModelOnLaunch = ud.object(forKey: Key.preloadFormattingModelOnLaunch.rawValue) as? Bool ?? true modelStoragePath = ud.string(forKey: Key.modelStoragePath.rawValue) ?? ModelStorage.defaultRoot.path @@ -456,10 +461,7 @@ final class AppSettings: ObservableObject { $volcAppKey.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.volcAppKey.rawValue) }.store(in: &cancellables) $volcAccessKey.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.volcAccessKey.rawValue) }.store(in: &cancellables) $volcResourceId.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.volcResourceId.rawValue) }.store(in: &cancellables) - $localASRPythonPath.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.localASRPythonPath.rawValue) }.store(in: &cancellables) $qwenASRModel.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.qwenASRModel.rawValue) }.store(in: &cancellables) - $mimoASRRepoPath.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.mimoASRRepoPath.rawValue) }.store(in: &cancellables) - $mimoASRModel.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.mimoASRModel.rawValue) }.store(in: &cancellables) $preloadSpeechModelOnLaunch.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.preloadSpeechModelOnLaunch.rawValue) }.store(in: &cancellables) $preloadFormattingModelOnLaunch.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.preloadFormattingModelOnLaunch.rawValue) }.store(in: &cancellables) $modelStoragePath.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.modelStoragePath.rawValue) }.store(in: &cancellables) diff --git a/Sources/Config/ModelCatalog.swift b/Sources/Config/ModelCatalog.swift index 2d5a5475..eb6f54ed 100644 --- a/Sources/Config/ModelCatalog.swift +++ b/Sources/Config/ModelCatalog.swift @@ -129,10 +129,7 @@ final class ModelCatalog: ObservableObject { ModelEntry(id: $0.id, displayName: $0.displayName, hint: $0.hint, family: nil) } if !asrModels.contains(where: { $0.id == settings.qwenASRModel }) { - settings.qwenASRModel = LocalASRConfiguration.qwen3DefaultModel - } - if !asrModels.contains(where: { $0.id == settings.mimoASRModel }) { - settings.mimoASRModel = LocalASRConfiguration.mimoDefaultModel + settings.qwenASRModel = QwenASRModel.defaultID } refreshStatus() } diff --git a/Sources/Config/ModelCatalogASR.swift b/Sources/Config/ModelCatalogASR.swift index 01389394..212c2cc6 100644 --- a/Sources/Config/ModelCatalogASR.swift +++ b/Sources/Config/ModelCatalogASR.swift @@ -4,50 +4,29 @@ import Hub extension ModelCatalog { static var asrDownloadBase: URL { whisperDownloadBase } - static var defaultASRModels: [ - (id: String, displayName: String, hint: String, provider: LocalASRConfiguration.Provider) - ] { + static var defaultASRModels: [(id: String, displayName: String, hint: String)] { [ ( - LocalASRConfiguration.qwen3DefaultModel, + QwenASRModel.defaultID, "Qwen3-ASR 1.7B", - L("model.qwen3_asr_quality"), - .qwen3 - ), - ( - LocalASRConfiguration.mimoDefaultModel, - "MiMo-V2.5-ASR", - L("model.mimo_asr_quality"), - .mimo - ), + L("model.qwen3_asr_quality") + ) ] } - func asrModels(for provider: LocalASRConfiguration.Provider) -> [ModelEntry] { - asrModels.filter { asrProvider(for: $0.id) == provider } - } - - func asrProvider(for id: String) -> LocalASRConfiguration.Provider? { - Self.defaultASRModels.first { $0.id == id }?.provider - } - - func asrRuntimeAvailability(for id: String) -> LocalASRRuntimeAvailability { - guard let provider = asrProvider(for: id) else { return .supported } - return LocalASRRuntime.availability(for: provider) + func asrModels(for engine: SpeechEngineType) -> [ModelEntry] { + switch engine { + case .qwen3: + return asrModels.filter { $0.id == QwenASRModel.defaultID } + case .mimo: + return [] + default: + return [] + } } func asrModelPath(for id: String) -> String { - asrSingleRepoIsComplete(id) ? ModelStorage.asrRepoDir(id)?.path ?? "" : "" - } - - func mimoTokenizerPath() -> String { - let id = LocalASRConfiguration.mimoTokenizerModel - return asrSingleRepoIsComplete(id) ? ModelStorage.asrRepoDir(id)?.path ?? "" : "" - } - - func mimoRepositoryPath() -> String { - let dir = ModelStorage.mimoASRRepositoryDir() - return Self.mimoRepositoryIsReady(at: dir) ? dir.path : "" + asrRepoIsComplete(id) ? ModelStorage.asrRepoDir(id)?.path ?? "" : "" } func refreshASRStatus(recheckingErrors: Bool = false) { @@ -55,14 +34,10 @@ extension ModelCatalog { let id = asrModels[i].id let size = asrRepoSize(id) asrModels[i].cacheSize = size - if case .unavailable(let message) = asrRuntimeAvailability(for: id) { - asrModels[i].status = .unavailable(message) - continue - } if recheckingErrors || (asrModels[i].status != .ready && !asrModels[i].status.isError) { asrModels[i].status = asrRepoIsComplete(id) ? .downloaded - : asrMissingStatus(for: id, size: size) + : asrMissingStatus(size: size) } } } @@ -77,11 +52,8 @@ extension ModelCatalog { _ id: String, onProgress: ((DownloadProgressInfo) -> Void)? ) async { - guard let idx = asrModels.firstIndex(where: { $0.id == id }), !asrModels[idx].status.isDownloading else { return } - if case .unavailable(let message) = asrRuntimeAvailability(for: id) { - asrModels[idx].status = .unavailable(message) - return - } + guard let idx = asrModels.firstIndex(where: { $0.id == id }), + !asrModels[idx].status.isDownloading else { return } if asrRepoIsComplete(id) { asrModels[idx].status = .downloaded @@ -95,43 +67,36 @@ extension ModelCatalog { asrModels[idx].downloadDetail = "" do { - let repos = asrRequiredRepoIDs(for: id) let api = HubApi(downloadBase: Self.asrDownloadBase) - let startedAt = Date() + let tracker = DownloadProgressTracker( + startDate: Date(), + initialBytes: asrRepoSize(id) + ) let estimatedTotalBytes = estimatedASRDownloadBytes(id) ?? 0 - if !asrModelFilesAreComplete(id) { - let tracker = DownloadProgressTracker(startDate: startedAt, initialBytes: asrRepoSize(id)) - for (repoIndex, repoID) in repos.enumerated() { - _ = try await api.snapshot(from: ModelStorage.hubModelRepo(repoID)) { [weak self] progress in - Task { @MainActor in - guard let self, let i = self.asrModels.firstIndex(where: { $0.id == id }) else { return } - let repositoryFraction = - (Double(repoIndex) + progress.fractionCompleted) / Double(repos.count) - let fraction = repositoryFraction * 0.9 - let completedBytes = self.asrRepoSize(id) - let info = tracker.update( - completedBytes: completedBytes, - totalBytes: estimatedTotalBytes, - fraction: fraction - ) - self.asrModels[i].downloadProgress = info.fraction - self.asrModels[i].downloadDetail = info.detailText - onProgress?(info) - } + let repositories = asrRequiredRepoIDs(for: id) + for (repositoryIndex, repositoryID) in repositories.enumerated() { + _ = try await api.snapshot(from: ModelStorage.hubModelRepo(repositoryID)) { [weak self] progress in + Task { @MainActor in + guard let self, + let i = self.asrModels.firstIndex(where: { $0.id == id }) else { return } + let repositoryFraction = + (Double(repositoryIndex) + progress.fractionCompleted) / Double(repositories.count) + let info = tracker.update( + completedBytes: self.asrRepoSize(id), + totalBytes: estimatedTotalBytes, + fraction: repositoryFraction + ) + self.asrModels[i].downloadProgress = info.fraction + self.asrModels[i].downloadDetail = info.detailText + onProgress?(info) } } } - if asrProvider(for: id) == .qwen3 { - asrModels[idx].downloadProgress = max(asrModels[idx].downloadProgress, 0.9) - asrModels[idx].downloadDetail = L("model.asr_installing_runtime") - _ = try await LocalASRRuntime.ensurePythonPath( - for: .qwen3, - preferredPath: AppSettings.shared.localASRPythonPath - ) - } try Task.checkCancellation() if let i = asrModels.firstIndex(where: { $0.id == id }) { - asrModels[i].status = asrRepoIsComplete(id) ? .downloaded : .error(L("model.asr_incomplete")) + asrModels[i].status = asrRepoIsComplete(id) + ? .downloaded + : .error(L("model.asr_incomplete")) asrModels[i].cacheSize = asrRepoSize(id) asrModels[i].downloadDetail = "" } @@ -154,90 +119,38 @@ extension ModelCatalog { func deleteASR(_ id: String) { guard let idx = asrModels.firstIndex(where: { $0.id == id }) else { return } - let provider = asrProvider(for: id) - for repoID in asrRequiredRepoIDs(for: id) { - try? FileManager.default.removeItem(at: ModelStorage.hubModelRepoDir(repoID)) - } - if provider == .mimo { - try? FileManager.default.removeItem(at: ModelStorage.mimoASRRepositoryDir()) - } - if let provider { - try? FileManager.default.removeItem(at: ModelStorage.localASRRuntimeDir(for: provider)) + for repositoryID in asrRequiredRepoIDs(for: id) { + try? FileManager.default.removeItem(at: ModelStorage.hubModelRepoDir(repositoryID)) } asrModels[idx].cacheSize = 0 - asrModels[idx].status = asrMissingStatus(for: id, size: 0) + asrModels[idx].status = .notDownloaded asrModels[idx].downloadDetail = "" let settings = AppSettings.shared - if provider == .qwen3, settings.qwenASRModel == id { - settings.qwenASRModel = nextAvailableASR(for: .qwen3, excluding: id) ?? id - } - if provider == .mimo, settings.mimoASRModel == id { - settings.mimoASRModel = nextAvailableASR(for: .mimo, excluding: id) ?? id + if id == QwenASRModel.defaultID, settings.qwenASRModel == id { + settings.qwenASRModel = QwenASRModel.defaultID } } - func nextAvailableASR(for provider: LocalASRConfiguration.Provider, excluding id: String) -> String? { - asrModels.first { - $0.id != id && - asrProvider(for: $0.id) == provider && - ($0.status == .downloaded || $0.status == .ready) - }?.id - } - - private func asrRequiredRepoIDs(for id: String) -> [String] { - if asrProvider(for: id) == .mimo { - return [id, LocalASRConfiguration.mimoTokenizerModel] - } - return [id] - } - private func asrRepoIsComplete(_ id: String) -> Bool { - let hasFiles = asrModelFilesAreComplete(id) - switch asrProvider(for: id) { - case .qwen3: - return hasFiles && LocalASRRuntime.isReady(for: .qwen3) - case .mimo: - return hasFiles && - Self.mimoRepositoryIsReady(at: ModelStorage.mimoASRRepositoryDir()) && - LocalASRRuntime.isReady(for: .mimo) - case nil: - return hasFiles + asrRequiredRepoIDs(for: id).allSatisfy { + Self.asrRepoContainsRequiredFiles($0, at: ModelStorage.asrRepoDir($0)) } } - private func asrModelFilesAreComplete(_ id: String) -> Bool { - asrRequiredRepoIDs(for: id).allSatisfy { asrSingleRepoIsComplete($0) } - } - - private func asrMissingStatus(for id: String, size: Int64) -> ModelStatus { - if case .unavailable(let message) = asrRuntimeAvailability(for: id) { - return .unavailable(message) - } - guard size > 0 else { return .notDownloaded } - if asrModelFilesAreComplete(id), asrProvider(for: id) == .qwen3 { - return .error(L("model.asr_runtime_missing")) - } - return .error(L("model.asr_incomplete")) + private func asrMissingStatus(size: Int64) -> ModelStatus { + size > 0 ? .error(L("model.asr_incomplete")) : .notDownloaded } private func asrRepoSize(_ id: String) -> Int64 { - let modelSize = asrRequiredRepoIDs(for: id).reduce(Int64(0)) { $0 + asrSingleRepoSize($1) } - guard let provider = asrProvider(for: id) else { return modelSize } - let runtimeSize = ModelStorage.directorySize(at: ModelStorage.localASRRuntimeDir(for: provider)) - let repositorySize = provider == .mimo - ? ModelStorage.directorySize(at: ModelStorage.mimoASRRepositoryDir()) - : 0 - return modelSize + runtimeSize + repositorySize - } - - private func asrSingleRepoIsComplete(_ id: String) -> Bool { - Self.asrRepoContainsRequiredFiles(id, at: ModelStorage.asrRepoDir(id)) + asrRequiredRepoIDs(for: id).reduce(0) { total, repositoryID in + guard let directory = ModelStorage.asrRepoDir(repositoryID) else { return total } + return total + ModelStorage.directorySize(at: directory) + } } - private func asrSingleRepoSize(_ id: String) -> Int64 { - guard let dir = ModelStorage.asrRepoDir(id) else { return 0 } - return ModelStorage.directorySize(at: dir) + private func asrRequiredRepoIDs(for id: String) -> [String] { + [id] } static func asrRepoContainsRequiredFiles(_ id: String, at dir: URL?) -> Bool { @@ -254,7 +167,7 @@ extension ModelCatalog { static func asrRequiredFiles(for id: String) -> [String] { switch id { - case LocalASRConfiguration.qwen3DefaultModel: + case QwenASRModel.defaultID: return [ "config.json", "model.safetensors", @@ -262,25 +175,10 @@ extension ModelCatalog { "preprocessor_config.json", "tokenizer_config.json", "vocab.json", + "merges.txt", ] - case LocalASRConfiguration.mimoDefaultModel: - return [ - "config.json", - "model.safetensors.index.json", - "tokenizer.json", - "model-00001-of-00007.safetensors", - "model-00002-of-00007.safetensors", - "model-00003-of-00007.safetensors", - "model-00004-of-00007.safetensors", - "model-00005-of-00007.safetensors", - "model-00006-of-00007.safetensors", - "model-00007-of-00007.safetensors", - ] - case LocalASRConfiguration.mimoTokenizerModel: - return ["config.json", "model.safetensors"] default: return ["config.json"] } } - } diff --git a/Sources/Config/ModelCatalogDownloadEstimates.swift b/Sources/Config/ModelCatalogDownloadEstimates.swift index d6d7eeb9..1cc21582 100644 --- a/Sources/Config/ModelCatalogDownloadEstimates.swift +++ b/Sources/Config/ModelCatalogDownloadEstimates.swift @@ -52,8 +52,7 @@ extension ModelCatalog { "mlx-community/gemma-3-12b-it-4bit": 8_068_018_787, "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, - LocalASRConfiguration.qwen3DefaultModel: 4_080_707_826, - LocalASRConfiguration.mimoDefaultModel: 35_997_080_271, + QwenASRModel.defaultID: 4_080_707_826, ] private static let downloadEstimateRegex = try! NSRegularExpression( diff --git a/Sources/Config/ModelCatalogMiMoRuntime.swift b/Sources/Config/ModelCatalogMiMoRuntime.swift deleted file mode 100644 index fdb3d533..00000000 --- a/Sources/Config/ModelCatalogMiMoRuntime.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -@MainActor -extension ModelCatalog { - static func mimoRepositoryIsReady(at dir: URL) -> Bool { - FileManager.default.fileExists( - atPath: dir.appendingPathComponent("src/mimo_audio/mimo_audio.py").path - ) - } -} diff --git a/Sources/Config/ModelStorage.swift b/Sources/Config/ModelStorage.swift index a36b1269..d090bd28 100644 --- a/Sources/Config/ModelStorage.swift +++ b/Sources/Config/ModelStorage.swift @@ -23,22 +23,6 @@ enum ModelStorage { huggingFaceBase.appendingPathComponent("models") } - static var asrRepositoryBase: URL { - root.appendingPathComponent("repositories") - } - - static func mimoASRRepositoryDir() -> URL { - asrRepositoryBase.appendingPathComponent("XiaomiMiMo/MiMo-V2.5-ASR") - } - - static var asrRuntimeBase: URL { - root.appendingPathComponent("runtimes") - } - - static func localASRRuntimeDir(for provider: LocalASRConfiguration.Provider) -> URL { - asrRuntimeBase.appendingPathComponent("\(provider.rawValue)-asr") - } - static func whisperVariantDir(_ variant: String) -> URL { hubModelsBase .appendingPathComponent("argmaxinc/whisperkit-coreml") diff --git a/Sources/Resources/Scripts/local-asr-runner.py b/Sources/Resources/Scripts/local-asr-runner.py deleted file mode 100644 index 84b5c555..00000000 --- a/Sources/Resources/Scripts/local-asr-runner.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import pathlib -import sys - - -def qwen_language(code): - return { - "zh": "Chinese", - "en": "English", - "ja": "Japanese", - "ko": "Korean", - "yue": "Cantonese", - }.get(code) - - -def mimo_tag(code): - return { - "zh": "", - "en": "", - }.get(code) - - -def make_qwen_transcriber(args): - try: - from qwen3_asr_mlx import Qwen3ASR - except ImportError as exc: - raise RuntimeError( - "Unable to import qwen3-asr-mlx or one of its native dependencies: " - f"{exc}" - ) from exc - - model = Qwen3ASR.from_pretrained(args.model) - - def transcribe(audio, language): - kwargs = {} - resolved = qwen_language(language) - if resolved: - kwargs["language"] = resolved - result = model.transcribe(audio, **kwargs) - return {"text": result.text} - - return transcribe - - -def make_mimo_transcriber(args): - if not args.tokenizer: - raise ValueError("MiMo-V2.5-ASR requires --tokenizer") - if args.repo: - sys.path.insert(0, args.repo) - try: - from src.mimo_audio.mimo_audio import MimoAudio - except ImportError as exc: - try: - from mimo_audio.mimo_audio import MimoAudio - except ImportError as fallback_exc: - raise RuntimeError( - "Missing Xiaomi MiMo-V2.5-ASR Python dependencies in the detected " - f"Python environment. Import errors: {exc}; {fallback_exc}" - ) from fallback_exc - - model = MimoAudio(model_path=args.model, mimo_audio_tokenizer_path=args.tokenizer) - - def transcribe(audio, language): - tag = mimo_tag(language) - if tag: - return {"text": model.asr_sft(audio, audio_tag=tag)} - return {"text": model.asr_sft(audio)} - - return transcribe - - -def make_transcriber(args): - if args.provider == "qwen3": - return make_qwen_transcriber(args) - return make_mimo_transcriber(args) - - -def run_once(args): - audio_path = pathlib.Path(args.audio) - if not audio_path.exists(): - raise FileNotFoundError(f"Audio file not found: {audio_path}") - transcribe = make_transcriber(args) - print(json.dumps(transcribe(args.audio, args.language), ensure_ascii=False)) - - -def serve(args): - """Load the model once, then answer one JSON request per stdin line with - one JSON response per stdout line. Exits when stdin closes.""" - transcribe = make_transcriber(args) - print(json.dumps({"ready": True}), flush=True) - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - request = json.loads(line) - audio = request["audio"] - if not pathlib.Path(audio).exists(): - raise FileNotFoundError(f"Audio file not found: {audio}") - response = transcribe(audio, request.get("language")) - except Exception as exc: # keep serving after a bad request - response = {"error": str(exc)} - print(json.dumps(response, ensure_ascii=False), flush=True) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--provider", choices=["qwen3", "mimo"], required=True) - parser.add_argument("--model", required=True) - parser.add_argument("--audio") - parser.add_argument("--language") - parser.add_argument("--tokenizer") - parser.add_argument("--repo") - parser.add_argument("--serve", action="store_true") - args = parser.parse_args() - - if args.serve: - serve(args) - return - if not args.audio: - raise ValueError("--audio is required unless --serve is set") - run_once(args) - - -if __name__ == "__main__": - try: - main() - except Exception as exc: - print(str(exc), file=sys.stderr) - sys.exit(1) diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index 6c67a818..a7ab469f 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -246,7 +246,6 @@ "model.downloads_active" = "%d Active Download(s)"; "model.download_confirm_title" = "Start model download?"; "model.download_confirm_message" = "%@ needs about %@ of network data. Files will be saved to %@. This may incur data charges; the download starts only after you confirm and can be cancelled."; -"model.download_runtime_note" = "A provider-specific runtime may also be downloaded into Utter's managed storage."; "model.delete_confirm_title" = "Delete local model files?"; "model.delete_confirm_message" = "Delete %@ and reclaim %@? The model can be downloaded again later."; "model.remove_reference_confirm_title" = "Remove imported model?"; @@ -261,7 +260,6 @@ "model.download_failed_permission" = "Utter cannot write to the model folder. Check its permissions or choose another storage location."; "model.download_failed_retry" = "The download did not finish. Select Resume; if progress still does not change, cancel and try again."; "model.resume" = "Resume"; -"model.mimo_macos_unavailable" = "Unavailable on macOS: Xiaomi's official runtime currently requires Linux, CUDA, and flash-attn"; "download.elapsed_format" = "Time %@"; "download.progress_format" = "Progress %@"; "download.remaining_format" = "Left %@"; @@ -452,11 +450,6 @@ "error.volc_timeout" = "ASR request timed out"; "error.volc_handshake_rejected" = "Doubao ASR connection was rejected by the server. Check App Key, Access Token, Resource ID, and whether streaming ASR is enabled for this account."; "error.local_asr_not_configured" = "Local ASR is not configured — download the model in Settings → Models"; -"error.local_asr_runner_missing" = "Local ASR runner script is missing from the app bundle"; -"error.local_asr_python_missing" = "Local ASR needs Python 3. Install Python, then try again."; -"error.local_asr_runtime_failed" = "Local ASR runtime setup failed"; -"error.local_asr_process_failed" = "Local ASR failed: %@"; -"error.local_asr_invalid_response" = "Local ASR returned an invalid response"; /* ── Volc ASR ── */ "volc.config_hint" = "Enter your Volcengine Doubao ASR credentials. Get them from the Volcengine console."; @@ -468,20 +461,9 @@ "volc.resource_id" = "Resource ID"; /* ── Local ASR ── */ -"local_asr.python" = "Python executable"; -"local_asr.model_path" = "Model path or Hugging Face ID"; -"local_asr.repo_path" = "MiMo repo path"; -"local_asr.tokenizer_path" = "Audio tokenizer path"; -"qwen_asr.config_hint" = "Choose Qwen3-ASR, then click Download to fetch the local model and prepare its Python runtime."; -"mimo_asr.config_hint" = "Choose MiMo-V2.5-ASR, then click Download to fetch the model, audio tokenizer, and runtime files."; +"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.mimo_asr_quality" = "Local ASR model plus audio tokenizer ~36 GB"; -"model.asr_incomplete" = "Only part of the model or runtime was downloaded. Select Resume to finish."; -"model.asr_preparing_runtime" = "Preparing runtime files"; -"model.asr_installing_runtime" = "Installing local runtime"; -"model.asr_runtime_missing" = "The model files are present, but the runtime is not installed. Select Resume to finish setup."; -"model.asr_runtime_download_failed" = "Runtime files download failed"; -"model.asr_runtime_incomplete" = "Runtime files are incomplete"; +"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."; "onboarding.download_notice" = "No download starts automatically. Review the size, then confirm if you want this local model."; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index a1566d23..d9ce223b 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -246,7 +246,6 @@ "model.downloads_active" = "%d 个下载任务"; "model.download_confirm_title" = "开始下载模型?"; "model.download_confirm_message" = "%@ 预计需要约 %@ 网络流量,文件将保存到 %@。下载可能产生流量费用;只有确认后才会开始,并且可以取消。"; -"model.download_runtime_note" = "对应模型的专用运行环境也可能下载到 Utter 的统一模型目录。"; "model.delete_confirm_title" = "删除本地模型文件?"; "model.delete_confirm_message" = "删除 %@ 并释放 %@ 空间?之后仍可重新下载。"; "model.remove_reference_confirm_title" = "移除导入的模型?"; @@ -261,7 +260,6 @@ "model.download_failed_permission" = "无法写入模型目录。请检查目录权限,或在模型页更换存储位置"; "model.download_failed_retry" = "下载未完成。请点击“继续下载”;若进度仍不变化,可先取消再重试"; "model.resume" = "继续"; -"model.mimo_macos_unavailable" = "macOS 暂不可用:小米官方运行时目前依赖 Linux、CUDA 和 flash-attn"; "download.elapsed_format" = "已用 %@"; "download.progress_format" = "进度 %@"; "download.remaining_format" = "剩余 %@"; @@ -452,11 +450,6 @@ "error.volc_timeout" = "语音识别请求超时"; "error.volc_handshake_rejected" = "豆包语音识别连接被服务端拒绝。请检查 App Key、Access Token、Resource ID,以及账号是否已开通流式语音识别。"; "error.local_asr_not_configured" = "本地语音识别未配置 — 请在 设置 → 模型 中下载模型"; -"error.local_asr_runner_missing" = "本地语音识别脚本未包含在应用包中"; -"error.local_asr_python_missing" = "本地语音识别需要 Python 3,请安装后重试。"; -"error.local_asr_runtime_failed" = "本地语音识别运行环境准备失败"; -"error.local_asr_process_failed" = "本地语音识别失败:%@"; -"error.local_asr_invalid_response" = "本地语音识别返回结果格式异常"; /* ── 豆包语音识别 ── */ "volc.config_hint" = "填写火山引擎豆包语音识别凭据,可从火山引擎控制台获取。"; @@ -468,20 +461,9 @@ "volc.resource_id" = "Resource ID(资源 ID)"; /* ── 本地语音识别 ── */ -"local_asr.python" = "Python 可执行文件"; -"local_asr.model_path" = "模型路径或 Hugging Face ID"; -"local_asr.repo_path" = "MiMo 仓库路径"; -"local_asr.tokenizer_path" = "音频 tokenizer 路径"; -"qwen_asr.config_hint" = "选择 Qwen3-ASR 后,请点击下载按钮获取本地模型,并准备对应的 Python 运行环境。"; -"mimo_asr.config_hint" = "选择 MiMo-V2.5-ASR 后,请点击下载按钮获取模型、音频 tokenizer 和运行文件。"; +"qwen_asr.config_hint" = "Qwen3-ASR 使用原生 Swift 和 MLX 在本机运行。模型下载一次后即可离线识别。"; "model.qwen3_asr_quality" = "本地 MLX 语音识别,约 4.1 GB"; -"model.mimo_asr_quality" = "本地语音识别模型 + 音频 tokenizer,约 36 GB"; -"model.asr_incomplete" = "模型或运行环境只下载了一部分。点击“继续下载”即可接着完成"; -"model.asr_preparing_runtime" = "准备运行文件"; -"model.asr_installing_runtime" = "安装本地运行环境"; -"model.asr_runtime_missing" = "模型文件已下载,但运行环境尚未安装。请点击“继续下载”完成安装"; -"model.asr_runtime_download_failed" = "运行文件下载失败"; -"model.asr_runtime_incomplete" = "运行文件不完整"; +"model.asr_incomplete" = "模型只下载了一部分。点击“继续下载”即可接着完成"; "error.llm_not_loaded" = "模型文件已在本地,但当前尚未加载到内存。请重新执行;Utter 会再次尝试加载"; "error.llm_not_downloaded" = "模型文件尚未下载。请前往 设置 → 模型,确认流量后下载"; "onboarding.download_notice" = "这里不会自动下载。请先确认体积,再决定是否下载这个本地模型。"; diff --git a/Sources/Speech/LocalASREngine.swift b/Sources/Speech/LocalASREngine.swift deleted file mode 100644 index e7b2fa00..00000000 --- a/Sources/Speech/LocalASREngine.swift +++ /dev/null @@ -1,199 +0,0 @@ -import AVFoundation -import Foundation - -struct LocalASRConfiguration: Equatable { - enum Provider: String, Equatable { - case qwen3 - case mimo - } - - static let defaultPythonPath = "python3" - static let qwen3DefaultModel = "mlx-community/Qwen3-ASR-1.7B-bf16" - static let mimoDefaultModel = "XiaomiMiMo/MiMo-V2.5-ASR" - static let mimoTokenizerModel = "XiaomiMiMo/MiMo-Audio-Tokenizer" - static let mimoRepositoryURL = "https://github.com/XiaomiMiMo/MiMo-V2.5-ASR.git" - - let provider: Provider - let pythonPath: String - let modelPath: String - let tokenizerPath: String - let repoPath: String - - var isReady: Bool { - hasRequiredFiles && LocalASRRuntime.isReady(for: provider) - } - - var hasRequiredFiles: Bool { - let hasModel = Self.pathExists(modelPath) - switch provider { - case .qwen3: - return hasModel - case .mimo: - return hasModel && Self.pathExists(tokenizerPath) && Self.pathExists(repoPath) - } - } - - private static func pathExists(_ path: String) -> Bool { - let normalized = path.trimmingCharacters(in: .whitespacesAndNewlines) - return !normalized.isEmpty && FileManager.default.fileExists(atPath: normalized) - } - - static func resolvePythonPath(preferredPath: String = "") -> String? { - let preferred = preferredPath.trimmingCharacters(in: .whitespacesAndNewlines) - var commands = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"] - if !preferred.isEmpty && preferred != defaultPythonPath { - commands.insert(preferred, at: 0) - } - for command in commands { - if let path = resolvePythonCandidate(command) { return path } - } - return nil - } - - private static func resolvePythonCandidate(_ candidate: String) -> String? { - guard !candidate.isEmpty else { return nil } - if candidate.contains("/") { - let expanded = NSString(string: candidate).expandingTildeInPath - return isUsablePython(at: expanded) ? expanded : nil - } - return findExecutable(named: candidate).first(where: { isUsablePython(at: $0) }) - } - - private static func findExecutable(named name: String) -> [String] { - let envPath = ProcessInfo.processInfo.environment["PATH"] ?? "" - let envDirs = envPath.split(separator: ":").map(String.init) - let commonDirs = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] - let localDirs = [ - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin").path - ] - var seen = Set() - return (envDirs + localDirs + commonDirs).compactMap { dir in - let path = URL(fileURLWithPath: dir).appendingPathComponent(name).path - guard seen.insert(path).inserted else { return nil } - return FileManager.default.isExecutableFile(atPath: path) ? path : nil - } - } - - private static func isUsablePython(at path: String) -> Bool { - guard FileManager.default.isExecutableFile(atPath: path) else { return false } - let process = Process() - let stdout = Pipe() - let stderr = Pipe() - process.executableURL = URL(fileURLWithPath: path) - process.arguments = ["--version"] - process.standardOutput = stdout - process.standardError = stderr - do { - try process.run() - process.waitUntilExit() - } catch { - return false - } - guard process.terminationStatus == 0 else { return false } - let out = stdout.fileHandleForReading.readDataToEndOfFile() - let err = stderr.fileHandleForReading.readDataToEndOfFile() - let version = (String(data: out + err, encoding: .utf8) ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let parsed = parsePythonVersion(version) else { return false } - return parsed.major == 3 && parsed.minor >= 10 - } - - private static func parsePythonVersion(_ output: String) -> (major: Int, minor: Int)? { - let parts = output - .replacingOccurrences(of: "Python ", with: "") - .split(separator: ".") - guard parts.count >= 2, - let major = Int(parts[0]), - let minor = Int(parts[1]) else { return nil } - return (major, minor) - } - - var logName: String { - switch provider { - case .qwen3: return "Qwen3ASR" - case .mimo: return "MiMoASR" - } - } -} - -final class LocalASREngine: SpeechEngine, @unchecked Sendable { - private let configuration: LocalASRConfiguration - private let server: LocalASRServer - - init(configuration: LocalASRConfiguration) { - self.configuration = configuration - self.server = LocalASRServer(configuration: configuration) - } - - var isReady: Bool { configuration.isReady } - - /// Starts the resident runner (loading the model once) so the first - /// utterance doesn't pay the multi-second cold start. - func prepare() async { - guard configuration.hasRequiredFiles, - let runnerURL = Self.runnerScriptURL(), - let pythonPath = try? LocalASRRuntime.pythonPath(for: configuration.provider) else { - return - } - await server.warmUp(runnerURL: runnerURL, pythonPath: pythonPath) - } - - func transcribe(audioURL: URL?, language: String?) async throws -> String { - guard configuration.hasRequiredFiles else { throw LocalASRError.notConfigured } - let pythonPath = try LocalASRRuntime.pythonPath(for: configuration.provider) - guard let audioURL else { throw LocalASRError.noAudioFile } - guard let runnerURL = Self.runnerScriptURL() else { throw LocalASRError.runnerMissing } - - let started = CFAbsoluteTimeGetCurrent() - let text: String - switch configuration.provider { - case .qwen3: - text = try await QwenAudioPreprocessor.withPreparedAudio(from: audioURL) { preparedURL in - try await server.transcribe( - audioURL: preparedURL, - language: language, - runnerURL: runnerURL, - pythonPath: pythonPath - ) - } - case .mimo: - text = try await server.transcribe( - audioURL: audioURL, - language: language, - runnerURL: runnerURL, - pythonPath: pythonPath - ) - } - let elapsed = CFAbsoluteTimeGetCurrent() - started - Log.info("[\(configuration.logName)] transcribed \(text.count) chars locally in \(String(format: "%.1f", elapsed))s") - return text - } - - private static func runnerScriptURL() -> URL? { - AppResources.bundle.url( - forResource: "local-asr-runner", - withExtension: "py", - subdirectory: "Scripts" - ) - } -} - -enum LocalASRError: LocalizedError { - case notConfigured - case noAudioFile - case runnerMissing - case pythonMissing - case processFailed(String) - case invalidResponse - - var errorDescription: String? { - switch self { - case .notConfigured: return L("error.local_asr_not_configured") - case .noAudioFile: return L("error.no_audio") - case .runnerMissing: return L("error.local_asr_runner_missing") - case .pythonMissing: return L("error.local_asr_python_missing") - case .processFailed(let message): return String(format: L("error.local_asr_process_failed"), message) - case .invalidResponse: return L("error.local_asr_invalid_response") - } - } -} diff --git a/Sources/Speech/LocalASRRuntime.swift b/Sources/Speech/LocalASRRuntime.swift deleted file mode 100644 index 2171f498..00000000 --- a/Sources/Speech/LocalASRRuntime.swift +++ /dev/null @@ -1,279 +0,0 @@ -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" - - static func availability( - for provider: LocalASRConfiguration.Provider - ) -> LocalASRRuntimeAvailability { - switch provider { - case .qwen3: - return .supported - case .mimo: - return .unavailable(L("model.mimo_macos_unavailable")) - } - } - - static func isReady(for provider: LocalASRConfiguration.Provider) -> Bool { - switch provider { - case .qwen3: - let python = qwenPythonURL() - return FileManager.default.isExecutableFile(atPath: python.path) && - qwenMarkerIsCurrent(at: qwenMarkerURL()) && - qwenMarkerIsCurrent(at: qwenNativeMarkerURL()) - case .mimo: - return false - } - } - - static func pythonPath(for provider: LocalASRConfiguration.Provider) throws -> String { - guard case .supported = availability(for: provider) else { - throw LocalASRRuntimeError.unsupported(L("model.mimo_macos_unavailable")) - } - guard isReady(for: provider) else { - throw LocalASRRuntimeError.notInstalled - } - return qwenPythonURL().path - } - - /// Installs a managed runtime. Call only from an explicit, user-confirmed - /// model download or repair action. - static func ensurePythonPath( - for provider: LocalASRConfiguration.Provider, - preferredPath: String - ) async throws -> String { - switch provider { - case .qwen3: - return try await ensureQwenRuntime(preferredPath: preferredPath) - case .mimo: - throw LocalASRRuntimeError.unsupported(L("model.mimo_macos_unavailable")) - } - } - - private static func ensureQwenRuntime(preferredPath: String) async throws -> String { - let runtimeDir = ModelStorage.localASRRuntimeDir(for: .qwen3) - let runtimePython = qwenPythonURL().path - if isReady(for: .qwen3) { return runtimePython } - - 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 writeCurrentQwenMarker(to: qwenMarkerURL()) - try writeCurrentQwenMarker(to: qwenNativeMarkerURL()) - return runtimePython - } - - guard let builderPython = LocalASRConfiguration.resolvePythonPath(preferredPath: preferredPath) else { - throw LocalASRRuntimeError.pythonMissing - } - - try? FileManager.default.removeItem(at: runtimeDir) - try FileManager.default.createDirectory( - at: runtimeDir.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - - try await runProcess(executable: builderPython, arguments: ["-m", "venv", runtimeDir.path]) - try await runProcess( - executable: runtimePython, - arguments: ["-m", "pip", "install", "--quiet", "--upgrade", "pip", "setuptools", "wheel"] - ) - try await installQwenPackage(using: runtimePython) - try await prepareNativeExtensions(in: runtimeDir) - try await runProcess( - executable: runtimePython, - arguments: ["-c", "import \(qwenImport)"] - ) - 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.localASRRuntimeDir(for: .qwen3).appendingPathComponent("bin/python") - } - - private static func qwenMarkerURL() -> URL { - ModelStorage.localASRRuntimeDir(for: .qwen3).appendingPathComponent(markerName) - } - - private static func qwenNativeMarkerURL() -> URL { - ModelStorage.localASRRuntimeDir(for: .qwen3).appendingPathComponent(nativeMarkerName) - } - - private static func prepareNativeExtensions(in runtimeDir: URL) async throws { - let nativeExtensions = nativeExtensionURLs(in: runtimeDir) - for url in nativeExtensions { - try? await runProcess( - executable: "/usr/bin/xattr", - arguments: ["-d", "com.apple.quarantine", url.path] - ) - try? await runProcess( - executable: "/usr/bin/xattr", - arguments: ["-d", "com.apple.provenance", url.path] - ) - try await runProcess( - executable: "/usr/bin/codesign", - arguments: ["-s", "-", "-f", url.path] - ) - } - } - - private static func nativeExtensionURLs(in runtimeDir: URL) -> [URL] { - guard let enumerator = FileManager.default.enumerator( - at: runtimeDir, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { - return [] - } - - return enumerator.compactMap { item in - guard let url = item as? URL else { return nil } - guard ["so", "dylib"].contains(url.pathExtension) else { return nil } - let values = try? url.resourceValues(forKeys: [.isRegularFileKey]) - return values?.isRegularFile == true ? url : nil - } - } - - private static func runProcess(executable: String, arguments: [String]) async throws { - let cancellation = ProcessCancellation() - try await withTaskCancellationHandler { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let process = Process() - let stderr = Pipe() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") - process.standardError = stderr - process.terminationHandler = { process in - let data = stderr.fileHandleForReading.readDataToEndOfFile() - let message = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - cancellation.clear(process) - if cancellation.isCancelled { - continuation.resume(throwing: CancellationError()) - } else if process.terminationStatus != 0 { - continuation.resume(throwing: LocalASRRuntimeError.processFailed(message ?? "")) - } else { - continuation.resume(returning: ()) - } - } - cancellation.register(process) - do { - try process.run() - cancellation.terminateIfCancelled() - } catch { - cancellation.clear(process) - continuation.resume(throwing: error) - } - } - } onCancel: { - cancellation.cancel() - } - } -} - -private final class ProcessCancellation: @unchecked Sendable { - private let lock = NSLock() - private var process: Process? - private var wasCancelled = false - - var isCancelled: Bool { - lock.lock() - defer { lock.unlock() } - return wasCancelled - } - - func register(_ process: Process) { - lock.lock() - self.process = process - lock.unlock() - } - - func clear(_ process: Process) { - lock.lock() - if self.process === process { - self.process = nil - } - lock.unlock() - } - - func cancel() { - lock.lock() - wasCancelled = true - let process = self.process - lock.unlock() - if process?.isRunning == true { - process?.terminate() - } - } - - func terminateIfCancelled() { - lock.lock() - let shouldTerminate = wasCancelled - let process = self.process - lock.unlock() - if shouldTerminate, process?.isRunning == true { - process?.terminate() - } - } -} - -enum LocalASRRuntimeAvailability: Equatable { - case supported - case unavailable(String) -} - -enum LocalASRRuntimeError: LocalizedError { - case pythonMissing - case notInstalled - case unsupported(String) - case processFailed(String) - - var errorDescription: String? { - switch self { - case .pythonMissing: - return L("error.local_asr_python_missing") - case .notInstalled: - return L("model.asr_runtime_missing") - case .unsupported(let message): - return message - case .processFailed(let message): - return message.isEmpty ? L("error.local_asr_runtime_failed") : message - } - } -} diff --git a/Sources/Speech/LocalASRServer.swift b/Sources/Speech/LocalASRServer.swift deleted file mode 100644 index ae9c52d2..00000000 --- a/Sources/Speech/LocalASRServer.swift +++ /dev/null @@ -1,273 +0,0 @@ -import Foundation - -enum LocalASRServerResponse: Equatable { - case ready - case text(String) - case error(String) - - /// Serve mode prints exactly one JSON object per line; model libraries can - /// still emit stray progress lines on stdout, which parse to nil and are - /// skipped by the reader. - static func parse(line: String) -> LocalASRServerResponse? { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("{"), - let data = trimmed.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - if let text = object["text"] as? String { - return .text(normalizeTranscriptText(text)) - } - if let message = object["error"] as? String { - return .error(message) - } - if object["ready"] as? Bool == true { - return .ready - } - return nil - } - - static func normalizeTranscriptText(_ text: String) -> String { - let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) - let compact = normalized.replacingOccurrences( - of: "\\s+", - with: "", - options: .regularExpression - ) - let noSpeechPlaceholders: Set = ["(无)", "(无)", "【无】", "[无]"] - return noSpeechPlaceholders.contains(compact) ? "" : normalized - } -} - -/// Keeps one `local-asr-runner.py --serve` process alive so the ASR model is -/// loaded once instead of on every utterance (which cost 2s+ per recording). -actor LocalASRServer { - private let configuration: LocalASRConfiguration - private var process: Process? - private var requestWriter: FileHandle? - private var responseLines: AsyncLineSequence.AsyncIterator? - private var currentRequest: Task? - private var idleShutdownTask: Task? - - private static let readyTimeout: TimeInterval = 300 - private static let requestTimeout: TimeInterval = 180 - private static let idleShutdownInterval: TimeInterval = 20 * 60 - - init(configuration: LocalASRConfiguration) { - self.configuration = configuration - } - - deinit { - if let process, process.isRunning { - process.terminationHandler = nil - process.terminate() - } - } - - func warmUp(runnerURL: URL, pythonPath: String) async { - do { - try await ensureServer(runnerURL: runnerURL, pythonPath: pythonPath) - scheduleIdleShutdown() - } catch { - Log.error("[LocalASRServer] \(configuration.logName) warm-up failed: \(error.localizedDescription)") - } - } - - func transcribe( - audioURL: URL, - language: String?, - runnerURL: URL, - pythonPath: String - ) async throws -> String { - while let running = currentRequest { - _ = try? await running.value - } - let request = Task { - try await self.performRequest( - audioURL: audioURL, - language: language, - runnerURL: runnerURL, - pythonPath: pythonPath - ) - } - currentRequest = request - defer { - currentRequest = nil - scheduleIdleShutdown() - } - return try await request.value - } - - private func performRequest( - audioURL: URL, - language: String?, - runnerURL: URL, - pythonPath: String - ) async throws -> String { - try await ensureServer(runnerURL: runnerURL, pythonPath: pythonPath) - guard let requestWriter else { - throw LocalASRError.processFailed("local ASR server is unavailable") - } - - var payload: [String: Any] = ["audio": audioURL.path] - if let language { - payload["language"] = language - } - let data = try JSONSerialization.data(withJSONObject: payload) - do { - try requestWriter.write(contentsOf: data + Data("\n".utf8)) - } catch { - shutdown() - throw LocalASRError.processFailed("could not reach the local ASR server") - } - - switch try await nextResponse(timeout: Self.requestTimeout) { - case .text(let text): - return text - case .error(let message): - throw LocalASRError.processFailed(message) - case .ready: - shutdown() - throw LocalASRError.invalidResponse - } - } - - private func ensureServer(runnerURL: URL, pythonPath: String) async throws { - if let process, process.isRunning, requestWriter != nil { return } - - shutdown() - let process = Process() - let stdin = Pipe() - let stdout = Pipe() - let stderr = Pipe() - process.executableURL = URL(fileURLWithPath: pythonPath) - process.arguments = serverArguments(runnerURL: runnerURL) - process.standardInput = stdin - process.standardOutput = stdout - process.standardError = stderr - - // Drain stderr so the child never blocks on a full pipe. - let logName = configuration.logName - stderr.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - let message = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !message.isEmpty { - Log.info("[LocalASRServer] \(logName) stderr: \(message.prefix(400))") - } - } - - let startedAt = CFAbsoluteTimeGetCurrent() - try process.run() - self.process = process - self.requestWriter = stdin.fileHandleForWriting - self.responseLines = stdout.fileHandleForReading.bytes.lines.makeAsyncIterator() - - let response = try await nextResponse(timeout: Self.readyTimeout) - guard response == .ready else { - shutdown() - if case .error(let message) = response { - throw LocalASRError.processFailed(message) - } - throw LocalASRError.invalidResponse - } - let elapsed = CFAbsoluteTimeGetCurrent() - startedAt - Log.info("[LocalASRServer] \(configuration.logName) server ready in \(String(format: "%.1f", elapsed))s") - } - - private func serverArguments(runnerURL: URL) -> [String] { - var args = [ - runnerURL.path, - "--provider", configuration.provider.rawValue, - "--model", configuration.modelPath, - "--serve", - ] - if !configuration.tokenizerPath.isEmpty { - args += ["--tokenizer", configuration.tokenizerPath] - } - if !configuration.repoPath.isEmpty { - args += ["--repo", configuration.repoPath] - } - return args - } - - private func nextResponse(timeout: TimeInterval) async throws -> LocalASRServerResponse { - do { - while true { - guard let line = try await withTimeout(timeout, operation: { [weak self] in - try await self?.readResponseLine() - }) ?? nil else { - shutdown() - throw LocalASRError.processFailed("local ASR server exited unexpectedly") - } - if let response = LocalASRServerResponse.parse(line: line) { - return response - } - } - } catch let error as LocalASRTimeout { - let _ = error - shutdown() - throw LocalASRError.processFailed("local ASR server timed out") - } - } - - private func readResponseLine() async throws -> String? { - guard var iterator = responseLines else { return nil } - let line = try await iterator.next() - responseLines = iterator - return line - } - - private func withTimeout( - _ seconds: TimeInterval, - operation: @escaping @Sendable () async throws -> T - ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw LocalASRTimeout() - } - guard let result = try await group.next() else { - throw LocalASRTimeout() - } - group.cancelAll() - return result - } - } - - private func scheduleIdleShutdown() { - idleShutdownTask?.cancel() - idleShutdownTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.idleShutdownInterval * 1_000_000_000)) - guard !Task.isCancelled else { return } - await self?.shutdownIfIdle() - } - } - - private func shutdownIfIdle() { - guard currentRequest == nil else { return } - guard process != nil else { return } - Log.info("[LocalASRServer] shutting down idle \(configuration.logName) server") - shutdown() - } - - private func shutdown() { - idleShutdownTask?.cancel() - idleShutdownTask = nil - if let process, process.isRunning { - process.terminationHandler = nil - process.terminate() - } - process = nil - try? requestWriter?.close() - requestWriter = nil - responseLines = nil - } -} - -private struct LocalASRTimeout: Error {} diff --git a/Sources/Speech/QwenASRModel.swift b/Sources/Speech/QwenASRModel.swift new file mode 100644 index 00000000..6698c703 --- /dev/null +++ b/Sources/Speech/QwenASRModel.swift @@ -0,0 +1,3 @@ +enum QwenASRModel { + static let defaultID = "mlx-community/Qwen3-ASR-1.7B-bf16" +} diff --git a/Sources/Speech/QwenNativeASREngine.swift b/Sources/Speech/QwenNativeASREngine.swift new file mode 100644 index 00000000..b4bae3ec --- /dev/null +++ b/Sources/Speech/QwenNativeASREngine.swift @@ -0,0 +1,128 @@ +import Foundation +import MLXAudioCore +import MLXAudioSTT + +final class QwenNativeASREngine: SpeechEngine, @unchecked Sendable { + private let modelDirectory: URL + private let runtime = QwenNativeASRRuntime() + + init(modelPath: String) { + modelDirectory = URL(fileURLWithPath: modelPath).standardizedFileURL + } + + var isReady: Bool { + Self.modelDirectoryIsReady(modelDirectory) + } + + func usesModel(at modelPath: String) -> Bool { + modelDirectory == URL(fileURLWithPath: modelPath).standardizedFileURL + } + + func prepare() async { + guard isReady else { return } + do { + try await runtime.prepare(modelDirectory: modelDirectory) + } catch { + Log.error("[Qwen3ASRNative] model warm-up failed: \(error.localizedDescription)") + } + } + + func transcribe(audioURL: URL?, language: String?) async throws -> String { + guard isReady else { throw QwenNativeASRError.notConfigured } + guard let audioURL else { throw QwenNativeASRError.noAudioFile } + + let started = CFAbsoluteTimeGetCurrent() + let result = try await QwenAudioPreprocessor.withPreparedAudio(from: audioURL) { preparedURL in + try await runtime.transcribe( + audioURL: preparedURL, + modelDirectory: modelDirectory, + language: language + ) + } + let elapsed = CFAbsoluteTimeGetCurrent() - started + Log.info( + "[Qwen3ASRNative] 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 + } + + static func modelDirectoryIsReady(_ directory: URL) -> Bool { + [ + "config.json", + "model.safetensors", + "tokenizer_config.json", + "vocab.json", + "merges.txt", + ].allSatisfy { relativePath in + let url = directory.appendingPathComponent(relativePath) + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + return size > 0 + } + } +} + +enum QwenNativeASRError: LocalizedError { + case notConfigured + case noAudioFile + + var errorDescription: String? { + switch self { + case .notConfigured: return L("error.local_asr_not_configured") + case .noAudioFile: return L("error.no_audio") + } + } +} + +private actor QwenNativeASRRuntime { + struct Result { + let text: String + let modelTime: Double + let peakMemoryGB: Double + } + + private var model: Qwen3ASRModel? + private var loadedDirectory: URL? + + func prepare(modelDirectory: URL) async throws { + _ = try await loadModel(from: modelDirectory) + } + + func transcribe( + audioURL: URL, + modelDirectory: URL, + language: String? + ) async throws -> Result { + try Task.checkCancellation() + let model = try await loadModel(from: modelDirectory) + let (sampleRate, audio) = try loadAudioArray( + from: audioURL, + sampleRate: Int(QwenAudioPreprocessor.sampleRate) + ) + guard sampleRate == Int(QwenAudioPreprocessor.sampleRate) else { + throw QwenAudioPreprocessorError.conversionFailed + } + + let output = model.generate(audio: audio, language: language) + try Task.checkCancellation() + return Result( + text: output.text, + modelTime: output.totalTime, + peakMemoryGB: output.peakMemoryUsage + ) + } + + private func loadModel(from directory: URL) async throws -> Qwen3ASRModel { + let standardizedDirectory = directory.standardizedFileURL + if let model, loadedDirectory == standardizedDirectory { + return model + } + + let loaded = try await Qwen3ASRModel.fromModelDirectory(standardizedDirectory) + model = loaded + loadedDirectory = standardizedDirectory + Log.info("[Qwen3ASRNative] loaded existing model from \(standardizedDirectory.path)") + return loaded + } +} diff --git a/Sources/Speech/SpeechEngineProvider.swift b/Sources/Speech/SpeechEngineProvider.swift index 53eb96b7..aa7863a7 100644 --- a/Sources/Speech/SpeechEngineProvider.swift +++ b/Sources/Speech/SpeechEngineProvider.swift @@ -5,8 +5,7 @@ final class SpeechEngineProvider { private var whisperEngine: WhisperEngine? private var appleSpeechEngine: AppleSpeechEngine? private var volcSpeechEngine: VolcSpeechEngine? - private var qwenSpeechEngine: LocalASREngine? - private var mimoSpeechEngine: LocalASREngine? + private var qwenSpeechEngine: QwenNativeASREngine? func engine(settings: AppSettings, requestPermission: Bool = true) async -> (any SpeechEngine)? { await ensureEngineLoaded(settings: settings, requestPermission: requestPermission) @@ -19,7 +18,7 @@ final class SpeechEngineProvider { case .apple: return appleSpeechEngine case .volc: return volcSpeechEngine case .qwen3: return qwenSpeechEngine - case .mimo: return mimoSpeechEngine + case .mimo: return nil } } @@ -48,26 +47,12 @@ final class SpeechEngineProvider { Log.info("[SpeechEngineProvider] Qwen ASR model requires manual download: \(settings.qwenASRModel)") return } - qwenSpeechEngine = LocalASREngine(configuration: LocalASRConfiguration( - provider: .qwen3, - pythonPath: settings.localASRPythonPath, - modelPath: ModelCatalog.shared.asrModelPath(for: settings.qwenASRModel), - tokenizerPath: "", - repoPath: "" - )) + let modelPath = ModelCatalog.shared.asrModelPath(for: settings.qwenASRModel) + if qwenSpeechEngine?.usesModel(at: modelPath) == true { return } + qwenSpeechEngine = QwenNativeASREngine(modelPath: modelPath) case .mimo: - guard localASRIsAvailable(settings.mimoASRModel) else { - mimoSpeechEngine = nil - Log.info("[SpeechEngineProvider] MiMo ASR model requires manual download: \(settings.mimoASRModel)") - return - } - mimoSpeechEngine = LocalASREngine(configuration: LocalASRConfiguration( - provider: .mimo, - pythonPath: settings.localASRPythonPath, - modelPath: ModelCatalog.shared.asrModelPath(for: settings.mimoASRModel), - tokenizerPath: ModelCatalog.shared.mimoTokenizerPath(), - repoPath: ModelCatalog.shared.mimoRepositoryPath() - )) + settings.speechEngine = .apple + await ensureEngineLoaded(settings: settings, requestPermission: requestPermission) } } diff --git a/Sources/UI/ModelManagementActions.swift b/Sources/UI/ModelManagementActions.swift index 742ae070..8d6f272a 100644 --- a/Sources/UI/ModelManagementActions.swift +++ b/Sources/UI/ModelManagementActions.swift @@ -161,16 +161,12 @@ extension ModelManagementView { let estimate = estimatedDownloadBytes(for: action.model, type: action.type) let remaining = estimate.map { max($0 - action.model.cacheSize, 0) } let sizeText = remaining.map(ModelCatalog.formatBytes) ?? L("download.unknown") - var message = String( + return String( format: L("model.download_confirm_message"), action.model.displayName, sizeText, ModelStorage.root.path ) - if action.type == .asr { - message += "\n\n" + L("model.download_runtime_note") - } - return message } private func estimatedDownloadBytes( diff --git a/Sources/UI/ModelManagementEnginePicker.swift b/Sources/UI/ModelManagementEnginePicker.swift index f920c518..3de2f105 100644 --- a/Sources/UI/ModelManagementEnginePicker.swift +++ b/Sources/UI/ModelManagementEnginePicker.swift @@ -19,7 +19,7 @@ extension ModelManagementView { private var speechEnginePicker: some View { HStack(spacing: 0) { - ForEach(SpeechEngineType.allCases, id: \.self) { engine in + ForEach(SpeechEngineType.selectableCases, id: \.self) { engine in speechEngineButton(engine) } } diff --git a/Sources/UI/ModelManagementRows.swift b/Sources/UI/ModelManagementRows.swift index 0a3f739f..aba22524 100644 --- a/Sources/UI/ModelManagementRows.swift +++ b/Sources/UI/ModelManagementRows.swift @@ -188,10 +188,7 @@ extension ModelManagementView { case .llm: return ModelStorage.localLLMURL(model.id) == nil case .asr: - if case .supported = catalog.asrRuntimeAvailability(for: model.id) { - return true - } - return false + return true } } @@ -210,14 +207,8 @@ extension ModelManagementView { onLoadLLM?() case .asr: onUnloadLocalASR?() - switch catalog.asrProvider(for: model.id) { - case .qwen3: - settings.qwenASRModel = model.id - case .mimo: - settings.mimoASRModel = model.id - case nil: - break - } + settings.qwenASRModel = model.id + settings.speechEngine = .qwen3 } } diff --git a/Sources/UI/ModelManagementSections.swift b/Sources/UI/ModelManagementSections.swift index 46b928e7..51d37939 100644 --- a/Sources/UI/ModelManagementSections.swift +++ b/Sources/UI/ModelManagementSections.swift @@ -94,20 +94,6 @@ extension ModelManagementView { } } - var mimoASRSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text(L("mimo_asr.config_hint")) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - - modelList( - catalog.asrModels(for: .mimo), - activeID: settings.mimoASRModel, - type: .asr - ) - } - } - var llmSection: some View { VStack(alignment: .leading, spacing: 12) { Label(L("model.text_formatting"), systemImage: "brain") diff --git a/Sources/UI/ModelManagementView.swift b/Sources/UI/ModelManagementView.swift index 64fe0c1c..6af2ae70 100644 --- a/Sources/UI/ModelManagementView.swift +++ b/Sources/UI/ModelManagementView.swift @@ -55,10 +55,7 @@ struct ModelManagementView: View { syncSelectedFamilyFromActiveModel() } .onChange(of: settings.llmModel) { _, _ in syncSelectedFamilyFromActiveModel() } - .onChange(of: settings.localASRPythonPath) { _, _ in onUnloadLocalASR?() } - .onChange(of: settings.mimoASRRepoPath) { _, _ in onUnloadLocalASR?() } .onChange(of: settings.qwenASRModel) { _, _ in onUnloadLocalASR?() } - .onChange(of: settings.mimoASRModel) { _, _ in onUnloadLocalASR?() } .alert(item: $pendingModelAction, content: modelActionAlert) } @@ -72,7 +69,9 @@ struct ModelManagementView: View { case .qwen3: qwenASRSection case .mimo: - mimoASRSection + Text(L("model.apple_managed_by_system")) + .font(.caption) + .foregroundStyle(.secondary) case .apple: Text(L("model.apple_managed_by_system")) .font(.caption) diff --git a/Tests/OpenTypeTests/ConfigurationTests.swift b/Tests/OpenTypeTests/ConfigurationTests.swift index be79b797..ec5d0e6f 100644 --- a/Tests/OpenTypeTests/ConfigurationTests.swift +++ b/Tests/OpenTypeTests/ConfigurationTests.swift @@ -53,43 +53,54 @@ final class ConfigurationTests: XCTestCase { XCTAssertEqual(InputLanguage.cantonese.localeIdentifier, "zh-HK") } - func testSpeechEngineCasesIncludeLocalASRProviders() { + func testSpeechEngineCasesIncludeNativeLocalEngines() { XCTAssertEqual(SpeechEngineType.allCases.map(\.rawValue), [ "whisper", "apple", "volc", "qwen3", "mimo", ]) + XCTAssertEqual(SpeechEngineType.selectableCases.map(\.rawValue), [ + "whisper", "apple", "volc", "qwen3", + ]) } - func testLocalASRDefaultsMatchOnDeviceRunner() { - XCTAssertEqual(LocalASRConfiguration.defaultPythonPath, "python3") - XCTAssertEqual(LocalASRConfiguration.qwen3DefaultModel, "mlx-community/Qwen3-ASR-1.7B-bf16") - XCTAssertEqual(LocalASRConfiguration.mimoDefaultModel, "XiaomiMiMo/MiMo-V2.5-ASR") - XCTAssertEqual(LocalASRConfiguration.mimoTokenizerModel, "XiaomiMiMo/MiMo-Audio-Tokenizer") + func testQwenASRDefaultUsesNativeCompatibleModel() { + XCTAssertEqual(QwenASRModel.defaultID, "mlx-community/Qwen3-ASR-1.7B-bf16") } - @MainActor - func testASRCompletenessRequiresWeightFiles() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - try writeTestFiles(["config.json", "tokenizer_config.json", "vocab.json"], under: dir) - XCTAssertFalse(ModelCatalog.asrRepoContainsRequiredFiles(LocalASRConfiguration.qwen3DefaultModel, at: dir)) + func testDisabledMiMoSelectionMigratesToAppleSpeechAndRemovesLegacyRuntimeSettings() { + let (defaults, suiteName) = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set("mimo", forKey: "speechEngine") + defaults.set("/usr/bin/python3", forKey: "localASRPythonPath") + defaults.set("/tmp/mimo", forKey: "mimoASRRepoPath") + defaults.set("XiaomiMiMo/MiMo-V2.5-ASR", forKey: "mimoASRModel") + defaults.set("/tmp/mimo-model", forKey: "mimoASRModelPath") + defaults.set("/tmp/mimo-tokenizer", forKey: "mimoASRTokenizerPath") - try writeTestFiles(ModelCatalog.asrRequiredFiles(for: LocalASRConfiguration.qwen3DefaultModel), under: dir) - XCTAssertTrue(ModelCatalog.asrRepoContainsRequiredFiles(LocalASRConfiguration.qwen3DefaultModel, at: dir)) + let settings = AppSettings(defaults: defaults) + XCTAssertEqual(settings.speechEngine, .apple) + XCTAssertEqual(defaults.string(forKey: "speechEngine"), SpeechEngineType.apple.rawValue) + for key in [ + "localASRPythonPath", + "mimoASRRepoPath", + "mimoASRModel", + "mimoASRModelPath", + "mimoASRTokenizerPath", + ] { + XCTAssertNil(defaults.object(forKey: key)) + } } @MainActor - func testMiMoRepositoryReadinessRequiresRunnerSource() throws { + func testASRCompletenessRequiresWeightFiles() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } - XCTAssertFalse(ModelCatalog.mimoRepositoryIsReady(at: dir)) - let runner = dir.appendingPathComponent("src/mimo_audio/mimo_audio.py") - try FileManager.default.createDirectory(at: runner.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data("x".utf8).write(to: runner) - XCTAssertTrue(ModelCatalog.mimoRepositoryIsReady(at: dir)) + try writeTestFiles(["config.json", "tokenizer_config.json", "vocab.json"], under: dir) + XCTAssertFalse(ModelCatalog.asrRepoContainsRequiredFiles(QwenASRModel.defaultID, at: dir)) + + try writeTestFiles(ModelCatalog.asrRequiredFiles(for: QwenASRModel.defaultID), under: dir) + XCTAssertTrue(ModelCatalog.asrRepoContainsRequiredFiles(QwenASRModel.defaultID, at: dir)) } func testAudioCaptureActivityDetectsSilence() { @@ -162,10 +173,9 @@ final class ConfigurationTests: XCTestCase { } @MainActor - func testLocalASRModelsRemainListed() { + func testOnlyReleasedNativeASRModelsAreListed() { let models = ModelCatalog.defaultASRModels - XCTAssertTrue(models.contains { $0.id == LocalASRConfiguration.qwen3DefaultModel && $0.provider == .qwen3 }) - XCTAssertTrue(models.contains { $0.id == LocalASRConfiguration.mimoDefaultModel && $0.provider == .mimo }) + XCTAssertEqual(models.map(\.id), [QwenASRModel.defaultID]) } func testUILanguageDisplayNames() { @@ -288,7 +298,7 @@ final class ConfigurationTests: XCTestCase { XCTAssertFalse(StartupModelPreloadPolicy.shouldPreloadSpeechModel( enabled: true, speechEngine: .whisper, modelDownloaded: false )) - for engine in [SpeechEngineType.apple, .volc, .qwen3, .mimo] { + for engine in [SpeechEngineType.apple, .volc, .qwen3] { XCTAssertFalse(StartupModelPreloadPolicy.shouldPreloadSpeechModel( enabled: true, speechEngine: engine, modelDownloaded: true )) @@ -331,13 +341,6 @@ final class ConfigurationTests: XCTestCase { )) } - func testLocalASRRuntimeCapabilityDoesNotTreatSystemPythonAsMiMoRuntime() { - XCTAssertEqual( - LocalASRRuntime.availability(for: .mimo), - .unavailable(L("model.mimo_macos_unavailable")) - ) - XCTAssertFalse(LocalASRRuntime.isReady(for: .mimo)) - } } private func writeTestFiles(_ paths: [String], under dir: URL) throws { diff --git a/Tests/OpenTypeTests/LocalASRRuntimeIntegrationTests.swift b/Tests/OpenTypeTests/LocalASRRuntimeIntegrationTests.swift deleted file mode 100644 index 4e2d247b..00000000 --- a/Tests/OpenTypeTests/LocalASRRuntimeIntegrationTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -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/LocalASRTranscriptOutputTests.swift b/Tests/OpenTypeTests/LocalASRTranscriptOutputTests.swift deleted file mode 100644 index 2c27a4e4..00000000 --- a/Tests/OpenTypeTests/LocalASRTranscriptOutputTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import XCTest -@testable import OpenType - -/// The resident local ASR runner answers one JSON object per line: -/// {"text": …}, {"error": …}, or the {"ready": true} handshake. -/// These tests lock in that per-line contract. -final class LocalASRTranscriptOutputTests: XCTestCase { - func testParsesTextResponseLine() { - XCTAssertEqual( - LocalASRServerResponse.parse(line: #"{"text":" 你好,OpenType。 "}"#), - .text("你好,OpenType。") - ) - XCTAssertEqual( - LocalASRServerResponse.parse( - line: #"{"text": "那个,我想问一下这个接口。", "language": "Chinese", "duration": 6.34}"# - ), - .text("那个,我想问一下这个接口。") - ) - } - - func testParsesReadyHandshakeAndErrorLines() { - XCTAssertEqual(LocalASRServerResponse.parse(line: #"{"ready": true}"#), .ready) - XCTAssertEqual( - LocalASRServerResponse.parse(line: #"{"error": "Audio file not found: /tmp/x.wav"}"#), - .error("Audio file not found: /tmp/x.wav") - ) - } - - func testSkipsStrayStdoutLines() { - XCTAssertNil(LocalASRServerResponse.parse(line: "Loading local ASR model...")) - XCTAssertNil(LocalASRServerResponse.parse(line: "Fetching 12 files: 100%")) - XCTAssertNil(LocalASRServerResponse.parse(line: "")) - XCTAssertNil(LocalASRServerResponse.parse(line: #"{"progress": 0.4}"#)) - } - - func testKeepsDictatedJSONInsideTextFieldVerbatim() { - XCTAssertEqual( - LocalASRServerResponse.parse(line: #"{"text":"配置里写 {\"key\": \"value\"} 就可以"}"#), - .text(#"配置里写 {"key": "value"} 就可以"#) - ) - } - - func testMapsNoSpeechPlaceholderToEmpty() { - XCTAssertEqual(LocalASRServerResponse.parse(line: #"{"text":"(无)"}"#), .text("")) - } -} diff --git a/Tests/OpenTypeTests/ModelUpgradeTests.swift b/Tests/OpenTypeTests/ModelUpgradeTests.swift index 9a6d6af0..a4179654 100644 --- a/Tests/OpenTypeTests/ModelUpgradeTests.swift +++ b/Tests/OpenTypeTests/ModelUpgradeTests.swift @@ -3,14 +3,6 @@ import Testing @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) diff --git a/Tests/OpenTypeTests/QualityProbeTests.swift b/Tests/OpenTypeTests/QualityProbeTests.swift index e9ba34cb..090f0039 100644 --- a/Tests/OpenTypeTests/QualityProbeTests.swift +++ b/Tests/OpenTypeTests/QualityProbeTests.swift @@ -93,22 +93,10 @@ final class QualityProbeTests: XCTestCase { XCTAssertEqual(cleaned, "let a = 1") } - // MARK: - D. Local ASR runner output parsing - - func testProbe_localASR_responseLinesNeverConcatenate() { - // Regression guard: the old multi-line joiner produced - // "可能会有两可能会有这种意外的泄露" by gluing partial lines together. - // The serve protocol parses each line independently. - let first = LocalASRServerResponse.parse(line: #"{"text": "可能会有两"}"#) - let second = LocalASRServerResponse.parse(line: #"{"text": "可能会有这种意外的泄露"}"#) - XCTAssertEqual(first, .text("可能会有两")) - XCTAssertEqual(second, .text("可能会有这种意外的泄露")) - } - - // MARK: - E. Direct-mode whitespace handling + // MARK: - D. Direct-mode whitespace handling // (see testProbe_basicClean_preservesNewlines below) - // MARK: - F. Prompt text block must embed user content verbatim + // MARK: - E. Prompt text block must embed user content verbatim func testProbe_promptBlock_keepsDictatedDelimiters() { let block = PromptTextBlock.block("代码里写的是 <<>> 结束符") diff --git a/Tests/OpenTypeTests/QwenNativeASREngineTests.swift b/Tests/OpenTypeTests/QwenNativeASREngineTests.swift new file mode 100644 index 00000000..4fa10a30 --- /dev/null +++ b/Tests/OpenTypeTests/QwenNativeASREngineTests.swift @@ -0,0 +1,122 @@ +import Foundation +import XCTest +@testable import OpenType + +final class QwenNativeASREngineTests: XCTestCase { + @MainActor + func testNativeQwenRemainsTheReleasedLocalASREngine() { + XCTAssertEqual( + ModelCatalog.defaultASRModels.map(\.id), + [QwenASRModel.defaultID] + ) + } + + func testModelDirectoryRequiresLocalWeightsAndTokenizerInputs() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("QwenNativeASREngineTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + for file in ["config.json", "model.safetensors", "tokenizer_config.json", "vocab.json"] { + try Data([1]).write(to: directory.appendingPathComponent(file)) + } + XCTAssertFalse(QwenNativeASREngine.modelDirectoryIsReady(directory)) + + try Data([1]).write(to: directory.appendingPathComponent("merges.txt")) + XCTAssertTrue(QwenNativeASREngine.modelDirectoryIsReady(directory)) + } + + func testExistingModelTranscribesRepositorySamplesWithoutDownloadingWeights() async throws { + guard ProcessInfo.processInfo.environment["OPENTYPE_QWEN_NATIVE_INTEGRATION"] == "1" else { + throw XCTSkip("Set OPENTYPE_QWEN_NATIVE_INTEGRATION=1 to run the native Qwen integration test") + } + let modelPath = try XCTUnwrap( + ProcessInfo.processInfo.environment["OPENTYPE_QWEN_MODEL_PATH"] + ) + let modelDirectory = URL(fileURLWithPath: modelPath, isDirectory: true) + let weightURL = modelDirectory.appendingPathComponent("model.safetensors") + let weightBefore = try weightURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + let safetensorsBefore = try safetensorsFiles(in: modelDirectory) + let engine = QwenNativeASREngine(modelPath: modelPath) + XCTAssertTrue(engine.isReady) + + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let englishStarted = CFAbsoluteTimeGetCurrent() + let english = try await engine.transcribe( + audioURL: repositoryRoot.appendingPathComponent("docs/assets/demos/en-sample.m4a"), + language: "en" + ) + let englishElapsed = CFAbsoluteTimeGetCurrent() - englishStarted + let chineseStarted = CFAbsoluteTimeGetCurrent() + let chinese = try await engine.transcribe( + audioURL: repositoryRoot.appendingPathComponent("docs/assets/demos/zh-sample.m4a"), + language: "zh" + ) + let chineseElapsed = CFAbsoluteTimeGetCurrent() - chineseStarted + + print("QWEN_NATIVE_EN_SECONDS=\(String(format: "%.3f", englishElapsed))") + print("QWEN_NATIVE_EN_TEXT=\(english)") + print("QWEN_NATIVE_ZH_SECONDS=\(String(format: "%.3f", chineseElapsed))") + print("QWEN_NATIVE_ZH_TEXT=\(chinese)") + + XCTAssertTrue(english.localizedCaseInsensitiveContains("design doc"), english) + XCTAssertTrue(chinese.contains("周五"), chinese) + XCTAssertEqual(try safetensorsFiles(in: modelDirectory), safetensorsBefore) + let weightAfter = try weightURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + XCTAssertEqual(weightAfter.fileSize, weightBefore.fileSize) + XCTAssertEqual(weightAfter.contentModificationDate, weightBefore.contentModificationDate) + } + + func testExistingModelNativeBenchmark() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["OPENTYPE_QWEN_NATIVE_BENCHMARK"] == "1" else { + throw XCTSkip("Set OPENTYPE_QWEN_NATIVE_BENCHMARK=1 to run the native benchmark") + } + let modelPath = try XCTUnwrap(environment["OPENTYPE_QWEN_MODEL_PATH"]) + let engine = QwenNativeASREngine(modelPath: modelPath) + XCTAssertTrue(engine.isReady) + + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let samples = [ + (name: "en", file: "en-sample.m4a", language: "en"), + (name: "zh", file: "zh-sample.m4a", language: "zh"), + (name: "command", file: "voice-cmd-sample.m4a", language: "zh"), + ] + + let prepareStarted = CFAbsoluteTimeGetCurrent() + await engine.prepare() + print("QWEN_BENCH_ENGINE=native") + print("QWEN_BENCH_PREPARE_SECONDS=\(formatSeconds(since: prepareStarted))") + + for round in 1...5 { + for sample in samples { + let started = CFAbsoluteTimeGetCurrent() + let text = try await engine.transcribe( + audioURL: repositoryRoot.appendingPathComponent("docs/assets/demos/\(sample.file)"), + language: sample.language + ) + print("QWEN_BENCH_\(sample.name.uppercased())_ROUND_\(round)_SECONDS=\(formatSeconds(since: started))") + print("QWEN_BENCH_\(sample.name.uppercased())_ROUND_\(round)_TEXT=\(text)") + XCTAssertFalse(text.isEmpty) + } + } + } + + private func safetensorsFiles(in directory: URL) throws -> Set { + let files = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + return Set(files.filter { $0.pathExtension == "safetensors" }.map(\.lastPathComponent)) + } + + private func formatSeconds(since started: CFAbsoluteTime) -> String { + String(format: "%.3f", CFAbsoluteTimeGetCurrent() - started) + } +} diff --git a/Tests/OpenTypeTests/UtilityTests.swift b/Tests/OpenTypeTests/UtilityTests.swift index ef12721a..4af68122 100644 --- a/Tests/OpenTypeTests/UtilityTests.swift +++ b/Tests/OpenTypeTests/UtilityTests.swift @@ -40,8 +40,8 @@ final class UtilityTests: XCTestCase { } func testModelStorageUsesHubRepoPathForASR() { - let suffix = ModelStorage.hubModelRepoDir("XiaomiMiMo/MiMo-V2.5-ASR").path - XCTAssertTrue(suffix.hasSuffix("/models/XiaomiMiMo/MiMo-V2.5-ASR")) + let suffix = ModelStorage.hubModelRepoDir(QwenASRModel.defaultID).path + XCTAssertTrue(suffix.hasSuffix("/models/mlx-community/Qwen3-ASR-1.7B-bf16")) } func testModelStorageRequiresWeightsBeforeLLMIsComplete() throws { @@ -112,7 +112,7 @@ final class UtilityTests: XCTestCase { 620_000_000 ) XCTAssertEqual( - ModelCatalog.estimatedDownloadBytes(from: "MiMo tokenizer ~1,024 MB"), + ModelCatalog.estimatedDownloadBytes(from: "ASR tokenizer ~1,024 MB"), 1_024_000_000 ) XCTAssertEqual( @@ -139,8 +139,8 @@ final class UtilityTests: XCTestCase { 225_923_469_800 ) XCTAssertEqual( - ModelCatalog.defaultDownloadEstimateBytes(for: LocalASRConfiguration.mimoDefaultModel), - 35_997_080_271 + ModelCatalog.defaultDownloadEstimateBytes(for: QwenASRModel.defaultID), + 4_080_707_826 ) } diff --git a/docs/index.html b/docs/index.html index 3302ab2f..ee4700d4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -37,7 +37,7 @@