Skip to content

Read-aloud rework: per-key actions, HTTP streaming TTS, Keys and Read Aloud tabs - #271

Merged
alexkroman merged 5 commits into
mainfrom
read-aloud-keys-http-tts
Oct 30, 2026
Merged

alexkroman merged 5 commits into
mainfrom
read-aloud-keys-http-tts

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

Summary

Keys

  • New Keys Settings tab: each trigger key — right ⌘, right ⌥, fn, and now left ⌘, left ⌥, left ⌃ — gets an action (Off · Dictate with a style · Read Selection Aloud · Ask About Selection) and its own Tap / Hold / Tap or hold mode. Left ⌥ reads aloud by default.
  • One engine snapshot, TriggerKeyBindings, holds the rules every reader uses (tap, router, press style, views). TriggerKeyChoice types the action. The bound key always dictates; TriggerKeyStyleStore.setChoice refuses taking the only dictating key off dictation and moves the binding otherwise. Each key's mode lives in one map, falling back to the old shared setting, so existing installs read unchanged.
  • Read/ask wait for the press to prove itself (tap, or a 350 ms hold); a mouse click during a press cancels it, so ⌘C and modifier-clicks never trigger a read. A tapped read frees the key gate, so the dictation key still works mid-read.
  • The welcome screen lists every configured key.

Read aloud

  • Moves from the undocumented WebSocket (which sent the now-refused voice param) to AssemblyAI's documented Streaming TTS HTTP API, one streamed request per piece. A short first piece starts reading quickly; later pieces are requested only while < 30 s of audio is queued, so long articles (cap raised to 100,000 chars) are synthesized only as far as they're heard. No WebSocket remains; check-invariants.sh no longer exempts any file.
  • New Read Aloud tab: voice picker (docs' English voices, plays a sample), speed (plays a sample), skip technical text (now on by default).
  • Reads use the dictation pill with the meter following the voice, between their own start/stop cues (default Juno-106 Brass, editable under Sound).
  • The read-aloud machinery (SelectionSpeechRouter) moved into the engine, with tests.

Also

  • API key entry is legacy-only where sign-in is configured (new installs sign in).
  • Dev builds sign in to production by default; a local xcconfig can still point at a sandbox.
  • Ask caps the selection sent to the LLM at 30,000 chars (32k-token model).
  • Code-review and /simplify findings addressed (hot-path allocations, duplicated encoders/request builders/cue players, pill ownership as a tested engine rule, docs).

Some Settings and welcome-screen wording/layout came from a parallel Codex session working in the same tree.

Test plan

  • swift test: 911 tests pass
  • Full UI suite (scripts/uitest.sh): 31 tests pass
  • App + UI-test bundle build (Debug)
  • scripts/check.sh --portable and check-invariants.sh --self-test pass
  • Live check: production TTS accepts voice_id; streamed first audio in ~0.3 s
  • Manual: read a long article with left ⌥ (start time, pacing, stop with any key)
  • Manual: Keys tab rebinding, Read Aloud voice/speed samples, read-aloud cue sounds

🤖 Generated with Claude Code

alexkroman-assembly and others added 3 commits October 29, 2026 18:44
…ettings

Keys
- Settings' new Keys tab gives each trigger key (right ⌘, right ⌥, fn, and
  now left ⌘, left ⌥, left ⌃) an action — off, dictate with a style, read
  the selection aloud, or ask about it — and its own tap/hold mode. Left ⌥
  reads aloud by default. One snapshot, TriggerKeyBindings, holds the rules
  every reader uses; TriggerKeyChoice types the action; the bound key always
  dictates, enforced in TriggerKeyStyleStore.setChoice.
- Read and ask wait for the press to prove itself (a tap, or a 350 ms hold),
  and a mouse click during a press cancels it, so ⌘C and modifier-clicks
  never read. A tapped read frees the gate, so the dictation key still works.
- The welcome screen lists every configured key.

Read aloud
- AssemblyAI's documented Streaming TTS HTTP API replaces the undocumented
  WebSocket (the retired `voice` parameter was being refused). A selection
  is read in pieces, a short first one so reading starts quickly, the rest
  requested only as the listener catches up, up to 100,000 characters.
- A Read Aloud tab: voice picker (the docs' English voices, with samples),
  speed (with samples), skip technical text (now on by default).
- Reads wear the dictation pill, the meter following the voice, between
  their own start and stop cues (default: Juno-106 Brass).
- The read-aloud machinery lives in the engine (SelectionSpeechRouter).

Also: API keys are legacy-only where sign-in is configured; dev builds sign
in to production; read-aloud's Ask caps the selection it sends to 30,000
characters. Some Settings and welcome-screen wording and layout came from a
parallel Codex session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cleanup (/simplify findings)
- One snapshot, TriggerKeyBindings, for every reader of the keys, with a
  typed TriggerKeyChoice. The stores are writers through one TriggerKeyMap
  encoder; each key's mode lives in one map (falling back to the shared
  mode, so existing installs read unchanged); the style stash is gone; the
  bound-key rule lives in TriggerKeyStyleStore.setChoice, tested.
- Hot paths: no Set per keystroke, level de-dupe before the main-actor hop,
  pacing sleeps out the excess, audio passes through without copies, level
  windows computed outside the lock, JSON decoded once per press/render.
- Shared request builder, one cue-pair type, ReadAloudStyle.verbatim,
  KeyActionName in Shared/, TriggerActivation.hint(for:), the pill rule in
  the engine, KeysSettingsTab as one type, ObservedKeyBindings.

Bugs the new tests exposed
- A read's end replayed the last dictation's notice on the pill (and to
  VoiceOver): PipelinePhase.overlayStateAfterSpeech keeps only a steady state.
- A stopped read's cancelled request surfaced as URLError(.cancelled) and a
  one-piece read returned as finished; both now read as a cancellation.
- Read-aloud cues re-primed only after a dictation; CueReprimeSchedule
  reloads them at once unless a capture is running.
- UI-test mode no longer writes the Mac's 🌐 key setting.

Tests
- Engine: interrupted reads, the URLSession path via a private URLProtocol,
  TTS error paths, per-press styles, previews, the real hold timer,
  StreamingPCMPlayer, the pill rules per phase, ask stopped mid-gateway.
- UI: every key dictating/Off, read aloud, ask (held, tapped, nothing said),
  interruption, per-key styles, text shortcuts, the Read Aloud tab and its
  sound, the pill around a read, live welcome-screen/menu-bar updates. The
  harness drives any key through the real tap, with a stub speaker.

940 engine tests, 47 UI tests, portable checks and invariants pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SelectionSpeechRouting and SelectionSpeaker no longer need to be public now
the router lives in the engine, and TriggerActivationStore's read/write went
unused once every key's mode moved to TriggerKeyActivationStore and the
bindings snapshot; it keeps only its defaults key, still read as the shared
mode. Fixes the periphery failure on the PR's first CI run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alexkroman
alexkroman enabled auto-merge (squash) October 30, 2026 03:49
alexkroman-assembly and others added 2 commits October 29, 2026 21:08
The UI suite now drives every trigger key and read-aloud end to end (47
tests, ~17 minutes on macos-26). With the engine suite, the leak run and the
lint passes on top, a passing run hit the 30-minute limit in its last step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's swift-format and swiftlint analyze findings on the previous push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alexkroman
alexkroman merged commit aee614b into main Oct 30, 2026
13 checks passed
@alexkroman
alexkroman deleted the read-aloud-keys-http-tts branch October 30, 2026 05:07
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.

2 participants