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
57 changes: 27 additions & 30 deletions OpenWhisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
/// 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!
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}
}
Expand Down
37 changes: 37 additions & 0 deletions OpenWhisp/Models/FluidAudioModelsLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? {
guard let folder = ParakeetDownloadStatePolicy.repoFolder(forVariant: id) else { return nil }
return fileListing(forRepoFolder: folder)
}

static func fileListing(forRepoFolder folder: String) -> Set<String>? {
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<String>()
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)
}
}
13 changes: 8 additions & 5 deletions OpenWhisp/Services/EngineReadiness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 29 additions & 21 deletions OpenWhisp/Services/OnboardingModelStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,25 @@ 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 {
/// The active engine's model is on disk (or needs no download, e.g. Apple
/// 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
}

Expand All @@ -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`.
Expand All @@ -60,6 +62,7 @@ public enum OnboardingModelStatus {
parakeetInstalled: Bool,
parakeetInFlight: Bool,
parakeetFailed: Bool = false,
parakeetProgress: Double? = nil,
whisperCppDownloading: Bool,
whisperCppProgress: Double?,
whisperCppFailed: Bool,
Expand All @@ -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 {
Expand Down
Loading
Loading