Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
55 changes: 55 additions & 0 deletions CHANGELOG.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Alphabetical; see [Packages](docs/packages/index.md) for how they depend on each
| `@plexusone/invites` | Invite-gated sign-up pages | [![npm](https://img.shields.io/npm/v/@plexusone/invites)](https://www.npmjs.com/package/@plexusone/invites) |
| `@plexusone/pages` | Pre-built pages | [![npm](https://img.shields.io/npm/v/@plexusone/pages)](https://www.npmjs.com/package/@plexusone/pages) |
| `@plexusone/shell` | Application shell | [![npm](https://img.shields.io/npm/v/@plexusone/shell)](https://www.npmjs.com/package/@plexusone/shell) |
| `@plexusone/speech` | Browser speech in and out | [![npm](https://img.shields.io/npm/v/@plexusone/speech)](https://www.npmjs.com/package/@plexusone/speech) |
| `@plexusone/telemetry` | Event instrumentation | [![npm](https://img.shields.io/npm/v/@plexusone/telemetry)](https://www.npmjs.com/package/@plexusone/telemetry) |
| `@plexusone/tenant` | Multi-tenant context | [![npm](https://img.shields.io/npm/v/@plexusone/tenant)](https://www.npmjs.com/package/@plexusone/tenant) |
| `@plexusone/ui` | UI components | [![npm](https://img.shields.io/npm/v/@plexusone/ui)](https://www.npmjs.com/package/@plexusone/ui) |
Expand Down
28 changes: 27 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
203 changes: 203 additions & 0 deletions docs/guides/read-aloud.md
Original file line number Diff line number Diff line change
@@ -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' ? (
<button onClick={pause}>Pause</button>
) : state === 'paused' ? (
<button onClick={resume}>Resume</button>
) : (
<button onClick={() => speak(segments).catch(() => undefined)}>Read aloud</button>
)}
{(state === 'speaking' || state === 'paused') && <button onClick={stop}>Stop</button>}
{error?.code === 'start-timeout' && (
<p>Your browser's voice isn't responding. Here is the text.</p>
)}
{paragraphs.map((p, i) => (
<p key={i} className={i === segment ? 'reading' : undefined}>
{p}
</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 <p key={index}>{text}</p>;
if (!word)
return (
<p key={index} className="reading">
{text}
</p>
);
return (
<p key={index} className="reading">
{text.slice(0, word.start)}
<mark>{text.slice(word.start, word.end)}</mark>
{text.slice(word.end)}
</p>
);
}
```

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 (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Default voice</option>
{voices.map((v) => (
<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang})
</option>
))}
</select>
);
}

// 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 (
<>
<textarea value={text} readOnly />
{listening ? (
<button onClick={stop}>Done</button>
) : (
<button onClick={() => start().then(onText, () => undefined)}>Dictate</button>
)}
{error?.code === 'not-allowed' && <p>Allow the microphone to dictate.</p>}
</>
);
}
```

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.
Loading
Loading