Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .agent-review/visuals/parakeet-v2-settings.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
36 changes: 29 additions & 7 deletions Sources/Speech/ParakeetEngine.swift
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -96,6 +96,10 @@ class ParakeetEngine: ObservableObject {

// FluidAudio ASR
var asrManager: AsrManager?
var modelVariant: ParakeetModelVariant = .v3
var loadedModelVariant: ParakeetModelVariant?
var modelCleanupTask: Task<Void, Never>?
let modelTeardownGate = ParakeetModelTeardownGate()
var modelInitializationTask: Task<Void, Never>?
var modelInitializationGeneration: UInt64 = 0
var modelFilePrefetchTask: Task<URL, Error>?
Expand Down Expand Up @@ -131,6 +135,18 @@ class ParakeetEngine: ObservableObject {
var systemInputReconciliationTask: Task<Void, Never>?

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 }
Expand All @@ -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()
Expand Down Expand Up @@ -2442,6 +2461,7 @@ class ParakeetEngine: ObservableObject {
private func finishTranscription() {
isTranscribing = false
clearRecoveredRecordingTimeline(keepingCapacity: true)
finishDeferredModelTeardownIfIdle()
}

var hasActiveASRWork: Bool {
Expand All @@ -2457,6 +2477,7 @@ class ParakeetEngine: ObservableObject {

private func finishPureSampleTranscriptionActivity() {
pureSampleTranscriptionActivityCount = max(0, pureSampleTranscriptionActivityCount - 1)
finishDeferredModelTeardownIfIdle()
}

private func beginASRInference() async throws {
Expand Down Expand Up @@ -2489,6 +2510,7 @@ class ParakeetEngine: ObservableObject {
asrInferenceWaiters.resumeFirst()
return
}
finishDeferredModelTeardownIfIdle()
}

private func runASRInference(
Expand Down
87 changes: 85 additions & 2 deletions Sources/Speech/ParakeetModelInitDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -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<Bool, Never>] = [:]

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<Void, Never>,
after cleanup: Task<Void, Never>?
) -> Task<Void, Never> {
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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading