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.
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.
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:
- 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.
- 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.
- 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.
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.
Prompt for judgment, code for mechanics, chunk everything.
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.
Agreement goes straight in, your vocabulary settles its own words, confidence picks the rest. The editor-in-chief sees only chunks that mostly disagree.
Verbatim shortcuts only at near-certainty. Everything else gets a vote.
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.
Pick a step to follow the audio, or read the whole path at once.
A new capture layer on the same pipeline, with meeting-only steps added later.
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.
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.
Pick a path to trace it; the rest of the map dims.
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 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.
Pick a layer to light it. Everything is on the Mac; the only dashed box is off by default.
The models are borrowed. The Yuri code sits around them: the engine protocol, the vote, the queue, the text mechanics.
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.
-
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.
-
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.
-
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.
-
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.
-
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.
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.
Pick a stage to light it. The orange marks the two things that had to be learned.
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.
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.
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.
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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
Leaving a speaker unnamed is better than naming them wrong. Close matches are asked about. Only clear matches are added to a voice.
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.
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.
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
Speaker confusion AMI test set
People split in two 34 people on real calls
- 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
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.
In the test, every word I said over the far side came through. None of the far side's.
v3.2: −15 dB, for as long as the app ran. v4.6: about −4 dB, only while a meeting records.
With headphones there is no echo, so the filter stays off. Dictation is unchanged.
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.
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
Word error rate shorter is more accurate
- 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.
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.
- 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.
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.
- 1Ask
Resolve the model repository's main branch to one commit. A commit that only changes the model card downloads nothing.
- 2List
That commit's files, with Hugging Face's own checksums: SHA-256 for the large files, the git blob hash for the rest.
- 3Refuse
A release that misses a part FluidAudio needs, has a path outside the folder, lacks a checksum or is over 1.5 GB.
- 4Download beside
Into a folder next to the model in use, from that exact commit, every file verified.
- 5Switch at launch
At the next start, before any engine opens the files.
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.
- 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.
- 2The teacher
large-v3 reads the training set once and keeps its top guesses at every step of each transcript. Part-way through.
- 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.
- 4Core ML
The better of B1 and B2 is converted for WhisperKit and scored again as the app would run it.
- 5Into Super
Scored on the reference set inside Super, next to Parakeet UK. It ships only if it lowers the error rate.
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.
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