MicJot 0.3.0
Released 2026-10-27.
A production review of 0.2.0 on 2026-10-26 (docs/REVIEW-2026-10-26.md) left 152 verified findings, and nine fix packages worked through all of them (records in qa/rounds/fix-2026-10-26/, outcome per finding at the top of the review). A three-round tester–fixer loop on the installed build followed (qa/rounds/qa-2026-10-27/round-{1,2,3}/): round 3 ended with verdict PASS, 379 unit tests, 42 snapshot baselines matched and the 13-check self-test passing on the installed app.
Writing (rule-based path, used without an Anthropic key)
- A spoken "point" before a number is a leading decimal only where a value is introduced ("the margin is point five percent" → "0.5%"); after a noun or pronoun the word stays ("at this point five percent of users" keeps "point"). "around five to ten users" is a range again, not "9:55 users": a clock phrase converts only when what follows the hour reads as a time (a meridiem, a time or function word, a pronoun, the sentence end), never before a count, unit, scale or currency word.
- "at the rate", "underscore", "hashtag" and "star sign" are no longer rewritten outside an address, identifier or tag context: "these findings underscore significant risks" keeps the verb, "variable underscore name" → "variable_name", "hashtag micjot" → "#micjot", "hashtag activism is on the rise" keeps the word.
- With the language set to anything but English, or detected as such with "Detect automatically", filler removal, stutter collapse and smart formatting are skipped (they are built for English), so a German "um" is kept. Spacing and sentence capitals are still fixed.
- No more half-conversions: "several hundred" stays words; "one point twenty five" converts whole (1.25) or not at all; a linked pair decides together ("from one point twenty to one point fifty" → "from 1.20 to 1.50", "one twenty over eighty" keeps every word); a ratio or proportion never shares a scale word ("one in four hundred" → "1 in 400", never "100 in 400"); "one to two thousand" → "1,000 to 2,000" in one style.
- "agenda colon two thirty pm" → "agenda: 2:30 PM"; "percentage point", "three degrees from Harvard" and "120 over 80" keep their words ("over" is "÷" only inside an equation).
- "a dot net developer" → "a .net developer"; "the second may be better" keeps its words; acronyms HM and UM survive filler removal; "what it is is important" keeps both copulas; a recognizer's "..." no longer capitalises the next word; an email address or URL opening a sentence keeps its case.
- "hyphen" joins two plain words ("co hyphen founder" → "co-founder"); "newline" is a layout command; a mid-line "dash" stays the word on purpose (a missed conversion is preferred to a wrong one).
- The word-safety test has no wholesale exemption any more: a command, symbol or currency word may disappear from a golden row only when its written form appears in the output. The remaining edge cases are listed in
docs/KNOWN-LIMITATIONS.md.
Speech engine and models
- Recordings shorter than 1 s are decoded (padded to 1.1 s) instead of ending as "Didn't catch that".
- Model files are pinned to one Hugging Face commit with every file's size and SHA-256. An interrupted download is recognised as incomplete and resumed, a file that fails verification is re-fetched, Retry works, and the progress bar follows the bytes on disk instead of sitting near 25%. Switching models mid-download lets the newest request win; a cancelled download keeps its partial files.
- Dictations over 30 s are cut at pauses MicJot finds itself (never at the 30 s mark, never leaving a tail under 3 s) and decoded window by window; the language is detected once on the first window and kept for the rest.
- A low-confidence segment is dropped only with evidence of silence, never for low confidence alone; Whisper's repetition loops are collapsed only when the recognizer flags repetition and the count exceeds anything a speaker says (16 repeats of a word, 8 of a phrase), so "Testing, testing, …" × 8 is kept as spoken.
- The steady-noise gate judges 3 s windows and uses the crest factor, so quiet speech over a fan is recognised while fan-only recordings are still rejected.
- The dictionary prompt is bounded to 64 tokens per window, starred entries first, instead of being truncated from the front.
- A failed model load counts as an engine failure: with the fallback on and a cloud key the cloud takes the dictation and the model is prepared again afterwards.
- The model is released under memory pressure (after 120 s idle on a warning; at once under critical pressure) and reloaded on the next dictation; one content-free log line records every load and its time.
- Cloud transcription uses an ephemeral session; provider error bodies are neither shown nor logged.
Dictation pipeline: recognised speech never goes nowhere
- A transcription failure or a processing timeout saves the audio as a pending dictation, finished when the model is ready or at the next launch, and the notice says so truthfully (or says why it could not be saved).
- Escape during processing keeps the rule-based text in History with ⌃⌘V armed ("Not inserted. ⌃⌘V pastes it."), or puts it on the clipboard when History cannot hold it; a held Escape is one press.
- Sleep, screen lock or a user switch during a recording or while transcribing sends the dictation to History, never into whatever is focused after unlock; a dictation finishing while the screen is locked goes to the clipboard. Quitting saves waiting and unfinished speech; a paste in flight is finished first.
- A model finishing its load during a recording no longer discards the recording or leaves system audio muted.
- Command mode without an Anthropic key inserts the text as a dictation and says so; a Command whose AI call fails keeps the instruction in History (or on the clipboard when History is off); Command mode needs the modifier held at release or for 300 ms, so a brushed Control key no longer swallows a dictation.
- Fn chords (Fn+Delete, Fn+arrow) under 0.25 s discard the recording silently, as before; a chord held longer finishes the dictation normally. A short utterance on a cold microphone, or a start lost to a main-thread stall, says "MicJot was still starting; please say that again."
- AI cleanup has a 10 s budget and a circuit breaker (a key error pauses AI until the key changes; three network failures or timeouts pause it for five minutes); an empty answer is handled; the answer is validated before insertion.
- "Detect automatically" survives a relaunch; a model chosen in Settings is applied when the current recording ends, not only while the Settings page is on screen; the live chime plays before the output is muted.
- "Use the cloud engine while the on-device model loads" is its own setting, off by default; the fallback toggle is disabled with a caption while it would do nothing.
- Every History entry records its insertion outcome (verified, unverified, Accessibility, unobserved, clipboard, failed, cancelled), shown as a badge; usage statistics count what reached the app.
- Several waiting dictations finishing together are one clipboard write and one notice; ⌃⌘V runs through the job queue; history is written off the main actor.
Privacy
- History can be turned off (Settings → Privacy) and has a retention setting (90 days by default; 7, 30, 90, 365 days or forever).
history.jsonand the pending-audio files are mode 0600; the support folder and the pending-audio folder are 0700, and the support folder is excluded from backups; an existing install's files are tightened at launch. - Nothing dictated into a password field, under Secure Event Input, or into an excluded app (password managers and terminals by default; the list is editable) is stored, kept for ⌃⌘V or saved as pending audio; when such a text cannot be pasted it is left on the clipboard with the concealed marker rather than stored.
- A session-only "Private dictation" switch in Settings → Privacy and the status menu stores nothing while on.
- Polish and Command no longer store other apps' selected text; selections are read through Accessibility and copied with ⌘C only when the app does not expose them; the log carries no home path and no provider error text.
- Onboarding and Settings state exactly what leaves the Mac and when (Anthropic: transcript or instruction plus selection, app name, tone, dictionary words, nearby text when context is on; OpenAI/Groq: audio), and that MicJot observes keyboard events system-wide through an event tap and acts only on its own keys. The privacy policy, terms and acknowledgements are bundled in the app and open locally.
- MicJot's clipboard item carries the Transient, AutoGenerated and Concealed markers; a Concealed item on the user's clipboard (a password manager's) is never retained or written back. The microphone usage text names the real standby behaviour.
Keyboard, event tap and text insertion
- The event tap and the HID monitor run on their own thread. The tap callback decides synchronously from a pure router and never waits on the main thread, so a stall inside MicJot no longer delays keystrokes in other apps. The self-test gates keystroke latency through MicJot's tap: idle p50 ≤ 4 ms and p95 ≤ 12 ms, p95 ≤ 20 ms while recording or inserting (measured idle p50 about 2 ms, p95 about 7 ms).
- Synthetic ⌘V and ⌘C and the global ⌃⌘V / ⌃⌘P use the key code of the active keyboard layout (Dvorak, Turkish-F, …); Settings shows the resolved keys.
- ⌃⌘V is swallowed only when there is a last dictation and the new Shortcuts toggle ("Paste the last dictation with ⌃⌘V") is on, so Word and Excel's Paste Special work otherwise.
- Right Command, Right Option and Control+Option wait 150 ms before recording, so an ordinary shortcut or a Cmd-click never opens the microphone; Fn starts at once. Ctrl+Opt+Cmd shortcuts no longer produce bogus finishes; a tap rebuilt mid-hold keeps the held key; a disconnected keyboard releases a held modifier.
- Secure Event Input is detected: the bar and Settings say the dictation key is paused while another app holds it.
- Paste verification accepts only the field growing by the pasted length, the caret landing after the text, or a new occurrence of the text; a click or typing during the poll no longer restores the clipboard early, and a field that keeps changing is settled by reading its value instead of polling for 2 s.
- Without Accessibility trust ⌘V is not posted; the text stays on the clipboard with a notice that says so.
- Accessibility calls carry a 0.25 s timeout on every element, so an unresponsive app cannot freeze MicJot; the paste target follows the system-wide focused element, so the Accessibility fallback never writes behind a Spotlight, Raycast or Alfred panel; IntelliJ-platform apps (Android Studio) are treated as unverifiable;
AXManualAccessibilityis switched off again after each dictation. - Clipboard capture plans from the declared types only: file promises, Universal Clipboard and large items are never materialised (images, promises and Universal Clipboard items are not restored; the dictation then stays on the clipboard). The insert log line carries the active input source.
AI cleanup (Claude)
- The retry loop is tested against a stubbed transport; it sleeps only on transient failures, and a 429 whose
retry-afterexceeds 5 s fails at once so the dictation falls back to local cleanup. - Polish and Command keep the selection's leading and trailing whitespace, so a triple-clicked paragraph keeps its newline.
- The request shape follows the model id (thinking parameter per model family); Settings hints when a model id looks wrong. Selections longer than 20,000 characters are refused before any request; token usage is logged as counts.
- Prompt data blocks use collision-free tags, so text from a field cannot close a data block or forge an instruction.
ClaudeClienttakes a configurable endpoint and credential header (x-api-keyor Bearer) for a future proxy, and isSendablewithout@unchecked.
App shell and Settings
- About window: version and build, a "QA build" badge when the QA entry points are compiled in, the three policy documents, "Check for Updates…" (present only when a feed is configured in the build), "Save Diagnostics…" and "Move to Applications…" when the copy is not installed. The same items are in the status menu.
- A copy launched from Downloads or an App Translocation path is offered a move to Applications before anything else; "Open at login" is disabled with a note until then.
- A newer copy replacing an older running one asks it to hand over (its waiting speech is saved) instead of killing it, and waits visibly until it has quit.
- The model download starts only on the Download click, in onboarding and in Settings alike, with size, speed, time left and Cancel; the size is one rounded figure everywhere ("~630 MB" for Large v3 Turbo).
- Microphone picker shows transport and input count, warns about virtual and aggregate devices, and meters the chosen device while the Settings window is on screen; "Keep the microphone ready" has a standby picker (30 s, 60 s, 90 s, 3 minutes; 90 s default) and the mute toggle reports devices it cannot mute.
- Settings → Transcription exposes the language (with what it affects) and the maximum dictation length; Settings → AI writing (Claude) exposes the Claude model id; new Privacy and "Updates and support" sections; Style has "Add an app".
- Permissions are polled only while a checklist is on screen; a revoked Accessibility grant is shown as such with the remove-and-re-add hint; Fn conflicts with the Globe-key action and the macOS Dictation shortcut are checked and named.
- VoiceOver: the bar is an accessibility element, notices are announced, row actions are always rendered; Reduce Motion stops the animations; text styles follow the system text size; strings are wrapped for localisation. Level updates re-render only the waveform; the 15 Hz hover timer is gone; the waveform runs at 30 fps and pauses when idle.
- Logout, restart and shutdown are never cancelled by the quit prompt; the main window reopens at its saved position; "Run setup again" works; API-key Save reports a failed Keychain write; the login-item toggle reflects what
SMAppServicereports.
Audio capture
- The microphone stays open for 90 s after each dictation instead of 30 s: on real usage 30 s covered two-thirds of the gaps between dictations, 90 s covers three-quarters. The orange microphone indicator stays on for the whole standby; "Keep the microphone ready" turns it off. A dictation after a longer pause still waits for the device to warm up (about 0.7 s on a Bluetooth headset), which is why the chime plays when the microphone is live rather than at the key press.
- A microphone on input 2, or on any input of a multi-channel interface, is heard: the device's channels are mixed to mono instead of keeping channel 0 only.
- Silence is described truthfully: "sent only silence" for a muted or mis-cabled microphone, "only faint noise" for a device still connecting or an interface with its gain down, "virtual audio device" for a loopback driver picked as the system default.
- When the selected microphone is not connected, MicJot dictates on the system default, says so once, keeps the selection, and switches back when the microphone returns.
- Sleep, screen lock and user switch close a standby microphone at once.
- System output muted while dictating is restored after a crash or force quit, and devices without a mute control are ducked to zero volume instead.
- The stop sound and the return of system audio wait until the 200 ms post-release tail has been recorded, so neither lands in the dictation.
Tests and release gate
- New
MicJotAppTeststarget drives the realDictationControllerwith fakes through the negative paths (timeouts during decode and paste, Escape in every phase, engine fallback, the waiting cap, the circuit breaker) and tests the AVFoundation conversion (stereo with the mic on input 2, resampling) that no test could reach before. - The self-test (QA builds) is hermetic (
MICJOT_SUPPORT_DIR), compares the inserted text exactly with the headless pipeline's output, pastes twice to prove the clipboard is restored, checks the layout key resolution (Dvorak included), and reports release-to-visible-text latency (about 1.4–1.5 s measured) beside the keystroke latency. - The snapshot gate compares every light screen with a committed baseline (
qa/snapshots-baseline/, RMS ≤ 0.02) instead of counting files; renders are deterministic (fixed clock, pinned scroll, sample devices, frozen pulse). - The benchmark corpus adds sub-second words and 18–30 s and 45–60 s passages, so both the padding and the chunking paths are scored; WER gate 1.0% (measured 0.54% on the dev split), hallucinations and empty-on-speech 0, cold model load ≤ 180 s; timing tests are bounded and the unit-test step runs under a 900 s watchdog.
QA loop on the installed build (2026-10-27)
Three rounds (qa/rounds/qa-2026-10-27/) found one blocker, five majors and twelve minors in the 0.3.0 build and fixed them:
- Synthetic ⌘V carries the layout-resolved key code and the modifier flags only. The fix program had added an explicit unicode string to the event, which AppKit treats as text input rather than the paste shortcut, so every insertion had fallen through to the 2 s Accessibility poll (and would have been lost in a web view). The self-test now requires the paste to land, with exactly one copy, in MicJot's own text view, in TextEdit and in a WKWebView textarea.
- Bluetooth default input: capture that follows the system default is described as the device behind CoreAudio's
CADefaultDeviceAggregatewrapper (the headset, not "a virtual audio device"), and a cold start on a Bluetooth microphone that ends in digital silence within 1.5 s says the microphone is still connecting, with the bar showing "Connecting to the microphone…". - No download without consent: a dictation with no model on disk never downloads it; the speech is saved as a pending dictation with "Download the speech model in Settings (about 630 MB) to dictate offline…". Choosing another model in Settings no longer downloads it on its own; only Download and Retry do.
- The keystroke-latency gate uses the decided thresholds (idle p50 ≤ 4 ms and p95 ≤ 12 ms; busy p95 ≤ 20 ms) and every latency line records how it was measured; the echo of MicJot's own tap stop is no longer counted as a system disable.
- Snapshots are deterministic: fixed clock for the demo history, pinned scroll position, a sample device list and a frozen waveform pulse (the gate had failed on clock drift).
- Existing
settings.jsonandhistory.jsonare tightened to 0600 when loaded at launch, not only at the next save. MicJot --transcribe <audio> [--language auto|en|de|…]passes the choice to the engine and prints the detected and cleanup languages, giving the non-English cleanup an end-to-end oracle; the oracle runs with the self-test's default language and dictionary.- One log line says when the speech model is ready and how long the load took.
- A memory-pressure warning releases the model only after 120 s idle; a paste is verified by a new occurrence of the text, never by text the field already held; a field that keeps changing is settled by reading its value; the Keyboard layout row shows the layout's name ("ABC") instead of its input-source id; one rounded download size in the model names, the Download button and the notice.
Release engineering from the 2026-10-26 production review (package G, qa/rounds/fix-2026-10-26/release-ops.md).
Distribution
scripts/build-app.shnow also producesdist/MicJot-<version>.dmg(drag to Applications), archives the debug symbols asdist/MicJot-<version>-<build>.dSYM.zip, writes.sha256files, and signs with the first available of$MICJOT_SIGN_IDENTITY, a Developer ID Application certificate (with--timestamp), the local "MicJot Dev" identity, or ad-hoc.--customerleaves the QA entry points out; the default QA build keeps them forqa/run-checks.sh. Builds on the owner's Mac are signed with the local "MicJot Dev" identity: the owner has not yet enrolled in the Apple Developer Program, so no build is notarized yet.- Build numbers (
CFBundleVersion) are the UTC minute of the build (YYYYMMDDHHMM) or$BUILD_NUMBER, so they always increase and no longer depend on the git history (they used to begit rev-list --count, which repeated for rebuilds and fell back to 1 outside a checkout). - New
scripts/notarize.sh(notarytool submit, staple, Gatekeeper assessment; fails with the exact setup command when noMicJotNotarykeychain profile exists;MICJOT_NOTARY_KEYCHAINnames the keychain file for CI),scripts/make-dmg.sh, anddocs/RELEASING.mdwith the one-time Apple Developer Program setup and the per-release commands. - GitHub Actions:
ci.yml(tests and an ad-hoc-signed build on a clean macOS runner, artifacts uploaded; it ran once, on the push of 2026-10-26 (UTC), and passed) andrelease.yml(signed, notarized release from a tag once the secrets exist; never run). - The app bundle carries
privacy.html,terms.htmlandacknowledgements.html, rendered byscripts/build-app.shfrom the Markdown, so the policy buttons work offline.
Updates
- In-house update checker, no third-party framework:
UpdateFeed(MicJotCore) parses the JSON feed and verifies an Ed25519 signature over the version, build, minimum macOS, URL, checksum and notes;UpdateCheckerreadsMicJotUpdateFeedURL/MicJotUpdatePublicKeyfrom Info.plist (feature off when absent), checks at most once a day plus on demand (the attempt is recorded before the request, so an unreachable server or a rejected feed never adds requests), compares build numbers, downloads the DMG to ~/Downloads, verifies its SHA-256 and opens it.scripts/make-update-keys.shcreates the key pair (private key in the login keychain or a file outside the repository, public key saved next to it);scripts/make-appcast.shsigns the feed and refuses a download URL the app would normalize differently. - The app shell wires it: a check 8 s after launch when due, "Check for Updates…" in the app and status menus and in About, an update window with notes and a Download button, and a status line in Settings → Updates and support. Both Info.plist keys are empty in this release, so the feature is off and no request is made until a feed URL and public key are injected at build time.
Diagnostics
- Every log line now also goes to a plain-text file in
~/Library/Application Support/MicJot/Logs(one file per day, 7 days kept, 5 MB each) with the same category, level and text as the unified log, which the system had been evicting within a day. Strings are redacted unless marked public; dictation text is never written. Info.plist asks the unified log to persist the subsystem's default-level messages. - MetricKit crash and hang reports are stored as JSON in
…/MicJot/Diagnostics; "Save Diagnostics…" (app menu, status menu, About, Settings) zips the logs, those reports, recent macOS crash reports and a settings summary (no keys, no history, no dictionary, no snippets) into~/Downloads/MicJot-Diagnostics-<timestamp>.zipfor the user to send. Nothing is uploaded. MicJot --versionprints the version and build;MicJot --unregister-login-itemremoves the login item (used by the uninstaller).--selftest,--snapshotand--transcribeexist only in QA builds.
Transfer package, installer, uninstaller
make-transfer-package.shno longer ships the source tree or QA logs unless--with-sourceis given, and records the packed model inpayload/manifest.json; the installer installs whichever model the package contains (it used to fail after unpacking a Base or Small model) and verifies the weight files; the guide carries the real version instead of a stale one.- The uninstaller removes the login item through the app, the Neural Engine cache (hundreds of MB), preferences and saved state, asks before touching history and keys, and offers to reset the Accessibility, Input Monitoring and Microphone grants (
tccutil), printing the commands otherwise.
Legal and hosting
docs/PRIVACY.mdanddocs/TERMS.md(drafts for legal review),docs/ACKNOWLEDGEMENTS.md(every dependency and the model licences, with the MIT and Apache-2.0 texts),docs/HOSTING.md(Cloudflare Pages for the site and feed, R2 for downloads, an R2 + Worker model mirror in WhisperKit's Hub layout, GitHub Releases as mirror), and a staticsite/scaffold (scripts/make-site.shrenders the policy pages from the docs). The default speech model stays Whisper Large v3 Turbo for every user (owner decision, 2026-10-27).scripts/make-model-mirror.shlays the mirror out; no bucket or Worker has been set up and the app still downloads from huggingface.co (docs/HOSTING.md, section 3).