diff --git a/OpenWhisp/Models/FluidAudioModelsLocator.swift b/OpenWhisp/Models/FluidAudioModelsLocator.swift index 9430550..b74079c 100644 --- a/OpenWhisp/Models/FluidAudioModelsLocator.swift +++ b/OpenWhisp/Models/FluidAudioModelsLocator.swift @@ -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). diff --git a/OpenWhisp/Services/ParakeetBridge.swift b/OpenWhisp/Services/ParakeetBridge.swift index f2fa310..8807f09 100644 --- a/OpenWhisp/Services/ParakeetBridge.swift +++ b/OpenWhisp/Services/ParakeetBridge.swift @@ -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 @@ -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. @@ -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) @@ -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 @@ -262,11 +302,14 @@ 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 @@ -274,6 +317,41 @@ actor ParakeetVocabularyBiaser { 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 diff --git a/OpenWhisp/Services/ParakeetFileEngine.swift b/OpenWhisp/Services/ParakeetFileEngine.swift index 2f780cd..3ade99f 100644 --- a/OpenWhisp/Services/ParakeetFileEngine.swift +++ b/OpenWhisp/Services/ParakeetFileEngine.swift @@ -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 { 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) diff --git a/OpenWhisp/Services/ParakeetModelIntegrity.swift b/OpenWhisp/Services/ParakeetModelIntegrity.swift index fe45833..212d43c 100644 --- a/OpenWhisp/Services/ParakeetModelIntegrity.swift +++ b/OpenWhisp/Services/ParakeetModelIntegrity.swift @@ -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). /// @@ -77,12 +112,19 @@ public enum ParakeetModelIntegrity { 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 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?) -> 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 diff --git a/OpenWhisp/Services/ParakeetStreamingEngine.swift b/OpenWhisp/Services/ParakeetStreamingEngine.swift index 00ace11..e9f4902 100644 --- a/OpenWhisp/Services/ParakeetStreamingEngine.swift +++ b/OpenWhisp/Services/ParakeetStreamingEngine.swift @@ -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 { 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. diff --git a/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift b/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift index d802175..bd388e1 100644 --- a/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift +++ b/Tests/OpenWhispCoreTests/ParakeetModelIntegrityTests.swift @@ -165,6 +165,87 @@ final class ParakeetModelIntegrityTests: XCTestCase { ) } + // MARK: - Non-variant repos (batch TDT v3 + CTC biasing manifests) + + private var batchListing: Set { + [ + "Preprocessor.mlmodelc/coremldata.bin", + "Encoder.mlmodelc/coremldata.bin", + "Decoder.mlmodelc/coremldata.bin", + "JointDecisionv3.mlmodelc/coremldata.bin", + "parakeet_vocab.json", + ] + } + + private var ctcListing: Set { + [ + "MelSpectrogram.mlmodelc/coremldata.bin", + "AudioEncoder.mlmodelc/coremldata.bin", + "vocab.json", + "tokenizer.json", + ] + } + + func testNonVariantRepoFoldersMatchFluidAudioLayout() { + // Pin the folder names the batch/CTC purge-and-redownload repairs key + // on. FluidAudio derives most from the HF repo name minus "-coreml" — + // but NOT the CTC folder, which keeps the suffix (`Repo.folderName`). + XCTAssertEqual(ParakeetModelIntegrity.batchRepoFolder, "parakeet-tdt-0.6b-v3") + XCTAssertEqual(ParakeetModelIntegrity.ctcBiasRepoFolder, "parakeet-ctc-110m-coreml") + } + + func testBatchCompleteRepoIsComplete() { + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + requiredPaths: ParakeetModelIntegrity.batchRequiredPaths, listing: batchListing), + .complete + ) + } + + func testBatchMissingFolderIsNotDownloaded() { + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + requiredPaths: ParakeetModelIntegrity.batchRequiredPaths, listing: nil), + .notDownloaded + ) + } + + func testBatchTornEncoderBundleIsIncomplete() { + // The batch flavor of the fresh-install trap: the encoder .mlmodelc + // directory exists (FluidAudio's `modelsExist` fileExists check passes, + // so the download is skipped forever) but its coremldata.bin never + // landed — the file engine must not treat this repo as installed. + var listing = batchListing + listing.remove("Encoder.mlmodelc/coremldata.bin") + listing.insert("Encoder.mlmodelc/model.mil") + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + requiredPaths: ParakeetModelIntegrity.batchRequiredPaths, listing: listing), + .incomplete(missing: ["Encoder.mlmodelc/coremldata.bin"]) + ) + } + + func testCtcBiasCompleteRepoIsComplete() { + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + requiredPaths: ParakeetModelIntegrity.ctcBiasRequiredPaths, listing: ctcListing), + .complete + ) + } + + func testCtcBiasManifestRequiresTheTokenizerJson() { + // FluidAudio's own presence gate never checks tokenizer.json — the + // models load fine and tokenization then fails. The manifest treats a + // tokenizer-less cache as torn so the repair path covers it too. + var listing = ctcListing + listing.remove("tokenizer.json") + XCTAssertEqual( + ParakeetModelIntegrity.verdict( + requiredPaths: ParakeetModelIntegrity.ctcBiasRequiredPaths, listing: listing), + .incomplete(missing: ["tokenizer.json"]) + ) + } + // MARK: - Verdict → badge mapping (the picker rows) func testVerdictStateMapping() {