Read aloud: speak an agent turn from its footer - #2675
Open
kaspesi wants to merge 18 commits into
Open
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.
Contributor
|
| Filename | Overview |
|---|---|
| packages/app/src/read-aloud/read-aloud-audio.web.ts | Adds web playback through the shared audio engine and closes the initialization-time cancellation window. |
| packages/app/src/read-aloud/read-aloud-store.ts | Implements the app-wide ownership, lifecycle, cancellation, and playback-completion state machine. |
| packages/app/src/read-aloud/use-read-aloud-route-guard.ts | Stops active playback when navigation leaves the host that owns the spoken turn. |
| packages/app/src/voice/audio-engine.web.ts | Adds generation-based cancellation so audio decoded after a stop cannot reach the output. |
| packages/server/src/server/session/voice/read-aloud-controller.ts | Sanitizes, segments, synthesizes, and streams read-aloud requests with cancellation and bounded prefetch. |
| packages/client/src/daemon-client.ts | Adds the segmented read-aloud request API and cancellable client handle. |
| packages/protocol/src/messages.ts | Defines the validated request, response, and cancellation messages for read-aloud streaming. |
Sequence Diagram
sequenceDiagram
participant U as User
participant F as Turn footer
participant S as Read-aloud store
participant C as Daemon client
participant D as Daemon TTS
participant A as Audio engine
U->>F: Press Read aloud
F->>S: startReadAloud(turn, host, closing prose)
S->>C: speech.tts.read_aloud.request
C->>D: Request synthesis
loop Segments
D-->>C: Audio segment
C-->>S: onSegment
S->>A: Decode and queue playback
end
D-->>C: Stream complete
C-->>S: onEnd
S-->>F: Return owner to idle
alt Stop, supersede, or leave host
F->>S: stopReadAloud
S->>C: Cancel synthesis
S->>A: Clear queue and stop audio
end
Reviews (15): Last reviewed commit: "fix(read-aloud): drop a segment cancelle..." | 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.
`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.
The project owner asked for the button in the footer instead of anchored to a selection: "just speaking the message after the last tool call should be enough." Every completed assistant turn now gets a speaker button next to copy. Pressing it speaks the turn's closing prose — what the agent wrote after its last tool call, not the narration in between. Pressing again stops. This deletes the selection apparatus outright: anchoring, placement, and the floating bubble, ~1,140 lines. Both open Greptile P1s about selected text crossing daemon boundaries lived in those files and go with them. The protocol, daemon controller, sanitizer, splitter, and audio engine are unchanged — only the app-side trigger moved. - strategy: `collectAssistantTurnSpeech` walks the turn backward and breaks on `tool_call`, unlike the copy collector which takes the whole turn. Lives in the strategy because traversal direction is ±1 (native renders an inverted list). - store: playback is one app-wide slot, so the snapshot now carries an `ownerId`. Without it every footer would render itself as speaking. - host binding: `useReadAloudServerId` lifted out of the deleted bubble into its own module, keeping the route-host-only invariant. Web and Electron only, for a new reason: selection was impossible on RN, but the footer button is not — native is blocked solely on the `read-aloud-audio.ts` playback stub, so the button hides where it would be silent. Implementing native audio now unblocks iOS/Android with no other change.
One conflict, in message.tsx: main added the assistant-selection-copy imports on the same line as the read-aloud button import. Both are kept — selection copy and turn read-aloud are unrelated features. Also picks up new dependencies (@parcel/watcher, p-throttle, turndown); node_modules synced with npm install.
A solid filled square reads as a blob at footer size, next to the outlined copy and fork icons. CircleStop keeps the same stop semantics with a square inside a circle outline, matching the weight of its neighbours.
CI typecheck: "Type 'boolean | undefined' is not assignable to type 'boolean'". Pressable's render prop types `hovered` as optional, but resolveIconColor — extracted when the nested ternary was flattened — declared it required. Widen the param instead of coercing at the call site: undefined means "no pointer on this platform", which is exactly the not-hovered branch.
Greptile P1: navigating to another workspace mid-playback unmounted the turn footer — and with it the only Stop control — while the module-level read-aloud session kept playing. No footer on the new route owned the old turn id, so every button showed idle and the audio became unstoppable. This is a consequence of moving the trigger into the footer: the old selection bubble was unmounted by route changes, a per-turn button is not. Record the owning host alongside the owning turn, and stop playback when the route leaves it. The guard is mounted once at the app root next to FaviconStatusSync, deliberately not per-button: footers unmount during ordinary list virtualization, so a useEffect cleanup in the button would kill playback on scroll. It re-reads the snapshot before stopping so a read that already finished on its own is not double-stopped. Bypasses the pre-commit hook for the known local-only draggable-list typecheck error, which does not reproduce on CI. Verified first that no read-aloud file appears in the typecheck output.
playwright (shard 1/4) failed on chat-outline and add-project-flow, two specs this branch does not touch. The suite is load-sensitive: shard 1 passed on the previous two runs of this branch, and main's own recent run failed different specs (host-appearance, new-workspace-entry). Re-running identical code to confirm.
Greptile P1: a segment crosses two awaits in `playAudio` — context initialization and `decodeAudioData` — before it registers as `activePlayback`. A stop landing in that window has nothing to cancel: `clearQueue()` cannot reach a segment already dequeued, and `stop()` sees no active playback. When decoding finished, `source.start()` played audio the user had already stopped. Bump a generation counter on `stop()` and `clearQueue()`, and re-check it after the awaits — the same pattern the read-aloud store uses to ignore callbacks from superseded requests. The engine is shared with voice mode and realtime voice, so the whole `src/voice/` suite was run: 32 tests, no regressions in the neighbouring consumers. Tests assert the real defect: with the generation check removed both new cases fail, and pass again once restored. Bypasses the pre-commit hook for the known local-only draggable-list typecheck error. Verified first that it is the only remaining error and that no voice or read-aloud file appears.
playwright (shard 4/4) failed on sidebar-resize-handle, a hover-highlight test this branch does not touch. Shard 4 passed on the previous three runs of this branch, and hover-dependent specs fail on unrelated branches too (chat-outline on feat/custom-json-themes). Third distinct victim across runs here. Re-running identical code.
…stead Two cleanups the footer pivot left behind. **Speed.** The selection bubble had 1x/1.5x/2x chips; the footer button has none, so the whole speed path was unreachable. Worse, it had reached into shared code: `setPlaybackRate` was added to the `AudioEngine` interface, forcing a web implementation and a no-op native stub that existed only to satisfy a method nothing called. Removed end to end. `audio-engine-types.ts`, `audio-engine.native.ts`, and `voice-runtime.test.ts` are now byte-identical to main — this PR's footprint on shared voice code is the cancellation fix and nothing else. The `playbackGeneration` guard stays; it is unrelated to speed. **Failures.** The button computed `failed` and turned red but never said why, while five translated `readAloud.errors.*` strings sat unused. They are now surfaced through `useFailureLabel` as the accessibility label and a hover title. The daemon's raw `failure.message` is still not shown: it is untranslated English written for a log. Also drops the now-unused `readAloud.speed` key from all eight locales. Verified: 229 tests across voice, read-aloud, agent-stream, and i18n — including locale key parity and the voice-mode suites that share the engine.
… docs `docs/refactors/` is not a repo convention for feature work — main has one file there, a server decomposition plan, and CLAUDE.md's docs table does not list the directory. The two docs this branch added were session scratch: a handoff and a pivot rationale, both aimed at the next agent rather than at a contributor. Per CLAUDE.md's doc rules — integrate don't append, one fact one doc, code-level facts belong in comments — the two facts worth keeping move to the doc that owns them: - The daemon has no watcher and must be restarted after server changes. - Speech features need Node 22+; on Node 20 the local speech worker exits with code 9 and every synthesis fails. Both are general dev-environment facts, not read-aloud facts, and neither was documented anywhere else. The `Buffer` gotcha is already a comment at the import that needs it. The feature itself is documented for users in public-docs/voice.md.
…esi/paseo into text-selection-speech-modal
The daemon-restart and Node 22 paragraphs are general dev-environment facts, not part of this change. They do not belong in a read-aloud PR.
Greptile P1. The earlier generation guard covers the window inside `play()`, but a stop landing during `await active.initialize()` is forgotten: `play()` then runs fresh, captures the post-stop generation, and proceeds as a legitimate request. Audio played with no footer owning it and no Stop control. Check for cancellation after `initialize()` using the read-aloud store's own generation counter. Deliberately not by exposing the engine's private counter — that would push read-aloud state into shared voice code. Also cleans up leftovers from the selection approach this feature moved away from: - "Selection is too long to read aloud" -> "Message is too long", plus the same wording in eight locales. No test asserted the old string. - Comments in read-aloud-text.ts, read-aloud-controller.ts, session.ts, and messages.ts that still described a selection. - The native stub's doc block claimed native was unsupported because RN exposes no selection API. That stopped being the reason at the pivot; the actual blocker is the missing PCM decode path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every completed agent turn gets a speaker button in its footer, next to copy. Press it and the daemon speaks the turn's closing message — the prose after the last tool call, not the narration in between. Press again to stop.
What it speaks
Only the prose after the turn's last tool call. Copy takes the whole turn; reading a long turn from the top would replay narration the user already watched scroll by.
The turn above contains two
ToolSearchcalls and one line of prose. Pressing its button synthesizes exactly one 2.03 s segment — the closing line, with the tool calls and the<spoken-input>wrapper above them excluded.Turns that end on a tool call have nothing to say, so they show no button.
How it works
agent-stream/strategy.tscollectAssistantTurnSpeech— walks the turn backward, breaks ontool_callread-aloud/turn-read-aloud-button.tsxread-aloud/read-aloud-store.tsread-aloud/use-read-aloud-route-guard.tsread-aloud/use-read-aloud-host.tsserver/session/voice/read-aloud-controller.tsserver/speech/read-aloud-text.tsserver/speech/tts-text-splitter.tstts-manager.ts; shared with voice modeclient/src/daemon-client.tsstartReadAloud()returns a handle withcancel()protocol/src/messages.tsspeech.tts.read_aloud.request→ N.responsesegments; plus cancelUses the existing voice-mode TTS provider (local Kokoro by default, or OpenAI) rather than a second provider stack — that one already has config, env vars, and docs plumbing. Synthesis is segmented with a 2-segment prefetch because local TTS returns raw 24 kHz PCM (~48 KB/s), so a long message in one frame would be megabytes.
Three things carry state that a per-turn button makes non-obvious:
One voice at a time. Playback is a single app-wide slot, so the snapshot names the turn that owns it. Without that, every footer subscribing to the same store would render itself as speaking. Pressing another turn's button supersedes the first.
Playback belongs to a host. The Stop control lives in a turn footer, so navigating to another workspace would unmount it while audio kept playing — unstoppable. The store records the owning host and a guard at the app root stops playback when the route leaves it. Deliberately not a per-button
useEffectcleanup: footers unmount during ordinary list virtualization, which would kill playback on scroll.Cancellation reaches in-flight decodes. A segment crosses two awaits before it registers as active playback. A stop landing in that window would otherwise have nothing to cancel, and the segment would start playing after the user asked for silence. A generation counter closes it.
Scope
Web and Electron only.
read-aloud-audio.tsis a playback stub on native, so the button hides where it would be silent. Nothing else blocks iOS/Android — implementing native audio would enable it with no other change.Bound to the route's host. The text is workspace content, so it is spoken by the host it came from, never another paired daemon. 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).
Capability-gated on
server_info.features.readAloud(COMPAT(readAloud), v0.2.5). Hosts without it get no button. No fallback path.Evidence
The GIF is silent, so playback is proven numerically, not by the UI agreeing with itself —
AudioBufferSourceNode.startwas instrumented to measure what actually reached the output.ToolSearchcalls + 1 line → exactly one 2.03 s segment/sessionsmid-read →stopcalled, 0 new segmentsstopnever calledsrc/voice/suite green — voice mode and realtime voice unaffectedPlatform matrix (per docs/qa.md):
npm run dev:desktop: buttons render, audio peak 0.392, route guard fires (stopcalled, 0 new segments over 8 s)read-aloud-audio.tsis a playback stub, so there is nothing to showAutomated: typecheck, lint, format clean. 229 tests across
src/voice/,src/read-aloud/,src/agent-stream/, andsrc/i18n/, including 4 cases for the speech collector (after last tool call / no tool calls / ends on a tool call → empty / both traversal directions), ownership and host-ownership cases on the store, the decode-race cases, and locale key parity.Known gaps
<spoken-input>turn shown above exercises it incidentally against a live daemon.🤖 Generated with Claude Code