@tinytars/frame 0.1.29 → 0.1.30

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.
@@ -3,6 +3,7 @@
3
3
  import LeafActionMenu from "@tinytars/frame/LeafActionMenu.svelte";
4
4
  import type { LeafMenuItem } from "@tinytars/frame/menu-items";
5
5
  import { speechRegistry, isSpeechSupported } from "./speech-registry.svelte";
6
+ import { currentRetell } from "./retell-registry.svelte";
6
7
 
7
8
  // The one persona treatment: a tinted bubble with a head row (uppercase persona tag on the
8
9
  // left; an optional meta/date and an optional action icon, e.g. download, on the right). Colors
@@ -24,58 +25,100 @@
24
25
  onTogglePin?: () => void;
25
26
  pinDisplay?: "auto" | "hidden";
26
27
  actions?: BubbleAction[];
28
+ // Opaque to this package: handed to the app's configured SpeechEngine.
29
+ voice?: string;
30
+ // False where the body is already a chosen telling (e.g. a chat reply in the picked persona).
31
+ retellable?: boolean;
27
32
  children?: Snippet;
28
33
  }
29
- let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, children }: Props = $props();
34
+ let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, voice, retellable = true, children }: Props = $props();
30
35
 
31
- // Every assistant-generated bubble gets Speak for free: reads bodyEl's own rendered text at click
32
- // time rather than a prop threaded through every one of this component's call sites, so no
33
- // existing caller needs to change. speechRegistry is the shared "only one thing speaks at a time"
34
- // singleton (mirrors menu-registry.svelte.ts); clicking the currently-speaking bubble's own button
35
- // again toggles it off (speechRegistry.speak handles that toggle internally).
36
+ // Every assistant-generated bubble gets read-aloud for free. It reads bodyEl's rendered text at
37
+ // click time instead of taking a prop, so no caller has to change. speechRegistry is the shared
38
+ // player, and SpeechControls keeps playback controllable after this bubble unmounts.
36
39
  const uid = $props.id();
37
40
  const bubbleId = `${persona}-${uid}`;
38
41
  const speakable = $derived(persona === "assistant" && isSpeechSupported());
39
- const speaking = $derived(speechRegistry.isSpeaking(bubbleId));
42
+ const speech = $derived(speechRegistry.statusOf(bubbleId));
43
+ const speakTitle = $derived(speech === "playing" ? "Pause reading" : speech === "paused" ? "Resume reading" : "Read aloud");
40
44
  let bodyEl: HTMLDivElement | undefined;
45
+
46
+ // A retelling replaces the body in place, so read-aloud and the label follow what is on screen.
47
+ // It lasts only while the app still offers that retelling.
48
+ const retell = $derived(persona === "assistant" && retellable && children ? currentRetell() : null);
49
+ let retold = $state<{ by: string; text: string } | null>(null);
50
+ let retelling = $state(false);
51
+ const shownRetold = $derived(retold && retell && retold.by === retell.label ? retold : null);
52
+ const shownLabel = $derived(shownRetold ? shownRetold.by : label);
53
+ async function toggleRetell() {
54
+ if (shownRetold) { retold = null; return; }
55
+ const r = retell!;
56
+ retelling = true;
57
+ const text = await r.retell(bodyEl?.textContent ?? "").catch(() => null);
58
+ retelling = false;
59
+ retold = text ? { by: r.label, text } : null;
60
+ }
61
+ const menuItems = $derived<BubbleAction[]>([
62
+ ...(actions ?? []),
63
+ ...(retell ? [{
64
+ key: "retell",
65
+ label: retelling ? `${retell.label}…` : shownRetold ? "Show original" : retell.label,
66
+ disabled: retelling,
67
+ onClick: toggleRetell,
68
+ }] : []),
69
+ ]);
70
+
41
71
  function toggleSpeak() {
42
- speechRegistry.speak(bubbleId, bodyEl?.textContent ?? "");
72
+ speechRegistry.toggle(bubbleId, bodyEl?.textContent ?? "", meta ? `${shownLabel} · ${meta}` : shownLabel, shownRetold ? retell?.voice : voice);
43
73
  }
44
74
  </script>
45
75
 
46
- <div class="persona-bubble p-{persona}" {id}>
76
+ <div class="persona-bubble p-{persona}" {id} data-speech-id={speakable ? bubbleId : undefined}>
47
77
  <div class="persona-head">
48
78
  <span class="persona-head-left">
49
- <span class="persona-tag">{label}</span>
79
+ <span class="persona-tag">{shownLabel}</span>
50
80
  </span>
51
- {#if meta || actions?.length || onTogglePin || speakable}
81
+ {#if meta || menuItems.length || onTogglePin || speakable}
52
82
  <span class="persona-head-right">
53
83
  {#if meta}<span class="persona-meta">{meta}</span>{/if}
54
84
  {#if speakable}
55
85
  <button
56
86
  type="button"
57
87
  class="persona-speak"
58
- class:speaking
59
- title={speaking ? "Stop reading" : "Read aloud"}
60
- aria-label={speaking ? "Stop reading" : "Read aloud"}
61
- aria-pressed={speaking}
88
+ class:active={speech !== "idle"}
89
+ title={speakTitle}
90
+ aria-label={speakTitle}
62
91
  onclick={toggleSpeak}
63
- >{speaking ? "" : "🔊"}</button>
92
+ >{speech === "playing" ? "⏸\uFE0E" : "▶\uFE0E"}</button>
93
+ {#if speech !== "idle"}
94
+ <button
95
+ type="button"
96
+ class="persona-speak"
97
+ title="Stop reading"
98
+ aria-label="Stop reading"
99
+ onclick={() => speechRegistry.stop()}
100
+ >⏹&#xFE0E;</button>
101
+ {/if}
64
102
  {/if}
65
- {#if actions?.length || onTogglePin}
66
- <LeafActionMenu items={actions ?? []} {pinned} {onTogglePin} {pinDisplay} />
103
+ {#if menuItems.length || onTogglePin}
104
+ <LeafActionMenu items={menuItems} {pinned} {onTogglePin} {pinDisplay} />
67
105
  {/if}
68
106
  </span>
69
107
  {/if}
70
108
  </div>
71
- {#if children}<div class="persona-body" bind:this={bodyEl}>{@render children()}</div>{/if}
109
+ {#if children}
110
+ <div class="persona-body" bind:this={bodyEl}>
111
+ {#if shownRetold}<p class="persona-retold">{shownRetold.text}</p>{:else}{@render children()}{/if}
112
+ </div>
113
+ {/if}
72
114
  </div>
73
115
 
74
116
  <style>
117
+ .persona-retold { margin: 0; white-space: pre-wrap; }
75
118
  .persona-speak {
76
119
  flex-shrink: 0; border: none; background: none; cursor: pointer;
77
120
  font-size: 0.95rem; line-height: 1; padding: 0.3rem; border-radius: 6px; color: var(--muted);
78
121
  }
79
122
  .persona-speak:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--fg); }
80
- .persona-speak.speaking { color: var(--accent); }
123
+ .persona-speak.active { color: var(--accent); }
81
124
  </style>
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import { speechRegistry } from "./speech-registry.svelte";
3
+
4
+ // The global now-playing pill. Reading keeps going after its bubble unmounts (tab or leaf switch),
5
+ // so this is the control that is always reachable. It renders nothing while idle.
6
+ const now = $derived(speechRegistry.current());
7
+ const playing = $derived(now.status === "playing");
8
+
9
+ function reveal() {
10
+ document.querySelector(`[data-speech-id="${now.id}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
11
+ }
12
+ </script>
13
+
14
+ {#if now.status !== "idle"}
15
+ <div class="speech-controls" role="region" aria-label="Read aloud">
16
+ <button type="button" class="sc-label" title="Show what is being read" onclick={reveal}>{now.label}</button>
17
+ <span class="sc-progress" aria-label="Sentence {now.index + 1} of {now.total}">{now.index + 1} / {now.total}</span>
18
+ <button
19
+ type="button"
20
+ class="sc-btn"
21
+ title={playing ? "Pause reading" : "Resume reading"}
22
+ aria-label={playing ? "Pause reading" : "Resume reading"}
23
+ onclick={() => (playing ? speechRegistry.pause() : speechRegistry.resume())}
24
+ >{playing ? "⏸︎" : "▶︎"}</button>
25
+ <button type="button" class="sc-btn" title="Stop reading" aria-label="Stop reading" onclick={() => speechRegistry.stop()}>⏹&#xFE0E;</button>
26
+ </div>
27
+ {/if}
28
+
29
+ <style>
30
+ .speech-controls {
31
+ position: fixed; bottom: 1rem; right: 1rem; z-index: 40;
32
+ display: flex; align-items: center; gap: 0.35rem; max-width: calc(100vw - 2rem);
33
+ padding: 0.3rem 0.4rem 0.3rem 0.8rem; border-radius: 999px;
34
+ background: var(--panel); color: var(--fg); border: 1px solid var(--border);
35
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); font-size: 0.85rem;
36
+ }
37
+ .sc-label {
38
+ border: none; background: none; color: inherit; cursor: pointer; padding: 0;
39
+ font: inherit; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
40
+ }
41
+ .sc-progress { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
42
+ .sc-btn {
43
+ border: none; background: none; cursor: pointer; color: var(--accent);
44
+ font-size: 0.95rem; line-height: 1; padding: 0.35rem; border-radius: 999px;
45
+ }
46
+ .sc-btn:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
47
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/frame",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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",
@@ -70,6 +70,8 @@
70
70
  "./SidebarGroupList.svelte": "./SidebarGroupList.svelte",
71
71
  "./SidebarLeafList.svelte": "./SidebarLeafList.svelte",
72
72
  "./speech-registry.svelte": "./speech-registry.svelte.ts",
73
+ "./retell-registry.svelte": "./retell-registry.svelte.ts",
74
+ "./SpeechControls.svelte": "./SpeechControls.svelte",
73
75
  "./support-access.svelte": "./support-access.svelte.ts",
74
76
  "./theme.css": "./theme.css",
75
77
  "./time-ago": "./time-ago.ts",
@@ -0,0 +1,18 @@
1
+ // An app may offer a second telling of any assistant bubble (e.g. the same content in another voice).
2
+ // PersonaBubble adds a menu item for it while one is configured; this package knows nothing of what
3
+ // the retelling says, only its label and the voice it is read aloud in.
4
+ export interface Retell {
5
+ label: string;
6
+ voice?: string;
7
+ retell(text: string): Promise<string | null>;
8
+ }
9
+
10
+ const state = $state<{ current: Retell | null }>({ current: null });
11
+
12
+ export function configureRetell(r: Retell | null): void {
13
+ state.current = r;
14
+ }
15
+
16
+ export function currentRetell(): Retell | null {
17
+ return state.current;
18
+ }
@@ -1,50 +1,189 @@
1
- // The one shared "who's currently speaking" registry, mirroring menu-registry.svelte.ts's
2
- // singleton shape: starting a new utterance stops whatever else was speaking, and clicking the
3
- // currently-speaking bubble's own button again stops it (toggle) — the same "only one thing
4
- // active" convention a popover-heavy UI wants for this too.
1
+ // The one shared read-aloud player. It mirrors menu-registry.svelte.ts's singleton shape: playing one
2
+ // thing stops whatever else was playing.
5
3
  //
6
- // speechSynthesis is a real, global, OS-level engine it can finish an utterance (or fail) entirely
7
- // on its own, not just via a manual stop() call. `onend`/`onerror` on the utterance are what keep
8
- // `speakingId` in sync with reality in that case; a plain isSpeaking() flag flipped only at the two
9
- // call sites (speak/stop) would go stale the instant a normal utterance finishes.
10
- const state = $state<{ speakingId: string | null }>({ speakingId: null });
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.
9
+ //
10
+ // The engine finishes or fails utterances on its own. cancel()'s `end` also arrives as a separate
11
+ // task, after the call that caused it. So every utterance captures the `generation` it was spoken
12
+ // under. Each pause, stop, or new play bumps that counter, which makes a late event from a
13
+ // superseded chunk a no-op.
14
+ //
15
+ // An app may configure a SpeechEngine: each chunk is then synthesized to audio (a neural voice) and
16
+ // 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
+ export type SpeechStatus = "idle" | "playing" | "paused";
19
+
20
+ export interface SpeechEngine {
21
+ synthesize(text: string, voice?: string): Promise<Blob>;
22
+ normalize?(text: string): string;
23
+ }
24
+
25
+ const MAX_CHUNK = 200;
26
+
27
+ const state = $state({
28
+ id: null as string | null,
29
+ label: "",
30
+ status: "idle" as SpeechStatus,
31
+ chunks: [] as string[],
32
+ index: 0,
33
+ });
34
+ let generation = 0;
35
+ let engine: SpeechEngine | null = null;
36
+ let voice: string | undefined;
37
+ let neural = false;
38
+ let audio: HTMLAudioElement | null = null;
39
+ let clips = new Map<number, Promise<string>>();
40
+
41
+ export function configureSpeech(e: SpeechEngine | null): void {
42
+ engine = e;
43
+ }
11
44
 
12
45
  function synth(): SpeechSynthesis | undefined {
13
46
  return typeof window !== "undefined" ? window.speechSynthesis : undefined;
14
47
  }
15
48
 
16
49
  export function isSpeechSupported(): boolean {
17
- return !!synth();
50
+ return !!engine || !!synth();
51
+ }
52
+
53
+ function pack(parts: string[]): string[] {
54
+ const out: string[] = [];
55
+ let buf = "";
56
+ for (const p of parts) {
57
+ const next = buf ? `${buf} ${p}` : p;
58
+ if (buf && next.length > MAX_CHUNK) {
59
+ out.push(buf);
60
+ buf = p;
61
+ } else {
62
+ buf = next;
63
+ }
64
+ }
65
+ if (buf) out.push(buf);
66
+ return out;
67
+ }
68
+
69
+ export function speechChunks(text: string): string[] {
70
+ const sentences = Array.from(new Intl.Segmenter(undefined, { granularity: "sentence" }).segment(text), (s) => s.segment.trim())
71
+ .filter(Boolean);
72
+ return pack(sentences.flatMap((s) => (s.length > MAX_CHUNK ? pack(s.split(/\s+/)) : [s])));
73
+ }
74
+
75
+ function halt() {
76
+ generation++;
77
+ synth()?.cancel();
78
+ audio?.pause();
79
+ audio = null;
80
+ }
81
+
82
+ function releaseClips() {
83
+ for (const p of clips.values()) p.then((url) => URL.revokeObjectURL(url), () => {});
84
+ clips = new Map();
85
+ }
86
+
87
+ function reset() {
88
+ releaseClips();
89
+ neural = false;
90
+ state.id = null;
91
+ state.label = "";
92
+ state.status = "idle";
93
+ state.chunks = [];
94
+ state.index = 0;
95
+ }
96
+
97
+ function advance(gen: number) {
98
+ if (gen !== generation) return;
99
+ if (state.index + 1 < state.chunks.length) {
100
+ state.index++;
101
+ speakCurrent();
102
+ } else {
103
+ reset();
104
+ }
105
+ }
106
+
107
+ function speakCurrent() {
108
+ const gen = ++generation;
109
+ if (neural) speakNeural(gen);
110
+ else speakBrowser(gen);
111
+ }
112
+
113
+ function speakBrowser(gen: number) {
114
+ const utterance = new SpeechSynthesisUtterance(state.chunks[state.index]);
115
+ utterance.onend = () => advance(gen);
116
+ utterance.onerror = () => { if (gen === generation) reset(); };
117
+ synth()!.speak(utterance);
118
+ }
119
+
120
+ function clip(i: number): Promise<string> {
121
+ let p = clips.get(i);
122
+ if (!p) {
123
+ p = engine!.synthesize(state.chunks[i], voice).then((blob) => URL.createObjectURL(blob));
124
+ clips.set(i, p);
125
+ }
126
+ return p;
127
+ }
128
+
129
+ function speakNeural(gen: number) {
130
+ const i = state.index;
131
+ clip(i).then((url) => {
132
+ if (gen !== generation) return;
133
+ const a = new Audio(url);
134
+ audio = a;
135
+ a.onended = () => advance(gen);
136
+ a.onerror = () => fallBack(gen);
137
+ a.play().catch(() => fallBack(gen));
138
+ if (i + 1 < state.chunks.length) clip(i + 1).catch(() => {});
139
+ }, () => fallBack(gen));
140
+ }
141
+
142
+ function fallBack(gen: number) {
143
+ if (gen !== generation) return;
144
+ neural = false;
145
+ audio = null;
146
+ if (synth()) speakBrowser(gen);
147
+ else reset();
18
148
  }
19
149
 
20
150
  export const speechRegistry = {
21
- isSpeaking(id: string): boolean {
22
- return state.speakingId === id;
151
+ statusOf(id: string): SpeechStatus {
152
+ return state.id === id ? state.status : "idle";
23
153
  },
24
- speak(id: string, text: string): void {
25
- const s = synth();
26
- if (!s || !text.trim()) return;
27
- // cancel()'s `end` event is dispatched as its own task, never inside this call — which is what
28
- // makes the toggle below reachable, since it compares against a speakingId this cancel has not
29
- // had the chance to clear. The old speakingId is cleared by the assignment further down (or by
30
- // the toggle); each handler guards on its own id so a late `end` cannot clear a newer utterance.
31
- // (An earlier version of this comment claimed the opposite, that cancel() fires onend
32
- // SYNCHRONOUSLY and that this is what clears the old id. Were that true the toggle would be
33
- // dead code and a second click would re-speak.)
34
- s.cancel();
35
- if (state.speakingId === id) {
36
- // Toggle: clicking the already-speaking bubble's own button again just stops it.
37
- state.speakingId = null;
38
- return;
39
- }
40
- const utterance = new SpeechSynthesisUtterance(text);
41
- utterance.onend = () => { if (state.speakingId === id) state.speakingId = null; };
42
- utterance.onerror = () => { if (state.speakingId === id) state.speakingId = null; };
43
- state.speakingId = id;
44
- s.speak(utterance);
154
+ current() {
155
+ return { id: state.id, label: state.label, status: state.status, index: state.index, total: state.chunks.length };
156
+ },
157
+ play(id: string, text: string, label: string, withVoice?: string): void {
158
+ const chunks = speechChunks(engine?.normalize ? engine.normalize(text) : text);
159
+ if (!isSpeechSupported() || !chunks.length) return;
160
+ halt();
161
+ releaseClips();
162
+ voice = withVoice;
163
+ neural = !!engine;
164
+ Object.assign(state, { id, label, status: "playing", chunks, index: 0 });
165
+ speakCurrent();
166
+ },
167
+ pause(): void {
168
+ if (state.status !== "playing") return;
169
+ halt();
170
+ state.status = "paused";
171
+ },
172
+ resume(): void {
173
+ if (state.status !== "paused" || !isSpeechSupported()) return;
174
+ state.status = "playing";
175
+ speakCurrent();
176
+ },
177
+ toggle(id: string, text: string, label: string, withVoice?: string): void {
178
+ if (state.id !== id) this.play(id, text, label, withVoice);
179
+ else if (state.status === "playing") this.pause();
180
+ else this.resume();
45
181
  },
46
182
  stop(): void {
47
- synth()?.cancel();
48
- state.speakingId = null;
183
+ halt();
184
+ reset();
49
185
  },
50
186
  };
187
+
188
+ // Nothing keeps speaking into a page that is being navigated away from or put in the bfcache.
189
+ if (typeof window !== "undefined") window.addEventListener("pagehide", () => speechRegistry.stop());