diff --git a/README.md b/README.md index 13488d4..45943b2 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,16 @@ written is still readable. ## Transcription -Built in, on-device, automatic. The default engine is **Parakeet TDT 0.6B v2** -(English) via [FluidAudio](https://github.com/FluidInference/FluidAudio)'s -Core ML port — roughly 20 seconds per hour of audio on Apple Silicon. Models -(~600 MB) download once on first transcription; `quill doctor` tells you -whether they're already cached so you're never downloading after an important -meeting. +Built in, on-device, automatic. The default engine is **Parakeet TDT 0.6B v3** +via [FluidAudio](https://github.com/FluidInference/FluidAudio)'s Core ML port — +roughly 20 seconds per hour of audio on Apple Silicon. v3 is the multilingual +model (25 European languages plus Japanese) and detects the spoken language on +its own, so there's nothing to configure for a non-English meeting. + +Set `transcription.model` to `"v2"` for the English-only model, which has +marginally higher recall on English. Models (~600 MB) download once on first +transcription; `quill doctor` tells you whether they're already cached so +you're never downloading after an important meeting. Each track is transcribed separately, shifted by its start offset so both share one clock, and merged by timestamp. Jobs run in a serial queue — you can @@ -75,7 +79,7 @@ Optional, at `~/.config/quill/config.json`: ```json { "recordings_dir": "~/Recordings", - "transcription": { "enabled": true, "engine": "parakeet" }, + "transcription": { "enabled": true, "engine": "parakeet", "model": "v3" }, "on_stop": "my-hook" } ``` @@ -83,6 +87,9 @@ Optional, at `~/.config/quill/config.json`: - `recordings_dir` — where sessions land. Resolution order: `--out` flag > config > `~/Recordings`. - `transcription.enabled` — set `false` to just record. +- `transcription.model` — parakeet model version: `"v3"` (default, + multilingual, self-detecting) or `"v2"` (English-only, marginally higher + recall on English). An unrecognized value warns and falls back to v3. - `mic_voice_processing` — Apple's echo cancellation on the mic (default off). Set `true` when recording meetings through the speakers, so playback doesn't bleed into the mic track and get transcribed twice as "me". The trade: while @@ -121,7 +128,8 @@ quill install --uninstall per-process picker if it bothers you). - If recordings come out silent, check System Settings → Privacy & Security → Screen & System Audio Recording. -- Parakeet v2 is English-only. Other languages will come with the Whisper - engine. +- Parakeet v2 (`"model": "v2"`) is English-only — it will happily return + English-looking nonsense for other languages rather than failing. v3 is the + default for that reason. - The binary embeds its Info.plist (`__TEXT,__info_plist`) so TCC can attribute permissions to quill itself when running as a LaunchAgent. diff --git a/Sources/quill/Config.swift b/Sources/quill/Config.swift index 5db668f..df0f4c8 100644 --- a/Sources/quill/Config.swift +++ b/Sources/quill/Config.swift @@ -44,6 +44,13 @@ enum Config { transcription()?["engine"] as? String ?? "parakeet" } + /// Configured model for the engine. For parakeet: "v3" (multilingual, + /// default) or "v2" (English-only, marginally higher recall on English). + /// The engine warns and falls back for anything it doesn't recognize. + static func transcriptionModel() -> String { + transcription()?["model"] as? String ?? "v3" + } + private static func transcription() -> [String: Any]? { load()?["transcription"] as? [String: Any] } diff --git a/Sources/quill/Doctor.swift b/Sources/quill/Doctor.swift index 8af8125..ec1a3dd 100644 --- a/Sources/quill/Doctor.swift +++ b/Sources/quill/Doctor.swift @@ -86,8 +86,9 @@ enum DoctorReport { remediation: nil ) } - let cache = AsrModels.defaultCacheDirectory(for: .v2) - if AsrModels.modelsExist(at: cache, version: .v2) { + let version = ParakeetEngine.configuredVersion() + let cache = AsrModels.defaultCacheDirectory(for: version) + if AsrModels.modelsExist(at: cache, version: version) { return Check(name: "transcription", status: .ok, remediation: nil) } return Check( diff --git a/Sources/quill/Transcription/ParakeetEngine.swift b/Sources/quill/Transcription/ParakeetEngine.swift index ff6cad5..f51c360 100644 --- a/Sources/quill/Transcription/ParakeetEngine.swift +++ b/Sources/quill/Transcription/ParakeetEngine.swift @@ -2,10 +2,14 @@ import AVFoundation import FluidAudio import Foundation -/// Parakeet TDT 0.6B v2 (English) via FluidAudio's Core ML port. Models -/// download once into FluidAudio's managed cache (~600 MB); after that, -/// transcription runs entirely on-device at roughly 20 seconds per hour of -/// audio on Apple Silicon. +/// Parakeet TDT 0.6B via FluidAudio's Core ML port. Models download once into +/// FluidAudio's managed cache (~600 MB); after that, transcription runs +/// entirely on-device at roughly 20 seconds per hour of audio on Apple +/// Silicon. +/// +/// Two model versions, selected with `transcription.model`: v3 is multilingual +/// (25 European languages plus Japanese) and detects the spoken language +/// itself; v2 is English-only with marginally higher recall on English. actor ParakeetEngine: TranscriptionEngine { enum EngineError: Error, CustomStringConvertible { case notPrepared @@ -21,14 +25,37 @@ actor ParakeetEngine: TranscriptionEngine { } } + /// The configured model version, warning and falling back rather than + /// silently transcribing with one the user didn't ask for. Shared with + /// `quill doctor` so the cache check can't drift from what we download. + static func configuredVersion() -> AsrModelVersion { + switch Config.transcriptionModel() { + case "v3": return .v3 + case "v2": return .v2 + case let other: + FileHandle.standardError.write(Data( + "warning: unknown parakeet model \"\(other)\" — using v3\n".utf8 + )) + return .v3 + } + } + nonisolated let name = "parakeet" - nonisolated let model = "parakeet-tdt-0.6b-v2-coreml" + nonisolated let model: String + private let version: AsrModelVersion private var manager: AsrManager? + init(version: AsrModelVersion = ParakeetEngine.configuredVersion()) { + self.version = version + self.model = version == .v2 + ? "parakeet-tdt-0.6b-v2-coreml" + : "parakeet-tdt-0.6b-v3-coreml" + } + func prepare() async throws { guard manager == nil else { return } - let models = try await AsrModels.downloadAndLoad(version: .v2) + let models = try await AsrModels.downloadAndLoad(version: version) let manager = AsrManager() try await manager.loadModels(models) self.manager = manager @@ -69,7 +96,7 @@ actor ParakeetEngine: TranscriptionEngine { } /// Group word timings into readable segments: break on sentence-ending - /// punctuation (parakeet v2 emits punctuation), a silence gap, or a hard + /// punctuation (both parakeet versions emit it), a silence gap, or a hard /// length cap so a run-on speaker still wraps. private static func segments(from words: [WordTiming]) -> [TranscriptSegment] { var out: [TranscriptSegment] = []