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:
@@ -44,12 +44,12 @@ public struct LocalASRCapabilities: Sendable, Equatable {
|
||||
self.hotwordReloadCost = hotwordReloadCost
|
||||
}
|
||||
|
||||
/// Qwen3 MLX via mlx-swift-asr — `context` soft prompt on `transcribe`.
|
||||
/// Qwen3 MLX via mlx-audio-swift — `context` soft prompt on streaming + batch.
|
||||
public static let qwen3MLX = LocalASRCapabilities(
|
||||
hotwordMode: .promptOnly,
|
||||
maxHotwordCount: 0,
|
||||
maxPromptCharacters: 800,
|
||||
supportsStreaming: false,
|
||||
supportsStreaming: true,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
||||
public var tokenizer: String?
|
||||
public var senseVoiceModel: String?
|
||||
public var paraformerModel: String?
|
||||
public var mlxConfig: String?
|
||||
public var mlxWeights: String?
|
||||
public var tokens: String?
|
||||
}
|
||||
|
||||
@@ -145,6 +147,25 @@ public enum LocalASRModelCatalog {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// User-facing override for which mirror to try first when installing models.
|
||||
public enum LocalASRDownloadSourcePreference: String, Codable, Sendable, CaseIterable {
|
||||
/// Pick automatically based on the system region (China-friendly default).
|
||||
case auto
|
||||
/// Force the HF mirror (`hf-mirror.com`) first — best for mainland China.
|
||||
case hfMirror
|
||||
/// Force the official Hugging Face endpoint first.
|
||||
case huggingface
|
||||
|
||||
/// The catalog `type` string this preference pins to the front (nil for `.auto`).
|
||||
public var pinnedType: String? {
|
||||
switch self {
|
||||
case .auto: return nil
|
||||
case .hfMirror: return "hfmirror"
|
||||
case .huggingface: return "huggingface"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Region-aware ordering for local ASR model download mirrors.
|
||||
public enum LocalASRDownloadSourceSorter {
|
||||
|
||||
@@ -153,31 +174,49 @@ public enum LocalASRDownloadSourceSorter {
|
||||
region?.identifier == "CN"
|
||||
}
|
||||
|
||||
/// Lower rank = tried earlier. CN: ModelScope → HuggingFace → GitHub; elsewhere: HF → GitHub → ModelScope.
|
||||
/// Whether to try the China-friendly mirror (`hf-mirror.com`) first.
|
||||
///
|
||||
/// Default is "yes unless the region is *definitely* overseas": an unknown
|
||||
/// region (common when a VPN/proxy masks locale) falls back to the mirror,
|
||||
/// which is reachable both inside and outside China, so mainland users work
|
||||
/// out of the box while overseas users only lose it when their region is set.
|
||||
public static func preferChinaMirror(region: Locale.Region? = Locale.current.region) -> Bool {
|
||||
guard let region else { return true }
|
||||
return region.identifier == "CN"
|
||||
}
|
||||
|
||||
/// Lower rank = tried earlier.
|
||||
/// China-first: hf-mirror → HuggingFace → ModelScope → GitHub.
|
||||
/// Overseas: HuggingFace → hf-mirror → GitHub → ModelScope.
|
||||
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
|
||||
switch type.lowercased() {
|
||||
case "modelscope":
|
||||
return chinaFirst ? 0 : 2
|
||||
case "hfmirror", "hf-mirror":
|
||||
return chinaFirst ? 0 : 1
|
||||
case "huggingface":
|
||||
return chinaFirst ? 1 : 0
|
||||
case "modelscope":
|
||||
return chinaFirst ? 2 : 3
|
||||
case "github":
|
||||
return 1
|
||||
return chinaFirst ? 3 : 2
|
||||
default:
|
||||
return 3
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
public static func sorted(
|
||||
_ sources: [LocalASRDownloadSource],
|
||||
region: Locale.Region? = Locale.current.region
|
||||
region: Locale.Region? = Locale.current.region,
|
||||
preferred: LocalASRDownloadSourcePreference = .auto
|
||||
) -> [LocalASRDownloadSource] {
|
||||
let chinaFirst = isChinaMainland(region: region)
|
||||
return sources.sorted { lhs, rhs in
|
||||
let leftRank = typeRank(lhs.type, chinaFirst: chinaFirst)
|
||||
let rightRank = typeRank(rhs.type, chinaFirst: chinaFirst)
|
||||
if leftRank != rightRank { return leftRank < rightRank }
|
||||
return lhs.priority < rhs.priority
|
||||
let chinaFirst = preferChinaMirror(region: region)
|
||||
let pinned = preferred.pinnedType?.lowercased()
|
||||
func rank(_ source: LocalASRDownloadSource) -> (Int, Int) {
|
||||
if let pinned, source.type.lowercased() == pinned {
|
||||
return (-1, source.priority)
|
||||
}
|
||||
return (typeRank(source.type, chinaFirst: chinaFirst), source.priority)
|
||||
}
|
||||
return sources.sorted { rank($0) < rank($1) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,142 +1,109 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"defaultModelId": "sherpa-qwen3-0.6b-int8",
|
||||
"runtimes": [
|
||||
{
|
||||
"id": "sherpa-onnx-1.13.4-macos-arm64",
|
||||
"displayName": "sherpa-onnx 1.13.4 (Apple Silicon)",
|
||||
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-arm64",
|
||||
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
|
||||
"archiveFileName": "sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2",
|
||||
"sizeBytes": 120000000,
|
||||
"platform": "macos-arm64",
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-onnx-1.13.4-macos-x64",
|
||||
"displayName": "sherpa-onnx 1.13.4 (Intel)",
|
||||
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-x64",
|
||||
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
|
||||
"archiveFileName": "sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2",
|
||||
"sizeBytes": 130000000,
|
||||
"platform": "macos-x64",
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"defaultModelId": "qwen3-mlx-0.6b-4bit",
|
||||
"runtimes": [],
|
||||
"models": [
|
||||
{
|
||||
"id": "sherpa-qwen3-0.6b-int8",
|
||||
"id": "qwen3-mlx-0.6b-4bit",
|
||||
"displayName": "Qwen3-ASR 0.6B",
|
||||
"backend": "sherpaQwen3",
|
||||
"backend": "mlx",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 650000000,
|
||||
"sizeBytes": 730000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "recognizerScoped",
|
||||
"hotwordMode": "promptOnly",
|
||||
"badgeKey": "mac.localASR.badge.balanced",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-qwen3-0.6b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25",
|
||||
"layout": {
|
||||
"convFrontend": "conv_frontend.onnx",
|
||||
"encoder": "encoder.int8.onnx",
|
||||
"decoder": "decoder.int8.onnx",
|
||||
"tokenizer": "tokenizer"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-qwen3-1.7b-int8",
|
||||
"displayName": "Qwen3-ASR 1.7B",
|
||||
"backend": "sherpaQwen3",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 1900000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "recognizerScoped",
|
||||
"badgeKey": "mac.localASR.badge.quality",
|
||||
"installKind": "repository",
|
||||
"installRelativePath": "models/sherpa-qwen3-1.7b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8",
|
||||
"installRelativePath": "models/qwen3-mlx-0.6b-4bit",
|
||||
"archiveBaseName": "Qwen3-ASR-0.6B-4bit",
|
||||
"layout": {
|
||||
"convFrontend": "conv_frontend.onnx",
|
||||
"encoder": "encoder.int8.onnx",
|
||||
"decoder": "decoder.int8.onnx",
|
||||
"tokenizer": "tokenizer"
|
||||
"mlxConfig": "config.json",
|
||||
"mlxWeights": "model.safetensors"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "modelscope",
|
||||
"type": "hfmirror",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://www.modelscope.cn/models/zengshuishui/Qwen3-ASR-onnx/resolve/master/{path}",
|
||||
"baseURL": "https://hf-mirror.com/mlx-community/Qwen3-ASR-0.6B-4bit/resolve/main/{path}",
|
||||
"files": [
|
||||
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
|
||||
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
|
||||
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
|
||||
{ "remotePath": "config.json", "localPath": "config.json", "sizeBytes": 5000 },
|
||||
{ "remotePath": "generation_config.json", "localPath": "generation_config.json", "sizeBytes": 200 },
|
||||
{ "remotePath": "preprocessor_config.json", "localPath": "preprocessor_config.json", "sizeBytes": 500 },
|
||||
{ "remotePath": "model.safetensors", "localPath": "model.safetensors", "sizeBytes": 708236945 },
|
||||
{ "remotePath": "model.safetensors.index.json", "localPath": "model.safetensors.index.json", "sizeBytes": 80000 },
|
||||
{ "remotePath": "tokenizer_config.json", "localPath": "tokenizer_config.json", "sizeBytes": 10000 },
|
||||
{ "remotePath": "merges.txt", "localPath": "merges.txt", "sizeBytes": 1700000 },
|
||||
{ "remotePath": "vocab.json", "localPath": "vocab.json", "sizeBytes": 2800000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "huggingface",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://huggingface.co/zengshuishui/Qwen3-ASR-onnx/resolve/main/{path}",
|
||||
"baseURL": "https://huggingface.co/mlx-community/Qwen3-ASR-0.6B-4bit/resolve/main/{path}",
|
||||
"files": [
|
||||
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
|
||||
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
|
||||
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
|
||||
{ "remotePath": "config.json", "localPath": "config.json", "sizeBytes": 5000 },
|
||||
{ "remotePath": "generation_config.json", "localPath": "generation_config.json", "sizeBytes": 200 },
|
||||
{ "remotePath": "preprocessor_config.json", "localPath": "preprocessor_config.json", "sizeBytes": 500 },
|
||||
{ "remotePath": "model.safetensors", "localPath": "model.safetensors", "sizeBytes": 708236945 },
|
||||
{ "remotePath": "model.safetensors.index.json", "localPath": "model.safetensors.index.json", "sizeBytes": 80000 },
|
||||
{ "remotePath": "tokenizer_config.json", "localPath": "tokenizer_config.json", "sizeBytes": 10000 },
|
||||
{ "remotePath": "merges.txt", "localPath": "merges.txt", "sizeBytes": 1700000 },
|
||||
{ "remotePath": "vocab.json", "localPath": "vocab.json", "sizeBytes": 2800000 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-sensevoice-small-int8",
|
||||
"displayName": "SenseVoice Small",
|
||||
"backend": "sherpaSenseVoice",
|
||||
"id": "qwen3-mlx-1.7b-4bit",
|
||||
"displayName": "Qwen3-ASR 1.7B",
|
||||
"backend": "mlx",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 250000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US", "ja-JP", "ko-KR"],
|
||||
"supportsHotwords": false,
|
||||
"hotwordMode": "none",
|
||||
"badgeKey": "mac.localASR.badge.fastest",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-sensevoice-small-int8",
|
||||
"archiveBaseName": "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17",
|
||||
"sizeBytes": 1700000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "promptOnly",
|
||||
"badgeKey": "mac.localASR.badge.quality",
|
||||
"installKind": "repository",
|
||||
"installRelativePath": "models/qwen3-mlx-1.7b-4bit",
|
||||
"archiveBaseName": "Qwen3-ASR-1.7B-4bit",
|
||||
"layout": {
|
||||
"senseVoiceModel": "model.int8.onnx",
|
||||
"tokens": "tokens.txt"
|
||||
"mlxConfig": "config.json",
|
||||
"mlxWeights": "model.safetensors"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"type": "hfmirror",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
|
||||
"url": "",
|
||||
"baseURL": "https://hf-mirror.com/mlx-community/Qwen3-ASR-1.7B-4bit/resolve/main/{path}",
|
||||
"files": [
|
||||
{ "remotePath": "config.json", "localPath": "config.json", "sizeBytes": 5000 },
|
||||
{ "remotePath": "generation_config.json", "localPath": "generation_config.json", "sizeBytes": 200 },
|
||||
{ "remotePath": "preprocessor_config.json", "localPath": "preprocessor_config.json", "sizeBytes": 500 },
|
||||
{ "remotePath": "model.safetensors", "localPath": "model.safetensors", "sizeBytes": 1650000000 },
|
||||
{ "remotePath": "model.safetensors.index.json", "localPath": "model.safetensors.index.json", "sizeBytes": 90000 },
|
||||
{ "remotePath": "tokenizer_config.json", "localPath": "tokenizer_config.json", "sizeBytes": 10000 },
|
||||
{ "remotePath": "merges.txt", "localPath": "merges.txt", "sizeBytes": 1700000 },
|
||||
{ "remotePath": "vocab.json", "localPath": "vocab.json", "sizeBytes": 2800000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "huggingface",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://huggingface.co/mlx-community/Qwen3-ASR-1.7B-4bit/resolve/main/{path}",
|
||||
"files": [
|
||||
{ "remotePath": "config.json", "localPath": "config.json", "sizeBytes": 5000 },
|
||||
{ "remotePath": "generation_config.json", "localPath": "generation_config.json", "sizeBytes": 200 },
|
||||
{ "remotePath": "preprocessor_config.json", "localPath": "preprocessor_config.json", "sizeBytes": 500 },
|
||||
{ "remotePath": "model.safetensors", "localPath": "model.safetensors", "sizeBytes": 1650000000 },
|
||||
{ "remotePath": "model.safetensors.index.json", "localPath": "model.safetensors.index.json", "sizeBytes": 90000 },
|
||||
{ "remotePath": "tokenizer_config.json", "localPath": "tokenizer_config.json", "sizeBytes": 10000 },
|
||||
{ "remotePath": "merges.txt", "localPath": "merges.txt", "sizeBytes": 1700000 },
|
||||
{ "remotePath": "vocab.json", "localPath": "vocab.json", "sizeBytes": 2800000 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
"mac.overlay.polishing" = "Polishing";
|
||||
"mac.overlay.live" = "Live";
|
||||
"mac.overlay.done" = "Done";
|
||||
"mac.overlay.dragHint" = "Drag to move · double-click to reset";
|
||||
"mac.record.start" = "Record";
|
||||
"mac.record.stop" = "Stop";
|
||||
"mac.record.pressStop" = "Press Stop";
|
||||
@@ -305,7 +306,11 @@
|
||||
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
|
||||
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
|
||||
"mac.localASR.models" = "Local ASR Engine & Models";
|
||||
"mac.localASR.modelsDesc" = "Models download directly. China uses ModelScope first; elsewhere Hugging Face first, then GitHub. Qwen3 1.7B downloads as multiple files.";
|
||||
"mac.localASR.modelsDesc" = "Models download directly. Mainland China uses the hf-mirror mirror first; elsewhere the official Hugging Face endpoint first. Interrupted downloads resume automatically and fall back across mirrors.";
|
||||
"mac.localASR.downloadSource" = "Download source";
|
||||
"mac.localASR.downloadSource.auto" = "Automatic (by region)";
|
||||
"mac.localASR.downloadSource.hfMirror" = "hf-mirror (recommended in China)";
|
||||
"mac.localASR.downloadSource.huggingface" = "Hugging Face (official)";
|
||||
"mac.localASR.download" = "Download";
|
||||
"mac.localASR.selectFolder" = "Choose folder";
|
||||
"mac.localASR.openFolder" = "Open folder";
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
"mac.overlay.polishing" = "润色中";
|
||||
"mac.overlay.live" = "实时";
|
||||
"mac.overlay.done" = "已完成";
|
||||
"mac.overlay.dragHint" = "拖动可移动位置,双击恢复默认";
|
||||
"mac.record.start" = "开始录音";
|
||||
"mac.record.stop" = "停止";
|
||||
"mac.record.pressStop" = "点击停止";
|
||||
@@ -305,7 +306,11 @@
|
||||
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
|
||||
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
|
||||
"mac.localASR.models" = "本地 ASR 引擎与模型";
|
||||
"mac.localASR.modelsDesc" = "模型可直接下载。中国大陆优先 ModelScope,其他地区优先 Hugging Face,再回退 GitHub。Qwen3 1.7B 以多文件方式下载。";
|
||||
"mac.localASR.modelsDesc" = "模型可直接下载。中国大陆优先 hf-mirror 镜像,其他地区优先 Hugging Face 官方;下载中断会自动断点续传并在镜像间回退。";
|
||||
"mac.localASR.downloadSource" = "下载源";
|
||||
"mac.localASR.downloadSource.auto" = "自动(按地区)";
|
||||
"mac.localASR.downloadSource.hfMirror" = "hf-mirror(中国大陆推荐)";
|
||||
"mac.localASR.downloadSource.huggingface" = "Hugging Face 官方";
|
||||
"mac.localASR.download" = "下载";
|
||||
"mac.localASR.selectFolder" = "选择目录";
|
||||
"mac.localASR.openFolder" = "打开目录";
|
||||
|
||||
Reference in New Issue
Block a user