feat: sync force-quit Flow teardown and polish macOS local ASR UX
End Live Activities and release the audio session synchronously on applicationWillTerminate, and continue macOS local-model install, onboarding, and settings polish on this branch.
This commit is contained in:
@@ -79,4 +79,13 @@ public struct LocalASRCapabilities: Sendable, Equatable {
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
/// FunASR Paraformer (Sherpa offline) — no project hotword API.
|
||||
public static let sherpaParaformer = LocalASRCapabilities(
|
||||
hotwordMode: .none,
|
||||
maxHotwordCount: 0,
|
||||
maxPromptCharacters: 0,
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,19 +9,41 @@ public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
|
||||
case mlx
|
||||
case sherpaQwen3
|
||||
case sherpaSenseVoice
|
||||
case sherpaParaformer
|
||||
case appleSpeech
|
||||
}
|
||||
|
||||
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
|
||||
case manual
|
||||
case archive
|
||||
/// Multi-file install from a remote repository (ModelScope / HuggingFace file API).
|
||||
case repository
|
||||
case runtime
|
||||
}
|
||||
|
||||
public struct LocalASRDownloadFile: Codable, Sendable, Equatable {
|
||||
public let remotePath: String
|
||||
public let localPath: String
|
||||
public let sizeBytes: Int?
|
||||
}
|
||||
|
||||
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
|
||||
public let type: String
|
||||
public let priority: Int
|
||||
/// Full URL for a single archive download.
|
||||
public let url: String
|
||||
/// Base URL template for repository installs; must contain `{path}`.
|
||||
public let baseURL: String?
|
||||
public let files: [LocalASRDownloadFile]?
|
||||
|
||||
public var isRepository: Bool {
|
||||
guard let files, !files.isEmpty else { return false }
|
||||
return baseURL?.contains("{path}") == true
|
||||
}
|
||||
|
||||
public var isArchive: Bool {
|
||||
!url.isEmpty && !isRepository
|
||||
}
|
||||
}
|
||||
|
||||
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
||||
@@ -30,6 +52,7 @@ public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
||||
public var decoder: String?
|
||||
public var tokenizer: String?
|
||||
public var senseVoiceModel: String?
|
||||
public var paraformerModel: String?
|
||||
public var tokens: String?
|
||||
}
|
||||
|
||||
@@ -91,6 +114,8 @@ public enum LocalASRModelCatalog {
|
||||
return .sherpaQwen3
|
||||
case .sherpaSenseVoice:
|
||||
return .sherpaSenseVoice
|
||||
case .sherpaParaformer:
|
||||
return .sherpaParaformer
|
||||
case .appleSpeech:
|
||||
return .appleSpeech
|
||||
}
|
||||
@@ -117,6 +142,42 @@ public enum LocalASRModelCatalog {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Region-aware ordering for local ASR model download mirrors.
|
||||
public enum LocalASRDownloadSourceSorter {
|
||||
|
||||
/// `true` when the system region is mainland China (`CN`).
|
||||
public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool {
|
||||
region?.identifier == "CN"
|
||||
}
|
||||
|
||||
/// Lower rank = tried earlier. CN: ModelScope → HuggingFace → GitHub; elsewhere: HF → GitHub → ModelScope.
|
||||
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
|
||||
switch type.lowercased() {
|
||||
case "modelscope":
|
||||
return chinaFirst ? 0 : 2
|
||||
case "huggingface":
|
||||
return chinaFirst ? 1 : 0
|
||||
case "github":
|
||||
return 1
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
public static func sorted(
|
||||
_ sources: [LocalASRDownloadSource],
|
||||
region: Locale.Region? = Locale.current.region
|
||||
) -> [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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocalASRModelCatalogError: Error, LocalizedError {
|
||||
case missingBundledCatalog
|
||||
case modelNotFound(String)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"defaultModelId": "qwen3-mlx-1.7b",
|
||||
"defaultModelId": "sherpa-qwen3-0.6b-int8",
|
||||
"runtimes": [
|
||||
{
|
||||
"id": "sherpa-onnx-1.13.4-macos-arm64",
|
||||
@@ -36,21 +36,9 @@
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "qwen3-mlx-1.7b",
|
||||
"displayName": "Qwen3-ASR 1.7B (MLX)",
|
||||
"backend": "mlx",
|
||||
"sizeBytes": 1400000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "promptOnly",
|
||||
"installKind": "manual",
|
||||
"installRelativePath": "models/qwen3-asr-1.7b-mlx",
|
||||
"requiredRelativeFiles": ["config.json", "model.safetensors", "vocab.json", "merges.txt"]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-qwen3-0.6b-int8",
|
||||
"displayName": "Qwen3-ASR 0.6B (Sherpa · hotwords)",
|
||||
"displayName": "Qwen3-ASR 0.6B",
|
||||
"backend": "sherpaQwen3",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 650000000,
|
||||
@@ -74,9 +62,84 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"installKind": "repository",
|
||||
"installRelativePath": "models/sherpa-qwen3-1.7b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8",
|
||||
"layout": {
|
||||
"convFrontend": "conv_frontend.onnx",
|
||||
"encoder": "encoder.int8.onnx",
|
||||
"decoder": "decoder.int8.onnx",
|
||||
"tokenizer": "tokenizer"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "modelscope",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://www.modelscope.cn/models/zengshuishui/Qwen3-ASR-onnx/resolve/master/{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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "huggingface",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://huggingface.co/zengshuishui/Qwen3-ASR-onnx/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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-paraformer-zh-int8",
|
||||
"displayName": "Paraformer Large",
|
||||
"backend": "sherpaParaformer",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 220000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": false,
|
||||
"hotwordMode": "none",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-paraformer-zh-int8",
|
||||
"archiveBaseName": "sherpa-onnx-paraformer-zh-int8-2025-10-07",
|
||||
"layout": {
|
||||
"paraformerModel": "model.int8.onnx",
|
||||
"tokens": "tokens.txt"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-paraformer-zh-int8-2025-10-07.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-sensevoice-small-int8",
|
||||
"displayName": "SenseVoice Small (Sherpa)",
|
||||
"displayName": "SenseVoice Small",
|
||||
"backend": "sherpaSenseVoice",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 250000000,
|
||||
|
||||
@@ -25,7 +25,7 @@ public enum LocalASRModelInstallState {
|
||||
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
|
||||
guard fileManager.fileExists(atPath: base.path) else { return false }
|
||||
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
|
||||
case .archive:
|
||||
case .archive, .repository:
|
||||
guard let relative = model.installRelativePath,
|
||||
let layout = model.layout,
|
||||
let baseName = model.archiveBaseName else { return false }
|
||||
@@ -41,7 +41,7 @@ public enum LocalASRModelInstallState {
|
||||
_ model: LocalASRModelDefinition,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
guard model.installKind == .archive,
|
||||
guard model.installKind == .archive || model.installKind == .repository,
|
||||
let relative = model.installRelativePath,
|
||||
let baseName = model.archiveBaseName else { return nil }
|
||||
return installDirectory(for: relative, fileManager: fileManager)
|
||||
@@ -98,6 +98,11 @@ public enum LocalASRModelInstallState {
|
||||
let tokens = layout.tokens else { return false }
|
||||
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
||||
case .sherpaParaformer:
|
||||
guard let paraformer = layout.paraformerModel,
|
||||
let tokens = layout.tokens else { return false }
|
||||
return fileManager.fileExists(atPath: root.appendingPathComponent(paraformer).path)
|
||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -184,9 +184,7 @@ public actor LocalASRModelManager {
|
||||
_ model: LocalASRModelDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) async throws {
|
||||
guard model.installKind == .archive,
|
||||
let relative = model.installRelativePath,
|
||||
let baseName = model.archiveBaseName,
|
||||
guard let relative = model.installRelativePath,
|
||||
let sources = model.sources,
|
||||
!sources.isEmpty else {
|
||||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||
@@ -198,38 +196,70 @@ public actor LocalASRModelManager {
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
|
||||
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice || model.backend == .sherpaParaformer {
|
||||
try await ensureRuntimeInstalled(catalog: catalog)
|
||||
}
|
||||
let sortedSources = sources.sorted { $0.priority < $1.priority }
|
||||
|
||||
let sortedSources = LocalASRDownloadSourceSorter.sorted(sources)
|
||||
var lastError: Error?
|
||||
for source in sortedSources {
|
||||
do {
|
||||
try await installArchive(
|
||||
from: source.url,
|
||||
installRelativePath: relative,
|
||||
archiveBaseName: baseName,
|
||||
layoutModel: model,
|
||||
itemId: model.id,
|
||||
displayName: model.displayName
|
||||
)
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
if !manifest.installedModelIDs.contains(model.id) {
|
||||
manifest.installedModelIDs.append(model.id)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .completed,
|
||||
fraction: 1,
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
|
||||
switch model.installKind {
|
||||
case .archive:
|
||||
guard let baseName = model.archiveBaseName else {
|
||||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||
}
|
||||
for source in sortedSources where source.isArchive {
|
||||
do {
|
||||
try await installArchive(
|
||||
from: source.url,
|
||||
installRelativePath: relative,
|
||||
archiveBaseName: baseName,
|
||||
layoutModel: model,
|
||||
itemId: model.id,
|
||||
displayName: model.displayName
|
||||
)
|
||||
try markModelInstalled(model, catalog: catalog)
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .completed,
|
||||
fraction: 1,
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
case .repository:
|
||||
guard let baseName = model.archiveBaseName else {
|
||||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||
}
|
||||
for source in sortedSources where source.isRepository {
|
||||
do {
|
||||
try await installRepository(
|
||||
source: source,
|
||||
installRelativePath: relative,
|
||||
archiveBaseName: baseName,
|
||||
layoutModel: model,
|
||||
itemId: model.id,
|
||||
displayName: model.displayName
|
||||
)
|
||||
try markModelInstalled(model, catalog: catalog)
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .completed,
|
||||
fraction: 1,
|
||||
message: model.displayName,
|
||||
activeItemId: model.id
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .failed,
|
||||
fraction: 0,
|
||||
@@ -238,11 +268,24 @@ public actor LocalASRModelManager {
|
||||
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
|
||||
}
|
||||
|
||||
private func markModelInstalled(
|
||||
_ model: LocalASRModelDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) throws {
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
if !manifest.installedModelIDs.contains(model.id) {
|
||||
manifest.installedModelIDs.append(model.id)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
}
|
||||
|
||||
public func installRuntime(
|
||||
_ runtime: LocalASRRuntimeDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) async throws {
|
||||
guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
|
||||
let sortedSources = LocalASRDownloadSourceSorter.sorted(runtime.sources)
|
||||
guard !sortedSources.isEmpty else {
|
||||
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
|
||||
}
|
||||
progress = LocalASRModelInstallProgress(
|
||||
@@ -251,25 +294,34 @@ public actor LocalASRModelManager {
|
||||
message: runtime.displayName,
|
||||
activeItemId: runtime.id
|
||||
)
|
||||
try await installArchive(
|
||||
from: source.url,
|
||||
installRelativePath: runtime.installRelativePath,
|
||||
archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
|
||||
layoutModel: nil,
|
||||
expectedBinaryCandidates: runtime.binaryCandidates,
|
||||
itemId: runtime.id,
|
||||
displayName: runtime.displayName
|
||||
)
|
||||
guard isRuntimeInstalled(runtime) else {
|
||||
throw LocalASRModelManagerError.binaryMissing
|
||||
var lastError: Error?
|
||||
for source in sortedSources where source.isArchive {
|
||||
do {
|
||||
try await installArchive(
|
||||
from: source.url,
|
||||
installRelativePath: runtime.installRelativePath,
|
||||
archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
|
||||
layoutModel: nil,
|
||||
expectedBinaryCandidates: runtime.binaryCandidates,
|
||||
itemId: runtime.id,
|
||||
displayName: runtime.displayName
|
||||
)
|
||||
guard isRuntimeInstalled(runtime) else {
|
||||
throw LocalASRModelManagerError.binaryMissing
|
||||
}
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
if !manifest.installedRuntimeIDs.contains(runtime.id) {
|
||||
manifest.installedRuntimeIDs.append(runtime.id)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||
if !manifest.installedRuntimeIDs.contains(runtime.id) {
|
||||
manifest.installedRuntimeIDs.append(runtime.id)
|
||||
}
|
||||
manifest.updatedAt = Date()
|
||||
try saveManifest(manifest)
|
||||
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
|
||||
throw lastError ?? LocalASRModelManagerError.downloadFailed("All runtime mirrors failed")
|
||||
}
|
||||
|
||||
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
|
||||
@@ -285,7 +337,8 @@ public actor LocalASRModelManager {
|
||||
_ model: LocalASRModelDefinition,
|
||||
catalog: LocalASRCatalogDocument
|
||||
) throws {
|
||||
guard model.installKind == .archive, let relative = model.installRelativePath else { return }
|
||||
guard model.installKind == .archive || model.installKind == .repository,
|
||||
let relative = model.installRelativePath else { return }
|
||||
let dir = installDirectory(for: relative)
|
||||
if fileManager.fileExists(atPath: dir.path) {
|
||||
try fileManager.removeItem(at: dir)
|
||||
@@ -445,6 +498,115 @@ public actor LocalASRModelManager {
|
||||
try? fileManager.removeItem(at: archiveURL)
|
||||
}
|
||||
|
||||
private func installRepository(
|
||||
source: LocalASRDownloadSource,
|
||||
installRelativePath: String,
|
||||
archiveBaseName: String,
|
||||
layoutModel: LocalASRModelDefinition,
|
||||
itemId: String,
|
||||
displayName: String
|
||||
) async throws {
|
||||
guard let baseURL = source.baseURL,
|
||||
let files = source.files,
|
||||
!files.isEmpty else {
|
||||
throw LocalASRModelManagerError.downloadFailed("Invalid repository source.")
|
||||
}
|
||||
|
||||
let destinationRoot = installDirectory(for: installRelativePath)
|
||||
.appendingPathComponent(archiveBaseName, isDirectory: true)
|
||||
let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
|
||||
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: stagingRoot) }
|
||||
|
||||
if fileManager.fileExists(atPath: destinationRoot.path) {
|
||||
try fileManager.removeItem(at: destinationRoot)
|
||||
}
|
||||
try fileManager.createDirectory(at: destinationRoot, withIntermediateDirectories: true)
|
||||
|
||||
let totalBytes = files.reduce(Int64(0)) { partial, file in
|
||||
partial + Int64(file.sizeBytes ?? 0)
|
||||
}
|
||||
var completedBytes: Int64 = 0
|
||||
|
||||
for (index, file) in files.enumerated() {
|
||||
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
|
||||
)
|
||||
|
||||
// Snapshot the running total so the progress closure captures an
|
||||
// immutable value (avoids concurrent access to `completedBytes`).
|
||||
let priorBytes = completedBytes
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .downloading,
|
||||
fraction: 0.10 + (Double(index) / Double(files.count)) * 0.45,
|
||||
message: displayName,
|
||||
bytesReceived: priorBytes,
|
||||
bytesTotal: totalBytes > 0 ? totalBytes : nil,
|
||||
activeItemId: itemId
|
||||
)
|
||||
|
||||
do {
|
||||
let controller = LocalASRModelDownloadClient.makeController(destinationURL: localURL) { update in
|
||||
Task {
|
||||
let aggregateReceived = priorBytes + update.bytesReceived
|
||||
let aggregateTotal = totalBytes > 0 ? totalBytes : update.bytesTotal
|
||||
await LocalASRModelManager.shared.updateDownloadProgress(
|
||||
itemId: itemId,
|
||||
displayName: displayName,
|
||||
update: LocalASRDownloadProgressUpdate(
|
||||
bytesReceived: aggregateReceived,
|
||||
bytesTotal: max(aggregateTotal, 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
activeDownloadController = controller
|
||||
try await controller.download(from: remoteURL)
|
||||
activeDownloadController = nil
|
||||
pausedResumeData = nil
|
||||
} catch {
|
||||
activeDownloadController = nil
|
||||
pausedResumeData = nil
|
||||
throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
if let size = file.sizeBytes {
|
||||
completedBytes += Int64(size)
|
||||
} else if let attrs = try? fileManager.attributesOfItem(atPath: localURL.path),
|
||||
let size = attrs[.size] as? Int64 {
|
||||
completedBytes += size
|
||||
}
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .validating,
|
||||
fraction: 0.82,
|
||||
message: displayName,
|
||||
activeItemId: itemId
|
||||
)
|
||||
guard LocalASRModelInstallState.isInstalled(
|
||||
layoutModel,
|
||||
manualMLXPath: nil,
|
||||
fileManager: fileManager
|
||||
) else {
|
||||
throw LocalASRModelManagerError.validationFailed("Required model files missing after download.")
|
||||
}
|
||||
|
||||
progress = LocalASRModelInstallProgress(
|
||||
phase: .finalizing,
|
||||
fraction: 0.95,
|
||||
message: displayName,
|
||||
activeItemId: itemId
|
||||
)
|
||||
}
|
||||
|
||||
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
|
||||
guard let runtime = LocalASRModelCatalog.runtime(
|
||||
for: LocalASRModelCatalog.currentRuntimePlatform(),
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
|
||||
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
|
||||
"mac.settings.about" = "About";
|
||||
"mac.settings.privacyPolicy" = "Privacy Policy";
|
||||
"mac.settings.thirdPartyLicenses" = "Third-Party Licenses";
|
||||
"mac.settings.licenses.footer" = "Open-source components used by OSGKeyboard. License texts are reproduced from upstream repositories; model weights are downloaded at runtime and cached on device. OSGKeyboard itself is source-available — commercial licensing: rocky.hk@gmail.com.";
|
||||
"mac.settings.restartOnboarding" = "Restart First-Run Setup";
|
||||
"mac.settings.general" = "General";
|
||||
"mac.settings.input" = "Input & Shortcuts";
|
||||
"mac.settings.recognitionLanguage" = "Recognition Language";
|
||||
@@ -185,14 +189,48 @@
|
||||
"mac.appearance.system" = "System";
|
||||
"mac.appearance.light" = "Light";
|
||||
"mac.appearance.dark" = "Dark";
|
||||
"mac.onboarding.progress" = "Step %lld of %lld";
|
||||
"mac.onboarding.back" = "Back";
|
||||
"mac.onboarding.next" = "Next";
|
||||
"mac.onboarding.finish" = "Finish";
|
||||
"mac.onboarding.skipForNow" = "Skip for now";
|
||||
"mac.onboarding.welcome.title" = "Welcome to OSGKeyboard";
|
||||
"mac.onboarding.welcome.subtitle" = "Set up dictation in a minute. Everything here can be changed later in Settings.";
|
||||
"mac.onboarding.welcome.privacy" = "Your voice stays local unless you choose a cloud provider.";
|
||||
"mac.onboarding.welcome.hotkey" = "Hold Option to dictate from any app.";
|
||||
"mac.onboarding.welcome.local" = "Local Sherpa recognition can be prepared now or later.";
|
||||
"mac.onboarding.microphone.title" = "Allow microphone access";
|
||||
"mac.onboarding.microphone.subtitle" = "OSGKeyboard needs the microphone to capture your dictation audio.";
|
||||
"mac.onboarding.microphone.allow" = "Allow Microphone";
|
||||
"mac.onboarding.microphone.granted" = "Microphone access is granted.";
|
||||
"mac.onboarding.microphone.needed" = "Microphone access is needed for recording.";
|
||||
"mac.onboarding.accessibility.title" = "Enable Accessibility";
|
||||
"mac.onboarding.accessibility.subtitle" = "Accessibility lets OSGKeyboard listen for the global shortcut and paste text into the front app. You can skip it and grant it later.";
|
||||
"mac.onboarding.accessibility.open" = "Open System Settings";
|
||||
"mac.onboarding.accessibility.granted" = "Accessibility permission is granted.";
|
||||
"mac.onboarding.accessibility.needed" = "Accessibility is not enabled yet.";
|
||||
"mac.onboarding.engine.title" = "Choose a recognition path";
|
||||
"mac.onboarding.engine.subtitle" = "Start locally for privacy, or use your own cloud API for provider-based recognition and polish.";
|
||||
"mac.onboarding.engine.localDesc" = "Private on-device recognition with Sherpa models. Falls back to Apple Speech until a model is installed.";
|
||||
"mac.onboarding.engine.cloudDesc" = "Use your API key for cloud speech recognition and AI polishing.";
|
||||
"mac.onboarding.cloud.title" = "Configure API access";
|
||||
"mac.onboarding.cloud.subtitle" = "Pick a provider and add your key. This step is optional, so you can finish now and fill it later in Settings.";
|
||||
"mac.onboarding.cloud.skipHint" = "You can leave these blank and finish setup; cloud mode will ask for a key before use.";
|
||||
"mac.onboarding.model.title" = "Prepare local recognition";
|
||||
"mac.onboarding.model.subtitle" = "Download the basic Sherpa runtime and default model now, or skip and continue with Apple Speech fallback.";
|
||||
"mac.onboarding.model.download" = "Download Default Model";
|
||||
"mac.onboarding.model.done" = "Local model is ready.";
|
||||
"mac.onboarding.model.skipHint" = "Skipping is safe: Settings keeps the same download controls for later.";
|
||||
"mac.onboarding.popover.title" = "Finish setup";
|
||||
"mac.onboarding.popover.subtitle" = "Open the main window to choose permissions, API settings, and local model download.";
|
||||
"mac.error.noAudio" = "No audio captured";
|
||||
"mac.error.noCloudASR" = "Selected provider has no cloud ASR";
|
||||
"mac.error.emptyTranscript" = "No speech recognized";
|
||||
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
|
||||
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
|
||||
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
|
||||
"mac.localASR.models" = "Local ASR Models";
|
||||
"mac.localASR.modelsDesc" = "Sherpa models download directly. For MLX Qwen3, drop your converted weights into the folder opened by “Open folder”. All three share one storage directory.";
|
||||
"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.download" = "Download";
|
||||
"mac.localASR.selectFolder" = "Choose folder";
|
||||
"mac.localASR.openFolder" = "Open folder";
|
||||
@@ -202,6 +240,7 @@
|
||||
"mac.localASR.installDone" = "Install completed.";
|
||||
"mac.localASR.installed" = "Installed";
|
||||
"mac.localASR.notInstalled" = "Not installed";
|
||||
"mac.localASR.personalDictionaryTag" = "Personal dictionary";
|
||||
"mac.localASR.hotwordsYes" = "Hotwords";
|
||||
"mac.localASR.hotwordsNo" = "No hotwords";
|
||||
"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
|
||||
@@ -219,8 +258,8 @@
|
||||
"mac.localASR.redownload" = "Re-download";
|
||||
"mac.localASR.revealInFinder" = "Reveal in Finder";
|
||||
"mac.localASR.openStorage" = "Open model storage folder";
|
||||
"mac.localASR.runtime" = "Sherpa Runtime";
|
||||
"mac.localASR.runtimeDesc" = "Required for Sherpa Qwen3 and SenseVoice models. Installed automatically with those models.";
|
||||
"mac.localASR.runtime" = "Local ASR runtime";
|
||||
"mac.localASR.runtimeDesc" = "Installed automatically with the models above; required for Qwen3, SenseVoice, and similar models.";
|
||||
"mac.localASR.phase.downloading" = "Downloading";
|
||||
"mac.localASR.phase.paused" = "Paused";
|
||||
"mac.localASR.phase.extracting" = "Extracting";
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech)";
|
||||
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
|
||||
"mac.settings.about" = "关于";
|
||||
"mac.settings.privacyPolicy" = "隐私政策";
|
||||
"mac.settings.thirdPartyLicenses" = "第三方许可";
|
||||
"mac.settings.licenses.footer" = "以下为 OSGKeyboard 使用的开源组件。许可正文摘自上游仓库;模型权重在运行时下载并缓存在本机。OSGKeyboard 本身采用源码可见许可,商业授权请联系 rocky.hk@gmail.com。";
|
||||
"mac.settings.restartOnboarding" = "重新开始首次引导";
|
||||
"mac.settings.general" = "通用";
|
||||
"mac.settings.input" = "输入与快捷键";
|
||||
"mac.settings.recognitionLanguage" = "识别语言";
|
||||
@@ -185,14 +189,48 @@
|
||||
"mac.appearance.system" = "跟随系统";
|
||||
"mac.appearance.light" = "浅色";
|
||||
"mac.appearance.dark" = "深色";
|
||||
"mac.onboarding.progress" = "第 %lld 步,共 %lld 步";
|
||||
"mac.onboarding.back" = "上一步";
|
||||
"mac.onboarding.next" = "下一步";
|
||||
"mac.onboarding.finish" = "完成";
|
||||
"mac.onboarding.skipForNow" = "暂时跳过";
|
||||
"mac.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
|
||||
"mac.onboarding.welcome.subtitle" = "用一分钟完成听写基础配置。这里的选项之后都可以在设置里修改。";
|
||||
"mac.onboarding.welcome.privacy" = "除非你选择云端服务商,语音会优先留在本机处理。";
|
||||
"mac.onboarding.welcome.hotkey" = "按住 Option 键即可在任意应用开始听写。";
|
||||
"mac.onboarding.welcome.local" = "Sherpa 本地识别可以现在准备,也可以稍后下载。";
|
||||
"mac.onboarding.microphone.title" = "允许麦克风访问";
|
||||
"mac.onboarding.microphone.subtitle" = "OSGKeyboard 需要麦克风来录制你的听写音频。";
|
||||
"mac.onboarding.microphone.allow" = "允许麦克风";
|
||||
"mac.onboarding.microphone.granted" = "麦克风权限已授权。";
|
||||
"mac.onboarding.microphone.needed" = "录音需要麦克风权限。";
|
||||
"mac.onboarding.accessibility.title" = "开启辅助功能";
|
||||
"mac.onboarding.accessibility.subtitle" = "辅助功能用于全局快捷键和向前台应用粘贴文本。你可以先跳过,之后再授权。";
|
||||
"mac.onboarding.accessibility.open" = "打开系统设置";
|
||||
"mac.onboarding.accessibility.granted" = "辅助功能权限已授权。";
|
||||
"mac.onboarding.accessibility.needed" = "尚未开启辅助功能权限。";
|
||||
"mac.onboarding.engine.title" = "选择识别方式";
|
||||
"mac.onboarding.engine.subtitle" = "默认可用本地识别以保护隐私,也可以配置自己的云端 API。";
|
||||
"mac.onboarding.engine.localDesc" = "使用 Sherpa 本地模型进行私密转写;模型未安装前会回退到 Apple 语音识别。";
|
||||
"mac.onboarding.engine.cloudDesc" = "使用你的 API Key 进行云端语音识别和 AI 润色。";
|
||||
"mac.onboarding.cloud.title" = "配置 API 访问";
|
||||
"mac.onboarding.cloud.subtitle" = "选择服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。";
|
||||
"mac.onboarding.cloud.skipHint" = "可以先留空并完成引导;云端模式使用前会提示需要 API Key。";
|
||||
"mac.onboarding.model.title" = "准备本地识别";
|
||||
"mac.onboarding.model.subtitle" = "现在下载基础 Sherpa runtime 和默认模型,或先跳过并使用 Apple Speech 兜底。";
|
||||
"mac.onboarding.model.download" = "下载默认模型";
|
||||
"mac.onboarding.model.done" = "本地模型已准备好。";
|
||||
"mac.onboarding.model.skipHint" = "跳过不会影响使用:设置页保留同样的下载入口。";
|
||||
"mac.onboarding.popover.title" = "完成首次配置";
|
||||
"mac.onboarding.popover.subtitle" = "打开主窗口选择权限、API 设置和本地模型下载。";
|
||||
"mac.error.noAudio" = "没有捕获到音频";
|
||||
"mac.error.noCloudASR" = "当前服务商不支持云端语音识别";
|
||||
"mac.error.emptyTranscript" = "没有识别到语音";
|
||||
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
|
||||
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
|
||||
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
|
||||
"mac.localASR.models" = "本地 ASR 模型";
|
||||
"mac.localASR.modelsDesc" = "Sherpa 模型可直接下载;MLX Qwen3 请将转换好的权重放入「打开目录」指向的文件夹。三个模型共用同一存储目录。";
|
||||
"mac.localASR.models" = "本地 ASR 引擎与模型";
|
||||
"mac.localASR.modelsDesc" = "模型可直接下载。中国大陆优先 ModelScope,其他地区优先 Hugging Face,再回退 GitHub。Qwen3 1.7B 以多文件方式下载。";
|
||||
"mac.localASR.download" = "下载";
|
||||
"mac.localASR.selectFolder" = "选择目录";
|
||||
"mac.localASR.openFolder" = "打开目录";
|
||||
@@ -202,6 +240,7 @@
|
||||
"mac.localASR.installDone" = "安装完成。";
|
||||
"mac.localASR.installed" = "已安装";
|
||||
"mac.localASR.notInstalled" = "未安装";
|
||||
"mac.localASR.personalDictionaryTag" = "个性词库";
|
||||
"mac.localASR.hotwordsYes" = "支持热词";
|
||||
"mac.localASR.hotwordsNo" = "无热词";
|
||||
"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
|
||||
@@ -219,8 +258,8 @@
|
||||
"mac.localASR.redownload" = "重新下载";
|
||||
"mac.localASR.revealInFinder" = "在 Finder 中显示";
|
||||
"mac.localASR.openStorage" = "打开模型存储目录";
|
||||
"mac.localASR.runtime" = "Sherpa 运行时";
|
||||
"mac.localASR.runtimeDesc" = "Sherpa Qwen3 与 SenseVoice 模型需要此运行时;下载上述模型时会自动安装。";
|
||||
"mac.localASR.runtime" = "本地识别运行时";
|
||||
"mac.localASR.runtimeDesc" = "下载上述模型时会自动安装;Qwen3 与 SenseVoice 等模型需要此组件。";
|
||||
"mac.localASR.phase.downloading" = "下载中";
|
||||
"mac.localASR.phase.paused" = "已暂停";
|
||||
"mac.localASR.phase.extracting" = "解压中";
|
||||
|
||||
Reference in New Issue
Block a user