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:
@@ -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.
|
||||
// 布局意图:标题 + 统计卡片顶对齐,麦克风栏底对齐,中间的听写预览卡片
|
||||
// 吸收全部剩余高度(弹性),因此拉高窗口时是卡片变高而非上下留白。
|
||||
//
|
||||
// 不用 ScrollView:ScrollView 在测量内容时给子视图的是「理想高度」提案,
|
||||
// 会让 `.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 ~300–520.
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user