diff --git a/CHANGELOG.json b/CHANGELOG.json
index 82e2eb0..ce55e07 100644
--- a/CHANGELOG.json
+++ b/CHANGELOG.json
@@ -6,6 +6,61 @@
"commitConvention": "conventional",
"maintainers": ["grokify", "johncwang@gmail.com"],
"releases": [
+ {
+ "version": "v0.6.0",
+ "date": "2026-11-02",
+ "highlights": [
+ {
+ "description": "`@plexusone/speech` 0.1.0: read text aloud and take dictation with the browser's own speech, with word highlighting, React hooks and a standalone build for pages without a build step",
+ "commit": "7cdc6a3"
+ }
+ ],
+ "added": [
+ {
+ "description": "`speak`: text or segments with pauses, split into short utterances, with pause and resume, a cancel before new speech and one retry when the browser accepts speech but never starts it (`start-timeout`); speech cancelled by another script ends as `stopped`",
+ "commit": "7cdc6a3"
+ },
+ {
+ "description": "`listen`: speech recognition with final and interim text, restarted across the browser's own stops until `stop()`, a fatal error or `maxSilences` silences in a row",
+ "commit": "7cdc6a3"
+ },
+ {
+ "description": "`getVoices`, `pickVoice`, `langMatches`, `splitText`, `isSynthesisSupported`, `isRecognitionSupported`, `stopSpeaking` and `SpeechError`",
+ "commit": "7cdc6a3"
+ },
+ {
+ "description": "`useSpeech`, `useListen` and `useVoices` in `@plexusone/speech/react`, and `@plexusone/speech/browser`, one file defining `window.PlexusSpeech`",
+ "commit": "7cdc6a3"
+ },
+ {
+ "description": "`onWord` on `speak`, with each word's segment and its offsets in the text passed, without surrounding punctuation; pieces with no letter or digit, such as a dash, are not reported",
+ "commit": "c160f17"
+ },
+ {
+ "description": "`trackWords` on `useSpeech`, which keeps the word being spoken in `word`",
+ "commit": "c160f17"
+ }
+ ],
+ "tests": [
+ {
+ "description": "Cover speaking, pausing, retrying, listening, text splitting and the hooks with fake browser speech",
+ "commit": "8d3028d"
+ },
+ {
+ "description": "Cover word offsets across chunks, words without a length, punctuation, dashes and `trackWords`",
+ "commit": "170ba5c"
+ }
+ ],
+ "documentation": [
+ {
+ "description": "`@plexusone/speech` package page, with highlighting words and how voices differ",
+ "commit": "cd7f540"
+ },
+ {
+ "description": "Adding read-aloud and voice input guide, and v0.6.0 release notes"
+ }
+ ]
+ },
{
"version": "v0.5.1",
"date": "2026-11-02",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d939519..70ffd26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,31 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
## [Unreleased]
+## [v0.6.0] - 2026-11-02
+
+### Highlights
+
+- `@plexusone/speech` 0.1.0: read text aloud and take dictation with the browser's own speech, with word highlighting, React hooks and a standalone build for pages without a build step
+
+### Added
+
+- `speak`: text or segments with pauses, split into short utterances, with pause and resume, a cancel before new speech and one retry when the browser accepts speech but never starts it (`start-timeout`); speech cancelled by another script ends as `stopped` ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `listen`: speech recognition with final and interim text, restarted across the browser's own stops until `stop()`, a fatal error or `maxSilences` silences in a row ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `getVoices`, `pickVoice`, `langMatches`, `splitText`, `isSynthesisSupported`, `isRecognitionSupported`, `stopSpeaking` and `SpeechError` ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `useSpeech`, `useListen` and `useVoices` in `@plexusone/speech/react`, and `@plexusone/speech/browser`, one file defining `window.PlexusSpeech` ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `onWord` on `speak`, with each word's segment and its offsets in the text passed, without surrounding punctuation; pieces with no letter or digit, such as a dash, are not reported ([`c160f17`](https://github.com/plexusone/systemforge-web/commit/c160f17))
+- `trackWords` on `useSpeech`, which keeps the word being spoken in `word` ([`c160f17`](https://github.com/plexusone/systemforge-web/commit/c160f17))
+
+### Documentation
+
+- `@plexusone/speech` package page, with highlighting words and how voices differ ([`cd7f540`](https://github.com/plexusone/systemforge-web/commit/cd7f540))
+- Adding read-aloud and voice input guide, and v0.6.0 release notes
+
+### Tests
+
+- Cover speaking, pausing, retrying, listening, text splitting and the hooks with fake browser speech ([`8d3028d`](https://github.com/plexusone/systemforge-web/commit/8d3028d))
+- Cover word offsets across chunks, words without a length, punctuation, dashes and `trackWords` ([`170ba5c`](https://github.com/plexusone/systemforge-web/commit/170ba5c))
+
## [v0.5.1] - 2026-11-02
### Highlights
@@ -277,7 +302,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
- Shared pnpm workflows from grokify/.github ([`bb07c1d`](https://github.com/plexusone/systemforge-web/commit/bb07c1d))
-[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.5.1...HEAD
+[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.6.0...HEAD
+[v0.6.0]: https://github.com/plexusone/systemforge-web/compare/v0.5.1...v0.6.0
[v0.5.1]: https://github.com/plexusone/systemforge-web/compare/v0.5.0...v0.5.1
[v0.5.0]: https://github.com/plexusone/systemforge-web/compare/v0.4.0...v0.5.0
[v0.4.0]: https://github.com/plexusone/systemforge-web/compare/v0.3.1...v0.4.0
diff --git a/README.md b/README.md
index 391b673..6d34054 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Alphabetical; see [Packages](docs/packages/index.md) for how they depend on each
| `@plexusone/invites` | Invite-gated sign-up pages | [](https://www.npmjs.com/package/@plexusone/invites) |
| `@plexusone/pages` | Pre-built pages | [](https://www.npmjs.com/package/@plexusone/pages) |
| `@plexusone/shell` | Application shell | [](https://www.npmjs.com/package/@plexusone/shell) |
+| `@plexusone/speech` | Browser speech in and out | [](https://www.npmjs.com/package/@plexusone/speech) |
| `@plexusone/telemetry` | Event instrumentation | [](https://www.npmjs.com/package/@plexusone/telemetry) |
| `@plexusone/tenant` | Multi-tenant context | [](https://www.npmjs.com/package/@plexusone/tenant) |
| `@plexusone/ui` | UI components | [](https://www.npmjs.com/package/@plexusone/ui) |
diff --git a/docs/changelog.md b/docs/changelog.md
index d939519..70ffd26 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -9,6 +9,31 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
## [Unreleased]
+## [v0.6.0] - 2026-11-02
+
+### Highlights
+
+- `@plexusone/speech` 0.1.0: read text aloud and take dictation with the browser's own speech, with word highlighting, React hooks and a standalone build for pages without a build step
+
+### Added
+
+- `speak`: text or segments with pauses, split into short utterances, with pause and resume, a cancel before new speech and one retry when the browser accepts speech but never starts it (`start-timeout`); speech cancelled by another script ends as `stopped` ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `listen`: speech recognition with final and interim text, restarted across the browser's own stops until `stop()`, a fatal error or `maxSilences` silences in a row ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `getVoices`, `pickVoice`, `langMatches`, `splitText`, `isSynthesisSupported`, `isRecognitionSupported`, `stopSpeaking` and `SpeechError` ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `useSpeech`, `useListen` and `useVoices` in `@plexusone/speech/react`, and `@plexusone/speech/browser`, one file defining `window.PlexusSpeech` ([`7cdc6a3`](https://github.com/plexusone/systemforge-web/commit/7cdc6a3))
+- `onWord` on `speak`, with each word's segment and its offsets in the text passed, without surrounding punctuation; pieces with no letter or digit, such as a dash, are not reported ([`c160f17`](https://github.com/plexusone/systemforge-web/commit/c160f17))
+- `trackWords` on `useSpeech`, which keeps the word being spoken in `word` ([`c160f17`](https://github.com/plexusone/systemforge-web/commit/c160f17))
+
+### Documentation
+
+- `@plexusone/speech` package page, with highlighting words and how voices differ ([`cd7f540`](https://github.com/plexusone/systemforge-web/commit/cd7f540))
+- Adding read-aloud and voice input guide, and v0.6.0 release notes
+
+### Tests
+
+- Cover speaking, pausing, retrying, listening, text splitting and the hooks with fake browser speech ([`8d3028d`](https://github.com/plexusone/systemforge-web/commit/8d3028d))
+- Cover word offsets across chunks, words without a length, punctuation, dashes and `trackWords` ([`170ba5c`](https://github.com/plexusone/systemforge-web/commit/170ba5c))
+
## [v0.5.1] - 2026-11-02
### Highlights
@@ -277,7 +302,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
- Shared pnpm workflows from grokify/.github ([`bb07c1d`](https://github.com/plexusone/systemforge-web/commit/bb07c1d))
-[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.5.1...HEAD
+[unreleased]: https://github.com/plexusone/systemforge-web/compare/v0.6.0...HEAD
+[v0.6.0]: https://github.com/plexusone/systemforge-web/compare/v0.5.1...v0.6.0
[v0.5.1]: https://github.com/plexusone/systemforge-web/compare/v0.5.0...v0.5.1
[v0.5.0]: https://github.com/plexusone/systemforge-web/compare/v0.4.0...v0.5.0
[v0.4.0]: https://github.com/plexusone/systemforge-web/compare/v0.3.1...v0.4.0
diff --git a/docs/guides/read-aloud.md b/docs/guides/read-aloud.md
new file mode 100644
index 0000000..47348f1
--- /dev/null
+++ b/docs/guides/read-aloud.md
@@ -0,0 +1,203 @@
+# Adding read-aloud and voice input
+
+`@plexusone/speech` lets an application read text aloud and take dictation
+with the browser's own speech: nothing to run and no key to manage. This guide
+adds a read-aloud control with word highlighting, a voice picker and voice
+input to an application. The [package page](../packages/speech.md) is the
+reference for every option.
+
+## 1. Choose an entry point
+
+| You have | Use |
+| ------------------------------------------ | ---------------------------------------------------------------- |
+| A React application | `@plexusone/speech/react`: `useSpeech`, `useListen`, `useVoices` |
+| Code without React | `@plexusone/speech`: `speak`, `listen`, `getVoices` |
+| A page with no build step, or a Go program | `@plexusone/speech/browser`: one file defining `PlexusSpeech` |
+
+```bash
+pnpm add @plexusone/speech
+```
+
+The main entry point has no dependencies. React is needed only for the
+hooks.
+
+## 2. Read text aloud
+
+Pass the text as segments when it has natural parts, such as the sentences
+of a lesson or the paragraphs of an article. Each segment can have a pause
+after it, and `segment` says which one is being read:
+
+```tsx
+import { useSpeech } from '@plexusone/speech/react';
+
+function ReadAloud({ paragraphs }: { paragraphs: string[] }) {
+ const { speak, stop, pause, resume, state, segment, error, supported } = useSpeech({
+ lang: 'en-US',
+ });
+ if (!supported) return null;
+
+ const segments = paragraphs.map((text) => ({ text, pauseMs: 400 }));
+ return (
+ <>
+ {state === 'speaking' ? (
+
+ ) : state === 'paused' ? (
+
+ ) : (
+
+ )}
+ {(state === 'speaking' || state === 'paused') && }
+ {error?.code === 'start-timeout' && (
+
Your browser's voice isn't responding. Here is the text.
+ )}
+ {paragraphs.map((p, i) => (
+
+ {p}
+
+ ))}
+ >
+ );
+}
+```
+
+Start speech from a click or a key press. Browsers may refuse speech that a
+page starts on its own, and report `not-allowed`.
+
+Long text is split into short utterances for you, because some voices stop
+partway through a long one. Pausing repeats at most the sentence that was
+interrupted when speech resumes.
+
+## 3. Highlight the word being read
+
+Set `trackWords` and mark `word` in the segment being read. `start` and `end`
+are offsets into that segment's text exactly as you passed it:
+
+```tsx
+import type { SpeakWord } from '@plexusone/speech';
+
+// In ReadAloud above: useSpeech({ lang: 'en-US', trackWords: true }), and
+// render each paragraph with renderParagraph(p, i, segment, word).
+function renderParagraph(text: string, index: number, segment: number, word?: SpeakWord) {
+ if (index !== segment) return
+ );
+}
+```
+
+Pick the colours together: a light background on the segment being read
+and a stronger one on the word reads well. Two things keep the segment's
+mark visible:
+
+- **No transition on the segment's background.** A short sentence is
+ spoken in under half a second, so a fade can still be running when the
+ next one starts.
+- **Mark the word with more than a shade,** such as an underline as well as
+ a background. In dark mode two backgrounds of one hue are hard to tell
+ apart.
+
+A one-word segment is covered by the word's mark, so only the word shows.
+
+Words are marked without the punctuation around them. Not every voice
+reports words: many of Chrome's online voices report none, and the word
+mark never appears while the segment's still does. Some voices report
+pieces of a word or join two short words, so treat the mark as a guide to
+where the voice is, not an exact transcript.
+
+`trackWords` re-renders the component on every word. To highlight without
+re-rendering, for example by setting a class on a DOM node, pass `onWord`
+instead and update the DOM yourself.
+
+## 4. When speech does not start
+
+`speak` rejects with a `SpeechError`. Show the text, or offer a recording,
+rather than a silent button:
+
+| `error.code` | What happened | What to show |
+| --------------- | ------------------------------------------------ | ---------------------------------------------- |
+| `unsupported` | The browser cannot speak (`supported` is false) | Hide the control |
+| `not-allowed` | Speech was not started from a click or key press | Start it from a button |
+| `start-timeout` | The browser accepted speech but never started it | The text to read, or a recording |
+| anything else | The voice failed | The text, and let the person try another voice |
+
+`ReadAloud` above shows the `start-timeout` message from the same hook that
+started the speech.
+
+## 5. Let people choose a voice
+
+`useVoices` lists the voices for a language once the browser has loaded
+them. Store the chosen voice's `voiceURI`; `voice` accepts it as a string:
+
+```tsx
+import { useVoices } from '@plexusone/speech/react';
+
+function VoicePicker({ value, onChange }: { value: string; onChange: (uri: string) => void }) {
+ const voices = useVoices('en');
+ return (
+
+ );
+}
+
+// useSpeech({ lang: 'en-US', voice: savedVoiceURI || undefined })
+```
+
+Voices differ by browser and device, so a saved voice may be missing on
+another computer. Without it, `speak` picks a voice for `lang`: the
+browser's default for that language, then a voice on the device.
+
+## 6. Add voice input to a text field
+
+`useListen` reports text as it is recognized, final and interim together:
+
+```tsx
+import { useListen } from '@plexusone/speech/react';
+
+function Dictation({ onText }: { onText: (text: string) => void }) {
+ const { start, stop, listening, text, error, supported } = useListen({ lang: 'en-US' });
+ if (!supported) return null;
+ return (
+ <>
+
+ {listening ? (
+
+ ) : (
+
+ )}
+ {error?.code === 'not-allowed' &&
Allow the microphone to dictate.
}
+ >
+ );
+}
+```
+
+The browser asks for the microphone the first time. Listening continues
+across pauses and stops after three silences in a row; `stop()` waits for
+the last words, `abort()` drops them. Speech recognition is available in
+Chrome, Edge and Safari, and in Chrome it sends audio to Google's service.
+Say so near the control if your users would want to know.
+
+## 7. Test it
+
+jsdom has no speech. Every function and hook takes the browser objects as
+options, so a test passes fakes: `synthesis` and `Utterance` for speaking,
+`recognition` for listening. A fake `speechSynthesis` that records what was
+spoken, and lets the test start and end each utterance, is enough to cover
+the controls above. The package's own
+[test fakes](https://github.com/plexusone/systemforge-web/blob/main/packages/speech/src/__tests__/fakes.ts)
+are one to copy.
diff --git a/docs/packages/index.md b/docs/packages/index.md
index e201bc9..006596b 100644
--- a/docs/packages/index.md
+++ b/docs/packages/index.md
@@ -16,11 +16,13 @@ SystemForge Web is a monorepo of focused packages, published to npm under
@plexusone/pages ─────▶ @plexusone/auth
Standalone: @plexusone/api-client, @plexusone/chat,
- @plexusone/design-tokens, @plexusone/telemetry
+ @plexusone/design-tokens, @plexusone/speech,
+ @plexusone/telemetry
```
An arrow is a peer dependency: the application installs both, and they
-share one copy. Every package also needs React 19.
+share one copy. Every package also needs React 19, except
+`@plexusone/design-tokens` and the main entry point of `@plexusone/speech`.
## Package summary
@@ -35,6 +37,7 @@ share one copy. Every package also needs React 19.
| [@plexusone/invites](invites.md) | Invite-gated sign-up pages | `InviteAdminPage`, `InvitePeoplePage`, `createInvitesApi` |
| [@plexusone/pages](pages.md) | Pre-built pages | `LoginPage`, `ErrorPage`, `OrganizationSettingsPage` |
| [@plexusone/shell](shell.md) | Application chrome | `AppShell`, `ProductSwitcher`, `OrganizationSwitcher`, `ProfileMenu` |
+| [@plexusone/speech](speech.md) | Browser speech in and out | `speak`, `listen`, `getVoices`, `useSpeech`, `useListen` |
| [@plexusone/telemetry](telemetry.md) | Instrumentation | `TelemetryProvider`, `ErrorBoundary` |
| [@plexusone/tenant](tenant.md) | Multi-tenancy | `TenantProvider`, `RequireRole`, `useOrganization` |
| [@plexusone/ui](ui.md) | UI components | `Button`, `Card`, `DropdownMenu`, `Tooltip` |
diff --git a/docs/packages/speech.md b/docs/packages/speech.md
new file mode 100644
index 0000000..8cce159
--- /dev/null
+++ b/docs/packages/speech.md
@@ -0,0 +1,226 @@
+# @plexusone/speech
+
+Text-to-speech and speech-to-text with the browser's own speech (the Web
+Speech API). Nothing to run and no key to manage: useful for previews,
+read-aloud and voice input. The browser decides where speech is processed:
+some voices and Chrome's speech recognition use an online service (see
+[Listening](#listening)).
+
+The main entry point has no dependencies and works without React. React
+hooks are in `@plexusone/speech/react`. A standalone build,
+`@plexusone/speech/browser`, is one file for pages without a build step.
+
+## Installation
+
+```bash
+pnpm add @plexusone/speech
+```
+
+React is needed only for `@plexusone/speech/react`.
+
+## Speaking
+
+`speak` takes text, or segments with a pause after each, and returns the
+speech in progress:
+
+```ts
+import { speak } from '@plexusone/speech';
+
+const speech = speak(
+ [{ text: 'Welcome to the course.', pauseMs: 600 }, { text: 'Let us start with the funnel.' }],
+ { lang: 'en-US', rate: 1.1, onSegment: (i) => highlight(i) }
+);
+
+await speech.done; // { status: 'ended' } or { status: 'stopped' }
+```
+
+| Option | Purpose |
+| ------------------------- | ----------------------------------------------------------------- |
+| `lang` | BCP 47 language. Picks a voice when `voice` is not set |
+| `voice` | A voice, or a voice's `voiceURI` or `name` |
+| `rate`, `pitch`, `volume` | As on `SpeechSynthesisUtterance` |
+| `onSegment` | Called as each segment starts, including one that is only a pause |
+| `onWord` | Called as each word is spoken (see Highlighting words) |
+| `signal` | An `AbortSignal` that stops the speech |
+| `maxChunkChars` | Longest text per utterance (default 180) |
+| `startTimeoutMs` | How long an utterance may take to start (default 3000; 0 is off) |
+
+The returned object has `stop()`, `pause()`, `resume()` and `state`
+(`speaking`, `paused`, `ended`, `stopped` or `error`). `done` rejects with a
+`SpeechError` whose `code` is the browser's error, `unsupported`, or
+`start-timeout` when the browser never started the speech. On
+`start-timeout`, offer another way to hear the text, such as a recording, or
+show it to read.
+
+The browser speaks one thing at a time, so `speak` stops speech already in
+progress. `stopSpeaking()` stops it from anywhere.
+
+### What it handles
+
+- **Long text.** Some voices stop partway through a long utterance (Chrome's
+ online voices after about 15 seconds). Text is split at sentence ends,
+ then clauses, then words, into utterances of at most `maxChunkChars`.
+- **Lost end events.** Chrome can drop an utterance's end event when the
+ utterance is garbage-collected; the one being spoken is kept referenced.
+- **Stalled speech.** In Chrome, speech that was paused and then abandoned,
+ by leaving the page or in another tab, stalls speech in every page: the
+ browser reports nothing speaking, accepts new speech and never starts it,
+ with no error. Only cancelling clears it, so `speak` always clears the
+ queue first. If an utterance still hasn't started after `startTimeoutMs`,
+ it is cancelled and tried once more, then `done` rejects with
+ `start-timeout`.
+- **Pausing without the browser's pause.** Because a paused, abandoned
+ utterance causes that stall, `pause()` cancels the current utterance and
+ `resume()` speaks it again from its start: at most one sentence, or
+ `maxChunkChars`, is repeated. A segment's `pauseMs` is a timer, which
+ `pause()` and `resume()` stop and continue.
+- **Cancelled from elsewhere.** Another script or library calling
+ `speechSynthesis.cancel()` ends this speech too; `done` then resolves
+ with `stopped` rather than waiting for an end that never comes.
+- **Leaving the page.** Speech is stopped when the page is hidden for good
+ (`pagehide`), so it can't stall the next page.
+- **User gestures.** Browsers may refuse speech that is not started from a
+ click or key press; `done` then rejects with code `not-allowed`.
+
+### Highlighting words
+
+Pass `onWord` to hear about each word as it is spoken. `start` and `end`
+are offsets into the text of segment `segment`, exactly as passed to
+`speak`, even when the text was split into several utterances or had its
+whitespace collapsed:
+
+```ts
+const text = 'Welcome to the course.';
+speak(text, {
+ onWord: ({ start, end }) => mark(text.slice(start, end)),
+});
+```
+
+A word is marked without the punctuation around it ("promote," marks
+"promote"), and a piece with no letter or digit, such as a dash, is not
+reported. Numbers and contractions such as "3.5%" and "didn't" are usually
+one word.
+
+Only some voices report words. Chrome's online voices often do not, so
+`onWord` may never be called; speech is the same either way. When the
+browser reports where a word starts but not its length, as Safari does,
+the word runs to the next space or punctuation. Voices differ in what they
+call a word: some report pieces of a word, join two short words, or skip a
+word they spell out, so the mark can lag for a word without drifting.
+
+## Voices
+
+Browsers load voices after the page. `getVoices` waits for them:
+
+```ts
+import { getVoices, pickVoice } from '@plexusone/speech';
+
+const voices = await getVoices({ lang: 'fr' }); // fr-FR, fr-CA, ...
+const best = pickVoice(voices, 'fr-FR');
+```
+
+`pickVoice` prefers the exact language and region, then the same language;
+within each, the browser's default voice, then a voice on the device (which
+needs no network). `langMatches('en', 'en-GB')` is true;
+`langMatches('en-US', 'en-GB')` is not.
+
+## Listening
+
+`listen` reports text as it is recognized:
+
+```ts
+import { listen } from '@plexusone/speech';
+
+const listening = listen({
+ lang: 'en-US',
+ onResult: ({ text, final, interim }) => (input.value = text),
+});
+
+// later
+listening.stop();
+const text = await listening.done;
+```
+
+In continuous mode (the default) the browser ends recognition by itself
+from time to time; `listen` restarts it until `stop()`, a fatal error (no
+permission, no microphone, no service), or `maxSilences` silences in a row
+(default 3). `abort()` stops at once and drops text still being recognized.
+Speech recognition is available in Chrome, Edge and Safari; check
+`isRecognitionSupported()`. Where the audio goes depends on the browser:
+Chrome sends it to Google's speech service, so say so near the control if
+your users would want to know, and do not promise that dictation stays on
+the device.
+
+## React
+
+```tsx
+import { useListen, useSpeech, useVoices } from '@plexusone/speech/react';
+
+function ReadAloud({ text }: { text: string }) {
+ const { speak, stop, state, supported } = useSpeech({ lang: 'en-US' });
+ if (!supported) return null;
+ return state === 'speaking' ? (
+
+ ) : (
+
+ );
+}
+```
+
+| Hook | Returns |
+| -------------------- | --------------------------------------------------------------------------------------- |
+| `useSpeech(options)` | `speak`, `stop`, `pause`, `resume`, `state`, `segment`, `word`, `error`, `supported` |
+| `useListen(options)` | `start`, `stop`, `abort`, `listening`, `text`, `final`, `interim`, `error`, `supported` |
+| `useVoices(lang)` | The voices, empty until loaded |
+
+With `trackWords: true`, `useSpeech` keeps the word being spoken in
+`word` (`{ segment, start, end }`), for highlighting; it is undefined
+between segments and after speech. It is off by default because it
+re-renders the component on every word; `onWord` alone does not:
+
+```tsx
+function Highlighted({ text }: { text: string }) {
+ const { speak, word } = useSpeech({ trackWords: true });
+ return (
+ <>
+
+