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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user