From f6c9e29ded22b88e4534f466f926c59c2cb8938d Mon Sep 17 00:00:00 2001 From: Samuel Alvarez Date: Tue, 28 Jul 2026 22:17:08 -0500 Subject: [PATCH 1/2] feat: pick the transcription model from a configured language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parakeet v2 is English-only and fails silently on everything else. It doesn't reject Spanish audio — it forces it through English phonetics and writes a plausible transcript with no error and no warning, which nobody who doesn't speak English can catch. FluidAudio already ships v3, multilingual across 25 European languages, so this is configuration rather than integration. transcription.language selects the model, not just a hint: "en" stays on v2, which scores better on English, and anything else moves to v3. Default "en" preserves today's behaviour and doesn't push a second ~600 MB download on anyone who never asked for another language. The three languages FluidAudio knows but v3 isn't trained on warn rather than passing quietly — a wrong-language transcript arriving unannounced is the exact failure this setting exists to prevent. --- README.md | 20 +++- Sources/quill/Config.swift | 16 +++- Sources/quill/Doctor.swift | 11 ++- .../quill/Transcription/ParakeetEngine.swift | 91 +++++++++++++++++-- .../TranscriptionCoordinator.swift | 7 +- 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 14192fa..eaec48c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ Core ML port — roughly 20 seconds per hour of audio on Apple Silicon. Models whether they're already cached so you're never downloading after an important meeting. +Recording in another language? Set `transcription.language` (see +[Config](#config)) and quill runs **Parakeet TDT 0.6B v3** instead — +multilingual across 25 European languages. + 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 start a new recording while the last one transcribes. Unfinished jobs resume @@ -75,7 +79,7 @@ Optional, at `~/.config/quill/config.json`: ```json { "recordings_dir": "~/Recordings", - "transcription": { "enabled": true, "engine": "parakeet" }, + "transcription": { "enabled": true, "engine": "parakeet", "language": "en" }, "on_stop": "my-hook" } ``` @@ -83,6 +87,16 @@ 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.language` — two-letter code (`"es"`, `"fr"`, `"de"`, …) or + `"auto"` to let the model detect it. Default `"en"`. This picks the model, + not just a hint: `"en"` runs Parakeet v2, English-only and the better + English transcript; anything else runs v3, multilingual across 25 European + languages (the [full list](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) + is NVIDIA's). Worth setting even though it's opt-in — v2 doesn't *reject* + Spanish audio, it forces it through English phonetics and writes plausible + nonsense. Each version caches separately, so the first run after a change + pays for another ~600 MB download and the model you were on stays put. A + change applies to the next batch of transcriptions, not one already running. - `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 +135,7 @@ 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 is English-only, and it fails *silently* on other languages — + set `transcription.language` to move to v3 before recording in one. - 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..59eaac2 100644 --- a/Sources/quill/Config.swift +++ b/Sources/quill/Config.swift @@ -4,7 +4,7 @@ import Foundation /// /// { /// "recordings_dir": "~/Recordings", -/// "transcription": { "enabled": true, "engine": "parakeet" }, +/// "transcription": { "enabled": true, "engine": "parakeet", "language": "en" }, /// "mic_voice_processing": true, /// "on_stop": "my-hook" /// } @@ -44,6 +44,20 @@ enum Config { transcription()?["engine"] as? String ?? "parakeet" } + /// Spoken language of your recordings as a two-letter code — "es", "fr", + /// "de", … — or "auto" to let the model detect it. Default "en". + /// + /// This selects the model, not just a hint: "en" runs Parakeet v2, which + /// is English-only; anything else runs v3, multilingual across 25 + /// European languages. Default "en" because it's the better English + /// transcript and it doesn't push a second ~600 MB download on anyone who + /// never asked for another language. The cost of getting this wrong is + /// asymmetric and silent — v2 doesn't reject Spanish audio, it forces it + /// through English phonetics and writes plausible nonsense. + static func transcriptionLanguage() -> String { + transcription()?["language"] as? String ?? "en" + } + 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..6ae23fb 100644 --- a/Sources/quill/Doctor.swift +++ b/Sources/quill/Doctor.swift @@ -77,7 +77,9 @@ enum DoctorReport { } /// Never discover a missing model after an important meeting: report - /// whether the parakeet models are already in FluidAudio's cache. + /// whether the parakeet models are already in FluidAudio's cache. Which + /// models those are depends on the configured language, and each version + /// caches separately — so changing the language reopens this check. static func checkTranscription() -> Check { guard Config.transcriptionEnabled() else { return Check( @@ -86,13 +88,14 @@ enum DoctorReport { remediation: nil ) } - let cache = AsrModels.defaultCacheDirectory(for: .v2) - if AsrModels.modelsExist(at: cache, version: .v2) { + let variant = ParakeetEngine.variant(for: Config.transcriptionLanguage()) + let cache = AsrModels.defaultCacheDirectory(for: variant.version) + if AsrModels.modelsExist(at: cache, version: variant.version) { return Check(name: "transcription", status: .ok, remediation: nil) } return Check( name: "transcription", - status: .warn("parakeet models not downloaded (~600 MB)"), + status: .warn("\(variant.model) not downloaded (~600 MB)"), remediation: "downloads automatically on first transcription — record a short test session while online" ) } diff --git a/Sources/quill/Transcription/ParakeetEngine.swift b/Sources/quill/Transcription/ParakeetEngine.swift index ff6cad5..5a6943b 100644 --- a/Sources/quill/Transcription/ParakeetEngine.swift +++ b/Sources/quill/Transcription/ParakeetEngine.swift @@ -2,10 +2,10 @@ 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. The configured language picks which model runs — see `Variant`. actor ParakeetEngine: TranscriptionEngine { enum EngineError: Error, CustomStringConvertible { case notPrepared @@ -21,14 +21,82 @@ actor ParakeetEngine: TranscriptionEngine { } } + /// A model version plus the decoder hint it's run with. Each version has + /// its own cache directory, so switching languages costs another download + /// but never invalidates the model you were using. + struct Variant: Sendable { + let version: AsrModelVersion + /// Recorded as transcript.json provenance. + let model: String + /// Language hint for script-aware token filtering — v3 only, silently + /// ignored by v2. It drops top-K candidates outside the language's + /// script, so it earns its keep for Cyrillic or Greek and does close + /// to nothing for Latin-script languages like Spanish, which share + /// their alphabet with English. v3 does the real work by itself. + let hint: Language? + } + + private static let english = Variant( + version: .v2, model: "parakeet-tdt-0.6b-v2-coreml", hint: nil + ) + + private static func multilingual(_ hint: Language?) -> Variant { + Variant(version: .v3, model: "parakeet-tdt-0.6b-v3-coreml", hint: hint) + } + + /// The 25 languages v3 is trained on. FluidAudio's `Language` is a wider + /// set — it drives script filtering, not model coverage, so it also + /// carries Belarusian, Bosnian and Serbian, which NVIDIA doesn't list. + private static let covered: Set = [ + .bulgarian, .croatian, .czech, .danish, .dutch, .english, .estonian, + .finnish, .french, .german, .greek, .hungarian, .italian, .latvian, + .lithuanian, .maltese, .polish, .portuguese, .romanian, .slovak, + .slovenian, .spanish, .swedish, .russian, .ukrainian, + ] + + /// Resolve a configured language code to a model. `"en"` stays on v2, + /// which is English-only and scores better on English than v3; `"auto"` + /// runs v3 with no hint and lets it detect; any covered language runs v3 + /// with the hint. + /// + /// Both off-ramps say so on stderr. An unknown code falls back to English + /// rather than transcribing with a model nobody chose; a known but + /// uncovered one still runs — Bosnian and Serbian are close enough to + /// covered neighbours to be worth attempting — but never silently, since + /// a model quietly transcribing a language it was never trained on is the + /// exact failure this setting exists to prevent. + static func variant(for language: String) -> Variant { + let code = language.lowercased() + if code == "en" { return english } + if code == "auto" { return multilingual(nil) } + guard let hint = Language(rawValue: code) else { + warn("unknown transcription language \"\(language)\" — using en") + return english + } + if !covered.contains(hint) { + warn("\(hint.rawValue) isn't one of parakeet v3's 25 languages — " + + "transcribing anyway, but expect a worse transcript") + } + return multilingual(hint) + } + + private static func warn(_ message: String) { + FileHandle.standardError.write(Data("warning: \(message)\n".utf8)) + } + nonisolated let name = "parakeet" - nonisolated let model = "parakeet-tdt-0.6b-v2-coreml" + nonisolated var model: String { selected.model } + private nonisolated let selected: Variant private var manager: AsrManager? + init(language: String) { + selected = Self.variant(for: language) + } + func prepare() async throws { guard manager == nil else { return } - let models = try await AsrModels.downloadAndLoad(version: .v2) + let models = try await AsrModels.downloadAndLoad(version: selected.version) let manager = AsrManager() try await manager.loadModels(models) self.manager = manager @@ -51,7 +119,9 @@ actor ParakeetEngine: TranscriptionEngine { } var state = try TdtDecoderState() - let result = try await manager.transcribe(audio, decoderState: &state) + let result = try await manager.transcribe( + audio, decoderState: &state, language: selected.hint + ) let words = buildWordTimings(from: result.tokenTimings ?? []) guard !words.isEmpty else { @@ -69,8 +139,11 @@ actor ParakeetEngine: TranscriptionEngine { } /// Group word timings into readable segments: break on sentence-ending - /// punctuation (parakeet v2 emits punctuation), a silence gap, or a hard - /// length cap so a run-on speaker still wraps. + /// punctuation (parakeet emits punctuation), a silence gap, or a hard + /// length cap so a run-on speaker still wraps. The suffix test survives + /// the move to v3: Spanish opens with ¿ and ¡, but those are prefixes. + /// Observed on Spanish audio, v3 doesn't emit them at all and closes + /// questions with "." rather than "?", so segments break on the period. private static func segments(from words: [WordTiming]) -> [TranscriptSegment] { var out: [TranscriptSegment] = [] var current: [WordTiming] = [] diff --git a/Sources/quill/Transcription/TranscriptionCoordinator.swift b/Sources/quill/Transcription/TranscriptionCoordinator.swift index 5300fbd..0f39117 100644 --- a/Sources/quill/Transcription/TranscriptionCoordinator.swift +++ b/Sources/quill/Transcription/TranscriptionCoordinator.swift @@ -140,6 +140,11 @@ actor TranscriptionCoordinator { log(dir, "done — \(merged.count) segments") } + /// The engine is built once per drain and released when the queue empties, + /// so editing `transcription.language` takes effect on the next batch, + /// never mid-batch. That's the wanted behaviour: a drain that started in + /// one language finishes in it, and every transcript in the batch records + /// the model that actually produced it. private func preparedEngine() async throws -> TranscriptionEngine { if let engine { return engine } let configured = Config.transcriptionEngine() @@ -148,7 +153,7 @@ actor TranscriptionCoordinator { "warning: unknown transcription engine \"\(configured)\" — using parakeet\n".utf8 )) } - let engine = ParakeetEngine() + let engine = ParakeetEngine(language: Config.transcriptionLanguage()) try await engine.prepare() self.engine = engine return engine From 08f423c17660dc7be4d9cd8311e689c9ffb993f8 Mon Sep 17 00:00:00 2001 From: Samuel Alvarez Date: Tue, 28 Jul 2026 22:17:14 -0500 Subject: [PATCH 2/2] docs: feat-001 multilingual transcription, with the v2/v3 comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same 12 s of Spanish through both paths. v3 returned it near-verbatim, two errors in ~40 words. v2 returned invented English — "how is the migration of the people in transcription" — logging "done, 2 segments" with no error and a well-formed transcript.md. Model selection, download, provenance and segmentation are verified. v3's quality on real meeting audio — noise, overlap, accents — is not, so the issue stays open rather than closing like rca-001 did. --- .../feat-001-multilingual-transcription.md | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 .issues/feat-001-multilingual-transcription.md diff --git a/.issues/feat-001-multilingual-transcription.md b/.issues/feat-001-multilingual-transcription.md new file mode 100644 index 0000000..efc05b8 --- /dev/null +++ b/.issues/feat-001-multilingual-transcription.md @@ -0,0 +1,306 @@ +--- +title: "Multilingual transcription via Parakeet TDT v3" +date: 2026-07-28 +status: implemented +affects: "transcription — model and language selection" +--- + +## Summary + +Quill records audio in any language correctly, but it does not **transcribe** +it: the engine is pinned to Parakeet TDT 0.6B **v2**, which is English-only. +The failure is silent — v2 does not reject Spanish audio, it forces it through +English phonetics and writes a `transcript.md` full of plausible nonsense. + +Multilingual capability is **already installed** in the dependency tree. +FluidAudio 0.15.5 (pinned in `Package.resolved`) exposes `AsrModelVersion.v3` += `parakeet-tdt-0.6b-v3-coreml`, multilingual across 25 European languages, +Spanish among them. This is configuration work, not integration work. + +--- + +## Context: current architecture + +A single Swift binary (SPM, no `.app` bundle) running as an `.accessory` +menu-bar daemon. One click records two independent tracks; on stop they are +transcribed on-device and merged. + +``` +Quill.swift ── AppController (@MainActor) + ├─ MenuBarController NSStatusItem, inline SVG + │ + ├─ START ─► RecordingSession → ~/Recordings// + │ ├─ SystemAudioRecorder → system.caf (Core Audio process tap) + │ └─ MicRecorder → mic.caf (AVAudioEngine) + │ + └─ STOP ─► meta.json (timestamps + per-track start_offset_ms) + │ + └─► TranscriptionCoordinator (actor, serial queue) + ├─ ParakeetEngine.transcribe(mic.caf) → "me" + ├─ ParakeetEngine.transcribe(system.caf) → "them" + ├─ shift by offset, sort by time + ├─ transcript.json + transcript.md (atomic) + └─ on_stop hook + notification +``` + +Design decisions that matter for this work: + +- **Two tracks = free diarization.** mic = `me`, system = `them`, with no + speaker-identification model. +- **The filesystem is the queue.** A session with `meta.json` but no + `transcript.json` is pending. `resumePending()` rescans on launch. +- **The engine lives behind a protocol.** `TranscriptionEngine` + (`Sources/quill/Transcription/TranscriptionEngine.swift:14`) exposes + `prepare()` / `transcribe()` / `release()` plus `name` and `model` as + provenance. Nothing outside that protocol knows which language is being + spoken — the blast radius of this change is one file and a half. +- **The engine loads lazily and is released when the queue drains** + (`TranscriptionCoordinator.swift:91`), so ~600 MB of weights don't sit idle. +- **CAF, not m4a.** No finalization pass needed: a crash mid-meeting doesn't + lose what was already written. + +--- + +## Verified finding + +`Sources/quill/Transcription/ParakeetEngine.swift:31` asks for `version: .v2`. + +Verified by cloning `FluidInference/FluidAudio` at tag **v0.15.5**, the exact +revision pinned in `Package.resolved`: + +| Check | Result | +|---|---| +| `AsrModelVersion` (`Sources/FluidAudio/ASR/Parakeet/SlidingWindow/TDT/AsrModels.swift:5`) | `.v2`, `.v3`, `.tdtCtc110m`, `.tdtJa` | +| v3 in its own docs (`Documentation/Models.md:14`) | *"Batch speech-to-text, 25 European languages (0.6B params). **Default ASR model**"* | +| `downloadAndLoad` default | `version: AsrModelVersion = .v3` — quill asks for `.v2` explicitly | +| Signature quill already calls | `transcribe(_ url:decoderState:language:)` — the third argument is optional, so **the current code compiles unchanged against v3** | +| `buildWordTimings` (used at `ParakeetEngine.swift:56`) | global function in `AsrTypes.swift:182`, model-agnostic | +| `AsrManager.loadModels(_ models: AsrModels)` | takes the struct that already carries the version — the manager is untouched | +| `Language` (`Sources/FluidAudio/Shared/TokenLanguageFilter.swift:4`) | includes `case spanish = "es"` | + +There are no traps in the v2 → v3 migration. + +### Important nuance: the `language` hint does less than it looks + +From the documentation on the signature itself: + +> *"Optional language hint for **script-aware token filtering** (v3 only). When +> set, top-K tokens that don't match the language's **script** are skipped."* + +And in `TokenLanguageFilter.swift:41`, Spanish and English sit in the same +group: `.latin`. So **the hint does not condition the model on Spanish**, it +only discards candidates from another alphabet. That is genuinely useful for +Russian or Greek; for Spanish its effect is close to nil. v3 does the real work +on its own. + +Design consequence: **don't build UI or configuration that promises a +fine-grained "Spanish mode"**, because the engine doesn't deliver one. + +--- + +## Project guidelines (extracted from history) + +There is no `CLAUDE.md` or `CONTRIBUTING.md`. The guidelines live in the +decisions, and they are consistent: + +**1. UI gets trimmed, not extended.** + +- `60c1571 revert: drop the floating recording overlay` — deleted 158 lines and + all of `UI/RecordingOverlay.swift`. Reason: *"macOS already shows a mic + indicator… drawing our own pill over the desktop was noise."* +- `d33beb3 fix: keep the menu bar to just the feather — no elapsed counter` + +The menu has five items and **none of them is a preference**: three actions +(record, open folder, quit) and two disabled status labels. + +**2. Every option lives in `config.json`.** The pattern was set in `7b76df8` +and `8ab6ebb`, identical each time: a new key, an opinionated default, and a +README paragraph explaining the trade-off. The entire diff of `8ab6ebb` +(flipping the echo-cancellation default) was **10 lines across 2 files**, none +of them UI. + +**3. `Config` is read-only and uncached.** It reads from disk on every query +and never writes. That's what keeps there from being any state to synchronize. + +**4. Opinionated defaults, documented with their cost.** Each one carries the +"why" in its doc comment and in the commit body. + +**5. Doc comments explain the why and the failure they prevent**, not the what. + +**6. Single binary, no external resources.** Even the icon is an inline SVG +(`MenuBarController.swift:92`). + +**7. Conventional commits with a body explaining the reason.** + +### Rejected: a visual language picker + +A submenu listing the 25 languages with a checkmark on the active one was +considered. **Rejected**, for three reasons: + +- It contradicts guideline 1, with two precedents of UI deleted as noise. +- It would require `Config` to write, breaking guideline 3 and introducing the + first source of persistent state written by the app. +- **A meeting's language doesn't change between sessions** — it's a property of + the user, not of the meeting. A picker optimizes for frequent switching. + Compare `mic_voice_processing`, which genuinely is context-dependent + (headphones vs. speakers) and still lives in JSON. + +--- + +## Design + +A single knob, inside the `transcription` block that already exists: + +```json +{ + "transcription": { "enabled": true, "engine": "parakeet", "language": "en" } +} +``` + +Language → model mapping, internal: + +| `language` | Model | Reason | +|---|---|---| +| `"en"` (default) | Parakeet v2 | better WER on English; preserves current behaviour | +| any of the other 25 | Parakeet v3 + hint | multilingual | +| `"auto"` | Parakeet v3, no hint | autodetection | + +**Default `"en"`.** It preserves today's behaviour exactly, doesn't force a +600 MB download on existing users, and makes other languages opt-in — the same +reasoning that made echo cancellation opt-in in `8ab6ebb`. + +One knob (language) is preferred over two (model + language): users think in +languages, not in model versions, and every value changes something real. + +### Plan by file + +| File | Change | +|---|---| +| `Sources/quill/Config.swift` | `transcriptionLanguage() -> String` alongside the other `transcription` accessors, same doc-comment style | +| `Sources/quill/Transcription/ParakeetEngine.swift` | language → (`AsrModelVersion`, `Language?`) mapping; take it in `init`; pass `language:` to `transcribe`; make `model` dynamic provenance (hardcoded today at `:25`) | +| `Sources/quill/Transcription/TranscriptionCoordinator.swift` | `preparedEngine()` (`:143`) builds the engine — read the config there | +| `Sources/quill/Doctor.swift` | `checkTranscription()` (`:81`) parameterizes the version instead of pinning `.v2` at `:89-90` | +| `README.md` | a paragraph with the trade-off, in the style of the `mic_voice_processing` one | + +Expected size: the size of `8ab6ebb` — dozens of lines across 4 files plus the +README. + +### What the architecture gives for free + +Two problems that showed up under the UI approach simply **disappear**: + +- *Invalidating the cached engine when the model changes*: because `Config` + reads from disk on every query and the coordinator releases the engine when + the queue drains, editing the JSON already takes effect on the next drain. + All that's needed is to **document** that a change mid-queue doesn't affect + the batch in flight — which is the desirable behaviour anyway (consistency + within a batch). +- *A progress-bar UI for the download*: `quill doctor` already exists for + exactly this (*"Never discover a missing model after an important + meeting"*). It only needs to look at the selected model's cache. + +--- + +## Risks and unknowns + +1. **v3's real-world quality is still unmeasured.** v3 traded some English + accuracy for multilingual coverage. The test below uses clean TTS, which + says nothing about noise, overlapping speech or accents. **This remains the + one serious unknown**, and it is why this issue stays open rather than + closing. +2. ~~**v3 download size: unverified.**~~ Resolved: 470 MB (v2 is 452 MB). + Separate caches, they coexist without clobbering each other. +3. ~~**None of this has been compiled.**~~ Resolved: it builds and runs. +4. **Segmentation: verified, with a new nuance.** Breaking on a `.` / `?` / `!` + suffix survives, but not for the predicted reason. On Spanish audio v3 emits + **no `¿` or `¡` at all** and closes questions with `.` rather than `?`. + Segments break on the period; the result is right, the premise was wrong. +5. **Three languages FluidAudio knows and v3 doesn't cover.** The `Language` + enum has 28 cases because it partitions *scripts*, not model coverage: it + includes `bs`, `be` and `sr`, which NVIDIA doesn't list among the 25. They + are transcribed anyway — they sit close to covered neighbours — but never + silently: a warning goes to stderr. + +--- + +## Verification + +Tested without recording a meeting: a fabricated session (`meta.json` + +`mic.caf`) under a custom root, which `resumePending()` picks up on its own via +`quill run --out `. Audio: 12.4 s of Spanish synthesized with +`say -v "Mónica"` (es_ES), the same file down both paths. + +**`transcription.language: "es"` → v3** + +> Buenos días a todos, como veis la migración del pipeline de transcripción. +> Creo que deberíamos añadir suporte multilingüe cuanto antes, porque el modelo +> actual solo entiende inglés, y eso es un problema serio para nuestras +> reuniones en español. + +Two errors in ~40 words (`como` missing its accent, `suporte` for `soporte`). +Accents, ñ and diaeresis all correct. + +**`transcription.language: "en"` → v2, the same audio** + +> How is the migration of the people in transcription? +> I think that we have a support of multilingual […] because the model actual +> zoning and less, and that is a problem for our union in Spanish. + +`transcribe.log` recorded `done — 2 segments`. No error, no warning, a +well-formed `transcript.md`. The silent failure from the summary, reproduced — +including an obscenity invented out of "cuanto antes". + +Also verified: + +- **Language → model mapping** across all 29 possible values: 25 pass with no + warning (English via v2, the other 24 via v3), `auto` passes, `bs`/`be`/`sr` + warn and run, an unknown code warns and falls back to English. +- **Provenance**: `transcript.md` records the model that actually ran. +- **Effective version**: v3 loads an 8192-token vocabulary; v2 loads 1024. + +--- + +## Code observations (independent of this feature) + +Found while reviewing; none of them blocks the work above. + +1. **`Package.swift:9` asks for FluidAudio `from: "0.7.0"` while + `Package.resolved` pins 0.15.5.** In SwiftPM, `from:` on a 0.x version + resolves up to `<1.0.0`, and within 0.x SemVer offers no protection against + breaking changes: a clean `swift package update` can pull an incompatible + API. Suggested: `.upToNextMinor(from: "0.15.0")`. *(That very version jump is + what brought multilingual support.)* +2. **`MicRecorder` is `@unchecked Sendable` over genuinely shared state.** + `firstBufferAt`, `livenessPeak` and `livenessFrames` are written from the tap + callback and read from main (`RecordingSession.swift:57`). Same in + `SystemAudioRecorder.swift:141`. Benign in practice, but a real data race the + compiler isn't checking. +3. **`Config.load()` reparses the JSON on every query**, several times per + session. Trivial cost, but it lets the config change mid-session + inconsistently. *(Note: this same property is what makes a language change + apply on its own — see above. Don't "fix" it without weighing that.)* +4. **Input-device changes during recording are unhandled.** Unplugging + headphones mid-meeting can stop `AVAudioEngine` and truncate the mic track + with no warning. The liveness check only covers the first second. +5. **`AVAudioFile.write` inside the audio IOProc** + (`SystemAudioRecorder.swift:148`) — disk I/O in a latency-sensitive + callback. It runs on its own `DispatchQueue`, so it's defensible, but it's + the likely cause if glitches ever show up. +6. **No tests and no CI.** For a project whose costliest bug was a silent + framework failure (rca-001), a test over `MicRecorder`'s format and liveness + would have the highest return — though the hard part is inherently untestable + without hardware. +7. **Transcript-level echo suppression is still unimplemented** (proposed at + `rca-001:96`). It's the safety net for paths where AEC doesn't work. + +--- + +## References + +- `.issues/rca-001-voice-processing-silent-mic.md` — RCA of the silent mic; + context for why `mic_voice_processing` is opt-in. +- FluidAudio v0.15.5 — `Documentation/Models.md`, `AsrModels.swift`, + `TokenLanguageFilter.swift`, `AsrManager.swift`. +- v3 model: `FluidInference/parakeet-tdt-0.6b-v3-coreml` (HuggingFace). +- Alternatives in the same enum, not evaluated: `.tdtCtc110m` (110M, smaller + and faster, English), `.tdtJa` (Japanese).