feat(mac): add Qwen3 MLX streaming dictation

Replace the Sherpa offline pipeline with native MLX streaming, resilient model downloads, live transcript previews, and supporting tests and documentation.
This commit is contained in:
Rocky
2026-07-23 14:34:56 +08:00
parent f1a811fbf0
commit c0c9dad149
35 changed files with 1373 additions and 764 deletions
+48 -40
View File
@@ -22,33 +22,35 @@ struct DashboardView: View {
}
var body: some View {
// Scrollable like the other pages so the shared status footer always
// stays pinned to the window's bottom edge. `minHeight: viewport` keeps
// the balanced Spacer layout when the window is tall (no scrollbar) and
// lets the content scroll only when the window is too short to fit it.
// +
//
//
// ScrollViewScrollView
// `.frame(maxHeight: .infinity)`
// 600 GeometryReader
// iOS HomeView
GeometryReader { proxy in
ScrollView {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
dictationStage
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
// Leftover window height splits evenly above / below the mic
// bar so spacing stays balanced at any window size. The top
// gap keeps a larger floor so the mic never hugs the canvas.
Spacer(minLength: 30)
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
Spacer(minLength: Spacing.xs)
VStack(spacing: 0) {
// +
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
}
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
//
dictationStage
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// +
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.xs)
}
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .top)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
@@ -105,7 +107,24 @@ struct DashboardView: View {
private var dictationStage: some View {
MacCard(padding: Spacing.md, cornerRadius: Radius.large) {
ZStack(alignment: .topLeading) {
if viewModel.transcript.isEmpty {
if viewModel.hasHomePreview {
//
ScrollView {
Text(viewModel.homePreviewText)
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.lineSpacing(4)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.defaultScrollAnchor(.bottom)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
} else {
Text(
viewModel.isRecording
? MacL10n.string("mac.status.listening", language: lang)
@@ -121,24 +140,13 @@ struct DashboardView: View {
alignment: .topLeading
)
.transition(.opacity)
} else {
Text(viewModel.transcript)
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.lineSpacing(4)
.textSelection(.enabled)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity)
}
}
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160)
//
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: .infinity)
}
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.frame(maxHeight: .infinity)
.animation(Motion.soft, value: viewModel.hasHomePreview)
.animation(Motion.quick, value: viewModel.isRecording)
}
@@ -24,7 +24,34 @@ final class MacDictationOverlayController {
private let bottomMargin: CGFloat = 36
private let fallbackSize = NSSize(width: 400, height: 52)
private init() {}
// MARK: - User-draggable position (persisted across launches)
/// True once the user has dragged the HUD; suppresses the default
/// bottom-center snap so the panel stays where the user placed it.
private var hasCustomPosition = false
/// Stored as center-X + bottom-left Y so the anchor stays stable while the
/// pill grows / shrinks with the live transcript (symmetric resize).
private var customCenterX: CGFloat = 0
private var customOriginY: CGFloat = 0
/// The origin we last set programmatically (kept for clamping / bookkeeping).
private var lastProgrammaticOrigin: NSPoint?
/// Cursor + window origin captured at the start of a manual drag, so we can
/// follow the absolute cursor and stay immune to the window moving under it.
private var dragCursorStart: NSPoint?
private var dragWindowStart: NSPoint?
private static let hasCustomPositionKey = "mac.overlay.hasCustomPosition"
private static let centerXKey = "mac.overlay.centerX"
private static let originYKey = "mac.overlay.originY"
private init() {
let defaults = UserDefaults.standard
if defaults.bool(forKey: Self.hasCustomPositionKey) {
hasCustomPosition = true
customCenterX = CGFloat(defaults.double(forKey: Self.centerXKey))
customOriginY = CGFloat(defaults.double(forKey: Self.originYKey))
}
}
func start(observing viewModel: MacDictationViewModel) {
guard cancellables.isEmpty else { return }
@@ -134,6 +161,8 @@ final class MacDictationOverlayController {
// full-screen apps, without going as high as the screen saver.
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
// Background dragging can't move a non-activating panel; we drive the
// drag ourselves from a SwiftUI DragGesture (see `dragMoved`).
panel.isMovableByWindowBackground = false
panel.hidesOnDeactivate = false
panel.ignoresMouseEvents = false
@@ -147,10 +176,15 @@ final class MacDictationOverlayController {
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
AnyView(
MacDictationOverlayView(viewModel: viewModel)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
MacDictationOverlayView(
viewModel: viewModel,
onDragChanged: { [weak self] in self?.dragMoved() },
onDragEnded: { [weak self] in self?.dragEnded() },
onResetPosition: { [weak self] in self?.resetPositionToDefault() }
)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
)
}
@@ -158,18 +192,27 @@ final class MacDictationOverlayController {
guard let panel, let hosting else { return }
hosting.layoutSubtreeIfNeeded()
let fitting = hosting.fittingSize
// Bounds include the 32pt horizontal transparent margin around the pill
// (16 per side) that gives the shadow room, so the pill body itself
// still spans ~300520.
let width = fitting.width.isFinite && fitting.width > 1
? min(max(fitting.width, 300), 520)
? min(max(fitting.width, 332), 552)
: fallbackSize.width
let height = fitting.height.isFinite && fitting.height > 1
? max(fitting.height, fallbackSize.height)
: fallbackSize.height
var frame = panel.frame
let midX = frame.midX
// Grow / shrink around the anchor center so the pill stays put: the
// dragged center when custom, otherwise its current center.
let targetMidX = hasCustomPosition ? customCenterX : frame.midX
frame.size = NSSize(width: width, height: height)
if midX.isFinite {
frame.origin.x = midX - width / 2
if targetMidX.isFinite {
frame.origin.x = targetMidX - width / 2
}
if let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame {
frame.origin = clampedOrigin(frame.origin, size: frame.size, in: visible)
}
lastProgrammaticOrigin = frame.origin
panel.setFrame(frame, display: true)
hosting.frame = NSRect(origin: .zero, size: frame.size)
}
@@ -179,13 +222,89 @@ final class MacDictationOverlayController {
let screen = NSScreen.main ?? NSScreen.screens.first
guard let visible = screen?.visibleFrame else { return }
let size = panel.frame.size
let origin = NSPoint(
x: visible.midX - size.width / 2,
y: visible.minY + bottomMargin
)
// Respect the user's dragged spot; otherwise snap to bottom-center.
let desired: NSPoint
if hasCustomPosition {
desired = NSPoint(x: customCenterX - size.width / 2, y: customOriginY)
} else {
desired = NSPoint(
x: visible.midX - size.width / 2,
y: visible.minY + bottomMargin
)
}
let origin = clampedOrigin(desired, size: size, in: visible)
lastProgrammaticOrigin = origin
panel.setFrameOrigin(origin)
}
/// Keep the panel fully inside the screen's visible frame so a dragged /
/// restored position can never strand it off-screen (e.g. after a display
/// or resolution change).
private func clampedOrigin(_ origin: NSPoint, size: NSSize, in visible: NSRect) -> NSPoint {
guard visible.width >= size.width, visible.height >= size.height else {
return origin
}
let x = min(max(origin.x, visible.minX), visible.maxX - size.width)
let y = min(max(origin.y, visible.minY), visible.maxY - size.height)
return NSPoint(x: x, y: y)
}
/// Follows the absolute cursor while dragging. Reading `NSEvent.mouseLocation`
/// (screen coordinates) instead of the gesture's local translation avoids the
/// feedback loop you'd get from moving the window the gesture lives in.
private func dragMoved() {
guard let panel else { return }
let cursor = NSEvent.mouseLocation
if dragCursorStart == nil {
dragCursorStart = cursor
dragWindowStart = panel.frame.origin
}
guard let cursorStart = dragCursorStart, let windowStart = dragWindowStart else { return }
let target = NSPoint(
x: windowStart.x + (cursor.x - cursorStart.x),
y: windowStart.y + (cursor.y - cursorStart.y)
)
let size = panel.frame.size
let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame
let origin = visible.map { clampedOrigin(target, size: size, in: $0) } ?? target
lastProgrammaticOrigin = origin
panel.setFrameOrigin(origin)
}
/// Persist the dragged spot as center-X + bottom-left Y.
private func dragEnded() {
dragCursorStart = nil
dragWindowStart = nil
guard let panel else { return }
customCenterX = panel.frame.midX
customOriginY = panel.frame.origin.y
hasCustomPosition = true
persistPosition()
}
/// Double-clicking the pill clears the custom spot and returns it to the
/// default bottom-center.
private func resetPositionToDefault() {
hasCustomPosition = false
clearPersistedPosition()
resizeToFit()
reposition()
}
private func persistPosition() {
let defaults = UserDefaults.standard
defaults.set(hasCustomPosition, forKey: Self.hasCustomPositionKey)
defaults.set(Double(customCenterX), forKey: Self.centerXKey)
defaults.set(Double(customOriginY), forKey: Self.originYKey)
}
private func clearPersistedPosition() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: Self.hasCustomPositionKey)
defaults.removeObject(forKey: Self.centerXKey)
defaults.removeObject(forKey: Self.originYKey)
}
private func scheduleHide() {
hideWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
+38 -5
View File
@@ -9,6 +9,12 @@ import SwiftUI
struct MacDictationOverlayView: View {
@ObservedObject var viewModel: MacDictationViewModel
/// Called continuously while the user drags the pill (reads the live cursor
/// position on the controller side). Double-click resets to the default.
var onDragChanged: (() -> Void)?
var onDragEnded: (() -> Void)?
/// Double-click anywhere on the pill to snap it back to the default spot.
var onResetPosition: (() -> Void)?
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -35,6 +41,9 @@ struct MacDictationOverlayView: View {
Spacer(minLength: Spacing.xs)
trailingControl
}
// Fixed content height so the pill never changes height between
// (waveform, 28pt) and (small spinner) states.
.frame(height: 28)
.padding(.horizontal, Spacing.md)
.padding(.vertical, 11)
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
@@ -45,7 +54,22 @@ struct MacDictationOverlayView: View {
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
.padding(2)
// Transparent margin large enough to contain the shadow's reach
// (radius 14 + y 5). The panel is sized to `fittingSize`, which ignores
// shadow, so without this room the borderless window clips the shadow
// into hard translucent-black corners.
.padding(EdgeInsets(top: 12, leading: 16, bottom: 20, trailing: 16))
.contentShape(Capsule(style: .continuous))
// Manual drag: `isMovableByWindowBackground` doesn't work on a
// non-activating panel, so we move the panel ourselves. The controller
// reads the live cursor position, so the translation value is unused.
.gesture(
DragGesture(minimumDistance: 3)
.onChanged { _ in onDragChanged?() }
.onEnded { _ in onDragEnded?() }
)
.onTapGesture(count: 2) { onResetPosition?() }
.help(MacL10n.string("mac.overlay.dragHint", language: lang))
.animation(Motion.soft, value: hasPreview)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
@@ -65,9 +89,7 @@ struct MacDictationOverlayView: View {
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
.contentTransition(.opacity)
.animation(Motion.quick, value: previewText)
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .trailing)
.accessibilityLabel(previewText)
}
} else {
@@ -133,8 +155,17 @@ struct MacDictationOverlayView: View {
return MacL10n.string("mac.overlay.done", language: lang)
}
@ViewBuilder
// Fixed-size trailing slot so the pill width / height stays steady as the
// control swaps between waveform, spinner and checkmark.
private var trailingControl: some View {
HStack(spacing: Spacing.sm) {
trailingContent
}
.frame(height: 28, alignment: .trailing)
}
@ViewBuilder
private var trailingContent: some View {
if viewModel.isRecording {
MiniWaveform(
level: viewModel.audioLevel,
@@ -149,10 +180,12 @@ struct MacDictationOverlayView: View {
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
ProgressView()
.controlSize(.small)
.frame(width: 28, height: 28)
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
.frame(width: 28, height: 28)
}
}
+25 -96
View File
@@ -1,7 +1,7 @@
// MacDictationPipeline.swift
// OSGKeyboard · Mac
//
// Dictation pipeline: samples ASR (cloud or local, chunked when long) polish.
// Dictation pipeline: samples ASR (cloud or local MLX streaming) polish.
import Foundation
@@ -32,14 +32,11 @@ struct MacLiveASRCaptureResult: Sendable {
}
enum MacDictationPipeline {
/// First-chunk threshold: longer local utterances use pipelined chunk ASR.
private static let chunkedLocalThresholdSamples = Int(
FlowUtteranceChunkConfig.flowDefault.maxChunkDurationSeconds(forChunkIndex: 0) * 16_000
)
/// Whether the active engine can surface `onPartial` text while recording.
static func supportsLivePartials(store: AppGroupStore) -> Bool {
if store.engineMode == "local" { return true }
if store.engineMode == "local" {
return MacLocalASRService.usesMLXLiveStreaming()
}
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
return strategy != .localFallback
}
@@ -53,28 +50,17 @@ enum MacDictationPipeline {
guard !samples.isEmpty else { throw MacDictationError.noAudio }
let locale = resolvedLocale(store: store)
var chunkWarning: String?
let raw: String
var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
localBias = resolveLocalBias(store: store, locale: locale)
if samples.count > chunkedLocalThresholdSamples {
let chunked = try await transcribeLocalChunked(
samples: samples,
locale: locale,
bias: localBias,
onPartial: onPartial
)
raw = chunked.text
chunkWarning = chunked.chunkWarning
} else {
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: localBias
)
}
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: localBias
)
onPartial?(raw)
} else {
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
@@ -93,16 +79,26 @@ enum MacDictationPipeline {
raw: raw,
store: store,
localBias: localBias,
chunkWarning: chunkWarning
chunkWarning: nil
)
}
/// Consumes a live mic snapshot stream until finished; yields stitched partials.
static func captureLive(
stream: AsyncStream<AudioBufferSnapshot>,
finishSignal: AsyncStream<Void>,
store: AppGroupStore,
onPartial: @escaping @Sendable (String) -> Void
) async -> MacLiveASRCaptureResult {
if store.engineMode == "local", MacLocalASRService.usesMLXLiveStreaming() {
return await MacMLXLiveCapture.run(
audioStream: stream,
finishSignal: finishSignal,
store: store,
onPartial: onPartial
)
}
let locale = resolvedLocale(store: store)
let localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
@@ -112,11 +108,7 @@ enum MacDictationPipeline {
}
do {
let adapter = try makeChunkASRAdapter(
store: store,
locale: locale,
bias: localBias
)
let adapter = try makeChunkASRAdapter(store: store)
if let cloudAdapter = adapter as? MacCloudASRChunkAdapter {
try? await cloudAdapter.prepare()
}
@@ -219,12 +211,7 @@ enum MacDictationPipeline {
}
}
// MARK: - Chunked local ASR
private struct ChunkedLocalResult {
let text: String
let chunkWarning: String?
}
// MARK: - Private
private static func resolvedLocale(store: AppGroupStore) -> Locale {
Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
@@ -252,65 +239,7 @@ enum MacDictationPipeline {
return bias
}
private static func makeChunkASRAdapter(
store: AppGroupStore,
locale: Locale,
bias: LocalASRBiasPayload?
) throws -> any ASRChunkTranscribing {
if store.engineMode == "local" {
return MacLocalASRChunkAdapter(locale: locale, bias: bias)
}
return try MacCloudASRChunkAdapter(store: store)
}
private static func transcribeLocalChunked(
samples: [Float],
locale: Locale,
bias: LocalASRBiasPayload?,
onPartial: (@Sendable (String) -> Void)?
) async throws -> ChunkedLocalResult {
let adapter = MacLocalASRChunkAdapter(locale: locale, bias: bias)
let pipeline = ChunkedUtterancePipeline(
asr: adapter,
locale: locale,
config: .flowDefault
)
let outcome = await pipeline.transcribe(
stream: audioStream(from: samples),
onPartial: { partial in
onPartial?(partial)
}
)
switch outcome {
case .success(let success):
let warning = success.chunkWarnings.first
return ChunkedLocalResult(text: success.text, chunkWarning: warning)
case .failure(let message):
throw MacLocalASRError.qwen3InferenceFailed(message)
case .cancelled:
throw MacLocalASRError.qwen3InferenceFailed("Cancelled")
}
}
/// Feeds recorded PCM into the chunker as if it arrived incrementally.
private static func audioStream(
from samples: [Float],
sliceSamples: Int = 8_000
) -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { continuation in
var offset = 0
while offset < samples.count {
let end = min(offset + sliceSamples, samples.count)
continuation.yield(
AudioBufferSnapshot(
samples: Array(samples[offset..<end]),
sampleRate: 16_000
)
)
offset = end
}
continuation.finish()
}
private static func makeChunkASRAdapter(store: AppGroupStore) throws -> any ASRChunkTranscribing {
try MacCloudASRChunkAdapter(store: store)
}
}
+66 -4
View File
@@ -52,7 +52,12 @@ final class MacDictationViewModel: ObservableObject {
@Published var isProcessing = false
/// True once live ASR has surfaced at least one partial during this take.
@Published private(set) var isStreamingPartial = false
/// Live text for the *current* take (drives the floating HUD). Reset on
/// every new Option press.
@Published var transcript = ""
/// Running overview of every finalized take this app run the Home card
/// accumulates sessions here so earlier utterances are never overwritten.
@Published private(set) var overviewTranscript = ""
@Published var statusMessage = ""
@Published var audioLevel: Float = 0
@Published var sessionSeconds: Int = 0
@@ -74,9 +79,10 @@ final class MacDictationViewModel: ObservableObject {
/// In-flight `beginRecording` started by the hotkey cancelled if the
/// key is released before the engine is ready (avoids a stuck session).
private var hotkeyBeginTask: Task<Void, Never>?
/// Live chunked ASR while recording (cloud / supported local paths).
/// Live chunked / streaming ASR while recording (cloud or MLX local).
/// Finished in `finishRecording` so partials can become the final draft.
private var liveCaptureTask: Task<MacLiveASRCaptureResult, Never>?
private var liveFinishContinuation: AsyncStream<Void>.Continuation?
let usageStatistics: UsageStatisticsStore
let speechHistory = SpeechHistoryStore.shared
@@ -154,6 +160,22 @@ final class MacDictationViewModel: ObservableObject {
transcript.split { $0 == " " || $0 == "\n" || $0 == "\t" }.count
}
/// Text the Home overview card shows: all finalized takes plus the live
/// current take appended at the end while recording / processing.
var homePreviewText: String {
let live = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
if live.isEmpty { return overviewTranscript }
if overviewTranscript.isEmpty { return live }
return overviewTranscript + "\n" + live
}
var hasHomePreview: Bool { !homePreviewText.isEmpty }
/// Clears the Home overview (the running session log), leaving any live take.
func clearOverview() {
overviewTranscript = ""
}
var isCloudMode: Bool { config.engineMode == "cloud" }
var languageLabel: String {
@@ -276,8 +298,19 @@ final class MacDictationViewModel: ObservableObject {
)
stopTimers()
audioLevel = 0
let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults)
let usesDeferredStop = MacDictationPipeline.supportsLivePartials(store: store)
&& store.engineMode == "local"
&& MacLocalASRService.usesMLXLiveStreaming()
let samples: [Float]
if usesDeferredStop {
liveFinishContinuation?.yield(())
samples = []
} else {
liveFinishContinuation?.finish()
liveFinishContinuation = nil
samples = recorder.stop()
}
let liveTask = liveCaptureTask
liveCaptureTask = nil
@@ -285,8 +318,14 @@ final class MacDictationViewModel: ObservableObject {
guard let self else { return }
do {
let result: MacDictationResult
let capturedSamples: [Float]
if let liveTask {
let capture = await Self.awaitLiveCapture(liveTask)
if usesDeferredStop {
capturedSamples = self.recorder.stop()
} else {
capturedSamples = samples
}
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
if self.transcript.isEmpty {
@@ -300,7 +339,7 @@ final class MacDictationViewModel: ObservableObject {
)
} else {
result = try await MacDictationPipeline.run(
samples: samples,
samples: capturedSamples,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
@@ -310,8 +349,9 @@ final class MacDictationViewModel: ObservableObject {
)
}
} else {
capturedSamples = usesDeferredStop ? self.recorder.stop() : samples
result = try await MacDictationPipeline.run(
samples: samples,
samples: capturedSamples,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
@@ -324,6 +364,7 @@ final class MacDictationViewModel: ObservableObject {
let pasted = try await self.deliver(result.text)
self.recordUsage(for: result.text)
self.speechHistory.append(text: result.text)
self.appendToOverview(result.text)
self.statusMessage = self.statusAfterDelivery(
pasted: pasted,
polishWarning: result.polishWarning,
@@ -332,17 +373,28 @@ final class MacDictationViewModel: ObservableObject {
} catch {
self.statusMessage = error.localizedDescription
}
// The finalized take now lives in the overview; clear the live take
// so the HUD flashes its completion state and the next press starts
// fresh without overwriting the Home overview.
self.transcript = ""
self.isStreamingPartial = false
self.isProcessing = false
self.liveFinishContinuation?.finish()
self.liveFinishContinuation = nil
}
}
private func startLiveCaptureIfSupported(store: AppGroupStore) {
guard MacDictationPipeline.supportsLivePartials(store: store) else { return }
let stream = recorder.makeSnapshotStream()
let (finishStream, finishContinuation) = AsyncStream<Void>.makeStream(
bufferingPolicy: .bufferingNewest(1)
)
liveFinishContinuation = finishContinuation
liveCaptureTask = Task { [weak self] in
await MacDictationPipeline.captureLive(
stream: stream,
finishSignal: finishStream,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
@@ -359,6 +411,8 @@ final class MacDictationViewModel: ObservableObject {
}
private func cancelLiveCapture() {
liveFinishContinuation?.finish()
liveFinishContinuation = nil
liveCaptureTask?.cancel()
liveCaptureTask = nil
isStreamingPartial = false
@@ -475,6 +529,14 @@ final class MacDictationViewModel: ObservableObject {
sessionTimer = nil
}
private func appendToOverview(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
overviewTranscript = overviewTranscript.isEmpty
? trimmed
: overviewTranscript + "\n" + trimmed
}
private func recordUsage(for text: String) {
usageStatistics.recordUtterance(
text: text,
@@ -0,0 +1,85 @@
// MacHallucinationFilter.swift
// OSGKeyboard · Mac
//
// Strips Qwen3 / MLX streaming scaffold tokens and silence hallucinations.
import Foundation
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
}
}
enum MacHallucinationFilter {
/// RMS below this skips feeding audio into the MLX streaming session.
static let silencePeakThreshold: Float = 0.0005
static func strip(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty { return "" }
if let marker = text.range(of: "<asr_text>", options: .backwards) {
text = String(text[marker.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
} else if let match = text.range(
of: #"^language\s+\S+\s*"#,
options: [.regularExpression, .caseInsensitive]
) {
text = String(text[match.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
if isMetadataNoiseLine(text) { return "" }
return text
}
/// When the transcript is mostly vocabulary tokens and audio energy stayed low, drop it.
static func shouldDiscardHotwordDump(
text: String,
peakRMS: Float,
bias: LocalASRBiasPayload?
) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return true }
guard peakRMS < FlowCaptureTailDrainPolicy.flowDefault.silenceRMSThreshold else {
return false
}
guard let bias, !bias.hardHotwords.isEmpty else { return false }
let lowered = trimmed.lowercased()
let hits = bias.hardHotwords.filter { lowered.contains($0.lowercased()) }.count
let wordCount = max(1, trimmed.split { $0.isWhitespace }.count)
return hits >= wordCount
}
private static func isMetadataNoiseLine(_ line: String) -> Bool {
let lowered = line.lowercased()
switch lowered {
case "language", "emotion", "event", "text",
"<asr_text>", "</asr_text>", "<|im_end|>":
return true
default:
if lowered.range(
of: #"^language(\s+\S+)?$"#,
options: .regularExpression
) != nil {
return true
}
return false
}
}
}
@@ -1,43 +0,0 @@
// MacLocalASRChunkAdapter.swift
// OSGKeyboard · Mac
//
// Adapts macOS local ASR to the shared chunked utterance pipeline.
import Foundation
import os
final class MacLocalASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
private let locale: Locale
private let bias: LocalASRBiasPayload?
private let cancelled = OSAllocatedUnfairLock(initialState: false)
init(locale: Locale, bias: LocalASRBiasPayload?) {
self.locale = locale
self.bias = bias
}
func resetForNewUtterance() {
cancelled.withLock { $0 = false }
}
func cancel() {
cancelled.withLock { $0 = true }
}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
let isCancelled = cancelled.withLock { $0 }
if isCancelled || Task.isCancelled { return .cancelled }
guard !samples.isEmpty else { return .success("") }
do {
let text = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
return .success(text)
} catch {
return .failure(error.localizedDescription)
}
}
}
@@ -10,6 +10,7 @@ import SwiftUI
final class MacLocalASRModelSettingsViewModel: ObservableObject {
@Published var catalog: LocalASRCatalogDocument?
@Published var selectedModelId: String = MacLocalASRPreferences.selectedModelId
@Published var downloadSource: LocalASRDownloadSourcePreference = MacLocalASRPreferences.downloadSource
@Published var installProgress = LocalASRModelInstallProgress.idle
@Published var diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
@Published var statusMessage = ""
@@ -76,15 +77,21 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
onLocalModelStateChanged?()
}
func setDownloadSource(_ source: LocalASRDownloadSourcePreference) {
downloadSource = source
MacLocalASRPreferences.downloadSource = source
}
func installModel(_ model: LocalASRModelDefinition) {
guard let catalog, !isInstalling else { return }
statusMessage = ""
isInstalling = true
isDownloadPaused = false
startProgressPolling()
let preferredSource = downloadSource
Task {
do {
try await manager.installModel(model, catalog: catalog)
try await manager.installModel(model, catalog: catalog, preferredSource: preferredSource)
installProgress = await manager.currentProgress()
selectModel(model.id)
statusMessage = MacL10n.string("mac.localASR.installDone")
@@ -222,18 +229,6 @@ struct MacLocalASRModelSettingsView: View {
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
if let runtime = modelVM.currentRuntime(in: catalog) {
MacFormSubtitleRow(title: runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
}
ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
modelRow(model)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
@@ -260,6 +255,10 @@ struct MacLocalASRModelSettingsView: View {
.padding(.horizontal, MacMetrics.settingsCardInset)
}
downloadSourceRow
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
@@ -271,6 +270,29 @@ struct MacLocalASRModelSettingsView: View {
}
}
private var downloadSourceRow: some View {
HStack(spacing: Spacing.sm) {
Text(MacL10n.string("mac.localASR.downloadSource", language: lang))
.foregroundStyle(palette.textSecondary)
Spacer(minLength: 0)
Picker("", selection: Binding(
get: { modelVM.downloadSource },
set: { modelVM.setDownloadSource($0) }
)) {
Text(MacL10n.string("mac.localASR.downloadSource.auto", language: lang))
.tag(LocalASRDownloadSourcePreference.auto)
Text(MacL10n.string("mac.localASR.downloadSource.hfMirror", language: lang))
.tag(LocalASRDownloadSourcePreference.hfMirror)
Text(MacL10n.string("mac.localASR.downloadSource.huggingface", language: lang))
.tag(LocalASRDownloadSourcePreference.huggingface)
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.disabled(modelVM.isInstalling)
}
}
@ViewBuilder
private func modelRow(_ model: LocalASRModelDefinition) -> some View {
let installed = modelVM.isInstalled(model)
+40 -20
View File
@@ -2,14 +2,12 @@
// OSGKeyboard · Mac
//
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback.
// Qwen3 MLX streaming (default), Apple Speech fallback.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case sherpaQwen3
case sherpaParaformer
case sherpaSenseVoice
case mlxQwen3
case appleSpeech
}
@@ -42,36 +40,53 @@ enum MacLocalASRError: Error, LocalizedError {
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
/// Legacy MLX path key retained for migration only.
static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
static let downloadSourceKey = LocalASRPreferenceKeys.downloadSource
/// Preferred model download mirror; `.auto` picks by region (hf-mirror-friendly).
static var downloadSource: LocalASRDownloadSourcePreference {
get {
guard let raw = UserDefaults.standard.string(forKey: downloadSourceKey),
let value = LocalASRDownloadSourcePreference(rawValue: raw) else {
return .auto
}
return value
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: downloadSourceKey) }
}
static var selectedModelId: String {
get {
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
return migratedModelId(raw)
}
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "sherpa-qwen3-0.6b-int8"
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-0.6b-4bit"
}
set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
}
/// Maps removed catalog entries to the current default Sherpa model.
/// Maps removed Sherpa / legacy catalog entries to the current MLX default.
static func migratedModelId(_ id: String) -> String {
switch id {
case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8":
return "sherpa-qwen3-0.6b-int8"
case "sherpa-qwen3-0.6b-int8",
"sherpa-qwen3-1.7b-int8",
"sherpa-sensevoice-small-int8",
"sherpa-paraformer-zh-int8",
"qwen3-mlx-1.7b":
return "qwen3-mlx-0.6b-4bit"
default:
return id
}
}
static var legacyBackend: MacLocalASRBackend {
guard let raw = UserDefaults.standard.string(forKey: backendKey),
let value = MacLocalASRBackend(rawValue: raw) else {
return .sherpaQwen3
guard let raw = UserDefaults.standard.string(forKey: backendKey) else {
return .mlxQwen3
}
if raw == "qwen3MLX" { return .sherpaQwen3 }
return value
if raw == "qwen3MLX" || raw == "sherpaQwen3" || raw == "mlxQwen3" {
return .mlxQwen3
}
if raw == "appleSpeech" { return .appleSpeech }
return .mlxQwen3
}
}
@@ -110,6 +125,12 @@ enum MacLocalASRService {
)
}
/// Whether the active local engine uses MLX streaming for live partials.
static func usesMLXLiveStreaming() -> Bool {
guard let model = selectedModelDefinition() else { return false }
return model.backend == .mlx && isModelInstalled(model)
}
/// Transcribe using the selected catalog model, falling back to Apple Speech.
static func transcribe(
samples: [Float],
@@ -131,17 +152,16 @@ enum MacLocalASRService {
) async throws -> String {
switch model.backend {
case .mlx:
throw MacLocalASRError.qwen3ModelMissing
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
return try await MacSherpaLocalASR.transcribe(
return try await MacMLXStreamingASRProvider.shared.transcribeBatch(
samples: samples,
sampleRate: 16_000,
locale: locale,
model: model,
locale: locale,
bias: bias
)
case .appleSpeech:
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
throw MacLocalASRError.qwen3ModelMissing
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// MacMLXLiveCapture.swift
// OSGKeyboard · Mac
//
// MLX streaming live capture: feed mic snapshots, tail drain, finalize.
import Foundation
import os
enum MacMLXLiveCapture {
private static let tailDrainPolicy = FlowCaptureTailDrainPolicy(
silenceRMSThreshold: 0.015,
silenceDurationSeconds: 0.35,
maxDrainSeconds: 0.75
)
/// Runs MLX streaming ASR until `finishSignal` fires, then tail-drains and finalizes.
static func run(
audioStream: AsyncStream<AudioBufferSnapshot>,
finishSignal: AsyncStream<Void>,
store: AppGroupStore,
onPartial: @escaping @Sendable (String) -> Void
) async -> MacLiveASRCaptureResult {
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let bias = resolveBias(store: store, locale: locale)
guard let model = MacLocalASRService.selectedModelDefinition(),
model.backend == .mlx,
MacLocalASRService.isModelInstalled(model) else {
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: bias,
shouldFallbackToBatch: true
)
}
do {
try await MacMLXStreamingASRProvider.shared.prepare(model: model)
let session = try await MacMLXStreamingASRProvider.shared.makeSession(
model: model,
bias: bias,
locale: locale
)
session.onDisplayUpdate = { text in
onPartial(text)
}
let drainTracker = FlowCaptureDrainTracker()
let draining = OSAllocatedUnfairLock(initialState: false)
let pendingFeed = OSAllocatedUnfairLock(initialState: [Float]())
let feedIntervalSamples = 1_600 // 100 ms @ 16 kHz
await withTaskGroup(of: Void.self) { group in
group.addTask {
// Only the first finish signal matters. The stream is only
// *yielded* to (never `finish()`ed) on the deferred-stop
// path, so without this `break` the loop would await a
// second element forever and hang the whole task group
// until the 120s hard timeout.
for await _ in finishSignal {
draining.withLock { $0 = true }
drainTracker.beginDrain()
break
}
}
group.addTask {
for await snapshot in audioStream {
if Task.isCancelled { break }
if draining.withLock({ $0 }) {
drainTracker.noteAudio(samples: snapshot.samples, policy: tailDrainPolicy)
let decision = drainTracker.shouldFinish(policy: tailDrainPolicy)
if decision.finished { break }
}
pendingFeed.withLock { buffer in
buffer.append(contentsOf: snapshot.samples)
while buffer.count >= feedIntervalSamples {
let chunk = Array(buffer.prefix(feedIntervalSamples))
buffer.removeFirst(feedIntervalSamples)
session.feed(samples: chunk)
}
}
}
}
}
let remainder = pendingFeed.withLock { $0 }
if !remainder.isEmpty, !Task.isCancelled {
session.feed(samples: remainder)
}
if Task.isCancelled {
session.cancel()
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: bias,
shouldFallbackToBatch: true
)
}
let raw = try await session.stop()
if MacHallucinationFilter.shouldDiscardHotwordDump(
text: raw,
peakRMS: session.peakAudioRMS(),
bias: bias
) {
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: bias,
shouldFallbackToBatch: true
)
}
return MacLiveASRCaptureResult(
raw: raw,
chunkWarning: nil,
localBias: bias,
shouldFallbackToBatch: false
)
} catch {
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: bias,
shouldFallbackToBatch: true
)
}
}
private static func resolveBias(store: AppGroupStore, locale: Locale) -> LocalASRBiasPayload? {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
)
)
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
return bias
}
}
@@ -0,0 +1,91 @@
// MacMLXStreamingASRProvider.swift
// OSGKeyboard · Mac
//
// Loads and caches Qwen3 MLX models; builds streaming sessions with bias.
import Foundation
import MLX
import MLXAudioSTT
actor MacMLXStreamingASRProvider {
static let shared = MacMLXStreamingASRProvider()
private var cachedModelId: String?
private var cachedModel: Qwen3ASRModel?
private var didWarmup = false
func prepare(model: LocalASRModelDefinition) async throws {
_ = try await loadModel(model)
try await warmupIfNeeded()
}
func makeSession(
model: LocalASRModelDefinition,
bias: LocalASRBiasPayload?,
locale: Locale
) async throws -> MacMLXStreamingSession {
let qwen = try await loadModel(model)
var config = StreamingConfig(
decodeIntervalSeconds: 0.5,
boundaryDecodeIntervalSeconds: 0.2,
boundaryBoostSeconds: 1.0,
encoderWindowOverlapSeconds: 1.0,
maxCachedWindows: 8,
delayPreset: .realtime,
language: MacQwen3LanguageHint.from(locale: locale),
context: bias?.promptBias,
temperature: 0,
maxTokensPerPass: 512,
minAgreementPasses: 2,
boundaryMinAgreementPasses: 2,
maxDecodeWindows: 1,
finalizeCompletedWindows: true
)
return MacMLXStreamingSession(model: qwen, config: config)
}
func transcribeBatch(
samples: [Float],
model: LocalASRModelDefinition,
locale: Locale,
bias: LocalASRBiasPayload?
) async throws -> String {
let qwen = try await loadModel(model)
let audio = MLXArray(samples.map { Float32($0) })
let language = MacQwen3LanguageHint.from(locale: locale)
let context = bias?.promptBias ?? ""
let output = qwen.generate(
audio: audio,
context: context,
language: language
)
let cleaned = MacHallucinationFilter.strip(output.text)
guard !cleaned.isEmpty else { throw MacLocalASRError.emptyTranscript }
return cleaned
}
// MARK: - Private
private func loadModel(_ definition: LocalASRModelDefinition) async throws -> Qwen3ASRModel {
if cachedModelId == definition.id, let cachedModel {
return cachedModel
}
guard let root = LocalASRModelInstallState.modelRootURL(definition) else {
throw MacLocalASRError.qwen3ModelMissing
}
let model = try await Qwen3ASRModel.fromModelDirectory(root)
cachedModelId = definition.id
cachedModel = model
didWarmup = false
return model
}
private func warmupIfNeeded() async throws {
guard !didWarmup else { return }
guard let model = cachedModel else { return }
didWarmup = true
// One second of silence primes Metal kernels before the first user take.
let silence = MLXArray([Float](repeating: 0, count: 16_000).map { Float32($0) })
_ = model.generate(audio: silence, language: MacQwen3LanguageHint.from(locale: Locale(identifier: "zh-CN")))
}
}
@@ -0,0 +1,86 @@
// MacMLXStreamingSession.swift
// OSGKeyboard · Mac
//
// Thin wrapper around mlx-audio-swift `StreamingInferenceSession`.
import Foundation
import MLXAudioSTT
/// One live MLX streaming ASR take (Option held).
final class MacMLXStreamingSession: @unchecked Sendable {
private let session: StreamingInferenceSession
private let eventTask: Task<Void, Never>
private let lock = NSLock()
private var endedContinuation: CheckedContinuation<String, Error>?
private var peakRMS: Float = 0
var onDisplayUpdate: (@Sendable (String) -> Void)?
init(model: Qwen3ASRModel, config: StreamingConfig) {
let session = StreamingInferenceSession(model: model, config: config)
self.session = session
final class EventSink: @unchecked Sendable {
weak var owner: MacMLXStreamingSession?
}
let sink = EventSink()
self.eventTask = Task {
for await event in session.events {
sink.owner?.handle(event)
}
}
sink.owner = self
}
func feed(samples: [Float]) {
guard !samples.isEmpty else { return }
let rms = FlowCaptureDrainTracker.rms(of: samples)
lock.withLock {
peakRMS = max(peakRMS, rms)
}
if rms < MacHallucinationFilter.silencePeakThreshold { return }
session.feedAudio(samples: samples)
}
func stop() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
lock.withLock {
endedContinuation = continuation
}
session.stop()
}
}
func cancel() {
session.cancel()
lock.withLock {
endedContinuation?.resume(throwing: MacLocalASRError.qwen3InferenceFailed("Cancelled"))
endedContinuation = nil
}
eventTask.cancel()
}
func peakAudioRMS() -> Float {
lock.withLock { peakRMS }
}
private func handle(_ event: TranscriptionEvent) {
switch event {
case .displayUpdate(let confirmed, let provisional):
let display = confirmed + provisional
let cleaned = MacHallucinationFilter.strip(display)
guard !cleaned.isEmpty else { return }
onDisplayUpdate?(cleaned)
case .ended(let fullText):
let cleaned = MacHallucinationFilter.strip(fullText)
lock.withLock {
if let continuation = endedContinuation {
continuation.resume(returning: cleaned)
endedContinuation = nil
}
}
case .provisional, .confirmed, .stats:
break
}
}
}
-64
View File
@@ -1,64 +0,0 @@
// 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
)
case .sherpaParaformer:
return try await MacSherpaONNXRunner.transcribeParaformer(
samples: samples,
sampleRate: sampleRate,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary
)
default:
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
}
}
}
-253
View File
@@ -1,253 +0,0 @@
// 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)
}
static func transcribeParaformer(
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 paraformer = layout.paraformerModel,
let tokens = layout.tokens else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Paraformer layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
let arguments = [
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
"--paraformer=\(modelRoot.appendingPathComponent(paraformer).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("{") {
// Sherpa's JSON result line (`{"text": ..., "lang": ..., ...}`).
// Trust only its `text` field including when it's empty
// (silence/no-speech) and never fall through to the raw
// JSON below, or the JSON blob itself gets inserted as text.
if let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
return sanitizeTranscript(text)
}
continue
}
if isMetadataNoiseLine(line) { continue }
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
let cleaned = sanitizeTranscript(line)
if !cleaned.isEmpty { return cleaned }
}
}
return ""
}
/// Qwen3-ASR (via sherpa-onnx) often prefixes the transcript with a
/// scaffold such as `language Chinese<asr_text>`. Older runtimes leave
/// that intact in `result.text`; incomplete generations can even stop at
/// the bare word `language`. Strip the scaffold so only spoken text remains.
private static func sanitizeTranscript(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty { return "" }
// Prefer the payload after the last `<asr_text>` marker.
if let marker = text.range(of: "<asr_text>", options: .backwards) {
text = String(text[marker.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
} else if let match = text.range(
of: #"^language\s+\S+\s*"#,
options: [.regularExpression, .caseInsensitive]
) {
// Fallback when the marker token was lost but the language prefix remains.
text = String(text[match.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Drop leftover control tokens / bare scaffold words.
if isMetadataNoiseLine(text) { return "" }
return text
}
/// Lines that are sherpa/Qwen metadata rather than spoken content.
private static func isMetadataNoiseLine(_ line: String) -> Bool {
let lowered = line.lowercased()
switch lowered {
case "language", "emotion", "event", "text",
"<asr_text>", "</asr_text>", "<|im_end|>":
return true
default:
// Exact scaffold with no spoken payload, e.g. "language Chinese".
if lowered.range(
of: #"^language(\s+\S+)?$"#,
options: .regularExpression
) != nil {
return true
}
return false
}
}
}
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
}
}