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:
Rocky
2026-07-09 08:55:37 +08:00
parent c2f07bd8d2
commit 200265fbd6
50 changed files with 4666 additions and 266 deletions
+36 -25
View File
@@ -183,37 +183,48 @@ struct BottomDictationBar: View {
.fixedSize()
}
//
//
// Option
private var recordControl: some View {
HStack(spacing: Spacing.sm) {
if viewModel.isRecording {
MiniWaveform(level: viewModel.audioLevel)
recordButton
.overlay(alignment: .top) {
if viewModel.isRecording {
Text(MacL10n.string("mac.record.pressStop", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize()
.offset(y: -22)
}
}
Button(action: viewModel.toggleRecording) {
ZStack {
Circle()
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
.frame(width: 52, height: 52)
.macGlassSurface(in: Circle(), fillOpacity: 0.2)
.shadow(
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
radius: pulse ? 14 : 6
)
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
}
private var recordButton: some View {
Button(action: viewModel.toggleRecording) {
ZStack {
Circle()
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
.frame(width: 52, height: 52)
.macGlassSurface(in: Circle(), fillOpacity: 0.2)
.shadow(
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
radius: pulse ? 14 : 6
)
if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(palette.textOnAccent)
}
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.onAppear {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
pulse = true
}
}
if viewModel.isRecording {
Text(MacL10n.string("mac.record.pressStop", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.onAppear {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
pulse = true
}
}
}
+45 -4
View File
@@ -31,9 +31,30 @@ enum MacDictationPipeline {
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let raw: String
var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
raw = try await MacLocalASRService.transcribe(samples: samples, locale: locale)
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
)
)
localBias = bias
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
} else {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
@@ -51,13 +72,33 @@ enum MacDictationPipeline {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
let postASR: String
if let localBias, !localBias.correctionPairs.isEmpty {
postASR = LocalASRTranscriptCorrector.apply(trimmed, pairs: localBias.correctionPairs)
} else {
postASR = trimmed
}
let polishContext: PolishContext?
if let supplement = localBias?.polishFragment.trimmingCharacters(in: .whitespacesAndNewlines),
!supplement.isEmpty {
polishContext = PolishContext(
appContext: store.detectedAppContext?.context ?? .unknown,
intensity: store.polishIntensity,
dictionarySupplement: supplement
)
} else {
polishContext = nil
}
if let polished = try? await PolishingService(store: store).polish(
trimmed,
mode: store.polishModeForPipeline
postASR,
mode: store.polishModeForPipeline,
context: polishContext
),
!polished.isEmpty {
return polished
}
return trimmed
return postASR
}
}
+36 -2
View File
@@ -107,8 +107,9 @@ final class MacDictationViewModel: ObservableObject {
/// Pre-load MLX weights + Metal shaders so the first dictation is fast.
func warmUpQwen3IfNeeded() {
guard config.engineMode == "local",
MacLocalASRPreferences.backend == .qwen3MLX,
MacLocalASRPreferences.qwen3ModelIsInstalled() else { return }
let model = MacLocalASRService.selectedModelDefinition(),
model.backend == .mlx,
MacLocalASRService.isModelInstalled(model) else { return }
let path = MacLocalASRPreferences.qwen3ModelPath
Task.detached(priority: .utility) {
_ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
@@ -156,6 +157,39 @@ final class MacDictationViewModel: ObservableObject {
return "\(seconds)s"
}
var localModelReady: Bool {
_ = localModelRevision
if let model = MacLocalASRService.selectedModelDefinition() {
return MacLocalASRService.isModelInstalled(model)
}
return MacLocalASRPreferences.qwen3ModelIsInstalled()
}
/// Context-aware warning when local engine is selected but the active model is not ready.
var localModelWarningMessage: String? {
_ = localModelRevision
guard config.engineMode == "local" else { return nil }
if localModelReady { return nil }
guard let model = MacLocalASRService.selectedModelDefinition() else {
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
}
if model.installKind == .manual {
return MacL10n.string("mac.settings.mlxModelMissing", language: config.uiLanguage)
}
return MacL10n.format(
"mac.settings.selectedModelMissing",
language: config.uiLanguage,
model.displayName
)
}
@Published private(set) var localModelRevision = 0
func bumpLocalModelRevision() {
localModelRevision += 1
objectWillChange.send()
}
var qwen3ModelInstalled: Bool {
MacLocalASRPreferences.qwen3ModelIsInstalled()
}
@@ -0,0 +1,455 @@
// MacLocalASRModelSettingsView.swift
// OSGKeyboard · Mac
//
// Local ASR model catalog, download progress, MLX path, and bias diagnostics.
import AppKit
import SwiftUI
@MainActor
final class MacLocalASRModelSettingsViewModel: ObservableObject {
@Published var catalog: LocalASRCatalogDocument?
@Published var selectedModelId: String = MacLocalASRPreferences.selectedModelId
@Published var installProgress = LocalASRModelInstallProgress.idle
@Published var diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
@Published var statusMessage = ""
@Published var isInstalling = false
@Published var isDownloadPaused = false
var onLocalModelStateChanged: (() -> Void)?
private let manager = LocalASRModelManager.shared
private var progressPollTask: Task<Void, Never>?
deinit {
progressPollTask?.cancel()
}
func reload() {
catalog = try? LocalASRModelCatalog.loadBundled()
if let catalog {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
selectedModelId = manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: manifest.selectedModelId
}
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
onLocalModelStateChanged?()
}
func isInstalled(_ model: LocalASRModelDefinition) -> Bool {
MacLocalASRService.isModelInstalled(model)
}
func isInstallingModel(_ model: LocalASRModelDefinition) -> Bool {
isInstalling && installProgress.activeItemId == model.id
}
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
guard model.installKind == .archive,
let relative = model.installRelativePath,
isInstalled(model) else { return nil }
let dir = LocalASRModelInstallState.installDirectory(for: relative)
let bytes = LocalASRModelInstallState.directoryByteCount(at: dir)
guard bytes > 0 else { return nil }
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
func currentRuntime(in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
LocalASRModelCatalog.runtime(for: LocalASRModelCatalog.currentRuntimePlatform(), in: catalog)
}
func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
LocalASRModelInstallState.isRuntimeInstalled(runtime)
}
func selectModel(_ modelId: String) {
guard let catalog, !isInstalling else { return }
selectedModelId = modelId
MacLocalASRPreferences.selectedModelId = modelId
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
manifest.selectedModelId = modelId
manifest.updatedAt = Date()
try? LocalASRInstalledManifestIO.save(manifest)
onLocalModelStateChanged?()
}
func installModel(_ model: LocalASRModelDefinition) {
guard let catalog, !isInstalling else { return }
statusMessage = ""
isInstalling = true
isDownloadPaused = false
startProgressPolling()
Task {
do {
try await manager.installModel(model, catalog: catalog)
installProgress = await manager.currentProgress()
selectModel(model.id)
statusMessage = MacL10n.string("mac.localASR.installDone")
} catch {
installProgress = await manager.currentProgress()
statusMessage = error.localizedDescription
}
isInstalling = false
isDownloadPaused = false
stopProgressPolling()
reload()
}
}
func pauseDownload() {
Task {
do {
try await manager.pauseDownload()
isDownloadPaused = true
installProgress = await manager.currentProgress()
} catch {
statusMessage = error.localizedDescription
}
}
}
func resumeDownload() {
Task {
do {
try await manager.resumeDownload()
isDownloadPaused = false
installProgress = await manager.currentProgress()
} catch {
statusMessage = error.localizedDescription
}
}
}
func deleteModel(_ model: LocalASRModelDefinition) {
guard let catalog, !isInstalling else { return }
Task {
do {
try await manager.deleteModel(model, catalog: catalog)
statusMessage = MacL10n.string("mac.localASR.deleteDone")
reload()
} catch {
statusMessage = error.localizedDescription
}
}
}
func revealModelInFinder(_ model: LocalASRModelDefinition) {
guard let relative = model.installRelativePath else { return }
let url = LocalASRModelInstallState.installDirectory(for: relative)
NSWorkspace.shared.activateFileViewerSelecting([url])
}
/// Opens (creating if needed) the model's shared subfolder so the user can
/// drop in manually-converted weights (used by the MLX model).
func revealModelFolder(_ model: LocalASRModelDefinition) {
guard let relative = model.installRelativePath else { return }
let url = LocalASRModelInstallState.installDirectory(for: relative)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
NSWorkspace.shared.open(url)
}
func revealStorageRoot() {
let url = LocalASRModelInstallState.rootDirectory()
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
NSWorkspace.shared.open(url)
}
func progressLabel(for progress: LocalASRModelInstallProgress, language: AppUILanguage) -> String {
let phaseKey: String
switch progress.phase {
case .downloading: phaseKey = "mac.localASR.phase.downloading"
case .paused: phaseKey = "mac.localASR.phase.paused"
case .extracting: phaseKey = "mac.localASR.phase.extracting"
case .validating: phaseKey = "mac.localASR.phase.validating"
case .finalizing: phaseKey = "mac.localASR.phase.finalizing"
case .failed: phaseKey = "mac.localASR.phase.failed"
case .completed: phaseKey = "mac.localASR.phase.completed"
case .idle: return progress.message
}
let phase = MacL10n.string(phaseKey, language: language)
if let received = progress.bytesReceived, let total = progress.bytesTotal, total > 0 {
let recv = ByteCountFormatter.string(fromByteCount: received, countStyle: .file)
let tot = ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
return "\(phase) · \(progress.message) (\(recv) / \(tot))"
}
return "\(phase) · \(progress.message)"
}
private func startProgressPolling() {
progressPollTask?.cancel()
progressPollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let current = await manager.currentProgress()
await MainActor.run {
self.installProgress = current
self.isDownloadPaused = current.phase == .paused
}
try? await Task.sleep(for: .milliseconds(120))
}
}
}
private func stopProgressPolling() {
progressPollTask?.cancel()
progressPollTask = nil
}
func formattedSize(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
}
struct MacLocalASRModelSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
Group {
if let catalog = modelVM.catalog {
modelPickerSection(catalog: catalog)
runtimeSection(catalog: catalog)
} else {
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
.foregroundStyle(palette.textSecondary)
}
}
.onAppear {
modelVM.onLocalModelStateChanged = { viewModel.bumpLocalModelRevision() }
modelVM.reload()
}
}
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section {
ForEach(catalog.models) { model in
modelRow(model)
}
if modelVM.isInstalling,
modelVM.installProgress.phase == .extracting
|| modelVM.installProgress.phase == .validating
|| modelVM.installProgress.phase == .finalizing {
ProgressView(value: modelVM.installProgress.fraction) {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
.font(TypeStyle.caption)
}
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
} header: {
Text(MacL10n.string("mac.localASR.models", language: lang))
} footer: {
Text(MacL10n.string("mac.localASR.modelsDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
private func runtimeSection(catalog: LocalASRCatalogDocument) -> some View {
Group {
if let runtime = modelVM.currentRuntime(in: catalog) {
Section {
LabeledContent(runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
}
Text(MacL10n.string("mac.localASR.runtimeDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
} header: {
Text(MacL10n.string("mac.localASR.runtime", language: lang))
}
}
}
}
@ViewBuilder
private func modelRow(_ model: LocalASRModelDefinition) -> some View {
let installed = modelVM.isInstalled(model)
let selected = modelVM.selectedModelId == model.id
let installing = modelVM.isInstallingModel(model)
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(alignment: .top) {
Button {
modelVM.selectModel(model.id)
} label: {
HStack(spacing: Spacing.sm) {
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
VStack(alignment: .leading, spacing: 2) {
Text(model.displayName)
.foregroundStyle(palette.textPrimary)
Text(modelSubtitle(model, installed: installed))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
.buttonStyle(.plain)
.disabled(modelVM.isInstalling)
Spacer()
modelRowActions(model: model, installed: installed, installing: installing)
}
}
.padding(.vertical, 2)
}
@ViewBuilder
private func modelRowActions(
model: LocalASRModelDefinition,
installed: Bool,
installing: Bool
) -> some View {
if installing {
HStack(spacing: Spacing.sm) {
circularInstallProgress(for: model)
if modelVM.installProgress.phase == .downloading
|| modelVM.installProgress.phase == .paused {
Button {
if modelVM.isDownloadPaused {
modelVM.resumeDownload()
} else {
modelVM.pauseDownload()
}
} label: {
Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .semibold))
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help(
modelVM.isDownloadPaused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang)
)
}
}
} else if model.installKind == .manual {
Button(MacL10n.string("mac.localASR.openFolder", language: lang)) {
modelVM.revealModelFolder(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if installed {
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
modelVM.deleteModel(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Button(MacL10n.string("mac.localASR.download", language: lang)) {
modelVM.installModel(model)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View {
let fraction: Double = {
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused,
let received = modelVM.installProgress.bytesReceived,
let total = modelVM.installProgress.bytesTotal,
total > 0 {
return min(1, max(0, Double(received) / Double(total)))
}
return modelVM.installProgress.fraction
}()
return ZStack {
Circle()
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
Circle()
.trim(from: 0, to: fraction)
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 0.15), value: fraction)
if modelVM.installProgress.phase == .paused {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary)
} else {
Text("\(Int(fraction * 100))%")
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(palette.textSecondary)
}
}
.frame(width: 36, height: 36)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes)
let hotword = model.supportsHotwords
? MacL10n.string("mac.localASR.hotwordsYes", language: lang)
: MacL10n.string("mac.localASR.hotwordsNo", language: lang)
let state = installed
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
if let usage = modelVM.installedDiskUsage(model) {
return "\(size) · \(hotword) · \(state) · \(usage)"
}
return "\(size) · \(hotword) · \(state)"
}
private var diagnosticsSection: some View {
Section {
if let snapshot = modelVM.diagnosticsSnapshot {
LabeledContent(MacL10n.string("mac.localASR.diagBackend", language: lang)) {
Text(snapshot.backendLabel ?? "")
}
LabeledContent(MacL10n.string("mac.localASR.diagUserTerms", language: lang)) {
Text("\(snapshot.diagnostics.userTermCount)")
}
LabeledContent(MacL10n.string("mac.localASR.diagBuiltinTerms", language: lang)) {
Text("\(snapshot.diagnostics.builtinTermCount)")
}
LabeledContent(MacL10n.string("mac.localASR.diagHotwords", language: lang)) {
Text("\(snapshot.hotwordCount)")
}
LabeledContent(MacL10n.string("mac.localASR.diagPrompt", language: lang)) {
Text("\(snapshot.promptBiasLength)")
}
if snapshot.diagnostics.truncated {
Label(
snapshot.diagnostics.truncationReason ?? MacL10n.string("mac.localASR.diagTruncated", language: lang),
systemImage: "exclamationmark.triangle"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
}
Text(snapshot.diagnostics.selectedSources.joined(separator: ", "))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
} else {
Text(MacL10n.string("mac.localASR.diagEmpty", language: lang))
.foregroundStyle(palette.textSecondary)
}
} header: {
Text(MacL10n.string("mac.localASR.diagnostics", language: lang))
} footer: {
Text(MacL10n.string("mac.localASR.diagnosticsDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
+107 -31
View File
@@ -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)
}
}
}
+3 -2
View File
@@ -50,14 +50,15 @@ actor MacQwen3ASREngine {
func transcribe(
samples: [Float],
language: String?,
modelPath: 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)
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
+6 -2
View File
@@ -12,7 +12,8 @@ enum MacQwen3LocalASR {
samples: [Float],
sampleRate: Int,
locale: Locale,
modelPath: String
modelPath: String,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
@@ -24,11 +25,14 @@ enum MacQwen3LocalASR {
}
let language = MacQwen3LanguageHint.from(locale: locale)
let context = bias?.promptBias?.trimmingCharacters(in: .whitespacesAndNewlines)
let promptContext = (context?.isEmpty == false) ? context : nil
do {
return try await MacQwen3ASREngine.shared.transcribe(
samples: samples,
language: language,
modelPath: modelPath
modelPath: modelPath,
context: promptContext
)
} catch let error as MacLocalASRError {
throw error
-1
View File
@@ -56,7 +56,6 @@ struct MacRootView: View {
Spacer()
devicesFooter
}
.background(palette.surfaceMuted)
}
private func sidebarRow(_ section: MacSection) -> some View {
+2 -52
View File
@@ -37,7 +37,7 @@ struct MacSettingsView: View {
providerSection
}
if viewModel.config.engineMode == "local" {
qwen3Section
MacLocalASRModelSettingsView(viewModel: viewModel)
}
inputSection
syncSection
@@ -147,12 +147,6 @@ struct MacSettingsView: View {
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { viewModel.setEngineMode("local") }
if viewModel.config.engineMode == "local", !viewModel.qwen3ModelInstalled {
Label(MacL10n.string("mac.settings.qwen3Missing", language: lang), systemImage: "exclamationmark.triangle")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
}
}
}
@@ -196,25 +190,7 @@ struct MacSettingsView: View {
}
}
// MARK: - Qwen3 model path
private var qwen3Section: some View {
Section {
HStack(spacing: Spacing.sm) {
TextField("", text: qwen3PathBinding, prompt: Text(verbatim: "~/Models/Qwen3-ASR"))
.macFieldStyle()
Button(MacL10n.string("mac.settings.qwen3Browse", language: lang)) {
pickQwen3Folder()
}
}
} header: {
Text(MacL10n.string("mac.settings.qwen3Model", language: lang))
} footer: {
Text(MacL10n.string("mac.settings.qwen3ModelDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
// MARK: - Qwen3 model path (legacy see MacLocalASRModelSettingsView)
// MARK: - Row helpers
@@ -366,17 +342,6 @@ struct MacSettingsView: View {
)
}
private var qwen3PathBinding: Binding<String> {
Binding(
get: { MacLocalASRPreferences.qwen3ModelPath },
set: { newPath in
MacLocalASRPreferences.qwen3ModelPath = newPath
Task { await MacQwen3ASREngine.shared.unload() }
viewModel.warmUpQwen3IfNeeded()
}
)
}
// MARK: - AppKit actions (macOS only)
private func openAccessibilitySettings() {
@@ -413,19 +378,4 @@ struct MacSettingsView: View {
private var accessibilityStatusNeeded: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "未授权" : "Needed"
}
private func pickQwen3Folder() {
#if os(macOS)
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
MacLocalASRPreferences.qwen3ModelPath = url.path
Task { await MacQwen3ASREngine.shared.unload() }
viewModel.warmUpQwen3IfNeeded()
}
#endif
}
}
+56
View File
@@ -0,0 +1,56 @@
// MacSherpaLocalASR.swift
// OSGKeyboard · Mac
//
// Sherpa-onnx backed local ASR (Qwen3 hotwords POC + SenseVoice baseline).
import Foundation
enum MacSherpaLocalASR {
static func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
model: LocalASRModelDefinition,
bias: LocalASRBiasPayload?
) async throws -> String {
let catalog = try LocalASRModelCatalog.loadBundled()
let manager = LocalASRModelManager.shared
guard let layout = model.layout,
let modelRoot = LocalASRModelInstallState.modelRootURL(model) else {
throw MacLocalASRError.qwen3ModelMissing
}
try await manager.ensureRuntimeInstalled(catalog: catalog)
guard let runtime = LocalASRModelCatalog.runtime(
for: LocalASRModelCatalog.currentRuntimePlatform(),
in: catalog
),
let binary = LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime) else {
throw MacLocalASRError.qwen3LoadFailed("Sherpa runtime binary missing")
}
switch model.backend {
case .sherpaQwen3:
return try await MacSherpaONNXRunner.transcribeQwen3(
samples: samples,
sampleRate: sampleRate,
locale: locale,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary,
bias: bias
)
case .sherpaSenseVoice:
return try await MacSherpaONNXRunner.transcribeSenseVoice(
samples: samples,
sampleRate: sampleRate,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary
)
default:
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
}
}
}
+153
View File
@@ -0,0 +1,153 @@
// MacSherpaONNXRunner.swift
// OSGKeyboard · Mac
//
// Invokes the downloaded `sherpa-onnx-offline` binary for Sherpa-backed POC models.
import Foundation
enum MacSherpaONNXRunner {
static func transcribeQwen3(
samples: [Float],
sampleRate: Int,
locale: Locale,
modelRoot: URL,
layout: LocalASRModelLayout,
runtimeBinary: URL,
bias: LocalASRBiasPayload?
) async throws -> String {
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
}
guard let conv = layout.convFrontend,
let encoder = layout.encoder,
let decoder = layout.decoder,
let tokenizer = layout.tokenizer else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Sherpa Qwen3 layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
var arguments = [
"--qwen3-asr-conv-frontend=\(modelRoot.appendingPathComponent(conv).path)",
"--qwen3-asr-encoder=\(modelRoot.appendingPathComponent(encoder).path)",
"--qwen3-asr-decoder=\(modelRoot.appendingPathComponent(decoder).path)",
"--qwen3-asr-tokenizer=\(modelRoot.appendingPathComponent(tokenizer).path)",
"--qwen3-asr-max-new-tokens=512",
"--num-threads=2",
]
if let language = MacQwen3LanguageHint.from(locale: locale) {
arguments.append("--qwen3-asr-language=\(language)")
}
if let hotwords = bias?.hardHotwords, !hotwords.isEmpty {
arguments.append("--qwen3-asr-hotwords=\(hotwords.joined(separator: ","))")
}
arguments.append(wavURL.path)
return try await run(binary: runtimeBinary, arguments: arguments)
}
static func transcribeSenseVoice(
samples: [Float],
sampleRate: Int,
modelRoot: URL,
layout: LocalASRModelLayout,
runtimeBinary: URL
) async throws -> String {
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
}
guard let model = layout.senseVoiceModel,
let tokens = layout.tokens else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete SenseVoice layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
let arguments = [
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
"--sense-voice-model=\(modelRoot.appendingPathComponent(model).path)",
"--num-threads=2",
wavURL.path,
]
return try await run(binary: runtimeBinary, arguments: arguments)
}
// MARK: - Private
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
let data = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("osg-sherpa-\(UUID().uuidString).wav")
try data.write(to: url, options: .atomic)
return url
}
private static func run(binary: URL, arguments: [String]) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = binary.deletingLastPathComponent()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
process.terminationHandler = { proc in
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: outputData, encoding: .utf8) ?? ""
let stderr = String(data: errorData, encoding: .utf8) ?? ""
guard proc.terminationStatus == 0 else {
let detail = stderr.isEmpty ? stdout : stderr
continuation.resume(
throwing: MacLocalASRError.qwen3InferenceFailed(
detail.trimmingCharacters(in: .whitespacesAndNewlines)
)
)
return
}
let text = parseTranscript(stdout: stdout)
if text.isEmpty {
continuation.resume(throwing: MacLocalASRError.emptyTranscript)
} else {
continuation.resume(returning: text)
}
}
do {
try process.run()
} catch {
continuation.resume(throwing: MacLocalASRError.qwen3InferenceFailed(error.localizedDescription))
}
}
}
private static func parseTranscript(stdout: String) -> String {
let lines = stdout
.split(whereSeparator: \.isNewline)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
for line in lines.reversed() {
if line.hasPrefix("{"), let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
return line
}
}
return ""
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
+50 -7
View File
@@ -86,6 +86,20 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
MacAppearancePreference.applyToApp(.current)
configurePopover()
configureStatusItem()
// The menu bar always follows the *system* appearance, so the status
// item must ignore the app's forced light/dark override. Re-pin the
// button appearance whenever the system theme flips.
DistributedNotificationCenter.default.addObserver(
self,
selector: #selector(systemAppearanceDidChange),
name: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
object: nil
)
}
deinit {
DistributedNotificationCenter.default.removeObserver(self)
}
/// Keep the app alive after the last window closes it lives in the menu bar.
@@ -96,18 +110,47 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
private func configureStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = item.button {
// Prefer the brand mark; fall back to an SF Symbol so the item is
// never invisible even if the asset fails to resolve.
let image = NSImage(named: "OSGBrandMark")
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard")
image?.isTemplate = true
image?.size = NSSize(width: 18, height: 18)
button.image = image
button.image = Self.makeStatusBarImage()
button.image?.accessibilityDescription = "OSGKeyboard"
button.action = #selector(togglePopover(_:))
button.target = self
}
statusItem = item
applyStatusItemAppearance()
}
/// Builds the menu-bar glyph from the dedicated horizontal status mark.
/// Height is pinned to the tallest practical menu-bar slot so the logo reads
/// clearly; width follows the asset's aspect ratio.
private static func makeStatusBarImage() -> NSImage? {
guard let image = NSImage(named: "OSGStatusMark")
?? NSImage(named: "OSGBrandMark")
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard") else {
return nil
}
let height: CGFloat = 9
let aspect = max(image.size.width / max(image.size.height, 1), 1)
image.size = NSSize(width: height * aspect, height: height)
image.isTemplate = true
return image
}
/// Pins the status-bar button to the current *system* appearance so its
/// template image tint matches the real menu-bar background regardless of
/// the in-app light/dark preference forced on `NSApp.appearance`.
private func applyStatusItemAppearance() {
guard let button = statusItem?.button else { return }
let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle")?
.lowercased().contains("dark") ?? false
button.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua)
}
@objc private func systemAppearanceDidChange() {
// The global-domain default lags the notification by a hair; hop to the
// next runloop tick so `AppleInterfaceStyle` reflects the new value.
DispatchQueue.main.async { [weak self] in
self?.applyStatusItemAppearance()
}
}
private func configurePopover() {