Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 78 additions & 22 deletions docs/plans/2026-09-18-voice-input-tts-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ voice?: boolean | VoiceOptions; // Default false
Both `false` resolves to disabled.
- An unknown `mode` falls back to `"toggle"` rather than throwing, since it
can arrive from an HTML attribute.
- The option **fails closed**: only `true` or an options object enables it.
The string `"false"` from a CMS template is truthy, and must not switch a
microphone on.
- `<claudius-chat>` gains `voice`, `voice-mode`, `voice-auto-submit`,
`voice-input`, `voice-output`, and `voice-lang`. Unlike attachment limits
these are plain enums, booleans, and strings, and the web component cannot
Expand Down Expand Up @@ -146,8 +149,9 @@ message may speak at a time and it already holds `streamingMessageId`.
- `interimResults = true` so words appear in the field as they are spoken.
`continuous = false` in both modes. Continuous mode duplicates results on
Chrome for Android and misreports `isFinal` in Safari; a single utterance
per session is what a chat message needs. If the engine ends the session
during a pause, pressing the mic again appends.
per session is what a chat message needs. In toggle mode, if the engine
ends the session during a pause, pressing the mic again appends. Hold mode
bridges the pause itself (below).
- Dictation **appends** to whatever is already typed. The field value at
`start()` is the base; each result renders `joinDictation(base, transcript)`
clipped to the existing 2000 character limit.
Expand All @@ -167,16 +171,41 @@ message may speak at a time and it already holds `streamingMessageId`.
stops; Space and Enter work the same way from the keyboard. Releasing
before the engine has actually started calls `abort()` rather than
`stop()`, so a permission prompt answered after release can never leave the
microphone open. A `click` with no preceding pointer or key press, which is
how screen readers and switch devices activate a button, toggles instead,
so hold mode stays usable without a sustained press.
- **Auto-submit** fires only when the session ended normally and produced
final text. Never after an error, an abort, or silence. It goes through the
same submit path as the send button, so pending attachments, the length
limit, and plugins all apply.
- Starting dictation **cancels any read-aloud** so the microphone does not
transcribe the widget's own voice.
microphone open. A `click` with no click count, which is how screen readers
and switch devices activate a button, toggles instead, so hold mode stays
usable without a sustained press.
- **A hold is one dictation spanning several engine sessions.** With
`continuous = false` the engine ends a session at the first pause in
speech, which would silently drop whatever the visitor says next with the
button still down. So when a session ends mid-hold after hearing something,
`ChatInput` opens another and keeps appending. A session that heard nothing
is not replaced, so an engine that ends immediately cannot spin. Only a
real hold is bridged this way: a toggle start has no guaranteed release, so
bridging it could leave the microphone open indefinitely. The button tells
its parent which it was (`onStart({ held })`).
- `start()` and `stop()` report whether they did anything. A press while the
previous session is still winding down is refused, and must not move the
dictation base: that session's final result is still to arrive against the
old base, and re-basing would duplicate the text. When it then ends with
the button down again, the bridge above picks the new hold up.
- **Auto-submit** fires once per dictation, when it ends: never after a real
error or an abort, and only if something was heard. In hold mode that means
on release, not at the first pause. A later session of a hold timing out in
silence (`no-speech`) is not an error, just a visitor who stopped talking
while still holding; the text heard earlier is still sent on release. It
goes through the same submit path as the send button, so pending
attachments, the length limit, and plugins all apply.
- **Dictation and read-aloud end each other**, in both directions, so the
microphone never transcribes the widget's own voice (which auto-submit
would then send as the visitor's message).
- A request starting to load also ends dictation. The field is disabled then,
and an auto-submit would clear text that `useChat` refuses to send.
- Unmounting (closing the chat) aborts the session.
- Defensive against engines that misbehave: an error releases the session
without waiting for the `end` the spec promises; hearing anything counts as
"started" for engines that skip the `start` event; and an `aborted` raised
by the engine itself (another tab took the microphone) ends the dictation
quietly without running auto-submit.

### Listening indicator

Expand All @@ -203,10 +232,15 @@ in opacity.
have text. Nothing plays automatically.
- Text passes through the existing `stripAnnouncementFormatting`, so markdown
markers are not read out and URLs collapse to hostnames.
- The text is split at sentence boundaries into chunks of at most 200
characters and queued as separate utterances. That avoids Chrome's
15-second cutoff and gives clean pause points. Utterances are held in a ref
because Safari garbage-collects them mid-speech otherwise.
- The text is split at sentence boundaries into chunks of at most 150
characters, about ten seconds of speech, and queued as separate utterances.
That avoids Chrome's 15-second cutoff, with room for numbers, which take
far longer to say than to write. Boundaries are newlines, `.` `!` `?`
followed by whitespace (so "example.com" and "$9.99" do not split), and the
full-width `。` `!` `?`, which have no space after them. An over-long
sentence splits between words and an over-long unbroken token is cut, since
that is what unpunctuated Chinese or Japanese looks like. Utterances are
held in a ref because Safari garbage-collects them mid-speech otherwise.
- **The voice is left to the browser**: only `utterance.lang` is set. An
earlier draft picked a local voice explicitly, for privacy and to avoid the
cutoff. That was dropped. Voice lists are alphabetical, so "first local
Expand All @@ -222,8 +256,26 @@ in opacity.
`speak()` fires `error: synthesis-failed`, which returns the control to
idle rather than leaving it stuck.
- Controls: idle shows Read aloud; speaking shows Pause and Stop; paused
shows Resume and Stop. Starting another message stops the current one.
- A session counter makes late events from cancelled utterances harmless.
shows Resume and Stop. Starting another message stops the current one. The
first button is a single element that changes role, so keyboard focus
survives the swap; when Stop disappears with focus on it, focus moves to
the remaining button. Otherwise focus would fall to `<body>`, outside the
dialog's focus trap.
- **Pause state comes from the utterance's `pause` / `resume` events**, plus
the `paused` flag for Firefox, which sets it synchronously. Chrome and
Safari set the flag only once the engine confirms, so reading it right
after `pause()` (the first implementation) left the UI on "Pause" with no
way to resume. Verified against Chrome 153.
- **The engine is a shared global, so the reader only cancels speech it
started.** A reader that never spoke must not silence the host page when
the chat closes, which matters even with voice switched off, because the
hook always mounts. An `interrupted` / `canceled` error with no pause of
ours pending means another widget or the host page took over: the reader
forgets its own playback and leaves the engine alone, since cancelling
again would kill the newcomer's speech.
- Per the spec `cancel()` leaves a paused engine paused, and the next reply
would queue in silence, so stopping while paused also resumes the engine.
- A run counter makes late events from cancelled utterances harmless.
- **Android pause:** if a chunk ends while a pause was requested and the
engine does not report `paused`, the platform treated pause as cancel. The
rest of the queue is cancelled and the control returns to idle, instead of
Expand All @@ -240,9 +292,10 @@ existing `role="alert"` slot:
| `no-speech` | No speech was detected |
| `audio-capture` | No microphone was found |
| `network`, `language-not-supported`, anything else, `start()` throwing | Voice input is unavailable |
| `aborted` | ignored |
| `aborted` | no message |
| `no-speech` on a later session of the same hold | no message |

The message clears on the next start or edit. Synthesis errors return the
The message clears on the next start, edit, or send. Synthesis errors return the
control to idle silently; there is nothing useful to tell the user.

## Accessibility
Expand Down Expand Up @@ -276,9 +329,12 @@ implementations that tests drive event by event.
- `useSpeechRecognition`: unsupported no-op, session setup, interim and final
transcripts, stop versus abort, each error mapping, `start()` throwing,
double start, unmount.
- `useSpeechSynthesis`: chunk queue with language and voice, state from
events, pause and resume, the Android pause path, switching messages, stale
events after cancel, unmount, unsupported no-op.
- `useSpeechSynthesis`: chunk queue with language, pause state as each
browser family reports it, the Android pause paths, switching messages,
stale events after cancel, speech it does not own, unmount, unsupported
no-op. The fake confirms a pause asynchronously by default, as Chrome and
Safari do; an earlier fake that set `paused` inside `pause()` hid a real
bug.
- Components: visibility rules, both modes by pointer, keyboard, and bare
click, appending, typing takes over, auto-submit and the cases where it
must not fire, error display, read-aloud only on settled assistant
Expand Down
42 changes: 29 additions & 13 deletions docs/src/content/docs/configuration/voice.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ recognition, and your privacy policy may need to say so.
<claudius-chat api-url="https://your-worker.workers.dev" voice></claudius-chat>
```

`true` turns on both the mic and read-aloud. Pass an object to tune them:
`true` turns on both the mic and read-aloud. Pass an object to tune them. In
the `voice` prop and in `ClaudiusConfig`, anything else leaves voice off,
including a string such as `"true"`: an option that switches on a microphone
fails closed. (HTML attributes are always strings, so `<claudius-chat>` parses
them for you, as shown below.)

| Option | Default | Description |
|--------|---------|-------------|
Expand Down Expand Up @@ -87,20 +91,27 @@ an error or a silent session. It uses the same path as the send button, so
pending [attachments](/configuration/attachments/), the length limit, and
[plugins](/plugins/) all apply.

Starting dictation stops any reply that is being read aloud, so the
microphone does not transcribe the widget's own voice.
Dictation and read-aloud end each other: starting one stops the other, so
the microphone never transcribes the widget's own voice. A message being sent
ends dictation too.

### Toggle or hold

`"toggle"` is the default and the more accessible choice, because it needs no
sustained press. While listening, the button shows a stop icon.

`"hold"` is push-to-talk: recording runs from press to release, with a mouse,
a finger, or Space / Enter on the keyboard. If the visitor lets go before the
browser's permission prompt has been answered, nothing starts, so granting
permission afterwards can never open the microphone with nobody at the
button. Screen readers and switch devices activate buttons with a click they
cannot hold, so in hold mode a click of that kind toggles instead.
a finger, or Space / Enter on the keyboard. Browsers end a recognition session
at the first pause in speech, so while the button stays down Claudius opens
the next session itself and keeps appending; a visitor can stop to think
mid-sentence without losing the rest. With `autoSubmit`, the message is sent
**on release**, not at the first pause.

If the visitor lets go before the browser's permission prompt has been
answered, nothing starts, so granting permission afterwards can never open
the microphone with nobody at the button. Screen readers and switch devices
activate buttons with a click they cannot hold, so in hold mode a click of
that kind toggles instead.

### Listening indicator

Expand All @@ -123,11 +134,16 @@ in opacity only.
The button appears once a reply has finished streaming. Formatting marks are
not spoken and links are shortened to their hostname, the same cleanup used
for screen-reader announcements. One reply plays at a time; starting another
stops the first, and closing the chat stops playback.

Long replies are queued sentence by sentence. Chrome cuts a single long
utterance off after about 15 seconds when it is using a network voice, and
short utterances avoid that.
stops the first, and closing the chat stops playback. `speechSynthesis` is
shared with the rest of the page, so Claudius only ever stops speech it
started itself: if your site uses speech synthesis too, closing the chat or
starting dictation will not cut it off. Pressing Read aloud does take over,
since only one thing can speak at a time.

Long replies are queued a few sentences at a time, about ten seconds of
speech each. Chrome cuts a single long utterance off after about 15 seconds
when it is using a network voice, and short utterances avoid that. Chinese
and Japanese sentence marks are recognized as well.

On **Android**, browsers implement "pause" as "stop". Claudius detects this
and returns the control to its idle state, rather than carrying on with the
Expand Down
71 changes: 69 additions & 2 deletions widget/e2e/voice.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { test, expect, type Page } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mockChatApi } from "./helpers";

const __dirname = dirname(fileURLToPath(import.meta.url));
const IIFE_PATH = resolve(__dirname, "..", "dist", "claudius.iife.js");
const CSS_PATH = resolve(__dirname, "..", "dist", "claudius.css");

/**
* Voice features against fake speech engines, injected before the app loads.
* Real engines cannot run here: CI has no microphone, and Chromium builds ship
Expand Down Expand Up @@ -31,6 +37,10 @@ async function installFakeSpeech(page: Page) {
this.onend?.({});
}
abort() {}
/** The engine ends the session by itself, as it does at a pause. */
end() {
this.onend?.({});
}
hear(transcript: string, isFinal: boolean) {
this.onresult?.({
results: [{ isFinal, length: 1, 0: { transcript } }],
Expand All @@ -39,24 +49,33 @@ async function installFakeSpeech(page: Page) {
}

const spoken: string[] = [];
let current: SpeechSynthesisUtterance | null = null;
const synth = {
speaking: false,
pending: false,
paused: false,
spoken,
speak(utterance: SpeechSynthesisUtterance) {
spoken.push(`${utterance.lang}|${utterance.text}`);
current = utterance;
this.speaking = true;
},
// Per the spec, cancel() does not clear the paused state.
cancel() {
this.speaking = false;
this.paused = false;
current = null;
},
// Like Chrome and Safari: `paused` flips only when the engine confirms,
// which is also when the utterance gets its `pause` event.
pause() {
this.paused = true;
setTimeout(() => {
this.paused = true;
current?.onpause?.call(current, new Event("pause") as never);
}, 0);
},
resume() {
this.paused = false;
current?.onresume?.call(current, new Event("resume") as never);
},
getVoices: () => [],
};
Expand Down Expand Up @@ -135,6 +154,54 @@ test.describe("voice input and read-aloud", () => {
.toBeGreaterThan(0);
});

test("hold-to-talk through the embed bundle: a real press and release", async ({
page,
}) => {
await installFakeSpeech(page);
const api = await mockChatApi(page);
api.enqueueReply("Yes, 20 percent.");
// Same approach as embed.spec.ts: the dev origin, minus the dev widget.
await page.goto("/");
await page.evaluate(() => {
document.getElementById("root")?.remove();
(window as unknown as { ClaudiusConfig: unknown }).ClaudiusConfig = {
apiUrl: "https://test.example",
voice: { mode: "hold", autoSubmit: true },
};
});
await page.addStyleTag({ path: CSS_PATH });
await page.addScriptTag({ path: IIFE_PATH });
await page.getByRole("button", { name: /open chat/i }).click();

const mic = page.getByRole("button", { name: "Hold to talk" });
const box = (await mic.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expect(mic).toHaveAttribute("aria-pressed", "true");
await hear(page, "do you offer", true);

// The engine ends the session at a pause, with the button still down.
// A new session takes over, and nothing is sent yet.
await page.evaluate(() =>
(
window as unknown as { __recognition: { end(): void } }
).__recognition.end(),
);
await expect(mic).toHaveAttribute("aria-pressed", "true");
expect(api.callCount()).toBe(0);
await hear(page, "a discount", true);

await page.mouse.up();

const log = page.getByRole("log");
await expect(log.getByText("do you offer a discount")).toBeVisible();
await expect(log.getByText("Yes, 20 percent.")).toBeVisible();
// Exactly one message: the click Chromium fires after the release carries
// a click count, so it must not have started another dictation.
expect(api.callCount()).toBe(1);
await expect(mic).toHaveAttribute("aria-pressed", "false");
});

test("reads a reply aloud with pause and stop controls", async ({ page }) => {
await installFakeSpeech(page);
const api = await mockChatApi(page);
Expand Down
Loading
Loading