215 lines
8.0 KiB
Swift
Executable File
215 lines
8.0 KiB
Swift
Executable File
import Foundation
|
|
import HexCore
|
|
|
|
#if canImport(FluidAudio)
|
|
import FluidAudio
|
|
|
|
actor ParakeetClient {
|
|
private var asr: AsrManager?
|
|
private var models: AsrModels?
|
|
private var currentVariant: ParakeetModel?
|
|
private let logger = HexLog.parakeet
|
|
private let vendorDirs = [
|
|
// Our app-specific cache path convention (under XDG or com.kitlangton.Hex/cache)
|
|
"fluidaudio/Models",
|
|
"FluidAudio/Models"
|
|
]
|
|
|
|
func isModelAvailable(_ modelName: String) async -> Bool {
|
|
guard let variant = ParakeetModel(rawValue: modelName) else {
|
|
logger.error("Unknown Parakeet variant requested: \(modelName)")
|
|
return false
|
|
}
|
|
if currentVariant == variant, asr != nil { return true }
|
|
|
|
let directory = AsrModels.defaultCacheDirectory(for: variant.asrVersion)
|
|
migrateLegacyCacheIfNeeded(variant, to: directory)
|
|
let available = AsrModels.modelsExist(
|
|
at: directory,
|
|
version: variant.asrVersion
|
|
)
|
|
if available {
|
|
logger.notice("Found Parakeet cache at \(directory.path)")
|
|
} else {
|
|
logger.debug("No Parakeet cache detected variant=\(variant.identifier) path=\(directory.path)")
|
|
}
|
|
return available
|
|
}
|
|
|
|
func ensureLoaded(modelName: String, progress: @escaping (Progress) -> Void) async throws {
|
|
guard let variant = ParakeetModel(rawValue: modelName) else {
|
|
throw NSError(
|
|
domain: "Parakeet",
|
|
code: -4,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unsupported Parakeet variant: \(modelName)"]
|
|
)
|
|
}
|
|
if currentVariant == variant, asr != nil { return }
|
|
if currentVariant != variant {
|
|
asr = nil
|
|
models = nil
|
|
}
|
|
migrateLegacyCacheIfNeeded(
|
|
variant,
|
|
to: AsrModels.defaultCacheDirectory(for: variant.asrVersion)
|
|
)
|
|
let t0 = Date()
|
|
logger.notice("Starting Parakeet load variant=\(variant.identifier)")
|
|
let p = Progress(totalUnitCount: 100)
|
|
p.completedUnitCount = 1
|
|
progress(p)
|
|
|
|
// Best-effort progress polling while FluidAudio downloads
|
|
let fm = FileManager.default
|
|
let support = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
let faDir = support?.appendingPathComponent("FluidAudio/Models/\(variant.identifier)", isDirectory: true)
|
|
let pollTask = Task {
|
|
while p.completedUnitCount < 95 {
|
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
if let dir = faDir, let size = directorySize(dir) {
|
|
let target: Double = 650 * 1024 * 1024 // ~650MB
|
|
let frac = max(0.0, min(1.0, Double(size) / target))
|
|
p.completedUnitCount = Int64(5 + frac * 90)
|
|
progress(p)
|
|
}
|
|
if Task.isCancelled { break }
|
|
}
|
|
}
|
|
defer { pollTask.cancel() }
|
|
|
|
// Download + load the requested variant (returns when all assets are present)
|
|
let models = try await AsrModels.downloadAndLoad(version: variant.asrVersion)
|
|
self.models = models
|
|
let manager = AsrManager(config: .init(), models: models)
|
|
self.asr = manager
|
|
self.currentVariant = variant
|
|
p.completedUnitCount = 100
|
|
progress(p)
|
|
logger.notice("Parakeet ensureLoaded completed in \(String(format: "%.2f", Date().timeIntervalSince(t0)))s")
|
|
}
|
|
|
|
private func directorySize(_ dir: URL) -> UInt64? {
|
|
let fm = FileManager.default
|
|
guard let en = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: .skipsHiddenFiles) else { return nil }
|
|
var total: UInt64 = 0
|
|
for case let url as URL in en {
|
|
if let vals = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), vals.isRegularFile == true {
|
|
total &+= UInt64(vals.fileSize ?? 0)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
private func migrateLegacyCacheIfNeeded(_ variant: ParakeetModel, to directory: URL) {
|
|
let legacyDirectory = directory
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent(variant.identifier, isDirectory: true)
|
|
|
|
do {
|
|
if try LegacyModelCacheMigrator.migrate(
|
|
from: legacyDirectory,
|
|
to: directory,
|
|
isValid: {
|
|
AsrModels.modelsExist(at: $0, version: variant.asrVersion)
|
|
}
|
|
) {
|
|
logger.notice("Migrated legacy Parakeet cache from \(legacyDirectory.path) to \(directory.path)")
|
|
}
|
|
} catch {
|
|
logger.error("Failed to migrate legacy Parakeet cache: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func transcribe(_ url: URL) async throws -> String {
|
|
guard let asr else { throw NSError(domain: "Parakeet", code: -1, userInfo: [NSLocalizedDescriptionKey: "Parakeet not initialized"]) }
|
|
let t0 = Date()
|
|
logger.notice("Transcribing with Parakeet file=\(url.lastPathComponent)")
|
|
var decoderState = TdtDecoderState.make(decoderLayers: await asr.decoderLayerCount)
|
|
let result = try await asr.transcribe(url, decoderState: &decoderState)
|
|
logger.info("Parakeet transcription finished in \(String(format: "%.2f", Date().timeIntervalSince(t0)))s")
|
|
return result.text
|
|
}
|
|
|
|
// Delete cached Parakeet models from known locations and reset state
|
|
func deleteCaches(modelName: String) async throws {
|
|
guard let variant = ParakeetModel(rawValue: modelName) else { return }
|
|
let fm = FileManager.default
|
|
|
|
var removedAny = false
|
|
for dir in modelDirectories(variant) {
|
|
if fm.fileExists(atPath: dir.path) {
|
|
try? fm.removeItem(at: dir)
|
|
removedAny = true
|
|
}
|
|
}
|
|
|
|
// Reset live objects so a future download can proceed cleanly
|
|
if removedAny {
|
|
self.asr = nil
|
|
self.models = nil
|
|
if currentVariant == variant {
|
|
currentVariant = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns all candidate directories where a Parakeet model might be cached.
|
|
/// Includes both exact matches and prefixed directories (e.g. versioned folders).
|
|
private func modelDirectories(_ variant: ParakeetModel) -> [URL] {
|
|
let fm = FileManager.default
|
|
var result: [URL] = [AsrModels.defaultCacheDirectory(for: variant.asrVersion)]
|
|
|
|
for root in candidateRoots() {
|
|
for vendor in vendorDirs {
|
|
let base = root.appendingPathComponent(vendor, isDirectory: true)
|
|
// Exact match directory
|
|
let direct = base.appendingPathComponent(variant.identifier, isDirectory: true)
|
|
result.append(direct)
|
|
// Prefixed directories (e.g. versioned folders)
|
|
if let items = try? fm.contentsOfDirectory(at: base, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) {
|
|
for item in items where item.lastPathComponent.hasPrefix(variant.identifier) && item != direct {
|
|
result.append(item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var seen = Set<String>()
|
|
return result.filter { seen.insert($0.standardizedFileURL.path).inserted }
|
|
}
|
|
|
|
private func candidateRoots() -> [URL] {
|
|
let fm = FileManager.default
|
|
let xdg = ProcessInfo.processInfo.environment["XDG_CACHE_HOME"].flatMap { URL(fileURLWithPath: $0, isDirectory: true) }
|
|
let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
|
let appCache = try? URL.hexApplicationSupport.appendingPathComponent("cache", isDirectory: true)
|
|
let userCache = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cache", isDirectory: true)
|
|
return [xdg, appCache, appSupport, userCache].compactMap { $0 }
|
|
}
|
|
}
|
|
|
|
private extension ParakeetModel {
|
|
var asrVersion: AsrModelVersion {
|
|
switch self {
|
|
case .englishV2: return .v2
|
|
case .multilingualV3: return .v3
|
|
}
|
|
}
|
|
}
|
|
|
|
#else
|
|
|
|
actor ParakeetClient {
|
|
func isModelAvailable(_ modelName: String) async -> Bool { false }
|
|
func ensureLoaded(modelName: String, progress: @escaping (Progress) -> Void) async throws {
|
|
throw NSError(
|
|
domain: "Parakeet",
|
|
code: -2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Parakeet support not linked. Add Swift Package: https://github.com/FluidInference/FluidAudio.git and link FluidAudio to Hex."]
|
|
)
|
|
}
|
|
func transcribe(_ url: URL) async throws -> String { throw NSError(domain: "Parakeet", code: -3, userInfo: [NSLocalizedDescriptionKey: "Parakeet not available"]) }
|
|
func deleteCaches(modelName: String) async throws {}
|
|
}
|
|
|
|
#endif
|