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
@@ -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"
}
+6 -1
View File
@@ -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" = "打开目录";