c2f07bd8d2
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
104 lines
3.9 KiB
Swift
104 lines
3.9 KiB
Swift
// 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.
|
|
|
|
import Foundation
|
|
|
|
enum MacLocalASRBackend: String, Sendable, CaseIterable {
|
|
case qwen3MLX
|
|
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 qwen3ModelPathKey = "mac.localASR.qwen3ModelPath"
|
|
|
|
static var backend: MacLocalASRBackend {
|
|
get {
|
|
guard let raw = UserDefaults.standard.string(forKey: backendKey),
|
|
let value = MacLocalASRBackend(rawValue: raw) else {
|
|
return .qwen3MLX
|
|
}
|
|
return value
|
|
}
|
|
set { UserDefaults.standard.set(newValue.rawValue, forKey: backendKey) }
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
/// 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() {
|
|
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.
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
|
}
|
|
}
|