@tinytars/frame 0.1.30 → 0.1.31

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/frame",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
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 chunk (roughly one sentence) at a time, and each chunk's `end` starts the next.
5
- // Chromium silently cuts off a single long utterance after ~15s, and chunking is also what makes
6
- // progress and resume possible. Pause is cancel() plus a remembered index, not
7
- // speechSynthesis.pause(), which is a no-op or broken on Android Chrome and some Linux voices. Resume
8
- // restarts the current chunk.
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 pack(sentences.flatMap((s) => (s.length > MAX_CHUNK ? pack(s.split(/\s+/)) : [s])));
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
- const utterance = new SpeechSynthesisUtterance(state.chunks[state.index]);
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
- halt();
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
- speakCurrent();
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);