Skip to content

Reliability, performance, and UX overhaul: recording, model loading, downloads, updater, hotkeys#103

Open
schallau wants to merge 42 commits into
karansinghgit:mainfrom
schallau:harden-privacy-updater
Open

Reliability, performance, and UX overhaul: recording, model loading, downloads, updater, hotkeys#103
schallau wants to merge 42 commits into
karansinghgit:mainfrom
schallau:harden-privacy-updater

Conversation

@schallau

@schallau schallau commented Jul 5, 2026

Copy link
Copy Markdown

This branch started as a privacy/updater hardening pass and grew into a broader reliability, performance, and UX overhaul driven by a full audit of the codebase. It's 29 commits, each small and self-contained with a detailed message explaining the defect and the fix — reviewing commit-by-commit is recommended. Happy to split this into smaller themed PRs if you'd prefer; the commits are organized so that's mechanical.

Recording pipeline

  • Stop dropping the first words of every dictation (9ebdd5d). Buffers that arrived before the AVAssetWriter was wired up were silently discarded; the session cold start plus a 300ms settle sleep meant every recording lost its opening word(s). Early buffers are now retained on the audio queue and flushed once the writer is ready; the writer is created before startRunning(); the settle sleep is gone. Min-duration wait now uses recordingStartTime instead of parsing the filename.
  • Device changes mid-recording no longer kill the recording silently (08925ec). selectedDeviceId.didSet rebuilt the capture session but never restarted it, so unplugging a mic finalized a truncated file with no error.
  • First-run mic prompt (0680a52). Recording now starts in the requestAccess callback only when granted, instead of "recording" into a mic it might never get. Denied permission shows an actionable message in the pill instead of a silent zero-byte flow.
  • Live level/waveform publishes coalesced to 30 Hz (2990c21) — was 3 @Published writes per audio buffer, re-rendering the whole pill 50–100×/s.

Model loading

  • Load watchdog (52da9bf, corrected by fd88eb8). A hung WhisperKit init previously left the pill on "Warming up model..." forever (the old watchdog comment had no implementation). Loads now race a bounded timeout via a continuation (a task group can't work here — it awaits hung children). The timeout is 600s/1200s: first loads legitimately run minutes of CoreML/ANE specialization, verified on a 16GB machine where a 180s value killed a healthy load.
  • First-load messaging (ff182ae). During a variant's first-ever load the pill/model row/hero button say "First-time setup — optimizing model for your Mac" with elapsed time, instead of looking hung. README updated to match.
  • Stale loads throw (fd88eb8). A load that finds its state reset by a concurrent unload/delete now throws CancellationError instead of returning as success (which re-selected a just-deleted model). Both "Use" flows re-verify the model is still downloaded before selecting. ParakeetEngine tracks its in-flight variant and gets the same protection.
  • Parakeet integrity (f151443). "Installed" previously meant "cache dir non-empty," so interrupted downloads showed installed forever; now validated with FluidAudio's modelsExist. ParakeetEngine.loadModel loads strictly from cache instead of downloadAndLoad's silent Hugging Face fetch (no progress, no cancel, contradicts offline-first).

Downloads & updater

  • Download errors are visible (12e52f2), and transient auto-repair status ("Retrying download...") renders neutrally instead of as a red failure (056d60b).
  • Cancel is race-free (8a5619a, 7b5212b). Progress callbacks bail once inactive; partial-download cleanup waits for the cancelled task to finish and re-checks that no fresh download started (cancel-then-retry previously deleted the retry's files).
  • Disk-space preflight (8a5619a): multi-GB downloads fail immediately with an actionable message instead of minutes in with a raw write error.
  • Updater (5c63a0f, f25ad31): autoUpdate had three divergent defaults (Settings showed ON while the launch check read OFF) — now one registered default with a regression test; daily background re-check (menu-bar apps run for weeks); install is temp-copy-then-replaceItemAt instead of delete-then-copy (a failed copy destroyed the installation); manual check shows "You're up to date"/failure feedback; a stalled download is cancellable instead of trapping the user in a frozen sheet; v1.2.3-dev tag parsing fixed (leading-v strip only).

Input & paste

  • Paste latency (2836b1d): the commit path slept an unconditional 500ms "waiting for focus" although the non-activating panel means focus usually never left — now polls only when the target isn't frontmost (zero delay in the common case). Clipboard restore window widened for slow apps; safe because restore is conditional on the pasteboard still holding the transcript.
  • Keystroke monitors are scoped (a077bf1): the combo-cancel and Escape global keyDown monitors previously lived for the app's lifetime, waking the app on every keystroke system-wide; now installed only while the hotkey is held / recording is active. Fn handling in the NSEvent path is skipped while the event tap owns it (closes a double-toggle window).
  • Option+Space hotkey (25c501c): first modifier+key chord option. The event tap gains keyDown/keyUp in its mask only when a chord is selected and swallows the chord's key so it doesn't type into the target app; NSEvent fallback keeps the chord working without Accessibility. Monitoring rebuilds when the selection changes, and now also recovers when Accessibility is granted mid-session (337e815) — previously the tap could only be created at launch.
  • Idle pill is now optional (337e815): Settings → General → "Show floating pill when idle" (default on preserves current behavior).

Onboarding, history, misc UX

  • Onboarding no longer hard-blocks on Accessibility — "Continue without auto-paste" with an explanation (8f0fc60).
  • History: launch-time re-normalization (~30 regex passes per stored transcript, every launch) now runs once per schema version; orphaned recordings are swept (app-named files, unreferenced, >24h old); LazyVStack; empty-state hints show the configured hotkey instead of a hardcoded wrong one (cd63f52, dcc14d3, 3e7bd11).
  • History playback draws real downsampled audio peaks instead of Float.random noise (932e1e0).
  • File transcription: imports persist to the app's Recordings dir instead of temp (fixes later "Audio file missing"), errors render as errors instead of as transcript text, and the selected model is loaded before transcribing (dcc14d3).
  • Hotkey press seconds after login no longer flashes a false "Model not downloaded" while the disk scan is still running (08925ec).

Housekeeping

  • Privacy/updater hardening and path validation with tests (3bf61bd).
  • Dead code removed: six unreferenced files, the never-called chunk-transcription path, and the KeyboardShortcuts package (imported in three files, API never called) including the pbxproj reference (bea407f, 1cf9054).
  • UI test suite rewritten — the old one asserted on UI that no longer exists and could never pass (ea48c67).

Validation

xcodebuild build + the unit suite (speaktypeTests) pass at every commit-sized checkpoint; the recording, model-load, paste, hotkey, and update flows were exercised in a live build during development. Known gaps called out honestly: Escape still can't abort an in-flight WhisperKit transcription (not cancellable upstream), history persistence remains in UserDefaults, and ModelDownloadService's state machine still needs a downloader-protocol refactor to be unit-testable.

schallau added 30 commits July 5, 2026 17:13
Buffers delivered before the asset writer was wired up were silently
discarded, so the session cold start (startRunning + a 300ms settle
sleep) swallowed the first word(s) of every dictation. Retain early
buffers on the audio queue and flush them once the writer is ready,
create the writer before starting the capture session, and drop the
settle sleep. Also compute the minimum-duration wait from
recordingStartTime instead of parsing the filename.
The 'watchdog' comment in performModelLoad had no implementation and
TranscriptionError.loadingTimeout was never thrown, so a hung
WhisperKit init (corrupt model dir, low-RAM swap spiral) left the pill
on 'Warming up model...' forever. Race the load against a bounded
timeout (180s, 360s below recommended RAM) and discard a late orphan
result instead of resurrecting stale state. Also let
unloadModelIfCurrent match a variant that is still mid-load, so
deleting a model during warm-up doesn't keep loading deleted files.
The commit path slept an unconditional 500ms 'waiting for focus' even
though the recorder panel is non-activating and focus usually never
left the target app — every dictation felt half a second slower than
the model. Now the wait only happens when the target isn't frontmost,
polling up to 500ms. Fall back to the current frontmost app when no
target was captured, and widen the clipboard-restore window to 800ms
so slow apps (Electron, remote desktops) don't paste the old clipboard
— safe because restore is skipped unless the pasteboard still holds
the transcript.
- Restart the rebuilt capture session when the input device changes
  while recording (unplug fallback or external switch); previously the
  new session was never started, so the dictation silently went dead
  and finalized truncated with no error.
- Show 'Mic access off' in the pill when microphone permission is
  denied instead of proceeding into a silent zero-byte recording.
- Treat models as present until the launch disk scan completes, so a
  hotkey press seconds after login no longer flashes a false 'Model
  not downloaded' error.
The auto-repair strings ('Cleaning duplicates...', 'Retrying
download...') were written into downloadError, so once ModelRow began
rendering that map they appeared as red failure notes while the retry
was actually progressing. Route them through a new downloadStatus map
rendered neutrally, and clear both maps on every terminal path
(success, failure, cancel). Also make file transcription's
no-model-selected error say where to fix it instead of the generic
'Model is not initialized'.
Parakeet models were marked 'Installed' if their cache directory
contained any file at all, so an interrupted download showed as
installed forever and then failed to load with no hint of the cause.
Use FluidAudio's AsrModels.modelsExist required-files check instead.
ParakeetEngine.loadModel also called downloadAndLoad, which silently
kicked off a multi-hundred-MB Hugging Face fetch with no progress or
cancel when files were missing; it now loads strictly from cache and
throws an actionable modelFilesMissing error pointing at Settings →
AI Models.
Every launch re-ran ~30 regex passes over every stored transcript,
a cost that grows linearly with usage. Gate it behind a stored
normalization-schema version so it runs once per rule change instead
of every launch. Also sweep the Recordings directory at launch for
audio files no history item references (pruned rows, failed imports)
— only app-named files older than 24h, so in-flight recordings are
never touched.
Two app-lifetime global keyDown monitors (modifier-combo cancel in
AppDelegate, Escape in MiniRecorderView) woke SpeakType on every
keystroke in every app just to bail on a guard. The combo-cancel
monitors now live only while the hotkey is held, and the Escape
monitors only while recording or processing. Also stop handling Fn in
the NSEvent flagsChanged path while the suppressing CGEvent tap is
active — a duplicate arriving after the 50ms dedupe window could
double-toggle recording.
Removes files with no references outside themselves: the parallel
onboarding PermissionsView, HistoryDetailView, HotkeyConfiguration
(which claimed a Ctrl+Shift+Space default the real hotkey logic never
used), RecordingSession, TranscriptionState, and the never-called
WhisperService.transcribeChunk left over from removed chunk stitching.
AudioInputView shrinks to just DeviceRow, the one component SettingsView
actually uses. The KeyboardShortcuts package was imported in three files
but its API never called and its one shortcut name never consumed —
imports and README mention dropped (package ref left in the project for
a separate pass).
The pill said only 'Warming up model...' for the whole 30-60s cold
load, which reads as a hang at login. Surface the engine's live
loadingStage (already tracked but never rendered) plus elapsed seconds
once the load passes 5s, and widen the warming pill to fit.
Setup hard-blocked on granting Accessibility even though dictation
works fine without it (text lands on the clipboard instead of
auto-pasting). Once the mic is granted, offer 'Continue without
auto-paste' with a note explaining the tradeoff and where to enable it
later. Also drop a duplicated frame modifier.
WaveformView rendered Float.random noise re-rolled on every
appearance, implying the bars represented the recording when they
were decorative. Downsample the actual file into per-bucket peaks
(16 kHz mono WAVs — a few ms of work, done off-main) and normalize so
quiet recordings still show a shape.
- Progress callbacks now bail once a download is no longer active, so
  a cancelled transfer can't repaint a row the user already reset (or
  briefly mark a just-deleted model as installed at 100%).
- Partial-download cleanup waits for the cancelled task to actually
  finish before deleting, instead of racing WhisperKit's in-flight
  writes and potentially leaving a tree the size scan later accepts.
- Check free space (expected size + 20% margin) before starting a
  multi-GB download and fail immediately with an actionable message
  rather than minutes later with a raw write error.
Three @published writes per audio buffer re-rendered the entire
observing pill 50-100x/s while recording. Accumulate the peak between
publishes and emit at most 30x/s — the waveform gets a uniform sample
cadence and older machines stop burning CPU on invisible re-renders.
While installing, the update sheet replaced every control with a
static 'Update in progress' label, so a hung DMG download trapped the
user until force-quit. Add UpdateService.cancelInstall(), which
invalidates the download session and resolves the pending continuation
with a cancellation the install flow resets on silently (no error
banner for a user-initiated cancel). The Cancel button appears only
during the download phase — once the installer is touching disk,
finishing is safer than stopping halfway.
Nothing calls its API — the imports and the one unused shortcut name
were already deleted — so drop the package reference, product
dependency, and framework link from the project. Package.resolved
regenerated by xcodebuild without the entry.
The old suite asserted on 'SpeakType Shortcuts' text and a
'Permissions' sidebar item, neither of which exists — it could never
pass and guarded nothing. The new tests walk every real sidebar
destination asserting a marker each screen renders unconditionally,
and exercise the Settings General/Audio/Permissions tabs.

Verified via build-for-testing only: executing XCUITests locally
requires a code-signing identity (Gatekeeper refuses ad-hoc-signed
test runners), which this environment lacks.
Same fix HistoryView already got: the hint hardcoded ⌘+Shift+Space,
which has never been the default (Fn) or necessarily the user's
choice.
The 180s load watchdog fired mid-load on a healthy 16GB machine:
first load of a large model includes CoreML/ANE specialization, which
legitimately runs several minutes, so the 'timeout' reported a working
load as failed (this is what broke loading whisper-large-v3_turbo).
Raise the ceiling to 600s/1200s — it exists to catch wedged loads, not
to bound performance — and reword the error, which wrongly blamed RAM.

Also from review: a load that finds its state reset by a concurrent
unload now throws CancellationError instead of returning normally, and
both selection flows re-check the model is still downloaded before
selecting — deleting a model during warm-up could otherwise re-select
it. ParakeetEngine gets the same protection (tracks its in-flight
variant, unload cancels a warm-up, stale completions clean up and
throw).
cancelDownload re-enables the Download button immediately, so a user
could start a fresh download while the old task was still unwinding —
whose deferred cleanup then deleted the new download's files. The
cleanup now re-checks on the main actor that no new download is in
flight for the variant before deleting.
On first run, startRecording fired the permission prompt and
immediately began 'recording' into a mic it might never get — even a
granted prompt produced an empty take. Recording now begins in the
requestAccess callback when granted, and not at all when denied; the
pill already handles a nil stop gracefully. Removes the fire-and-
forget requestPermission.
The first load after downloading a model runs CoreML/ANE
specialization — minutes for large models, cached by macOS afterward —
but the UI presented it like any other load, which reads as a hang
(and is exactly what made the too-tight watchdog look plausible).
Track per-variant first-load completion and, during that one load,
say 'First-time setup — optimizing model for your Mac' in the pill,
the model row (with an honest few-minutes estimate replacing the
10-30s tooltip), and the recommended-model button. README's stale
30-60s note updated to match.
The hotkey system only supported bare modifier keys held alone; this
adds the first modifier+key chord. Chords start on the key's keyDown
(with the modifier held) and stop when the modifier is released, so
hold-to-record and toggle modes both behave like the existing options.

The suppressing event tap gains keyDown/keyUp in its mask — only when
a chord is selected, so modifier-hotkey users keep the narrow
flagsChanged-only mask — and swallows the chord's key (plus repeats
and the trailing keyUp) so Option+Space doesn't also type into the
target app. Without Accessibility the tap doesn't exist; NSEvent
fallback monitors still trigger the chord, just without suppression.
Because the tap mask now depends on the selection, the monitoring
stack rebuilds when the hotkey setting changes.
The always-on resting pill divides opinion — some want the anchor,
others see clutter that floats over everything while doing nothing.
New General setting 'Show floating pill when idle' (default on keeps
current behavior): when off, the panel is ordered out whenever it
returns to idle and reappears the moment recording starts.

Also rebuild the hotkey monitoring when Accessibility becomes granted
mid-session: the suppressing event tap could only be created at
launch, so granting the permission later left global hotkeys dead
until an app restart (global key monitors need the grant; local ones
don't — which made the hotkey appear to work only inside the app).
cancelDownload re-enabled downloads immediately and its deferred
cleanup only skipped deletion when isDownloading was true. If a retry
*completed* before the cancelled task's cleanup ran, isDownloading was
false and the cleanup deleted the retry's freshly downloaded files.
A boolean can't distinguish 'my download' from 'a newer retry that
already finished'.

Each download now claims a monotonic per-variant generation. Progress
callbacks, completion/error handlers, and post-cancel cleanup only act
while their generation is still current, so a superseded task can
neither repaint the row nor delete a newer download's files. The
auto-repair path (which also calls deleteModel) gets the same guard,
and cancelDownload no longer runs cleanup at all when no task was
actually running (avoids deleting a completed model).
schallau added 6 commits July 6, 2026 03:41
On first run, startRecording requests mic access asynchronously, but
the pill flipped isListening = true synchronously — so if the user
denied the prompt, the HUD stayed stuck showing 'recording' until a
manual stop/cancel. startRecording now reports back via an onStarted
callback (true when capture begins, false when denied); the authorized
path still fires it synchronously so the common case is unchanged. The
pill enters the listening HUD only on true, and surfaces the mic-off
hint on false.
The chord press is dispatched to the main queue asynchronously, but the
release branch only queued a stop when isHotkeyPressed was already true.
A fast tap can deliver the release event to the tap callback before the
press block runs and sets isHotkeyPressed, so the stop was dropped and
recording stuck on (and toggle mode wedged, since the release also
resets the pressed flag). Queue the release unconditionally: press and
release run on the main queue in FIFO order and handleHotkeyStateChange
no-ops a release with no matching press, so this is safe. Applied to
both the event-tap and NSEvent-fallback paths.
Inherited from the old AudioInputView when DeviceRow was extracted;
git diff --check flagged it.
Adversarial review of the generation-token commit found two holes the
guards missed:

1. Auto-repair's deleteModel was neither generation- nor cancellation-
   aware. Cancel-then-download during an in-flight 'Multiple models
   found' repair could let that deleteModel remove the new download's
   files and reset its UI — the exact race the tokens were meant to
   close. deleteModel now takes an expectedGeneration and bails (before
   any removal and before its terminal state writes) when a newer
   download has claimed the variant; cancelDownload tombstones the
   generation so an already-running repair loses ownership immediately.
   The user's trash button still passes nil for an unconditional delete.

2. refreshDownloadedModels' 'keep in-flight rows' filter dropped a
   download that COMPLETED during the disk scan (isDownloading flipped
   false, but the stale scan snapshot saw it <80% so didn't re-add it),
   reverting a just-installed model to the Download button. The filter
   now also retains completed rows (progress >= 1.0).
Two more eager-UI-state flips of the same class as the mic-permission
fix:

- selectAudioDevice resumed recording with a fire-and-forget
  startRecording() and then unconditionally set isListening = true, so
  switching to a device that can't be added (unplugged that instant,
  grabbed exclusively) left the pill showing a live HUD over nothing.
  It now uses the onStarted callback and returns to idle if capture
  doesn't restart.
- The pill's model menu set selectedModel eagerly and only debugLogged
  a load failure, so picking an undownloaded/failing model left the app
  permanently switched to a model that can't load. It now reverts the
  selection on failure, matching the other selection paths.
A silent launch/periodic check reached showUpdateWindow twice for the
same release — once via showUpdateWindowPublisher's sink (fired inside
checkForUpdates) and once via performUpdateCheckIfNeeded's reminder
check — and showUpdateWindow created a fresh NSWindow each time with no
guard, stacking two identical windows. It now reuses the open window.
@schallau

schallau commented Jul 5, 2026

Copy link
Copy Markdown
Author

Pushed a round of self-review fixes (through 9534ce0). These came out of an adversarial review of the concurrency-sensitive paths in this PR — I'd rather surface and fix these before you spend review time on them.

Three edge cases found and fixed:

  1. Cancel-then-retry could delete the retry's model files (6526b97, hardened in 483eaa5). cancelDownload re-enabled downloads immediately and its deferred cleanup only skipped deletion on a boolean isDownloading flag — which can't tell "my download" from "a newer retry that already finished". Fixed with per-variant monotonic generation tokens: progress callbacks, completion/error handlers, and post-cancel cleanup only act while their generation is current. A second review pass caught that the auto-repair path's deleteModel was still generation-blind, so 483eaa5 makes deleteModel take an expectedGeneration (bails before any file removal and before its terminal state writes when superseded) and has cancelDownload tombstone the generation so an in-flight auto-repair loses ownership immediately.

  2. First-run mic prompt left the pill stuck showing "recording" (0f025b4). startRecording requests mic access asynchronously, but the pill flipped to the listening HUD synchronously — so denying the prompt left a false recording state. startRecording now reports back via an onStarted(Bool) callback (fired synchronously on the already-authorized path, so the common case is unchanged); the pill enters the HUD only when capture actually begins.

  3. A very quick Option+Space tap could drop the release (b28ffbe). The chord press is dispatched to the main queue async; the release branch was gated on the not-yet-updated isHotkeyPressed, so a fast tap could drop the stop and leave recording stuck on. Now the release is queued unconditionally — press and release run FIFO on the main queue and handleHotkeyStateChange no-ops a release with no matching press.

Adjacent same-class issues fixed while in there:

  • refreshDownloadedModels could revert a model that finished downloading during the disk scan back to the "Download" button; the "keep live rows" filter now also retains completed rows (483eaa5).
  • Switching audio input mid-recording and switching model from the pill menu both flipped UI state before the operation confirmed; they now gate on success / revert on failure (01eb1b2).
  • A silent update check opened two identical "Software Update" windows (publisher sink + reminder check); the window is now deduped (9534ce0).

Known, deliberately deferred (low priority, not regressions from this PR):

  • If AVAssetWriter init fails after capture is initiated (disk full / sandbox), isListening can stay true with nothing recording — a pre-existing edge, worth a small .onChange(of: isRecording) reconciliation in a separate change.
  • A model whose files are deleted outside the app (Finder) can show a stale "Installed" row until restart — the deliberate trade for fix: errors #4 above, since the scan can't distinguish it from a just-completed download.

Happy to split any of this out into smaller PRs if that's easier to review. make ci is green (build + unit tests); the UI test suite was rewritten against the current interface earlier in the branch.

schallau added 4 commits July 6, 2026 04:36
The generation tokens guarded UI/delete STATE but not concurrent
WhisperKit writes: cancelDownload freed the slot (isDownloading=false)
before the cancelled task's WhisperKit.download had actually stopped
(cancellation is cooperative), and downloadModel gated only on
isDownloading — so a quick cancel-then-retry could have two downloads
writing the same cache directory, corrupting it or tripping 'Multiple
models found'. The new download now captures the prior task and awaits
it before writing (cancelDownload keeps the task reference for exactly
this), so writers for a variant never overlap.
onStarted(true) fired immediately after beginAuthorizedRecording, but
setupSession could leave an inputless session (device missing / can't
be added) and the async writer setup could still throw — either way the
pill entered the recording HUD over nothing. setupSession now returns
whether a usable input was installed; beginAuthorizedRecording bails
with onStarted(false) when there's no input, and onStarted(true) now
fires from inside the writer-setup task, after the writer is created
and the session start is scheduled (onStarted(false) on writer
failure). Capture-time buffering is unaffected — isRecording still
flips before the task, so no opening audio is lost.
The revert-on-failure added for the pill model menu was unconditional:
selecting A then B, with A's load failing later, would revert to A's
predecessor and clobber the newer B selection. Revert only when the
failed variant is still the active selection.
…nload

Adversarial re-verification found the download-serialization closed
download-vs-download but left a residual: the cleanup task's file
removal could still run concurrently with a retry's writes (retry
awaited the cancelled download task, but the delete happens in a
separate cleanup task). Point activeTasks at the cleanup task instead,
so a retry captures it as previousTask and awaits BOTH the cancelled
download unwinding and the cleanup delete before writing — the whole
per-variant download/cancel/cleanup pipeline is now strictly serial.
Also refresh a stale comment (onStarted is no longer synchronous).
@schallau

schallau commented Jul 5, 2026

Copy link
Copy Markdown
Author

Another self-review round (through 52445cd), fixing three more edge cases in the same concurrency-sensitive paths:

  1. Cancel-then-retry could overlap two download writers (ed26406, 52445cd). The generation tokens from the previous round guarded UI/delete state but not the actual WhisperKit.download writes — cancelDownload freed the slot before the cancelled (cooperatively-cancelled) task had stopped writing, so a fast retry could put two writers on the same cache directory. Fixed by serializing per variant: a new download captures the prior task and awaits it before writing. A follow-up (52445cd, found by re-review) extends this so the retry also chains behind the post-cancel cleanup delete, making the whole download/cancel/cleanup pipeline strictly serial.

  2. Recorder reported "started" before capture was known-good (2860246). onStarted(true) fired right after beginAuthorizedRecording, but setupSession could leave an inputless session (device missing/unpluggable) and the async writer setup could still throw — either way the pill entered the recording HUD over nothing. setupSession now reports whether a usable input was installed, the start bails with onStarted(false) when there's none, and onStarted(true) fires from inside the writer-setup task only once capture is genuinely underway. Cold-start audio buffering is unaffected (isRecording still flips before the task).

  3. Async model-menu failure could clobber a newer selection (0bc6e3e). The revert-on-failure I added last round was unconditional; selecting A then B, with A failing later, reverted to A's predecessor. It now reverts only when the failed variant is still the active selection.

On lint (raised in review): I checked — the serious violations in the touched files are all pre-existing (large files already over SwiftLint's length limits, plus long lines that predate this branch); none of the serious violations land on lines this PR added. The repo ships with ~380 violations and CI treats lint as advisory, so this isn't a regression from the PR. A cleanup pass would be a large, separate, noisy change — happy to do it as its own PR if you'd like, but I didn't want to bury these fixes under formatting churn.

Build + unit tests green throughout.

schallau added 2 commits July 6, 2026 04:54
selectedDeviceId.didSet ignored setupSession()'s Bool result, so when
a mic disappears mid-recording with no usable fallback (selectedDeviceId
becomes nil), it still started an inputless session — the mid-recording
twin of the false-HUD/no-capture bug just fixed on the start path. Now
the restart only runs when a usable input was installed; otherwise the
recording stays active but paused (capture resumes automatically if a
mic reconnects, since fetchAvailableDevices reassigns the device), and
the partial still finalizes on stop.
Adds tests for the pure logic introduced/changed in this branch that had
no coverage (the reviewer flagged perf/UX-sensitive test gaps):

- HotkeyOptionTests: the new Option+Space chord — keyCode(49)/.option/
  isChord classification the event-tap branches on, plus the bare-modifier
  options, rawValue round-trip (persistence), and unique display names.
- WaveformViewTests: peakSamples downsampling that replaced the random-noise
  placeholder — verifies real bucket count, 0...1 normalization, that peaks
  track actual loudness (a two-level synthesized WAV), and empty-on-missing.
  peakSamples changed from private to internal so @testable can reach it.
- AIModelEngineRoutingTests: whisper/parakeet engine routing the Parakeet
  cache-validation fixes depend on — every catalog variant maps correctly,
  the partition is total and disjoint, unknown variants have no engine/size.

52 unit tests pass (was 36). make ci green; Release build clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant