blog · · Mohak Bajaj
Eleven ways dictation fails, and the fix for each
Jot has push-to-talk dictation. Hold fn, talk, let go, and the words are typed into the app in front of you. With a local model, nothing leaves the Mac. The model is the easy part. Most of the work is everything around it: when recording starts and stops, what counts as silence, what you feed the model, and what you do with the text it gives back.
These are the ways it went wrong in Jot, and the fix for each. All of them shipped in Jot 2.0.
1. The last word gets cut off
People let go of the key while the last word is still sounding. If recording stops at key-up, the model hears "send it to Sa" and guesses. Jot keeps recording for 200 ms after release. If the newest 100 ms still has speech in it, the tail grows, up to 600 ms.
/// Audio kept after a normal stop: a release often lands while the last /// word is still sounding. pub const POST_RELEASE_TAIL: Duration = Duration::from_millis(200); /// The longest the tail grows while speech carries on past it. pub const MAX_RELEASE_TAIL: Duration = Duration::from_millis(600); /// How much of the newest audio decides whether speech carries on. pub const TAIL_LOOKBACK: Duration = Duration::from_millis(100);
2. Quiet speech is thrown away as silence
A dictation app has to drop clips with no speech, or the model invents text for a silent room. Jot 1.1 took one RMS over the whole clip. That failed on a common case: hold the key, think for a few seconds, then say a short sentence quietly. The long quiet part averaged the speech away, and the clip was dropped.
Now the check is on the loudest 30 ms window, with the DC offset removed. If any window reaches -50 dBFS, the clip is speech. A whisper is well above that. A quiet room's noise floor is below it.
pub const SILENCE_DBFS: f32 = -50.0;
const SILENCE_WINDOW: usize = 480; // 30 ms at 16 kHz
pub fn loudest_window_dbfs(samples: &[f32]) -> f32 {
let loudest = samples
.chunks(SILENCE_WINDOW)
.map(|window| {
let count = window.len() as f64;
let mean = window.iter().map(|&s| f64::from(s)).sum::<f64>() / count;
let variance = window.iter().map(|&s| (f64::from(s) - mean).powi(2)).sum::<f64>() / count;
variance.sqrt()
})
.fold(0.0, f64::max);
(20.0 * loudest.max(1e-6).log10()) as f32
}3. The first dictation after launch is slow
Two things made it slow.
- Hashing the model. Local models are hundreds of MB to a few GB, and Jot checked the SHA-256 before it loaded one. Now a check that passed is saved next to the model as a small receipt with the size, hash and modification time. The next launch reads the receipt and does not hash gigabytes again.
- A cold model. The first run of a model is much slower than the second. So when you press the key, Jot sends the model 1.5 s of silence and drops the result. By the time you stop talking, the model is warm. A model that ran in the last 60 seconds skips the warm run.
4. Short clips and lost punctuation
"Yes." is a 400 ms clip, and some models do badly on audio that short. Jot pads every clip with silence to at least 1.5 s. Parakeet models also get 400 ms of silence at the end, so the last sentence keeps its full stop or question mark.
const MIN_CLIP_SAMPLES: usize = SPEECH_RATE as usize * 3 / 2; // 1.5 s
const PARAKEET_TAIL_SAMPLES: usize = SPEECH_RATE as usize * 2 / 5; // 400 ms
fn pad_clip(model: &LocalModel, mut pcm: Vec<f32>) -> Vec<f32> {
pcm.resize(pcm.len().max(MIN_CLIP_SAMPLES), 0.0);
if model.id.starts_with("handy-computer/parakeet-") {
pcm.resize(pcm.len() + PARAKEET_TAIL_SAMPLES, 0.0);
}
pcm
}5. AirPods turn into a phone call
If your AirPods are the default input, opening their microphone switches them to the Bluetooth headset profile. Your music drops to call quality for as long as you dictate. Jot now checks each input's transport type through CoreAudio. If the default is Bluetooth and a built-in or wired microphone exists, Jot records from that. A Bluetooth mic is still used when it is the only one.
6. The model says things you did not say
Speech models trained on internet audio have habits. Jot now removes four of them:
- Prompt echo. Whisper and Qwen3-ASR sometimes return the context prompt word for word when they hear noise. If the text only repeats the prompt, Jot drops it.
- Subtitle credits. Models trained on subtitled video add lines like "Sous-titrage Société Radio-Canada" after a pause. Jot strips a known list of them from the end.
- Silent pieces. A long clip is cut at pauses for models that cannot decode long audio. A piece with no speech is left out, so the model has nothing to invent text for.
- Repetition loops. When a model gets stuck repeating a phrase, the engine stops it with an error. The text before the loop is usually fine, so Jot keeps that, not nothing.
7. Names come out wrong
You can give Jot a dictionary: names, product words, jargon. The terms now reach the model. Whisper gets them as a context prompt, capped at 500 characters, because Whisper drops an overlong prompt from the front and the first terms would be lost. Qwen3-ASR gets up to 1,000 characters. Models that take a vocabulary get up to 64 terms.
8. "New line" without a cleanup model
In English, "new line", "new paragraph", "open quote", "comma" and "colon" now work with no AI cleanup at all. Matching needs whole words, so "newline" and "renew line items" stay as they are. A mark after an article or a possessive is a noun, so "add a comma" types the word, not the mark.
9. Dictation pollutes your clipboard
Jot types by pasting: it puts the text on the clipboard, sends ⌘V, then puts your old clipboard back. That had two problems. Clipboard managers saved every transcript. And if you copied something in the next half second, the restore overwrote it.
Now the transcript is written with the org.nspasteboard.ConcealedType and transient markers, so clipboard managers skip it. The old clipboard only comes back if the pasteboard's change count is still the one from Jot's write. If anything else wrote in between, it wins.
let before = ports.read()?; // the user's clipboard
let ours = ports.write_transient(body)?; // change count after our write
if !ports.paste() {
return Ok(PasteOutcome::Copied);
}
ports.restore_later(before, ours); // only if the count is still ours10. The GPU fails and dictation stops
Local models run on Metal when they can. A failed Metal command buffer can crash the engine, and the same failure would come back on every clip. Jot now sorts failures. A device or backend fault moves the model to the CPU for the rest of the session. A GPU out-of-memory error retries only that clip on the CPU. A failure the CPU would repeat, like a bad request, is not retried. A stuck run is stopped after 180 s plus 8 s for each second of audio, so a long dictation is not cut off early.
11. A model download dies at 90%
Downloads now write to a .part file. If the network drops, the next attempt sends a Range request and continues from where it stopped. If the server ignores the range, Jot starts over and does not append a second copy to the first.
What I learned
None of these needed a better model. Each one was a small rule at the edge: a few hundred milliseconds of audio, a threshold, a flag on the clipboard. But users notice them more than word error rate. A dictation app that drops your last word feels broken, even when the model is perfect.
Jot is at sayjot.xyz. Hold fn and try it.