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).
96 lines
3.1 KiB
Swift
96 lines
3.1 KiB
Swift
// MacQwen3ASREngine.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr.
|
|
// Model load + Metal JIT warmup take several seconds — call `prepareIfNeeded`
|
|
// at launch so the first dictation is fast.
|
|
|
|
import Foundation
|
|
import MLXASR
|
|
|
|
/// Lifecycle of the on-disk MLX model inside the app process.
|
|
enum MacQwen3EnginePhase: Sendable, Equatable {
|
|
case idle
|
|
case loading
|
|
case ready
|
|
case failed(String)
|
|
}
|
|
|
|
actor MacQwen3ASREngine {
|
|
static let shared = MacQwen3ASREngine()
|
|
|
|
private var stt: Qwen3ASRSTT?
|
|
private var loadedModelPath: String?
|
|
private(set) var phase: MacQwen3EnginePhase = .idle
|
|
|
|
private init() {}
|
|
|
|
/// Load and warm up the model when the path changes or nothing is loaded yet.
|
|
func prepareIfNeeded(modelPath: String) async throws {
|
|
if loadedModelPath == modelPath, stt != nil, phase == .ready { return }
|
|
|
|
phase = .loading
|
|
stt = nil
|
|
loadedModelPath = nil
|
|
|
|
let directory = URL(fileURLWithPath: modelPath, isDirectory: true)
|
|
do {
|
|
let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory)
|
|
stt = instance
|
|
loadedModelPath = modelPath
|
|
phase = .ready
|
|
} catch {
|
|
let detail = error.localizedDescription
|
|
phase = .failed(detail)
|
|
throw MacLocalASRError.qwen3LoadFailed(detail)
|
|
}
|
|
}
|
|
|
|
/// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first.
|
|
func transcribe(
|
|
samples: [Float],
|
|
language: String?,
|
|
modelPath: String,
|
|
context: String? = nil
|
|
) async throws -> String {
|
|
try await prepareIfNeeded(modelPath: modelPath)
|
|
guard let stt else {
|
|
throw MacLocalASRError.qwen3LoadFailed("Engine not initialized")
|
|
}
|
|
|
|
let result = try await stt.transcribe(audio: samples, language: language, context: context)
|
|
let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else {
|
|
throw MacLocalASRError.emptyTranscript
|
|
}
|
|
return text
|
|
}
|
|
|
|
/// Drop cached weights (e.g. after the user changes the model folder).
|
|
func unload() {
|
|
stt = nil
|
|
loadedModelPath = nil
|
|
phase = .idle
|
|
}
|
|
}
|
|
|
|
enum MacQwen3LanguageHint {
|
|
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
|
|
/// Returns `nil` for auto-detect.
|
|
static func from(locale: Locale) -> String? {
|
|
let raw = locale.identifier.lowercased()
|
|
if raw.isEmpty || raw == "auto" { return nil }
|
|
if raw.hasPrefix("zh") { return "Chinese" }
|
|
if raw.hasPrefix("en") { return "English" }
|
|
if raw.hasPrefix("ja") { return "Japanese" }
|
|
if raw.hasPrefix("ko") { return "Korean" }
|
|
if raw.hasPrefix("fr") { return "French" }
|
|
if raw.hasPrefix("de") { return "German" }
|
|
if raw.hasPrefix("es") { return "Spanish" }
|
|
if raw.hasPrefix("pt") { return "Portuguese" }
|
|
if raw.hasPrefix("ru") { return "Russian" }
|
|
if raw.hasPrefix("ar") { return "Arabic" }
|
|
return nil
|
|
}
|
|
}
|