@tinytars/frame 0.1.28 → 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.
- package/AccountMenu.svelte +1 -1
- package/Onboarding.svelte +1 -1
- package/OrgFooter.svelte +25 -0
- package/PersonaBubble.svelte +63 -20
- package/SpeechControls.svelte +47 -0
- package/attach-controller.ts +1 -1
- package/brand.ts +10 -3
- package/footer.css +37 -0
- package/package.json +6 -1
- package/retell-registry.svelte.ts +18 -0
- package/speech-registry.svelte.ts +174 -35
- package/theme.css +1 -1
package/AccountMenu.svelte
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
// About is app-level, not provider/patient-specific, so it's offered in both modes.
|
|
39
39
|
onAbout?: () => void;
|
|
40
40
|
// Copy, all domain-flavorable — defaults are generic, callers override with their own vocabulary
|
|
41
|
-
// (e.g.
|
|
41
|
+
// (e.g. "patient"/"roster") rather than this package assuming one.
|
|
42
42
|
providerBadgeLabel?: string;
|
|
43
43
|
subjectFallback?: string;
|
|
44
44
|
viewingSubjectLabel?: string;
|
package/Onboarding.svelte
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// (what to ask, and each field's own validity rule) is entirely caller-supplied — a number field
|
|
23
23
|
// is optional and omitted from the create payload when left blank or when the user skips; a
|
|
24
24
|
// select field always has a value and is always included. Everything the caller collects is
|
|
25
|
-
// stored wherever it wants (
|
|
25
|
+
// stored wherever it wants (e.g. an encrypted vault, never the server).
|
|
26
26
|
let {
|
|
27
27
|
productName,
|
|
28
28
|
subheading = "Welcome. A couple of details help tailor your results — or skip and add them later.",
|
package/OrgFooter.svelte
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { deriveLegalLinks, type OrgIdentity } from "@tinytars/frame/brand";
|
|
3
|
+
|
|
4
|
+
let { org }: { org: OrgIdentity } = $props();
|
|
5
|
+
const links = $derived(deriveLegalLinks(org.legalBase, org.legalPaths));
|
|
6
|
+
const year = new Date().getFullYear();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<footer class="frame-footer">
|
|
10
|
+
<div class="frame-footer__row">
|
|
11
|
+
<div>
|
|
12
|
+
{org.status}
|
|
13
|
+
<br />© {year} {org.name}
|
|
14
|
+
</div>
|
|
15
|
+
<nav class="frame-footer__links" aria-label="Legal">
|
|
16
|
+
{#each links as link (link.href)}
|
|
17
|
+
{#if org.legalBase}
|
|
18
|
+
<a href={link.href} target="_blank" rel="noopener">{link.label}</a>
|
|
19
|
+
{:else}
|
|
20
|
+
<a href={link.href}>{link.label}</a>
|
|
21
|
+
{/if}
|
|
22
|
+
{/each}
|
|
23
|
+
</nav>
|
|
24
|
+
</div>
|
|
25
|
+
</footer>
|
package/PersonaBubble.svelte
CHANGED
|
@@ -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
|
|
32
|
-
// time
|
|
33
|
-
//
|
|
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
|
|
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.
|
|
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">{
|
|
79
|
+
<span class="persona-tag">{shownLabel}</span>
|
|
50
80
|
</span>
|
|
51
|
-
{#if meta ||
|
|
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:
|
|
59
|
-
title={
|
|
60
|
-
aria-label={
|
|
61
|
-
aria-pressed={speaking}
|
|
88
|
+
class:active={speech !== "idle"}
|
|
89
|
+
title={speakTitle}
|
|
90
|
+
aria-label={speakTitle}
|
|
62
91
|
onclick={toggleSpeak}
|
|
63
|
-
>{
|
|
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
|
+
>⏹︎</button>
|
|
101
|
+
{/if}
|
|
64
102
|
{/if}
|
|
65
|
-
{#if
|
|
66
|
-
<LeafActionMenu items={
|
|
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}
|
|
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.
|
|
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()}>⏹︎</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/attach-controller.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export type AttachMode = "camera" | "library" | "files";
|
|
6
6
|
|
|
7
7
|
// Everything. A leaf's Attach is storage, not import: whatever you attach is kept, shown, and — if
|
|
8
|
-
// it is a PDF or a text file — read so
|
|
8
|
+
// it is a PDF or a text file — read so the host app can quote it. Anything else simply attaches and sits
|
|
9
9
|
// there, which is strictly better than a file picker that greys the file out with no explanation.
|
|
10
10
|
//
|
|
11
11
|
// The narrow "image/*,application/pdf,.xlsx,.xls" this replaces meant a .docx, a .csv or a .heic
|
package/brand.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// MEDICAL_DISCLAIMER, ...) stay in packages/brand, which is not a dependency any other app takes.
|
|
1
|
+
// The shape of an organization's brand, not its values: each host app declares its own
|
|
2
|
+
// OrgIdentity (see ARCHITECTURE.md, "Entity neutrality").
|
|
4
3
|
export type LegalLink = { path: string; label: string };
|
|
5
4
|
|
|
5
|
+
export type OrgIdentity = {
|
|
6
|
+
name: string;
|
|
7
|
+
status: string;
|
|
8
|
+
/** Origin the legal pages live on; "" when they are served by the host app itself. */
|
|
9
|
+
legalBase: string;
|
|
10
|
+
legalPaths: LegalLink[];
|
|
11
|
+
};
|
|
12
|
+
|
|
6
13
|
export function deriveLegalLinks(base: string, paths: LegalLink[]): { href: string; label: string }[] {
|
|
7
14
|
return paths.map((l) => ({ href: `${base}${l.path}`, label: l.label }));
|
|
8
15
|
}
|
package/footer.css
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Markup contract for OrgFooter.svelte, and for any non-Svelte host (e.g. an Astro site) rendering
|
|
2
|
+
the same .frame-footer markup. Every value falls back to a default, so it works without theme.css;
|
|
3
|
+
a host themes it through the --frame-footer-* properties or overrides the classes directly. */
|
|
4
|
+
.frame-footer {
|
|
5
|
+
border-top: 1px solid var(--border, #e5e7eb);
|
|
6
|
+
color: var(--muted, #6b7280);
|
|
7
|
+
font-size: var(--frame-footer-font-size, 0.85rem);
|
|
8
|
+
font-family: var(--frame-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
|
|
9
|
+
background: var(--frame-footer-bg, #fff);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.frame-footer__row {
|
|
13
|
+
max-width: var(--frame-footer-max, 64rem);
|
|
14
|
+
margin: 0 auto;
|
|
15
|
+
padding: 1.25rem 1.25rem;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-wrap: wrap;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: space-between;
|
|
20
|
+
gap: 0.75rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.frame-footer__links {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
flex-wrap: wrap;
|
|
26
|
+
gap: 1rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.frame-footer__links a {
|
|
30
|
+
color: var(--muted, #6b7280);
|
|
31
|
+
text-decoration: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.frame-footer__links a:hover {
|
|
35
|
+
color: var(--frame-footer-accent, currentColor);
|
|
36
|
+
text-decoration: underline;
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/frame",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"*.ts",
|
|
26
26
|
"!*.test.ts",
|
|
27
|
+
"!vitest.config.ts",
|
|
27
28
|
"*.svelte",
|
|
28
29
|
"*.css"
|
|
29
30
|
],
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"./ExportTab.svelte": "./ExportTab.svelte",
|
|
46
47
|
"./Field.svelte": "./Field.svelte",
|
|
47
48
|
"./filter": "./filter.ts",
|
|
49
|
+
"./footer.css": "./footer.css",
|
|
48
50
|
"./FormGrid.svelte": "./FormGrid.svelte",
|
|
49
51
|
"./group-filter": "./group-filter.ts",
|
|
50
52
|
"./LeafActionMenu.svelte": "./LeafActionMenu.svelte",
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"./Modal.svelte": "./Modal.svelte",
|
|
56
58
|
"./ModalActions.svelte": "./ModalActions.svelte",
|
|
57
59
|
"./Onboarding.svelte": "./Onboarding.svelte",
|
|
60
|
+
"./OrgFooter.svelte": "./OrgFooter.svelte",
|
|
58
61
|
"./pdf-render": "./pdf-render.ts",
|
|
59
62
|
"./PdfThumbnail.svelte": "./PdfThumbnail.svelte",
|
|
60
63
|
"./persisted-json": "./persisted-json.ts",
|
|
@@ -67,6 +70,8 @@
|
|
|
67
70
|
"./SidebarGroupList.svelte": "./SidebarGroupList.svelte",
|
|
68
71
|
"./SidebarLeafList.svelte": "./SidebarLeafList.svelte",
|
|
69
72
|
"./speech-registry.svelte": "./speech-registry.svelte.ts",
|
|
73
|
+
"./retell-registry.svelte": "./retell-registry.svelte.ts",
|
|
74
|
+
"./SpeechControls.svelte": "./SpeechControls.svelte",
|
|
70
75
|
"./support-access.svelte": "./support-access.svelte.ts",
|
|
71
76
|
"./theme.css": "./theme.css",
|
|
72
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
|
|
2
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
-
|
|
22
|
-
return state.
|
|
151
|
+
statusOf(id: string): SpeechStatus {
|
|
152
|
+
return state.id === id ? state.status : "idle";
|
|
23
153
|
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
state.
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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());
|
package/theme.css
CHANGED