@tinytars/frame 0.1.30 → 0.1.32
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/package.json +1 -1
- package/speech-registry.svelte.ts +36 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/frame",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"description": "Domain-neutral Svelte app-shell: session/auth controllers, account chrome, and menu/card/modal primitives built on @tinytars/vault.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// The one shared read-aloud player. It mirrors menu-registry.svelte.ts's singleton shape: playing one
|
|
2
2
|
// thing stops whatever else was playing.
|
|
3
3
|
//
|
|
4
|
-
// Text is spoken one
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Text is spoken one sentence at a time, and each chunk's `end` starts the next. Chromium silently
|
|
5
|
+
// cuts off a single long utterance after ~15s, and chunking is also what makes progress possible.
|
|
6
|
+
// Browser-voice pause is cancel() plus a remembered position, not speechSynthesis.pause(), which is a
|
|
7
|
+
// no-op or broken on Android Chrome and some Linux voices. The position comes from the voice's word
|
|
8
|
+
// `boundary` events, and resume rewinds one word before it for context. A voice that sends no
|
|
9
|
+
// boundaries resumes at the start of its current sentence instead.
|
|
9
10
|
//
|
|
10
11
|
// The engine finishes or fails utterances on its own. cancel()'s `end` also arrives as a separate
|
|
11
12
|
// task, after the call that caused it. So every utterance captures the `generation` it was spoken
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
//
|
|
15
16
|
// An app may configure a SpeechEngine: each chunk is then synthesized to audio (a neural voice) and
|
|
16
17
|
// played through an HTMLAudioElement, the next chunk prefetched while the current one plays. If the
|
|
17
|
-
// engine or playback fails, the rest of that playback falls back to the browser voice.
|
|
18
|
+
// engine or playback fails, the rest of that playback falls back to the browser voice. A playing clip
|
|
19
|
+
// pauses for real and resumes REWIND_SECONDS back.
|
|
18
20
|
export type SpeechStatus = "idle" | "playing" | "paused";
|
|
19
21
|
|
|
20
22
|
export interface SpeechEngine {
|
|
@@ -23,6 +25,7 @@ export interface SpeechEngine {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const MAX_CHUNK = 200;
|
|
28
|
+
const REWIND_SECONDS = 1;
|
|
26
29
|
|
|
27
30
|
const state = $state({
|
|
28
31
|
id: null as string | null,
|
|
@@ -32,6 +35,8 @@ const state = $state({
|
|
|
32
35
|
index: 0,
|
|
33
36
|
});
|
|
34
37
|
let generation = 0;
|
|
38
|
+
// Character offset within the current chunk of the last word the browser voice reached.
|
|
39
|
+
let reached = 0;
|
|
35
40
|
let engine: SpeechEngine | null = null;
|
|
36
41
|
let voice: string | undefined;
|
|
37
42
|
let neural = false;
|
|
@@ -69,7 +74,13 @@ function pack(parts: string[]): string[] {
|
|
|
69
74
|
export function speechChunks(text: string): string[] {
|
|
70
75
|
const sentences = Array.from(new Intl.Segmenter(undefined, { granularity: "sentence" }).segment(text), (s) => s.segment.trim())
|
|
71
76
|
.filter(Boolean);
|
|
72
|
-
return
|
|
77
|
+
return sentences.flatMap((s) => (s.length > MAX_CHUNK ? pack(s.split(/\s+/)) : [s]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function oneWordBefore(text: string, at: number): number {
|
|
81
|
+
const starts = Array.from(text.matchAll(/\S+/g), (m) => m.index);
|
|
82
|
+
const current = starts.findLastIndex((i) => i <= at);
|
|
83
|
+
return starts[Math.max(current - 1, 0)] ?? 0;
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
function halt() {
|
|
@@ -104,14 +115,16 @@ function advance(gen: number) {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
function speakCurrent() {
|
|
118
|
+
function speakCurrent(from = 0) {
|
|
108
119
|
const gen = ++generation;
|
|
109
120
|
if (neural) speakNeural(gen);
|
|
110
|
-
else speakBrowser(gen);
|
|
121
|
+
else speakBrowser(gen, from);
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
function speakBrowser(gen: number) {
|
|
114
|
-
|
|
124
|
+
function speakBrowser(gen: number, from = 0) {
|
|
125
|
+
reached = from;
|
|
126
|
+
const utterance = new SpeechSynthesisUtterance(state.chunks[state.index].slice(from));
|
|
127
|
+
utterance.onboundary = (e) => { if (gen === generation) reached = from + e.charIndex; };
|
|
115
128
|
utterance.onend = () => advance(gen);
|
|
116
129
|
utterance.onerror = () => { if (gen === generation) reset(); };
|
|
117
130
|
synth()!.speak(utterance);
|
|
@@ -166,13 +179,23 @@ export const speechRegistry = {
|
|
|
166
179
|
},
|
|
167
180
|
pause(): void {
|
|
168
181
|
if (state.status !== "playing") return;
|
|
169
|
-
|
|
182
|
+
if (neural && audio) {
|
|
183
|
+
audio.pause();
|
|
184
|
+
audio.currentTime = Math.max(0, audio.currentTime - REWIND_SECONDS);
|
|
185
|
+
} else {
|
|
186
|
+
halt();
|
|
187
|
+
}
|
|
170
188
|
state.status = "paused";
|
|
171
189
|
},
|
|
172
190
|
resume(): void {
|
|
173
191
|
if (state.status !== "paused" || !isSpeechSupported()) return;
|
|
174
192
|
state.status = "playing";
|
|
175
|
-
|
|
193
|
+
if (neural && audio) {
|
|
194
|
+
const gen = generation;
|
|
195
|
+
audio.play().catch(() => fallBack(gen));
|
|
196
|
+
} else {
|
|
197
|
+
speakCurrent(oneWordBefore(state.chunks[state.index], reached));
|
|
198
|
+
}
|
|
176
199
|
},
|
|
177
200
|
toggle(id: string, text: string, label: string, withVoice?: string): void {
|
|
178
201
|
if (state.id !== id) this.play(id, text, label, withVoice);
|