feat: sync force-quit Flow teardown and polish macOS local ASR UX

End Live Activities and release the audio session synchronously on
applicationWillTerminate, and continue macOS local-model install,
onboarding, and settings polish on this branch.
This commit is contained in:
Rocky
2026-07-09 17:13:07 +08:00
parent 200265fbd6
commit dcb66a9849
34 changed files with 1936 additions and 340 deletions
+24 -56
View File
@@ -2,13 +2,13 @@
// 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.
// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case qwen3MLX
case sherpaQwen3
case sherpaParaformer
case sherpaSenseVoice
case appleSpeech
}
@@ -42,48 +42,37 @@ enum MacLocalASRError: Error, LocalizedError {
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
/// Shared managed subfolder for the manually-provided MLX weights.
/// Legacy MLX path key retained for migration only.
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 migratedModelId(raw)
}
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b"
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "sherpa-qwen3-0.6b-int8"
}
set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
}
/// Maps removed catalog entries to the current default Sherpa model.
static func migratedModelId(_ id: String) -> String {
switch id {
case "qwen3-mlx-1.7b":
return "sherpa-qwen3-0.6b-int8"
default:
return id
}
}
static var legacyBackend: MacLocalASRBackend {
guard let raw = UserDefaults.standard.string(forKey: backendKey),
let value = MacLocalASRBackend(rawValue: raw) else {
return .qwen3MLX
return .sherpaQwen3
}
if raw == "qwen3MLX" { return .sherpaQwen3 }
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 {
@@ -97,7 +86,7 @@ enum MacLocalASRService {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
let selectedId = manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: manifest.selectedModelId
: MacLocalASRPreferences.migratedModelId(manifest.selectedModelId)
if selectedId == "apple-speech-fallback" { return nil }
return LocalASRModelCatalog.model(selectedId, in: catalog)
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
@@ -116,34 +105,19 @@ enum MacLocalASRService {
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
LocalASRModelInstallState.isInstalled(
model,
manualMLXPath: MacLocalASRPreferences.qwen3ModelPath
manualMLXPath: nil,
fileManager: FileManager.default
)
}
/// Transcribe using the selected catalog model, with MLX Apple Speech fallback.
/// Transcribe using the selected catalog model, falling back to Apple Speech.
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 transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
@@ -157,14 +131,8 @@ enum MacLocalASRService {
) 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:
throw MacLocalASRError.qwen3ModelMissing
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
return try await MacSherpaLocalASR.transcribe(
samples: samples,
sampleRate: 16_000,