31f5937a7f
Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
241 lines
8.2 KiB
Swift
241 lines
8.2 KiB
Swift
// LocalASRModelCatalog.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Bundled catalog of downloadable / manual local ASR models and Sherpa runtimes.
|
|
|
|
import Foundation
|
|
|
|
public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
|
|
case mlx
|
|
case sherpaQwen3
|
|
case sherpaSenseVoice
|
|
case sherpaParaformer
|
|
case appleSpeech
|
|
}
|
|
|
|
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
|
|
case manual
|
|
case archive
|
|
/// Multi-file install from a remote repository (ModelScope / HuggingFace file API).
|
|
case repository
|
|
case runtime
|
|
}
|
|
|
|
public struct LocalASRDownloadFile: Codable, Sendable, Equatable {
|
|
public let remotePath: String
|
|
public let localPath: String
|
|
public let sizeBytes: Int?
|
|
}
|
|
|
|
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
|
|
public let type: String
|
|
public let priority: Int
|
|
/// Full URL for a single archive download.
|
|
public let url: String
|
|
/// Base URL template for repository installs; must contain `{path}`.
|
|
public let baseURL: String?
|
|
public let files: [LocalASRDownloadFile]?
|
|
|
|
public var isRepository: Bool {
|
|
guard let files, !files.isEmpty else { return false }
|
|
return baseURL?.contains("{path}") == true
|
|
}
|
|
|
|
public var isArchive: Bool {
|
|
!url.isEmpty && !isRepository
|
|
}
|
|
}
|
|
|
|
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
|
public var convFrontend: String?
|
|
public var encoder: String?
|
|
public var decoder: String?
|
|
public var tokenizer: String?
|
|
public var senseVoiceModel: String?
|
|
public var paraformerModel: String?
|
|
public var mlxConfig: String?
|
|
public var mlxWeights: String?
|
|
public var tokens: String?
|
|
}
|
|
|
|
public struct LocalASRRuntimeDefinition: Codable, Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let displayName: String
|
|
public let installRelativePath: String
|
|
public let binaryCandidates: [String]
|
|
public let archiveFileName: String
|
|
public let sizeBytes: Int
|
|
public let platform: String
|
|
public let sources: [LocalASRDownloadSource]
|
|
}
|
|
|
|
public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let displayName: String
|
|
public let backend: LocalASRModelBackend
|
|
public let sizeBytes: Int
|
|
public let recommendedLocales: [String]
|
|
public let supportsHotwords: Bool
|
|
public let hotwordMode: LocalASRHotwordMode
|
|
/// Optional localization key for a short quality/speed badge
|
|
/// (e.g. `mac.localASR.badge.fastest`).
|
|
public let badgeKey: String?
|
|
public let installKind: LocalASRInstallKind
|
|
public let installRelativePath: String?
|
|
public let archiveBaseName: String?
|
|
public let layout: LocalASRModelLayout?
|
|
public let requiredRelativeFiles: [String]?
|
|
public let runtimePlatform: String?
|
|
public let sources: [LocalASRDownloadSource]?
|
|
}
|
|
|
|
public struct LocalASRCatalogDocument: Codable, Sendable, Equatable {
|
|
public let schemaVersion: Int
|
|
public let defaultModelId: String
|
|
public let runtimes: [LocalASRRuntimeDefinition]
|
|
public let models: [LocalASRModelDefinition]
|
|
}
|
|
|
|
public enum LocalASRModelCatalog {
|
|
|
|
public static func loadBundled() throws -> LocalASRCatalogDocument {
|
|
let url =
|
|
Bundle.main.url(forResource: "local-asr-catalog", withExtension: "json")
|
|
?? Bundle(for: LocalASRCatalogBundleToken.self)
|
|
.url(forResource: "local-asr-catalog", withExtension: "json")
|
|
guard let url else {
|
|
throw LocalASRModelCatalogError.missingBundledCatalog
|
|
}
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode(LocalASRCatalogDocument.self, from: data)
|
|
}
|
|
|
|
public static func model(_ id: String, in catalog: LocalASRCatalogDocument) -> LocalASRModelDefinition? {
|
|
catalog.models.first { $0.id == id }
|
|
}
|
|
|
|
public static func capabilities(for model: LocalASRModelDefinition) -> LocalASRCapabilities {
|
|
switch model.backend {
|
|
case .mlx:
|
|
return .qwen3MLX
|
|
case .sherpaQwen3:
|
|
return .sherpaQwen3
|
|
case .sherpaSenseVoice:
|
|
return .sherpaSenseVoice
|
|
case .sherpaParaformer:
|
|
return .sherpaParaformer
|
|
case .appleSpeech:
|
|
return .appleSpeech
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
public static func runtime(for platform: String, in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
|
|
if platform == "macos-arm64" {
|
|
return catalog.runtimes.first { $0.platform == "macos-arm64" }
|
|
}
|
|
if platform == "macos-x64" {
|
|
return catalog.runtimes.first { $0.platform == "macos-x64" }
|
|
}
|
|
return catalog.runtimes.first
|
|
}
|
|
|
|
public static func currentRuntimePlatform() -> String {
|
|
#if arch(arm64)
|
|
return "macos-arm64"
|
|
#else
|
|
return "macos-x64"
|
|
#endif
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// User-facing override for which mirror to try first when installing models.
|
|
public enum LocalASRDownloadSourcePreference: String, Codable, Sendable, CaseIterable {
|
|
/// Pick automatically based on the system region (China-friendly default).
|
|
case auto
|
|
/// Force the HF mirror (`hf-mirror.com`) first — best for mainland China.
|
|
case hfMirror
|
|
/// Force the official Hugging Face endpoint first.
|
|
case huggingface
|
|
|
|
/// The catalog `type` string this preference pins to the front (nil for `.auto`).
|
|
public var pinnedType: String? {
|
|
switch self {
|
|
case .auto: return nil
|
|
case .hfMirror: return "hfmirror"
|
|
case .huggingface: return "huggingface"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Region-aware ordering for local ASR model download mirrors.
|
|
public enum LocalASRDownloadSourceSorter {
|
|
|
|
/// `true` when the system region is mainland China (`CN`).
|
|
public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool {
|
|
region?.identifier == "CN"
|
|
}
|
|
|
|
/// Whether to try the China-friendly mirror (`hf-mirror.com`) first.
|
|
///
|
|
/// Default is "yes unless the region is *definitely* overseas": an unknown
|
|
/// region (common when a VPN/proxy masks locale) falls back to the mirror,
|
|
/// which is reachable both inside and outside China, so mainland users work
|
|
/// out of the box while overseas users only lose it when their region is set.
|
|
public static func preferChinaMirror(region: Locale.Region? = Locale.current.region) -> Bool {
|
|
guard let region else { return true }
|
|
return region.identifier == "CN"
|
|
}
|
|
|
|
/// Lower rank = tried earlier.
|
|
/// China-first: hf-mirror → HuggingFace → ModelScope → GitHub.
|
|
/// Overseas: HuggingFace → hf-mirror → GitHub → ModelScope.
|
|
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
|
|
switch type.lowercased() {
|
|
case "hfmirror", "hf-mirror":
|
|
return chinaFirst ? 0 : 1
|
|
case "huggingface":
|
|
return chinaFirst ? 1 : 0
|
|
case "modelscope":
|
|
return chinaFirst ? 2 : 3
|
|
case "github":
|
|
return chinaFirst ? 3 : 2
|
|
default:
|
|
return 4
|
|
}
|
|
}
|
|
|
|
public static func sorted(
|
|
_ sources: [LocalASRDownloadSource],
|
|
region: Locale.Region? = Locale.current.region,
|
|
preferred: LocalASRDownloadSourcePreference = .auto
|
|
) -> [LocalASRDownloadSource] {
|
|
let chinaFirst = preferChinaMirror(region: region)
|
|
let pinned = preferred.pinnedType?.lowercased()
|
|
func rank(_ source: LocalASRDownloadSource) -> (Int, Int) {
|
|
if let pinned, source.type.lowercased() == pinned {
|
|
return (-1, source.priority)
|
|
}
|
|
return (typeRank(source.type, chinaFirst: chinaFirst), source.priority)
|
|
}
|
|
return sources.sorted { rank($0) < rank($1) }
|
|
}
|
|
}
|
|
|
|
public enum LocalASRModelCatalogError: Error, LocalizedError {
|
|
case missingBundledCatalog
|
|
case modelNotFound(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .missingBundledCatalog:
|
|
return "Missing bundled local ASR catalog."
|
|
case .modelNotFound(let id):
|
|
return "Local ASR model not found: \(id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class LocalASRCatalogBundleToken {}
|