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
+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)
}
}