YapAI wrote it. Yap reads it out loud.Install from the VS Code Marketplace
· A VS Code extension that reads the selected code aloud, or summarizes it with an LLM and reads the explanation aloud — an audiobook for source files.
Select code, right-click, and pick what you want to hear — or use the keyboard shortcuts and never leave the editor. Commands
Read and summarize also appear in the editor context menu when text is selected.
With an empty selection they act on the whole document. The status bar shows what
is playing and the position within it ( PlaybackThe speaker is a chunked player, not a fire-and-forget call: the text is split into sentence-ish chunks and each one is a separate engine process. That is what makes the transport controls possible.
Chunk size is tunable through
Platform support
Only the macOS path has been run against an actual speech device. The Windows
and Linux invocations are covered by unit tests that assert the exact argv and
PowerShell script produced — On Linux the engine is probed at first use and Windows rate mapping is not a pass-through: SAPI uses a -10…10 scale where 0 is
roughly 200 wpm, so Because audio needs a real output device, the extension declares
Voice-name validation is macOS-only, deliberately: Read modes
|
| Provider | Key needed | Typical latency |
|---|---|---|
| Copilot | no (needs a subscription) | ~2–3s |
| Claude Code CLI | no | 6–20s |
| API key | yes | ~2–4s |
The CLI is the slowest by a wide margin, and the spread is real: repeated
identical calls on the same machine ranged from 6s to 20s. Almost all of it is
CLI startup rather than inference, which is why --strict-mcp-config (skipping
your configured MCP servers) is passed unconditionally, and why setting
codeNarrator.claudeCli.model to a smaller model does not reliably speed it
up — it just produces a shallower summary. Leave that setting empty.
If claude works in your terminal but the extension reports it missing, set
codeNarrator.claudeCli.path. A GUI-launched VS Code frequently inherits a
shorter PATH than an interactive shell.
The CLI is invoked with cwd set to the temp directory, so no project
CLAUDE.md, settings file, or workspace-trust prompt is pulled into what should
be one stateless call. All tools are denied and --max-turns 1 is set: nothing
should turn a summary request into an agent loop over your repository.
Privacy
Summarizing sends source code off the machine, so:
- A modal consent prompt appears before the first outbound call, naming the
destination. "Allow always" is remembered in
globalState. codeNarrator.summary.denyListblocks files that must never be sent. Defaults cover.env,.env.*,*.pem,*.key,*.p12,secrets/**, andcredentials*.- Reading aloud is entirely local and involves no network call.
- A workspace cannot redirect where your code goes.
codeNarrator.claudeCli.pathandcodeNarrator.apiKey.endpointare declared"scope": "machine", so they can only be set in user settings. Without that, a repository could ship a.vscode/settings.jsonpointing the endpoint at its own server — exfiltrating both the selection and your API key — or pointing the CLI path at an arbitrary binary that Yap would then execute. Neither needs you to do anything but summarize once, and "Allow always" would suppress the consent prompt entirely. - A workspace can only tighten the deny list, never loosen it. The effective
list is your user-level patterns plus whatever the workspace adds, so a
repository cannot whitelist its own
.env. - Summarizing is disabled in a restricted workspace (
untrustedWorkspaces: limited). Reading aloud still works, because it is local.
The Claude Code CLI provider is not an offline option — claude runs locally
but still sends the code to Anthropic's API. The consent dialog says so.
Development
npm install
npm test # 102 unit tests over the pure modules, no VS Code harness needed
npm run verify # type-check + test + bundle
Press F5 to launch the Extension Development Host with the extension loaded, then cmd+R in that window to reload after an edit.
src/
extension.ts # activate(), command registration, progress + consent UX
speaker.ts # Speaker: spawn/kill the OS TTS process, chunked playback
humanize.ts # code -> speakable prose
summarize.ts # Summarizer interface + Copilot, Claude CLI, API-key providers
selection.ts # the text to act on, with fallbacks
deny.ts # glob matching for the summarizer deny list
comments.ts # per-language comment/docstring scanner for commentsOnly mode
claudecli.ts # argv construction and output cleanup for the CLI provider
apikey.ts # key validation and paste normalization
speaker.ts, humanize.ts, deny.ts, comments.ts, claudecli.ts, and
apikey.ts import no vscode, which is what
makes them testable with plain node --test (via Node's native TypeScript type
stripping — no compile step, no test dependencies).
Two invariants worth preserving when editing:
- Text reaches the synthesizer through a temp file, never a command line.
A selection can contain quotes, backticks,
$, and newlines; interpolating that into a shell string is arbitrary code execution triggered by selecting text.spawn(..., { shell: false })plus a temp file avoids the whole class. - Untrusted text never reaches a command line. The speech layer uses a temp file; the CLI summarizer uses stdin. A selection is arbitrary text, and arbitrary text in argv or a shell string is an injection hazard triggered by the victim selecting code.
stop()bumps a generation counter, so it cancels the queued chunks and not just the chunk currently playing — and akill()-induced non-zero exit is not reported as an error. Pause and seek use the same mechanism, which is why they can interrupt a chunk without the player treating it as a failure.utter()andcancelCurrent()are the process-lifecycle seam. Overriding those two is how the queue and cursor logic is tested without a speech device (src/test/player.test.ts); keep themprotectedand keep the state machine out of them.
Publishing
package.json declares capabilities.untrustedWorkspaces as limited and
virtualWorkspaces as unsupported — the latter because Yap spawns a local speech
engine and so needs a local extension host.
The three transport commands carry enablement clauses driven by two context
keys, codeNarrator.playing and codeNarrator.paused, set from the player's
state callback. Without them Pause and the seek commands appear in the palette
while nothing is playing and silently do nothing. Those keys are also usable in
your own keybindings.json when clauses.
Packaging
npm run package # -> yap-0.0.1.vsix
code --install-extension yap-0.0.1.vsix # smoke-test the real artifact
Roadmap
- Verify the Windows and Linux backends on real hardware
- Semantic-token-based comment detection, falling back to the token table
- Word-boundary highlighting for karaoke-style follow-along (
say -oemits timing data) - Chapter-by-chapter narration of a whole file via the document symbol tree
- Summary caching keyed by a hash of the selection
