Field notes · macOS · 2026
Transcriberr

Record, transcribe, diarize, rewrite — all on-device. No audio leaves the Mac unless you add a cloud engine yourself. Building it was hard in places I didn't expect.

Parakeet Whisper large-v3 Gemma · LiteRT-LM Dictation · any app People · by voice macOS 26+ Apple Silicon

The log as a recording

Every commit, first to latest, as one bar: taller means more lines changed. The gaps between bursts of work are trimmed, like silence. Under it, the diarization: which part of the app each commit touched. Drag across it, or press play.

Version
v5.0.2
Swift lines
50,161
Tests
519
Commit
156 of 156
v5.0.2 v5.0.2: the calendar invite helps name people People & voices

68 lessons, where they happened

  1. 01 · Part onepre-1.0

    What you integrate is a model-runtime pair

    Gemma under MLX turned a business meeting into a conversation about music production. The recorder was proven healthy first, then the decoder. Same weights on LiteRT-LM: word-perfect.

    151 dBSNR on a test tone. Isolate the layer before you blame it.
  2. 02 · Platformv1.0

    Signatures must agree

    Ran from Xcode, died at launch as a packaged build. dyld refuses to pair an ad-hoc app with a dylib signed by Google's team.

    1 re-signper dylib, scripted into packaging
  3. 03 · Enginesv1.0

    A small model cannot copy. Chunk everything.

    After about two thousand characters "Yuri:" became "uri:". Code now does the mechanics. The model gets ~2,600-character windows, each opening with the tail of the last.

    371 → 0fillers after the deterministic pass, before the model sees the text
  4. 04 · Audiov1.1

    Audio code earns trust by drawing a waveform

    Meeting capture worked on day one. Its timer showed 00:00 forever. The class wasn't observable, so SwiftUI never redrew it.

    00:00on a recorder that was recording perfectly
  5. 05 · Audiov1.5

    A speaker count is a ceiling

    Forcing an exact number of clusters manufactured phantom speakers whenever fewer people talked. The stepper became a maximum.

    ≤ Nspeakers at most
  6. 06 · Enginesv1.8

    Measure before you redesign

    The sequential Super run on a 52-minute meeting took 24:59, to give context to disputes that almost never came. Now only disputed chunks get the slow pass.

    221 / 226chunks where the two engines already agreed
  7. 07 · Testsv2.1

    A test suite pays for itself on day one

    "Yes yes" said for emphasis was collapsed as a stutter. The version dedup checked only the relationship array. A boundary trim deleted sentence ends.

    16 → 41tests across v2.1 and v2.2, three bugs caught on the way
  8. 08 · Testsv2.2

    If only the UI can run it, nobody can debug it

    A CLI command now runs the app's full pipeline headlessly on real recordings, through the same classes the GUI uses. Nearly every hard bug after this fell to it.

    1 → 0echo crawls across speaker turns on a real re-run, with turns and speakers byte-identical
  9. 09 · Enginesv2.2.1

    Swift actors are reentrant at await

    Every empty summary coincided with a Gemma merge. Two conversations had interleaved on the single engine until one starved. A non-reentrant FIFO lock now guards it.

    0 charsin each empty summary, every one lined up with a Gemma merge
  10. 10 · Enginesv2.2.2

    A lock has to survive a hung holder

    When inference hung, the task holding the lock never ran its defer. Every later call queued behind it. The lock is now generation-stamped and resets when the engine is rebuilt.

    ∞how long every later call waited behind the hung one
  11. 11 · Enginesv2.2.4

    Bound every loop that waits on a model

    The disputes loop had no timeout: one wedged Gemma call froze the whole phase. Now 120 s per arbitration, with only the worst ten disputes are arbitrated.

    70%of chunks disputed when the two engines come from different model families, on Ukrainian
  12. 12 · Enginesv2.2.5

    Read the error by its name

    Swift numbers an error enum's cases with payloads first and the plain ones after them, so the "Error 3" I read as "backend unavailable" was the chunk timeout.

    2 releasesshipped against the wrong failure
  13. 13 · Libraryv2.2.6

    JSONEncoder's key order is not stable

    Snapshots were deduplicated by comparing encoded strings. I blamed SwiftData staleness for a week. Instrumentation showed both views saw the row. The JSON didn't match.

    131 → 0duplicate saves let through: 131 of 150 by string, 0 of 300 by decoded content
  14. 14 · Enginesv2.2.6

    Confidence doesn't compare across engines

    Parakeet's calibrated-high confidence outvoted Whisper on Ukrainian: "NBE" lost to "ДНБІ". The merge now takes per-language vote priors.

    × 0.5Parakeet's vote weight on Ukrainian
  15. 15 · Libraryv2.3.0

    Give the LLM a read-only door

    The knowledge-base CLI and MCP server open the store with saving disabled, so they can never write to or migrate the store the app has open. SwiftData's unique attribute upserts silently, so names are checked in code.

    6 callsone read-only interface, over the CLI and MCP
  16. 16 · Libraryv2.3.1

    Never overwrite on a name collision

    WhatsApp exports share file names. A second import replaced the first recording's audio on disk. A merge then concatenated one file with itself.

    2 rows, 1 fileuntil collisions became "name (n)"
  17. 17 · Libraryv2.5.0

    Save the row before you touch the file

    The first draft awaited compression before saving the row, so a crash mid-transcode would have left a recording nothing pointed to. The WAV goes only after the AAC checks out.

    7.7 → 1.0 GBthe Recordings folder, after moving the library to AAC
  18. 18 · Audiov2.5.5

    Don't depend on an order nobody guaranteed

    Buffer zero was assumed to be the mic. With the order reversed, the echo gate muted the other participants exactly while I talked.

    Flatthe waveform, for everyone but me
  19. 19 · Platformv2.5.5

    Pin your dependencies

    With no Package.resolved, a fresh checkout resolved newer FluidAudio and LiteRT-LM, whose frameworks both ship a module.modulemap into the same include path. The build failed on any clean machine. Both packages are now pinned to exact versions in the project file.

    2 packagesone file name, one include path
  20. 20 · Audiov2.5.7

    Never re-encode what you can trim

    Splitting a compressed recording decoded it and re-encoded both halves: robotic, with clicks at the cut. The split now trims the original bitstream in passthrough.

    6% → 0%RMS distortion at the split point
  21. 21 · Testsv2.5.6

    Review the inverse as hard as the original

    Split is merge run backwards. A ten-pass review found it had inverted merge's write-to-disk-then-save order, with no rollback on partial failure.

    15confirmed issues found before split shipped
  22. 22 · Audiov2.5.8

    An AsyncStream cannot be un-finished

    Created once in init, finished by stop: live captions worked for the first recording of a session. Fixed in the plain recorder, then found again in meeting mode, the default.

    Twicethe same bug, in two recorders
  23. 23 · Dictationv3.0.0

    The user talks before the engine is ready

    The first end-to-end run captured 0.2 s of a ten-second sentence: voice processing on a cold engine took 4 s. The input chain is now pre-warmed.

    4 s → 0.03 sto start listening
  24. 24 · Platformv3.1.1

    A permission belongs to a signature

    Ad-hoc signing keyed the Accessibility grant to each build's hash. Every release revoked it while System Settings still showed the tick.

    Every releaseuntil builds were signed with an identifier-based requirement
  25. 25 · Dictationv3.2.0

    Order the text stages, or they eat each other

    Vocabulary spellings run before destutter; the other way round, "Kim Kim" collapses to "Kim". Names are harvested from the library at launch.

    126 nameslearned from 46 transcripts in 0.3 s
  26. 26 · Audiov3.2.1

    Held open at idle, it costs the whole Mac

    Apple's voice-processing unit ducks every other app for as long as it exists in the process. Stopping the engine doesn't lift it. Dictation kept it warm from launch.

    −15 dBon every other app, for as long as Transcriberr ran
  27. 27 · Audiov3.2.3

    The same mistake in a different costume

    The dictation prewarm opened the default input. When that was a Bluetooth speaker, its playback fell back to the hands-free profile for the whole session.

    44.1 → 16 kHza soundbar, from stereo to mono, just because the app was open
  28. 28 · Audiov3.2.2

    A search that can't say 'nothing found' finds something

    A silent first minute defeated the echo delay search: it locked onto noise, the filter sat nowhere near the echo. The far side reached the transcript under my name.

    5,222 → 1,202words on the mic track: both speakers, then only me
  29. 29 · Audiov3.2.4

    Not everything that records is a microphone

    A scan for input devices also finds loopback drivers like BlackHole and the one Teams installs. They record what the Mac plays; transport type tells them apart from hardware.

    'virt'the transport type that marks a loopback device
  30. 30 · Libraryv3.2.5

    The freeze was a full scan on the main thread

    The stall after pressing Run was a version snapshot that fetched and JSON-decoded every transcript version in the store, looking for a duplicate.

    1 predicatesegment count checked inside the fetch, before anything is decoded
  31. 31 · Audiov3.2.6

    A nonisolated async method doesn't inherit the caller's actor

    The model was @MainActor, but await recorder.start() still ran on the cooperative pool, writing UI state underneath SwiftUI.

    6 fieldsobservable state written from the wrong thread
  32. 32 · Libraryv3.4.0

    A queue that remembers must also forget

    The transcription queue now survives a restart. A run that takes the app down twice is dropped. Model downloads are pinned and checked against SHA-256.

    2 crashesand the run is dropped
  33. 33 · Enginesv3.5.0

    Confident about something nobody said

    Whisper writes "Дякую." over silence and scores it as confident speech, so its own thresholds can never catch it. Each one cost a Gemma arbitration.

    30 / 44"hard conflicts" in one day that were Whisper thanking silence
  34. 34 · Enginesv3.6.0

    A context window is a claim. Measure yours.

    One 17k-token read took 109 s and answered in the wrong language. 6k-token reads wedged the runtime in 5 runs of 6. 3k-token sections were stable.

    32k → 3ktokens: the window on paper against the read the stack handles
  35. 35 · Enginesv3.7.0

    Read the timestamps: the ghost lived in the padding

    The surviving phantoms sat at 29.5 s in 26-second chunks, every time. WhisperKit pads each window to 30 s with zeros. The model writes stock lines over the padding.

    97%agreement between two runs of the same build; anything smaller is noise
  36. 36 · Enginesv3.8.0

    Hold a lock only while the work runs

    The inference gate serialized every engine while Gemma was merely loaded, so Super's three-wide pipeline ran single-file. It is now a readers-writer lock.

    241 → 183 sa 6-minute Super run, same words
  37. 37 · Testsv3.9.0

    A reference set turns opinions into numbers

    Two English and two Ukrainian meeting slices with checked text, scored end to end. Every quality change since has had to move the word error rate.

    27.6 → 21.5%Ukrainian WER in one quality pass
  38. 38 · Enginesv3.9.0

    Forced to Ukrainian, Whisper translates English

    "After four interviews they realized that" came back in Ukrainian. Where the two engines disagree, language detection re-reads the stretch and splices English back in by time.

    3 of 3English sentences in the Ukrainian slices repaired, nothing else touched
  39. 39 · Enginesv3.10.0

    When the model is the limit, stop prompting

    Asked only "A or B" on 40 disputed stretches with a known answer, Gemma chose right 21 times. The plain word vote managed 26. Its rulings now run for English only.

    21 / 40a coin flip
  40. 40 · Enginesv3.11.0

    Three calls, one shared progress object

    Super runs three chunks at once on one WhisperKit, which replaces a shared progress property at the end of every call. Two calls finishing together released it twice: a crash.

    SIGSEGVcaught by the reference set before any user saw it
  41. 41 · Enginesv3.11.1

    State on a shared object outlives the run that filled it

    A later run read the previous recording's Whisper words at the same timestamps. The CLI never saw it: one process, one run.

    1 ownerwho resets the cache on every run, including runs that never touch it
  42. 42 · Dictationv3.12.1

    A timeout must also give up its place in the queue

    During a half-hour Super read, dictation's Gemma call timed out but stayed queued. Every later call waited behind it.

    24 sto recognise a passage during Super, against 0.1 s normally
  43. 43 · Enginesv3.13.0

    Give each chip part of the work, then score it

    Two reads in parallel don't help when both queue for the same chip. Whisper's decoder moved to the GPU, its encoder stayed on the Neural Engine; the encoder on the GPU drops words.

    330 → 233 sa full Super run on a Ukrainian reference slice
  44. 44 · Testsv3.13.1

    A method only in a protocol extension is never overridden

    Recovery was defined in an extension, so calls through the protocol skipped each engine's own version and a hung Whisper call rebuilt the idle Gemma too.

    1 enginerebuilt now: the one that is stuck
  45. 45 · Platformv3.14.0

    Ask before you phone home

    The update check is off until the user says yes. Then it is one request for the public latest release, identical from every Mac. The comparison runs locally.

    1 GETidentical from every Mac, only after a yes
  46. 46 · Platformv3.14.2

    An update proves itself before it replaces the app

    The update downloads the release and its Ed25519 signature, checks it against the key compiled into the app, validates the bundle and its code signature. Only then is it swapped in; if the swap fails, the old app is restored. A recording or dictation in progress blocks it.

    1 keycompiled into the app; every update is checked against it
  47. 47 · Testsv3.15.0

    A recording has to survive the crash that interrupts it

    A full code review turned up more than forty fixes. The biggest: a WAV cut off by a crash is now repaired at launch and put back in the library. Meeting capture stops and saves when the mic disappears.

    40+fixes from one full code review
  48. 48 · Audiov3.16.0

    A finished job can run over the next meeting

    Sampled during a call, the previous meeting's Super pass was still running Whisper in the background. Super now pauses between chunks while anything records and picks up on stop.

    3.4 coresfor twenty minutes, under the next meeting
  49. 49 · Audiov3.16.1

    Cancelling the reader ends an AsyncStream

    Switching Live off mid-recording cancelled the task iterating the stream, which terminated it: switched back on, live captions heard nothing. Each live session now subscribes to a stream of its own.

    0 → 3 linesafter a toggle, replayed on a real meeting's audio
  50. 50 · Audiov3.17.0

    Turns come from word times

    Every 26-second chunk became one segment. Mic and far-side chunks overlap on the clock, so replies printed before their questions. Segments are now laid onto Parakeet's word times and cut at pauses and at voice changes.

    98%of segments overlapped the next, in every meeting since August
  51. 51 · Audiov3.17.4

    Losing the user's words is worse than leaving echo

    A gate against invented lines on speaker calls took 1,287 of 4,450 mic words out of a 72-minute coaching session on headphones. It now switches on only when the mic is mostly echo and removes only what the mic's own reading was unsure of.

    12 dBof echo in the mic before the gate switches on
  52. 52 · Audiov4.0.0

    A voice print is a better witness than loudness

    Far-side bleed into the mic reads loud and confident. A print of my voice scores every 1.5-second window: across four meetings I scored 0.49 to 0.70, every other voice at most 0.32.

    48 → 7 msper window, once three clips shared one model slot under three masks
  53. 53 · Enginesv4.1.0

    Keep the losing reading

    Super threw away every word it outvoted. Kept, the other reading is underlined in the transcript, one click swaps it in. The pick becomes a pair the vote obeys from then on.

    9 / 37disputes on the English and Ukrainian reference slices, all found again in the final text
  54. 54 · Enginesv4.2.0

    The invite knows the names

    Before a meeting is transcribed, the overlapping calendar event lends its attendees and the name-like words of the invite to that run's vocabulary and to nothing after it.

    9.2 → 8.5%word error rate on an English slice; names 4 of 5 → 5 of 5
  55. 55 · Testsv4.3.1

    A name with spaces poisons a vote

    Attendee names went into the vote's authoritative set whole, so "van", "der" and "will" outranked confidence. Only single uncommon words go in now. Dutch names that are also words stay out.

    1 wordper vote term, checked against English, Dutch and Ukrainian spell checkers
  56. 56 · Peoplev4.3.3

    Scraps of a few seconds became fake speakers

    The diarizer leaves a cluster of a few seconds in most meetings. Each one became a speaker with three to ten words. A cluster under 8 s and under 3% of the speech now goes to the nearest real voice.

    8 → 6speakers listed on a five-voice call; no real speaker merged on five test meetings
  57. 57 · Libraryv4.3.4

    A process that lives for days must notice its own update

    An assistant keeps one MCP server running for days. After a schema change, every server started earlier ran old code against the new store and failed each call. It now watches its own binary and execs itself in place.

    Same pidsame stdin and stdout, so the client's connection survives the restart
  58. 58 · Enginesv4.4.0

    One model for every language forces the wrong trade

    Every smaller Whisper ties or beats large-v3 inside Super in English, at two to four times the speed. In Ukrainian it loses 5 to 15 points. English now gets its own model.

    189 → 51 san English reference slice in Super, with word error rate 10.8 → 8.7%
  59. 59 · Testsv4.4.1

    To a diarizer, a short reply looks like noise

    The diarizer drops speech under a second, so a quick “yes” between two stretches of me talking sat outside every region. The noise rule from v4.3.3 deleted it. The call track's own sound timeline decides now.

    “Добре”kept; the misreads under it, “100%” and “0%”, still go
  60. 60 · Enginesv4.5.2

    An empty result has to mean “heard nothing”

    A chunk stopped at its 100 s decode budget returned an empty string, as if it were silence. A busy Mac left silent holes in a Whisper run with no error anywhere. It throws now, is retried once. The run note counts what stayed empty.

    20 of 20parallel bench clips came back empty, with no error
  61. 61 · Audiov4.6.0

    Apple's echo filter came back, only while recording

    Apple's voice processing was turned off by default in v3.2.1 because it made the whole Mac quieter. On laptop speakers it beats my own canceller, so it runs on the meeting mic again, created at start and released at stop.

    −20.7 → −64.9 dBFSecho on the mic, down to the noise level of the room
  62. 62 · Enginesv4.7.0

    A failed load must not fall back quietly

    FluidAudio answers a failed model load by wiping the folder and filling it with stock Parakeet v3. The fine-tune would have run as stock under its own name. A load that leaves no UK config behind is now thrown away.

    6.4% vs 8.2%Ukrainian word error rate on clean clips: Parakeet UK against Whisper large-v3
  63. 63 · Peoplev5.0.0

    An empty slot is still an input

    The voice model takes three clips per call. A batch of four or five left a slot zero-filled, which came back as one or two garbage embeddings mixed into most matches. Spare slots now repeat the batch's own clips.

    381 clipsof 23 people, to set every naming threshold again on 3-second clips
  64. 64 · Peoplev5.0.0

    Voices from two models are kept apart

    A second voice model, SimAM-ResNet100, told people apart about twice as well on the same clips. Its scores are on a different scale, so switching parks every voice in the old model and rebuilds it from the recordings it was learned from.

    ≈ 2×better at telling people apart; switching back is instant
  65. 65 · Peoplev5.0.0

    A newer diarizer, with the old one as fallback

    Nemotron 3 runs first on the Neural Engine and leaves the GPU to Whisper. Past eight voices, or when its slots fill, the offline diarizer takes over. The reference set's words and speaker labels did not move.

    24.2 → 13.8%diarization error rate on the AMI test set
  66. 66 · Enginesv5.0.0

    A broken GPU engine stays broken

    With a training job holding the GPU, Gemma's read-back timed out on long prompts and came back null. That call is retried on a CPU engine. The GPU engine is rebuilt, because once it fails it fails every later call.

    20 sthe read-back timeout past about 2,500 tokens
  67. 67 · Enginesv5.0.1

    A launch job renamed titles it should have left alone

    At launch, the app names last week's recordings that never got a title. The check it reused also matched titles that were already set, so the first launch renamed six recordings and would have gone on doing it.

    6 vs 2recordings renamed, against the two still called “Meeting” and a date
  68. 68 · Peoplev5.0.2

    The calendar invite helps name people

    A saved person on the meeting's invite is named at a slightly lower bar. In a small call, when one speaker and one invitee are left over, the speaker gets that name. The voice is never learned from that guess.

    −0.05off the naming bar for someone on the invite; the margin stays
  69. The patternv5.0.2

    None of them raised an error at the moment of the mistake

    Each one fell to the same method: a headless run, a reference set and a number.

    113releases, from 156 commits
Drawn from the git log of the app's repository: every non-merge commit, v1.0 to v5.0.2. Swift lines and tests are counted in the tree at each commit; the lanes are sorted from the commit messages.

What I Learned Building a Fully Local Transcriber for macOS

Transcriberr is a native macOS app: record, transcribe, diarize, rewrite — all on-device. No audio leaves the Mac unless you add a cloud engine yourself. Building it was hard in places I didn't expect. Here's what broke and what held.

The mapHow every recording moves through the app

The map first. Every recording, from the mic or from a meeting, runs through the same seven stations. Every lesson below happens at one of them:

One pipeline every recording

Follow the lit line from the record button: every recording, mic or meeting, runs the same seven stations. Each hardship below happens at one of them.

The pipeline, August: seven numbered stations on one line. Record (the mic, or a meeting tap of system audio) feeds Decode plus VAD (silence-aware chunks), then the ASR engines: Whisper v3 (multilingual gold), Gemma on LiteRT (prompt-steerable), Super (two engines plus a referee) and Parakeet (Neural Engine, 100 times realtime). Then Who spoke (split by voice, label every word, catch names from talk), Versions (every run kept, engines compete), Destutter (for for for becomes for, pure code) and LLM presets (chunked, queued, local). Hardship 1 lives at the engines; hardship 2 at destutter and presets. One pipeline In Record the mic, or a meeting tap of system audio Decode + VAD silence-aware chunks Engines Hardship #1 Whisper v3 multilingual gold Gemma · LiteRT prompt-steerable Super two engines + a referee Parakeet ANE · 100× realtime Who spoke split by voice, label every word, catch names from talk Versions every run kept, engines compete Destutter for for for → for, um, uh out · pure code LLM presets chunked · queued · local Hardship #2 a small model cannot copy 1 2 3 4 5 6 7
The rule

One pipeline, every recording. Meetings started as a new capture layer on the same pipeline; since v1.4 each side of the call is also transcribed on its own.

The pipeline: record → decode → engines → diarize → versions → destutter → presets.

Hardship #1The model that lied convincingly

The first build used one multimodal model for everything: Gemma via MLX, audio in, text out. The demo looked great. Then it transcribed a business meeting as a conversation about music production. That conversation never happened. Fluent, confident, invented.

The rule that saved me: isolate the layer before you blame it. From the transcript view, a hallucinating model and a broken mic pipeline look identical. So prove layers one at a time. Headless CLI, test tone: 151 dB SNR — recorder healthy. Decode healthy. Which leaves the model — and there it was: under MLX, the 8-bit quantized audio tower was degraded. Same Gemma through Google's LiteRT-LM runtime, the stack already proven on Android: word-perfect. Same weights, different runtime, opposite result.

What you integrate is a model-runtime pair.

Hardship #2A small model cannot copy

Post-processing — cleanup, rewrite, translation — runs on a small on-device LLM. The prompt said: remove stutters, keep every sentence, copy speaker labels exactly. The model obeyed for about two thousand characters. Then "Yuri:" became "uri:", stutters passed straight through. By minute 27 whole phrases were falling out: "Have weekend. Have great. Bye."

No prompt fixes this: the failure is attention arithmetic. Picture a monk copying a manuscript. Page one is immaculate. Page forty, letters fall off names and clauses vanish. Sterner instructions don't help the monk. One page at a time does. So, three moves:

  1. Mechanics go in code. A deterministic pass collapses "for for for for", phrase echoes and fillers before the model sees text. Measured on real recordings: 371 fillers → 0. 29 triple-stutters → 0. Code doesn't drift and costs nothing. The sieve before the chef.
  2. Short outputs only. Rewrites run per speaker-turn window, ~2,600 characters, then stitch. A failed window keeps its source text, so the worst case is unpolished text and nothing is lost.
  3. Pass the baton. Each window's prompt opens with the tail of the previous processed window. A relay runner takes the baton at full stride because she watched the last twenty meters. Names, spellings and sentence flow survive the seams.

One page at a time pass the baton

Top: one long rewrite, drifting after about two thousand characters. Bottom: short windows, each one opening with the glowing tail of the window before it.

Chunked rewrites. In one long pass, the small model is faithful for about two thousand characters; then the speaker label "Yuri:" becomes "uri:", stutters pass straight through. By minute 27 whole phrases fall out: "Have weekend. Have great. Bye." The fix: split the destuttered transcript at speaker turns into windows of about 2,600 characters. A small LLM cleans each window. The tail of each finished window is carried forward as a baton and opens the next window's prompt, so names, spellings and sentence flow survive the seams. The outputs are stitched into one transcript; if a window fails, it keeps its source text, so the worst case is unpolished text and nothing is lost. One long pass Fidelity ~2,000 chars minute 27 label intact Yuri: letters fall off uri: phrases fall out Have weekend. Have great. Bye. Chunked · one window at a time ~2,600 chars · split at turns window 1 window 2 window 3 small LLM small LLM small LLM output 1 output 2 output 3 tail tail tail tail If it fails stitched names hold flow holds baton: the previous window's tail opens the next prompt a failed window keeps its source text

Pick a step, or read it whole: the same small model, fed one page at a time with the last page’s tail in hand.

The rule

Prompt for judgment, code for mechanics, chunk everything.

Chunked rewrites: each window’s output passes its tail to the next prompt, then the windows are stitched.

The local engine is one chef in one kitchen, so generations queue FIFO. I let two cooks share the stove once. The plate came back empty.

The rule: prompt for judgment, code for mechanics, chunk everything.

Hardship #3macOS bugs that raised no error

Four macOS incidents. Zero useful errors at the moment of the mistake.

  • ~/Library/Caches is not storage. macOS purged a 10 GB model mid-week, silently. Models live in Application Support now.
  • SwiftData contexts don't share news. Writes on one ModelContext are invisible to reads on another. Deletes that didn't delete. Restores that didn't restore. Branch offices with separate notebooks: each internally consistent, company books wrong. The fix was structural: one main-actor context, a single ledger for every write. A whole class of bugs ended.
  • Signatures must agree. Google ships its LiteRT dylib signed with Google's Team ID. Ad-hoc app + Google-signed dylib runs fine from Xcode — and dies at launch as a packaged build, because dyld refuses to pair binaries from different teams. One codesign --force -s - per dylib, scripted into packaging.
  • A recorder can be perfect and invisible. Meeting capture worked on day one. Its timer showed 00:00 forever — the class wasn't observable, so SwiftUI never re-rendered. Audio code earns trust by drawing a waveform.

ArchitectureDesign choices that paid off

Six choices from the first build that the rest of the app still depends on.

Engines are a protocol, quality is a tournament

Every engine — Parakeet on the Neural Engine, Whisper large-v3, Gemma — implements one small protocol: transcribeChunk and generateText, with each engine's capability flags kept beside it. That's why versioned transcripts cost almost nothing to build: every run is snapshotted, engines race on the same audio, you review the photo finish. It's also how I settled "is it better than a Samsung S24?" — side by side, recording by recording.

The scoreboard, concretely: two real recordings, 28 and 42 minutes, same audio to both devices. Seven domain terms tracked — a company, a product, two cities, a security standard, a cloud platform, a meeting type. Transcriberr in Super mode: 7/7 exact. Samsung: 2/7 — a European city came back as a country at war, a container platform became a word that doesn't exist, a company name turned into a cleaning product and a "dev call" became "deaf calls". Where Samsung won: turn grouping — 39 clean speaker turns against my 115 fragments on the long file. That gap became a setting: speaker-turn gap, default 30 s.

Super mode: two stenographers and an editor-in-chief

Super mode = two engines + a merge (ROVER-style). Two court stenographers type the same hearing. An editor lays the tapes side by side and slides them until the words line up — that's the alignment. Then, per word:

  • Same word → straight into the record.
  • Different words → a pair I settled before wins, then a word from my vocabulary. Otherwise the surer stenographer wins on per-word confidence.
  • Real dispute: when the two tapes disagree on most of a chunk, the whole chunk goes to the editor-in-chief who has read the whole case file, the local LLM, given the surrounding transcript. A single word like "OWASP" against "a wasp" is settled by the vote.

Two stenographers one editor-in-chief

Read top to bottom: two word tapes slid into line and a verdict per word. Your vocabulary settles a word before confidence does. The local LLM sees only whole chunks where the tapes mostly disagree.

Super mode, a ROVER-style merge. Stenographer A (Parakeet) typed: we'll, review, security, "a wasp" (confidence .41), ten. Stenographer B (Whisper) typed: we'll, review, security, "OWASP" (confidence .47), 10. The tapes are slid side by side until matching words line up. Same word: straight into the record (we'll, review, security). Different words: a pair you settled before wins first, then a word from your vocabulary; OWASP is on the list, so it wins without looking at confidence. Otherwise the surer stenographer wins on per-word confidence, weighted by how far each engine is trusted in the language (ten versus 10 gives 10). The record reads: we'll review security OWASP 10. Below: only when the tapes disagree on most of a chunk does the whole chunk go to the editor-in-chief, the local LLM, which reads the surrounding transcript with your vocabulary in its prompt. Steno A Parakeet tapes slid into line Steno B Whisper The record we'll review security Same Same Same we'll review security we'll review security same word: straight in ten Vote 10 10 surer wins a wasp conf .41 Different OWASP conf .47 Your vocabulary OWASP is listed wins before confidence OWASP your list decides checked first: your past picks, then your list Editor-in-chief · local LLM Gets a whole chunk only when the tapes disagree on most of it, with your vocabulary in the prompt

Agreement goes straight in, your vocabulary settles its own words, confidence picks the rest. The editor-in-chief sees only chunks that mostly disagree.

The rule

Verbatim shortcuts only at near-certainty. Everything else gets a vote.

Super mode: two word tapes aligned and voted word by word, your vocabulary first; whole chunks that mostly disagree go to a local LLM. The vocabulary joined the vote in v3.5.0; it has been in the LLM's prompt since v1.8.

The bug that taught me the design: my first merge rubber-stamped anything that looked similar — and threw away the one word only Whisper caught, "OWASP 10". So: verbatim shortcuts only at near-certainty. Everything else gets a vote.

Meeting mode: plug into the soundboard

Recording a call with a mic pointed at the speakers is bootleg concert taping. The engineer's move: plug into the soundboard. macOS 14.4+ lets an app tap what other apps play — digitally, before it ever becomes sound. That tap plus the mic go into one aggregate device: single clock, drift-compensated. Two metronomes, one conductor. Downmix, 16 kHz, same WAV as every recording.

Plug into the soundboard

Two feeds go in on the left, one clock takes them. What comes out on the right is the same WAV every recording makes.

Meeting mode. Inside your Mac, a new capture layer takes two feeds: your mic, your side of the call, plus a system audio tap that takes Zoom, Teams or Meet digitally, before it ever becomes sound. Each feed runs on its own clock, like two metronomes. Both go into one aggregate device with a single, drift-compensated clock: one conductor. The result is downmixed to mono at 16 kHz and written as the same WAV every recording uses. Below, the untouched pipeline takes it from there: transcription, diarization, versions and presets, inherited for free. A mic aimed at the speakers would be bootleg taping. The system audio tap needs macOS 14.4 or later; the app itself now needs macOS 26. Inside your Mac macOS 14.4+ Your mic your side of the call System audio tap Zoom · Teams · Meet digital, before it's sound A mic at the speakers is bootleg taping. Aggregate One clock drift-compensated two metronomes, one conductor Downmix to mono 16 kHz Same WAV as every recording nothing downstream changes Untouched pipeline · free Transcription Diarization Versions Presets

Pick a step to follow the audio, or read the whole path at once.

The rule

A new capture layer on the same pipeline, with meeting-only steps added later.

Meeting mode: mic and system-audio tap share one clock, then the normal pipeline.

Which means meetings inherited transcription, diarization, versions and presets for free. From v1.4 they also got steps of their own: each side of the call is transcribed separately, diarization runs on the far side only. Later the echo is removed too.

A CLI harness that runs the app's real code

Every stage — record, decode, transcribe, diarize — runs headlessly through the same classes the GUI uses. Nearly every hard bug fell to the CLI. If a pipeline is only testable through its UI, it isn't debuggable.

Vocabulary as data, harvested from life

Names decide whether you trust a transcript. The app keeps per-language vocabulary lists — injected into ASR prompts and every rewrite — bootstrapped from my own files and past transcripts. My folder names beat any model's guess.

Adversarial review before every release

Twice before shipping, an independent review pass attacked the newest code with orders to break it. Twice it found real bugs the happy path hides: a double-press race saving duplicate recordings, a frozen meeting timer, a queue that one wedged generation could block forever, a cleanup pass quietly deleting sentence boundaries. Today's code deserves a hostile reader today — while the design is still soft.

The one-line summary

Local-first AI is its own discipline: prove each layer with evidence, let deterministic code do everything it can, give the model only short, judgment-shaped work — and never trust a demo.

Part twoThe month after part one

Part one went up in August. Part two follows the month after it through the commit messages, cut down to what each change taught.

The same map a month later

The framed middle is the August pipeline, unchanged in shape. What is new sits around it: a third way in that goes around it, plus a queue underneath it.

The machine a month later. Three ways in: the mic (a plain recording), a meeting tap (mic plus system tap on one clock, raw tracks kept, echo removed) and holding a key (dictation in any app). Mic and meeting audio run the August pipeline: Decode (16 kHz mono, silence-aware chunks), the engines (Whisper large-v3 anchors Ukrainian, Gemma on LiteRT hangs sometimes and gets benched, Super is a vote then a referee, Parakeet v3 on the Neural Engine also does dictation), Who spoke (in meetings my track is me, diarize only the others), Versions (every run kept; merge, split, restore), Destutter (pure code, no model) and LLM presets (chunked, queued, local), out to the transcript: speakers, versions, summaries, minutes, translations, as txt, srt or json. Dictation skips the pipeline and goes from the key straight to Parakeet, then is pasted at the cursor with the clipboard put back. Under the engines a queue runs one model at a time, because two models running at once made Gemma hang; presets queue there too. The knowledge base reads the library through a CLI and an MCP server, read-only. After Stop, audio shrinks to AAC, a tenth of the size, 7.7 GB to 1.0 GB. Every transcript, version and output is shadowed as plain JSON in a folder nothing deletes. The August pipeline In Mic plain recording Meeting tap mic + system tap, one clock; raw tracks kept, echo removed Decode 16 kHz mono, silence-aware chunks Who spoke meetings: my track is me; diarize only the others Versions every run kept; merge, split, restore Destutter pure code, no model LLM presets chunked · queued · local Engines Whisper large-v3 anchors Ukrainian Gemma · LiteRT hangs sometimes, benched Super a vote, then a referee Parakeet v3 ANE · dictation too Hold a key dictation, any app no decoder, no chunking One model at a time two at once made Gemma hang Out At the cursor dictated text pasted into the app in front, the clipboard put back The transcript speakers, versions, summaries, minutes, translations, txt · srt · json Knowledge base reads the library: CLI + MCP server, read-only, always After Stop: audio shrinks to AAC, a tenth of the size, 7.7 GB → 1.0 GB. Every transcript, version and output shadowed as plain JSON in a folder nothing deletes.

Pick a path to trace it; the rest of the map dims.

The rule

The middle of the map is the August pipeline. Dictation skips it; the queue under the engines runs one model at a time, because two at once made Gemma hang.

The machine, a month later: what goes in, what comes out and where the models sit.

The middle of the map is the August pipeline. Dictation goes from the key straight to Parakeet and skips it: no decoder, no chunking, no diarization. The knowledge base reads the store. The queue under the engines runs one model at a time, because two models running at once made Gemma hang.

ArchitectureThe stack

Audio comes in through AVAudioEngine for the microphone, with Apple's voice processing as an option that is off by default. Meetings come in through a CoreAudio process tap joined to the mic in one aggregate device. The dictation key is a CGEvent tap. The app in front, the kind of field and the text before the caret come from the Accessibility API. Decoding is AVFoundation: AVAssetReader where it works, AVAudioFile and AVAudioConverter where it refuses, always down to 16 kHz mono.

What is borrowed and what is Yuri code

Six layers from sound in to text out. White is Apple, black is the three packages that run the models, orange is written here.

The stack in six layers, every box running on the Mac. In: AVAudioEngine for the microphone, raw by default, with Apple's echo filter on by default in meetings played over speakers, a CoreAudio process tap joined to the mic in one aggregate device for meetings, a listen-only CGEvent tap for the dictation key plus the Accessibility API for the app in front, the kind of field and the text before the caret. Decode: AVFoundation (AVAssetReader, AVAudioFile, AVAudioConverter) down to 16 kHz mono; Yuri code for silence-aware chunking (the one-second live feed and speaker-turn windows) and the NLMS echo canceller on Accelerate, run on the raw tracks after the recording stops, which leaves a mic the echo filter already cleaned alone. Models, through three packages: FluidAudio carries Parakeet TDT, stock v3 and the Ukrainian fine-tune, on the Neural Engine, diarization (Nemotron 3 first, pyannote as the fallback) and the WeSpeaker voice model; WhisperKit carries whisper large-v3 as CoreML on the Neural Engine plus large-v3 turbo for English; LiteRT-LM carries Gemma, text on Metal and the audio encoder on the CPU. The high-accuracy voice model, SimAM-ResNet100, runs on Core ML directly. Cloud APIs for OpenAI, Anthropic and Gemini are in the code, off by default. Yuri code around the models: the engine protocol every backend implements, Super mode's ROVER word vote and arbitration, the queue that lets Gemma run only alone, with its watchdog and benching, the text mechanics (destutter, chunk windows, context between them) plus names and people: vocabulary lists, names learned from the library and the voices of saved people. Store: SwiftData behind one main-actor context, JSON backups written additively and never pruned (Yuri code), AAC at 48 kbps written through AVAudioFile with AVAssetExportSession trims, plus models and saved voices in Application Support, except Whisper's models, which sit in ~/Documents/huggingface. Out: the SwiftUI app and its menu bar item, NSPasteboard plus a synthetic Command-V, App Intents for Shortcuts and Siri, the transcriberr://dictate URL scheme. In Yuri code: the headless transcriberrcli that runs every stage and the MCP server over JSON-RPC on stdio, with a switch in Settings that shuts it off. In AVAudioEngine the mic, echo filter in meetings CoreAudio tap meeting audio CGEvent tap the dictation key Accessibility API app, field, caret text Decode AVFoundation down to 16 kHz mono silence-aware chunking 1 s feed, speaker turns NLMS echo canceller after the call, if echo is left Models · three packages, plus Core ML for the high-accuracy voice model FluidAudio Parakeet v3, Parakeet UK Nemotron, pyannote, WeSpeaker WhisperKit large-v3, turbo in English CoreML · Neural Engine LiteRT-LM Gemma, audio + text Metal, audio on CPU Cloud APIs in the code, off by default Yuri code · around the models one protocol every backend Super mode word vote the queue Gemma runs alone text mechanics destutter, windows names, people library, voices Store SwiftData one main-actor context JSON backups additive, never pruned AVAudioFile AAC at 48 kbps Application Support models, saved voices Out SwiftUI app and its menu bar item NSPasteboard plus a synthetic ⌘V App Intents Shortcuts and Siri URL scheme transcriberr://dictate transcriberrcli every stage, headless MCP server lets AI assistants read it Apple frameworks model packages Yuri code off by default

Pick a layer to light it. Everything is on the Mac; the only dashed box is off by default.

The rule

The models are borrowed. The Yuri code sits around them: the engine protocol, the vote, the queue, the text mechanics.

The stack: Apple frameworks, the three packages that run the models and the parts that are Yuri code.

The models come through three packages. FluidAudio carries Parakeet, a CoreML build of Nvidia's TDT model that runs on the Neural Engine, in two versions: stock v3 and since v4.7 my Ukrainian fine-tune, downloaded from Hugging Face. It also carries diarization: since v5.0 Nvidia's Nemotron 3 runs first. The pyannote pipeline, also CoreML, is the fallback. Part five lists all the models. WhisperKit carries whisper large-v3 as CoreML on the Neural Engine plus, since v4.4, large-v3 turbo for English; since v3.15.9 the GPU split is an option. LiteRT-LM, Google's runtime, carries Gemma: text runs on Metal while the audio encoder runs on the CPU. The cloud backends for OpenAI, Anthropic and Gemini are in the code behind an API key and off by default.

The parts I wrote, one by one

Each part below says what it does and where the idea came from. Where it is a known method, I name it; where it came from the Android version of the app or from a measured failure, I say that instead.

Chunking at quiet moments

Whisper and Gemma read about thirty seconds of audio at most, so a recording has to be cut. A cut on the clock lands in the middle of a word. The chunker measures loudness every 10 ms and finds stretches quieter than a fixed level for at least a quarter of a second. Each cut moves to the middle of the nearest one within two seconds either way. A chunk is at most 28 seconds, each one starts a second before the last one ended. The words repeated across that overlap are trimmed once both are transcribed. Live captions work differently. The recorder hands over one second of audio at a time, a phrase ends at a pause of 0.45 s. Parakeet re-reads the open phrase about once a second.

The rules came over from the Android version of Transcriber, whose plan lists VAD-aligned boundaries with two seconds of flex and a one-second overlap. The 28 seconds is Google's number: LiteRT caps audio at about 30. One change came from a bug. Cuts measured on a fixed grid let chunks grow to 33 s. Up to 5 s at a seam was never transcribed, so each cut is now measured from the one before it. The phrase finder for live captions has no outside model and replaced the old five-second cuts in v3.16.

The echo canceller

In a meeting I record two tracks on one clock: my mic and the call audio taken straight from the system. Over laptop speakers the mic hears the call as well. After the recording stops, the canceller first works out how late the call arrives in the mic. It takes the four loudest minutes of the call track and slides the two tracks against each other by up to 250 ms. Then a filter with 1,024 taps learns what the room does to the call's sound and subtracts its prediction from the mic. It adjusts a little after every sample, only while the call side is talking. A second stage silences frames where what is left is mostly predicted echo. If the first stage removes less than 8 dB, the mic is left alone. Echo is removed later by matching word times. Since v4.6, a meeting on speakers runs Apple's echo filter on the mic while it records. The canceller then finds no echo path in a mic that was already filtered, so it passes it through.

Nothing here is new. Delay search, an NLMS filter, a check for both sides talking at once and a residual suppressor are the usual chain in speakerphone echo cancelling; Speex and WebRTC have the same shape. My case is easier than theirs, because both tracks sit on one clock and the work happens after the call, with the whole recording to look at. The chain grew one step at a time, each after a measured failure: a 12% duck in v1.7, a gate, the NLMS filter in v2.0, the suppressor in v3.2.2. In v2.0 the filter alone removed 11.8 dB of echo and 0.2 dB of my speech; with the suppressor added in v3.2.2, the stretches where only the far side talks came down 21 dB and my own speech about 2 dB.

One protocol for every engine

Parakeet, Whisper, Gemma, the cloud APIs and Super itself all answer the same few calls: load, release, transcribe a chunk, generate text, recover when stuck. The pipeline only ever talks to that protocol, so changing an engine is a setting. Super is one more engine that happens to hold two others. Engines that can score each word also answer a second protocol that returns words with their confidence, which is what the vote needs.

It is a port of the AsrBackend interface in the Android app. One lesson went in later. Recovery was first written in a protocol extension. Swift calls a method that exists only in an extension directly, so each engine's own recovery never ran. Since v3.13.1 recovery is part of the protocol.

The word vote and the referee

Super runs two engines on the same chunk (since v5.0 by default Whisper and Parakeet UK) and lines up their two word lists the way a diff lines up two files, by edit distance. Where both wrote the same word, it goes in. Where they differ, a short list of rules decides, in order: a choice I made before, then a word from my vocabulary, then a Latin-script word from the trusted engine over a native-script guess from the weak one, then the more confident engine, weighted by how far each engine is trusted in that language. Chunks where the engines share less than 80% of their words get a second pass, in which Gemma reads both versions with the text around them and picks. Only the ten worst chunks go to Gemma. Since v3.10 that happens only in English.

The vote is ROVER, a method NIST published in 1997 for combining speech recognisers by aligning their outputs and voting word by word. The code calls it that. Handing the hard cases to a language model is the newer half. It was kept only where it measured as helping. Asked A or B on 40 disputes with a known answer, Gemma was right 21 times and the vote 26.

The queue

The figure under Engines shows the Gemma line. Around it sits a second gate for all three engines: Gemma takes it alone, while Whisper and Parakeet can share it. Dictation and live captions go to the front of the line and never wait behind queued work, only behind a Gemma call that is already running. Live captions and the dictation tidy-up give up after a few seconds, so a long meeting in the background cannot hold up a sentence I am dictating.

The single lock for Gemma came from the Android app. Making Whisper and Parakeet wait while Gemma runs came from a measurement: every pairing that ran Gemma beside another engine got stuck about three times per run. The rest are textbook patterns: a readers-writer lock, a watchdog timer and benching, which works like a circuit breaker.

Tidying text before the model sees it

Plain code removes fillers such as uh, um and ем, collapses repeats like “for for for” and leaves alone the doubles people mean, such as “yes yes” or “bye bye”. Then the transcript is cut at speaker turns into windows of at most 2,600 characters. Gemma cleans one window at a time. Each window after the first opens with the last 400 characters of the finished one before it, so names and sentences carry across the seams. A window that fails keeps its original text.

No paper behind this. It came from watching Gemma drift: on a long transcript it stayed faithful for about two thousand characters. Then “Yuri:” became “uri:”. Code took over everything a rule can do. The model was left with short spans, which the commit that brought in the windows puts as small models being faithful on short spans.

Names

There are vocabulary lists per language plus a harvester that learns names from the library at launch. It looks for capitalised words and acronyms that appear in at least two recordings, at least a fifth of the time in the middle of a sentence and more often capitalised than not. Names from the vocabulary are used three ways in transcripts: the vote prefers them, split names are joined back together (“Kim Kim” becomes KimKim). A capitalised word one sound away from a known name that is not in the dictionary is respelled to it. Harvested names go to dictation and can be promoted into the vocabulary in Settings.

The built-in lists came from the Android app. The harvester has no outside source; the idea is that my own transcripts know the names I use better than any model does. It found 126 names in 46 transcripts in 0.3 s.

Backups that are never pruned

Besides the database, every recording also gets a folder of plain JSON, in a Transcriberr Backups folder in Documents. The recording's current state is overwritten, every transcript version is written once and never changed. Every generated summary or rewrite is added under its own name. Deleting a recording in the app leaves its folder alone. One CLI command rebuilds the database from these files, filling in only what is missing.

This came in v2.4.0. The commit gives the reason in a sentence: a corrupted or lost store is no longer the only copy of anything.

The command-line twin

transcriberrcli is built from the app's own source files, with no copies. It runs every stage without the interface: recording, decoding, each engine, a full Super run, echo cancelling, the voice print and the reference-set scoring. The inputs to the last step of a run can be saved and replayed in a second, which is how most fixes in part four were checked.

It came out of the debugging in part one. If a pipeline can be tested only through its interface, it cannot be debugged. It has one blind spot: each CLI run is a fresh process, so state left over between runs inside the app never shows up there.

The MCP server

The same CLI can serve the library to an AI assistant over the Model Context Protocol: JSON-RPC messages, one per line, on standard input and output. It offers six tools to list, search and read transcripts and their summaries. It opens the database read-only, so it can run beside the app without touching anything. Since v5.0 one switch in Settings shuts it off: the server then lists no tools and refuses every call, also on a connection that is already open.

It follows the MCP specification and is written by hand, because the official Swift SDK needs a Swift 6 toolchain while the app still builds in Swift 5 mode.

The turn timing and the voice print from part four are also written here. The voice print borrows one piece, the WeSpeaker model that turns a stretch of audio into numbers, through FluidAudio; the scoring and the decisions around it are mine. Part five uses it for other people too. The optional second voice model, SimAM-ResNet100, runs as plain Core ML. The filterbank it expects as input is written here in Swift on Accelerate, checked against torchaudio's.

The rest of the app is Apple's. Storage is SwiftData behind one main-actor context, audio is stored as AAC written through AVAudioFile. The ways out are the SwiftUI app and its menu bar item, the pasteboard plus a synthetic ⌘V, App Intents for Shortcuts and Siri, plus a URL scheme. One small piece is not Swift. An Objective-C shim catches the NSException that AVAudioEngine can throw, because Swift cannot catch it and the app used to quit on the record button. An IOKit power assertion, called from Swift, keeps the Mac awake through a long transcription.

MeetingsMeeting mode: captions and which voice is mine

Meeting mode records a call as two tracks: my mic and the call's audio taken straight from the system. Live captions now run for meeting recordings as well as plain ones. Both recorders emit the same five-second chunk feed and the live transcriber attaches to whichever one starts. The meeting recorder measures mic energy against system-tap energy per IO cycle, before the downmix. It saves the intervals where the mic dominates. After transcription, the speaker that best overlaps those intervals gets my name. The speaker-count stepper now reaches the diarizer; it had been dropped on the way.

Alongside the mixed file, meeting mode writes the raw mic track and the raw system track. When both exist, transcription runs them as two chunk streams on one timeline. Mic segments are me. Diarization runs only on the system track, with one speaker fewer since I counted myself. An echo guard drops mic segments that duplicate a system segment at the same time.

EnginesPhantom speakers, echo, a 25-minute Super run, empty summaries

Four problems showed up on real meetings. First, the speaker count became a maximum. Forcing an exact cluster count manufactured phantom speakers when fewer people talked. A system-track cluster that carries my own name is the platform's echo of my voice and folds into me.

Echo, first pass: while the system tap is loud and the mic is not dominant, the mic's contribution to the mix is attenuated to 12% with 40 ms smoothing, so the far side no longer arrives twice, once digitally and once through the room. Then the duck became a full gate, then an offline NLMS canceller, run against the raw tracks after the recording stops. Measured: 11.8 dB off the echo, 0.2 dB off speech. A sentence-level scrub catches what the canceller leaves at speaker turns, with fuzzy token matching, because echo degrades "schedule" into "scheduled".

Super mode, measured on a real 52-minute meeting: the sequential max-quality run took 24:59. 221 of 226 chunks agreed between the engines and never reached arbitration. The sequential constraint bought context for five decisions and cost twenty minutes on 226. New design: the first pass runs three chunks wide with the vote only. Disputed chunks, agreement under 0.8, get a second sequential pass in which Gemma arbitrates with transcript context from both sides plus the vocabulary.

The test suite went from 16 to 41 and caught three bugs on the way: "yes yes" as deliberate emphasis was being collapsed as a stutter, the version dedup checked only the relationship array and a boundary trim was deleting sentence ends. A CLI run command executes the full pipeline headlessly on real recordings before a deploy.

The empty summaries. The log showed that every zero-character response coincided with a Gemma merge running at the same moment. Swift actors are reentrant at await: an ensemble arbitration and a preset generation had interleaved two conversations on the single LiteRT engine until one starved. A non-reentrant FIFO lock now guards the text and audio paths.

Then the lock itself got stuck. Now and then a Gemma call never came back. The caller holding the turn never handed it on, so every later call waited behind it for good. The figure below walks through what happens now. Recovery replaces only the stuck Gemma, so chunks already running on Parakeet and Whisper carry on. Each ruling in the dispute loop gets 120 seconds. Only the ten chunks where the engines agree least are sent to Gemma at all; the rest keep the vote. Engines from different model families disagree on about 70% of Ukrainian chunks, so without that cap the loop would run for hours.

When Gemma never answers

Four parts of the app share one copy of Gemma. It can answer only one question at a time. Read the rows top to bottom.

  1. 1A normal day

    Whoever asks first goes first. The others wait in line in the order they asked. When the answer comes back, the caller hands the turn to the next one.

  2. 2One question never comes back

    Gemma runs inside Google's LiteRT library. Once a question is handed over, nothing on the Swift side can take it back. About three times in every thirty chunks it never returned. The referee kept waiting, so it never handed the turn on. Everyone behind it waited too. In the app that looked like a summary that never arrived.

  3. 3A fresh copy takes over

    Once a call has been inside Gemma for 120 seconds, the app treats it as stuck and loads a new copy of Gemma beside the stuck one and swaps it in. Each copy carries a number. Every place in the line remembers which copy it was given for. The new copy is number 8, the line starts again with it. The next caller goes.

  4. 4The stuck call comes back late

    Sometimes the stuck call does finish, minutes later. It tries to hand its turn back. It hands it back to copy 7, which is gone, so nothing happens. Without the numbers, that late hand-back would have freed a turn that now belonged to someone else. Two callers would have shared Gemma at once: the same mix-up that had emptied the summaries.

  5. 5When it keeps happening

    A chunk that gets stuck twice is read by the other engine alone. When Gemma is one of Super's two engines and calls time out twice in one run, Gemma is left out for the rest of that run. As the referee it is only replaced. A ruling that never came keeps the voted text.

The one-at-a-time lock came over from the Android version of the app, where one Gemma also served many callers. The numbered copies arrived in v2.2.2, after the lock itself got stuck. Distributed systems use the same trick under the name fencing token: a holder whose number is out of date can no longer release anything.

EnginesA misread error, duplicate saves and Ukrainian in the vote

Three engine bugs: an error read by the wrong name, duplicate saves and a merge that made Ukrainian worse. "Error 3" in the log, which I read as "backend unavailable", was the chunk timeout. Swift numbers an error enum's cases with payloads first and the plain ones after them, so the order in the source is no guide. Two releases went to the wrong failure. The max-quality first pass had a bare 300-second timeout and no recovery; it now recovers, retries, degrades to the healthy engine. After two wedges in a run Gemma sits out the rest. In a sweep of all six engine pairs on the same Ukrainian meeting, Gemma wedged in every pairing that contained it, about three times per thirty chunks, whichever partner it had.

Version snapshots were deduplicated by comparing encoded JSON strings. JSONEncoder's key order is not stable across two encodes of the same value. In a standalone harness under heap churn, 131 of 150 duplicate saves got through with the string compare and 0 of 300 with a compare of the decoded content. I had blamed SwiftData staleness for a week. Instrumentation showed both views seeing the stored row and the JSON not matching.

The ROVER merge degraded Ukrainian. Parakeet's token surfaces carry a literal leading space, which doubled every separator in merged text. Whisper splits Ukrainian words at the apostrophe, so "Пам" and "'ятаєш" arrived as two tokens. And Parakeet's calibrated-high confidence outvoted Whisper on divergent words: "NBE" lost to "ДНБІ". The merge now takes per-language vote priors, Parakeet at half weight on Ukrainian. Every fallback prefers the stronger-language engine. Validated on the real meeting: zero double spaces, zero split apostrophes, Whisper's Latin entities survive.

Concurrent GPU or Neural Engine work from another engine wedges LiteRT's Metal path on its own. So while a Gemma engine is live, heavy native inference across all engines goes through one generation-stamped gate. With no Gemma loaded, the gate passes everything through.

LibraryFolders, the AI knowledge base, backups, disk space

Folders (one per recording) and tags (many to many), with nullify delete rules so deleting one never cascades into the others. Name uniqueness is checked case-insensitively in code; SwiftData's unique attribute upserts without saying so. Behind them, a read-only layer for LLMs. The CLI opens the same store file with allowsSave false, which maps to Core Data's read-only option, so the process can never write to or migrate a store the GUI has open. WAL makes reads from a second process safe. There are kb list, search, get, outputs, folders, tags and stats commands, plus an mcp command that serves the same six calls as JSON-RPC over stdio.

Import no longer overwrites an existing file with the same name. WhatsApp exports share names; a second import replaced the first recording's audio on disk, both rows then played the same file. A merge then concatenated one file twice.

Every recording's transcript now shadows to a Backups folder next to the project as human-readable JSON: current state, every historical version, every generated output. Backups are never removed on delete. The per-chunk append path does not back up, since that would add a synchronous encode and disk write to the hot path; the backup refreshes at the run-completion checkpoints, which already hold the full transcript. A restore command re-injects into a live store and fills in only what is missing.

Finished recordings are transcoded to 16 kHz mono AAC at 48 kbps, about a tenth of the WAV. Recording still writes WAV; encoder work inside a CoreAudio IO callback is not a risk I want with live capture. The transcode runs after the file is closed and the row is saved, is checked against the original's duration and only then deletes the WAV. The first draft awaited compression before the row was saved, so a crash mid-transcode would have left a fully recorded file with no row pointing at it. A review pass on that diff found ten issues; that was the serious one. Migrating the existing library moved 23 of 24 WAV recordings; the Recordings folder went from 7.7 GB to 1.0 GB. Two things learned on the way: AVAudioFile finalises a WAV header only when the writer is deallocated; read(into:) throws at end of file.

The meeting recorder's live gate still let audible echo into the saved file. After Stop, the offline canceller now reruns against the raw tracks and rebuilds the saved mix. That rebuild plus the compression used to run synchronously before Stop returned, so pressing Stop looked hung. Both run in the background now. Transcription starts at once off the raw WAV.

RecorderTwo bugs that showed only on the second recording

The language used for a run was never written back onto the recording, so an older recording could show whatever language had been picked last for a different one. The run's languages are locked onto the recording when the job starts.

The plain recorder's live-caption stream was created once in init and finished by stop. An AsyncStream cannot be un-finished. Live captions worked for the first recording of each session and died for every one after.

Meetings, audioA muted call, a broken build and splitting a recording

The meeting recorder told the mic and the system tap apart by position in the aggregate device's buffer list: buffer zero the mic, the rest the tap. Nothing guarantees that order. The echo gate downstream mutes whatever lands in the mic slot whenever the other side dominates, so with the order reversed it muted the other participants exactly while I was talking. The waveform went flat for everyone but me. The tap is built as a stereo mixdown by construction, so buffers are now told apart by channel count when the counts differ and by position only when they match.

No package versions were pinned and no Package.resolved was committed, so a fresh checkout resolved newer FluidAudio and LiteRT-LM packages whose XCFrameworks both ship a file named module.modulemap into the same include path. The build failed. Both are pinned to exact versions in the project file now.

Split, the inverse of merge: audio cut at a point, transcript partitioned by segment midpoint, mic and system tracks and the speaking intervals cut the same way, folder, tags and run settings carried over, the source left untouched. A ten-pass review before shipping found fifteen confirmed issues. Among them: the halves were saved to the database before their sidecar tracks were written, inverting merge's write-disk-then-save order; no rollback on partial failure; speaker names copied verbatim to both halves; Cancel did not stop a split already running.

Then the sound of a split: robotic, with small clicks at the cut. Splitting an already compressed recording decoded to PCM and re-encoded both halves, a second lossy pass on top of the first. Measured against a real recording, about 6% RMS distortion at the split point, against under 1% for an ordinary re-encode of the same material. The bitrate could not go up; 48 kbps is the top of AVAudioConverter's list for 16 kHz mono. The split now trims the original bitstream with AVAssetExportSession in passthrough mode, which measured 0%. Both the cut and the join get a 20 ms fade.

Then the meeting recorder turned out to have the same live-caption bug the plain recorder had had: stream created in init, never recreated on start. Meeting mode is the default, so live captions had never worked in it.

DictationDictation into any app

v3.0 adds dictation into any app: hold a key, speak, let go: the text lands at the cursor. A bug hunt came first, nine fixes: the elapsed timer froze after pause and resume and the frozen value was saved as the duration; switching to the Library mid-recording forgot the recording in progress; Cancel right after Run was a no-op; the delete paths did not cancel a running job.

Then dictation. Hold Right Option in any app, speak, release. The passage is captured in memory, decoded in one Parakeet pass on the Neural Engine, cleaned in code and pasted at the cursor by pasteboard plus a synthetic ⌘V, with the previous clipboard restored. The text stages run in a fixed order: vocabulary spellings, then destutter, then self-corrections ("Monday, no, Tuesday"), then spoken commands. Vocabulary has to go first; run after destutter, "Kim Kim" collapses to "Kim".

Hold a key, talk, let go

Read top to bottom: five stages from the key to the cursor, each with what it taught.

Dictation in five stages. Capture: hold Right Option in any app, speak, release; the passage is captured in memory. On a cold AVAudioEngine, enabling voice processing took 4 seconds. The first run caught 0.2 s of a ten-second sentence; the input chain is now pre-warmed at launch and after every stop. Start now takes 0.03 seconds. Decode: one Parakeet pass on the Neural Engine, with no decoder, no chunking and no diarization; a live preview decodes the buffer every 2.5 s as provisional text. Clean: plain code in a fixed order, 1 vocabulary spellings, 2 destutter, 3 self-corrections such as "Monday, no, Tuesday", 4 spoken commands. Vocabulary has to go first: run after destutter, "Kim Kim" collapses to "Kim". Route: read once at key-down through Accessibility, the app in front, the kind of field and up to 600 characters before the caret. Terminals, editors and password fields go verbatim; everything else gets the plain-code text; chat and mail get a Gemma pass in a casual or formal register, accepted only if at least 75% of the words survive in order and the tail survives, otherwise the plain text goes in. Paste: at the cursor through the pasteboard and a synthetic Command-V, with the old clipboard put back; "scratch that" sends Backspace events to the app; without Accessibility access it copies only; commands also work in Dutch, German and Ukrainian. Capture Hold Right Option in any app, then release cold engine 4 s pre-warmed 0.03 s The first run caught 0.2 s of a ten-second sentence. Now the input chain is pre-warmed at launch and after every stop. Decode Parakeet one pass, Neural Engine The whole passage at once: no decoder, no chunking, no diarization. Live preview: decoded every 2.5 s while you speak, shown as provisional. Clean Plain code four stages, fixed order 1 vocabulary 2 destutter 3 self-corrections 4 spoken commands 3 is "Monday, no, Tuesday" Why 1 goes first vocabulary, then destutter Kim Kim kept destutter, then vocabulary Kim Kim Kim collapses Route Which lane the app in front picks Read once at key-down: app, field, 600 characters before the caret. Verbatim terminals, editors, password fields Clean everything else, plain-code text Smart chat and mail, a Gemma pass The guard Kept only if 75% of words survive in order, tail too; else plain text. Paste At the cursor pasteboard + ⌘V Old clipboard put back. "Scratch that" sends Backspace. No access? Copy only. Commands also in Dutch, German, Ukrainian.

Pick a stage to light it. The orange marks the two things that had to be learned.

The rule

Vocabulary has to go first; run after destutter, "Kim Kim" collapses to "Kim". And warm the input chain before anyone talks: 4 s cold, 0.03 s pre-warmed.

Dictation: hold a key, Parakeet decodes the passage, plain code cleans it, the app in front picks the lane, the text is pasted at the cursor.

The first end-to-end run captured 0.2 s of a ten-second sentence. Enabling voice processing on a cold AVAudioEngine took 4 s, measured. Those are the seconds in which the user is already talking. The input chain is now pre-warmed at launch and after every stop. Start takes 0.03 s.

Context, on-device. At session start the app reads through Accessibility the frontmost app, the focused element's role, the window title and up to 600 characters before the caret. Password fields are verbatim and never read. Per-app rules: terminals and editors verbatim, chat apps a Gemma pass in a casual register, mail formal, everything else the deterministic text. The Gemma result is accepted only if at least 75% of the raw words survive in order and the tail of the passage survives; in one end-to-end run Gemma dropped a trailing sentence. Otherwise the deterministic text goes in. "Scratch that" removes the last passage in the target app through Backspace events. Commands also in Dutch, German and Ukrainian. Shortcuts actions that run without activating the app.

Permissions. macOS keys a grant to the app's designated requirement. Ad-hoc signing set that to the per-build hash, so every release invalidated the grant while System Settings still showed the tick and AXIsProcessTrusted stayed false. The packaging script now signs with an explicit identifier-based requirement, which every build satisfies. Upgrading from an older build still needs one removal and re-add in the Accessibility list. That step is documented; the fix is still open.

The hotkey moved from NSEvent monitors to a listen-only CGEvent tap at session level, the same channel Karabiner-style tools use. It re-arms itself if macOS disables the tap and reports every event, so the Dictate screen shows the last key event seen.

Names learned from the library: at launch, capitalised terms that appear mid-sentence in more than one transcript, three words or fewer and not ordinary words by lowercase frequency, are harvested and applied to dictation. 126 names from 46 transcripts in 0.3 s. Live preview: the buffered audio is decoded every 2.5 s while speaking and shown as provisional text. An end-to-end script speaks a sentence through the speakers with the system voice, dictates it and checks the knowledge base for it. On its first run the echo canceller suppressed the speaker output and nothing was recognised; the script now turns voice processing off for the loop.

What the month taught

None of the month's bugs produced an error at the moment of the mistake: a lock held across an await, a grant keyed to a build hash, a stream that cannot be restarted, a buffer order nobody guaranteed. All of them fell to the same method as in part one, a headless run and a number.

Part threeFrom v3.2 to v3.15

Part two stopped at v3.2, when dictation shipped. What follows is v3.2.1 to v3.15.0, twenty-five releases, taken from the commit messages and cut down the same way.

AudioHow an idle app made the whole Mac sound worse

Apple's voice-processing unit ducks every other app's output by 15 dB for as long as it exists in the process. Stopping the engine does not lift it; only disabling it does. Dictation kept it warm from launch, so the whole Mac was quieter whenever Transcriberr ran. Dictation now captures raw by default. Voice processing is an option created at start and destroyed at stop.

The same mistake came back in a different costume. The dictation prewarm reached for the engine's input node. That line alone opens whatever macOS calls the default input. When that was a Bluetooth speaker, its playback fell from 44.1 kHz stereo to 16 kHz mono for the whole session: the link drops to the hands-free profile to carry a mic stream. An AVAudioEngine cannot release an input device once its input node has been used, so in that case the engine is thrown away at stop and nothing is prewarmed. Measured on the speaker: 44,100 Hz idle, 16,000 Hz during a session, 44,100 Hz after stop.

The microphone can now be chosen, in Settings and in the menu bar. Not everything with input channels is a microphone: loopback drivers such as BlackHole or the one Teams installs record what the Mac plays. Transport type tells them apart. They are listed in their own section.

MeetingsThe echo canceller, fixed twice more

A meeting whose first minute was silence defeated the echo canceller. Its delay search only looked at the first 60 seconds of the far side, locked onto noise. The filter sat nowhere near the echo. On a 33-minute interview about 410 of the other side's words reached the transcript under my name. The search now looks at the loudest minute and normalises the correlation, so "found nothing" is a result: below 0.1 there is no echo path and the mic passes untouched. Subtraction alone was not enough, because the recogniser transcribed the garbled leftover anyway, so frames the filter can fully explain now go to silence. The mic track transcribed alone: 5,222 words from both speakers before, 1,202 from me after.

Then the loudest minute was the wrong minute. On a 32-minute call it read 0.065, no echo path, while other minutes held a 40 ms echo at 0.45. The search now tries the four loudest non-overlapping minutes and keeps the best. When the linear stage removes less than 8 dB, the suppressor is skipped, because muting from a meaningless prediction deleted my own words. What leaks past is caught by timing: a mic word is echo when it sits in a run of three or more words matching the far side within 0.6 s. A single "yes" said at the same moment survives.

Recorder, libraryA freeze, the wrong thread and a meeting held five times in memory

The stall after pressing Run was the version snapshot: it fetched every transcript version in the store and JSON-decoded all of them, on the main thread, looking for a duplicate. It now rejects on segment count inside the fetch. The echo canceller and the backups had been running on the main actor too.

The recorders' start and stop are nonisolated async on purpose, because setting up voice processing and the system tap blocks for up to seconds. A nonisolated async method does not inherit the caller's actor, so they wrote six pieces of observable state from the cooperative pool, underneath SwiftUI. Every such write now hops to the main actor. A stereo microphone also brought back the reversed meeting split: the tap was told apart by being stereo. Now both buffers were. The tap's channel count is now chosen per session to differ from the mic's.

A split-track run held five copies of the meeting at once, over 2 GB for two hours. Peak is three now. The transcription queue survives a restart. A run that takes the app down twice is dropped. Model downloads are pinned to a revision and checked against SHA-256.

EnginesUkrainian and lines Whisper invented

The goal was better Ukrainian. Research came first. No newer local model beats Whisper large-v3 on conversational Ukrainian, so the gain had to come from the merge. A Whisper vocabulary prompt was built, measured and removed: 39% slower at 40 tokens. It also pushed English terms into Cyrillic, "housekeeping" into "хаускіпінг".

In one day's log, 30 of 44 hard conflicts between the engines were Whisper writing "Дякую." over silence while Parakeet heard nothing. Whisper scores those lines as confident speech, so its own thresholds can never catch them. A stock line now needs a witness in the audio. The ones that survived inside chunks of real speech sat at 29.5 s in 26-second chunks, every time: WhisperKit pads each window to 30 s with zeros. The model writes over the padding. A stock line past the end of the real audio is now dropped. On a Ukrainian slice, inline phantoms went from 4 to 0. Two runs of one build agree on about 97% of words, so any smaller difference is noise.

Can Gemma read the whole meeting first and brief every later call? One 17k-token read took 109 s and answered in the wrong language. 6k-token reads wedged the runtime in 5 runs of 6. 3k-token sections were stable, six of them in 47 s. The 32k window is what the model claims; 3k is what this stack handles. The brief keeps only people and terms that occur in the transcript. It applies a spelling fix only when the source is in the text, the replacement is attested and the two sound alike.

QualityA reference set

Two English and two Ukrainian meeting slices, with checked text, scored end to end by word error rate. After this, a change to quality had to move the number.

  • English speech inside Ukrainian. Whisper forced to Ukrainian translates English: a sentence said in English came back in Ukrainian. Where the two engines disagree, Whisper's language detection is asked about that stretch. English is re-read and spliced back in by time. It fired on exactly the three English sentences in the Ukrainian slices and nowhere else.
  • Gemma as arbiter. Asked only "A or B" on 40 disputed stretches with a known answer, it chose right 21 times; the plain word vote managed 26. Its rulings help a little in English and not in Ukrainian, so by default they run for English only. The limit is the model itself.
  • Where it landed. Word error rate on the four slices went from 12.5, 8.1, 27.6 and 20.4% to 8.9, 5.3, 16.5 and 12.2%.

Meetings now get a quick Parakeet draft the moment a recording stops. Super refines it in the background, replacing the draft in one step when it finishes. The transcript has a Clean and a Verbatim view; the store always keeps what the engines heard.

SpeedMaking Super faster

A 42-minute meeting in Super took about 28 minutes, with CPU and GPU mostly idle. The gate that keeps Gemma from running beside another engine serialised every engine for as long as Gemma was merely loaded, so the three-wide pipeline ran single-file. It is a readers-writer lock now: Gemma takes it exclusive, Whisper and Parakeet share it. Silent chunks skip both engines; in one meeting 22 of 98 system-audio chunks were digital zero. A 6-minute run went from 241 to 183 s with the same words. Three calls in flight bought about 1.5×, because they contend for the same chip.

The next gain came from giving each chip part of the work: Whisper's encoder stays on the Neural Engine and its decoder moves to the GPU. A full Super run on the Ukrainian slices went from 330 to 233 s and from 254 to 173 s. The encoder on the GPU drops words, 32 deletions against 5 on one track, so every placement is scored for accuracy as well as speed. And with a recording open, the app used half a CPU core doing nothing: a pulsing dot animated its frame size, which is a layout change, so SwiftUI re-laid out the whole screen every frame. Idle CPU right after launch measured 0%; the screen with a recording open was not checked.

EnginesCrashes from state shared between runs

Super runs three chunks at once on one WhisperKit, which replaces a shared progress property at the end of every call. Two calls finishing together released it twice. The app crashed. The reference set caught it before any user did. The Super ensemble also lives for the life of the app. Once it learned to remember whole-track readings, a later run could read the previous recording's words at the same timestamps. The CLI could never see that: one process, one run.

Dictation froze while Super read a long meeting: a passage took 24 s to recognise, where it normally takes 0.1 s. Dictation's Gemma call had timed out but stayed in the gate's queue. Every later call waited behind it. A cancelled waiter now leaves the queue. Dictation's calls are marked interactive, so they wait only for Gemma actually running.

A review pass found that engine recovery was defined in a protocol extension. A method that exists only in an extension is statically dispatched, so calls through the protocol skipped each engine's own version. A hung Whisper call rebuilt the idle Gemma too. Now only the stuck engine is rebuilt. The same pass found the app reporting version 1.0 everywhere: the project generator filled the plist with a default. The plist wins. Dictation into a password field is now pasted and nothing else: no history, backup, preview or on-screen text.

The last releases add updates. The check is off until the user says yes; then it is one request for the public latest release, identical from every Mac. The version comparison runs locally. The update itself is one button: it downloads the release and its Ed25519 signature, verifies them against the key compiled into the app, validates the bundle and its code signature. Only then is the app swapped; if the swap fails, the old one is restored. A recording, a meeting or a dictation in progress blocks it.

The latest release came out of a full code review: more than forty fixes. A recording cut off by a crash is now repaired at launch and put back in the library. Meeting capture stops and saves when the mic device disappears. The echo canceller no longer mutes the first frames of a meeting. The models folder is kept out of Time Machine.

What part three taught

Most of these bugs were about scope: a unit held open while the app sat idle, a lock held while a model was merely loaded, a cache that outlived the run that filled it, a timeout that kept its place in the queue. And a quality claim now has to move the number on the reference set.

Part fourFrom v3.15 to v4.3

Part three stopped at v3.15.0. Twenty-three releases followed, up to v4.3.2. The suite grew from 243 tests to 353. The panel below plays them back in order: each bar is a release, taller where more Swift changed. The orange line over them is the test count. The hollow bars at the end have not happened yet.

v3.15 to v4.3 played back

Drag across the bars, or press play. A card jumps to its releases.

Version
v4.3.2
Tests
353 +110
Swift lines
37,433
Release
23 of 23

  1. v3.15.1 to 3.15.10

    Ten patches

    Recording pauses when the Mac sleeps and picks up on wake. A meeting in English and Arabic stopped coming back as Arabic once Whisper's language choice read the whole probability distribution. Three status footers had been animating at idle for 15 to 30% of a core. The compiler warnings went from 48 to none, the first step towards Swift 6.

  2. v3.16

    Live captions, the whole conversation

    Captions were five-second cuts. They are phrases now: a pause of 0.45 s closes one. Parakeet re-reads the open phrase about once a second. Switching Live off and on mid-meeting killed the feed for good, because cancelling the task that reads an AsyncStream ends the stream; each session gets its own. Super also waits while I record. Before, one meeting's Super pass ran Whisper on about 3.4 cores for twenty minutes through the next call.

  3. v3.17.0 to 3.17.4

    One turn per voice

    Meetings were read in 26-second chunks per track, each chunk one block of text; 98% of the blocks overlapped the next, so answers printed before their questions. Each line is now laid onto Parakeet's word times and cut at one-second pauses and wherever the diarizer hears a new voice. A 44-minute call came apart into its five speakers.

    Four patches followed, mostly about calls on laptop speakers, where Whisper filled my quiet mic with “Hello, I'm Joe” eight times over. The first gate against it took 1,287 of my 4,450 words out of a 72-minute coaching session; it now switches on only when the mic is about 94% echo.

  4. v4.0

    A voice print

    I read a passage once and the app keeps an embedding of my voice. In a meeting, every 1.5-second window of the mic is scored against it. Across four meetings I scored 0.49 to 0.70 and every other voice at most 0.32, so bleed from the far side no longer passes as me. The embedding model takes three waveforms per call that turned out to be tied together; packing three clips into one slot under three masks took a window from 48 ms to 7. Whisper now comes in seven models, from large-v3 down to small.

  5. v4.1

    Disputed words, underlined

    When Parakeet and Whisper read one word two ways, Super kept the winner and threw the other reading away. It keeps both now. The transcript underlines the word, a click swaps in the other reading. My pick is saved as a pair the vote obeys from then on; names join the vocabulary. One English reference slice holds 9 disputes and one Ukrainian slice 37. Every one is found again in the final text.

  6. v4.2

    The calendar goes first

    Before a meeting is transcribed, the app finds the calendar event it overlaps and takes the attendees' names and the name-like words in the invite, as vocabulary for that run only. With no vocabulary of my own, one English slice went from 9.2 to 8.5% word error rate and spelled all five names right, up from four; the other slice did not move. Since v4.3.2, subscribed calendars, birthdays, declined events and meetings I am not in are skipped.

  7. v4.3.0 to 4.3.2

    Corrections become a benchmark

    Every line I fix and every transcript I mark as checked leaves a 16 kHz clip with the approved text and what the engine first wrote. Settings shows the minutes per language and the engine's error rate against mine. The CLI runs any engine over the clips. Two review passes followed. In the second, multi-word names such as “van der” were found outranking confidence in the vote, so only single uncommon words go in. In the third, a voice print that does not fit the mic falls back to loudness. The Whisper model is pinned for the length of a run.

243 to 353 tests

Where the 110 new ones went. Turn timing holds the most, because it decides who said what and when. Across v4.0 to v4.3.2 the reference set's word error rates did not move.

  • Turn timing25
  • Voice print17
  • Pipeline logic14
  • Disputed words14
  • Meeting context12
  • Arabic9
  • Benchmark6
  • Core logic5
  • Whisper guards4
  • Whisper models3
  • Store migration1

The hollow bars next

Most of part three's bugs were about which thread held what. Swift 6 checks that when the code compiles, so the move goes in stages.

  • Strict concurrency on Swift 5. Complete checking switched on, then the new warnings worked through one file at a time, starting with Dictation and Audio, where a change of thread has caused the subtlest regressions.
  • Swift 6. The language mode flips once that step is free of warnings.
  • Each step ships as its own patch release, checked with the full suite, the dictation end-to-end script and the reference set.

Part fiveThe app learns who is speaking

Part four stopped at v4.3.2. Thirteen releases followed, up to v5.0.2. The suite grew from 353 tests to 519 and the Swift from 37,433 lines to 50,161, most of it in one release. Most of the new code is one feature: the app learns the people I talk to and puts their names in the transcript.

PeopleHow a speaker gets a name

A transcript of a meeting starts with Speaker 1, Speaker 2 and so on. Since v4.5 the app can put real names there. Part four taught it my own voice; now it learns everyone else's. In a recording I click a speaker, type who it is and press Remember this voice. The app takes that speaker's clean stretches, with nobody talking over them and at least 10 s in all. It keeps them only when they sound like one person: the samples have to agree with each other at 0.60. My own name and names I typed by hand are never changed. Every other speaker is scored against every saved voice and gets a name only when this rule says so.

When a speaker gets a name

A speaker's 3-second clips are scored against every saved voice. Move the two closest scores, or pick a case.

  • Named when the closest voice scores at least 0.50 and leads the next closest by 0.15. When two people are both that close, the speaker stays unnamed.
  • Asked from 0.40: the person's page shows “Was this them?”, with the clip to listen to.
  • Learned from at 0.55 and five clips or more: the meeting is added to that person's voice.
  • On the invite, the bar to be named drops by 0.05. The lead and the bar to learn stay where they are.
The rule

Leaving a speaker unnamed is better than naming them wrong. Close matches are asked about. Only clear matches are added to a voice.

The naming rule as the app runs it (PeopleMatch in the app's source). Thresholds for the standard model, WeSpeaker ResNet34; the high-accuracy model names at 0.48 and asks from 0.38. The ticks mark the calibration on 381 clips of 23 people: the right person scored at least 0.52, anyone else at most 0.47.

The thresholds were measured. Speakers are matched on 3-second clips, the length voices are learned from; on 1.5-second clips the same model told two people apart half as well, 12.5% errors against 6.4%. Every threshold was set on 381 clips of 23 people from my meetings. The first round of measurements was wrong: the voice model takes three clips per call. A batch of four or five left a slot of zeros that came back as one or two garbage readings in most matches. Spare slots now repeat the batch's own clips.

Settings has a second voice model, SimAM-ResNet100, a 100 MB download, which told people apart about twice as well on the same clips. Its scores sit on their own scale, so it names at 0.48. Switching rebuilds every voice from the recordings it was learned from. Switching back is instant.

Since v5.0.2 the calendar helps. A saved person on the meeting's invite is named at 0.45. In a call with up to three other people, when one speaker who talked for at least 20 s and one invitee are left over, the speaker gets that name. The voice is never learned from it. The sheet that opens on a click shows the invitees who are not named yet:

Five people not named yet

The row above a meeting's transcript and the sheet a click on a speaker opens. Rebuilt from the app; the names are made up.

Since v5.0.2 the sheet lists the people on the calendar invite who are not yet a speaker in this recording. Picking one names the speaker and, with the box ticked, starts learning their voice.

v5.0 gives people a section of their own in the sidebar. This is what is in it:

The People section feature by feature

Voices are learned and matched on this Mac. The text features use whichever text engine is set; with a cloud engine, the page says that the person's lines are sent to it.

  • Person page

    Everything they said, in one place

    Every recording they speak in, how long they talked and the last thing they said there. A click opens the recording at their first line, with their lines marked and Previous and Next to step through them.

  • Confidence

    How sure the app is

    A percentage and a level, with the reasons: how much of their speech was learned, from how many recordings, how alike the samples are and how close they sound to anyone else saved. When the level is lower than the percentage suggests, the page says why.

  • Improve from my recordings

    Better samples from old meetings

    Picks the meetings they are already named in, takes their longest clean stretches and keeps the result only if it makes them easier to tell apart. Up to 120 samples, taken from every recording in proportion. Each sample shows where it came from, the odd one out is marked. Any one can be forgotten.

  • Was this them?

    Asking about close matches

    A speaker who sounds like someone saved, though not clearly enough to name, shows up on that person's page. Listen, then Yes names the speaker and adds the voice; No and that speaker is never asked about again.

  • Find people

    Naming people in old recordings

    Listens to every recording already there, names the people it is sure of and asks about the rest. Run it after updating or after training someone new.

  • People you meet often

    Suggestions for who to add

    Voices that come back in at least three of my meetings in the last 90 days and sound like no one saved, plus names I typed in several recordings with no voice behind them. Type who it is and they are named in every one of those recordings, their voice learned from them.

  • What they said

    A brief per person

    What they mainly said in each meeting, then what they committed to, the questions they left open and their latest word on each topic. A promise made again in a later meeting shows once, from the newest. Each item opens the recording where it was said and can be marked done; the period can be copied as text. Refresh reads only what is new.

  • How they speak

    Speaking stats per person

    Talk share, pace, the longest turn, questions and cutting in, over their last ten meetings, plus how the words split between them, me and everyone else in the meetings we share.

  • You

    My own stats and goals

    The same measures for me, per language, with talk share against a fair share so a 1:1 and a group of six count alike. Up to two goals, worded from my own lines. A goal is reached when met in three of my last four meetings, rethought after six meetings or three weeks without movement, replaced after two rethinks.

  • Rename, merge

    Renaming and merging

    Renaming a person renames them in every recording, saved versions included. Two entries for the same person merge into one, with their voices and history. Two speakers of one recording with the same name are one person.

  • Kept here

    Voices stay on the Mac

    Saved voices live beside my own voice print, readable only by me, kept out of Time Machine and never copied into the backups in Documents. Deleting a person also deletes what was read from their meetings, a read still running included.

From the v5.0 to v5.0.2 release notes. Speech engines leave many fillers out, so the filler counts read low.

SpeakersSplitting a recording into speakers

Before anyone can be named, the recording has to be split into speakers: who talks when. A model called a diarizer does that. Mine was making up extra speakers. The offline diarizer left a cluster of a few seconds in most meetings, 4.1, 4.5 and 5.4 s in three of them where every real voice had at least 24 s. Each became a speaker with three to ten words. A cluster under 8 s and under 3% of the speech now joins the nearest real voice. That fix deleted short replies in turn: the diarizer drops speech under a second, so a quick “yes” between two stretches of me talking sat outside every region and looked like noise. The call track's own sound timeline decides that now.

v5.0 runs Nvidia's Nemotron 3 first, through FluidAudio on the Neural Engine, which leaves the GPU to Whisper. It marks speech word by word, so a speaker's pauses of up to a second are joined. Two labels whose voices reach the same-person bar of 0.62 are merged. That merge fixed one voice under two labels when a recording starts mid-sentence, because Nemotron's speaker memory is empty on the first chunk.

Nemotron 3 against the offline diarizer

Benched through the app's own code path. Shorter is better on every row.

Diarization error AMI test set

Offline diarizer24.2%
Nemotron 313.8%

Speaker confusion AMI test set

Offline diarizer2.9%
Nemotron 30.6%

People split in two 34 people on real calls

Offline diarizer6
Nemotron 34
Falls back to the offline diarizer when
  • more than 8 voices are expected
  • all 8 of Nemotron's slots are in use
  • it finds more voices than the upper bound
  • an exact speaker count is set and not met
AMI scored with no collar. On the reference set, word error rate and speaker labels did not move.

MeetingsEcho from laptop speakers

When a call plays over laptop speakers, the mic hears the other side too. The transcript gets their words twice: once from the call, once, late and garbled, from my mic. Part three made Apple's voice processing, which removes that echo, an option, off by default, because it ducked every other app by 15 dB for as long as Transcriberr ran. Echo was left to my own chain after the call. On laptop speakers that chain still let about a hundred short bursts through on a 29-minute call, 22 s of doubled syllables, 60 to 75 ms late, because its last stage either mutes a frame or leaves it. v4.6 brings Apple's filter back for meetings only: a second audio engine with voice processing listens to the same mic while a meeting records. Its cleaned sound replaces the raw mic. It is created at start and released at stop, so other apps are quieter only while a meeting records.

Echo in my mic with and without Apple's filter

MacBook speakers, the call played by another process. Lower is quieter.

Echo, raw mic −20.7 dBFS
With Apple's filter −64.9 dBFS
Talking over the call

In the test, every word I said over the far side came through. None of the far side's.

Other apps get quieter

v3.2: −15 dB, for as long as the app ran. v4.6: about −4 dB, only while a meeting records.

When it is off

With headphones there is no echo, so the filter stays off. Dictation is unchanged.

−64.9 dBFS is the noise level of the room itself. The offline canceller still runs after the call and finds no echo path in a filtered mic: peak correlation 0.01 to 0.04, under its threshold of 0.1.

EnginesThe ten models in the app

Every step of the app that listens or writes runs a model. Since v5.0 there are ten, for four jobs: speech to text, who spoke, whose voice it is and the text afterwards. Two of them are made here: Parakeet UK (trained on this Mac) and the Core ML build of the high-accuracy voice model. A Ukrainian Whisper is in training. The smaller Whisper models that can be picked are left out.

Ten models for four jobs

Where each one runs, where it comes from and what it is for. Orange is trained or converted here.

Speech to text

  • Parakeet UKv3 fine-tuned for Ukrainian, version 2 · Neural Engine · 630 MB from Hugging Face · one side of Super by default
  • Parakeet v3stock, 25 languages · Neural Engine · FluidAudio
  • Parakeet v2English only · downloaded only when picked
  • Whisper large-v3every language but English · Neural Engine, decoder optionally on the GPU · WhisperKit
  • Whisper large-v3 turboEnglish, compressed · about 4× faster in Super
  • Whisper turbo UKstill in training

Who spoke

  • Nemotron 3Nvidia's diarizer · Neural Engine · first since v5.0
  • pyannotethe offline diarizer · Core ML · the fallback past 8 voices

Whose voice

  • WeSpeaker ResNet34the standard voice model · through FluidAudio · my voice print and everyone saved
  • SimAM-ResNet100high accuracy, converted to Core ML here · 100 MB · non-commercial licence

The text

  • GemmaLiteRT-LM · text on the GPU, retried on the CPU · presets, titles, briefs, the referee in English

Cloud engines for OpenAI, Anthropic and Gemini are in the code, off until a key is added. They are not counted.

Every model runs on the Mac. The downloads are pinned and checked file by file, or, for Parakeet UK since v5.0, follow the newest release and check it against Hugging Face's checksums.

EnginesA faster Whisper for English

Super is the app's most accurate mode: two speech engines, Whisper and Parakeet, read the same audio and vote word by word. Whisper is the slow half. v4.0 brought seven Whisper models. A sweep of the reference set, four meeting slices with checked transcripts, showed the trade. Inside Super, every smaller model tied or beat large-v3 in English at two to four times the speed. In Ukrainian they lost 5 to 15 points of word error rate. One setting for every language forced a choice between speed and Ukrainian, so v4.4 split it in two.

Two Whispers one per language

Super runs Whisper and Parakeet v3 on the same audio and votes. Here only the Whisper model changed, on the two English reference slices.

Time for a Super run shorter is faster

Slice A · large-v3189 s
Slice A · turbo51 s
Slice B · large-v3167 s
Slice B · turbo46 s

Word error rate shorter is more accurate

Slice A · large-v310.8%
Slice A · turbo8.7%
Slice B · large-v35.1%
Slice B · turbo5.3%
Since v4.4
  • English: large-v3 turbo, compressed. About four times faster in Super, with the same accuracy.
  • Every other language, auto-detect and mixed included: large-v3. The smaller models lost 5 to 15 points in Ukrainian; the Ukrainian slices stay at 18.3% and 15.5%.
  • The model is pinned for the length of a run. Versions names it.
Measured with the reference-set script, one slice at a time. Slice B's 0.2 points are the run-to-run noise.

At the first launch, a smaller model picked earlier for speed moved to the English slot. Other languages went back to large-v3.

EnginesParakeet UK, my Ukrainian model

Parakeet UK is Nvidia's Parakeet speech model, fine-tuned by me for Ukrainian; how it was trained is its own article. In the app it began as files copied by hand over the stock model, so the label said Parakeet v3 whatever was inside. v4.7 made it an engine of its own, downloaded from Hugging Face. That exposed a problem in FluidAudio: it answers a failed load by wiping the folder and filling it with stock v3, so the fine-tune could have run as stock under its own name. A load that leaves no UK config behind is thrown away. In Super it now counts as much as Whisper in Ukrainian; stock v3 counts half.

How much the training helped: ten Ukrainian test sets were kept out of training. On a one-hour sample of each the models score like this:

Ukrainian model by model

Mean word error rate over the ten sets. Shorter is better. Orange is trained here.

Parakeet v3, stock11.06%
Whisper turbo, stock11.05%
Whisper large-v39.79%
Whisper turbo UK, first run8.97%
Parakeet UK, version 28.37%
English inside Ukrainian
  • On 200 clips with an English sentence between two Ukrainian ones, large-v3 writes 0.3% of the English words in English.
  • The first Whisper turbo UK run writes 10.3%: training on Ukrainian with English mixed in kept more English.
  • The rest are dropped or translated. Whole English sentences are still often lost.
From the training project's results table. Whisper large-v3 runs through the app's own CLI; the others run in PyTorch, so the Core ML builds the app ships can differ slightly. The published model card has Parakeet UK at 8.29% on the same samples in Core ML.

v4.7 pinned one release of the model. v5.0 follows the newest one and checks it before use:

A new Parakeet UK release checked before use

Once a day, only when update checks are allowed and only on Macs that use Parakeet UK.

  1. 1Ask

    Resolve the model repository's main branch to one commit. A commit that only changes the model card downloads nothing.

  2. 2List

    That commit's files, with Hugging Face's own checksums: SHA-256 for the large files, the git blob hash for the rest.

  3. 3Refuse

    A release that misses a part FluidAudio needs, has a path outside the folder, lacks a checksum or is over 1.5 GB.

  4. 4Download beside

    Into a folder next to the model in use, from that exact commit, every file verified.

  5. 5Switch at launch

    At the next start, before any engine opens the files.

The request is the same anonymous one as the app's update check. On an empty home folder, version 2 (632 MB) downloaded and checked in about 45 s, then transcribed a held-out clip correctly. The once-a-day loop inside the running app has not been exercised end to end.

Super's default pair is now Whisper and Parakeet UK, so a new Mac never downloads the English-only Parakeet v2 unless someone picks it.

EnginesTraining Whisper for Ukrainian

In Ukrainian, Super's two engines are Parakeet UK and Whisper large-v3. Only Parakeet UK was trained for the language. The plan is to train Whisper as well. Turbo is the one to train: its encoder is large-v3's and already hears Ukrainian, so the encoder stays frozen and only the four-layer decoder learns, on 30-second Ukrainian windows with English mixed in, so that English is not forgotten. The training runs on this Mac.

Whisper turbo UK the plan

Five steps from a trained decoder to an engine in the app. Black is done, orange is running, white is still to come.

  1. 1B1: plain

    One epoch on the transcripts alone. Validation word error rate 11.7% → 7.4%. Better than large-v3 on six of the ten sets; worse on podcasts and public videos.

  2. 2The teacher

    large-v3 reads the training set once and keeps its top guesses at every step of each transcript. Part-way through.

  3. 3B2: distilled

    The same training, with half of the lesson coming from large-v3's guesses, so turbo also learns which other words large-v3 thought were likely.

  4. 4Core ML

    The better of B1 and B2 is converted for WhisperKit and scored again as the app would run it.

  5. 5Into Super

    Scored on the reference set inside Super, next to Parakeet UK. It ships only if it lowers the error rate.

Distillation settings: weight 0.5, temperature 2.

If it ships, both of Super's engines in Ukrainian will be trained for it. Super will also run at turbo's speed in Ukrainian too. Until then, Ukrainian runs on large-v3 beside Parakeet UK.

Library, enginesSmaller fixes, v4.3 to v5.0

Changes from the same thirteen releases that did not need a section of their own.

  • Nothing generated is thrown away. Generating a summary again archives the old one with the engine and prompt that made it. Each preset keeps its earlier prompts, up to 30. Export to a folder writes a recording's transcript, versions, outputs and their history, with a SHA-256 manifest.
  • An empty result means nothing heard. A Whisper chunk stopped at its 100 s budget used to return an empty string, as if it were silence. Twenty bench clips run in parallel all came back empty with no error. It throws now, the part is tried once more. The run note says how many parts stayed empty. In Super, the other engine's reading of that part stands.
  • A busy GPU. While a training job held the GPU, Gemma's read-back timed out after 20 s on prompts past about 2,500 tokens and came back null. That call is retried on a CPU engine built on first need and dropped after five idle minutes. The GPU engine is rebuilt, because once it fails it fails every later call. Gemma's thinking mode was tried on titles, briefs and the speech coach. It stays off: no gain in Super, a lean towards deleting words in Ukrainian, three to four times slower.
  • Titles from the whole recording. Titles came from the opening greetings, such as “Happy Friday Work Updates”. The text engine now reads six slices across the recording, skips the first 90 s of a long one and is told to name the subject. Two meetings kept their default names because the error was swallowed without a log line; every outcome is logged now. Untitled recordings from the last week are named at launch.
  • AI assistants. An MCP server that lives for days now notices when its binary changes and execs itself in place: same process, same pipes, so the client's connection survives. The requests it had read are handed over. One switch in Settings turns off library access for every AI assistant. And the CLI prints word-level start and end times as JSON, for a tool that lines audio up against a known script.
What part five taught

Most of part five comes down to thresholds: a name at 0.50 and 0.15 ahead, a speaker under 8 s folded into another, a voice sample added at 0.55. Each one was set on clips with a known answer. Where the evidence is thin, the app asks or keeps the words.

Known problemsWhat still doesn't work in v5.0.2

Gemma, the local text model behind summaries, titles and briefs, can still hang. A stuck engine now rebuilds only itself. A call that fails on a busy GPU is tried again on the CPU. As Super's referee its rulings help a little in English and not in Ukrainian, so by default they run for English only. For Ukrainian, Whisper large-v3 and Parakeet UK anchor Super. A Ukrainian Whisper turbo is in training; its first run beats large-v3 on six of ten test sets. It joins Super only if it moves the reference set. Swift 6 has not started: the app still builds in Swift 5 with minimal concurrency checking. The once-a-day check for a new Parakeet UK release has been tested through the CLI and not yet inside the running app. The first dictation after an install can come back empty while Parakeet compiles for the new binary. Upgrading from a build before the signing change still needs the one-time Accessibility re-add. The suite is at 519 tests. Every quality change is scored on the reference set.

Transcriberr runs Parakeet, Whisper and Gemma fully on-device on Apple Silicon. Part one published August 2026, parts two and three September 2026, parts four and five October 2026.
Yuri Ihnatov — ihnatov.nl · github.com/Ihnatov-yuri