Read aloud: speak selected text on web and desktop - #2675
Conversation
Select text in the web or Electron app and a speaker button floats above the selection; press it and the daemon speaks the selection back. Press again, or clear the selection, to stop. Uses the existing voice-mode TTS provider (local Kokoro by default, or OpenAI) rather than adding a second provider stack. Synthesis is segmented with a 2-segment prefetch because local TTS returns raw 24 kHz PCM (~48 KB/s) — a long selection in one message would be megabytes. - protocol: speech.tts.read_aloud.request -> N .response segments, plus a cancel request - server: read-aloud-controller sanitizes markup, splits, synthesizes and streams; the sentence splitter is extracted from tts-manager and now shared with voice mode - client: startReadAloud() returns a handle with cancel() - app: selection anchoring from endpoint rects plus the clipping ancestor's visible box, a pure above/below/park placement decision, and playback state in a small store reusing the voice AudioEngine Web and Electron only. React Native exposes no JS API for the current text selection, so the native bubble is a deliberate no-op. Capability-gated on server_info.features.readAloud — hosts without it show no button at all, no fallback path.
|
| Filename | Overview |
|---|---|
| packages/app/src/read-aloud/read-aloud-selection-bubble.web.tsx | Adds the route-gated selection bubble, controls, placement, and playback integration; the prior retained-anchor host-binding issue remains outstanding. |
| packages/app/src/read-aloud/use-selection-anchor.web.ts | Adds selection capture, clipping-ancestor geometry, endpoint tracking, and detached-range retention. |
| packages/app/src/voice/audio-engine.web.ts | Adds mutable playback speed, while the previously reported stop-during-decode race remains outstanding. |
| packages/app/src/read-aloud/read-aloud-store.ts | Adds the read-aloud playback state machine, generation-based callback suppression, rate persistence, and daemon cancellation. |
| packages/client/src/daemon-client.ts | Adds the typed streaming read-aloud request handle and cancellation path. |
| packages/server/src/server/session/voice/read-aloud-controller.ts | Adds sanitized, segmented TTS synthesis with bounded prefetch and response streaming. |
| packages/protocol/src/messages.ts | Adds read-aloud request, response, cancellation, error, and capability message schemas. |
Sequence Diagram
sequenceDiagram
participant U as User
participant B as Selection bubble
participant C as Route host client
participant D as Route host daemon
participant T as TTS provider
participant A as Web AudioEngine
U->>B: Select text and press Read aloud
B->>C: startReadAloud(selected text)
C->>D: speech.tts.read_aloud.request
D->>T: Synthesize prefetched segments
T-->>D: Segment audio
D-->>C: Stream segment responses
C-->>B: onSegment(audio)
B->>A: Queue and play segment
U->>B: Stop or clear selection
B->>C: cancel request
B->>A: Clear queue and stop playback
Reviews (4): Last reviewed commit: "test(agent-manager): retry the interrupt..." | Re-trigger Greptile
useReadAloudServerId preferred the route's host but fell back to any other paired host advertising the capability. The text being read is workspace content — code, agent output — so that fallback disclosed it across an independently paired daemon boundary. Bind to the route host with no fallback: a route host that doesn't advertise readAloud shows no button, and neither does a route with no host at all (settings, history). The doc comment argued the opposite — "read aloud carries no workspace context, any host will do" — which was the bug, so it's rewritten to state the invariant instead.
| return; | ||
| } | ||
| // A press after a failure is a retry; `startReadAloud` clears the failure. | ||
| startReadAloud({ client, text: anchor.text }); |
There was a problem hiding this comment.
When playback of text selected on host A finishes after navigation to capable host B, the detached anchor remains available until the selection hook's next asynchronous commit, while client already refers to host B. Pressing the briefly retained bubble sends host A's text to host B for synthesis, disclosing workspace content across independently paired daemons.
How this was verified: The request combines the current route client with retained anchor text without recording or validating the anchor's originating server.
Knowledge Base Used: Paseo Mobile App (packages/app)
`cancelAgentRun succeeds when the provider queues completion before rejecting the interrupt` failed on server-tests (windows-latest) with ENOTEMPTY: rmdir '...\agents'. The assertions passed — the fixture's cleanup is what threw. AgentManager can flush an agent snapshot into `agents/` while cleanup runs, and on Windows an open handle makes rmdir fail with ENOTEMPTY, which `force: true` does not cover. Retry the removal with the options already used in spawn.launch-regression.test.ts and run-git-command.windows-shell.test.ts. Only the shared fixture is changed; the other rmSync sites in this file have not failed and are left alone. This addresses one observed failure mode, not the whole job — server-tests has also failed with an unrelated timeout in execution-session.websocket.test.ts.
Select text in the web or Electron app and a speaker button floats above the selection. Press it and the daemon speaks the selection back; press again, or clear the selection, to stop.
How it works
Uses the existing voice-mode TTS provider (local Kokoro by default, or OpenAI) rather than standing up a second provider stack — that one already has config, env vars, and docs plumbing.
Synthesis is segmented with a 2-segment prefetch. Local TTS returns raw 24 kHz PCM (~48 KB/s), so a long selection delivered in one message would be megabytes.
protocol/src/messages.tsspeech.tts.read_aloud.request→ N.responsesegments;…cancel_read_aloud.requestserver/session/voice/read-aloud-controller.tsserver/speech/read-aloud-text.tsserver/speech/tts-text-splitter.tstts-manager.ts; now shared with voice modeclient/src/daemon-client.tsstartReadAloud()returns a handle withcancel()app/src/read-aloud/use-selection-anchor.web.tsvisibleBox; retained during playbackapp/src/read-aloud/read-aloud-placement.tsapp/src/read-aloud/read-aloud-selection-bubble.web.tsx#overlay-root; owns widths and stop intentapp/src/read-aloud/read-aloud-store.tsapp/src/read-aloud/read-aloud-audio.web.tsAudioEngine(playback context only, no mic)Scope
Web and Electron only. React Native exposes no JS API for the current text selection — iOS hands it to the system edit menu, Android to an ActionMode — so there is nothing to anchor to on the mobile apps.
read-aloud-selection-bubble.tsx(the non-.webfile) is a deliberatereturn null. Closing that gap would mean a turn-level "Read aloud" button reading the whole turn; not started.Bound to the route's host. The selection is spoken by the host it came from, never another paired daemon — the text is workspace content, so crossing hosts would disclose it across an independently paired boundary. No fallback: a route host that doesn't advertise the capability shows no button, and neither does a route with no host at all (settings, history).
useReadAloudServerIdis not unit-covered — it composesuseLocalSearchParamswith a zustand selector, so covering it needs a render harness; the invariant is a single comparison.Capability-gated on
server_info.features.readAloud(COMPAT(readAloud), v0.2.5). Hosts without it get no button at all — an upgrade prompt on every text selection would be worse than the feature being absent. No fallback path.Verification
npm run typecheck,npm run lint,npm run format:check— greenread-aloud-text.test.ts9,read-aloud-controller.test.ts9,tts-manager.test.ts6 (that last one covers the splitter extraction, so voice mode's segmentation is still under test)read-aloud-placement.test.ts14,read-aloud-store.test.ts5,voice-runtime.test.ts18,i18n/resources.test.ts32AudioContext— two segments, non-silent buffers (peak 0.27 / 0.35), contextrunning.agent-manager.test.ts, fixed in a separate commit:cancelAgentRun succeeds when the provider queues completion before rejecting the interruptfailed onserver-tests (windows-latest)withENOTEMPTY: rmdir '...\\agents'. The assertions passed — the fixture's teardown threw.AgentManagercan flush an agent snapshot intoagents/while cleanup runs, and on Windows an open handle makesrmdirfail withENOTEMPTY, whichforce: truedoes not cover. Fixed with themaxRetries: 5, retryDelay: 100options already used inspawn.launch-regression.test.tsandrun-git-command.windows-shell.test.ts; only the shared fixture is touched, since the file's otherrmSyncsites have not failed. Note thatserver-testsis load-sensitive under--fileParallelismand has separately failed with an unrelated timeout inexecution-session.websocket.test.tsand, on ubuntu, insidecheckout-git.commits.test.ts(passes locally 12/12) — this PR removes one failure mode, it does not make that job immune.getBoundingClientRect()browser cases; see the Outcome section ofdocs/refactors/read-aloud-selection-anchoring-plan.md.Known gaps — not fixed here
The GIF makes this look more finished than it is. Three things are open:
failure.message.<spoken-input>block should speak only the inner text.docs/refactors/read-aloud-handoff.mdcarries the full state, the environment gotchas (Node 22+ for the dev daemon, no daemon hot-reload,Bufferis not a browser global), and the open questions.🤖 Generated with Claude Code