Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions OpenWhisp/Models/FluidAudioModelsLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ enum FluidAudioModelsLocator {
ParakeetModelIntegrity.verdict(forVariant: id, listing: fileListing(forVariant: id))
}

/// Completeness verdict for a non-variant repo (the batch TDT v3 model, the
/// CTC biasing model) against its explicit manifest — same check as
/// `verdict(forVariant:)` for repos that aren't ParakeetCatalog variants.
static func verdict(forRepoFolder folder: String, requiredPaths: [String])
-> ParakeetModelIntegrity.Verdict
{
ParakeetModelIntegrity.verdict(
requiredPaths: requiredPaths, listing: fileListing(forRepoFolder: folder))
}

/// Recursive file listing (relative paths) of a variant's repo folder, or
/// nil when the folder doesn't exist. Names only — no attributes — so it's
/// cheap even for the ~600 MB repos (a few dozen entries).
Expand Down
116 changes: 97 additions & 19 deletions OpenWhisp/Services/ParakeetBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ enum ParakeetBridgeError: Error, LocalizedError {
}
}

/// Rate-limits download-progress reports before they hop to the main actor:
/// FluidAudio's ProgressHandler fires per byte-chunk (hundreds/sec on a fast
/// link), and each report is a main-actor Task. Whole-percent granularity is
/// all the UI renders anyway. Shared by the streaming and batch engines.
final class ParakeetProgressThrottle: @unchecked Sendable {
private let lock = NSLock()
private var lastPercent = -1
/// Returns true when `fraction` crossed into a new whole percent.
func shouldReport(_ fraction: Double) -> Bool {
let percent = Int((fraction * 100).rounded(.down))
lock.lock(); defer { lock.unlock() }
guard percent != lastPercent else { return false }
lastPercent = percent
return true
}
}

enum ParakeetBridge {

// MARK: - Streaming manager loading
Expand All @@ -70,16 +87,7 @@ enum ParakeetBridge {
onProgress: (@Sendable (ParakeetLoadPhase) -> Void)? = nil
) async throws -> any ParakeetStreamSession {
let variant = ParakeetCatalog.variant(for: variantID)
let progressHandler: ProgressHandler? = onProgress.map { report in
{ progress in
switch progress.phase {
case .listing, .downloading:
report(.downloading(fraction: progress.fractionCompleted))
case .compiling:
report(.compiling)
}
}
}
let progressHandler = fluidProgressHandler(for: onProgress)
do {
if variant.multilingual {
// Nemotron multilingual: separate manager type + repo download.
Expand Down Expand Up @@ -111,11 +119,31 @@ enum ParakeetBridge {
}
}

/// Collapse FluidAudio's byte-granular `DownloadProgress` into the coarse
/// `ParakeetLoadPhase`s the readiness/status UIs render. Shared by the
/// streaming and batch loaders.
private static func fluidProgressHandler(
for onProgress: (@Sendable (ParakeetLoadPhase) -> Void)?
) -> ProgressHandler? {
onProgress.map { report in
{ progress in
switch progress.phase {
case .listing, .downloading:
report(.downloading(fraction: progress.fractionCompleted))
case .compiling:
report(.compiling)
}
}
}
}

/// Map a FluidAudio-path error onto the bridge's typed cases. Network-side
/// failures (FluidAudio's own `DownloadError`, URLSession errors) become
/// `.download`; everything else — above all CoreML failing to open bytes
/// already on disk — is `.load`. Cancellation passes through unchanged.
private static func classified(_ error: Error) -> Error {
/// (`fileprivate`, not `private`: ParakeetVocabularyBiaser below classifies
/// its CTC load path through the same mapping.)
fileprivate static func classified(_ error: Error) -> Error {
if error is CancellationError { return error }
if error is DownloadError || error is URLError {
return ParakeetBridgeError.download(underlying: error.localizedDescription)
Expand All @@ -133,12 +161,24 @@ enum ParakeetBridge {
}

/// Download (first use) + load Parakeet TDT v3 for batch/file transcription.
static func loadBatch() async throws -> BatchHandle {
let models = try await AsrModels.downloadAndLoad(version: .v3)
let manager = AsrManager()
try await manager.loadModels(models)
let layers = await manager.decoderLayerCount
return BatchHandle(manager: manager, decoderLayers: layers)
///
/// Same contract as `loadStreamSession`: `onProgress` gets byte-granular
/// download fractions + the compile phase, and errors are rethrown as
/// `ParakeetBridgeError` (cancellation stays untyped) so ParakeetFileEngine
/// can run the corrupt-cache repair without importing FluidAudio.
static func loadBatch(
onProgress: (@Sendable (ParakeetLoadPhase) -> Void)? = nil
) async throws -> BatchHandle {
do {
let models = try await AsrModels.downloadAndLoad(
version: .v3, progressHandler: fluidProgressHandler(for: onProgress))
let manager = AsrManager()
try await manager.loadModels(models)
let layers = await manager.decoderLayerCount
return BatchHandle(manager: manager, decoderLayers: layers)
} catch {
throw classified(error)
}
}

/// Transcribe a WAV file with the batch model. `languageCode` is the bare
Expand Down Expand Up @@ -262,18 +302,56 @@ actor ParakeetVocabularyBiaser {
if loadFailed { return nil }
if let models, let tokenizer { return (models, tokenizer) }
do {
let loadedModels = try await CtcModels.downloadAndLoad(variant: .ctc110m)
let loadedTokenizer = try await CtcTokenizer.load()
let (loadedModels, loadedTokenizer) = try await Self.loadRepairingCorruptCache()
models = loadedModels
tokenizer = loadedTokenizer
return (loadedModels, loadedTokenizer)
} catch is CancellationError {
// A cancelled transcription must not disable biasing for the whole
// session — the next file gets a fresh attempt.
return nil
} catch {
// One shot: a missing/failed CTC model shouldn't re-download per file.
loadFailed = true
NSLog("[Parakeet] CTC biasing model unavailable: %@", error.localizedDescription)
return nil
}
}

/// Load with the same corrupt-cache repair as the transcription engines:
/// FluidAudio's presence gate skips the download over a torn cache forever,
/// so a LOAD failure with the repo folder present purges it and redownloads
/// once. Download failures aren't repairable by deleting bytes, and
/// cancellation passes through untyped — neither ever purges. Fail-open
/// stays the caller's job (`ensureLoaded` swallows whatever this throws).
private static func loadRepairingCorruptCache() async throws -> (CtcModels, CtcTokenizer) {
do {
return try await loadOnce()
} catch let error as ParakeetBridgeError {
guard case .load(let underlying) = error,
FluidAudioModelsLocator.installedFolders()
.contains(ParakeetModelIntegrity.ctcBiasRepoFolder)
else { throw error }
NSLog(
"[Parakeet] CTC load failed with model files present (%@) — purging '%@' and redownloading once",
underlying, ParakeetModelIntegrity.ctcBiasRepoFolder)
try? FluidAudioModelsLocator.removeRepoFolder(ParakeetModelIntegrity.ctcBiasRepoFolder)
return try await loadOnce()
}
}

private static func loadOnce() async throws -> (CtcModels, CtcTokenizer) {
do {
let models = try await CtcModels.downloadAndLoad(variant: .ctc110m)
// The tokenizer reads tokenizer.json from the same repo folder; a
// cache missing only that file fails HERE, not in downloadAndLoad —
// classifying it as `.load` is what makes it repairable above.
let tokenizer = try await CtcTokenizer.load()
return (models, tokenizer)
} catch {
throw ParakeetBridge.classified(error)
}
}
}

// MARK: - ParakeetStreamSession protocol + adapters
Expand Down
55 changes: 50 additions & 5 deletions OpenWhisp/Services/ParakeetFileEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,58 @@ final class ParakeetFileEngine: FileTranscriptionEngine {
if let handle = loadedHandle { return (loadGeneration, Task { handle }) }
if let existing = inFlightLoad { return (loadGeneration, existing) }
let status = onWorkerStatus
// Real download/compile progress into the worker-status line (the file
// engine has no readiness stream — this is its one status seam),
// throttled to whole percents before the main-actor hop.
let throttle = ParakeetProgressThrottle()
let onProgress: @Sendable (ParakeetLoadPhase) -> Void = { phase in
switch phase {
case .downloading(let fraction):
guard throttle.shouldReport(fraction) else { return }
let percent = Int((fraction * 100).rounded(.down))
Task { @MainActor in status?("Downloading Parakeet model… \(percent)%") }
case .compiling:
Task { @MainActor in status?("Preparing Parakeet model…") }
}
}
let task = Task<ParakeetBridge.BatchHandle, Error> {
NSLog("[Parakeet] loading TDT v3 batch model…")
await MainActor.run { status?("Preparing Parakeet model…") }
let handle = try await ParakeetBridge.loadBatch()
NSLog("[Parakeet] TDT v3 batch model loaded.")
await MainActor.run { status?("Parakeet ready") }
return handle
// Verified completeness (not folder presence) picks the honest
// initial status: a torn first-run download still has the (re)download
// ahead of it. Real fractions replace this the moment bytes flow.
let onDisk = FluidAudioModelsLocator.verdict(
forRepoFolder: ParakeetModelIntegrity.batchRepoFolder,
requiredPaths: ParakeetModelIntegrity.batchRequiredPaths) == .complete
await MainActor.run {
status?(onDisk ? "Preparing Parakeet model…" : "Downloading Parakeet model…")
}
do {
let handle = try await ParakeetBridge.loadBatch(onProgress: onProgress)
NSLog("[Parakeet] TDT v3 batch model loaded.")
await MainActor.run { status?("Parakeet ready") }
return handle
} catch let error as ParakeetBridgeError {
// Corrupt-cache repair (mirrors ParakeetStreamingEngine): the
// repo folder exists — so FluidAudio's presence gate will skip
// the download forever — but the model can't load (torn first-run
// download). Purge the batch repo and redownload once, inside the
// single-flight task so concurrent waiters share one repair. A
// download error is NOT repairable by deleting bytes, and
// cancellation never reaches here (it stays untyped).
guard case .load(let underlying) = error,
FluidAudioModelsLocator.installedFolders()
.contains(ParakeetModelIntegrity.batchRepoFolder)
else { throw error }
NSLog(
"[Parakeet] batch load failed with model files present (%@) — purging '%@' and redownloading once",
underlying, ParakeetModelIntegrity.batchRepoFolder)
try? FluidAudioModelsLocator.removeRepoFolder(ParakeetModelIntegrity.batchRepoFolder)
await MainActor.run { status?("Downloading Parakeet model…") }
let handle = try await ParakeetBridge.loadBatch(onProgress: onProgress)
NSLog("[Parakeet] TDT v3 batch model loaded after cache repair.")
await MainActor.run { status?("Parakeet ready") }
return handle
}
}
inFlightLoad = task
return (loadGeneration, task)
Expand Down
46 changes: 44 additions & 2 deletions OpenWhisp/Services/ParakeetModelIntegrity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,41 @@ public enum ParakeetModelIntegrity {
]
}

// MARK: - Non-variant repos (batch TDT v3 + CTC biasing)

/// Repo folder of the batch (TDT v3) model — `ParakeetFileEngine`'s backend
/// for every non-live path (files, meetings, history re-transcribe). Not a
/// ParakeetCatalog variant, so it gets its own manifest here. Mirrors
/// FluidAudio's `Repo.parakeetV3.folderName`.
public static let batchRepoFolder = "parakeet-tdt-0.6b-v3"

/// Required paths for the batch model at the app's default int8 encoder
/// precision — FluidAudio's `ModelNames.ASR.requiredModelsV3(.int8)` plus
/// the vocabulary JSON its `AsrModels.load` also needs.
public static let batchRequiredPaths: [String] = [
"Preprocessor.mlmodelc/coremldata.bin",
"Encoder.mlmodelc/coremldata.bin",
"Decoder.mlmodelc/coremldata.bin",
"JointDecisionv3.mlmodelc/coremldata.bin",
"parakeet_vocab.json",
]

/// Repo folder of the CTC-WS vocabulary-biasing model (MAK-71). Unlike the
/// other repos FluidAudio keeps the `-coreml` suffix in this folder name.
public static let ctcBiasRepoFolder = "parakeet-ctc-110m-coreml"

/// Required paths for the CTC biasing model: the two CoreML bundles plus
/// BOTH root JSONs — `vocab.json` (`CtcModels.load`) and `tokenizer.json`
/// (`CtcTokenizer.load`). FluidAudio's own presence gate never checks the
/// tokenizer, so a cache missing only that file loads the models fine and
/// then fails tokenization — same torn-cache family, different file.
public static let ctcBiasRequiredPaths: [String] = [
"MelSpectrogram.mlmodelc/coremldata.bin",
"AudioEncoder.mlmodelc/coremldata.bin",
"vocab.json",
"tokenizer.json",
]

/// Verify a variant against its repo folder's recursive file listing
/// (relative paths; nil = the folder doesn't exist).
///
Expand All @@ -77,12 +112,19 @@ public enum ParakeetModelIntegrity {
public static func verdict(forVariant id: String, listing: Set<String>?) -> Verdict {
guard let listing else { return .notDownloaded }
if let required = requiredPaths(forVariant: id) {
let missing = required.filter { !listing.contains($0) }
return missing.isEmpty ? .complete : .incomplete(missing: missing.sorted())
return verdict(requiredPaths: required, listing: listing)
}
return genericVerdict(listing: listing)
}

/// Verify an explicit manifest (the batch/CTC repos, which aren't catalog
/// variants) against a repo folder's recursive file listing.
public static func verdict(requiredPaths: [String], listing: Set<String>?) -> Verdict {
guard let listing else { return .notDownloaded }
let missing = requiredPaths.filter { !listing.contains($0) }
return missing.isEmpty ? .complete : .incomplete(missing: missing.sorted())
}

/// Generic rule for manifest-less variants: the folder must hold at least
/// one model bundle, and every compiled `.mlmodelc` bundle must contain its
/// root `coremldata.bin`. (`.mlpackage` is FluidAudio's accepted uncompiled
Expand Down
21 changes: 3 additions & 18 deletions OpenWhisp/Services/ParakeetStreamingEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -652,28 +652,13 @@ final class ParakeetStreamingEngine: NSObject, StreamingTranscriptionEngine {
if inFlightLoad == failed { inFlightLoad = nil }
}

/// Rate-limits download-progress reports before they hop to the main actor:
/// FluidAudio's ProgressHandler fires per byte-chunk (hundreds/sec on a fast
/// link), and each report is a main-actor Task. Whole-percent granularity is
/// all the UI renders anyway.
private final class ProgressThrottle: @unchecked Sendable {
private let lock = NSLock()
private var lastPercent = -1
/// Returns true when `fraction` crossed into a new whole percent.
func shouldReport(_ fraction: Double) -> Bool {
let percent = Int((fraction * 100).rounded(.down))
lock.lock(); defer { lock.unlock() }
guard percent != lastPercent else { return false }
lastPercent = percent
return true
}
}

@MainActor
private func loadTaskOnMain() -> Task<any ParakeetStreamSession, Error> {
if let existing = inFlightLoad { return existing }
let variant = variantID
let throttle = ProgressThrottle()
// Whole-percent throttle (ParakeetProgressThrottle, shared with the
// batch engine) so per-byte-chunk reports don't flood the main actor.
let throttle = ParakeetProgressThrottle()
// Forward real download/compile progress into the readiness stream
// (menu row + onboarding bar). Weak: a replaced engine must not keep
// reporting into the tracker.
Expand Down
Loading
Loading