Files
OSGKeyboard/OSGKeyboard/ThirdParty/Qwen3Speech/Sources/AudioCommon/HuggingFaceDownloader.swift
T
Rocky df1c5ff32c feat: migrate on-device Qwen3 ASR to CoreML for background Flow dictation
Replace MLX GPU inference with CoreML bundles so transcription continues
while the host app is backgrounded. Adds model download and warm-up,
vendored Qwen3Speech, and updates onboarding, settings, and copy for the
~1.6 GB CoreML package (iOS 18+).
2026-06-23 00:46:58 +08:00

429 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Hub
import os
/// Download errors
public enum DownloadError: Error, LocalizedError {
case failedToDownload(String)
case invalidRemoteFileName(String)
/// A download attempt made no progress for `seconds` and was aborted
/// so the caller's retry loop can fire instead of hanging.
case stalled(modelId: String, seconds: Int)
public var errorDescription: String? {
switch self {
case .failedToDownload(let file):
return "Failed to download: \(file)"
case .invalidRemoteFileName(let file):
return "Refusing to write unsafe remote file name: \(file)"
case .stalled(let modelId, let seconds):
return "Download stalled for \(modelId): no progress in \(seconds)s"
}
}
}
/// HuggingFace model downloader — shared between ASR, TTS, VAD, etc.
///
/// Uses `HubApi` from the swift-transformers `Hub` module for downloads,
/// which provides HF token auth and metadata tracking. Files that finished
/// downloading are skipped on retry (etag/commit-hash check), but a file
/// interrupted mid-transfer restarts from byte 0 — there is no usable
/// mid-file resume in the current Hub stack, which is why the stall guard
/// and retry ladder below favor patience over fast abort.
public enum HuggingFaceDownloader {
// MARK: - Cache Directory
/// Get cache directory for a model.
///
/// Returns the old flat cache path if it already contains model files (preserving
/// ~10 GB of existing cached models), otherwise returns the new Hub-style path.
public static func getCacheDirectory(for modelId: String, basePath: URL? = nil, cacheDirName: String = "qwen3-speech") throws -> URL {
let base = basePath ?? resolveBaseCacheDir(cacheDirName: cacheDirName)
let fm = FileManager.default
// Check old (flat) cache path for backward compat:
// ~/Library/Caches/qwen3-speech/aufklarer_Qwen3-ASR-0.6B-MLX-4bit/
let oldDir = base.appendingPathComponent(sanitizedCacheKey(for: modelId), isDirectory: true)
if weightsExist(in: oldDir) {
return oldDir
}
// New Hub-style path:
// ~/Library/Caches/qwen3-speech/models/aufklarer/Qwen3-ASR-0.6B-MLX-4bit/
let hub = HubApi(downloadBase: base)
let repo = Hub.Repo(id: modelId)
let dir = hub.localRepoLocation(repo)
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: - Weight Existence Check
/// Extensions recognised as cached model weights: the canonical
/// HF `.safetensors` layout plus Apple CoreML bundle directories
/// (`.mlmodelc`, `.mlpackage`) shipped by CoreML-only repos.
public static let weightFileExtensions: Set<String> = [
"safetensors", "mlmodelc", "mlpackage"
]
/// Returns `true` when `directory` contains at least one entry
/// whose extension matches `weightFileExtensions`. Used by
/// `downloadWeights` to short-circuit network requests when
/// `offlineMode: true` is set on caches that contain only CoreML
/// bundles and no `.safetensors` files.
public static func weightsExist(in directory: URL) -> Bool {
let fm = FileManager.default
guard fm.fileExists(atPath: directory.path) else { return false }
let contents: [URL]
do {
contents = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
} catch {
AudioLog.download.debug("Could not list directory \(directory.path): \(error)")
contents = []
}
return contents.contains { weightFileExtensions.contains($0.pathExtension) }
}
// MARK: - Download
/// Download model files from HuggingFace using `HubApi.snapshot()`.
///
/// Builds glob patterns from the file list:
/// - Always includes `config.json`
/// - If `additionalFiles` doesn't contain `.safetensors` files, adds `*.safetensors`
/// and `model.safetensors.index.json` to discover sharded weights automatically
/// - All entries in `additionalFiles` are added as-is (they work as glob patterns)
public static func downloadWeights(
modelId: String,
to directory: URL,
additionalFiles: [String] = [],
offlineMode: Bool = false,
hubEndpoint: String? = nil,
retryDelaysSeconds: [Int]? = nil,
progressHandler: ((Double) -> Void)? = nil
) async throws {
// Skip network requests when weights are already cached
if offlineMode && weightsExist(in: directory) {
progressHandler?(1.0)
return
}
prepareRepoDirectoryForDownload(at: directory)
var globs: [String] = ["config.json"]
let hasExplicitWeights = additionalFiles.contains { $0.hasSuffix(".safetensors") }
if !hasExplicitWeights {
globs.append("*.safetensors")
globs.append("model.safetensors.index.json")
}
for file in additionalFiles where !globs.contains(file) {
globs.append(file)
}
// Derive the download base from the directory.
// getCacheDirectory returns either:
// old: base/cacheKey (flat, already has weights — won't reach here)
// new: base/models/org/model (Hub-style)
// For Hub API we need `base` as downloadBase.
//
// Forward `offlineMode` explicitly so HubApi doesn't fall through to
// its internal NWPathMonitor auto-detect, which on macOS can briefly
// report `.unsatisfied` and then refuse to download (manifesting as
// "Offline mode error: No files available locally for this repository"
// for a freshly-requested model).
let hub = makeHubApi(for: modelId, repoDir: directory, offlineMode: offlineMode, hubEndpoint: hubEndpoint)
let repo = Hub.Repo(id: modelId)
// Retry with capped backoff — HuggingFace can timeout on slow
// connections or rate-limit, and flaky networks (hotspots, captive
// portals) drop out for minutes at a time. Each attempt is wrapped
// in a progress-stall guard so a wedged mid-transfer (which
// `hub.snapshot` won't surface on its own) aborts and retries
// instead of hanging until the CI job is killed.
//
// No retries in offline mode: the failure is a deterministic local
// cache miss, and 110 s of backoff can't change what's on disk.
let delays = offlineMode ? [] : (retryDelaysSeconds ?? downloadRetryDelaysSeconds)
let maxAttempts = delays.count + 1
var lastError: Error?
for attempt in 1...maxAttempts {
do {
try await withDownloadStallGuard(modelId: modelId) { reportProgress in
try await hub.snapshot(from: repo, matching: globs) { progress in
reportProgress(progress.fractionCompleted)
progressHandler?(progress.fractionCompleted)
}
}
return // Success
} catch {
lastError = error
if isRecoverableHubCacheError(error) {
prepareRepoDirectoryForDownload(at: directory, force: true)
}
if attempt < maxAttempts {
try await Task.sleep(for: .seconds(delays[attempt - 1]))
}
}
}
throw DownloadError.failedToDownload(
"\(modelId) after \(maxAttempts) attempt\(maxAttempts == 1 ? "" : "s") "
+ "(target: \(directory.path)): "
+ (lastError?.localizedDescription ?? "unknown"))
}
/// Download an explicit list of files from HuggingFace without adding any
/// implicit weight globs. This is useful for overlaying tokenizer or config
/// assets from a second repository on top of an existing cache.
public static func downloadFiles(
modelId: String,
to directory: URL,
files: [String],
offlineMode: Bool = false,
hubEndpoint: String? = nil,
retryDelaysSeconds: [Int]? = nil,
progressHandler: ((Double) -> Void)? = nil
) async throws {
if files.isEmpty {
progressHandler?(1.0)
return
}
prepareRepoDirectoryForDownload(at: directory)
let hub = makeHubApi(for: modelId, repoDir: directory, offlineMode: offlineMode, hubEndpoint: hubEndpoint)
let repo = Hub.Repo(id: modelId)
let globs = files.map { $0 }
// Same retry semantics as downloadWeights, including the offline
// no-retry rule — keep the two loops in lockstep.
let delays = offlineMode ? [] : (retryDelaysSeconds ?? downloadRetryDelaysSeconds)
let maxAttempts = delays.count + 1
var lastError: Error?
for attempt in 1...maxAttempts {
do {
try await withDownloadStallGuard(modelId: modelId) { reportProgress in
try await hub.snapshot(from: repo, matching: globs) { progress in
reportProgress(progress.fractionCompleted)
progressHandler?(progress.fractionCompleted)
}
}
return
} catch {
lastError = error
if isRecoverableHubCacheError(error) {
prepareRepoDirectoryForDownload(at: directory, force: true)
}
if attempt < maxAttempts {
try await Task.sleep(for: .seconds(delays[attempt - 1]))
}
}
}
throw DownloadError.failedToDownload(
"\(modelId) after \(maxAttempts) attempt\(maxAttempts == 1 ? "" : "s") "
+ "(target: \(directory.path)): "
+ (lastError?.localizedDescription ?? "unknown"))
}
// MARK: - Retry ladder
/// Delays between download attempts. One more attempt than entries:
/// 5 attempts with 5/15/30/60 s pauses (~110 s of backoff on top of the
/// per-attempt stall patience). Generous on purpose — abandoned attempts
/// restart files from byte 0 with the current Hub stack, so the cheap
/// resource here is wall-clock, not bytes. A network that's down for a
/// couple of minutes (AP roam, hotspot sleep, captive-portal re-auth)
/// should not kill a 2.75 GB first-run download.
static let downloadRetryDelaysSeconds = [5, 15, 30, 60]
/// Total attempts per download (retries + the initial try).
static var downloadMaxAttempts: Int { downloadRetryDelaysSeconds.count + 1 }
// MARK: - Download stall guard
/// Seconds of zero download progress after which an attempt is
/// considered wedged and aborted. `hub.snapshot` reports
/// `fractionCompleted` continuously while bytes flow, so a healthy
/// (even slow) transfer keeps resetting the clock; only a genuinely
/// stalled connection trips this.
///
/// The default is tuned for end users, not CI: aborted attempts restart
/// each file from byte 0 (the Hub stack's mid-file resume never engages
/// on a fresh download), so firing the guard on a connection that would
/// have recovered throws away every byte of that attempt. Flaky networks
/// — AP roams, captive-portal re-auth, hotspot sleep — routinely stall
/// for 13 minutes and then recover, hence 300 s. CI pins
/// `HF_DOWNLOAD_STALL_TIMEOUT=90` to keep failing fast (app users can't
/// set env vars; CI can).
static var downloadStallTimeoutSeconds: Int {
if let raw = ProcessInfo.processInfo.environment["HF_DOWNLOAD_STALL_TIMEOUT"],
let v = Int(raw), v > 0 {
return v
}
return 300
}
/// Thread-safe last-progress timestamp. `hub.snapshot`'s progress
/// callback may fire from a background queue, so guard with a lock.
private final class ProgressClock: @unchecked Sendable {
private let lock = NSLock()
private var last = Date()
func tick() { lock.lock(); last = Date(); lock.unlock() }
func idleSeconds() -> Double {
lock.lock(); defer { lock.unlock() }
return Date().timeIntervalSince(last)
}
}
/// Run a download `operation` that reports fractional progress, and
/// abort it if progress stalls for `downloadStallTimeoutSeconds`.
/// On stall the in-flight `hub.snapshot` task is cancelled (URLSession
/// honors cancellation) and `DownloadError.stalled` is thrown so the
/// caller's retry loop fires instead of hanging indefinitely.
static func withDownloadStallGuard(
modelId: String,
stallTimeoutSeconds: Int? = nil,
_ operation: @escaping (@escaping @Sendable (Double) -> Void) async throws -> Void
) async throws {
let stall = stallTimeoutSeconds ?? downloadStallTimeoutSeconds
let clock = ProgressClock()
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await operation { _ in clock.tick() }
}
group.addTask {
// Poll on a fraction of the window so we detect a stall
// within ~stall..stall+pollStep seconds.
let pollStep = max(1, stall / 3)
while true {
try await Task.sleep(for: .seconds(pollStep))
if clock.idleSeconds() >= Double(stall) {
throw DownloadError.stalled(modelId: modelId, seconds: stall)
}
}
}
// Whichever finishes first wins; cancel the other (the poller
// on success, or the download on stall).
defer { group.cancelAll() }
try await group.next()
}
}
// MARK: - Security Helpers (kept for backward compat + security tests)
/// Convert an arbitrary modelId into a single, safe path component for on-disk caching.
public static func sanitizedCacheKey(for modelId: String) -> String {
let replaced = modelId.replacingOccurrences(of: "/", with: "_")
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
var scalars: [UnicodeScalar] = []
scalars.reserveCapacity(replaced.unicodeScalars.count)
for s in replaced.unicodeScalars {
scalars.append(allowed.contains(s) ? s : "_")
}
var cleaned = String(String.UnicodeScalarView(scalars))
cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "._"))
if cleaned.isEmpty || cleaned == "." || cleaned == ".." {
cleaned = "model"
}
return cleaned
}
/// Validate that a remote file name is safe.
public static func validatedRemoteFileName(_ file: String) throws -> String {
let base = URL(fileURLWithPath: file).lastPathComponent
guard base == file else {
throw DownloadError.invalidRemoteFileName(file)
}
guard !base.isEmpty, !base.hasPrefix("."), !base.contains("..") else {
throw DownloadError.invalidRemoteFileName(file)
}
guard base.range(of: #"^[A-Za-z0-9._-]+$"#, options: .regularExpression) != nil else {
throw DownloadError.invalidRemoteFileName(file)
}
return base
}
/// Validate that a local path stays within the expected directory.
public static func validatedLocalPath(directory: URL, fileName: String) throws -> URL {
let local = directory.appendingPathComponent(fileName, isDirectory: false)
let dirPath = directory.standardizedFileURL.path
let localPath = local.standardizedFileURL.path
let prefix = dirPath.hasSuffix("/") ? dirPath : (dirPath + "/")
guard localPath.hasPrefix(prefix) else {
throw DownloadError.invalidRemoteFileName(fileName)
}
return local
}
// MARK: - Private Helpers
/// Remove a repo folder that has Hub metadata but no complete weights.
/// Stale partial caches trigger "File metadata must have been retrieved from server".
static func prepareRepoDirectoryForDownload(at directory: URL, force: Bool = false) {
let fm = FileManager.default
guard fm.fileExists(atPath: directory.path) else { return }
if !force && weightsExist(in: directory) { return }
try? fm.removeItem(at: directory)
try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
}
private static func isRecoverableHubCacheError(_ error: Error) -> Bool {
let text = (error as? LocalizedError)?.errorDescription
?? error.localizedDescription
return text.localizedCaseInsensitiveContains("metadata")
|| text.localizedCaseInsensitiveContains("offline mode")
}
/// Resolve the base cache directory from env vars or system default.
private static func resolveBaseCacheDir(cacheDirName: String) -> URL {
let fm = FileManager.default
let root: URL
if let override = ProcessInfo.processInfo.environment["QWEN3_CACHE_DIR"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
root = URL(fileURLWithPath: override, isDirectory: true)
} else if let override = ProcessInfo.processInfo.environment["QWEN3_ASR_CACHE_DIR"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// Legacy env var support
root = URL(fileURLWithPath: override, isDirectory: true)
} else {
root = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
}
return root.appendingPathComponent(cacheDirName, isDirectory: true)
}
/// Create a `HubApi` whose `downloadBase` is derived from the repo directory that
/// `getCacheDirectory` returned (strips the `models/<org>/<model>` suffix).
///
/// `offlineMode` is forwarded as `useOfflineMode` so callers get the mode
/// they asked for instead of relying on `NWPathMonitor` auto-detection,
/// which can spuriously report `.unsatisfied` on macOS.
private static func makeHubApi(
for modelId: String,
repoDir: URL,
offlineMode: Bool,
hubEndpoint: String?
) -> HubApi {
// repoDir is base/models/org/model
// We need base
let repo = Hub.Repo(id: modelId)
let suffix = "/\(repo.type.rawValue)/\(repo.id)"
let repoDirPath = repoDir.path
let downloadBase: URL
if repoDirPath.hasSuffix(suffix) {
let basePath = String(repoDirPath.dropLast(suffix.count))
downloadBase = URL(fileURLWithPath: basePath, isDirectory: true)
} else {
// Fallback: old-style flat dir — use its parent as downloadBase.
// Hub won't match this path, so we derive base from env/defaults.
downloadBase = resolveBaseCacheDir(cacheDirName: repoDir.deletingLastPathComponent().lastPathComponent)
}
return HubApi(downloadBase: downloadBase, endpoint: hubEndpoint, useOfflineMode: offlineMode)
}
}