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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ public enum LocalASRModelInstallState {
|
||||
fileManager: FileManager
|
||||
) -> Bool {
|
||||
switch model.backend {
|
||||
case .mlx:
|
||||
guard let config = layout.mlxConfig,
|
||||
let weights = layout.mlxWeights else { return false }
|
||||
return fileManager.fileExists(atPath: root.appendingPathComponent(config).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(weights).path)
|
||||
case .sherpaQwen3:
|
||||
guard let conv = layout.convFrontend,
|
||||
let encoder = layout.encoder,
|
||||
|
||||
@@ -182,7 +182,8 @@ public actor LocalASRModelManager {
|
||||
|
||||
public func installModel(
|
||||
_ model: LocalASRModelDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
catalog: LocalASRCatalogDocument,
|
||||
preferredSource: LocalASRDownloadSourcePreference = .auto
|
||||
) async throws {
|
||||
guard let relative = model.installRelativePath,
|
||||
let sources = model.sources,
|
||||
@@ -196,11 +197,7 @@ public actor LocalASRModelManager {
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice || model.backend == .sherpaParaformer {
|
||||
try await ensureRuntimeInstalled(catalog: catalog)
|
||||
}
|
||||
|
||||
let sortedSources = LocalASRDownloadSourceSorter.sorted(sources)
|
||||
let sortedSources = LocalASRDownloadSourceSorter.sorted(sources, preferred: preferredSource)
|
||||
var lastError: Error?
|
||||
|
||||
switch model.installKind {
|
||||
@@ -518,9 +515,10 @@ public actor LocalASRModelManager {
|
||||
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: stagingRoot) }
|
||||
|
||||
if fileManager.fileExists(atPath: destinationRoot.path) {
|
||||
try fileManager.removeItem(at: destinationRoot)
|
||||
}
|
||||
// Do NOT wipe an existing destination: files land here only after a full
|
||||
// download completes (partials stay in URLSession's temp dir), so already
|
||||
// present files are complete and can be reused when a prior attempt failed
|
||||
// partway or we fall back to another mirror.
|
||||
try fileManager.createDirectory(at: destinationRoot, withIntermediateDirectories: true)
|
||||
|
||||
let totalBytes = files.reduce(Int64(0)) { partial, file in
|
||||
@@ -529,12 +527,21 @@ public actor LocalASRModelManager {
|
||||
var completedBytes: Int64 = 0
|
||||
|
||||
for (index, file) in files.enumerated() {
|
||||
let localURL = destinationRoot.appendingPathComponent(file.localPath)
|
||||
|
||||
// Skip files a previous attempt already finished (non-empty on disk).
|
||||
if fileManager.fileExists(atPath: localURL.path),
|
||||
let attrs = try? fileManager.attributesOfItem(atPath: localURL.path),
|
||||
let size = attrs[.size] as? Int64, size > 0 {
|
||||
completedBytes += Int64(file.sizeBytes ?? Int(size))
|
||||
continue
|
||||
}
|
||||
|
||||
let remoteURLString = baseURL.replacingOccurrences(of: "{path}", with: file.remotePath)
|
||||
guard let remoteURL = URL(string: remoteURLString) else {
|
||||
throw LocalASRModelManagerError.downloadFailed("Invalid URL for \(file.remotePath)")
|
||||
}
|
||||
|
||||
let localURL = destinationRoot.appendingPathComponent(file.localPath)
|
||||
try fileManager.createDirectory(
|
||||
at: localURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
|
||||
@@ -5,4 +5,6 @@ import Foundation
|
||||
|
||||
enum LocalASRPreferenceKeys {
|
||||
static let selectedModelId = "mac.localASR.selectedModelId"
|
||||
/// Persists the user's preferred model download mirror (see `LocalASRDownloadSourcePreference`).
|
||||
static let downloadSource = "mac.localASR.downloadSource"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user