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
@@ -17,12 +17,20 @@ public struct LocalASRDownloadProgressUpdate: Sendable {
}
}
/// Controls an in-flight URLSession download; supports pause via resume data.
/// Controls an in-flight URLSession download; supports pause via resume data,
/// plus automatic retry-with-resume on transient network failures.
public final class LocalASRModelDownloadController: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
private let destinationURL: URL
private let onProgress: @Sendable (LocalASRDownloadProgressUpdate) -> Void
private let maxRetries: Int
private lazy var delegateSession: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
let config = URLSessionConfiguration.default
// Big weight files over flaky links: allow long total transfers but
// fail (and retry) a stalled connection that goes quiet for a while.
config.timeoutIntervalForRequest = 90
config.timeoutIntervalForResource = 24 * 60 * 60
config.waitsForConnectivity = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
private var remoteURL: URL?
@@ -30,12 +38,15 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload
private var completionContinuation: CheckedContinuation<Void, Error>?
private var isPausing = false
private var finished = false
private var retryCount = 0
init(
destinationURL: URL,
maxRetries: Int = 4,
onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
) {
self.destinationURL = destinationURL
self.maxRetries = maxRetries
self.onProgress = onProgress
super.init()
}
@@ -43,6 +54,7 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload
/// Runs until the archive is fully written to `destinationURL` (survives pause/resume).
public func download(from remoteURL: URL) async throws {
self.remoteURL = remoteURL
retryCount = 0
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
completionContinuation = continuation
startTask(resumeData: nil)
@@ -133,14 +145,55 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard !finished else { return }
if isPausing { return }
if let error {
finished = true
completionContinuation?.resume(
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
)
completionContinuation = nil
session.finishTasksAndInvalidate()
guard let error else { return }
// Transient network drop: resume from where we stopped (if the server
// handed back resume data) after a short exponential backoff, up to a cap.
if Self.isRetryable(error), retryCount < maxRetries {
retryCount += 1
let resumeData = (error as NSError)
.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
let delay = Self.backoffSeconds(attempt: retryCount)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, !self.finished, !self.isPausing else { return }
self.startTask(resumeData: resumeData)
}
return
}
finished = true
completionContinuation?.resume(
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
)
completionContinuation = nil
session.finishTasksAndInvalidate()
}
/// Network hiccups worth retrying; permanent failures (404, cancelled) are not.
private static func isRetryable(_ error: Error) -> Bool {
let nsError = error as NSError
guard nsError.domain == NSURLErrorDomain else { return false }
switch nsError.code {
case NSURLErrorNetworkConnectionLost,
NSURLErrorTimedOut,
NSURLErrorCannotConnectToHost,
NSURLErrorCannotFindHost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet,
NSURLErrorSecureConnectionFailed,
NSURLErrorResourceUnavailable,
NSURLErrorHTTPTooManyRedirects,
NSURLErrorDataLengthExceedsMaximum,
NSURLErrorZeroByteResource:
return true
default:
return false
}
}
/// 1s, 2s, 4s, 8s capped at 30s.
private static func backoffSeconds(attempt: Int) -> Double {
min(30, pow(2, Double(attempt - 1)))
}
}
@@ -148,9 +201,14 @@ public enum LocalASRModelDownloadClient {
public static func makeController(
destinationURL: URL,
maxRetries: Int = 4,
onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
) -> LocalASRModelDownloadController {
LocalASRModelDownloadController(destinationURL: destinationURL, onProgress: onProgress)
LocalASRModelDownloadController(
destinationURL: destinationURL,
maxRetries: maxRetries,
onProgress: onProgress
)
}
}