feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2

Adds a bundled local ASR model catalog for the macOS app with one-click
Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a
shared model storage directory used by MLX Qwen3. Fixes the light-mode
sidebar material and makes the menu-bar icon follow the system appearance
with a refreshed status mark. Renames the built product to OSGKeyboard.app.

Bumps version to 0.5.2 (build 19).
This commit is contained in:
Rocky
2026-07-09 08:55:37 +08:00
parent c2f07bd8d2
commit 200265fbd6
50 changed files with 4666 additions and 266 deletions
@@ -0,0 +1,99 @@
// LocalASRBiasPayload.swift
// OSGKeyboard · Shared
//
// Output of `LocalASRBiasAdapter` vocabulary signals for each pipeline layer.
import Foundation
public struct LocalASRCorrectionPair: Sendable, Equatable {
public let alias: String
public let term: String
public init(alias: String, term: String) {
self.alias = alias
self.term = term
}
}
public struct LocalASRBiasDiagnostics: Sendable, Equatable, Codable {
public var userTermCount: Int
public var builtinTermCount: Int
public var truncated: Bool
public var truncationReason: String?
public var selectedSources: [String]
public init(
userTermCount: Int = 0,
builtinTermCount: Int = 0,
truncated: Bool = false,
truncationReason: String? = nil,
selectedSources: [String] = []
) {
self.userTermCount = userTermCount
self.builtinTermCount = builtinTermCount
self.truncated = truncated
self.truncationReason = truncationReason
self.selectedSources = selectedSources
}
}
public struct LocalASRBiasPayload: Sendable, Equatable {
public var hardHotwords: [String]
public var promptBias: String?
public var corpusContext: String?
public var polishFragment: String
public var correctionPairs: [LocalASRCorrectionPair]
public var diagnostics: LocalASRBiasDiagnostics
public static let empty = LocalASRBiasPayload(
hardHotwords: [],
promptBias: nil,
corpusContext: nil,
polishFragment: "",
correctionPairs: [],
diagnostics: LocalASRBiasDiagnostics()
)
public init(
hardHotwords: [String],
promptBias: String?,
corpusContext: String?,
polishFragment: String,
correctionPairs: [LocalASRCorrectionPair],
diagnostics: LocalASRBiasDiagnostics
) {
self.hardHotwords = hardHotwords
self.promptBias = promptBias
self.corpusContext = corpusContext
self.polishFragment = polishFragment
self.correctionPairs = correctionPairs
self.diagnostics = diagnostics
}
}
public struct LocalASRBiasRequest: Sendable {
public var dictionary: PersonalDictionary
public var locale: Locale
public var frontAppBundleId: String?
public var capabilities: LocalASRCapabilities
/// Max builtin `phrases.tsv` terms considered for ASR bias (not polish-only).
public var builtinASRLimit: Int
/// Max builtin terms referenced in the polish supplement block.
public var builtinPolishLimit: Int
public init(
dictionary: PersonalDictionary,
locale: Locale,
frontAppBundleId: String? = nil,
capabilities: LocalASRCapabilities,
builtinASRLimit: Int = 300,
builtinPolishLimit: Int = 40
) {
self.dictionary = dictionary
self.locale = locale
self.frontAppBundleId = frontAppBundleId
self.capabilities = capabilities
self.builtinASRLimit = builtinASRLimit
self.builtinPolishLimit = builtinPolishLimit
}
}
@@ -0,0 +1,82 @@
// LocalASRCapabilities.swift
// OSGKeyboard · Shared
//
// Declares what each on-device ASR backend can accept for vocabulary bias.
// Callers must consult capabilities before building a `LocalASRBiasPayload`.
import Foundation
/// How a backend accepts vocabulary hints (honest matrix not every model
/// supports hard hotwords).
public enum LocalASRHotwordMode: String, Sendable, Codable, Equatable {
case none
case promptOnly
case perRequest
case recognizerScoped
case cloudVocabulary
}
/// Cost of refreshing hotwords on a backend (e.g. Sherpa Qwen3 reloads recognizer).
public enum LocalASRHotwordReloadCost: String, Sendable, Codable, Equatable {
case none
case recognizerReload
case modelReload
}
public struct LocalASRCapabilities: Sendable, Equatable {
public let hotwordMode: LocalASRHotwordMode
public let maxHotwordCount: Int
public let maxPromptCharacters: Int
public let supportsStreaming: Bool
public let hotwordReloadCost: LocalASRHotwordReloadCost
public init(
hotwordMode: LocalASRHotwordMode,
maxHotwordCount: Int,
maxPromptCharacters: Int,
supportsStreaming: Bool,
hotwordReloadCost: LocalASRHotwordReloadCost
) {
self.hotwordMode = hotwordMode
self.maxHotwordCount = maxHotwordCount
self.maxPromptCharacters = maxPromptCharacters
self.supportsStreaming = supportsStreaming
self.hotwordReloadCost = hotwordReloadCost
}
/// Qwen3 MLX via mlx-swift-asr `context` soft prompt on `transcribe`.
public static let qwen3MLX = LocalASRCapabilities(
hotwordMode: .promptOnly,
maxHotwordCount: 0,
maxPromptCharacters: 800,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// Apple Speech on macOS no project-controlled hotword API today.
public static let appleSpeech = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// Sherpa Qwen3 hard hotwords via `--qwen3-asr-hotwords`.
public static let sherpaQwen3 = LocalASRCapabilities(
hotwordMode: .recognizerScoped,
maxHotwordCount: 100,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .recognizerReload
)
/// Sherpa SenseVoice fast Chinese baseline without hotwords.
public static let sherpaSenseVoice = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
}
@@ -0,0 +1,134 @@
// 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 appleSpeech
}
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
case manual
case archive
case runtime
}
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
public let type: String
public let priority: Int
public let url: String
}
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 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
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 bundle = Bundle(for: LocalASRCatalogBundleToken.self)
guard let url = bundle.url(forResource: "local-asr-catalog", withExtension: "json") 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 .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
}
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 {}
@@ -111,4 +111,29 @@ extension PersonalDictionary {
if hasNonASCII { return "zh" }
return "en"
}
/// Alias canonical term pairs for deterministic post-ASR correction.
/// Sorted longest-alias-first by the caller (`LocalASRTranscriptCorrector`).
public func localCorrectionPairs() -> [LocalASRCorrectionPair] {
var seen = Set<String>()
var pairs: [LocalASRCorrectionPair] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { continue }
for alias in entry.aliases {
let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard trimmed.caseInsensitiveCompare(term) != .orderedSame else { continue }
let key = "\(trimmed.lowercased())|\(term.lowercased())"
guard seen.insert(key).inserted else { continue }
pairs.append(LocalASRCorrectionPair(alias: trimmed, term: term))
}
}
return pairs.sorted { lhs, rhs in
if lhs.alias.count != rhs.alias.count {
return lhs.alias.count > rhs.alias.count
}
return lhs.alias.localizedCaseInsensitiveCompare(rhs.alias) == .orderedAscending
}
}
}
@@ -24,6 +24,10 @@ public struct PolishContext: Sendable {
/// bias terminology choices.
public let precedingText: String?
/// Extra dictionary block appended after `PersonalDictionary.promptFragment()`
/// (e.g. builtin `phrases.tsv` terms on macOS local ASR).
public let dictionarySupplement: String?
/// Cap on how many characters of `precedingText` we actually
/// include in the prompt. The full preceding text is often
/// hundreds of KB in a long note we only need the tail.
@@ -33,11 +37,13 @@ public struct PolishContext: Sendable {
appContext: AppContext = .unknown,
intensity: PolishIntensity = .default,
precedingText: String? = nil,
dictionarySupplement: String? = nil,
maxPrecedingChars: Int = 500
) {
self.appContext = appContext
self.intensity = intensity
self.precedingText = precedingText
self.dictionarySupplement = dictionarySupplement
self.maxPrecedingChars = maxPrecedingChars
}
@@ -74,7 +74,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
configuration.engineMode = engineMode
applyEngineModeSideEffects()
persistConfiguration()
persistConfiguration(postConfigChanged: true)
}
}
@Published public var hasCompletedOnboarding: Bool {