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
+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,