Files
OSGKeyboard/OSGKeyboardShared/Models/LocalASRModelCatalog.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
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>
2026-09-18 17:31:29 -07:00

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 {}