@supersuit/artifacts 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/lib/artifacts/store.js +7 -2
- package/lib/reader/index.d.ts +0 -2
- package/lib/reader/index.js +1 -3
- package/lib/routes/artifacts.js +3 -4
- package/package.json +1 -1
- package/lib/reader/browser-reader.d.ts +0 -8
- package/lib/reader/browser-reader.js +0 -171
- package/lib/reader/speech-plan.d.ts +0 -20
- package/lib/reader/speech-plan.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.18.0 (2026-09-29)
|
|
7
|
+
|
|
8
|
+
**No page is ever read by the browser's own voice, and a page can turn its audio off.** 0.10.0
|
|
9
|
+
made a page with no recorded narration read itself with `speechSynthesis`, which on a phone is
|
|
10
|
+
Safari's built-in voice. The operator heard it on a letter going to a partner: *"NEVER PUT
|
|
11
|
+
DEFAULT SAFARI VOICE DICTATION. ALWAYS KOKORO OR IF I WANT TO SPEND MONEY 11LABS."* A page is
|
|
12
|
+
now read by the audio recorded at publish, or it has no player at all.
|
|
13
|
+
|
|
14
|
+
And *"add ability to disable audio"*: `voice: none` on a republish removes the page's stored
|
|
15
|
+
narration, timings and hash, so the player goes too. Before this, an update merged fields, so a
|
|
16
|
+
recording survived every republish that did not mention it and there was no way to take it off.
|
|
17
|
+
|
|
18
|
+
- Removed: `BrowserReader`, `planSentences`, `wordAt`, `sentenceOf`, `fromWord`,
|
|
19
|
+
`MAX_SENTENCE_WORDS` from `./reader`. Nothing in a host should have mounted them directly.
|
|
20
|
+
- DETECTOR: an un-narrated page that shows a player bar, or any `speechSynthesis` in `lib/`.
|
|
21
|
+
- REMEDY: bump. To silence a page, republish it with `voice: none` (a publisher that sends it;
|
|
22
|
+
Freedom's publish-artifact does from the release after this).
|
|
23
|
+
- Tests: route GUARDs that an un-narrated page, and one whose timings fail, mount no reader; store
|
|
24
|
+
tests that audio survives a silent republish and `voice: none` clears it; `check-packed` fails if
|
|
25
|
+
the tarball carries `speechSynthesis` or the fixture page mounts a player. The default-pages
|
|
26
|
+
golden was regenerated on purpose: the only change is the removed browser-reader element.
|
|
27
|
+
|
|
6
28
|
## 0.17.0 (2026-09-29)
|
|
7
29
|
|
|
8
30
|
**A host can accept a signed, hour-long publisher pass wherever it accepts the publish key.** A
|
package/README.md
CHANGED
|
@@ -154,7 +154,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
154
154
|
| `audience` | Free text stored with the page, never shown |
|
|
155
155
|
| `id` | Republish in place instead of minting a new page |
|
|
156
156
|
| `cover` | An image URL (absolute or site-relative) used as the unfurl image instead of the share card |
|
|
157
|
-
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent
|
|
157
|
+
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated (Kokoro by default, ElevenLabs on request). Absent, or the timings will not load, the page has no player: it is never read by the browser's own voice. `voice: none` turns audio off and removes any recording the page already had |
|
|
158
158
|
| `password` | Shuts the body behind a door (below) |
|
|
159
159
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
160
160
|
| `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
|
package/lib/artifacts/store.js
CHANGED
|
@@ -126,7 +126,7 @@ async function saveArtifact(col, input) {
|
|
|
126
126
|
...(input.meta.to ? { to: input.meta.to } : {}),
|
|
127
127
|
...(input.meta.audience ? { audience: input.meta.audience } : {}),
|
|
128
128
|
...(input.meta.cover ? { cover: input.meta.cover } : {}),
|
|
129
|
-
...(input.meta.voice ? { voice: input.meta.voice } : {}),
|
|
129
|
+
...(input.meta.voice && input.meta.voice !== 'none' ? { voice: input.meta.voice } : {}),
|
|
130
130
|
...(input.meta.narration ? { narration: input.meta.narration } : {}),
|
|
131
131
|
...(input.meta.timings ? { timings: input.meta.timings } : {}),
|
|
132
132
|
...(input.meta.narrationHash ? { narrationHash: input.meta.narrationHash } : {}),
|
|
@@ -190,8 +190,13 @@ async function saveArtifact(col, input) {
|
|
|
190
190
|
...(input.meta.comments ? {} : { comments: FieldValue.delete() }),
|
|
191
191
|
...(input.meta.commentsVisible ? {} : { commentsVisible: FieldValue.delete() }),
|
|
192
192
|
};
|
|
193
|
+
// `voice: none` is the page declaring it has no audio. An update merges, so the recording it
|
|
194
|
+
// had would otherwise survive every republish; clear it on purpose, and the player goes with it.
|
|
195
|
+
const audio = input.meta.voice === 'none'
|
|
196
|
+
? { voice: FieldValue.delete(), narration: FieldValue.delete(), timings: FieldValue.delete(), narrationHash: FieldValue.delete() }
|
|
197
|
+
: {};
|
|
193
198
|
await ref.update({
|
|
194
|
-
...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, markdown: input.markdown, version: next,
|
|
199
|
+
...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, ...audio, markdown: input.markdown, version: next,
|
|
195
200
|
// `updatedAt` is when this version went live; finishing its own publish does not move it.
|
|
196
201
|
...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
|
|
197
202
|
note: nextNote ?? FieldValue.delete(),
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
|
|
2
|
-
export { BrowserReader } from './browser-reader.js';
|
|
3
2
|
export { PlayerBar } from './player-bar.js';
|
|
4
|
-
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS, type Sentence } from './speech-plan.js';
|
|
5
3
|
export { CommentLayer } from './comment-layer.js';
|
|
6
4
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
7
5
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// The `./reader` entry:
|
|
1
|
+
// The `./reader` entry: the reader and the bar it uses. Each component module carries its
|
|
2
2
|
// own 'use client'; this barrel is plain re-exports so it can be imported from either side.
|
|
3
3
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
|
|
4
|
-
export { BrowserReader } from './browser-reader.js';
|
|
5
4
|
export { PlayerBar } from './player-bar.js';
|
|
6
|
-
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS } from './speech-plan.js';
|
|
7
5
|
export { CommentLayer } from './comment-layer.js';
|
|
8
6
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
9
7
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -17,7 +17,6 @@ import { themedPack } from '../brand/theme.js';
|
|
|
17
17
|
import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
|
|
18
18
|
import { renderShareCard } from '../brand/share-card.js';
|
|
19
19
|
import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
20
|
-
import { BrowserReader } from '../reader/browser-reader.js';
|
|
21
20
|
import { CommentLayer } from '../reader/comment-layer.js';
|
|
22
21
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
23
22
|
import { UpdatedTime } from '../reader/updated-time.js';
|
|
@@ -262,9 +261,9 @@ export function createArtifactRoutes(config) {
|
|
|
262
261
|
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
263
262
|
const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
|
|
264
263
|
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined, Section: pack.Section, figure: pack.figure }) }));
|
|
265
|
-
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
|
|
266
|
-
|
|
267
|
-
|
|
264
|
+
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
265
|
+
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
266
|
+
recorded at publish, or there is none. */] }));
|
|
268
267
|
}
|
|
269
268
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
270
269
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export declare const BROWSER_READER_LABEL = "Read aloud by your browser";
|
|
2
|
-
export declare function BrowserReader({ rootId, label, accent, ground, }: {
|
|
3
|
-
rootId: string;
|
|
4
|
-
/** The line under the play button. */
|
|
5
|
-
label?: string;
|
|
6
|
-
accent: string;
|
|
7
|
-
ground: string;
|
|
8
|
-
}): import("react").JSX.Element;
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
// Read-along for a page with no recorded narration: the browser's own voice (speechSynthesis)
|
|
4
|
-
// reads the same word spans the recorded reader lights, and its word-boundary events light them.
|
|
5
|
-
// It sounds worse than a recorded narrator and sends nothing anywhere.
|
|
6
|
-
//
|
|
7
|
-
// One utterance per sentence (see speech-plan.ts). Safari's boundary charIndex drifts on long
|
|
8
|
-
// passages; every new utterance restarts it at zero against offsets we know, so a drift cannot
|
|
9
|
-
// outlast the sentence it began in. Every utterance carries the generation it was spoken in, and
|
|
10
|
-
// an event from an older generation (a sentence cancelled by a click, a seek or a speed change,
|
|
11
|
-
// whose end or error the engine still reports) is ignored.
|
|
12
|
-
//
|
|
13
|
-
// The mount is rendered hidden and empty on the server and on the first client render, so the
|
|
14
|
-
// markup always hydrates; it shows only once the browser is known to have speechSynthesis. It
|
|
15
|
-
// never carries data-artifact-player while hidden, because the popovers measure that element to
|
|
16
|
-
// keep clear of the bar, and a hidden element measures as the whole screen.
|
|
17
|
-
import { useEffect, useRef, useState } from 'react';
|
|
18
|
-
import { BLOCK, seekableWord, wrapWords } from './artifact-reader.js';
|
|
19
|
-
import { nextRate, PlayerBar } from './player-bar.js';
|
|
20
|
-
import { fromWord, planSentences, wordAt } from './speech-plan.js';
|
|
21
|
-
export const BROWSER_READER_LABEL = 'Read aloud by your browser';
|
|
22
|
-
function spokenWords(spans) {
|
|
23
|
-
const words = [];
|
|
24
|
-
const starts = new Set();
|
|
25
|
-
let prevBlock = null;
|
|
26
|
-
spans.forEach((span, i) => {
|
|
27
|
-
// Punctuation glued on across an element edge ("**bold**.") is a tail, not a word; it is
|
|
28
|
-
// spoken with the word it follows, which is also what lets it end a sentence.
|
|
29
|
-
const tail = span.nextSibling instanceof HTMLElement && span.nextSibling.classList.contains('artifact-word-tail') ? span.nextSibling.textContent ?? '' : '';
|
|
30
|
-
words.push((span.textContent ?? '') + tail);
|
|
31
|
-
const block = span.closest(BLOCK);
|
|
32
|
-
if (i > 0 && block !== prevBlock)
|
|
33
|
-
starts.add(i);
|
|
34
|
-
prevBlock = block;
|
|
35
|
-
});
|
|
36
|
-
return { words, starts };
|
|
37
|
-
}
|
|
38
|
-
export function BrowserReader({ rootId, label = BROWSER_READER_LABEL, accent, ground, }) {
|
|
39
|
-
const [ready, setReady] = useState(false);
|
|
40
|
-
const [playing, setPlaying] = useState(false);
|
|
41
|
-
const [current, setCurrent] = useState(-1);
|
|
42
|
-
const [count, setCount] = useState(0);
|
|
43
|
-
const [rate, setRate] = useState(1);
|
|
44
|
-
const spansRef = useRef([]);
|
|
45
|
-
const planRef = useRef([]);
|
|
46
|
-
const genRef = useRef(0);
|
|
47
|
-
const rateRef = useRef(1);
|
|
48
|
-
const currentRef = useRef(-1);
|
|
49
|
-
const playingRef = useRef(false);
|
|
50
|
-
// True from the first play until the page is finished: pause and resume act on a live queue.
|
|
51
|
-
const startedRef = useRef(false);
|
|
52
|
-
const litRef = useRef(null);
|
|
53
|
-
const setPlay = (p) => {
|
|
54
|
-
playingRef.current = p;
|
|
55
|
-
setPlaying(p);
|
|
56
|
-
};
|
|
57
|
-
const light = (i) => {
|
|
58
|
-
const el = i >= 0 ? spansRef.current[i] ?? null : null;
|
|
59
|
-
if (el !== litRef.current) {
|
|
60
|
-
litRef.current?.classList.remove('artifact-word-lit');
|
|
61
|
-
el?.classList.add('artifact-word-lit');
|
|
62
|
-
litRef.current = el;
|
|
63
|
-
if (el && playingRef.current) {
|
|
64
|
-
const r = el.getBoundingClientRect();
|
|
65
|
-
if (r.top < 80 || r.bottom > window.innerHeight - 140)
|
|
66
|
-
el.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
currentRef.current = i;
|
|
70
|
-
setCurrent(i);
|
|
71
|
-
};
|
|
72
|
-
const finish = () => {
|
|
73
|
-
startedRef.current = false;
|
|
74
|
-
setPlay(false);
|
|
75
|
-
light(-1);
|
|
76
|
-
};
|
|
77
|
-
const speak = (s, gen) => {
|
|
78
|
-
const synth = window.speechSynthesis;
|
|
79
|
-
const u = new SpeechSynthesisUtterance(s.text);
|
|
80
|
-
u.rate = rateRef.current;
|
|
81
|
-
u.onboundary = (e) => {
|
|
82
|
-
if (gen !== genRef.current || e.name !== 'word')
|
|
83
|
-
return;
|
|
84
|
-
light(wordAt(s, e.charIndex));
|
|
85
|
-
};
|
|
86
|
-
u.onend = () => {
|
|
87
|
-
if (gen !== genRef.current)
|
|
88
|
-
return;
|
|
89
|
-
const next = s.first + s.offsets.length;
|
|
90
|
-
if (next < spansRef.current.length)
|
|
91
|
-
speak(fromWord(planRef.current, next), gen);
|
|
92
|
-
else
|
|
93
|
-
finish();
|
|
94
|
-
};
|
|
95
|
-
u.onerror = () => {
|
|
96
|
-
if (gen === genRef.current)
|
|
97
|
-
finish();
|
|
98
|
-
};
|
|
99
|
-
// Light the sentence's first word now: some engines send no boundary events at all, and the
|
|
100
|
-
// page should still show where it is.
|
|
101
|
-
light(s.first);
|
|
102
|
-
synth.speak(u);
|
|
103
|
-
};
|
|
104
|
-
/** Cancel whatever is speaking and read from word `i` on. */
|
|
105
|
-
const speakFrom = (i) => {
|
|
106
|
-
if (!planRef.current.length)
|
|
107
|
-
return;
|
|
108
|
-
const synth = window.speechSynthesis;
|
|
109
|
-
const gen = ++genRef.current;
|
|
110
|
-
synth.cancel();
|
|
111
|
-
// An engine paused before the cancel stays paused for the next utterance in some browsers.
|
|
112
|
-
synth.resume();
|
|
113
|
-
startedRef.current = true;
|
|
114
|
-
setPlay(true);
|
|
115
|
-
speak(fromWord(planRef.current, Math.max(0, Math.min(i, spansRef.current.length - 1))), gen);
|
|
116
|
-
};
|
|
117
|
-
const speakFromRef = useRef(speakFrom);
|
|
118
|
-
speakFromRef.current = speakFrom;
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
if (typeof window === 'undefined' || !('speechSynthesis' in window))
|
|
121
|
-
return;
|
|
122
|
-
const root = document.getElementById(rootId);
|
|
123
|
-
if (!root)
|
|
124
|
-
return;
|
|
125
|
-
const spans = wrapWords(root);
|
|
126
|
-
if (!spans.length)
|
|
127
|
-
return;
|
|
128
|
-
const { words, starts } = spokenWords(spans);
|
|
129
|
-
spansRef.current = spans;
|
|
130
|
-
planRef.current = planSentences(words, starts);
|
|
131
|
-
setCount(spans.length);
|
|
132
|
-
setReady(true);
|
|
133
|
-
const onClick = (e) => {
|
|
134
|
-
const el = seekableWord(e.target);
|
|
135
|
-
if (!el)
|
|
136
|
-
return;
|
|
137
|
-
const idx = spansRef.current.indexOf(el);
|
|
138
|
-
if (idx !== -1)
|
|
139
|
-
speakFromRef.current(idx);
|
|
140
|
-
};
|
|
141
|
-
root.addEventListener('click', onClick);
|
|
142
|
-
return () => {
|
|
143
|
-
root.removeEventListener('click', onClick);
|
|
144
|
-
genRef.current++;
|
|
145
|
-
window.speechSynthesis.cancel();
|
|
146
|
-
};
|
|
147
|
-
}, [rootId]);
|
|
148
|
-
const toggle = () => {
|
|
149
|
-
const synth = window.speechSynthesis;
|
|
150
|
-
if (!startedRef.current)
|
|
151
|
-
speakFrom(0);
|
|
152
|
-
else if (playingRef.current) {
|
|
153
|
-
synth.pause();
|
|
154
|
-
setPlay(false);
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
synth.resume();
|
|
158
|
-
setPlay(true);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
if (!ready)
|
|
162
|
-
return _jsx("div", { "data-artifact-reader": "browser", hidden: true });
|
|
163
|
-
return (_jsx("div", { "data-artifact-reader": "browser", children: _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: label, accent: accent, ground: ground, value: Math.max(current, 0), max: Math.max(count - 1, 0), step: 1, onSeek: (v) => speakFrom(v), progress: `${current + 1} / ${count}`, rate: rate, onRate: () => {
|
|
164
|
-
const r = nextRate(rateRef.current);
|
|
165
|
-
rateRef.current = r;
|
|
166
|
-
setRate(r);
|
|
167
|
-
// The engine fixes the rate when an utterance starts, so restart at the current word.
|
|
168
|
-
if (startedRef.current && playingRef.current)
|
|
169
|
-
speakFrom(Math.max(currentRef.current, 0));
|
|
170
|
-
} }) }));
|
|
171
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export type Sentence = {
|
|
2
|
-
/** The words joined by single spaces: exactly what is handed to the speech engine. */
|
|
3
|
-
text: string;
|
|
4
|
-
/** Index of the sentence's first word in the page's word list. */
|
|
5
|
-
first: number;
|
|
6
|
-
/** offsets[i] is the char offset, inside `text`, of word `first + i`. */
|
|
7
|
-
offsets: number[];
|
|
8
|
-
};
|
|
9
|
-
export declare const MAX_SENTENCE_WORDS = 60;
|
|
10
|
-
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
11
|
-
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
12
|
-
export declare function planSentences(words: string[], starts?: ReadonlySet<number>): Sentence[];
|
|
13
|
-
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
14
|
-
* is at or before it. */
|
|
15
|
-
export declare function wordAt(s: Sentence, charIndex: number): number;
|
|
16
|
-
/** The index of the sentence holding `wordIndex`. */
|
|
17
|
-
export declare function sentenceOf(plan: Sentence[], wordIndex: number): number;
|
|
18
|
-
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
19
|
-
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
20
|
-
export declare function fromWord(plan: Sentence[], wordIndex: number): Sentence;
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// How the browser read-aloud splits a page into utterances, and maps a speech engine's
|
|
2
|
-
// word-boundary events back to the page's word spans. Pure, so it is tested without a browser.
|
|
3
|
-
//
|
|
4
|
-
// One utterance per sentence is the drift bound: Safari's boundary charIndex wanders on long
|
|
5
|
-
// passages, and every new utterance restarts it at zero against a sentence whose word offsets
|
|
6
|
-
// are known. The cap keeps a page with no punctuation (a list, a table) from becoming one long
|
|
7
|
-
// utterance, which is also where Chrome is known to stop speaking part-way.
|
|
8
|
-
export const MAX_SENTENCE_WORDS = 60;
|
|
9
|
-
// A word ends a sentence when it ends in . ! ? or :, optionally followed by closing quotes or
|
|
10
|
-
// brackets. "10:30" and "example.com" do not, because the punctuation is not at the end.
|
|
11
|
-
const ENDS_SENTENCE = /[.!?:]["'”’)\]]*$/;
|
|
12
|
-
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
13
|
-
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
14
|
-
export function planSentences(words, starts) {
|
|
15
|
-
const plan = [];
|
|
16
|
-
let cur = null;
|
|
17
|
-
for (let i = 0; i < words.length; i++) {
|
|
18
|
-
if (cur && (cur.offsets.length >= MAX_SENTENCE_WORDS || starts?.has(i))) {
|
|
19
|
-
plan.push(cur);
|
|
20
|
-
cur = null;
|
|
21
|
-
}
|
|
22
|
-
if (!cur)
|
|
23
|
-
cur = { text: '', first: i, offsets: [] };
|
|
24
|
-
if (cur.offsets.length)
|
|
25
|
-
cur.text += ' ';
|
|
26
|
-
cur.offsets.push(cur.text.length);
|
|
27
|
-
cur.text += words[i];
|
|
28
|
-
if (ENDS_SENTENCE.test(words[i])) {
|
|
29
|
-
plan.push(cur);
|
|
30
|
-
cur = null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
if (cur)
|
|
34
|
-
plan.push(cur);
|
|
35
|
-
return plan;
|
|
36
|
-
}
|
|
37
|
-
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
38
|
-
* is at or before it. */
|
|
39
|
-
export function wordAt(s, charIndex) {
|
|
40
|
-
let i = 0;
|
|
41
|
-
for (let k = 0; k < s.offsets.length; k++) {
|
|
42
|
-
if (s.offsets[k] <= charIndex)
|
|
43
|
-
i = k;
|
|
44
|
-
else
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
return s.first + i;
|
|
48
|
-
}
|
|
49
|
-
/** The index of the sentence holding `wordIndex`. */
|
|
50
|
-
export function sentenceOf(plan, wordIndex) {
|
|
51
|
-
let idx = 0;
|
|
52
|
-
for (let k = 0; k < plan.length; k++) {
|
|
53
|
-
if (plan[k].first <= wordIndex)
|
|
54
|
-
idx = k;
|
|
55
|
-
else
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
return idx;
|
|
59
|
-
}
|
|
60
|
-
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
61
|
-
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
62
|
-
export function fromWord(plan, wordIndex) {
|
|
63
|
-
const s = plan[sentenceOf(plan, wordIndex)];
|
|
64
|
-
const k = Math.max(0, Math.min(wordIndex - s.first, s.offsets.length - 1));
|
|
65
|
-
const base = s.offsets[k];
|
|
66
|
-
return { text: s.text.slice(base), first: s.first + k, offsets: s.offsets.slice(k).map((o) => o - base) };
|
|
67
|
-
}
|