200265fbd6
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).
180 lines
6.4 KiB
Swift
180 lines
6.4 KiB
Swift
// MacLocalASRService.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
|
|
// Qwen3 MLX (default), Sherpa Qwen3 hotwords POC, SenseVoice, Apple Speech fallback.
|
|
|
|
import Foundation
|
|
|
|
enum MacLocalASRBackend: String, Sendable, CaseIterable {
|
|
case qwen3MLX
|
|
case sherpaQwen3
|
|
case sherpaSenseVoice
|
|
case appleSpeech
|
|
}
|
|
|
|
enum MacLocalASRError: Error, LocalizedError {
|
|
case qwen3ModelMissing
|
|
case qwen3LoadFailed(String)
|
|
case qwen3InferenceFailed(String)
|
|
case speechDenied
|
|
case speechFailed(String)
|
|
case emptyTranscript
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .qwen3ModelMissing:
|
|
return MacL10n.string("mac.error.qwen3ModelMissing")
|
|
case .qwen3LoadFailed(let detail):
|
|
return MacL10n.format("mac.error.qwen3LoadFailed", detail)
|
|
case .qwen3InferenceFailed(let detail):
|
|
return MacL10n.format("mac.error.qwen3InferenceFailed", detail)
|
|
case .speechDenied:
|
|
return "Speech recognition permission denied"
|
|
case .speechFailed(let detail):
|
|
return detail
|
|
case .emptyTranscript:
|
|
return MacL10n.string("mac.error.emptyTranscript")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MacLocalASRPreferences {
|
|
static let backendKey = "mac.localASR.backend"
|
|
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
|
|
/// Shared managed subfolder for the manually-provided MLX weights.
|
|
static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
|
|
|
|
static var selectedModelId: String {
|
|
get {
|
|
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
|
|
return raw
|
|
}
|
|
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b"
|
|
}
|
|
set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
|
|
}
|
|
|
|
static var legacyBackend: MacLocalASRBackend {
|
|
guard let raw = UserDefaults.standard.string(forKey: backendKey),
|
|
let value = MacLocalASRBackend(rawValue: raw) else {
|
|
return .qwen3MLX
|
|
}
|
|
return value
|
|
}
|
|
|
|
/// Fixed location inside the shared managed model storage root. All three
|
|
/// catalog models live under the same directory, so MLX no longer needs a
|
|
/// per-model folder picker — the user drops converted weights here.
|
|
static var qwen3ModelPath: String {
|
|
LocalASRModelInstallState.installDirectory(for: qwen3ModelRelativePath).path
|
|
}
|
|
|
|
static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool {
|
|
var isDir: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
|
|
return false
|
|
}
|
|
let fm = FileManager.default
|
|
let config = (path as NSString).appendingPathComponent("config.json")
|
|
let weights = (path as NSString).appendingPathComponent("model.safetensors")
|
|
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
|
|
return false
|
|
}
|
|
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
|
|
return names.contains("vocab.json") && names.contains("merges.txt")
|
|
}
|
|
}
|
|
|
|
enum MacLocalASRService {
|
|
|
|
static func loadCatalog() -> LocalASRCatalogDocument? {
|
|
try? LocalASRModelCatalog.loadBundled()
|
|
}
|
|
|
|
static func selectedModelDefinition() -> LocalASRModelDefinition? {
|
|
guard let catalog = loadCatalog() else { return nil }
|
|
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
|
let selectedId = manifest.selectedModelId.isEmpty
|
|
? MacLocalASRPreferences.selectedModelId
|
|
: manifest.selectedModelId
|
|
if selectedId == "apple-speech-fallback" { return nil }
|
|
return LocalASRModelCatalog.model(selectedId, in: catalog)
|
|
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
|
|
}
|
|
|
|
static func currentCapabilities() -> LocalASRCapabilities {
|
|
guard let model = selectedModelDefinition() else { return .appleSpeech }
|
|
return LocalASRModelCatalog.capabilities(for: model)
|
|
}
|
|
|
|
static func currentBackendLabel() -> String {
|
|
guard let model = selectedModelDefinition() else { return "Apple Speech" }
|
|
return model.displayName
|
|
}
|
|
|
|
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
|
|
LocalASRModelInstallState.isInstalled(
|
|
model,
|
|
manualMLXPath: MacLocalASRPreferences.qwen3ModelPath
|
|
)
|
|
}
|
|
|
|
/// Transcribe using the selected catalog model, with MLX → Apple Speech fallback.
|
|
static func transcribe(
|
|
samples: [Float],
|
|
locale: Locale,
|
|
bias: LocalASRBiasPayload? = nil
|
|
) async throws -> String {
|
|
if let model = selectedModelDefinition(), isModelInstalled(model) {
|
|
do {
|
|
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
|
|
} catch {
|
|
if model.backend != .mlx {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
if MacLocalASRPreferences.qwen3ModelIsInstalled() {
|
|
return try await MacQwen3LocalASR.transcribe(
|
|
samples: samples,
|
|
sampleRate: 16_000,
|
|
locale: locale,
|
|
modelPath: MacLocalASRPreferences.qwen3ModelPath,
|
|
bias: bias
|
|
)
|
|
}
|
|
|
|
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
|
}
|
|
|
|
private static func transcribeWithModel(
|
|
_ model: LocalASRModelDefinition,
|
|
samples: [Float],
|
|
locale: Locale,
|
|
bias: LocalASRBiasPayload?
|
|
) async throws -> String {
|
|
switch model.backend {
|
|
case .mlx:
|
|
return try await MacQwen3LocalASR.transcribe(
|
|
samples: samples,
|
|
sampleRate: 16_000,
|
|
locale: locale,
|
|
modelPath: MacLocalASRPreferences.qwen3ModelPath,
|
|
bias: bias
|
|
)
|
|
case .sherpaQwen3, .sherpaSenseVoice:
|
|
return try await MacSherpaLocalASR.transcribe(
|
|
samples: samples,
|
|
sampleRate: 16_000,
|
|
locale: locale,
|
|
model: model,
|
|
bias: bias
|
|
)
|
|
case .appleSpeech:
|
|
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
|
}
|
|
}
|
|
}
|