From 656aec1ebd117e5ab2d3360c4f0d1925ec88c6dc Mon Sep 17 00:00:00 2001 From: Nick Kedev Date: Sun, 6 Sep 2026 06:50:32 -0700 Subject: [PATCH 1/6] Add optional English-only Parakeet v2 with safe model ownership --- README.md | 5 +- Sources/Speech/ParakeetEngine.swift | 36 ++- .../Speech/ParakeetModelInitDiagnostics.swift | 87 ++++++- Sources/Speech/ParakeetModelLifecycle.swift | 219 ++++++++++++------ Sources/Speech/STTRouter.swift | 73 ++++-- .../TranscriptionModelWarmupOwnership.swift | 4 +- .../ExistingInstallModelPrefetchPolicy.swift | 2 +- Sources/Support/ModelCacheInventory.swift | 71 ++++-- .../TranscriptionModelPreferences.swift | 49 +++- .../Settings/TranscriptedSettingsView.swift | 2 +- Tests/E2E/TranscriptedE2ESmoke.swift | 19 ++ ...stingInstallModelPrefetchPolicyTests.swift | 15 ++ Tests/FirstRunExperienceTests.swift | 7 + Tests/ModelCacheInventoryTests.swift | 28 +++ Tests/ParakeetCacheMigrationTests.swift | 65 ++++++ Tests/ParakeetModelInitDiagnosticsTests.swift | 147 +++++++++++- Tests/STTRouterPolicyTests.swift | 29 +++ .../TranscriptionModelPreferencesTests.swift | 13 ++ ...anscriptionModelWarmupOwnershipTests.swift | 29 +++ docs/capture-format.md | 14 ++ 20 files changed, 781 insertions(+), 133 deletions(-) create mode 100644 Tests/ParakeetCacheMigrationTests.swift diff --git a/README.md b/README.md index 66f568615..0ee03f434 100644 --- a/README.md +++ b/README.md @@ -204,8 +204,9 @@ titles, names, or file paths), and both have off switches in Privacy settings. Nothing. It's MIT-licensed open source. No account, no trial, no "pro" tier. **How accurate is it?** -Good enough to search and quote. Parakeet (the default model) is fast and -strong, Whisper is available as an advanced option, and a custom dictionary +Good enough to search and quote. Parakeet V3 is the multilingual default; +Parakeet V2 is an English-only choice in Settings → Model. Whisper is also +available, and a custom dictionary keeps names, acronyms, and project jargon spelled right. Speaker review cleans up who-said-what after shared-mic meetings. diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index 006b2a4a3..525050ba2 100644 --- a/Sources/Speech/ParakeetEngine.swift +++ b/Sources/Speech/ParakeetEngine.swift @@ -1,5 +1,5 @@ // ParakeetEngine.swift -// FluidAudio-based STT engine — CoreML Parakeet TDT V3 for batch transcription. +// FluidAudio-based STT engine — CoreML Parakeet TDT V2/V3 for batch transcription. // AVAudioEngine tap → NSLock-batched samples → resampled to 16kHz → AsrManager.transcribe() // for final batch inference. @@ -92,6 +92,10 @@ class ParakeetEngine: ObservableObject { // FluidAudio ASR var asrManager: AsrManager? + var modelVariant: ParakeetModelVariant = .v3 + var loadedModelVariant: ParakeetModelVariant? + var modelCleanupTask: Task? + let modelTeardownGate = ParakeetModelTeardownGate() var modelInitializationTask: Task? var modelInitializationGeneration: UInt64 = 0 var modelFilePrefetchTask: Task? @@ -127,6 +131,18 @@ class ParakeetEngine: ObservableObject { var systemInputReconciliationTask: Task? var isModelLoaded: Bool { asrManagerReady } + + func isModelLoaded(for variant: ParakeetModelVariant) -> Bool { + asrManagerReady && loadedModelVariant == variant + } + + func modelDownloadState(for variant: ParakeetModelVariant) -> ParakeetModelState { + guard variant == modelVariant else { + return ModelCacheInventory.activeParakeetModelDirectory(variant: variant) != nil + || bundledParakeetModelPath(variant: variant) != nil ? .cached : .notLoaded + } + return modelDownloadState + } var inputDeviceName: String { cachedInputDeviceName } var isRecordingFromSharedMeetingMic: Bool { sharedMeetingMicClaim != nil } var hasReceivedAudioSamples: Bool { didReceiveAudioSamples } @@ -149,18 +165,21 @@ class ParakeetEngine: ObservableObject { /// than a network download. Dictation uses this to open the microphone /// immediately and load the model concurrently. var modelFilesAvailableLocally: Bool { - if asrManagerReady { return true } - switch modelDownloadState { + modelFilesAvailableLocally(for: modelVariant) + } + + func modelFilesAvailableLocally(for variant: ParakeetModelVariant) -> Bool { + if isModelLoaded(for: variant) { return true } + switch modelDownloadState(for: variant) { case .downloading, .failed: return false case .notLoaded, .cached, .loading, .ready: - return prefetchedModelPath != nil || hasBundledParakeetModel + return (variant == modelVariant && prefetchedModelPath != nil) + || ModelCacheInventory.activeParakeetModelDirectory(variant: variant) != nil + || bundledParakeetModelPath(variant: variant) != nil } } - private lazy var hasBundledParakeetModel: Bool = - bundledParakeetModelPath() != nil - init() { markCachedRuntimeModelIfAvailable() scheduleInputDeviceNameRefresh() @@ -2329,6 +2348,7 @@ class ParakeetEngine: ObservableObject { private func finishTranscription() { isTranscribing = false clearRecoveredRecordingTimeline(keepingCapacity: true) + finishDeferredModelTeardownIfIdle() } var hasActiveASRWork: Bool { @@ -2344,6 +2364,7 @@ class ParakeetEngine: ObservableObject { private func finishPureSampleTranscriptionActivity() { pureSampleTranscriptionActivityCount = max(0, pureSampleTranscriptionActivityCount - 1) + finishDeferredModelTeardownIfIdle() } private func beginASRInference() async { @@ -2378,6 +2399,7 @@ class ParakeetEngine: ObservableObject { next.resume() return } + finishDeferredModelTeardownIfIdle() } private func runASRInference( diff --git a/Sources/Speech/ParakeetModelInitDiagnostics.swift b/Sources/Speech/ParakeetModelInitDiagnostics.swift index 8b9cf1be7..39bbfb725 100644 --- a/Sources/Speech/ParakeetModelInitDiagnostics.swift +++ b/Sources/Speech/ParakeetModelInitDiagnostics.swift @@ -1,6 +1,86 @@ import AVFoundation import Foundation +/// Admission happens before any cancellation or readiness mutation. A picker +/// change is not permission to interrupt a recording or an active decoder. +enum ParakeetModelSelectionPolicy { + static func canSelect( + _ requested: ParakeetModelVariant, + current: ParakeetModelVariant, + hasActiveWork: Bool + ) -> Bool { + requested == current || !hasActiveWork + } + + /// Prefetch is network-only: even an idle loaded manager must survive it. + static func canPrefetch(hasManager: Bool, hasActiveWork: Bool) -> Bool { + !hasManager && !hasActiveWork + } +} + +/// Wait for decoder ownership to drain without polling. Each canceled waiter +/// removes only its own continuation; it cannot open the gate for a successor. +@MainActor +final class ParakeetModelTeardownGate { + private(set) var isPending = false + private var waiters: [UUID: CheckedContinuation] = [:] + + func begin() { isPending = true } + + func finish() { + isPending = false + let completed = Array(waiters.values) + waiters.removeAll() + for waiter in completed { waiter.resume(returning: true) } + } + + func wait() async -> Bool { + guard !Task.isCancelled else { return false } + guard isPending else { return true } + let id = UUID() + let finished = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + // Cancellation may have arrived before registration. + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + waiters[id] = continuation + } + } onCancel: { + Task { @MainActor in + self.waiters.removeValue(forKey: id)?.resume(returning: false) + } + } + return finished && !Task.isCancelled + } +} + +/// A canceled task may still own native resources. Its successor waits for +/// actual completion, not just the cancellation flag. +enum ParakeetModelTaskDrain { + static func draining( + _ initialization: Task, + after cleanup: Task? + ) -> Task { + Task { + await cleanup?.value + await initialization.value + } + } +} + +/// Both dimensions are needed: switching away and back creates a new attempt +/// even when the variant matches again. Used at every asynchronous model seam. +struct ParakeetModelWorkToken: Equatable { + let variant: ParakeetModelVariant + let generation: UInt64 + + func isCurrent(variant: ParakeetModelVariant, generation: UInt64) -> Bool { + self.variant == variant && self.generation == generation + } +} + final class ParakeetModelDownloadProgressTracker: @unchecked Sendable { private let lock = NSLock() private let stageCount: Int @@ -129,14 +209,17 @@ enum ParakeetBundledModelLayoutPolicy { // bundled directory is not actually loadable and must fail closed here. static func resolveBundledModelPath( resourcePath: String?, + variant: ParakeetModelVariant = .v3, fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } ) -> URL? { guard let resourcePath else { return nil } let root = URL(fileURLWithPath: resourcePath) .appendingPathComponent("parakeet-models") - let path = root.appendingPathComponent(runtime.subdirectory) - guard fileExists(path.appendingPathComponent(runtime.checkFile).path) else { + let path = root.appendingPathComponent(variant.directoryName) + let required = variant.requiredModelDirectoryNames.map { "\($0)/coremldata.bin" } + + variant.requiredFileNames + guard required.allSatisfy({ fileExists(path.appendingPathComponent($0).path) }) else { return nil } return path diff --git a/Sources/Speech/ParakeetModelLifecycle.swift b/Sources/Speech/ParakeetModelLifecycle.swift index 0307ffcc5..e55ca595f 100644 --- a/Sources/Speech/ParakeetModelLifecycle.swift +++ b/Sources/Speech/ParakeetModelLifecycle.swift @@ -12,6 +12,15 @@ import FluidAudio import Foundation import TranscriptedCore +extension ParakeetModelVariant { + var fluidAudioVersion: AsrModelVersion { + switch self { + case .v2: return .v2 + case .v3: return .v3 + } + } +} + private final class ParakeetModelDownloadProgressTarget: @unchecked Sendable { weak var engine: ParakeetEngine? @@ -45,11 +54,13 @@ extension ParakeetEngine { } private func startModelDownloadTask() -> Task { + let variant = modelVariant let progressTracker = ParakeetModelDownloadProgressTracker() let generation = beginModelDownloadAttempt(progressTracker: progressTracker) + let token = ParakeetModelWorkToken(variant: variant, generation: generation) let progressTarget = ParakeetModelDownloadProgressTarget(engine: self) let task = Task.detached(priority: .utility) { - try await AsrModels.download(version: .v3) { progress in + try await AsrModels.download(version: variant.fluidAudioVersion) { progress in let beginsNewStage: Bool switch progress.phase { case .listing: @@ -64,7 +75,7 @@ extension ParakeetEngine { Task { @MainActor in progressTarget.engine?.recordModelDownloadProgress( overallProgress, - generation: generation + token: token ) } } @@ -85,10 +96,10 @@ extension ParakeetEngine { return modelDownloadAttemptGeneration } - private func recordModelDownloadProgress(_ progress: Double, generation: UInt64) { - guard ParakeetModelDownloadAttemptPolicy.isCurrent( - expectedGeneration: generation, - currentGeneration: modelDownloadAttemptGeneration + private func recordModelDownloadProgress(_ progress: Double, token: ParakeetModelWorkToken) { + guard token.isCurrent( + variant: modelVariant, + generation: modelDownloadAttemptGeneration ), modelFilePrefetchTask != nil else { return } modelDownloadState = .downloading(progress: max(0, min(1, progress))) } @@ -97,6 +108,7 @@ extension ParakeetEngine { generation: UInt64, progressTracker: ParakeetModelDownloadProgressTracker ) { + let token = ParakeetModelWorkToken(variant: modelVariant, generation: generation) modelDownloadWatchdogTask?.cancel() modelDownloadWatchdogTask = Task { @MainActor [weak self] in while !Task.isCancelled { @@ -113,7 +125,8 @@ extension ParakeetEngine { } } guard let self else { return } - guard ParakeetModelDownloadAttemptPolicy.shouldTimeOut( + guard token.isCurrent(variant: self.modelVariant, generation: self.modelDownloadAttemptGeneration), + ParakeetModelDownloadAttemptPolicy.shouldTimeOut( expectedGeneration: generation, currentGeneration: self.modelDownloadAttemptGeneration, hasActiveTask: self.modelFilePrefetchTask != nil, @@ -122,12 +135,7 @@ extension ParakeetEngine { return } - self.modelDownloadAttemptGeneration &+= 1 - self.modelFilePrefetchTask?.cancel() - self.modelFilePrefetchTask = nil - self.modelInitializationGeneration &+= 1 - self.modelInitializationTask?.cancel() - self.modelInitializationTask = nil + self.cancelModelWork() self.modelDownloadState = .failed(Self.stalledDownloadMessage) EventReporter.shared.capture( level: .error, @@ -154,9 +162,11 @@ extension ParakeetEngine { } /// Load Parakeet models from the app bundle (preferred) or download from HuggingFace (fallback). - /// Bundle path: Contents/Resources/parakeet-models/parakeet-tdt-0.6b-v3/ - func initialize() async { + /// Bundle path: Contents/Resources/parakeet-models// + func initialize(variant: ParakeetModelVariant = .v3) async { guard !isShuttingDown, !Task.isCancelled else { return } + guard selectModelVariant(variant) else { return } + guard !isModelLoaded(for: variant) else { return } if let modelInitializationTask { await modelInitializationTask.value @@ -164,10 +174,10 @@ extension ParakeetEngine { } modelInitializationGeneration &+= 1 - let generation = modelInitializationGeneration + let token = ParakeetModelWorkToken(variant: variant, generation: modelInitializationGeneration) let task = Task { @MainActor [weak self] in guard let self else { return } - await self.performInitialize(generation: generation) + await self.performInitialize(token: token) } modelInitializationTask = task await task.value @@ -176,23 +186,60 @@ extension ParakeetEngine { /// Await the in-flight model initialization task, if any. Returns true /// when a task was joined. Unlike polling `modelDownloadState`, this /// resumes the moment initialization settles (ready or failed). - func joinModelInitialization() async -> Bool { - guard let modelInitializationTask else { return false } + func joinModelInitialization(variant: ParakeetModelVariant = .v3) async -> Bool { + guard modelVariant == variant, let modelInitializationTask else { return false } await modelInitializationTask.value return true } - private func performInitialize(generation: UInt64) async { + private func isCurrent(_ token: ParakeetModelWorkToken) -> Bool { + !isShuttingDown && !Task.isCancelled + && token.isCurrent(variant: modelVariant, generation: modelInitializationGeneration) + } + + @discardableResult + func prepareModelVariantForRecording(_ variant: ParakeetModelVariant) -> Bool { + // Establish the concrete identity before audio capture begins. Cached + // dictation can record before asynchronous CoreML warmup has started. + selectModelVariant(variant) + } + + @discardableResult + private func selectModelVariant(_ variant: ParakeetModelVariant) -> Bool { + // Keep the existing ready state intact when a caller bypasses router + // ownership. Publishing a failure here would also disrupt its capture. + guard ParakeetModelSelectionPolicy.canSelect( + variant, + current: modelVariant, + hasActiveWork: isRecording || isTranscribing || hasActiveASRWork + ) else { return false } + guard variant != modelVariant else { return true } + cancelModelWork() + teardownModel() + modelVariant = variant + prefetchedModelPath = nil + markCachedRuntimeModelIfAvailable() + return true + } + + private func performInitialize(token: ParakeetModelWorkToken) async { defer { - if generation == modelInitializationGeneration { + if token.isCurrent(variant: modelVariant, generation: modelInitializationGeneration) { modelInitializationTask = nil } } - guard !isShuttingDown, !Task.isCancelled else { return } + guard isCurrent(token) else { return } + // A canceled inference still owns its decoder until it actually ends. + // Drain it before installing a different manager; selection alone is + // not permission to release CoreML resources under active work. + finishDeferredModelTeardownIfIdle() + guard await modelTeardownGate.wait(), isCurrent(token) else { return } + if let modelCleanupTask { await modelCleanupTask.value } + guard isCurrent(token) else { return } scheduleInputDeviceNameRefresh() markCachedRuntimeModelIfAvailable() - guard asrManager == nil else { + guard !isModelLoaded(for: token.variant) else { EventReporter.shared.capture(level: .warning, engine: "parakeet", event: "already_initialized", message: "initialize() called but ASR manager already exists — ignoring") modelDownloadState = .ready @@ -217,11 +264,11 @@ extension ParakeetEngine { var failureStage: ParakeetModelInitStage = .authorizationRequest var loadSource: ParakeetModelLoadSource = .unresolved - Self.migrateLegacyParakeetCacheIfNeeded() + Self.migrateLegacyParakeetCacheIfNeeded(variant: token.variant) // FluidAudio 0.15.x resolves bundled models as /, and the - // folder name lost its -coreml suffix. Gate on JointDecisionv3.mlmodelc (new required - // file) so an incomplete bundle can't trigger a download into the signed app bundle. - let bundledModelPath = bundledParakeetModelPath() + // folder name lost its -coreml suffix. Require the requested variant's complete + // layout so an incomplete bundle can't trigger a download into the signed bundle. + let bundledModelPath = bundledParakeetModelPath(variant: token.variant) let bundledModelPresent = bundledModelPath != nil let encoderComputeUnits = Self.benchmarkEncoderComputeUnits @@ -236,10 +283,10 @@ extension ParakeetEngine { AppLogger.transcription.info("PARAKEET | loading from bundle: \(bundlePath.path)") models = try await AsrModels.load( from: bundlePath, - version: .v3, + version: token.variant.fluidAudioVersion, encoderComputeUnits: encoderComputeUnits ) - guard !Task.isCancelled, !isShuttingDown else { return } + guard isCurrent(token) else { return } loadSourceName = loadSource.rawValue } else { // Fallback: download from HuggingFace (~600MB on first run). @@ -264,53 +311,61 @@ extension ParakeetEngine { AppLogger.transcription.info("PARAKEET | waiting for background Parakeet model cache...") let generation = modelDownloadAttemptGeneration downloadedPath = try await modelFilePrefetchTask.value + guard isCurrent(token) else { return } guard finishModelDownloadAttempt(generation: generation) else { return } prefetchedModelPath = downloadedPath self.modelFilePrefetchTask = nil } else if let prefetchedModelPath { downloadedPath = prefetchedModelPath - } else if let cachedModelPath = ModelCacheInventory.activeParakeetModelDirectory() { + } else if let cachedModelPath = ModelCacheInventory.activeParakeetModelDirectory(variant: token.variant) { prefetchedModelPath = cachedModelPath downloadedPath = cachedModelPath } else { - AppLogger.transcription.info("PARAKEET | models not bundled, downloading (~600MB)...") + AppLogger.transcription.info("PARAKEET | models not bundled, downloading \(token.variant.rawValue)...") let task = startModelDownloadTask() let generation = modelDownloadAttemptGeneration downloadedPath = try await task.value + guard isCurrent(token) else { return } guard finishModelDownloadAttempt(generation: generation) else { return } modelFilePrefetchTask = nil prefetchedModelPath = downloadedPath } - guard !Task.isCancelled, !isShuttingDown else { return } + guard isCurrent(token) else { return } modelDownloadState = .loading AppLogger.transcription.info("PARAKEET | loading downloaded models from: \(downloadedPath.path)") models = try await AsrModels.load( from: downloadedPath, - version: .v3, + version: token.variant.fluidAudioVersion, encoderComputeUnits: encoderComputeUnits ) - guard !Task.isCancelled, !isShuttingDown else { return } + guard isCurrent(token) else { return } loadSourceName = loadSource.rawValue } failureStage = .managerInitialize let manager = AsrManager(config: .default) - try await manager.loadModels(models) - guard !Task.isCancelled, !isShuttingDown else { - Task { await manager.cleanup() } + do { + try await manager.loadModels(models) + } catch { + await manager.cleanup() + throw error + } + guard isCurrent(token) else { + await manager.cleanup() return } asrManager = manager + loadedModelVariant = token.variant asrManagerReady = true modelDownloadState = .ready - AppLogger.transcription.info("PARAKEET | TDT V3 models loaded (source: \(loadSourceName))") + AppLogger.transcription.info("PARAKEET | TDT \(token.variant.rawValue) models loaded (source: \(loadSourceName))") EventReporter.shared.capture(level: .info, engine: "parakeet", event: "models_loaded", message: "Parakeet ASR models initialized successfully", context: ["load_source": loadSourceName]) } catch { - guard !Task.isCancelled, !isShuttingDown else { return } + guard isCurrent(token) else { return } finishModelDownloadAttempt(generation: modelDownloadAttemptGeneration) modelFilePrefetchTask = nil prefetchedModelPath = nil @@ -328,11 +383,16 @@ extension ParakeetEngine { } } - func prefetchModelFilesIfNeeded() async { + func prefetchModelFilesIfNeeded(variant: ParakeetModelVariant = .v3) async { guard !isShuttingDown, !Task.isCancelled else { return } - guard asrManager == nil else { return } - - guard bundledParakeetModelPath() == nil else { + // Prefetch is disposable and must not change a runtime in use. + guard ParakeetModelSelectionPolicy.canPrefetch( + hasManager: asrManager != nil, + hasActiveWork: isRecording || isTranscribing || hasActiveASRWork + ) else { return } + guard selectModelVariant(variant) else { return } + + guard bundledParakeetModelPath(variant: variant) == nil else { return } @@ -354,9 +414,11 @@ extension ParakeetEngine { task = startModelDownloadTask() } let generation = modelDownloadAttemptGeneration + let token = ParakeetModelWorkToken(variant: variant, generation: generation) do { let downloadedPath = try await task.value + guard token.isCurrent(variant: modelVariant, generation: modelDownloadAttemptGeneration) else { return } guard finishModelDownloadAttempt(generation: generation) else { return } guard !Task.isCancelled, !isShuttingDown else { return } guard modelInitializationTask == nil, asrManager == nil, !asrManagerReady else { @@ -378,6 +440,7 @@ extension ParakeetEngine { context: ["load_source": ParakeetModelLoadSource.download.rawValue] ) } catch { + guard token.isCurrent(variant: modelVariant, generation: modelDownloadAttemptGeneration) else { return } guard !Task.isCancelled, !isShuttingDown else { return } guard finishModelDownloadAttempt(generation: generation) else { return } if modelFilePrefetchTask != nil { @@ -400,7 +463,7 @@ extension ParakeetEngine { @discardableResult func markCachedRuntimeModelIfAvailable() -> Bool { - guard let cachedModelPath = ModelCacheInventory.activeParakeetModelDirectory() else { + guard let cachedModelPath = ModelCacheInventory.activeParakeetModelDirectory(variant: modelVariant) else { return false } @@ -411,21 +474,17 @@ extension ParakeetEngine { return true } - /// FluidAudio 0.15.x renamed the v3 cache folder from `parakeet-tdt-0.6b-v3-coreml` - /// to `parakeet-tdt-0.6b-v3` (ModelNames.folderName strips the suffix). Rename a - /// 0.7.9-era cache in place so existing users keep their ~600MB download; FluidAudio - /// then only fetches the one file new in 0.15.x (JointDecisionv3.mlmodelc). A failed - /// rename is harmless — the loader falls back to a fresh download. - private static func migrateLegacyParakeetCacheIfNeeded() { - let newDir = AsrModels.defaultCacheDirectory(for: .v3) + /// Reuse the pinned 0.7.9 caches under FluidAudio 0.15.x's canonical names. + /// v2 already used the current model files; v3 may need its new joint model. + /// Missing files are filled in by FluidAudio after a successful rename. + private static func migrateLegacyParakeetCacheIfNeeded(variant: ParakeetModelVariant) { + let newDir = AsrModels.defaultCacheDirectory(for: variant.fluidAudioVersion) guard !newDir.lastPathComponent.hasSuffix("-coreml") else { return } - let legacyDir = newDir.deletingLastPathComponent() - .appendingPathComponent(newDir.lastPathComponent + "-coreml", isDirectory: true) - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: legacyDir.path), - !fileManager.fileExists(atPath: newDir.path) else { return } do { - try fileManager.moveItem(at: legacyDir, to: newDir) + guard try ModelCacheInventory.migrateLegacyParakeetModelDirectory( + variant: variant, + fluidAudioModelsDirectory: newDir.deletingLastPathComponent() + ) else { return } AppLogger.transcription.info("PARAKEET | migrated legacy model cache to \(newDir.lastPathComponent)") EventReporter.shared.capture(level: .info, engine: "parakeet", event: "model_cache_migrated", message: "Renamed pre-0.15 FluidAudio model cache folder") @@ -436,9 +495,10 @@ extension ParakeetEngine { } } - func bundledParakeetModelPath() -> URL? { + func bundledParakeetModelPath(variant: ParakeetModelVariant = .v3) -> URL? { ParakeetBundledModelLayoutPolicy.resolveBundledModelPath( - resourcePath: Bundle.main.resourcePath + resourcePath: Bundle.main.resourcePath, + variant: variant ) } @@ -450,8 +510,17 @@ extension ParakeetEngine { modelDownloadWatchdogTask?.cancel() modelDownloadWatchdogTask = nil modelInitializationGeneration &+= 1 - modelInitializationTask?.cancel() + let previousInitialization = modelInitializationTask + previousInitialization?.cancel() modelInitializationTask = nil + // Cancellation does not stop a native CoreML load immediately. Keep + // its lifetime in the drain chain so the successor cannot allocate a + // second model until the stale load (and manager cleanup) has ended. + if let previousInitialization { + modelCleanupTask = ParakeetModelTaskDrain.draining( + previousInitialization, after: modelCleanupTask + ) + } modelFilePrefetchTask?.cancel() modelFilePrefetchTask = nil } @@ -461,19 +530,27 @@ extension ParakeetEngine { /// in-flight, so an active `AsrManager.transcribe()` call doesn't get its /// backing object released out from under it. Called from `cleanup()`. func teardownModel() { - let cleanupDecision = ParakeetASRManagerCleanupPolicy.decision( - isTranscribing: isTranscribing || hasActiveASRWork - ) - let mgr = asrManager - if cleanupDecision == .cleanupNow { - asrManager = nil - } asrManagerReady = false + loadedModelVariant = nil modelDownloadState = .notLoaded - if cleanupDecision == .cleanupNow { - Task { await mgr?.cleanup() } - } else { - AppLogger.transcription.info("PARAKEET | deferring ASR manager cleanup while transcription is active") + modelTeardownGate.begin() + finishDeferredModelTeardownIfIdle() + } + + func finishDeferredModelTeardownIfIdle() { + guard modelTeardownGate.isPending, + ParakeetASRManagerCleanupPolicy.decision( + isTranscribing: isTranscribing || hasActiveASRWork + ) == .cleanupNow else { return } + let manager = asrManager + asrManager = nil + let previousCleanup = modelCleanupTask + modelCleanupTask = Task { + await previousCleanup?.value + await manager?.cleanup() } + // Publish the drain task before waking initialization waiters so they + // always await native cleanup before allocating a replacement manager. + modelTeardownGate.finish() } } diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index e4ee38606..2bf59b407 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -59,8 +59,10 @@ class STTRouter: ObservableObject { /// instead of blocking recording on the load. var selectedModelFilesAvailableLocally: Bool { switch selectedModel { + case .parakeetTDTv2: + return parakeetEngine.modelFilesAvailableLocally(for: .v2) case .parakeetTDTv3: - return parakeetEngine.modelFilesAvailableLocally + return parakeetEngine.modelFilesAvailableLocally(for: .v3) case .whisperLargeV3Turbo, .whisperLargeV3: // Whisper does not expose a files-on-disk signal; keep the // conservative wait-for-load start path. @@ -84,14 +86,21 @@ class STTRouter: ObservableObject { parakeetEngine.$inputFormatReady.assign(to: &$inputFormatReady) parakeetEngine.$modelDownloadState - .sink { [weak self] _ in - self?.refreshModelDownloadState() + .sink { [weak self] state in + guard let self else { return } + // @Published emits before storage changes. Forward the emitted + // value only when it belongs to the selected concrete variant. + self.refreshModelDownloadState( + publishedState: self.selectedModel.parakeetVariant == self.parakeetEngine.modelVariant + ? state : nil + ) } .store(in: &cancellables) whisperEngine.$modelDownloadState - .sink { [weak self] _ in - self?.refreshModelDownloadState() + .sink { [weak self] state in + guard let self else { return } + self.refreshModelDownloadState(publishedState: self.selectedModel.isWhisper ? state : nil) } .store(in: &cancellables) @@ -106,8 +115,10 @@ class STTRouter: ObservableObject { func isModelLoaded(for model: TranscriptionModelChoice) -> Bool { switch model { + case .parakeetTDTv2: + return parakeetEngine.isModelLoaded(for: .v2) case .parakeetTDTv3: - return parakeetEngine.isModelLoaded + return parakeetEngine.isModelLoaded(for: .v3) case .whisperLargeV3Turbo, .whisperLargeV3: return whisperEngine.isModelLoaded(for: model) } @@ -132,7 +143,7 @@ class STTRouter: ObservableObject { private func cancelAndTeardownModel(_ model: TranscriptionModelChoice) { switch model { - case .parakeetTDTv3: + case .parakeetTDTv2, .parakeetTDTv3: parakeetEngine.cancelModelWork() parakeetEngine.teardownModel() case .whisperLargeV3Turbo, .whisperLargeV3: @@ -153,7 +164,7 @@ class STTRouter: ObservableObject { } // Joining the same model is safe. A different model sharing the runtime - // (the two Whisper variants) must wait until active foreground use ends. + // (Parakeet or Whisper variants) must wait until active foreground use ends. if warmupOwnership.hasForegroundUse(on: model.runtime) { if warmupOwnership.hasForegroundUse(of: model) { await initializeModel(model) @@ -170,8 +181,9 @@ class STTRouter: ObservableObject { } func prefetchSelectedModelFilesForExistingInstall() async { - guard selectedModel == .parakeetTDTv3 else { return } - await parakeetEngine.prefetchModelFilesIfNeeded() + guard let variant = selectedModel.parakeetVariant else { return } + guard !warmupOwnership.hasForegroundUse(on: .parakeet) else { return } + await parakeetEngine.prefetchModelFilesIfNeeded(variant: variant) refreshModelDownloadState() } @@ -223,6 +235,11 @@ class STTRouter: ObservableObject { private func setActiveRecordingModel(_ model: TranscriptionModelChoice) { let resolvedModel = beginForegroundUse(of: model) + if let variant = resolvedModel.parakeetVariant { + // Cached-model fast start may beat its async warmup task. Select + // the leased variant before isRecording protects that identity. + parakeetEngine.prepareModelVariantForRecording(variant) + } let replacement = recordingModelOwnership.replace(with: resolvedModel) if let replacedModel = replacement.replacedModel { endForegroundUse(of: replacedModel) @@ -261,8 +278,10 @@ class STTRouter: ObservableObject { } switch model { + case .parakeetTDTv2: + await parakeetEngine.initialize(variant: .v2) case .parakeetTDTv3: - await parakeetEngine.initialize() + await parakeetEngine.initialize(variant: .v3) case .whisperLargeV3Turbo, .whisperLargeV3: await whisperEngine.initialize(model: model) } @@ -278,10 +297,10 @@ class STTRouter: ObservableObject { func waitForRecordingModelLoadProgress() async { let model = recordingModel defer { refreshModelDownloadState() } - if model == .parakeetTDTv3 { + if let variant = model.parakeetVariant { var isDownloading = false if case .downloading = recordingModelDownloadState { isDownloading = true } - if !isDownloading, await parakeetEngine.joinModelInitialization() { + if !isDownloading, await parakeetEngine.joinModelInitialization(variant: variant) { return } } @@ -348,7 +367,18 @@ class STTRouter: ObservableObject { } switch model { - case .parakeetTDTv3: + case .parakeetTDTv2, .parakeetTDTv3: + guard isModelLoaded(for: model) else { + lastEmptyTranscriptionReason = .modelFailure + EventReporter.shared.capture( + level: .error, + engine: "parakeet", + event: "asr_manager_unavailable", + message: "Requested Parakeet model is not available for transcription", + context: ["model": model.rawValue] + ) + return nil + } let text = await parakeetEngine.transcribe(preparedRecording: preparedRecording) lastEmptyTranscriptionReason = text == nil ? parakeetEngine.lastEmptyTranscriptionReason : nil return text @@ -437,7 +467,12 @@ class STTRouter: ObservableObject { } switch resolvedModel { - case .parakeetTDTv3: + case .parakeetTDTv2, .parakeetTDTv3: + guard isModelLoaded(for: resolvedModel) else { + throw NSError(domain: "STTRouter", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "\(resolvedModel.title) is not loaded" + ]) + } return try await parakeetEngine.transcribeSamples(samples, source: source) case .whisperLargeV3Turbo, .whisperLargeV3: return try await whisperEngine.transcribeSamples( @@ -469,8 +504,8 @@ class STTRouter: ObservableObject { whisperEngine.cleanup() } - private func refreshModelDownloadState() { - let refreshed = modelDownloadState(for: selectedModel) + private func refreshModelDownloadState(publishedState: ParakeetModelState? = nil) { + let refreshed = publishedState ?? modelDownloadState(for: selectedModel) // Skip the redundant @Published reassignment when nothing changed. // Background meeting transcription refreshes this state repeatedly // (per segment / per wait tick), and every reassignment fires @@ -485,8 +520,10 @@ class STTRouter: ObservableObject { for model: TranscriptionModelChoice ) -> ParakeetModelState { switch model { + case .parakeetTDTv2: + return parakeetEngine.modelDownloadState(for: .v2) case .parakeetTDTv3: - return parakeetEngine.modelDownloadState + return parakeetEngine.modelDownloadState(for: .v3) case .whisperLargeV3Turbo, .whisperLargeV3: return whisperEngine.modelDownloadState } diff --git a/Sources/Speech/TranscriptionModelWarmupOwnership.swift b/Sources/Speech/TranscriptionModelWarmupOwnership.swift index 3926162c3..525489f5e 100644 --- a/Sources/Speech/TranscriptionModelWarmupOwnership.swift +++ b/Sources/Speech/TranscriptionModelWarmupOwnership.swift @@ -11,7 +11,7 @@ enum TranscriptionModelRuntime: Hashable { extension TranscriptionModelChoice { var runtime: TranscriptionModelRuntime { switch self { - case .parakeetTDTv3: + case .parakeetTDTv3, .parakeetTDTv2: return .parakeet case .whisperLargeV3Turbo, .whisperLargeV3: return .whisper @@ -198,7 +198,7 @@ struct TranscriptionModelWarmupOwnership { } /// Resolve a foreground request onto the model already active on the shared - /// runtime. This prevents one Whisper variant from unloading another while + /// runtime. This prevents one Parakeet or Whisper variant from unloading another while /// a dictation or meeting still owns it. mutating func claimForegroundUse( of model: TranscriptionModelChoice diff --git a/Sources/Support/ExistingInstallModelPrefetchPolicy.swift b/Sources/Support/ExistingInstallModelPrefetchPolicy.swift index 9a10f7191..202fb728b 100644 --- a/Sources/Support/ExistingInstallModelPrefetchPolicy.swift +++ b/Sources/Support/ExistingInstallModelPrefetchPolicy.swift @@ -24,7 +24,7 @@ enum ExistingInstallModelPrefetchPolicy { static func shouldPrefetch(_ context: ExistingInstallModelPrefetchContext) -> Bool { guard context.isExistingInstall else { return false } - guard context.selectedModel == .parakeetTDTv3 else { return false } + guard context.selectedModel.parakeetVariant != nil else { return false } guard !context.eagerModelWarmupEnabled else { return false } guard !context.isModelLoaded else { return false } guard !context.isModelWorkInFlight else { return false } diff --git a/Sources/Support/ModelCacheInventory.swift b/Sources/Support/ModelCacheInventory.swift index dab3a479c..d8e39f809 100644 --- a/Sources/Support/ModelCacheInventory.swift +++ b/Sources/Support/ModelCacheInventory.swift @@ -74,11 +74,11 @@ enum ModelCacheInventory { // FluidAudio 0.15.x resolves the v3 cache folder WITHOUT the -coreml suffix // (ModelNames.folderName strips it). ParakeetEngine renames a 0.7.9-era // -coreml cache to this name on first init so users keep their download. - static let activeParakeetModelDirectoryName = "parakeet-tdt-0.6b-v3" + static let activeParakeetModelDirectoryName = ParakeetModelVariant.v3.directoryName static let knownStaleFluidAudioModelDirectories: Set = [ - "parakeet-tdt-0.6b-v2", - "parakeet-tdt-0.6b-v2-coreml", + // Both v2 folder spellings are supported: FluidAudio 0.7.9 already + // downloaded the modern v2 model set into the -coreml directory. "parakeet-tdt-0.6b-v3-coreml", // The retired Nemotron streaming beta (removed 2026-08) downloaded // ~600 MB via FluidAudio; both folder-name derivations of its repo @@ -122,20 +122,53 @@ enum ModelCacheInventory { } static func activeParakeetModelDirectory( + variant: ParakeetModelVariant = .v3, fileManager: FileManager = .default, fluidAudioModelsDirectory: URL = defaultFluidAudioModelsDirectory() ) -> URL? { let candidate = fluidAudioModelsDirectory - .appendingPathComponent(activeParakeetModelDirectoryName, isDirectory: true) + .appendingPathComponent(variant.directoryName, isDirectory: true) .standardizedFileURL - guard hasCompleteParakeetModel(at: candidate, fileManager: fileManager) else { + guard hasCompleteParakeetModel(at: candidate, variant: variant, fileManager: fileManager) else { return nil } return candidate } + /// FluidAudio 0.7.9 used the -coreml suffix for both variants; 0.15.x + /// removed it. Preserve partial downloads too: FluidAudio fills in missing + /// files after migration. This only renames a real directory and never + /// merges, overwrites, or follows a linked source/destination/cache root. + @discardableResult + static func migrateLegacyParakeetModelDirectory( + variant: ParakeetModelVariant, + fileManager: FileManager = .default, + fluidAudioModelsDirectory: URL = defaultFluidAudioModelsDirectory() + ) throws -> Bool { + let suppliedRoot = fluidAudioModelsDirectory.standardizedFileURL + guard (try? fileManager.destinationOfSymbolicLink(atPath: suppliedRoot.path)) == nil else { + return false + } + // Normalize system aliases such as /var -> /private/var before + // constructing the two fixed, immediate child paths. + let root = suppliedRoot.resolvingSymlinksInPath() + let current = root.appendingPathComponent(variant.directoryName, isDirectory: true) + let legacy = root.appendingPathComponent(variant.directoryName + "-coreml", isDirectory: true) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: legacy.path, isDirectory: &isDirectory), + isDirectory.boolValue, + (try? fileManager.destinationOfSymbolicLink(atPath: legacy.path)) == nil, + !fileManager.fileExists(atPath: current.path), + // fileExists returns false for dangling links; those are still + // destination collisions and must remain untouched. + (try? fileManager.destinationOfSymbolicLink(atPath: current.path)) == nil + else { return false } + try fileManager.moveItem(at: legacy, to: current) + return true + } + static func removeKnownStaleFluidAudioModels( fileManager: FileManager = .default, fluidAudioModelsDirectory: URL = defaultFluidAudioModelsDirectory() @@ -255,7 +288,7 @@ enum ModelCacheInventory { return Int64(values?.fileSize ?? 0) } - private static func hasCompleteParakeetModel(at directory: URL, fileManager: FileManager) -> Bool { + private static func hasCompleteParakeetModel(at directory: URL, variant: ParakeetModelVariant, fileManager: FileManager) -> Bool { var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue, @@ -267,14 +300,7 @@ enum ModelCacheInventory { // FluidAudio 0.15.x renamed the joint model directory. Checking the // legacy name here makes a complete current cache look incomplete and // can trigger the existing-install prefetch on every launch. - let requiredDirectories = [ - "Encoder.mlmodelc", - "JointDecisionv3.mlmodelc", - "Decoder.mlmodelc", - "Preprocessor.mlmodelc", - ] - - for name in requiredDirectories { + for name in variant.requiredModelDirectoryNames { let modelDirectory = directory.appendingPathComponent(name, isDirectory: true) guard fileManager.fileExists(atPath: modelDirectory.path, isDirectory: &isDirectory), isDirectory.boolValue, @@ -284,22 +310,21 @@ enum ModelCacheInventory { } let coreMLData = modelDirectory.appendingPathComponent("coremldata.bin") - guard fileManager.fileExists(atPath: coreMLData.path) else { + guard isRegularNonSymlinkFile(at: coreMLData, fileManager: fileManager) else { return false } } - let requiredFiles = [ - "config.json", - "parakeet_v3_vocab.json", - "parakeet_vocab.json", - ] - - return requiredFiles.allSatisfy { name in - fileManager.fileExists(atPath: directory.appendingPathComponent(name).path) + return variant.requiredFileNames.allSatisfy { name in + isRegularNonSymlinkFile(at: directory.appendingPathComponent(name), fileManager: fileManager) } } + private static func isRegularNonSymlinkFile(at url: URL, fileManager: FileManager) -> Bool { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + return values?.isRegularFile == true && values?.isSymbolicLink != true && (values?.fileSize ?? 0) > 0 + } + private static func resolvedDirectoryIsInsideAppCache( _ directory: URL, cacheRoot: URL, diff --git a/Sources/Support/TranscriptionModelPreferences.swift b/Sources/Support/TranscriptionModelPreferences.swift index d5f0241b7..faa546596 100644 --- a/Sources/Support/TranscriptionModelPreferences.swift +++ b/Sources/Support/TranscriptionModelPreferences.swift @@ -1,7 +1,26 @@ import Foundation +/// App-owned model identity; conversion to FluidAudio stays at the Speech boundary. +enum ParakeetModelVariant: String, CaseIterable, Sendable { + case v2 + case v3 + + var directoryName: String { "parakeet-tdt-0.6b-\(rawValue)" } + var jointModelName: String { self == .v2 ? "JointDecision.mlmodelc" : "JointDecisionv3.mlmodelc" } + var requiredModelDirectoryNames: [String] { + ["Encoder.mlmodelc", jointModelName, "Decoder.mlmodelc", "Preprocessor.mlmodelc"] + } + var requiredFileNames: [String] { + // FluidAudio v0.15.4: ModelNames.swift and AsrModels.getRequiredModels + // define the compiled model set; AsrModels loads the shared vocabulary + // for v2. Recheck this contract when changing the dependency version. + self == .v2 ? ["parakeet_vocab.json"] : ["config.json", "parakeet_v3_vocab.json", "parakeet_vocab.json"] + } +} + enum TranscriptionModelChoice: String, CaseIterable, Identifiable { case parakeetTDTv3 = "parakeet-tdt-v3" + case parakeetTDTv2 = "parakeet-tdt-v2" case whisperLargeV3Turbo = "whisper-large-v3-turbo" case whisperLargeV3 = "whisper-large-v3" @@ -11,6 +30,8 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { switch self { case .parakeetTDTv3: return "Parakeet TDT V3" + case .parakeetTDTv2: + return "Parakeet TDT V2 (English only)" case .whisperLargeV3Turbo: return "Whisper Large V3 Turbo" case .whisperLargeV3: @@ -21,7 +42,9 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var shortTitle: String { switch self { case .parakeetTDTv3: - return "Parakeet" + return "Parakeet V3" + case .parakeetTDTv2: + return "Parakeet V2" case .whisperLargeV3Turbo: return "Whisper Turbo" case .whisperLargeV3: @@ -32,7 +55,9 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var summary: String { switch self { case .parakeetTDTv3: - return "Default local model for dictation and meetings." + return "Default multilingual local model for dictation and meetings." + case .parakeetTDTv2: + return "English-only local model for dictation and meetings." case .whisperLargeV3Turbo: return "Local Whisper with broad language coverage." case .whisperLargeV3: @@ -46,7 +71,7 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var isWhisper: Bool { switch self { - case .parakeetTDTv3: + case .parakeetTDTv2, .parakeetTDTv3: return false case .whisperLargeV3Turbo, .whisperLargeV3: return true @@ -55,7 +80,7 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var engineName: String { switch self { - case .parakeetTDTv3: + case .parakeetTDTv2, .parakeetTDTv3: return "parakeet" case .whisperLargeV3Turbo, .whisperLargeV3: return "whisper" @@ -64,6 +89,8 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var transcriptionEngineIdentifier: String { switch self { + case .parakeetTDTv2: + return "parakeet_v2_local" case .parakeetTDTv3: return "parakeet_local" case .whisperLargeV3Turbo: @@ -75,6 +102,8 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var transcriptionEngineDisplayName: String { switch self { + case .parakeetTDTv2: + return "Parakeet V2" case .parakeetTDTv3: return "Parakeet" case .whisperLargeV3Turbo: @@ -86,7 +115,7 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var whisperKitModelName: String? { switch self { - case .parakeetTDTv3: + case .parakeetTDTv2, .parakeetTDTv3: return nil case .whisperLargeV3Turbo: return "large-v3-v20240930_turbo_632MB" @@ -97,6 +126,8 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { var approximateDownloadSize: String { switch self { + case .parakeetTDTv2: + return "~460 MB" case .parakeetTDTv3: return "~600 MB" case .whisperLargeV3Turbo: @@ -105,6 +136,14 @@ enum TranscriptionModelChoice: String, CaseIterable, Identifiable { return "~626 MB" } } + + var parakeetVariant: ParakeetModelVariant? { + switch self { + case .parakeetTDTv2: return .v2 + case .parakeetTDTv3: return .v3 + case .whisperLargeV3Turbo, .whisperLargeV3: return nil + } + } } enum TranscriptionModelPreferences { diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 8910efee9..7bd1e0350 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -1966,7 +1966,7 @@ struct TranscriptedSettingsView: View { title: "Model", info: GeneralInfo( title: "Model", - message: "All models run on this Mac. Parakeet is the fast default; the Whisper models add broader language coverage. Changes apply to the next capture." + message: "All models run on this Mac. Parakeet V3 is the multilingual default; Parakeet V2 is English-only; Whisper adds broader language coverage. Captures keep the model they started with. Overlapping captures on the same engine share that model until they finish." ), automationIdentifier: "transcripted.settings.general.model" ) { diff --git a/Tests/E2E/TranscriptedE2ESmoke.swift b/Tests/E2E/TranscriptedE2ESmoke.swift index 46d22d7a0..6479d3344 100644 --- a/Tests/E2E/TranscriptedE2ESmoke.swift +++ b/Tests/E2E/TranscriptedE2ESmoke.swift @@ -1,4 +1,5 @@ import Foundation +import TranscriptedCaptureKit private enum E2ESmokeError: Error, CustomStringConvertible { case failed(String) @@ -61,6 +62,7 @@ private final class TranscriptedE2ESmokeHarness { try await verifyAppFacingDiscovery(fixtures: fixtures) try verifyHomePreview(fixtures: fixtures) try verifyImportedAudioArtifact(fixtures: fixtures) + try verifyParakeetArtifactIdentity(fixtures: fixtures) try verifyMCPFacingDiscovery(fixtures: fixtures, captureLibrary: captureLibrary) try verifyFailedMeetingArtifact(fixtures: fixtures) try verifySupportDiagnosticsPrivacy(fixtures: fixtures, logsDir: logsDir) @@ -368,6 +370,23 @@ private final class TranscriptedE2ESmokeHarness { ) } + private func verifyParakeetArtifactIdentity(fixtures: SmokeFixtures) throws { + for identifier in ["parakeet_local", "parakeet_v2_local", "future_local_model"] { + let markdown = fixtures.importedMeetingMarkdown.replacingOccurrences( + of: "transcription_engine: parakeet_local", + with: "transcription_engine: \(identifier)" + ) + let appDocument = try unwrap(TranscriptFrontmatter.document(in: markdown), + "App reader must accept concrete model metadata") + let agentDocument = try unwrap(CaptureMarkdownParser.parseMeeting(from: markdown), + "Agent reader must accept concrete model metadata") + try expect(appDocument.values["transcription_engine"] == identifier, + "App reader must preserve concrete and unknown model identifiers") + try expect(agentDocument.sttEngine == identifier, + "Agent reader must preserve concrete and unknown model identifiers") + } + } + private func verifyMCPFacingDiscovery(fixtures: SmokeFixtures, captureLibrary: URL) throws { let directories = TranscriptedDataDirectories.resolve(environment: [ "TRANSCRIPTED_DATA_DIR": captureLibrary.path, diff --git a/Tests/ExistingInstallModelPrefetchPolicyTests.swift b/Tests/ExistingInstallModelPrefetchPolicyTests.swift index 3b14ebe6a..b64812541 100644 --- a/Tests/ExistingInstallModelPrefetchPolicyTests.swift +++ b/Tests/ExistingInstallModelPrefetchPolicyTests.swift @@ -8,6 +8,21 @@ import Foundation func testExistingInstallModelPrefetchPolicy() { + runSuite("Existing installs can prefetch selected Parakeet v2") { + assertTrue(ExistingInstallModelPrefetchPolicy.shouldPrefetch( + ExistingInstallModelPrefetchContext( + isExistingInstall: true, selectedModel: .parakeetTDTv2, + isModelLoaded: false, isModelWorkInFlight: false, eagerModelWarmupEnabled: false + ) + )) + assertFalse(ExistingInstallModelPrefetchPolicy.shouldPrefetch( + ExistingInstallModelPrefetchContext( + isExistingInstall: true, selectedModel: .parakeetTDTv2, + isModelLoaded: false, isModelWorkInFlight: true, eagerModelWarmupEnabled: false + ) + )) + } + runSuite("TranscriptedAppState — heavyweight model warmup is opt-in") { let source = readSourceFixture("Sources/TranscriptedAppState.swift") assertTrue( diff --git a/Tests/FirstRunExperienceTests.swift b/Tests/FirstRunExperienceTests.swift index a8d12b56d..fbb23f518 100644 --- a/Tests/FirstRunExperienceTests.swift +++ b/Tests/FirstRunExperienceTests.swift @@ -1,6 +1,13 @@ import Foundation func testFirstRunExperience() { + runSuite("Parakeet v2 persistence copy uses the approximate required CoreML download size") { + let detail = FirstRunExperience.modelPersistenceDetail(for: .parakeetTDTv2) + assertTrue(detail.hasPrefix("One-time ~460 MB download.")) + assertEqual(TranscriptionModelChoice.parakeetTDTv2.approximateDownloadSize, "~460 MB") + assertTrue(detail.contains("saved on this Mac")) + } + runSuite("FirstRunExperience.onboardingPermissions — meetings-first setup does not hard-block on System Audio") { assertTrue( FirstRunExperience.hasRequiredMeetingSetup(microphoneGranted: true), diff --git a/Tests/ModelCacheInventoryTests.swift b/Tests/ModelCacheInventoryTests.swift index df762143c..b018486d7 100644 --- a/Tests/ModelCacheInventoryTests.swift +++ b/Tests/ModelCacheInventoryTests.swift @@ -1,6 +1,34 @@ import Foundation func testModelCacheInventory() { + runSuite("Parakeet v2 completeness is version-specific and cleanup preserves reusable caches") { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ModelCacheV2-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let variant = ParakeetModelVariant.v2 + let v2 = root.appendingPathComponent(variant.directoryName) + for name in variant.requiredModelDirectoryNames { + writeTestFile(v2.appendingPathComponent("\(name)/coremldata.bin"), bytes: 1) + } + assertNil(ModelCacheInventory.activeParakeetModelDirectory(variant: .v2, fluidAudioModelsDirectory: root)) + writeTestFile(v2.appendingPathComponent("parakeet_vocab.json"), bytes: 1) + assertNotNil(ModelCacheInventory.activeParakeetModelDirectory(variant: .v2, fluidAudioModelsDirectory: root)) + assertNil(ModelCacheInventory.activeParakeetModelDirectory(variant: .v3, fluidAudioModelsDirectory: root)) + let legacy = root.appendingPathComponent("parakeet-tdt-0.6b-v2-coreml") + writeTestFile(legacy.appendingPathComponent("model.bin"), bytes: 1) + let result = try? ModelCacheInventory.removeKnownStaleFluidAudioModels(fluidAudioModelsDirectory: root) + assertEqual(result?.removedBytes, 0) + assertEqual(result?.removedNames, []) + assertTrue(FileManager.default.fileExists(atPath: v2.path)) + assertTrue(FileManager.default.fileExists(atPath: legacy.path)) + + let decoder = v2.appendingPathComponent("Decoder.mlmodelc") + try! FileManager.default.removeItem(at: decoder) + try! FileManager.default.createSymbolicLink(at: decoder, withDestinationURL: v2.appendingPathComponent("Encoder.mlmodelc")) + assertNil(ModelCacheInventory.activeParakeetModelDirectory(variant: .v2, fluidAudioModelsDirectory: root), + "symlinked compiled model directories must not satisfy completeness") + } + runSuite("ModelCacheInventory totals model cache directories") { let root = FileManager.default.temporaryDirectory .appendingPathComponent("ModelCacheInventoryTests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/ParakeetCacheMigrationTests.swift b/Tests/ParakeetCacheMigrationTests.swift new file mode 100644 index 000000000..8dd8bf8c3 --- /dev/null +++ b/Tests/ParakeetCacheMigrationTests.swift @@ -0,0 +1,65 @@ +import Foundation + +func testParakeetCacheMigration() { + runSuite("Legacy Parakeet caches migrate without overwrites or symlink traversal") { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("ParakeetMigration-\(UUID().uuidString)") + defer { try? fm.removeItem(at: root) } + try! fm.createDirectory(at: root, withIntermediateDirectories: true) + + for variant in ParakeetModelVariant.allCases { + let cache = root.appendingPathComponent(variant.rawValue) + let legacy = cache.appendingPathComponent(variant.directoryName + "-coreml") + let current = cache.appendingPathComponent(variant.directoryName) + assertFalse(try! ModelCacheInventory.migrateLegacyParakeetModelDirectory( + variant: variant, fluidAudioModelsDirectory: cache + ), "an absent legacy cache is a no-op") + try! fm.createDirectory(at: legacy, withIntermediateDirectories: true) + let bytes = Data([1, 2, 3, 4]) + try! bytes.write(to: legacy.appendingPathComponent("existing-model.bin")) + assertTrue(try! ModelCacheInventory.migrateLegacyParakeetModelDirectory( + variant: variant, fluidAudioModelsDirectory: cache + )) + assertEqual(try! Data(contentsOf: current.appendingPathComponent("existing-model.bin")), bytes) + assertFalse(fm.fileExists(atPath: legacy.path)) + assertFalse(try! ModelCacheInventory.migrateLegacyParakeetModelDirectory( + variant: variant, fluidAudioModelsDirectory: cache + ), "repeated migration is a no-op") + + try! fm.createDirectory(at: legacy, withIntermediateDirectories: true) + try! Data([9]).write(to: legacy.appendingPathComponent("legacy-only.bin")) + assertFalse(try! ModelCacheInventory.migrateLegacyParakeetModelDirectory( + variant: variant, fluidAudioModelsDirectory: cache + ), "coexisting canonical cache must not be merged or overwritten") + assertEqual(try! Data(contentsOf: current.appendingPathComponent("existing-model.bin")), bytes) + assertTrue(fm.fileExists(atPath: legacy.appendingPathComponent("legacy-only.bin").path)) + } + + for collision in ["source-link", "destination-link", "root-link"] { + let cache = root.appendingPathComponent(collision) + let target = root.appendingPathComponent(collision + "-target") + try! fm.createDirectory(at: cache, withIntermediateDirectories: true) + try! fm.createDirectory(at: target, withIntermediateDirectories: true) + let legacy = cache.appendingPathComponent(ParakeetModelVariant.v2.directoryName + "-coreml") + let current = cache.appendingPathComponent(ParakeetModelVariant.v2.directoryName) + var suppliedCache = cache + if collision == "source-link" { + try! fm.createSymbolicLink(at: legacy, withDestinationURL: target) + } else { + try! fm.createDirectory(at: legacy, withIntermediateDirectories: true) + if collision == "destination-link" { + // A dangling link is still a collision despite fileExists == false. + try! fm.createSymbolicLink(at: current, withDestinationURL: target.appendingPathComponent("absent")) + } else { + suppliedCache = root.appendingPathComponent("linked-cache") + try! fm.createSymbolicLink(at: suppliedCache, withDestinationURL: cache) + } + } + assertFalse(try! ModelCacheInventory.migrateLegacyParakeetModelDirectory( + variant: .v2, fluidAudioModelsDirectory: suppliedCache + ), "\(collision) must remain untouched") + assertTrue(fm.fileExists(atPath: legacy.path)) + assertTrue(fm.fileExists(atPath: target.path)) + } + } +} diff --git a/Tests/ParakeetModelInitDiagnosticsTests.swift b/Tests/ParakeetModelInitDiagnosticsTests.swift index d5c269c79..72ebbcc00 100644 --- a/Tests/ParakeetModelInitDiagnosticsTests.swift +++ b/Tests/ParakeetModelInitDiagnosticsTests.swift @@ -1,7 +1,106 @@ import AVFoundation import Foundation -func testParakeetModelInitDiagnostics() { +private actor ParakeetModelDrainTestGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + for waiter in waiters { waiter.resume() } + waiters.removeAll() + } +} + +private actor ParakeetModelDrainTestTrace { + var events: [String] = [] + func record(_ event: String) { events.append(event) } +} + +func testParakeetModelInitDiagnostics() async { + await testParakeetTeardownWaiting() + runSuite("Variant admission preserves active runtime and network-only prefetch") { + for current in ParakeetModelVariant.allCases { + for requested in ParakeetModelVariant.allCases { + assertEqual(ParakeetModelSelectionPolicy.canSelect( + requested, current: current, hasActiveWork: true + ), requested == current, "active work may join only its current variant") + assertTrue(ParakeetModelSelectionPolicy.canSelect( + requested, current: current, hasActiveWork: false + )) + } + } + assertFalse(ParakeetModelSelectionPolicy.canPrefetch(hasManager: true, hasActiveWork: false), + "even an idle loaded manager must not be unloaded by prefetch") + assertFalse(ParakeetModelSelectionPolicy.canPrefetch(hasManager: false, hasActiveWork: true)) + assertFalse(ParakeetModelSelectionPolicy.canPrefetch(hasManager: true, hasActiveWork: true)) + assertTrue(ParakeetModelSelectionPolicy.canPrefetch(hasManager: false, hasActiveWork: false)) + } + let started = ParakeetModelDrainTestGate() + let release = ParakeetModelDrainTestGate() + let trace = ParakeetModelDrainTestTrace() + let oldLoad = Task { + await started.open() + // Like a native load, this continuation deliberately ignores cancellation. + await release.wait() + await trace.record("old manager cleaned") + } + await started.wait() + oldLoad.cancel() + let cleanup = Task { await trace.record("previous cleanup") } + let drain = ParakeetModelTaskDrain.draining(oldLoad, after: cleanup) + let successor = Task { + await drain.value + await trace.record("new load") + } + await cleanup.value + let beforeRelease = await trace.events + await release.open() + await successor.value + let afterRelease = await trace.events + runSuite("Canceled native model work drains before successor initialization") { + assertEqual(beforeRelease, ["previous cleanup"]) + assertEqual(afterRelease, ["previous cleanup", "old manager cleaned", "new load"]) + assertTrue(oldLoad.isCancelled, "the drain waits even when cancellation was already requested") + } + + runSuite("Model work tokens reject stale success, failure and progress after switching back") { + let oldV3 = ParakeetModelWorkToken(variant: .v3, generation: 1) + let v2 = ParakeetModelWorkToken(variant: .v2, generation: 2) + let newV3 = ParakeetModelWorkToken(variant: .v3, generation: 3) + assertFalse(oldV3.isCurrent(variant: .v2, generation: 1)) + assertFalse(oldV3.isCurrent(variant: .v3, generation: 3)) + assertFalse(v2.isCurrent(variant: .v3, generation: 3)) + assertTrue(newV3.isCurrent(variant: .v3, generation: 3)) + assertFalse(newV3.isCurrent(variant: .v3, generation: 4), "cancellation/retry invalidates even the same model") + } + + runSuite("Parakeet bundles must contain every required file of the requested version") { + for variant in ParakeetModelVariant.allCases { + let prefix = "/fixture/parakeet-models/\(variant.directoryName)/" + let files = Set((variant.requiredModelDirectoryNames.map { "\($0)/coremldata.bin" } + + variant.requiredFileNames).map { prefix + $0 }) + assertNotNil(ParakeetBundledModelLayoutPolicy.resolveBundledModelPath( + resourcePath: "/fixture", variant: variant, fileExists: { files.contains($0) } + )) + let other: ParakeetModelVariant = variant == .v2 ? .v3 : .v2 + assertNil(ParakeetBundledModelLayoutPolicy.resolveBundledModelPath( + resourcePath: "/fixture", variant: other, fileExists: { files.contains($0) } + )) + for missing in files { + assertNil(ParakeetBundledModelLayoutPolicy.resolveBundledModelPath( + resourcePath: "/fixture", variant: variant, + fileExists: { $0 != missing && files.contains($0) } + )) + } + } + } + runSuite("ParakeetModelInitDiagnostics.failureContext captures safe initialization details") { let context = ParakeetModelInitDiagnostics.failureContext( stage: .downloadModels, @@ -169,6 +268,13 @@ func testParakeetModelInitDiagnostics() { subdirectory: ParakeetBundledModelLayoutPolicy.runtime.subdirectory, checkFile: ParakeetBundledModelLayoutPolicy.runtime.checkFile ) + let modelRoot = root.appendingPathComponent("parakeet-models/\(ParakeetModelVariant.v3.directoryName)") + for file in ParakeetModelVariant.v3.requiredModelDirectoryNames.map({ "\($0)/coremldata.bin" }) + + ParakeetModelVariant.v3.requiredFileNames { + let url = modelRoot.appendingPathComponent(file) + try! FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try! Data([0]).write(to: url) + } let resolved = ParakeetBundledModelLayoutPolicy.resolveBundledModelPath( resourcePath: root.path @@ -222,6 +328,45 @@ func testParakeetModelInitDiagnostics() { } } +@MainActor +private func testParakeetTeardownWaiting() async { + let gate = ParakeetModelTeardownGate() + let initiallyIdle = await gate.wait() + gate.begin() + let canceled = Task { @MainActor in await gate.wait() } + await Task.yield() + canceled.cancel() + let canceledResult = await canceled.value + let remainedPending = gate.isPending + let successor = Task { @MainActor in await gate.wait() } + let secondSuccessor = Task { @MainActor in await gate.wait() } + await Task.yield() + gate.finish() + let successorResult = await successor.value + let secondResult = await secondSuccessor.value + gate.finish() // Repeated completion must never double-resume a waiter. + + gate.begin() + let canceledBeforeStart = Task { @MainActor in await gate.wait() } + canceledBeforeStart.cancel() + let preCanceledResult = await canceledBeforeStart.value + let retryStillPending = gate.isPending + gate.finish() + let idleAgain = await gate.wait() + + runSuite("Teardown waits are event-driven, reusable, and independently cancellable") { + assertTrue(initiallyIdle) + assertFalse(canceledResult) + assertTrue(remainedPending, "canceling initialization must not release native decoder ownership") + assertTrue(successorResult) + assertTrue(secondResult, "completion resumes every still-live waiter") + assertFalse(preCanceledResult) + assertTrue(retryStillPending) + assertTrue(idleAgain) + assertFalse(gate.isPending) + } +} + private func makeBundledModelFixtureRoot() -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("ParakeetBundledModelLayoutPolicy-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/STTRouterPolicyTests.swift b/Tests/STTRouterPolicyTests.swift index caf94eebe..591b78525 100644 --- a/Tests/STTRouterPolicyTests.swift +++ b/Tests/STTRouterPolicyTests.swift @@ -11,6 +11,35 @@ import Foundation func testSTTRouterPolicy() { + runSuite("Recording admission establishes the resolved variant before capturing audio") { + let sourceURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Sources/Speech/STTRouter.swift") + let source = try! String(contentsOf: sourceURL, encoding: .utf8) + let start = source.range(of: "private func setActiveRecordingModel")! + let end = source.range(of: "private func clearActiveRecordingModel", range: start.upperBound.. Date: Sun, 6 Sep 2026 17:50:08 -0700 Subject: [PATCH 2/6] Test Parakeet lifecycle executor under delayed native work --- Sources/Speech/ParakeetModelLifecycle.swift | 4 +- .../ParakeetLifecycle/EngineScaffold.swift | 51 ++++ .../ParakeetLifecycle/ExecutorSmoke.swift | 219 ++++++++++++++++++ .../ParakeetLifecycle/FakeFluidAudio.swift | 84 +++++++ Tests/Integration/ParakeetLifecycle/README.md | 37 +++ Tests/README.md | 6 + scripts/README.md | 2 + scripts/dev/test-parakeet-lifecycle.sh | 27 +++ scripts/entrypoints/run-integration-smoke.sh | 4 + 9 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 Tests/Integration/ParakeetLifecycle/EngineScaffold.swift create mode 100644 Tests/Integration/ParakeetLifecycle/ExecutorSmoke.swift create mode 100644 Tests/Integration/ParakeetLifecycle/FakeFluidAudio.swift create mode 100644 Tests/Integration/ParakeetLifecycle/README.md create mode 100644 scripts/dev/test-parakeet-lifecycle.sh diff --git a/Sources/Speech/ParakeetModelLifecycle.swift b/Sources/Speech/ParakeetModelLifecycle.swift index e55ca595f..488b759dc 100644 --- a/Sources/Speech/ParakeetModelLifecycle.swift +++ b/Sources/Speech/ParakeetModelLifecycle.swift @@ -104,7 +104,9 @@ extension ParakeetEngine { modelDownloadState = .downloading(progress: max(0, min(1, progress))) } - private func scheduleModelDownloadWatchdog( + // Internal so the executor integration harness can supply an aged progress + // tracker without shortening the production five-minute timeout. + func scheduleModelDownloadWatchdog( generation: UInt64, progressTracker: ParakeetModelDownloadProgressTracker ) { diff --git a/Tests/Integration/ParakeetLifecycle/EngineScaffold.swift b/Tests/Integration/ParakeetLifecycle/EngineScaffold.swift new file mode 100644 index 000000000..5b2e60d71 --- /dev/null +++ b/Tests/Integration/ParakeetLifecycle/EngineScaffold.swift @@ -0,0 +1,51 @@ +import AVFoundation +import Foundation +import FluidAudio + +// The production lifecycle extension is compiled unchanged against this state +// scaffold. Audio graph, cache IO, logging and external downloads are excluded. +// No lifecycle implementation belongs here: tests must exercise the real file. +@MainActor final class ParakeetEngine { + var isShuttingDown = false + var isRecording = false + var isTranscribing = false + var hasActiveASRWork = false + var asrManager: AsrManager? + var modelVariant: ParakeetModelVariant = .v3 + var loadedModelVariant: ParakeetModelVariant? + var modelCleanupTask: Task? + let modelTeardownGate = ParakeetModelTeardownGate() + var modelInitializationTask: Task? + var modelInitializationGeneration: UInt64 = 0 + var modelFilePrefetchTask: Task? + var prefetchedModelPath: URL? + var modelDownloadWatchdogTask: Task? + var modelDownloadAttemptGeneration: UInt64 = 0 + var asrManagerReady = false + var modelDownloadState: ParakeetModelState = .notLoaded + func isModelLoaded(for variant: ParakeetModelVariant) -> Bool { + asrManagerReady && loadedModelVariant == variant + } + func scheduleInputDeviceNameRefresh() {} +} + +enum ModelCacheInventory { + static func activeParakeetModelDirectory(variant: ParakeetModelVariant) -> URL? { nil } + static func migrateLegacyParakeetModelDirectory(variant: ParakeetModelVariant, fluidAudioModelsDirectory: URL) throws -> Bool { false } +} +enum ModelDownloadService { + struct Failure { let detail: String } + static func classifyError(_ error: Error) -> Failure { Failure(detail: "fake load failed") } +} +struct SilentLogger { + func info(_ message: String) {} + func warning(_ message: String) {} + func error(_ message: String) {} +} +enum AppLogger { static let transcription = SilentLogger() } +@MainActor final class EventReporter { + enum Level { case info, warning, error } + static let shared = EventReporter() + func capture(level: Level, engine: String, event: String, message: String, context: [String: String] = [:]) {} +} +extension AVAuthorizationStatus { var diagnosticName: String { "test" } } diff --git a/Tests/Integration/ParakeetLifecycle/ExecutorSmoke.swift b/Tests/Integration/ParakeetLifecycle/ExecutorSmoke.swift new file mode 100644 index 000000000..d89c72579 --- /dev/null +++ b/Tests/Integration/ParakeetLifecycle/ExecutorSmoke.swift @@ -0,0 +1,219 @@ +import Foundation +import FluidAudio + +@main struct ParakeetLifecycleExecutorSmoke { + @MainActor static var assertions = 0 + @MainActor static func check(_ value: Bool, _ description: String) { + guard value else { fatalError("FAIL: \(description)") } + assertions += 1 + } + @MainActor static func settle() async { + for _ in 0..<30 { await Task.yield() } + try? await Task.sleep(nanoseconds: 10_000_000) + } + @MainActor static func waitFor(_ event: String) async { + for _ in 0..<500 { + if await FakeFluidAudio.shared.events.contains(event) { return } + try? await Task.sleep(nanoseconds: 2_000_000) + } + fatalError("Timed out waiting for \(event); events: inspect fake loader") + } + @MainActor static func finishLoad(_ variant: String, attempt: Int = 1) async { + let fake = FakeFluidAudio.shared + await waitFor("download-\(variant)#\(attempt)") + await fake.release("download-\(variant)#\(attempt)") + await waitFor("load-\(variant)#\(attempt)") + await fake.release("load-\(variant)#\(attempt)") + await waitFor("manager-\(variant)#\(attempt)") + await fake.release("manager-\(variant)#\(attempt)") + } + @MainActor static func dispose(_ engine: ParakeetEngine) async { + engine.cancelModelWork() + engine.teardownModel() + await engine.modelCleanupTask?.value + } + + @MainActor static func joiningAndReadyPrefetch() async { + await FakeFluidAudio.shared.reset() + let engine = ParakeetEngine() + let first = Task { await engine.initialize(variant: .v2) } + await waitFor("download-v2#1") + let second = Task { await engine.initialize(variant: .v2) } + let join = Task { await engine.joinModelInitialization(variant: .v2) } + check(!(await engine.joinModelInitialization(variant: .v3)), "other variant must not join") + await finishLoad("v2") + await first.value; await second.value + check(await join.value, "explicit join awaited production initialization") + check(engine.isModelLoaded(for: .v2), "v2 ready") + check(await FakeFluidAudio.shared.events.filter { $0.hasPrefix("download-") }.count == 1, "one shared download") + let manager = engine.asrManager + await engine.prefetchModelFilesIfNeeded(variant: .v3) + check(engine.asrManager === manager && engine.modelVariant == .v2, "prefetch preserves idle loaded manager") + check(engine.modelDownloadState == .ready, "prefetch preserves readiness") + await dispose(engine) + } + + @MainActor static func prefetchJoinAndFailedRetry() async { + let fake = FakeFluidAudio.shared + await fake.reset() + let engine = ParakeetEngine() + let prefetch = Task { await engine.prefetchModelFilesIfNeeded(variant: .v2) } + await waitFor("download-v2#1") + await fake.progress("download-v2#1", 0.8) + await settle() + check(engine.modelDownloadState == .downloading(progress: 0.2), "current progress reaches published state") + let initialization = Task { await engine.initialize(variant: .v2) } + await settle() + check(await fake.events == ["download-v2#1"], "initialize joins existing prefetch") + await fake.release("download-v2#1", fail: true) + await prefetch.value; await initialization.value + if case .failed = engine.modelDownloadState { check(true, "current failure is visible") } + else { check(false, "current download error must publish failure") } + check(engine.modelFilePrefetchTask == nil && engine.modelInitializationTask == nil, "failed work relinquishes task ownership") + let retry = Task { await engine.initialize(variant: .v2) } + await waitFor("download-v2#2"); await fake.release("download-v2#2") + await waitFor("load-v2#1"); await fake.release("load-v2#1") + await waitFor("manager-v2#1"); await fake.release("manager-v2#1") + await retry.value + check(engine.isModelLoaded(for: .v2), "retry succeeds after current error") + await dispose(engine) + } + + @MainActor static func rapidSwitchAndStaleProgress() async { + let fake = FakeFluidAudio.shared + await fake.reset() + let engine = ParakeetEngine() + let old = Task { await engine.initialize(variant: .v3) } + await waitFor("download-v3#1") + let middle = Task { await engine.initialize(variant: .v2) } + await settle() + check(engine.modelVariant == .v2, "middle selection installed") + let latest = Task { await engine.initialize(variant: .v3) } + await settle() + check(engine.modelVariant == .v3, "latest selection installed") + check(await fake.events == ["download-v3#1"], "successor waits for canceled native work") + let state = engine.modelDownloadState + await fake.progress("download-v3#1", 0.8) + await settle() + check(engine.modelDownloadState == state, "same-version stale progress rejected by generation") + await fake.release("download-v3#1") + await waitFor("download-v3#2") + check(!(await fake.events.contains("load-v3#1")), "stale download success cannot load models") + await fake.release("download-v3#2") + await waitFor("load-v3#1"); await fake.release("load-v3#1") + await waitFor("manager-v3#1"); await fake.release("manager-v3#1") + await old.value; await middle.value; await latest.value + check(engine.isModelLoaded(for: .v3), "latest v3 ready") + check(!(await fake.events.contains("download-v2#1")), "superseded middle selection never allocates") + await dispose(engine) + } + + @MainActor static func staleManagerResult(fail: Bool) async { + let fake = FakeFluidAudio.shared + await fake.reset() + let engine = ParakeetEngine() + let first = Task { await engine.initialize(variant: .v3) } + await waitFor("download-v3#1"); await fake.release("download-v3#1") + await waitFor("load-v3#1"); await fake.release("load-v3#1") + await waitFor("manager-v3#1") + let next = Task { await engine.initialize(variant: .v2) } + await settle() + check(!(await fake.events.contains("download-v2#1")), "native manager initialization drains before replacement") + await fake.release("manager-v3#1", fail: fail) + await waitFor("download-v2#1") + let events = await fake.events + check(events.firstIndex(of: "cleanup-v3")! < events.firstIndex(of: "download-v2#1")!, "stale manager cleaned before next allocation") + check(!engine.asrManagerReady && engine.loadedModelVariant == nil, "stale success/error cannot publish readiness") + check(engine.modelDownloadState == .downloading(progress: 0), "stale error cannot replace successor state") + await finishLoad("v2") + await first.value; await next.value + check(engine.isModelLoaded(for: .v2), "replacement ready after stale manager result") + await dispose(engine) + } + + @MainActor static func watchdogRetry() async { + let fake = FakeFluidAudio.shared + await fake.reset() + let engine = ParakeetEngine() + let first = Task { await engine.initialize(variant: .v2) } + await waitFor("download-v2#1") + engine.scheduleModelDownloadWatchdog( + generation: engine.modelDownloadAttemptGeneration, + progressTracker: ParakeetModelDownloadProgressTracker(initialActivityUptime: ProcessInfo.processInfo.systemUptime - 301) + ) + await settle() + if case .failed = engine.modelDownloadState { check(true, "watchdog failed stalled download") } + else { check(false, "watchdog must fail stalled download") } + check(engine.modelInitializationTask == nil && engine.modelFilePrefetchTask == nil, "watchdog cancels task ownership") + let retry = Task { await engine.initialize(variant: .v2) } + await settle() + check(await fake.events == ["download-v2#1"], "retry waits for stalled native task") + await fake.release("download-v2#1", fail: true) + await waitFor("download-v2#2") + await fake.progress("download-v2#1", 1) + await settle() + check(engine.modelDownloadState == .downloading(progress: 0), "stalled callback cannot overwrite retry") + await fake.release("download-v2#2") + await waitFor("load-v2#1"); await fake.release("load-v2#1") + await waitFor("manager-v2#1"); await fake.release("manager-v2#1") + await first.value; await retry.value + check(engine.isModelLoaded(for: .v2), "retry succeeds after watchdog") + await dispose(engine) + } + + @MainActor static func activeInferenceTeardown() async { + let fake = FakeFluidAudio.shared + await fake.reset() + let engine = ParakeetEngine() + let load = Task { await engine.initialize(variant: .v3) } + await finishLoad("v3"); await load.value + let manager = engine.asrManager + for kind in 0..<3 { + engine.isRecording = kind == 0 + engine.isTranscribing = kind == 1 + engine.hasActiveASRWork = kind == 2 + await engine.initialize(variant: .v2) + check(engine.modelVariant == .v3 && engine.isModelLoaded(for: .v3), "active work rejects selection before mutation") + await engine.prefetchModelFilesIfNeeded(variant: .v2) + check(engine.asrManager === manager, "active work rejects prefetch") + } + engine.teardownModel() + check(engine.asrManager === manager && engine.modelTeardownGate.isPending, "teardown retains actively used manager") + check(!engine.asrManagerReady && engine.loadedModelVariant == nil, "teardown clears readiness immediately") + let reload = Task { await engine.initialize(variant: .v3) } + await settle() + check(!(await fake.events.contains("cleanup-v3")), "no cleanup while inference active") + await fake.delayNextCleanup() + engine.hasActiveASRWork = false + engine.finishDeferredModelTeardownIfIdle() + await waitFor("cleanup-drain-v3#1") + await settle() + check(!(await fake.events.contains("load-v3#2")), "replacement waits for actual native cleanup completion") + await fake.release("cleanup-drain-v3#1") + // Reuses its prefetched path, so only native load/manager repeat. + await waitFor("load-v3#2") + let events = await fake.events + check(events.firstIndex(of: "cleanup-v3")! < events.firstIndex(of: "load-v3#2")!, "cleanup completes before reallocation") + await fake.release("load-v3#2") + await waitFor("manager-v3#2"); await fake.release("manager-v3#2") + await reload.value + check(engine.isModelLoaded(for: .v3), "reload resumes when inference drains") + await dispose(engine) + } + + @MainActor static func main() async { + // A hard process deadline also covers accidental cycles in task.value. + DispatchQueue.global().asyncAfter(deadline: .now() + 30) { + fputs("FAIL: lifecycle executor exceeded 30 second deadline\n", stderr) + exit(1) + } + await joiningAndReadyPrefetch() + await prefetchJoinAndFailedRetry() + await rapidSwitchAndStaleProgress() + await staleManagerResult(fail: false) + await staleManagerResult(fail: true) + await watchdogRetry() + await activeInferenceTeardown() + print("PASS: production Parakeet lifecycle executor (\(assertions) assertions)") + } +} diff --git a/Tests/Integration/ParakeetLifecycle/FakeFluidAudio.swift b/Tests/Integration/ParakeetLifecycle/FakeFluidAudio.swift new file mode 100644 index 000000000..6c3ae6090 --- /dev/null +++ b/Tests/Integration/ParakeetLifecycle/FakeFluidAudio.swift @@ -0,0 +1,84 @@ +// Test-only module boundary. Compile as FluidAudio; never link into the app. +#if !FAKE_CORE +import Foundation +import CoreML + +public enum AsrModelVersion: String, Sendable { case v2, v3 } +public struct DownloadProgress: Sendable { + public enum Phase: Sendable { case listing, downloading, compiling } + public let phase: Phase + public let fractionCompleted: Double +} + +/// Deliberately ignores task cancellation, as native CoreML work may do. +/// Tests explicitly release each suspension and prove stale work is drained. +public actor FakeFluidAudio { + public static let shared = FakeFluidAudio() + private var pending: [String: CheckedContinuation] = [:] + private var callbacks: [String: @Sendable (DownloadProgress) -> Void] = [:] + private var counts: [String: Int] = [:] + private var holdNextCleanup = false + public private(set) var events: [String] = [] + public func reset() { + precondition(pending.isEmpty, "Unreleased fake operations") + callbacks.removeAll(); counts.removeAll(); events.removeAll() + holdNextCleanup = false + } + public func suspend(_ operation: String) async throws { + counts[operation, default: 0] += 1 + let key = "\(operation)#\(counts[operation]!)" + events.append(key) + try await withCheckedThrowingContinuation { pending[key] = $0 } + } + public func download(_ version: AsrModelVersion, callback: @escaping @Sendable (DownloadProgress) -> Void) async throws { + let key = "download-\(version.rawValue)#\(counts["download-\(version.rawValue)", default: 0] + 1)" + callbacks[key] = callback + try await suspend("download-\(version.rawValue)") + } + public func release(_ key: String, fail: Bool = false) { + guard let continuation = pending.removeValue(forKey: key) else { preconditionFailure("No pending \(key)") } + if fail { continuation.resume(throwing: NSError(domain: "fake-loader", code: 42)) } + else { continuation.resume() } + } + public func progress(_ key: String, _ fraction: Double) { + callbacks[key]?(DownloadProgress(phase: .downloading, fractionCompleted: fraction)) + } + public func record(_ event: String) { events.append(event) } + public func delayNextCleanup() { holdNextCleanup = true } + public func cleanup(_ version: AsrModelVersion) async { + events.append("cleanup-\(version.rawValue)") + if holdNextCleanup { + holdNextCleanup = false + try? await suspend("cleanup-drain-\(version.rawValue)") + } + } +} + +public struct AsrModels: Sendable { + public let version: AsrModelVersion + public static func defaultCacheDirectory(for version: AsrModelVersion) -> URL { + URL(fileURLWithPath: "/nonexistent-parakeet-test-cache/\(version.rawValue)") + } + public static func download(version: AsrModelVersion, progress: @escaping @Sendable (DownloadProgress) -> Void) async throws -> URL { + try await FakeFluidAudio.shared.download(version, callback: progress) + return defaultCacheDirectory(for: version) + } + public static func load(from: URL, version: AsrModelVersion, encoderComputeUnits: MLComputeUnits?) async throws -> AsrModels { + try await FakeFluidAudio.shared.suspend("load-\(version.rawValue)") + return AsrModels(version: version) + } +} + +public actor AsrManager { + public enum Config: Sendable { case `default` } + private var version: AsrModelVersion = .v3 + public init(config: Config) {} + public func loadModels(_ models: AsrModels) async throws { + version = models.version + try await FakeFluidAudio.shared.suspend("manager-\(version.rawValue)") + } + public func cleanup() async { + await FakeFluidAudio.shared.cleanup(version) + } +} +#endif diff --git a/Tests/Integration/ParakeetLifecycle/README.md b/Tests/Integration/ParakeetLifecycle/README.md new file mode 100644 index 000000000..4fc0efcb5 --- /dev/null +++ b/Tests/Integration/ParakeetLifecycle/README.md @@ -0,0 +1,37 @@ +# Parakeet lifecycle executor integration tests + +Run from the repository root: + +```bash +bash scripts/dev/test-parakeet-lifecycle.sh +``` + +`bash run-integration-smoke.sh` also runs this harness. + +This compiles the **actual** `Sources/Speech/ParakeetModelLifecycle.swift` +extension, along with the production identity, state, generation, teardown, +download-progress and admission policies. It links a test-only `FluidAudio` +module that suspends download, CoreML load and manager initialization until +the test releases each operation. The fake deliberately ignores cancellation; +this models native work that continues after its caller has canceled. + +Cases exercise same-version initialization joins; initialization joining a +prefetch; current progress/error and retry; v3 → v2 → v3 supersession; stale +download success, progress, manager success and manager errors; watchdog +cancellation and retry; active recording/transcription/inference admission; +deferred teardown; and cleanup before successor allocation. The watchdog is +given an aged production progress tracker, not a reduced production timeout. +Each operation wait has a one-second deadline and the executable has a +30-second process deadline, including waits on task completion. + +`EngineScaffold.swift` supplies only the engine state and excluded collaborators. +It must never duplicate lifecycle decisions or executor methods. The fake +module lives only under `build/parakeet-lifecycle-tests` and is not referenced +by app build scripts. An app build against the real pinned FluidAudio remains +required to catch API drift that the fake module cannot validate. + +Limits: this is executor integration proof, not full `ParakeetEngine`/router, +CoreAudio, SwiftUI or real CoreML integration. The active-inference test drives +the activity flags and idle callback directly, so it does not establish that +every audio inference call site releases its lease. Real model inference, +cache migration, artifact E2E and live/UI testing are separate acceptance gates. diff --git a/Tests/README.md b/Tests/README.md index 82c561484..ecb4ae73b 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -107,6 +107,12 @@ Use this when changing: ## Integration Smoke +Parakeet model-lifecycle changes also have a deterministic executor harness: +`bash scripts/dev/test-parakeet-lifecycle.sh`. It compiles the production +lifecycle extension against delayed fake models (no network, cache writes, or +microphone). See `Tests/Integration/ParakeetLifecycle/README.md` for coverage and +the explicit boundary between this executor test and full engine/live proof. + `bash run-integration-smoke.sh` verifies that the app-side dependency bundle still exposes the `TranscriptedCore` types that `Sources/Meeting/` depends on. It also runs the wake-recovery smoke binary and currently finishes with diff --git a/scripts/README.md b/scripts/README.md index 96fe97dc9..e362f577f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -42,6 +42,8 @@ The wrappers share code from `scripts/entrypoints/lib/`: ## Active helper scripts +- `scripts/dev/test-parakeet-lifecycle.sh` — compile the production Parakeet lifecycle executor against deterministic delayed model fakes; see `Tests/Integration/ParakeetLifecycle/README.md` for scope and limitations + - `scripts/dev/agent-preflight.sh` — summarize branch state, changed paths, trusted docs, and suggested checks selected directly from the agent test matrix - `scripts/dev/test-matrix-checks.py` — dependency-free selector that executes `.agents/test-matrix.yml` path rules for preflight - `scripts/dev/check-build-source-lists.py` — checks the hand-maintained fast-test and smoke source lists for missing files diff --git a/scripts/dev/test-parakeet-lifecycle.sh b/scripts/dev/test-parakeet-lifecycle.sh new file mode 100644 index 000000000..a00a43f9d --- /dev/null +++ b/scripts/dev/test-parakeet-lifecycle.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Compile the actual app-owned lifecycle executor against suspended fake models. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" +TEST_DIR="$REPO_ROOT/Tests/Integration/ParakeetLifecycle" +OUT_DIR="$REPO_ROOT/build/parakeet-lifecycle-tests" +mkdir -p "$OUT_DIR" +swiftc -parse-as-library -emit-library -emit-module -module-name FluidAudio \ + "$TEST_DIR/FakeFluidAudio.swift" -o "$OUT_DIR/libFluidAudio.dylib" \ + -emit-module-path "$OUT_DIR/FluidAudio.swiftmodule" +swiftc -parse-as-library -emit-module -module-name TranscriptedCore -D FAKE_CORE \ + "$TEST_DIR/FakeFluidAudio.swift" -emit-module-path "$OUT_DIR/TranscriptedCore.swiftmodule" +swiftc -parse-as-library -I "$OUT_DIR" -L "$OUT_DIR" -lFluidAudio \ + -Xlinker -rpath -Xlinker "$OUT_DIR" \ + Sources/Support/TranscriptionModelPreferences.swift \ + Sources/Support/TranscriptedConstants.swift \ + Sources/Speech/ParakeetRecoveryState.swift \ + Sources/TranscriptedCore/Utilities/SupersessionEpoch.swift \ + Sources/Speech/DictationInputDeviceSelectionPolicy.swift \ + Sources/Speech/ParakeetModelState.swift \ + Sources/Speech/ParakeetModelInitDiagnostics.swift \ + Sources/Speech/ParakeetStartRecordingFailurePolicy.swift \ + Sources/Speech/ParakeetModelLifecycle.swift \ + "$TEST_DIR/EngineScaffold.swift" "$TEST_DIR/ExecutorSmoke.swift" \ + -o "$OUT_DIR/executor-smoke" +TRANSCRIPTED_DISABLE_FILE_LOGGER=1 "$OUT_DIR/executor-smoke" diff --git a/scripts/entrypoints/run-integration-smoke.sh b/scripts/entrypoints/run-integration-smoke.sh index 0ee2c0d85..74e584ea2 100755 --- a/scripts/entrypoints/run-integration-smoke.sh +++ b/scripts/entrypoints/run-integration-smoke.sh @@ -117,6 +117,10 @@ echo "" echo "Running wake smoke…" TRANSCRIPTED_DISABLE_FILE_LOGGER=1 "$WAKE_SMOKE_BIN" +echo "" +echo "Running Parakeet lifecycle executor smoke…" +bash "$REPO_ROOT/scripts/dev/test-parakeet-lifecycle.sh" + echo "" echo "Running recovery merge package tests…" TRANSCRIPTED_DISABLE_FILE_LOGGER=1 swift test --filter MicRecordingFileMergerTests From 2c6e2bc14358886cc97425cf08ea1654e5005c87 Mon Sep 17 00:00:00 2001 From: Nick Kedev Date: Mon, 7 Sep 2026 13:58:19 -0700 Subject: [PATCH 3/6] Accept Parakeet v2 artifacts in QA validation --- .../Validators/TranscriptValidator.swift | 1 + .../TranscriptedQATests/ValidatorTests.swift | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Tools/TranscriptedQA/Sources/TranscriptedQA/Validators/TranscriptValidator.swift b/Tools/TranscriptedQA/Sources/TranscriptedQA/Validators/TranscriptValidator.swift index 216517d4f..17f6c6bfe 100644 --- a/Tools/TranscriptedQA/Sources/TranscriptedQA/Validators/TranscriptValidator.swift +++ b/Tools/TranscriptedQA/Sources/TranscriptedQA/Validators/TranscriptValidator.swift @@ -4,6 +4,7 @@ struct TranscriptValidator { let directory: URL private let validTranscriptionEngines: Set = [ "parakeet_local", + "parakeet_v2_local", "whisper_large_v3_turbo_local", "whisper_large_v3_local", ] diff --git a/Tools/TranscriptedQA/Tests/TranscriptedQATests/ValidatorTests.swift b/Tools/TranscriptedQA/Tests/TranscriptedQATests/ValidatorTests.swift index ab2f04377..e72c57d2f 100644 --- a/Tools/TranscriptedQA/Tests/TranscriptedQATests/ValidatorTests.swift +++ b/Tools/TranscriptedQA/Tests/TranscriptedQATests/ValidatorTests.swift @@ -95,6 +95,43 @@ final class ValidatorTests: XCTestCase { XCTAssertFalse(results.contains { $0.status == .fail }) } + func testTranscriptValidatorAcceptsEverySupportedLocalEngine() throws { + for engine in [ + "parakeet_local", + "parakeet_v2_local", + "whisper_large_v3_turbo_local", + "whisper_large_v3_local", + ] { + try writeTranscriptWithEngine(engine) + } + + let results = TranscriptValidator(directory: tempRoot).validate() + let engineResults = results.filter { $0.check == "transcript/yaml-engine-stt" } + XCTAssertEqual(engineResults.count, 4) + XCTAssertTrue(engineResults.allSatisfy { $0.status == .pass }) + XCTAssertFalse(results.contains { $0.status == .fail }) + } + + func testTranscriptValidatorRejectsUnknownLocalEngine() throws { + try writeTranscriptWithEngine("parakeet_unknown_local") + + let results = TranscriptValidator(directory: tempRoot).validate() + let engineResult = try XCTUnwrap(results.first { $0.check == "transcript/yaml-engine-stt" }) + XCTAssertEqual(engineResult.status, .fail) + XCTAssertEqual(engineResult.detail, "Expected supported local STT engine, got parakeet_unknown_local") + } + + private func writeTranscriptWithEngine(_ engine: String) throws { + let name = "Call_\(engine)" + try TestDataGenerator(outputDir: tempRoot).generateTranscript( + name: name, utteranceCount: 1, speakerCount: 1 + ) + let file = tempRoot.appendingPathComponent("\(name).md") + let content = try String(contentsOf: file, encoding: .utf8) + .replacingOccurrences(of: "transcription_engine: parakeet_local", with: "transcription_engine: \(engine)") + try content.write(to: file, atomically: true, encoding: .utf8) + } + func testDictationValidatorRequiresDictationDayEvidence() throws { try """ --- From 6a752e173584191c0b438f376ea6a5659bbb99ed Mon Sep 17 00:00:00 2001 From: Nick Kedev Date: Tue, 8 Sep 2026 05:19:18 -0700 Subject: [PATCH 4/6] Document Parakeet v2 review evidence and proof boundaries --- .agent-review/parakeet-v2-workpad.md | 100 ++++++++++++++++++ .../visuals/parakeet-v2-settings.jpg | Bin 0 -> 62712 bytes 2 files changed, 100 insertions(+) create mode 100644 .agent-review/parakeet-v2-workpad.md create mode 100644 .agent-review/visuals/parakeet-v2-settings.jpg diff --git a/.agent-review/parakeet-v2-workpad.md b/.agent-review/parakeet-v2-workpad.md new file mode 100644 index 000000000..83fc85a53 --- /dev/null +++ b/.agent-review/parakeet-v2-workpad.md @@ -0,0 +1,100 @@ +# Parakeet v2 contribution workpad + +## Scope and ownership + +Add an opt-in English-only Parakeet TDT v2 model through the existing shared +selector. Parakeet v3 remains the default, both Whisper choices remain available, +and unknown stored preferences retain their existing fallback. + +This is one model capability, not a general Speech refactor. Support owns the +persisted identity/cache metadata; Speech owns the runtime. Meetings continue +to use `MeetingSTTAdapter` and the existing router. No Core library, capture +device, permission, entitlement, telemetry payload, release, or update changes. + +The lifecycle changes are necessary because v2 and v3 share one engine: selection +must not unload active inference or let canceled native loads overlap. Concrete +foreground leases defer selection until the last user releases the runtime; +generation checks reject stale callbacks; successor initialization waits for +canceled work and cleanup to drain. Existing lifecycle states remain canonical. + +## Compatibility and model provenance + +- Uses the existing pinned FluidAudio 0.15.4 model distribution and v2 API; + no new model host, dependency, checksum bypass, or entitlement. +- V2 requires its own compiled joint model and vocabulary. Supported canonical + and compatible legacy v2 caches are preserved; migration refuses symlinks, + merging, and overwriting an existing destination. +- Cache completeness, migration, coexistence, and re-download eligibility are + fixture-tested; personal caches were not deleted for testing. +- Saved v2 artifacts use `parakeet_v2_local`; v3 keeps `parakeet_local` and its + existing footer. Capture-format docs, reader smoke coverage, and the QA engine + allowlist recognize the new value without weakening unknown-engine rejection. + +## Automated proof + +Implementation tested at `2c6e2bc1`, against upstream base +`792d82e159b2f5546e2b474d4a3100a4fe6fa442`, on Apple Silicon with Xcode 26.6 +and Metal Toolchain 17F109. Subsequent evidence-only files do not change runtime. + +- `python3 scripts/dev/agent-context.py --base `: owner/matrix routing checked. +- `bash scripts/dev/agent-preflight.sh`: pass. +- `bash build-deps.sh --force`: pass, including Metal shaders. +- `bash build.sh --no-open`: pass, including isolated launch and bundle budget. +- `bash run-tests.sh`: 12,746 assertions pass. +- `bash run-integration-smoke.sh`: pass, including 45 production-lifecycle + executor assertions and seven recovery-merge tests. +- `python3 scripts/dev/check-build-source-lists.py`: pass. +- `bash run-e2e-smoke.sh`: pass. +- `bash -n run-integration-smoke.sh` and its canonical entrypoint: pass. +- `swift test --package-path Tools/TranscriptedQA`: 67 tests pass. +- Additional Core package proof: 1,079 tests, 13 existing skips, zero failures. +- `bash scripts/ops/transcripted-qa-bench.sh --mode full --strict-artifacts`: + 15/15 pass, exit 0. Final post-GUI artifact validation: 94 pass, zero fail, + seven warnings (absent dictations and optional capture-quality metadata). +- `TRANSCRIPTED_RUNTIME_BUDGET=1 bash build.sh --no-open`: exit 0; bundle 88.5 MiB, + launch-to-interactive 937 ms against 3000 ms. **No live dictation samples:** + latency/RTF percentiles are unavailable, not measured passing values. The + ambient log spans earlier runs and long idle intervals; it is not a controlled + model-load benchmark. + +The lifecycle harness compiles the actual production executor against delayed +fake dependencies and scaffold engine state. It covers joining, supersession, +late callbacks, retries/watchdog, active-use admission and cleanup ordering. +It is not hardware, real-engine activity-callback, or full-router proof by itself. + +## Actual app checks + +- Selected v2 in Settings; verified English-only copy and all four picker options. +- Downloaded/prepared each real model and imported the same local synthetic + English fixture through the native picker. All four saved expected text with + their correct concrete engine identifiers. +- Switched back to cached v2 and observed Ready. Relaunch preserved selection. +- During a 48:33 repeated-synthetic v2 import, selected v3. The saved job stayed + v2; logs show v3 initialization only after the job finished and cleaned up. +- Exact-dependency cached v2/v3 inference also passed with process networking + denied. A network-denied full-app run was not separately performed. + +![Actual development Settings with v2 selected](visuals/parakeet-v2-settings.jpg) + +Screenshot captured from the actual development app on 2026-09-07. It contains +only Settings, no transcript text, identities, paths or private user content. +The permission warnings are real: this ad-hoc build did not consistently retain +or recognize grants. They do not imply a live-capture pass. + +## Limitations and review notes + +- Live microphone-dependent dictation, meeting/overlap and hardware paste-back + checks were explicitly skipped at the contributor's request: no input device + is connected. Bluetooth/AirPods/Zoom behavior is not claimed. +- No representative accented-English quality study; the motivation is not a + claim that v2 improves accuracy for all accents. +- Whisper Turbo's first cold import did not finish while model preparation + exceeded the existing wait budget. Retrying after Ready passed. This is + disclosed, not asserted to be a v2 regression or silently treated as a pass. +- Earlier independent full-base and incremental reviews found no outstanding + must-fix. Rejected review suggestion: delete legacy v2 cache unconditionally. + Historical FluidAudio 0.7.9 inspection showed it already used the compatible + modern layout, so guarded migration/preservation is safer than data deletion. +- Keep the PR draft until human review. No related issue currently exists; + this branch-local workpad supplies the agent-workpad context without inventing + an issue or publishing one on the contributor's behalf. diff --git a/.agent-review/visuals/parakeet-v2-settings.jpg b/.agent-review/visuals/parakeet-v2-settings.jpg new file mode 100644 index 0000000000000000000000000000000000000000..97d8210d39b11c983ce1737765499d1c87237de2 GIT binary patch literal 62712 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 iSrbTd7Yb8D%07`=R@=6d2)ZJeN= Date: Tue, 8 Sep 2026 07:51:00 -0700 Subject: [PATCH 5/6] Qualify related issue status in contribution workpad --- .agent-review/parakeet-v2-workpad.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agent-review/parakeet-v2-workpad.md b/.agent-review/parakeet-v2-workpad.md index 83fc85a53..238ef3414 100644 --- a/.agent-review/parakeet-v2-workpad.md +++ b/.agent-review/parakeet-v2-workpad.md @@ -95,6 +95,6 @@ or recognize grants. They do not imply a live-capture pass. must-fix. Rejected review suggestion: delete legacy v2 cache unconditionally. Historical FluidAudio 0.7.9 inspection showed it already used the compatible modern layout, so guarded migration/preservation is safer than data deletion. -- Keep the PR draft until human review. No related issue currently exists; +- Keep the PR draft until human review. No related issue has been identified for this contribution; this branch-local workpad supplies the agent-workpad context without inventing an issue or publishing one on the contributor's behalf. From bd7e57586c23eb49d097a930af18e00bd4f4d05b Mon Sep 17 00:00:00 2001 From: Nick Kedev Date: Tue, 8 Sep 2026 10:14:06 -0700 Subject: [PATCH 6/6] Keep Parakeet contribution documentation focused --- .agent-review/parakeet-v2-workpad.md | 100 --------------------------- README.md | 4 +- 2 files changed, 2 insertions(+), 102 deletions(-) delete mode 100644 .agent-review/parakeet-v2-workpad.md diff --git a/.agent-review/parakeet-v2-workpad.md b/.agent-review/parakeet-v2-workpad.md deleted file mode 100644 index 238ef3414..000000000 --- a/.agent-review/parakeet-v2-workpad.md +++ /dev/null @@ -1,100 +0,0 @@ -# Parakeet v2 contribution workpad - -## Scope and ownership - -Add an opt-in English-only Parakeet TDT v2 model through the existing shared -selector. Parakeet v3 remains the default, both Whisper choices remain available, -and unknown stored preferences retain their existing fallback. - -This is one model capability, not a general Speech refactor. Support owns the -persisted identity/cache metadata; Speech owns the runtime. Meetings continue -to use `MeetingSTTAdapter` and the existing router. No Core library, capture -device, permission, entitlement, telemetry payload, release, or update changes. - -The lifecycle changes are necessary because v2 and v3 share one engine: selection -must not unload active inference or let canceled native loads overlap. Concrete -foreground leases defer selection until the last user releases the runtime; -generation checks reject stale callbacks; successor initialization waits for -canceled work and cleanup to drain. Existing lifecycle states remain canonical. - -## Compatibility and model provenance - -- Uses the existing pinned FluidAudio 0.15.4 model distribution and v2 API; - no new model host, dependency, checksum bypass, or entitlement. -- V2 requires its own compiled joint model and vocabulary. Supported canonical - and compatible legacy v2 caches are preserved; migration refuses symlinks, - merging, and overwriting an existing destination. -- Cache completeness, migration, coexistence, and re-download eligibility are - fixture-tested; personal caches were not deleted for testing. -- Saved v2 artifacts use `parakeet_v2_local`; v3 keeps `parakeet_local` and its - existing footer. Capture-format docs, reader smoke coverage, and the QA engine - allowlist recognize the new value without weakening unknown-engine rejection. - -## Automated proof - -Implementation tested at `2c6e2bc1`, against upstream base -`792d82e159b2f5546e2b474d4a3100a4fe6fa442`, on Apple Silicon with Xcode 26.6 -and Metal Toolchain 17F109. Subsequent evidence-only files do not change runtime. - -- `python3 scripts/dev/agent-context.py --base `: owner/matrix routing checked. -- `bash scripts/dev/agent-preflight.sh`: pass. -- `bash build-deps.sh --force`: pass, including Metal shaders. -- `bash build.sh --no-open`: pass, including isolated launch and bundle budget. -- `bash run-tests.sh`: 12,746 assertions pass. -- `bash run-integration-smoke.sh`: pass, including 45 production-lifecycle - executor assertions and seven recovery-merge tests. -- `python3 scripts/dev/check-build-source-lists.py`: pass. -- `bash run-e2e-smoke.sh`: pass. -- `bash -n run-integration-smoke.sh` and its canonical entrypoint: pass. -- `swift test --package-path Tools/TranscriptedQA`: 67 tests pass. -- Additional Core package proof: 1,079 tests, 13 existing skips, zero failures. -- `bash scripts/ops/transcripted-qa-bench.sh --mode full --strict-artifacts`: - 15/15 pass, exit 0. Final post-GUI artifact validation: 94 pass, zero fail, - seven warnings (absent dictations and optional capture-quality metadata). -- `TRANSCRIPTED_RUNTIME_BUDGET=1 bash build.sh --no-open`: exit 0; bundle 88.5 MiB, - launch-to-interactive 937 ms against 3000 ms. **No live dictation samples:** - latency/RTF percentiles are unavailable, not measured passing values. The - ambient log spans earlier runs and long idle intervals; it is not a controlled - model-load benchmark. - -The lifecycle harness compiles the actual production executor against delayed -fake dependencies and scaffold engine state. It covers joining, supersession, -late callbacks, retries/watchdog, active-use admission and cleanup ordering. -It is not hardware, real-engine activity-callback, or full-router proof by itself. - -## Actual app checks - -- Selected v2 in Settings; verified English-only copy and all four picker options. -- Downloaded/prepared each real model and imported the same local synthetic - English fixture through the native picker. All four saved expected text with - their correct concrete engine identifiers. -- Switched back to cached v2 and observed Ready. Relaunch preserved selection. -- During a 48:33 repeated-synthetic v2 import, selected v3. The saved job stayed - v2; logs show v3 initialization only after the job finished and cleaned up. -- Exact-dependency cached v2/v3 inference also passed with process networking - denied. A network-denied full-app run was not separately performed. - -![Actual development Settings with v2 selected](visuals/parakeet-v2-settings.jpg) - -Screenshot captured from the actual development app on 2026-09-07. It contains -only Settings, no transcript text, identities, paths or private user content. -The permission warnings are real: this ad-hoc build did not consistently retain -or recognize grants. They do not imply a live-capture pass. - -## Limitations and review notes - -- Live microphone-dependent dictation, meeting/overlap and hardware paste-back - checks were explicitly skipped at the contributor's request: no input device - is connected. Bluetooth/AirPods/Zoom behavior is not claimed. -- No representative accented-English quality study; the motivation is not a - claim that v2 improves accuracy for all accents. -- Whisper Turbo's first cold import did not finish while model preparation - exceeded the existing wait budget. Retrying after Ready passed. This is - disclosed, not asserted to be a v2 regression or silently treated as a pass. -- Earlier independent full-base and incremental reviews found no outstanding - must-fix. Rejected review suggestion: delete legacy v2 cache unconditionally. - Historical FluidAudio 0.7.9 inspection showed it already used the compatible - modern layout, so guarded migration/preservation is safer than data deletion. -- Keep the PR draft until human review. No related issue has been identified for this contribution; - this branch-local workpad supplies the agent-workpad context without inventing - an issue or publishing one on the contributor's behalf. diff --git a/README.md b/README.md index 0ee03f434..2393521ab 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,8 @@ Nothing. It's MIT-licensed open source. No account, no trial, no "pro" tier. **How accurate is it?** Good enough to search and quote. Parakeet V3 is the multilingual default; -Parakeet V2 is an English-only choice in Settings → Model. Whisper is also -available, and a custom dictionary +Parakeet V2 is an English-only choice in Settings → Model. +Whisper is available as an advanced option, and a custom dictionary keeps names, acronyms, and project jargon spelled right. Speaker review cleans up who-said-what after shared-mic meetings.