Skip to content

feat(widget): voice input and text-to-speech playback - #153

Merged
PAMulligan merged 5 commits into
mainfrom
54-voice-input-tts
Sep 19, 2026
Merged

PAMulligan merged 5 commits into
mainfrom
54-voice-input-tts

Conversation

@PAMulligan

@PAMulligan PAMulligan commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up: #155. This PR was merged at 766ddd4. An independent review that finished after the merge reproduced several bugs in it, including one that affects embeds with voice switched off. They are fixed in #155, which should be merged before the 1.16.0 release. The description below is as written at merge time.

Description

Adds opt-in voice to the widget, built directly on the browser's Web Speech API with no new dependencies and no worker changes.

  • Dictation. A mic button in the composer, shown only where the browser has SpeechRecognition. Click-to-toggle (default) or hold-to-talk. Words stream into the message field as they are spoken and append to anything already typed. Typing takes over from an open session, and sending ends it. Optional auto-submit.
  • Read aloud. A speaker button under each settled assistant reply, with pause, resume, and stop. One reply at a time; it falls silent when dictation starts or the chat closes.
  • voice option on all three embed styles: React prop, window.ClaudiusConfig.voice, and <claudius-chat voice voice-mode voice-auto-submit voice-input voice-output voice-lang>. Off by default, like attachments, so a minor release does not put a microphone in every existing embed.
  • Client configs. widget.voice in clients/_schema.json, claudius validate, and both snippet generators.
  • i18n. Eleven strings in en, es, fr, and de.
  • Docs. New configuration/voice.md, plus the design doc at docs/plans/2026-09-18-voice-input-tts-design.md, which records every decision below in more depth.

Please read: two deliberate departures from the issue

1. The privacy wording in #54 is not accurate, so the docs say something different. The issue asks for a documented posture that "speech is processed on-device by the browser; no audio leaves the client." That is false for the default Web Speech API. MDN: "By default, using speech recognition on a web page involves a server-based recognition engine. Your audio is sent to a web service for recognition processing." Chrome sends audio to Google; Safari uses the Siri engine and Apple does not say where a request is handled. Publishing the original claim could mislead clients writing their own privacy policies.

The docs state what is true and verifiable instead: the widget never handles audio (it does not call getUserMedia), and no audio reaches the worker or Anthropic, but the visitor's browser vendor does process it, and site owners should say so. On-device recognition (processLocally, desktop Chrome 139+) exists but is experimental, unavailable on Android, Safari, and Firefox, and needs a language-pack install flow. I left it as a possible follow-up rather than build v1 on it.

2. The "waveform / level indicator" shows voice activity, not loudness. The Web Speech API exposes no audio signal. A true meter needs a second microphone capture through getUserMedia, which conflicts with the recognizer on Android and iOS (the platforms the issue cares most about), double-prompts in Safari, and would give the widget raw audio, making the privacy statement above untrue. The bars instead react to the engine's speechstart / speechend and result events, so they still show whether the visitor is being heard.

Smaller interpretation calls: "auto-submit toggle" is a configuration option rather than a visitor-facing switch; and the voice is left to the browser (only the language is set), because picking one explicitly lands on the novelty voice "Albert" on some Macs and would replace Edge's Natural voices.

Platform quirks handled, each pinned by a test

  • Releasing the mic before the permission prompt is answered aborts rather than stops, so a late "Allow" cannot open the microphone with nobody at the button.
  • Replies are queued as sentence-sized utterances to dodge Chrome's ~15 s cutoff.
  • On Android pause() ends the utterance; the rest of the queue is dropped instead of reading on after a "pause".
  • Late error events from cancelled utterances are ignored.
  • Hold mode still works for screen readers and switch devices, which activate a button with a click they cannot hold.
  • prefers-reduced-motion stills the indicator.

Related Issue

Closes #54

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Chore (dependency updates, CI changes, refactoring)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist

  • Tests pass (pnpm test in both widget/ and worker/): widget 507, worker 153, scripts 82
  • Linting passes (pnpm lint in widget/): 0 errors, the same 2 pre-existing warnings as main
  • Formatting passes (pnpm format:check in widget/)
  • Types check (pnpm typecheck in widget/), and docs:api:check and check:exports are clean
  • Widget builds successfully (cd widget && pnpm build)
  • Documentation updated, and the docs site built locally (61 pages; new anchors verified)
  • Branch name follows convention (feat/, fix/, docs/, chore/): uses the repo's existing <issue>-<slug> pattern

Screenshots / Notes

Bundle size. Budgets are raised in their own commit, to measured size plus the usual 5%. True cost against main, measured with size-limit: claudius.iife.js +3.86 KB gzip, claudius.js +4.67 KB gzip, claudius.css +262 B gzip. That is in line with attachments (+3.6 KB gzip). The IIFE build cannot code-split, so the code cannot be deferred until voice is enabled.

What was and was not verified.

  • Unit and component tests drive fake speech engines event by event (jsdom has none). Coverage on the new files is 94 to 100% of statements.
  • A new Playwright spec, e2e/voice.spec.ts, injects fake engines into a real browser. The whole desktop e2e suite (14 tests) passes locally against system Chrome 153. mobile.spec.ts runs on WebKit, which I cannot run locally, so that one relies on CI.
  • Against real Chrome 153 I confirmed that the unprefixed SpeechRecognition is preferred, and that speak() with no voices installed fires error: synthesis-failed, which returns the control to idle rather than leaving it stuck.
  • Not verified: real speech end to end. This machine has no microphone input and no TTS voices, so I have not heard it listen or speak. Nor have I tried it on a physical iPhone or Android device, where the Web Speech API is quirkiest. The Android pause and Safari behaviours are implemented from MDN compat data and covered by tests against fakes. Worth five minutes on a real phone before release.

Unrelated, noticed in passing: the "Available keys" table in configuration/localization.md was already missing the attachment, streaming, and tool-use string keys. I added the voice row and left the rest alone.

Storybook: ChatInput › WithVoiceInput / WithVoiceHoldToTalk, and ChatMessage › WithReadAloud / ReadingAloud.

🤖 Generated with Claude Code

PAMulligan and others added 5 commits September 18, 2026 21:29
Design for #54. Records the researched platform constraints that shape
the feature, most importantly that Web Speech recognition is server-based
by default, so the issue's on-device privacy claim cannot be documented
as written.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add an opt-in `voice` option (React prop, `window.ClaudiusConfig` key, and
`<claudius-chat voice ...>` attributes). Off by default, like attachments.

Dictation: a mic button in the composer, shown only where the browser has
SpeechRecognition. Toggle (default) or hold-to-talk, with interim results
streaming into the message field and appending to typed text. Typing takes
over from an open session, and sending ends it. Optional auto-submit fires
only after a session that ended normally and heard something. A
voice-activity indicator sits inside the field while listening.

Read aloud: a speaker control under settled assistant replies, with pause,
resume, and stop. One reply at a time, silenced when dictation starts or
the chat closes. Markdown is stripped the same way as the screen-reader
announcement.

Platform quirks handled, each pinned by a test:
- stop() before the engine has started aborts instead, so answering the
  permission prompt after release cannot open the mic
- replies are queued as sentence-sized utterances to dodge Chrome's
  ~15 s cutoff
- on Android pause() ends the utterance; the rest of the queue is dropped
  rather than read on after a "pause"
- late error events from cancelled utterances are ignored
- an idle engine is not sent cancel() right before speak()
- hold mode still toggles on a click with no click count, which is how
  screen readers and switch devices activate a button

Speech language follows the widget locale, refined by the region in
<html lang> or the browser language (en-GB page -> British recognition),
defaulting to en-US. Eleven new strings in en, es, fr, and de.

The test setup gains a PointerEvent polyfill: jsdom has none, so Testing
Library was dispatching bare Events with no `button` or `pointerId`.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…snippets

`widget.voice` accepts a boolean or { input, output, mode, autoSubmit, lang }
in clients/_schema.json and in `claudius validate`, with a per-field error
for each malformed option.

The script snippet passes the value straight through to ClaudiusConfig. The
web component snippet emits one attribute per option (voice-mode,
voice-auto-submit, voice-input, voice-output, voice-lang): unlike attachment
limits these are plain enums and booleans, and the element cannot read
ClaudiusConfig, so without them the modes would not be configurable there.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New configuration/voice.md: enabling voice across the three embed styles,
options and attributes, toggle versus hold, auto-submit semantics, language
resolution, a browser support matrix, where each browser's voices come from
and how to list them, and troubleshooting for blocked microphones.

The privacy section corrects the premise in #54. Web Speech recognition is
not on-device by default: MDN states that audio "is sent to a web service
for recognition processing", and Safari uses the Siri engine without saying
where a request is handled. The page says what is true and verifiable
instead: the widget never handles audio (no getUserMedia) and none reaches
the worker, while the browser vendor does process it, which site owners
should reflect in their privacy policy. It also notes that a network voice
sends reply text to the vendor.

Also: widget options table and attribute list, client config fields,
localization keys, two FAQ entries, CLAUDE.md, and the design doc brought
in line with what was built.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Voice input and read-aloud (two hooks, the mic button and its hold / toggle
gestures, the listening indicator, the read-aloud controls, and eleven
strings in four locales) add, measured against main with size-limit:

  claudius.iife.js   +11.9 KB raw   +3.86 KB gzip   +3.43 KB brotli
  claudius.js        +15.9 KB raw   +4.67 KB gzip   +3.71 KB brotli
  claudius.css        +1.5 KB raw    +262 B gzip     +257 B brotli

That is in line with attachments (+3.6 KB gzip) for a comparable feature.
The IIFE build cannot code-split, so the code cannot be deferred until
voice is enabled. New limits are the measured sizes plus the usual 5%
headroom, matching the earlier budget bumps.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying chat-widget with  Cloudflare Pages  Cloudflare Pages

Latest commit: 766ddd4
Status: ✅  Deploy successful!
Preview URL: https://8c0d8314.chat-widget-ejc.pages.dev
Branch Preview URL: https://54-voice-input-tts.chat-widget-ejc.pages.dev

View logs

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
claudius.iife.js (raw) 205.28 KB (+6.01% 🔺)
claudius.iife.js (gzip) 65.74 KB (+6.09% 🔺)
claudius.iife.js (brotli) 57.7 KB (+6.17% 🔺)
claudius.js (raw) 95.57 KB (+19.46% 🔺)
claudius.js (gzip) 26.13 KB (+21.17% 🔺)
claudius.js (brotli) 22.43 KB (+19.29% 🔺)
claudius.css (raw) 23.1 KB (+7% 🔺)
claudius.css (gzip) 4.93 KB (+5.47% 🔺)
claudius.css (brotli) 4.35 KB (+6.13% 🔺)

@PAMulligan
PAMulligan merged commit 9f8484b into main Sep 19, 2026
10 checks passed
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.

Voice input (Web Speech API) and text-to-speech playback

1 participant