From fb2c6622f0d5bb3dfcb07064644c24231b11fddb Mon Sep 17 00:00:00 2001 From: Maksym Naboka Date: Tue, 4 Aug 2026 11:20:49 -0700 Subject: [PATCH] =?UTF-8?q?fix(onboarding):=20Parakeet=20model=20download?= =?UTF-8?q?=20=E2=80=94=20real=20progress,=20integrity=20verification,=20c?= =?UTF-8?q?orrupt-cache=20repair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh install could land in a permanently stuck state: a torn first-run download leaves the FluidAudio repo folder present, FluidAudio's single-file presence gate then skips the download forever, and MLModel.load fails on every launch with a raw 'Unable to load model: file://…' in the menu bar — while onboarding showed a green 'Your speech model is ready' (folder-presence == installed) and offered no repair. - ParakeetModelIntegrity (core, tested): per-variant required-file manifests (unified tiers incl. per-tier encoder suffix, EOU) + generic mlmodelc coremldata.bin rule for the multilingual layout. 'Installed' now means verified complete everywhere (AppState, tracker, onboarding, Models pane). - Real download progress: FluidAudio 0.15.5's ProgressHandler threaded through ParakeetBridge → engine readiness (throttled per whole percent) → menu bar percentage, onboarding determinate bar + percent caption, Models pane status. Compile phase reported as .loading ('Optimizing the model for your Mac…'). - Corrupt-cache auto-repair: a load failure with files present purges the variant's repo folder and redownloads once, inside the single-flight load task. Typed ParakeetBridgeError (download vs load; cancellation passes through untouched) maps failures to user copy — no more file:// URLs in UI. - Honest failure flag: prefetch failure now reported whenever the engine is still current (present-but-corrupt included); onboarding precedence flipped so a failure outranks bytes-on-disk (failed card + Retry, not a green lie). - Settings → Models: per-variant verified badges, live download/compile/failure status row, and an explicit 'Redownload Model' repair button. Co-Authored-By: Claude Fable 5 --- OpenWhisp/Models/AppState.swift | 57 +++-- .../Models/FluidAudioModelsLocator.swift | 37 ++++ OpenWhisp/Services/EngineReadiness.swift | 13 +- .../Services/OnboardingModelStatus.swift | 50 +++-- OpenWhisp/Services/ParakeetBridge.swift | 113 ++++++++-- .../Services/ParakeetDownloadState.swift | 31 ++- .../Services/ParakeetModelIntegrity.swift | 124 +++++++++++ .../Services/ParakeetStreamingEngine.swift | 97 ++++++-- OpenWhisp/Views/ModelReadinessTracker.swift | 8 +- OpenWhisp/Views/OnboardingView.swift | 73 ++++-- OpenWhisp/Views/Settings/ModelsPane.swift | 110 +++++++++- Package.swift | 1 + .../OnboardingModelStatusTests.swift | 35 ++- .../ParakeetModelIntegrityTests.swift | 207 ++++++++++++++++++ docs/PARAKEET.md | 7 +- 15 files changed, 823 insertions(+), 140 deletions(-) create mode 100644 OpenWhisp/Services/ParakeetModelIntegrity.swift create mode 100644 Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift diff --git a/OpenWhisp/Models/AppState.swift b/OpenWhisp/Models/AppState.swift index fbcc0ef..ecfc888 100644 --- a/OpenWhisp/Models/AppState.swift +++ b/OpenWhisp/Models/AppState.swift @@ -1202,16 +1202,15 @@ class AppState: ObservableObject { /// selected when the OS supports it (SpeechAnalyzerAvailability). var speechAnalyzerStreamEngine: StreamingTranscriptionEngine! /// Variant ids with a Parakeet model prefetch/warm in flight — drives the - /// coarse "Downloading…" badge in the Models pane (FluidAudio has no progress - /// callback, so this is presence-of-folder + this in-flight flag). Cleared - /// when the variant's repo folder appears on disk (polled by the pane). + /// "Downloading…" badge in the Models pane (real percentages flow separately + /// through `ModelReadinessTracker`). Cleared when the prefetch completes. @Published var parakeetInFlightVariants: Set = [] - /// True when the last Parakeet model prefetch FAILED (e.g. offline first-run) - /// and its repo folder never landed. FluidAudio exposes no progress or error - /// callback, so this is the only failure signal — it lets onboarding show a - /// retryable "couldn't download" state instead of a perpetual spinner. Set - /// when `prefetchAwaiting()` returns false with the folder still absent; - /// cleared whenever a fresh prefetch is kicked (the Retry path). + /// True when the last Parakeet model prefetch FAILED for the still-current + /// engine — a download failure (offline first-run) OR a model that won't + /// load even after the engine's purge-and-redownload repair. Lets onboarding + /// show a retryable failure card instead of a perpetual spinner (or, worse, + /// a green "ready" over a corrupt cache). Cleared whenever a fresh prefetch + /// is kicked (the Retry path). @Published var parakeetPrefetchFailed = false var translationService: OpenAITranslationService! var hotkeyMonitor: HotkeyControlling! @@ -3122,22 +3121,22 @@ class AppState: ObservableObject { } /// Kick the streaming-variant model prefetch and mark it in-flight so the - /// Models pane shows a coarse "Downloading…" badge (FluidAudio gives no - /// progress). Event-driven: the badge clears when the engine's load task - /// completes — success or failure (on failure the row honestly reverts to - /// "Not downloaded"). No disk polling, and no state mutation during view - /// rendering. Idempotent (the engine coalesces concurrent loads). + /// Models pane shows a "Downloading…" badge (real percentages flow through + /// the readiness tracker). Event-driven: the badge clears when the engine's + /// load task completes — success or failure (on failure the row honestly + /// reverts to "Not downloaded"). No disk polling, and no state mutation + /// during view rendering. Idempotent (the engine coalesces concurrent loads). func prefetchParakeetVariant() { let variant = ParakeetCatalog.normalize(parakeetVariant) // A new prefetch attempt clears any stale failure — this doubles as the // Retry path (onboarding re-kicks this on the retry button). parakeetPrefetchFailed = false - // If the repo is already on disk there's nothing to download — don't + // If the model files verify complete there's nothing to download — don't // flash a badge; still prefetch (it warms the loaded model cheaply). - let installed = Self.installedFluidAudioFolders() - if ParakeetDownloadStatePolicy.state( - forVariant: variant, installedFolders: installed, inFlightVariants: [] - ) != .installed { + // Verified completeness, not folder presence: a torn first-run download + // leaves a present-but-unloadable folder that still needs the badge + // while the engine repairs (purges + redownloads) it. + if FluidAudioModelsLocator.verdict(forVariant: variant) != .complete { parakeetInFlightVariants.insert(variant) } // Capture THIS engine instance: if the user switches variant mid-prefetch, @@ -3148,17 +3147,15 @@ class AppState: ObservableObject { Task { @MainActor in let ok = await engine?.prefetchAwaiting() ?? false parakeetInFlightVariants.remove(variant) - // Only report a failure when the model genuinely isn't on disk. A load - // can "fail" for reasons unrelated to the download (e.g. the engine was - // replaced by a variant switch) while the bytes are already staged; a - // present folder means the user is not stuck, so don't cry failure. - if !ok { - let onDisk = ParakeetDownloadStatePolicy.state( - forVariant: variant, - installedFolders: Self.installedFluidAudioFolders(), - inFlightVariants: [] - ) == .installed - parakeetPrefetchFailed = !onDisk + // A false return from the engine that is STILL current means the + // user genuinely has no working model — including present-but-corrupt + // files (the engine already tried its purge-and-redownload repair). + // The old "folder on disk ⇒ don't cry failure" heuristic is exactly + // how a torn download showed a green "ready" in onboarding while the + // menu bar said "Model unavailable". Only a prefetch orphaned by a + // variant switch stays quiet: its successor re-warms and re-reports. + if !ok, engine != nil, engine === parakeetStreamEngine { + parakeetPrefetchFailed = true } } } diff --git a/OpenWhisp/Models/FluidAudioModelsLocator.swift b/OpenWhisp/Models/FluidAudioModelsLocator.swift index b72a559..9430550 100644 --- a/OpenWhisp/Models/FluidAudioModelsLocator.swift +++ b/OpenWhisp/Models/FluidAudioModelsLocator.swift @@ -26,4 +26,41 @@ enum FluidAudioModelsLocator { return isDir.boolValue }) } + + /// Completeness verdict for a variant's model cache — the check that tells + /// "installed" from "present but torn" (a killed first-run download leaves a + /// folder FluidAudio's presence gate accepts but `MLModel.load` can't open). + static func verdict(forVariant id: String) -> ParakeetModelIntegrity.Verdict { + ParakeetModelIntegrity.verdict(forVariant: id, listing: fileListing(forVariant: id)) + } + + /// 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). + static func fileListing(forVariant id: String) -> Set? { + guard let folder = ParakeetDownloadStatePolicy.repoFolder(forVariant: id) else { return nil } + return fileListing(forRepoFolder: folder) + } + + static func fileListing(forRepoFolder folder: String) -> Set? { + let base = modelsDirectory().appendingPathComponent(folder, isDirectory: true) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: base.path, isDirectory: &isDir), + isDir.boolValue, + let enumerator = FileManager.default.enumerator( + at: base, includingPropertiesForKeys: nil, + options: [.producesRelativePathURLs]) + else { return nil } + var paths = Set() + for case let url as URL in enumerator { paths.insert(url.relativePath) } + return paths + } + + /// Delete a repo folder (corrupt-cache repair / explicit "Redownload Model"). + /// A missing folder is a no-op, not an error. + static func removeRepoFolder(_ folder: String) throws { + let target = modelsDirectory().appendingPathComponent(folder, isDirectory: true) + guard FileManager.default.fileExists(atPath: target.path) else { return } + try FileManager.default.removeItem(at: target) + } } diff --git a/OpenWhisp/Services/EngineReadiness.swift b/OpenWhisp/Services/EngineReadiness.swift index 15a68fb..5e8a7a3 100644 --- a/OpenWhisp/Services/EngineReadiness.swift +++ b/OpenWhisp/Services/EngineReadiness.swift @@ -20,8 +20,8 @@ public enum EngineReadiness: Equatable { /// worth reporting in the menu (no work is happening to report). case idle /// Model bytes are being fetched. `progress` is 0...1 when the backend - /// reports it (WhisperKit) and nil when it does not (FluidAudio/Parakeet has - /// no progress callback — an indeterminate "Downloading…" is the honest UI). + /// reports it (WhisperKit's Progress callback, FluidAudio's ProgressHandler) + /// and nil before the first report — an indeterminate "Downloading…" then. case downloading(progress: Double?) /// Bytes are on disk; the model/session is being loaded into memory (the /// several-second CoreML/ANE compile+load that the owner reported as a dead @@ -134,7 +134,8 @@ public enum EngineReadinessResolver { public struct ParakeetObservation: Equatable { /// A prefetch/load task is running for the selected variant. public var prefetchInFlight: Bool - /// The variant's repo folder is present on disk (bytes downloaded). + /// The variant's model files verified complete on disk (bytes down; + /// `ParakeetModelIntegrity` → `.complete`, not mere folder presence). public var modelOnDisk: Bool /// The engine holds a loaded `ParakeetStreamSession` — the one true /// "can start capturing now" signal. @@ -167,10 +168,12 @@ public enum EngineReadinessResolver { // A loaded session outranks everything: the engine can capture now, even // if a redundant prefetch is still settling. if o.sessionLoaded { return .ready } - if o.prefetchFailed { return .failed("download failed — check your connection") } + if o.prefetchFailed { return .failed(ParakeetFailureCopy.downloadFailed) } if o.prefetchInFlight { // Bytes present → this is the in-memory load; otherwise it's the - // first-run fetch. FluidAudio reports no progress, hence nil. + // first-run fetch. Real fractions arrive via the engine's own + // readiness callback, which outranks this resolver — nil is only + // the before-first-report placeholder. return o.modelOnDisk ? .loading : .downloading(progress: nil) } // Nothing in flight: the model will load lazily at the first session. diff --git a/OpenWhisp/Services/OnboardingModelStatus.swift b/OpenWhisp/Services/OnboardingModelStatus.swift index 9a20414..5537d2d 100644 --- a/OpenWhisp/Services/OnboardingModelStatus.swift +++ b/OpenWhisp/Services/OnboardingModelStatus.swift @@ -7,15 +7,13 @@ import Foundation /// flags (`isModelDownloading` / `modelDownloadProgress` / `modelDownloadStatus` /// / `modelDownloadFailed`). Once Parakeet became the default engine (MAK-46), /// a fresh install downloads a Parakeet model whose progress lives in a totally -/// separate place (`parakeetInFlightVariants` — a coarse in-flight flag, no -/// percentage, since FluidAudio exposes none). With the old wiring the step -/// would cheerfully say "Your speech model is ready" while Parakeet was still -/// downloading in the background, and the first dictation would stall. +/// separate place. With the old wiring the step would cheerfully say "Your +/// speech model is ready" while Parakeet was still downloading in the +/// background, and the first dictation would stall. /// /// This maps whichever engine is active to the ONE readiness state the step /// renders, so the copy, icon, and progress bar always describe the model that's -/// actually being fetched. Parakeet (and WhisperKit-preloading) report only -/// indeterminate progress; whisper.cpp still carries a real percentage. +/// actually being fetched. public enum OnboardingModelStatus { /// What the onboarding model step should display. public enum State: Equatable { @@ -23,12 +21,11 @@ public enum OnboardingModelStatus { /// Speech) — the step shows the green "ready" card. case ready /// A download is running. `progress` is the 0…1 fraction when the engine - /// reports one (whisper.cpp), or nil for engines that only expose a coarse - /// in-flight state (Parakeet / a WhisperKit preload) — the step then shows - /// an indeterminate spinner. + /// reports one (whisper.cpp, WhisperKit, Parakeet via the readiness + /// tracker), or nil when none is available yet — the step then shows an + /// indeterminate spinner. case downloading(progress: Double?) - /// The download failed and can be retried (only the whisper.cpp path - /// surfaces a discrete failure today). + /// The download failed and can be retried. case failed } @@ -37,13 +34,18 @@ public enum OnboardingModelStatus { /// /// - Parameters: /// - engine: the `transcriptionEngine` setting value. - /// - parakeetInstalled: whether the selected Parakeet variant's repo folder - /// is on disk (`ParakeetDownloadStatePolicy` → `.installed`). + /// - parakeetInstalled: whether the selected Parakeet variant's model files + /// VERIFIED complete on disk (`ParakeetModelIntegrity` → `.complete`). /// - parakeetInFlight: whether a Parakeet prefetch is running for the /// selected variant. - /// - parakeetFailed: the last Parakeet prefetch failed and the model isn't - /// on disk (`AppState.parakeetPrefetchFailed`) — surfaces the retryable - /// failure card instead of a perpetual spinner. + /// - parakeetFailed: the last Parakeet prefetch failed + /// (`AppState.parakeetPrefetchFailed`) — surfaces the retryable failure + /// card instead of a perpetual spinner. Outranks `parakeetInstalled`: a + /// present-but-corrupt cache can pass the file check while the model + /// still cannot load, and "ready" there was the green lie that hid the + /// fresh-install torn-download trap. + /// - parakeetProgress: the download fraction (0…1) from the readiness + /// tracker, when a download is reporting one. /// - whisperCppDownloading: `isModelDownloading` (whisper.cpp GGML fetch). /// - whisperCppProgress: `modelDownloadProgress` (0…1) or nil. /// - whisperCppFailed: `modelDownloadFailed`. @@ -60,6 +62,7 @@ public enum OnboardingModelStatus { parakeetInstalled: Bool, parakeetInFlight: Bool, parakeetFailed: Bool = false, + parakeetProgress: Double? = nil, whisperCppDownloading: Bool, whisperCppProgress: Double?, whisperCppFailed: Bool, @@ -70,15 +73,20 @@ public enum OnboardingModelStatus { ) -> State { switch engine { case "parakeet": - if parakeetInstalled { return .ready } - if parakeetInFlight { return .downloading(progress: nil) } - // A finished-but-failed prefetch (offline first-run) with no model on - // disk: show the retryable failure card, not a spinner that never ends. + // A running prefetch outranks everything (a retry must show progress, + // not a lingering failure card) — with the real fraction when the + // download is reporting one. + if parakeetInFlight { return .downloading(progress: normalizedProgress(parakeetProgress)) } + // A finished-but-failed prefetch: the retryable failure card — even + // when files are present on disk. Present-but-unloadable is the + // corrupt-cache state the engine's repair path exists for; claiming + // "ready" here contradicted the menu bar's "Model unavailable". if parakeetFailed { return .failed } + if parakeetInstalled { return .ready } // Not on disk and no prefetch running yet — the launch prefetch kicks // it momentarily; show downloading so the copy is honest rather than // claiming "ready" for a model that isn't there. - return .downloading(progress: nil) + return .downloading(progress: normalizedProgress(parakeetProgress)) case "whisperKit": if whisperKitStaged { return .ready } if whisperKitDownloading { diff --git a/OpenWhisp/Services/ParakeetBridge.swift b/OpenWhisp/Services/ParakeetBridge.swift index 428a8a5..f2fa310 100644 --- a/OpenWhisp/Services/ParakeetBridge.swift +++ b/OpenWhisp/Services/ParakeetBridge.swift @@ -11,6 +11,43 @@ import FluidAudio /// streaming manager shapes (`any StreamingAsrManager` and the Nemotron /// multilingual actor); /// - the batch (TDT v3) handle used by ParakeetFileEngine. +/// Coarse load-progress phases the bridge forwards to the engine. Collapses +/// FluidAudio's `DownloadProgress` (fraction + listing/downloading/compiling +/// phase) into what the readiness UI can render. +enum ParakeetLoadPhase: Sendable { + /// Bytes are coming down; `fraction` is 0…1 of the whole operation. + case downloading(fraction: Double) + /// Post-download CoreML compilation — reported as `.loading` upstream. + case compiling +} + +/// Typed error over the FluidAudio load path, so callers can distinguish a +/// network failure (retry when online) from a load failure over bytes already +/// on disk (the corrupt-cache case the purge-and-redownload repair targets) — +/// without importing FluidAudio themselves. `LocalizedError` so every existing +/// `error.localizedDescription` sink (menu row, onboarding failure card, +/// session error toast) gets the user-facing copy instead of CoreML's raw +/// `Unable to load model: file://…` string; the raw detail stays in the case +/// payload for logs. +enum ParakeetBridgeError: Error, LocalizedError { + case download(underlying: String) + case load(underlying: String) + + var errorDescription: String? { + switch self { + case .download: return ParakeetFailureCopy.downloadFailed + case .load: return ParakeetFailureCopy.loadFailed + } + } + + /// The raw underlying message, for NSLog only — never for the UI. + var underlying: String { + switch self { + case .download(let raw), .load(let raw): return raw + } + } +} + enum ParakeetBridge { // MARK: - Streaming manager loading @@ -22,24 +59,68 @@ enum ParakeetBridge { /// The id was normalized by the catalog, but an id FluidAudio doesn't know /// (catalog/library drift after a version bump) still falls back to the /// default variant rather than failing the session. - static func loadStreamSession(variantID: String) async throws -> any ParakeetStreamSession { + /// + /// `onProgress` receives byte-granular download fractions and the compile + /// phase (FluidAudio's `ProgressHandler`, called on an arbitrary queue). + /// Errors are rethrown as `ParakeetBridgeError` — except cancellation, + /// which passes through untyped so a variant-switch mid-load can't be + /// mistaken for a corrupt cache and trigger a purge. + static func loadStreamSession( + variantID: String, + onProgress: (@Sendable (ParakeetLoadPhase) -> Void)? = nil + ) async throws -> any ParakeetStreamSession { let variant = ParakeetCatalog.variant(for: variantID) - if variant.multilingual { - // Nemotron multilingual: separate manager type + repo download. - let chunkMs = variant.multilingualChunkMs ?? 1120 - let dir = try await StreamingNemotronMultilingualAsrManager.downloadVariant( - languageCode: "auto", chunkMs: chunkMs) - let manager = StreamingNemotronMultilingualAsrManager() - try await manager.loadModels(from: dir) - return NemotronMultilingualStreamSession(manager: manager) + let progressHandler: ProgressHandler? = onProgress.map { report in + { progress in + switch progress.phase { + case .listing, .downloading: + report(.downloading(fraction: progress.fractionCompleted)) + case .compiling: + report(.compiling) + } + } + } + do { + if variant.multilingual { + // Nemotron multilingual: separate manager type + repo download. + let chunkMs = variant.multilingualChunkMs ?? 1120 + let dir = try await StreamingNemotronMultilingualAsrManager.downloadVariant( + languageCode: "auto", chunkMs: chunkMs, progressHandler: progressHandler) + let manager = StreamingNemotronMultilingualAsrManager() + try await manager.loadModels(from: dir) + return NemotronMultilingualStreamSession(manager: manager) + } + // English streaming families (Unified / EOU), wrapped in the unified adapter. + let fluidVariant = StreamingModelVariant(rawValue: variant.id) + ?? StreamingModelVariant(rawValue: ParakeetCatalog.defaultVariantID) + ?? .parakeetUnified320ms + let manager = fluidVariant.createManager() + // The `StreamingAsrManager` protocol's no-arg `loadModels()` drops + // the progress callback on the floor — downcast to the concrete + // managers to reach their `progressHandler:` overloads. + if let unified = manager as? StreamingUnifiedAsrManager { + try await unified.loadModels(progressHandler: progressHandler) + } else if let eou = manager as? StreamingEouAsrManager { + try await eou.loadModels(progressHandler: progressHandler) + } else { + try await manager.loadModels() + } + return StreamingAsrManagerSession(manager: manager) + } catch { + throw classified(error) + } + } + + /// 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 { + if error is CancellationError { return error } + if error is DownloadError || error is URLError { + return ParakeetBridgeError.download(underlying: error.localizedDescription) } - // English streaming families (Unified / EOU), wrapped in the unified adapter. - let fluidVariant = StreamingModelVariant(rawValue: variant.id) - ?? StreamingModelVariant(rawValue: ParakeetCatalog.defaultVariantID) - ?? .parakeetUnified320ms - let manager = fluidVariant.createManager() - try await manager.loadModels() - return StreamingAsrManagerSession(manager: manager) + return ParakeetBridgeError.load(underlying: error.localizedDescription) } // MARK: - Batch (TDT v3) — ParakeetFileEngine backend diff --git a/OpenWhisp/Services/ParakeetDownloadState.swift b/OpenWhisp/Services/ParakeetDownloadState.swift index 7de4378..29f39d1 100644 --- a/OpenWhisp/Services/ParakeetDownloadState.swift +++ b/OpenWhisp/Services/ParakeetDownloadState.swift @@ -1,19 +1,19 @@ import Foundation /// Coarse per-variant download state for the Parakeet variant picker (MAK-46 -/// Phase 4). FluidAudio has no progress callback, so the honest thing is a -/// three-state indicator — installed / downloading… / not downloaded — derived -/// from (a) whether the variant's CoreML repo folder exists on disk and (b) -/// whether a prefetch is in flight. No fake percentages. +/// Phase 4) — installed / downloading… / not downloaded — derived from (a) +/// whether the variant's model files are complete on disk and (b) whether a +/// prefetch is in flight. (Real download percentages live on the readiness +/// path — `EngineReadiness.downloading(progress:)` — not on these badges.) /// /// Pure + Foundation-only so the mapping (variant → repo folder → state) is /// unit-tested; the disk walk + in-flight tracking stay app-side. public enum ParakeetDownloadState: Equatable { - /// The variant's repo folder isn't on disk yet. + /// The variant's model files aren't (fully) on disk yet. case notDownloaded - /// A prefetch/load is running (indeterminate — no percentage available). + /// A prefetch/load is running. case downloading - /// The repo folder is present on disk. + /// The variant's model files are complete on disk. case installed /// Short suffix for the variant row subtitle. `installed` returns nil (no @@ -68,4 +68,21 @@ public enum ParakeetDownloadStatePolicy { } return .notDownloaded } + + /// Verdict-based state: `installed` means the variant's files VERIFIED + /// complete, not merely that the repo folder exists. This is the resolver + /// the UI should prefer — the folder-presence overload above predates + /// `ParakeetModelIntegrity` and can call a torn first-run download + /// "installed" (the fresh-install onboarding bug). + public static func state( + forVariant id: String, + verdict: ParakeetModelIntegrity.Verdict, + inFlightVariants: Set + ) -> ParakeetDownloadState { + if verdict == .complete { return .installed } + if inFlightVariants.contains(ParakeetCatalog.normalize(id)) { + return .downloading + } + return .notDownloaded + } } diff --git a/OpenWhisp/Services/ParakeetModelIntegrity.swift b/OpenWhisp/Services/ParakeetModelIntegrity.swift new file mode 100644 index 0000000..fe45833 --- /dev/null +++ b/OpenWhisp/Services/ParakeetModelIntegrity.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Completeness verification for the Parakeet (FluidAudio) model cache. +/// +/// Why this exists (fresh-install onboarding bug): FluidAudio's download gate is +/// a single-file presence check — if the variant's encoder bundle EXISTS on disk +/// (even truncated by a killed or dropped first-run download), the download is +/// skipped forever and `MLModel.load` fails on every launch with a raw `file://` +/// error. Nothing app-side could tell "installed" from "present but broken": the +/// folder-exists heuristic said installed, onboarding said "ready", and the menu +/// bar said "Model unavailable" — all at once. A variant counts as installed +/// only when every file it needs is actually there. +/// +/// Pure + Foundation-only (OpenWhispCore): callers hand in the repo folder's +/// recursive file listing; the disk walk stays app-side +/// (`FluidAudioModelsLocator.fileListing`). +public enum ParakeetModelIntegrity { + + /// Completeness verdict for one variant's on-disk model cache. + public enum Verdict: Equatable { + /// Every required file is present — the variant counts as installed. + case complete + /// The repo folder exists but required files are missing — a torn + /// download, or only a different tier's files. `missing` lists the + /// absent relative paths (for logs/tests, not user copy). + case incomplete(missing: [String]) + /// The repo folder isn't on disk at all. + case notDownloaded + } + + /// Required paths (relative to the variant's repo folder) for the manifest- + /// verified variants. Compiled CoreML bundles (`.mlmodelc`) are directories; + /// requiring their root `coremldata.bin` is what catches a torn bundle — + /// the bare directory alone satisfies FluidAudio's own presence gate. + /// + /// Names mirror FluidAudio 0.15.5's `ModelNames` (the dependency is pinned + /// `exact:`, so drift is a deliberate bump, not a surprise). The Unified + /// tiers share one repo; each latency tier bakes its attention context into + /// a distinct int8 encoder bundle (the app's default precision) alongside + /// tier-independent decoder/joint/vocab files. The multilingual variant's + /// layout is language-dependent, so it returns nil and is checked by the + /// generic bundle rule instead. + public static func requiredPaths(forVariant id: String) -> [String]? { + switch ParakeetCatalog.normalize(id) { + case "parakeet-unified-320ms": return unifiedPaths(contextSuffix: "70_2_2") + case "parakeet-unified-640ms": return unifiedPaths(contextSuffix: "70_7_1") + case "parakeet-unified-1120ms": return unifiedPaths(contextSuffix: "70_7_7") + case "parakeet-eou-320ms": + // FluidAudio nests the EOU chunk tier one level under the repo folder. + return [ + "320ms/streaming_encoder.mlmodelc/coremldata.bin", + "320ms/decoder.mlmodelc/coremldata.bin", + "320ms/joint_decision.mlmodelc/coremldata.bin", + "320ms/vocab.json", + ] + default: + return nil + } + } + + private static func unifiedPaths(contextSuffix: String) -> [String] { + [ + "parakeet_unified_encoder_streaming_\(contextSuffix)_int8.mlmodelc/coremldata.bin", + "parakeet_unified_decoder.mlmodelc/coremldata.bin", + "parakeet_unified_joint_decision_single_step.mlmodelc/coremldata.bin", + "vocab.json", + ] + } + + /// Verify a variant against its repo folder's recursive file listing + /// (relative paths; nil = the folder doesn't exist). + /// + /// Manifest variants check their exact required paths — which also treats a + /// repo holding only a DIFFERENT tier's encoder as not-installed (correct: + /// FluidAudio still has this tier's download ahead of it). Variants without + /// a manifest fall back to the generic rule. + public static func verdict(forVariant id: String, listing: Set?) -> 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 genericVerdict(listing: listing) + } + + /// 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 + /// fallback layout and has no single sentinel file to check.) + private static func genericVerdict(listing: Set) -> Verdict { + var mlmodelcBundles = Set() + var hasUncompiledBundle = false + for path in listing { + let components = path.split(separator: "/") + for (index, component) in components.enumerated() { + if component.hasSuffix(".mlmodelc") { + mlmodelcBundles.insert(components[0...index].joined(separator: "/")) + } else if component.hasSuffix(".mlpackage") { + hasUncompiledBundle = true + } + } + } + guard hasUncompiledBundle || !mlmodelcBundles.isEmpty else { + return .incomplete(missing: [""]) + } + let missing = mlmodelcBundles + .map { "\($0)/coremldata.bin" } + .filter { !listing.contains($0) } + .sorted() + return missing.isEmpty ? .complete : .incomplete(missing: missing) + } +} + +/// User-facing copy for Parakeet model failures. One place so the menu row, +/// onboarding failure card, and Settings agree — and so the raw CoreML +/// `Unable to load model: file://…` string never reaches the UI again. +public enum ParakeetFailureCopy { + /// The fetch itself failed (offline first run, HuggingFace hiccup). + public static let downloadFailed = "download failed — check your connection" + /// The bytes are down but the model can't load, and the automatic + /// purge-and-redownload repair didn't fix it. + public static let loadFailed = + "the model couldn't be loaded — use Redownload Model in Settings → Models" +} diff --git a/OpenWhisp/Services/ParakeetStreamingEngine.swift b/OpenWhisp/Services/ParakeetStreamingEngine.swift index 1d8baa4..00ace11 100644 --- a/OpenWhisp/Services/ParakeetStreamingEngine.swift +++ b/OpenWhisp/Services/ParakeetStreamingEngine.swift @@ -599,26 +599,23 @@ final class ParakeetStreamingEngine: NSObject, StreamingTranscriptionEngine { } // MAK-94: report the phase we're about to enter. FluidAudio downloads // into the variant's repo folder and only then compiles + loads the - // CoreML session, so "folder on disk" is the honest download/load split — - // and it's the LOAD that the user hits on every launch after the first. - // FluidAudio exposes no progress callback, hence the nil progress. - let onDisk = ParakeetDownloadStatePolicy.state( - forVariant: variantID, - installedFolders: FluidAudioModelsLocator.installedFolders(), - inFlightVariants: [] - ) == .installed + // CoreML session, so "files verified complete" is the honest + // download/load split — and it's the LOAD that the user hits on every + // launch after the first. Verified completeness, not folder presence: a + // torn first-run download leaves a folder that FluidAudio's presence + // gate accepts but that still has a (re)download ahead of it. + // Real fractions replace the nil the moment the downloader reports. + let onDisk = FluidAudioModelsLocator.verdict(forVariant: variantID) == .complete reportReadiness(onDisk ? .loading : .downloading(progress: nil)) // Route through ensureLoaded so a FAILED load clears `inFlightLoad` // (clearFailedLoad) before the error is swallowed — otherwise a failed // download poisons the cache and later prefetches no-op forever. + // ensureLoaded reports the terminal readiness edge (.ready/.failed) + // itself, so the lazy session-start path stays in sync too. do { _ = try await ensureLoaded() - // A download that just completed transitions through .loading before - // the session lands; emitting .ready here is the terminal edge. - reportReadiness(.ready) return true } catch { - reportReadiness(.failed(error.localizedDescription)) return false } } @@ -630,9 +627,19 @@ final class ParakeetStreamingEngine: NSObject, StreamingTranscriptionEngine { do { let session = try await task.value await storeSession(session) + // Terminal readiness edge here (not only in prefetchAwaiting) so a + // LAZY load — first dictation triggering the download — also lands + // the menu/overlay on .ready instead of a stale "Downloading…". + await reportReadiness(.ready) return session } catch { await clearFailedLoad(task) + // Cancellation is a variant switch replacing this engine, not a + // failure of the model — reporting it would flash a bogus error + // (and the successor engine re-reports through its own callback). + if !(error is CancellationError) { + await reportReadiness(.failed(error.localizedDescription)) + } throw error } } @@ -645,15 +652,75 @@ 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 { if let existing = inFlightLoad { return existing } let variant = variantID + let throttle = ProgressThrottle() + // Forward real download/compile progress into the readiness stream + // (menu row + onboarding bar). Weak: a replaced engine must not keep + // reporting into the tracker. + let onProgress: @Sendable (ParakeetLoadPhase) -> Void = { [weak self] phase in + switch phase { + case .downloading(let fraction): + guard throttle.shouldReport(fraction) else { return } + Task { @MainActor [weak self] in + self?.reportReadiness(.downloading(progress: fraction)) + } + case .compiling: + Task { @MainActor [weak self] in + self?.reportReadiness(.loading) + } + } + } let task = Task { NSLog("[Parakeet] loading variant '%@'…", variant) - let session = try await ParakeetBridge.loadStreamSession(variantID: variant) - NSLog("[Parakeet] variant loaded.") - return session + do { + let session = try await ParakeetBridge.loadStreamSession( + variantID: variant, onProgress: onProgress) + NSLog("[Parakeet] variant loaded.") + return session + } catch let error as ParakeetBridgeError { + // Corrupt-cache repair (the fresh-install trap): the repo folder + // exists — so FluidAudio's presence gate will skip the download + // forever — but the model can't load (torn/interrupted first-run + // download). Purge the variant's 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, + let folder = ParakeetDownloadStatePolicy.repoFolder(forVariant: variant), + FluidAudioModelsLocator.installedFolders().contains(folder) + else { throw error } + NSLog( + "[Parakeet] load failed with model files present (%@) — purging '%@' and redownloading once", + underlying, folder) + try? FluidAudioModelsLocator.removeRepoFolder(folder) + Task { @MainActor [weak self] in + self?.reportReadiness(.downloading(progress: nil)) + } + let session = try await ParakeetBridge.loadStreamSession( + variantID: variant, onProgress: onProgress) + NSLog("[Parakeet] variant loaded after cache repair.") + return session + } } inFlightLoad = task return task diff --git a/OpenWhisp/Views/ModelReadinessTracker.swift b/OpenWhisp/Views/ModelReadinessTracker.swift index 00e9d5b..11d6ad9 100644 --- a/OpenWhisp/Views/ModelReadinessTracker.swift +++ b/OpenWhisp/Views/ModelReadinessTracker.swift @@ -147,11 +147,9 @@ final class ModelReadinessTracker: ObservableObject { let variant = ParakeetCatalog.normalize(appState.parakeetVariant) let observation = EngineReadinessResolver.ParakeetObservation( prefetchInFlight: appState.parakeetInFlightVariants.contains(variant), - modelOnDisk: ParakeetDownloadStatePolicy.state( - forVariant: variant, - installedFolders: AppState.installedFluidAudioFolders(), - inFlightVariants: [] - ) == .installed, + // Verified completeness, not folder presence — a torn download must + // resolve as "still downloading", never as bytes-staged .loading. + modelOnDisk: FluidAudioModelsLocator.verdict(forVariant: variant) == .complete, sessionLoaded: appState.parakeetStreamEngine?.isSessionLoaded ?? false, prefetchFailed: appState.parakeetPrefetchFailed ) diff --git a/OpenWhisp/Views/OnboardingView.swift b/OpenWhisp/Views/OnboardingView.swift index f7f8008..d321e19 100644 --- a/OpenWhisp/Views/OnboardingView.swift +++ b/OpenWhisp/Views/OnboardingView.swift @@ -21,10 +21,15 @@ struct OnboardingView: View { // Live Input-Monitoring status, refreshed by the same poll timer. Drives the // hotkey step's readiness and the "try it" hotkey-is-dead guard (MAK-24). @State private var inputMonitoringStatus: OnboardingHotkeyGate.InputMonitoringStatus = .unknown - // FluidAudio repo folders on disk, so the model step can tell whether the - // (default) Parakeet model has finished downloading. Refreshed by the poll - // timer — cheap dir listing — so "downloading…" flips to "ready" on its own. - @State private var parakeetInstalledFolders: Set = [] + // Whether the (default) Parakeet model's files VERIFY complete on disk — + // not mere folder presence, which a torn first-run download also satisfies. + // Refreshed by the poll timer — a cheap name-only walk — so "downloading…" + // flips to "ready" on its own. + @State private var parakeetModelComplete = false + // The Parakeet engine's live readiness (real download percentage, the + // compile phase, the mapped failure reason), mirrored from + // ModelReadinessTracker by the same poll. + @State private var parakeetReadiness: EngineReadiness = .idle private let pollTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() @@ -145,16 +150,18 @@ struct OnboardingView: View { /// poll refreshes the underlying signals). private var modelStatus: OnboardingModelStatus.State { let variant = ParakeetCatalog.normalize(appState.parakeetVariant) - let parakeetState = ParakeetDownloadStatePolicy.state( - forVariant: variant, - installedFolders: parakeetInstalledFolders, - inFlightVariants: appState.parakeetInFlightVariants - ) + let parakeetProgress: Double? + if case .downloading(let fraction) = parakeetReadiness { + parakeetProgress = fraction + } else { + parakeetProgress = nil + } return OnboardingModelStatus.state( engine: appState.transcriptionEngine, - parakeetInstalled: parakeetState == .installed, - parakeetInFlight: parakeetState == .downloading, + parakeetInstalled: parakeetModelComplete, + parakeetInFlight: appState.parakeetInFlightVariants.contains(variant), parakeetFailed: appState.parakeetPrefetchFailed, + parakeetProgress: parakeetProgress, whisperCppDownloading: appState.isModelDownloading, whisperCppProgress: appState.modelDownloadProgress, whisperCppFailed: appState.modelDownloadFailed, @@ -210,8 +217,8 @@ struct OnboardingView: View { } /// Progress caption during a download. whisper.cpp publishes a rich status - /// string (bytes/percent); the streaming engines (Parakeet / WhisperKit - /// preload) don't, so fall back to a plain one-liner for them. + /// string (bytes/percent); Parakeet's percentage and compile phase come from + /// the readiness tracker; WhisperKit carries its own status string. private var modelDownloadCaption: String { if appState.transcriptionEngine == "whisper", !appState.modelDownloadStatus.isEmpty { return appState.modelDownloadStatus @@ -220,11 +227,26 @@ struct OnboardingView: View { appState.transcriptionEngine == "whisperKit" { return appState.whisperKitDownloadStatus } + if appState.transcriptionEngine == "parakeet" { + switch parakeetReadiness { + case .loading: + // Post-download CoreML compile/load — a download caption here + // would look like a stalled fetch. + return "Optimizing the model for your Mac…" + case .downloading(let fraction): + if let fraction, fraction > 0 { + return "Downloading the speech model… \(Int(min(fraction, 1) * 100))%" + } + default: + break + } + } return "Downloading the speech model…" } - /// Failure caption. whisper.cpp and WhisperKit carry specific status strings; - /// the streaming engines (Parakeet) don't, so fall back to a plain one-liner. + /// Failure caption. whisper.cpp and WhisperKit carry specific status + /// strings; Parakeet surfaces the readiness tracker's mapped reason (network + /// vs corrupt-cache), so the card says what actually went wrong. private var modelFailureDetail: String { if appState.transcriptionEngine == "whisper", !appState.modelDownloadStatus.isEmpty { return appState.modelDownloadStatus @@ -232,13 +254,19 @@ struct OnboardingView: View { if appState.transcriptionEngine == "whisperKit", !appState.whisperKitDownloadStatus.isEmpty { return appState.whisperKitDownloadStatus } + if appState.transcriptionEngine == "parakeet", + case .failed(let reason) = parakeetReadiness { + return "Couldn't prepare the speech model: \(reason). Retry redownloads it." + } return "Couldn't reach the model server. Check your connection and retry." } /// Engine-aware retry: whisper.cpp re-runs its GGML download; Parakeet - /// re-kicks its FluidAudio prefetch (which also clears the failure flag); - /// WhisperKit re-runs the model-manager download for the selected model - /// (its start clears `whisperKitDownloadFailed`). + /// re-kicks its FluidAudio prefetch (which clears the failure flag, and — + /// when the failure was a corrupt cache — triggers the engine's + /// purge-and-redownload repair); WhisperKit re-runs the model-manager + /// download for the selected model (its start clears + /// `whisperKitDownloadFailed`). private func retryModelDownload() { switch appState.transcriptionEngine { case "parakeet": @@ -525,10 +553,13 @@ struct OnboardingView: View { micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized accessibilityGranted = AXIsProcessTrusted() inputMonitoringStatus = appState.liveInputMonitoringStatus - // Only the Parakeet path needs the on-disk folder scan; skip the listing - // for the other engines so the poll stays cheap. + // Only the Parakeet path needs the on-disk scan + readiness mirror; + // skip both for the other engines so the poll stays cheap. if appState.transcriptionEngine == "parakeet" { - parakeetInstalledFolders = AppState.installedFluidAudioFolders() + let variant = ParakeetCatalog.normalize(appState.parakeetVariant) + parakeetModelComplete = + FluidAudioModelsLocator.verdict(forVariant: variant) == .complete + parakeetReadiness = ModelReadinessTracker.shared.readiness } appState.refreshPermissionLabels() } diff --git a/OpenWhisp/Views/Settings/ModelsPane.swift b/OpenWhisp/Views/Settings/ModelsPane.swift index bbb3cd0..b3d67cc 100644 --- a/OpenWhisp/Views/Settings/ModelsPane.swift +++ b/OpenWhisp/Views/Settings/ModelsPane.swift @@ -6,6 +6,9 @@ import Cocoa /// and Advanced → Storage sections. struct ModelsPane: View { @ObservedObject var appState: AppState + /// Live engine readiness (real Parakeet download percentage / compile phase / + /// mapped failure), for the status row under the variant picker. + @ObservedObject private var readinessTracker = ModelReadinessTracker.shared // Model storage: the scanned list (refreshed on appear + after a delete) and // the item pending a delete confirmation. @@ -13,10 +16,11 @@ struct ModelsPane: View { @State private var storageDeleteTarget: ModelStorage.Item? @State private var storageMessage: String = "" @State private var showAllWhisperModels = false - /// Cached FluidAudio repo folders present on disk, so the Parakeet variant - /// rows never walk the directory during rendering (refreshed on appear and + /// Cached per-variant completeness verdicts, so the Parakeet variant rows + /// never walk the directory during rendering (refreshed on appear and /// whenever a prefetch finishes, i.e. `parakeetInFlightVariants` changes). - @State private var parakeetInstalledFolders: Set = [] + /// Verdict-based — a torn download reads "Not downloaded", never installed. + @State private var parakeetVerdicts: [String: ParakeetModelIntegrity.Verdict] = [:] private var isWhisperCpp: Bool { appState.transcriptionEngine == "whisper" } private var isWhisperKit: Bool { appState.transcriptionEngine == "whisperKit" } @@ -48,16 +52,16 @@ struct ModelsPane: View { .onAppear { appState.refreshWhisperKitStagedModels() refreshStorage() - parakeetInstalledFolders = AppState.installedFluidAudioFolders() + refreshParakeetVerdicts() } // Sizes refresh automatically after downloads finish — no manual button. .onChange(of: appState.isModelDownloading) { refreshStorage() } .onChange(of: appState.whisperKitDownloadingModel) { refreshStorage() } .onChange(of: appState.isLLMModelDownloading) { refreshStorage() } - // A Parakeet prefetch finishing flips the in-flight set — rescan the - // installed folders (and sizes) once, in event context, not per render. + // A Parakeet prefetch finishing flips the in-flight set — re-verify the + // variants (and sizes) once, in event context, not per render. .onChange(of: appState.parakeetInFlightVariants) { - parakeetInstalledFolders = AppState.installedFluidAudioFolders() + refreshParakeetVerdicts() refreshStorage() } .confirmationDialog( @@ -175,15 +179,16 @@ struct ModelsPane: View { /// Parakeet variant picker (MAK-46). FluidAudio stages the model itself on /// first use (HuggingFace → ~/Library/Application Support/FluidAudio); - /// selecting a variant prefetches it in the background. FluidAudio exposes no - /// download progress, so each row shows a COARSE state — "Not downloaded" / - /// "Downloading…" / (installed → no badge) — via ParakeetDownloadStatePolicy. + /// selecting a variant prefetches it in the background. Each row shows a + /// verified state — "Not downloaded" / "Downloading…" / (installed → no + /// badge) — and the status row underneath carries the live percentage, + /// integrity result, and the Redownload repair for the selected variant. private var parakeetModelSection: some View { Section { ForEach(ParakeetCatalog.variants, id: \.id) { variant in let state = ParakeetDownloadStatePolicy.state( forVariant: variant.id, - installedFolders: parakeetInstalledFolders, + verdict: parakeetVerdicts[variant.id] ?? .notDownloaded, inFlightVariants: appState.parakeetInFlightVariants ) SelectableRow( @@ -192,13 +197,94 @@ struct ModelsPane: View { isSelected: appState.parakeetVariant == variant.id ) { appState.parakeetVariant = variant.id } } + + parakeetStatusView } header: { Text("Model") } footer: { - SettingsFootnote("The model downloads automatically the first time it's needed and is cached under Application Support/FluidAudio. All transcription stays on your Mac.") + SettingsFootnote("Models download automatically the first time they're needed and are cached under Application Support/FluidAudio. Files are verified on disk; Redownload replaces a damaged copy. All transcription stays on your Mac.") } } + /// Live status for the SELECTED variant: download percentage while fetching, + /// the compile phase, a verified checkmark when complete, and — on failure + /// or an incomplete cache — the explicit Redownload repair the menu-bar + /// "Model unavailable" error previously had no answer to. + @ViewBuilder private var parakeetStatusView: some View { + let selectedVerdict = + parakeetVerdicts[ParakeetCatalog.normalize(appState.parakeetVariant)] ?? .notDownloaded + switch readinessTracker.readiness { + case .downloading(let progress): + VStack(alignment: .leading, spacing: 6) { + if let progress, progress > 0 { + ProgressView(value: min(progress, 1)).frame(maxWidth: 280) + Text("Downloading… \(Int(min(progress, 1) * 100))%") + .font(.caption).foregroundColor(.secondary) + } else { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Downloading…").font(.caption).foregroundColor(.secondary) + } + } + } + .padding(.vertical, 4) + case .loading: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading the model…").font(.caption).foregroundColor(.secondary) + } + .padding(.vertical, 4) + case .failed(let reason): + VStack(alignment: .leading, spacing: 8) { + Label(reason, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + Button("Redownload Model") { redownloadSelectedParakeetVariant() } + } + .padding(.vertical, 4) + default: + switch selectedVerdict { + case .complete: + Label("Model files verified", systemImage: "checkmark.seal") + .font(.caption) + .foregroundColor(.secondary) + .padding(.vertical, 2) + case .incomplete: + VStack(alignment: .leading, spacing: 8) { + Label("Model files are incomplete — redownload to repair.", + systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + Button("Redownload Model") { redownloadSelectedParakeetVariant() } + } + .padding(.vertical, 4) + case .notDownloaded: + EmptyView() + } + } + } + + /// Purge the selected variant's repo folder and prefetch it again — the + /// explicit repair for a damaged cache (also reachable implicitly: a failed + /// load with files present triggers the engine's own purge-and-redownload). + private func redownloadSelectedParakeetVariant() { + let variant = ParakeetCatalog.normalize(appState.parakeetVariant) + if let folder = ParakeetDownloadStatePolicy.repoFolder(forVariant: variant) { + try? FluidAudioModelsLocator.removeRepoFolder(folder) + } + refreshParakeetVerdicts() + refreshStorage() + appState.prefetchParakeetVariant() + } + + /// Re-verify every catalog variant's on-disk completeness (event context + /// only — never during render). + private func refreshParakeetVerdicts() { + parakeetVerdicts = Dictionary(uniqueKeysWithValues: ParakeetCatalog.variants.map { + ($0.id, FluidAudioModelsLocator.verdict(forVariant: $0.id)) + }) + } + /// Variant subtitle with the coarse download-state badge appended (installed /// variants get no badge — the row is unadorned). private func parakeetSubtitle( diff --git a/Package.swift b/Package.swift index 320a6fe..2452075 100644 --- a/Package.swift +++ b/Package.swift @@ -48,6 +48,7 @@ let package = Package( "ParakeetTailHallucination.swift", "AgentContextVocabulary.swift", "ParakeetDownloadState.swift", + "ParakeetModelIntegrity.swift", "AgentEouAutoStop.swift", "StreamingRoutePolicy.swift", "SpeechAnalyzerAvailability.swift", diff --git a/Tests/OpenWhispCoreTests/OnboardingModelStatusTests.swift b/Tests/OpenWhispCoreTests/OnboardingModelStatusTests.swift index fcc04fa..989a238 100644 --- a/Tests/OpenWhispCoreTests/OnboardingModelStatusTests.swift +++ b/Tests/OpenWhispCoreTests/OnboardingModelStatusTests.swift @@ -13,6 +13,7 @@ final class OnboardingModelStatusTests: XCTestCase { parakeetInstalled: Bool = false, parakeetInFlight: Bool = false, parakeetFailed: Bool = false, + parakeetProgress: Double? = nil, whisperCppDownloading: Bool = false, whisperCppProgress: Double? = nil, whisperCppFailed: Bool = false, @@ -26,6 +27,7 @@ final class OnboardingModelStatusTests: XCTestCase { parakeetInstalled: parakeetInstalled, parakeetInFlight: parakeetInFlight, parakeetFailed: parakeetFailed, + parakeetProgress: parakeetProgress, whisperCppDownloading: whisperCppDownloading, whisperCppProgress: whisperCppProgress, whisperCppFailed: whisperCppFailed, @@ -99,15 +101,32 @@ final class OnboardingModelStatusTests: XCTestCase { XCTAssertEqual(status(engine: "parakeet", parakeetInstalled: true), .ready) } - func testParakeetInFlightIsIndeterminateDownloading() { - // FluidAudio exposes no percentage — must be an indeterminate spinner, - // never a determinate bar stuck at 0. + func testParakeetInFlightIsIndeterminateDownloadingBeforeFirstReport() { + // No fraction reported yet — an indeterminate spinner, never a + // determinate bar stuck at 0. XCTAssertEqual( status(engine: "parakeet", parakeetInFlight: true), .downloading(progress: nil) ) } + func testParakeetInFlightCarriesTheRealFraction() { + // FluidAudio's ProgressHandler reports byte-granular fractions through + // the readiness tracker — the step must render the determinate bar. + XCTAssertEqual( + status(engine: "parakeet", parakeetInFlight: true, parakeetProgress: 0.42), + .downloading(progress: 0.42) + ) + } + + func testParakeetProgressAtZeroStaysIndeterminate() { + // 0.0 means "started, nothing yet" — indeterminate, not a stuck-empty bar. + XCTAssertEqual( + status(engine: "parakeet", parakeetInFlight: true, parakeetProgress: 0), + .downloading(progress: nil) + ) + } + func testParakeetNotYetInstalledOrInFlightStillReadsDownloading() { // The launch prefetch kicks a moment after onboarding opens; until the // folder exists we must NOT claim "ready" for a model that isn't there @@ -130,11 +149,15 @@ final class OnboardingModelStatusTests: XCTestCase { ) } - func testParakeetInstalledBeatsAStaleFailure() { - // Model is on disk — ready wins over any leftover failure flag. + func testParakeetFailureBeatsFilesOnDisk() { + // The corrupt-cache trap (fresh-install torn download): files can pass + // the on-disk check while the model still cannot load — the engine sets + // the failure flag after its purge-and-redownload repair also failed. + // "Ready" here was the green lie that contradicted the menu bar's + // "Model unavailable"; the failure card (with Retry) must win. XCTAssertEqual( status(engine: "parakeet", parakeetInstalled: true, parakeetFailed: true), - .ready + .failed ) } diff --git a/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift b/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift new file mode 100644 index 0000000..d802175 --- /dev/null +++ b/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift @@ -0,0 +1,207 @@ +import XCTest +@testable import OpenWhispCore + +/// Completeness verification for the Parakeet model cache (the fresh-install +/// onboarding fix): a torn first-run download leaves a repo folder FluidAudio's +/// presence gate accepts but `MLModel.load` can't open, and the app must never +/// call that state "installed". These pin the manifests, the generic bundle +/// rule, and the verdict→badge mapping. +final class ParakeetModelIntegrityTests: XCTestCase { + + /// A fully-staged Unified repo for the given encoder context suffix, plus + /// the tier-independent shared files. + private func unifiedListing(suffixes: [String]) -> Set { + var listing: Set = [ + "parakeet_unified_decoder.mlmodelc/coremldata.bin", + "parakeet_unified_joint_decision_single_step.mlmodelc/coremldata.bin", + "vocab.json", + "metadata.json", + ] + for suffix in suffixes { + listing.insert("parakeet_unified_encoder_streaming_\(suffix)_int8.mlmodelc/coremldata.bin") + } + return listing + } + + // MARK: - Manifest tier (Unified English — the default engine's variants) + + func testCompleteUnifiedRepoIsComplete() { + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + forVariant: "parakeet-unified-320ms", + listing: unifiedListing(suffixes: ["70_2_2"])), + .complete + ) + } + + func testMissingFolderIsNotDownloaded() { + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "parakeet-unified-320ms", listing: nil), + .notDownloaded + ) + } + + func testTornEncoderBundleIsIncomplete() { + // THE bug from the field: the encoder .mlmodelc directory exists (so + // FluidAudio skips the download forever) but its coremldata.bin never + // landed. The verdict must name the missing sentinel, not say installed. + var listing = unifiedListing(suffixes: ["70_2_2"]) + listing.remove("parakeet_unified_encoder_streaming_70_2_2_int8.mlmodelc/coremldata.bin") + // The directory itself still shows up in a recursive walk via its other + // partial contents. + listing.insert("parakeet_unified_encoder_streaming_70_2_2_int8.mlmodelc/model.mil") + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "parakeet-unified-320ms", listing: listing), + .incomplete(missing: ["parakeet_unified_encoder_streaming_70_2_2_int8.mlmodelc/coremldata.bin"]) + ) + } + + func testOtherTiersEncoderDoesNotSatisfyThisTier() { + // Each latency tier bakes its attention context into a distinct encoder + // bundle. A repo staged for the 640ms tier is NOT installed for 320ms — + // FluidAudio still has this tier's encoder download ahead of it. + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + forVariant: "parakeet-unified-320ms", + listing: unifiedListing(suffixes: ["70_7_1"])), + .incomplete(missing: ["parakeet_unified_encoder_streaming_70_2_2_int8.mlmodelc/coremldata.bin"]) + ) + } + + func testEachUnifiedTierMapsToItsOwnEncoderSuffix() { + // Suffixes mirror FluidAudio's UnifiedConfig [left, chunk, right] per + // tier (320ms → 70_2_2, 640ms → 70_7_1, 1120ms → 70_7_7). + for (variant, suffix) in [ + ("parakeet-unified-320ms", "70_2_2"), + ("parakeet-unified-640ms", "70_7_1"), + ("parakeet-unified-1120ms", "70_7_7"), + ] { + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + forVariant: variant, listing: unifiedListing(suffixes: [suffix])), + .complete, "variant \(variant) should be satisfied by suffix \(suffix)" + ) + } + } + + func testEouManifestIsNestedUnderTheChunkTierFolder() { + let listing: Set = [ + "320ms/streaming_encoder.mlmodelc/coremldata.bin", + "320ms/decoder.mlmodelc/coremldata.bin", + "320ms/joint_decision.mlmodelc/coremldata.bin", + "320ms/vocab.json", + ] + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "parakeet-eou-320ms", listing: listing), + .complete + ) + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "parakeet-eou-320ms", listing: ["320ms/vocab.json"]), + .incomplete(missing: [ + "320ms/decoder.mlmodelc/coremldata.bin", + "320ms/joint_decision.mlmodelc/coremldata.bin", + "320ms/streaming_encoder.mlmodelc/coremldata.bin", + ]) + ) + } + + func testUnknownVariantNormalizesToTheDefaultManifest() { + // A stale stored id snaps to the default variant (catalog behavior) — + // its verdict must follow the default's manifest, not the generic rule. + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + forVariant: "made-up-variant", + listing: unifiedListing(suffixes: ["70_2_2"])), + .complete + ) + } + + // MARK: - Generic tier (multilingual — language-dependent layout) + + func testMultilingualCompleteBundlesAreComplete() { + let listing: Set = [ + "multilingual/1120ms/encoder.mlmodelc/coremldata.bin", + "multilingual/1120ms/decoder.mlmodelc/coremldata.bin", + "multilingual/1120ms/joint.mlmodelc/coremldata.bin", + "multilingual/1120ms/tokenizer.json", + ] + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "nemotron-multilingual-1120ms", listing: listing), + .complete + ) + } + + func testMultilingualTornBundleIsIncomplete() { + let listing: Set = [ + "multilingual/1120ms/encoder.mlmodelc/model.mil", // no coremldata.bin + "multilingual/1120ms/decoder.mlmodelc/coremldata.bin", + ] + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "nemotron-multilingual-1120ms", listing: listing), + .incomplete(missing: ["multilingual/1120ms/encoder.mlmodelc/coremldata.bin"]) + ) + } + + func testMultilingualEmptyFolderIsIncomplete() { + // The folder exists (listing non-nil) but holds no model bundle at all — + // e.g. a download that died during the file listing phase. + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + forVariant: "nemotron-multilingual-1120ms", listing: ["metadata.json"]), + .incomplete(missing: [""]) + ) + } + + func testMultilingualUncompiledPackageLayoutIsAccepted() { + // FluidAudio accepts the uncompiled .mlpackage layout; it has no single + // sentinel file, so its presence alone satisfies the generic rule. + let listing: Set = [ + "multilingual/1120ms/encoder.mlpackage/Data/com.apple.CoreML/model.mlmodel", + "multilingual/1120ms/tokenizer.json", + ] + XCTAssertEqual( + ParakeetModelIntegrity.verdict(forVariant: "nemotron-multilingual-1120ms", listing: listing), + .complete + ) + } + + // MARK: - Verdict → badge mapping (the picker rows) + + func testVerdictStateMapping() { + XCTAssertEqual( + ParakeetDownloadStatePolicy.state( + forVariant: "parakeet-unified-320ms", verdict: .complete, inFlightVariants: []), + .installed + ) + // Present-but-torn + repair in flight → downloading (the badge the old + // folder-presence check couldn't show: it said installed). + XCTAssertEqual( + ParakeetDownloadStatePolicy.state( + forVariant: "parakeet-unified-320ms", + verdict: .incomplete(missing: ["x"]), + inFlightVariants: ["parakeet-unified-320ms"]), + .downloading + ) + XCTAssertEqual( + ParakeetDownloadStatePolicy.state( + forVariant: "parakeet-unified-320ms", + verdict: .incomplete(missing: ["x"]), + inFlightVariants: []), + .notDownloaded + ) + XCTAssertEqual( + ParakeetDownloadStatePolicy.state( + forVariant: "parakeet-unified-320ms", verdict: .notDownloaded, inFlightVariants: []), + .notDownloaded + ) + } + + // MARK: - Failure copy + + func testFailureCopyNeverLeaksAFileURL() { + // The menu bar previously rendered CoreML's raw "Unable to load model: + // file:///Users/…" — the shared copy must stay path-free. + XCTAssertFalse(ParakeetFailureCopy.downloadFailed.contains("file://")) + XCTAssertFalse(ParakeetFailureCopy.loadFailed.contains("file://")) + } +} diff --git a/docs/PARAKEET.md b/docs/PARAKEET.md index c29b1c4..1b1e543 100644 --- a/docs/PARAKEET.md +++ b/docs/PARAKEET.md @@ -188,8 +188,11 @@ the level-tick clock finishes the session once the window elapses. Pure timing i a separate model family — a possible follow-up. 2. **Utterance-onset clipping** on the streaming Unified tier (drops a leading word at onset). The TDT v3 file engine and the multilingual streaming variant do not clip. -3. **No download progress.** FluidAudio exposes no progress callback; the Models pane - shows a coarse three-state indicator instead of a percentage. +3. ~~**No download progress.**~~ Resolved: FluidAudio 0.15.5's `ProgressHandler` is + threaded through `ParakeetBridge.loadStreamSession` — the menu bar, onboarding, + and the Models pane show a real percentage, and `ParakeetModelIntegrity` verifies + the cache on disk (a corrupt/torn download is purged and redownloaded once + automatically; Settings → Models has an explicit "Redownload Model" repair). 4. **Meeting speaker attribution** uses the existing Me/Them heuristic; Parakeet token timestamps could feed diarization, and FluidAudio ships an offline diarizer — a follow-up if Parakeet backs meetings with per-speaker labels.