c0c9dad149
Replace the Sherpa offline pipeline with native MLX streaming, resilient model downloads, live transcript previews, and supporting tests and documentation.
149 lines
6.2 KiB
Swift
149 lines
6.2 KiB
Swift
// LocalASRModelInstallState.swift
|
|
// OSGKeyboard · Shared
|
|
|
|
import Foundation
|
|
|
|
public enum LocalASRModelInstallState {
|
|
|
|
public static func rootDirectory(fileManager: FileManager = .default) -> URL {
|
|
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
|
|
}
|
|
|
|
public static func installDirectory(for relativePath: String, fileManager: FileManager = .default) -> URL {
|
|
rootDirectory(fileManager: fileManager).appendingPathComponent(relativePath, isDirectory: true)
|
|
}
|
|
|
|
public static func isInstalled(
|
|
_ model: LocalASRModelDefinition,
|
|
manualMLXPath: String?,
|
|
fileManager: FileManager = .default
|
|
) -> Bool {
|
|
switch model.installKind {
|
|
case .manual:
|
|
guard let required = model.requiredRelativeFiles, !required.isEmpty else { return false }
|
|
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, .repository:
|
|
guard let relative = model.installRelativePath,
|
|
let layout = model.layout,
|
|
let baseName = model.archiveBaseName else { return false }
|
|
let root = installDirectory(for: relative, fileManager: fileManager)
|
|
.appendingPathComponent(baseName, isDirectory: true)
|
|
return validateArchiveModel(at: root, model: model, layout: layout, fileManager: fileManager)
|
|
case .runtime:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public static func modelRootURL(
|
|
_ model: LocalASRModelDefinition,
|
|
fileManager: FileManager = .default
|
|
) -> URL? {
|
|
guard model.installKind == .archive || model.installKind == .repository,
|
|
let relative = model.installRelativePath,
|
|
let baseName = model.archiveBaseName else { return nil }
|
|
return installDirectory(for: relative, fileManager: fileManager)
|
|
.appendingPathComponent(baseName, isDirectory: true)
|
|
}
|
|
|
|
public static func resolveRuntimeBinary(
|
|
runtime: LocalASRRuntimeDefinition,
|
|
fileManager: FileManager = .default
|
|
) -> URL? {
|
|
let root = installDirectory(for: runtime.installRelativePath, fileManager: fileManager)
|
|
for candidate in runtime.binaryCandidates {
|
|
let direct = root.appendingPathComponent(candidate)
|
|
if fileManager.isExecutableFile(atPath: direct.path) {
|
|
return direct
|
|
}
|
|
}
|
|
for candidate in runtime.binaryCandidates {
|
|
let name = (candidate as NSString).lastPathComponent
|
|
if let found = findExecutable(named: name, under: root, fileManager: fileManager) {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public static func isRuntimeInstalled(
|
|
_ runtime: LocalASRRuntimeDefinition,
|
|
fileManager: FileManager = .default
|
|
) -> Bool {
|
|
resolveRuntimeBinary(runtime: runtime, fileManager: fileManager) != nil
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private static func validateArchiveModel(
|
|
at root: URL,
|
|
model: LocalASRModelDefinition,
|
|
layout: LocalASRModelLayout,
|
|
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,
|
|
let decoder = layout.decoder,
|
|
let tokenizer = layout.tokenizer else { return false }
|
|
return fileManager.fileExists(atPath: root.appendingPathComponent(conv).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(encoder).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(decoder).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokenizer, isDirectory: true).path)
|
|
case .sherpaSenseVoice:
|
|
guard let onnx = layout.senseVoiceModel,
|
|
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
|
|
}
|
|
}
|
|
|
|
private static func findExecutable(
|
|
named name: String,
|
|
under root: URL,
|
|
fileManager: FileManager
|
|
) -> URL? {
|
|
guard let enumerator = fileManager.enumerator(
|
|
at: root,
|
|
includingPropertiesForKeys: [.isExecutableKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else { return nil }
|
|
for case let url as URL in enumerator {
|
|
guard url.lastPathComponent == name else { continue }
|
|
if fileManager.isExecutableFile(atPath: url.path) {
|
|
return url
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public static func directoryByteCount(at url: URL, fileManager: FileManager = .default) -> Int64 {
|
|
guard let enumerator = fileManager.enumerator(
|
|
at: url,
|
|
includingPropertiesForKeys: [.fileSizeKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else { return 0 }
|
|
var total: Int64 = 0
|
|
for case let fileURL as URL in enumerator {
|
|
let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
total += Int64(size)
|
|
}
|
|
return total
|
|
}
|
|
}
|