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:
@@ -1,13 +1,15 @@
|
||||
// MacLocalASRService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// On-device ASR for macOS. Primary: Qwen3-ASR-1.7B (MLX via mlx-swift-asr).
|
||||
// Falls back to Apple Speech when Qwen3 weights are absent or backend is Apple Speech.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -39,28 +41,33 @@ enum MacLocalASRError: Error, LocalizedError {
|
||||
|
||||
enum MacLocalASRPreferences {
|
||||
static let backendKey = "mac.localASR.backend"
|
||||
static let qwen3ModelPathKey = "mac.localASR.qwen3ModelPath"
|
||||
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 backend: MacLocalASRBackend {
|
||||
static var selectedModelId: String {
|
||||
get {
|
||||
guard let raw = UserDefaults.standard.string(forKey: backendKey),
|
||||
let value = MacLocalASRBackend(rawValue: raw) else {
|
||||
return .qwen3MLX
|
||||
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
|
||||
return raw
|
||||
}
|
||||
return value
|
||||
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b"
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: backendKey) }
|
||||
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 {
|
||||
get { UserDefaults.standard.string(forKey: qwen3ModelPathKey) ?? defaultQwen3ModelPath }
|
||||
set { UserDefaults.standard.set(newValue, forKey: qwen3ModelPathKey) }
|
||||
}
|
||||
|
||||
/// Default install location for MLX-converted Qwen3-ASR weights.
|
||||
static var defaultQwen3ModelPath: String {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return appSupport.appendingPathComponent("OSGKeyboard/models/qwen3-asr-1.7b-mlx", isDirectory: true).path
|
||||
LocalASRModelInstallState.installDirectory(for: qwen3ModelRelativePath).path
|
||||
}
|
||||
|
||||
static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool {
|
||||
@@ -80,24 +87,93 @@ enum MacLocalASRPreferences {
|
||||
}
|
||||
|
||||
enum MacLocalASRService {
|
||||
/// Transcribe using the user's preferred local backend with automatic
|
||||
/// fallback to Apple Speech when Qwen3 weights are not present.
|
||||
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
|
||||
let preferQwen3 = MacLocalASRPreferences.backend == .qwen3MLX
|
||||
if preferQwen3, MacLocalASRPreferences.qwen3ModelIsInstalled() {
|
||||
|
||||
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 MacQwen3LocalASR.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: locale,
|
||||
modelPath: MacLocalASRPreferences.qwen3ModelPath
|
||||
)
|
||||
} catch MacLocalASRError.qwen3ModelMissing {
|
||||
// Fall through to Apple Speech when weights are absent.
|
||||
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
|
||||
} catch {
|
||||
throw error
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user