Add a dismissible Settings banner and a persistent About link to hex.kitlangton.com, without installing or migrating anything. Keep the announcement limited to supported Macs and add isolated native screenshot coverage.
Update the legacy repository README to direct new development to anomalyco/hex while preserving Swift history and downloads. Closes#291.
Hex 0.8.3 added a stale-TCC fallback that synchronously read KeyEventMonitor state while the first handler registration barrier already owned the same dispatch queue. When Input Monitoring was not yet granted, libdispatch trapped on the recursive sync and crashed fresh installs during launch.\n\nMake monitor state reads detect queue re-entry, keep external reads synchronized against barrier writes, and snapshot event handlers only after entering the queue. Inject the system permission probes so the denied-permission registration path has a deterministic regression test.\n\nFixes #254\nFixes #258\nFixes #265
Review fixes for the audio engine lifecycle overhaul (8739929), addressing
findings from a three-way simplification/correctness/efficiency review.
Correctness:
- applicationWillTerminate pumps the main run loop while waiting for
recording.cleanup() instead of blocking on the semaphore outright.
cleanup() hops to the main actor (media-key resume) and the main queue
(Core Audio listener removal), so the blocking wait would have
deadlocked and hit its 3s timeout in exactly the quit-after-recording
scenario the #245 fix targets, exiting with teardown incomplete.
- Warm-capture suspension is now tracked as a Set<CaptureSuspensionSource>
(systemSleep / displaySleep / screenLock) instead of one bool. This
fixes two leaks of the warm mic at a locked screen: wake notifications
fire while the screen is still locked (the bool version resumed
immediately; now capture stays suspended until unlock removes the last
source), and locking during an active recording previously never set
the flag (now the fact is always recorded and
finalizeCaptureStateAfterRecording re-suspends once the recording
stops).
- Mid-recording engine rebuilds are gated on the input device actually
changing or the engine having died (configuration change). Output-only
device changes (plugging in headphones) previously triggered
restartPreservingRecording and caused an avoidable audio gap in active
dictations.
- startRecording cancels any pending environment-change debounce task;
a device event within 250ms of the hotkey press would otherwise fire
into the new recording and force a pointless mid-recording rebuild.
Efficiency:
- Idle rebuilds are skipped when the engine is already running on the
resolved input device. Bluetooth connects fire 3-5 notifications over
1-2 seconds (devices-changed, default-input-changed,
default-output-changed, ...); the first rebuild settles the engine and
the rest now no-op instead of causing repeated teardown+rebuild cycles.
This also absorbs the wake+unlock double-resume.
Simplification:
- Shared tearDownWarmCapture() replaces two divergent suspend branches.
- handleCaptureEnvironmentChange is synchronous (no awaits), documenting
that its recording-state snapshot cannot go stale via actor reentrancy.
- SuperFastCaptureController.stop() does one processingQueue hop via
detachEngine(clearingRecordingState:) instead of two.
- SoundEffect drops the isEngineRunning flag that shadowed
engine.isRunning, logs shutdown honestly, and no longer prewarms an
engine on enable that the idle timer would stop 10 seconds later.
Addresses the audio-engine bug cluster (#209, #218, #226, #245, #246,
#251, #252) while keeping Super Fast Mode's warm-microphone design intact.
Root problems, all variations of 'the engine outlives the world it was
built for':
1. Route/device changes while idle only *deferred* the engine rebuild to
the next recording, leaving the stale engine running against the old
route indefinitely. That kept coreaudiod churning while idle (#209)
and made the first post-change recording unreliable.
2. Route/device changes *during* a recording deferred entirely: the
recording kept 'running' while the dead engine captured nothing,
producing empty transcripts after Bluetooth connects, A2DP<->HFP
switches, and AirPods handoffs (#251, #252, #218, #226).
3. App-quit teardown was a fire-and-forget Task racing process exit,
with SuperFastCaptureController.deinit as a second competing teardown
path - the EXC_BAD_ACCESS in AVAudioEngine teardown (#245).
4. The sound-effects output engine (isAutoShutdownEnabled = false) ran
forever once started, keeping an output IOProc alive even in standard
mode.
Fixes:
- SuperFastCaptureController gains restartPreservingRecording(reason:),
which rebuilds engine/tap/converter while keeping the active
AVAudioFile open, so mid-recording route changes resume capture onto
the same file (small gap) instead of silently losing the rest of the
take. startIfNeeded/stop are refactored around shared
armEngine/detachEngine helpers; detachEngine bumps the capture
generation so in-flight tap callbacks from the old engine are ignored.
- RecordingClient rebuilds the engine immediately (debounced 250ms) on
idle environment changes instead of deferring; failures fall back to
the previous deferred-restart marker.
- The warm engine is suspended on screen lock / display sleep / system
sleep and rearmed on unlock/wake. Nobody dictates at a locked screen,
the mic indicator no longer glows while locked, and a whole class of
stale-engine-across-sleep states disappears. Active recordings are
never interrupted; an explicit recording request overrides suspension.
The pre-roll ring buffer is cleared on suspend so no pre-lock audio is
retained.
- applicationWillTerminate now blocks (3s bound) on recording.cleanup()
via a detached task + semaphore, making teardown deterministic.
- The sound-effects engine stops 10 seconds after the last play;
restarting on the next play costs milliseconds.
The persistent mic indicator while unlocked-and-idle (#246) remains
by design when Super Fast Mode is on - that is what keeps the 0.45s
pre-roll available - but it now clears whenever the screen is locked
or asleep.
Adds openai_whisper-large-v3-v20240930_turbo_632MB to the curated model
list (#185): near large-v3 accuracy at several times the decode speed, in
632MB instead of 1.5GB. It slots between Base and Large v3 as the best
accuracy-per-second Whisper option.
Also renames the Whisper entries to their real model sizes: Tiny and Base
were previously displayed as "Whisper Small (Tiny)" and "Whisper Medium
(Base)", which misrepresented which checkpoints they actually are. With
the model library now showing honest install state, the names should be
honest too. Display names are UI-only (selection is stored by
internalName), so existing selections are unaffected. Large v3's accuracy
rating is bumped to 5 stars so the Turbo tradeoff (slightly lower
accuracy, much faster) reads correctly in the list.
Fixes#250, where four reporters on 0.7.6 hit dead hotkeys with symptoms
that fingerprint a stale Input Monitoring TCC grant: modifier-only hotkeys
kept working while keyed hotkeys and the hotkey recorder went dead, often
after sleep or MDM re-login, and toggling the permission in System
Settings did not help.
Root cause was Hex trusting cached permission checks over reality, in two
compounding places in KeyEventMonitorClient:
1. The tap callback dropped events whenever the cached
IOHIDCheckAccess/AXIsProcessTrusted flags said permissions were
missing - even for events macOS was demonstrably delivering.
2. desiredMonitoringState() required inputMonitoringTrusted, so the 100ms
permission watchdog tore down a *working* tap as soon as
IOHIDCheckAccess returned a stale denial.
macOS withholds keyDown/keyUp from taps lacking Input Monitoring but
still delivers flagsChanged to Accessibility-granted taps, which is why
victims saw modifiers work while keys died.
The fix treats event arrival as the authoritative permission signal:
- A keyDown/keyUp reaching the tap proves Input Monitoring is granted;
noteEventDelivered() self-heals the cached flag and
currentInputMonitoringTrust() honors that proof over a stale denial
(which also keeps the settings permission indicator honest).
- The tap is no longer torn down when the Input Monitoring check reports
denied; without the permission macOS simply withholds key events, and
keeping the tap alive is also what triggers the permission prompt.
- The tap is recreated on NSWorkspace.didWakeNotification and
sessionDidBecomeActiveNotification (matching the reporter whose
hotkeys died after sleep/MDM logout), and the watchdog re-enables taps
macOS silently disabled via CGEvent.tapIsEnabled.
Model library rows now have explicit Download buttons, a visible menu for
Show in Finder / Remove Download (with confirmation), and clear In Use /
Installed status labels. Row clicks select installed models only.
State hardening behind the UX:
- Availability scans can no longer clear selectedModel to "" on false
negatives (this hit users after FluidAudio 0.15.5 moved its cache dir:
the scan came back empty, 0.8.0 wiped the setting, and push-to-talk
silently did nothing afterward).
- Selection matching is pattern-aware (ModelPatternMatcher.namesMatch)
instead of exact-ID, so legacy/glob selections resolve correctly.
- downloadModel resolves glob selections to concrete names up front so
completion updates rows and settings consistently.
- Stopping a recording with no model selected now discards the audio and
flashes model setup instead of handing an empty model name to the
transcriber (which produced junk like "[BLANK_AUDIO]").
- The summary card shows an honest orange "Not downloaded" state with an
inline Download button when the selected model is missing on disk.
Also trims per-tick re-renders of non-downloading rows and redundant
settings writes on repeated scans.
FluidAudio 0.15.5 changed Parakeet's canonical cache directory by dropping the -coreml suffix. Atomically migrate complete legacy caches into the new location, replacing interrupted partial downloads while preserving both directories if validation fails.
Use FluidAudio's version-aware cache APIs instead of guessing its directory layout. Keep a previously completed selection visible while availability loads, and cover the restart state with a regression test.
Restructure the transforms page around a compact preview, word-rule editor, and separate output formatting controls. Suggest English Parakeet for Auto or English output, while retaining multilingual Parakeet for explicitly non-English output.
Make recording stops explicit, reject stale capture callbacks, and clean up captured audio reliably. Replace the old model list with the model library, preserve valid selections across failures, gate download updates by session, and add reducer coverage. Refresh stable dependencies, migrate FluidAudio 0.15.5, restore Xcode test linking, and polish update, changelog, and transform behavior.
* Add configurable paste formatting transforms
Add independent settings to lowercase pasted transcripts and remove Unicode punctuation. Persist both options, include them in the transforms preview, and apply them after existing word modifications before paste and history storage.
Cover formatting combinations and legacy settings migration with tests.
Opencode-Session: ses_0bd949d00fferT6nCF6ntO80Mp
Opencode-Message: msg_f42760e83001l5zbmkL7mxckWu
Opencode-Agent: build
* Reference PR in paste transforms changeset
Opencode-Session: ses_0bd949d00fferT6nCF6ntO80Mp
Opencode-Message: msg_f4276da02001R7u6klN4JoUXT7
Opencode-Agent: build
Two small first-experience fixes that surfaced after 0.7.5 shipped:
- Launching at login no longer pops the Settings window unless the user opens it explicitly. The previous policy only suppressed when the dock icon was hidden, so most 'Open on Login' users got a settings panel in their face every boot. Closes#222.
- 'Show in Finder' on a Parakeet model now opens FluidAudio's on-disk cache at ~/Library/Containers/com.kitlangton.Hex/Data/Library/Application Support/FluidAudio/Models instead of the WhisperKit-only models folder, which made users think their Parakeet download had silently failed. Closes#205.