diff --git a/.agent-review/visuals/parakeet-v2-settings.jpg b/.agent-review/visuals/parakeet-v2-settings.jpg new file mode 100644 index 000000000..97d8210d3 Binary files /dev/null and b/.agent-review/visuals/parakeet-v2-settings.jpg differ diff --git a/README.md b/README.md index 66f568615..2393521ab 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 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. diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index a8fc5b45c..fd0cff000 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. @@ -96,6 +96,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? @@ -131,6 +135,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 } @@ -153,18 +169,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() @@ -2442,6 +2461,7 @@ class ParakeetEngine: ObservableObject { private func finishTranscription() { isTranscribing = false clearRecoveredRecordingTimeline(keepingCapacity: true) + finishDeferredModelTeardownIfIdle() } var hasActiveASRWork: Bool { @@ -2457,6 +2477,7 @@ class ParakeetEngine: ObservableObject { private func finishPureSampleTranscriptionActivity() { pureSampleTranscriptionActivityCount = max(0, pureSampleTranscriptionActivityCount - 1) + finishDeferredModelTeardownIfIdle() } private func beginASRInference() async throws { @@ -2489,6 +2510,7 @@ class ParakeetEngine: ObservableObject { asrInferenceWaiters.resumeFirst() 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 9caaffeb6..a777eee90 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,18 +96,21 @@ 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))) } - 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 ) { + let token = ParakeetModelWorkToken(variant: modelVariant, generation: generation) modelDownloadWatchdogTask?.cancel() modelDownloadWatchdogTask = Task { @MainActor [weak self] in while !Task.isCancelled { @@ -113,7 +127,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 +137,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 +164,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,26 +176,63 @@ 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 } - 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 @@ -208,11 +257,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 @@ -227,10 +276,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). @@ -255,53 +304,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 @@ -319,11 +376,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 } @@ -345,9 +407,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 { @@ -369,6 +433,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 { @@ -391,7 +456,7 @@ extension ParakeetEngine { @discardableResult func markCachedRuntimeModelIfAvailable() -> Bool { - guard let cachedModelPath = ModelCacheInventory.activeParakeetModelDirectory() else { + guard let cachedModelPath = ModelCacheInventory.activeParakeetModelDirectory(variant: modelVariant) else { return false } @@ -402,21 +467,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") @@ -427,9 +488,10 @@ extension ParakeetEngine { } } - func bundledParakeetModelPath() -> URL? { + func bundledParakeetModelPath(variant: ParakeetModelVariant = .v3) -> URL? { ParakeetBundledModelLayoutPolicy.resolveBundledModelPath( - resourcePath: Bundle.main.resourcePath + resourcePath: Bundle.main.resourcePath, + variant: variant ) } @@ -441,8 +503,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 } @@ -452,19 +523,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 0143678d5..e5fc1335a 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) } @@ -284,7 +303,7 @@ class STTRouter: ObservableObject { guard !isRecordingModelLoaded, !Task.isCancelled, ProcessInfo.processInfo.systemUptime < deadline else { return } let changes: AnyPublisher - if recordingModel == .parakeetTDTv3 { + if recordingModel.parakeetVariant != nil { changes = parakeetEngine.$modelDownloadState.dropFirst().map { _ in () }.eraseToAnyPublisher() } else { changes = whisperEngine.$modelDownloadState.dropFirst().map { _ in () }.eraseToAnyPublisher() @@ -353,7 +372,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) if !Task.isCancelled { lastEmptyTranscriptionReason = text == nil ? parakeetEngine.lastEmptyTranscriptionReason : nil @@ -450,7 +480,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( @@ -482,8 +517,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 @@ -498,8 +533,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 51e18461e..52479a554 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -1978,7 +1978,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/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..4ccc57339 --- /dev/null +++ b/Tests/Integration/ParakeetLifecycle/ExecutorSmoke.swift @@ -0,0 +1,223 @@ +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") + var secondFinished = false + let second = Task { + await engine.initialize(variant: .v2) + secondFinished = true + } + await settle() + check(!secondFinished, "same-variant initialization waits for the shared load") + await finishLoad("v2") + await first.value; await second.value + check(secondFinished, "same-variant initialization resumes when the shared load completes") + 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/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/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/Tests/STTRouterPolicyTests.swift b/Tests/STTRouterPolicyTests.swift index caf94eebe..dae74e15e 100644 --- a/Tests/STTRouterPolicyTests.swift +++ b/Tests/STTRouterPolicyTests.swift @@ -11,6 +11,49 @@ import Foundation func testSTTRouterPolicy() { + runSuite("Both Parakeet variants retain upstream deadline-bound model waiting") { + 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: "func waitForRecordingModelLoadProgress(until deadline:")! + let end = source.range(of: "func startRecording()", range: start.upperBound.. = [ "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 """ --- diff --git a/docs/capture-format.md b/docs/capture-format.md index 76c6ca75f..b484dca28 100644 --- a/docs/capture-format.md +++ b/docs/capture-format.md @@ -93,6 +93,20 @@ Written at initial save (all flat unless noted): | `total_word_count` | `1204` | | | `title` | `"Weekly Sync"` | Optional at save (imported audio, detected meetings); the restyle always writes one. | +The `transcription_engine` value identifies the concrete model used, not a +later picker selection. Current identifiers are additive within format version 1: + +| Identifier | Model | Raw transcript footer name | +| --- | --- | --- | +| `parakeet_local` | Parakeet TDT v3 (default) | `Parakeet` (preserved for compatibility) | +| `parakeet_v2_local` | Parakeet TDT v2 (English only) | `Parakeet V2` | +| `whisper_large_v3_turbo_local` | Whisper Large v3 Turbo | `Whisper Large V3 Turbo` | +| `whisper_large_v3_local` | Whisper Large v3 | `Whisper Large V3` | + +Readers should preserve unknown identifiers rather than assuming every local +capture uses Parakeet v3. The footer is descriptive; use the frontmatter key +for model identity. + Recording-health keys (optional, only when health info exists): | Key | Example | Notes | 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