Files
gart/code/Hex-main/Hex/Clients/ParakeetClient.swift
T

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