Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
8.4 KiB
Swift
243 lines
8.4 KiB
Swift
// LocalASRModelCatalog.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Bundled macOS local-ASR model catalog. Qwen3 MLX is the active default and
|
|
// Apple Speech is the fallback; Sherpa backend/runtime identifiers remain for
|
|
// decoding legacy catalog and persisted install state, not current recognition.
|
|
|
|
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 SharedL10n.string("error.localASR.missingBundledCatalog")
|
|
case .modelNotFound(let id):
|
|
return SharedL10n.format("error.localASR.modelNotFound", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class LocalASRCatalogBundleToken {}
|