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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,7 +56,6 @@ struct MacRootView: View {
|
||||
Spacer()
|
||||
devicesFooter
|
||||
}
|
||||
.background(palette.surfaceMuted)
|
||||
}
|
||||
|
||||
private func sidebarRow(_ section: MacSection) -> some View {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user