@juspay/svelte-ui-components 4.24.0 → 4.26.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/dist/MarkdownText/markdown.js +82 -9
- package/dist/MarkdownText/properties.d.ts +46 -2
- package/dist/SpeechSynthesis/controller.svelte.d.ts +89 -0
- package/dist/SpeechSynthesis/controller.svelte.js +385 -0
- package/dist/SpeechSynthesis/types.d.ts +68 -0
- package/dist/SpeechSynthesis/types.js +1 -0
- package/dist/VoiceOrb/VoiceOrb.svelte +400 -0
- package/dist/VoiceOrb/VoiceOrb.svelte.d.ts +4 -0
- package/dist/VoiceOrb/orbMath.d.ts +131 -0
- package/dist/VoiceOrb/orbMath.js +267 -0
- package/dist/VoiceOrb/properties.d.ts +105 -0
- package/dist/VoiceOrb/properties.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist-wc/index.js +548 -173
- package/package.json +1 -1
|
@@ -115,9 +115,19 @@ function tagAllowed(allowed, tag) {
|
|
|
115
115
|
* consumer can hand this anything at runtime, so anything other than the one
|
|
116
116
|
* recognised opt-in degrades to today's escaping rather than throwing — an
|
|
117
117
|
* unrecognised value must never be the reason content silently disappears.
|
|
118
|
+
*
|
|
119
|
+
* `'sanitize'` without a sanitizer resolves to `'escape'` for the same reason:
|
|
120
|
+
* the unsafe path stays unreachable by omission rather than by discipline.
|
|
118
121
|
*/
|
|
119
|
-
function
|
|
120
|
-
|
|
122
|
+
function resolveRawHtmlStrategy(sanitize) {
|
|
123
|
+
const rawHtml = sanitize?.rawHtml;
|
|
124
|
+
if (rawHtml === 'strip') {
|
|
125
|
+
return 'strip';
|
|
126
|
+
}
|
|
127
|
+
if (rawHtml === 'sanitize' && typeof sanitize?.htmlSanitizer === 'function') {
|
|
128
|
+
return 'passthrough';
|
|
129
|
+
}
|
|
130
|
+
return 'escape';
|
|
121
131
|
}
|
|
122
132
|
// An inert, server-compatible HTML parser handles quoted delimiters, entities
|
|
123
133
|
// and raw-text elements. Escaping its text nodes prevents decoded entities from
|
|
@@ -139,7 +149,23 @@ function rawHtmlText(node) {
|
|
|
139
149
|
function createSanitizingRenderer(protocols, allowedTags, disableTaskLists, rawHtml) {
|
|
140
150
|
return {
|
|
141
151
|
html(token) {
|
|
142
|
-
|
|
152
|
+
if (rawHtml === 'strip') {
|
|
153
|
+
return rawHtmlText(parseFragment(token.text));
|
|
154
|
+
}
|
|
155
|
+
/*
|
|
156
|
+
* Emitted verbatim, and sanitized later in one pass over the whole
|
|
157
|
+
* document. Sanitizing here instead looks safer and is not: marked splits
|
|
158
|
+
* a block-level container into separate opening and closing tokens with
|
|
159
|
+
* the markdown between them as its own block, so a sanitizer -- which
|
|
160
|
+
* parses its input and serialises the tree back -- sees `<div class="x">`
|
|
161
|
+
* alone, balances it into `<div class="x"></div>`, and drops the lone
|
|
162
|
+
* `</div>`. The content the author wrapped then renders OUTSIDE its
|
|
163
|
+
* container. See markdown.test.ts, 'one pass over the assembled output'.
|
|
164
|
+
*/
|
|
165
|
+
if (rawHtml === 'passthrough') {
|
|
166
|
+
return token.text;
|
|
167
|
+
}
|
|
168
|
+
return escapeHtml(token.text);
|
|
143
169
|
},
|
|
144
170
|
link(token) {
|
|
145
171
|
if (hasSafeProtocol(token.href, protocols.links) && tagAllowed(allowedTags, 'a')) {
|
|
@@ -273,12 +299,19 @@ function wrapTables(html, label, wrapperClass) {
|
|
|
273
299
|
: `<div class="${classAttr}" tabindex="0">`;
|
|
274
300
|
return html.replace(TABLE_OPEN, `${open}<table>`).replace(TABLE_CLOSE, '</table></div>');
|
|
275
301
|
}
|
|
276
|
-
function instanceFor(options) {
|
|
302
|
+
function instanceFor(options, rawHtml) {
|
|
277
303
|
const breaks = options.breaks === true;
|
|
278
304
|
const protocols = resolveProtocolSets(options.sanitize);
|
|
279
305
|
const allowedTags = resolveTagAllowList(options.sanitize?.allowedTags);
|
|
280
306
|
const disableTaskLists = options.sanitize?.disableTaskLists === true;
|
|
281
|
-
|
|
307
|
+
/*
|
|
308
|
+
* The strategy is what the renderer closes over, and it is a string, so the
|
|
309
|
+
* cache keeps working even though a caller now supplies a function: two
|
|
310
|
+
* consumers passing different sanitizers share one instance because that
|
|
311
|
+
* instance no longer knows about either of them. Keying on function identity
|
|
312
|
+
* would instead grow the map without bound, since the idiomatic call site
|
|
313
|
+
* passes a fresh inline arrow on every render.
|
|
314
|
+
*/
|
|
282
315
|
const key = [
|
|
283
316
|
breaks ? 'breaks' : 'default',
|
|
284
317
|
[...protocols.links].sort().join(','),
|
|
@@ -298,6 +331,26 @@ function instanceFor(options) {
|
|
|
298
331
|
}
|
|
299
332
|
return instance;
|
|
300
333
|
}
|
|
334
|
+
/** marked's parse can be configured to return a promise; this pipeline never is. */
|
|
335
|
+
function parseWith(markdown, options, rawHtml) {
|
|
336
|
+
const instance = instanceFor(options, rawHtml);
|
|
337
|
+
const output = options.inline === true ? instance.parseInline(markdown) : instance.parse(markdown);
|
|
338
|
+
return typeof output === 'string' ? output : null;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* A consumer's sanitizer is arbitrary code. `null` here means it did not return
|
|
342
|
+
* usable markup, and the caller falls back to the escaping path that was always
|
|
343
|
+
* there -- never to the unsanitized assembly.
|
|
344
|
+
*/
|
|
345
|
+
function runSanitizer(html, htmlSanitizer) {
|
|
346
|
+
try {
|
|
347
|
+
const sanitized = htmlSanitizer(html);
|
|
348
|
+
return typeof sanitized === 'string' ? sanitized : null;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
301
354
|
/**
|
|
302
355
|
* Markdown → HTML with the sanitizing pipeline above. Pure string transform —
|
|
303
356
|
* no DOM involved — so it renders identically on server and client.
|
|
@@ -362,14 +415,34 @@ export function unwrapMarkdownFence(source) {
|
|
|
362
415
|
return body;
|
|
363
416
|
}
|
|
364
417
|
export function renderMarkdown(markdown, options = {}) {
|
|
418
|
+
/* Unwrapping happens before parsing, so the sanitizer sees the document the
|
|
419
|
+
author meant rather than a fence around it. */
|
|
365
420
|
const source = options.unwrapFence === true ? unwrapMarkdownFence(markdown) : markdown;
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
421
|
+
const rawHtml = resolveRawHtmlStrategy(options.sanitize);
|
|
422
|
+
const parsed = parseWith(source, options, rawHtml);
|
|
423
|
+
if (parsed === null) {
|
|
369
424
|
return '';
|
|
370
425
|
}
|
|
371
|
-
/*
|
|
426
|
+
/*
|
|
427
|
+
* One pass, over the assembled document, because that is the only string in
|
|
428
|
+
* which a raw-HTML container and the markdown it wraps are both present and
|
|
429
|
+
* balanced. The cost is the contract: the sanitizer also sees markdown-
|
|
430
|
+
* generated HTML, so a configuration restrictive enough to strip `<p>` or
|
|
431
|
+
* `<code>` will strip them here too. Documented on `htmlSanitizer`.
|
|
432
|
+
*/
|
|
433
|
+
let output = parsed;
|
|
434
|
+
if (rawHtml === 'passthrough') {
|
|
435
|
+
const htmlSanitizer = options.sanitize?.htmlSanitizer;
|
|
436
|
+
const sanitized = typeof htmlSanitizer === 'function' ? runSanitizer(parsed, htmlSanitizer) : null;
|
|
437
|
+
output = sanitized ?? parseWith(source, options, 'escape') ?? '';
|
|
438
|
+
}
|
|
439
|
+
/*
|
|
440
|
+
* After sanitization, so the library's own link hardening cannot be stripped
|
|
441
|
+
* by the caller's configuration -- and so it reaches links that arrived as
|
|
442
|
+
* raw HTML rather than markdown syntax.
|
|
443
|
+
*/
|
|
372
444
|
const linked = annotateExternalLinks(output);
|
|
445
|
+
/* Inline parsing produces no block elements, so there is no table to wrap. */
|
|
373
446
|
return options.inline === true
|
|
374
447
|
? linked
|
|
375
448
|
: wrapTables(linked, options.tableLabel, options.tableWrapperClass);
|
|
@@ -109,13 +109,57 @@ export type MarkdownSanitizeOptions = {
|
|
|
109
109
|
* keep their existing rendering. Neither mode inserts source HTML into the DOM.
|
|
110
110
|
*/
|
|
111
111
|
rawHtml?: MarkdownRawHtmlMode;
|
|
112
|
+
/**
|
|
113
|
+
* Sanitizer consulted only while `rawHtml` is `'sanitize'`. It is called ONCE
|
|
114
|
+
* per render, with the whole assembled document, and what it returns is what
|
|
115
|
+
* gets rendered.
|
|
116
|
+
*
|
|
117
|
+
* Note what that means: it sees markdown-generated HTML too, not only the raw
|
|
118
|
+
* HTML the author wrote. A configuration restrictive enough to drop `<p>`,
|
|
119
|
+
* `<code>` or `<table>` will drop them here as well, so allow the tags this
|
|
120
|
+
* component emits. It is not called per raw-HTML token, because marked splits
|
|
121
|
+
* a block-level container into separate opening and closing tokens -- and a
|
|
122
|
+
* sanitizer handed `<div class="x">` on its own balances it to
|
|
123
|
+
* `<div class="x"></div>`, which puts the content the author wrapped outside
|
|
124
|
+
* its container.
|
|
125
|
+
*
|
|
126
|
+
* This library deliberately does not implement HTML sanitization and does not
|
|
127
|
+
* depend on a sanitizer. Escaping everything is a provably safe default, and a
|
|
128
|
+
* sanitizer written here could only subtract from it: mutation XSS, namespace
|
|
129
|
+
* confusion and parse-then-reparse divergence are a specialist problem, and a
|
|
130
|
+
* home-grown one inside a package this many projects install would be the most
|
|
131
|
+
* dangerous file in it. Injection keeps that code in your dependency tree,
|
|
132
|
+
* where it can be audited and updated on its own schedule.
|
|
133
|
+
*
|
|
134
|
+
* It also keeps the contract honest: this signature says the returned string
|
|
135
|
+
* is YOUR responsibility. Pass DOMPurify's `sanitize`, or anything you trust.
|
|
136
|
+
* Note that the library's URL protocol allow-list guards markdown-syntax links
|
|
137
|
+
* and images only: a `javascript:` href written as raw HTML is emitted exactly
|
|
138
|
+
* as your sanitizer returns it, because re-checking that would mean parsing
|
|
139
|
+
* and rewriting the output -- the sanitization this option exists to delegate.
|
|
140
|
+
*
|
|
141
|
+
* If it throws or returns a non-string, the document is re-rendered with
|
|
142
|
+
* `'escape'` instead; the unsanitized assembly is never emitted.
|
|
143
|
+
*
|
|
144
|
+
* It must work on the server as well as in the browser. `MarkdownText`
|
|
145
|
+
* renders this through `{@html}` from a `$derived`, so a sanitizer that only
|
|
146
|
+
* works client-side (DOMPurify without a DOM, say) throws during SSR, falls
|
|
147
|
+
* back to escaped output there, and succeeds on hydration -- the two renders
|
|
148
|
+
* then disagree. Give it a server implementation, or leave `rawHtml` alone on
|
|
149
|
+
* server-rendered pages.
|
|
150
|
+
*/
|
|
151
|
+
htmlSanitizer?: (html: string) => string;
|
|
112
152
|
};
|
|
113
153
|
/**
|
|
114
154
|
* `'escape'` renders raw HTML as visible escaped text (the default, and what
|
|
115
|
-
* the component has always done); `'strip'` removes it
|
|
155
|
+
* the component has always done); `'strip'` removes it; `'sanitize'` keeps raw
|
|
156
|
+
* HTML in place and hands the assembled document to
|
|
157
|
+
* `MarkdownSanitizeOptions.htmlSanitizer`, rendering what comes back. With no
|
|
158
|
+
* sanitizer supplied, `'sanitize'` behaves exactly like `'escape'` -- the unsafe
|
|
159
|
+
* path is unreachable by omission rather than by discipline. See
|
|
116
160
|
* `MarkdownSanitizeOptions.rawHtml`.
|
|
117
161
|
*/
|
|
118
|
-
export type MarkdownRawHtmlMode = 'escape' | 'strip';
|
|
162
|
+
export type MarkdownRawHtmlMode = 'escape' | 'strip' | 'sanitize';
|
|
119
163
|
export type RenderMarkdownOptions = {
|
|
120
164
|
/** Render single newlines as `<br>` (GFM "breaks" mode). */
|
|
121
165
|
breaks?: boolean;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { SpeechSynthesisEngineLike, SpeechSynthesisOptions, SpeechUtteranceOptions } from './types';
|
|
2
|
+
export declare const DEFAULT_ERROR_TIMEOUT_MS = 4000;
|
|
3
|
+
/**
|
|
4
|
+
* Clamps a caller-supplied numeric option into `[min, max]`, falling back to `fallback` for
|
|
5
|
+
* a non-finite value (`NaN`, `+/-Infinity`) rather than clamping garbage into range silently.
|
|
6
|
+
* Exported (and kept free of the class) so range enforcement is unit-testable without a
|
|
7
|
+
* browser or an engine.
|
|
8
|
+
*/
|
|
9
|
+
export declare function clampSpeechNumber(value: number, range: {
|
|
10
|
+
readonly min: number;
|
|
11
|
+
readonly max: number;
|
|
12
|
+
readonly fallback: number;
|
|
13
|
+
}): number;
|
|
14
|
+
/** Floors a caller-supplied toast delay at zero, falling back to the default for non-finite input. */
|
|
15
|
+
export declare function clampErrorTimeoutMs(value: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* Wraps the real `speechSynthesis` global so the controller only ever talks to
|
|
18
|
+
* `SpeechSynthesisEngineLike` -- never a live `SpeechSynthesisUtterance` (which extends
|
|
19
|
+
* EventTarget). `speak()` builds and wires a real utterance internally per call; nothing
|
|
20
|
+
* outside this function ever sees it.
|
|
21
|
+
*
|
|
22
|
+
* `window.speechSynthesis` is a single page-wide singleton, so voice-change notification is
|
|
23
|
+
* wired with `addEventListener('voiceschanged', ...)` rather than the single-slot
|
|
24
|
+
* `.onvoiceschanged =` property -- the latter would let a second controller's registration
|
|
25
|
+
* silently overwrite a first controller's, since both would be assigning the same DOM
|
|
26
|
+
* property on the same object. `addEventListener` registration is additive: each call to
|
|
27
|
+
* this function adds its own listener, and the engine's own `onvoiceschanged` setter -- the
|
|
28
|
+
* injected-engine contract stays a plain property, unrelated to how this wrapper talks to
|
|
29
|
+
* the real DOM object underneath -- removes exactly that listener when the controller tears
|
|
30
|
+
* down (`set onvoiceschanged(null)`), never another instance's.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBrowserEngine(synth: SpeechSynthesis): SpeechSynthesisEngineLike;
|
|
33
|
+
/**
|
|
34
|
+
* Headless text-to-speech over the browser's `speechSynthesis` API. The symmetric
|
|
35
|
+
* counterpart to `SpeechToTextController`: support detection, the voiceschanged dance for
|
|
36
|
+
* voice enumeration, toggle-to-stop orchestration (calling `speak()` while already speaking
|
|
37
|
+
* stops instead of starting a new utterance), and a self-hiding error toast. Rendering
|
|
38
|
+
* stays entirely with the host.
|
|
39
|
+
*/
|
|
40
|
+
export declare class SpeechSynthesisController {
|
|
41
|
+
/** True between the confirmed start and end of an utterance. */
|
|
42
|
+
speaking: boolean;
|
|
43
|
+
/** Often empty until the platform's voiceschanged fires -- see `initialize()`. */
|
|
44
|
+
voices: readonly SpeechSynthesisVoice[];
|
|
45
|
+
errorMessage: string;
|
|
46
|
+
errorVisible: boolean;
|
|
47
|
+
supported: boolean;
|
|
48
|
+
private readonly options;
|
|
49
|
+
private readonly messages;
|
|
50
|
+
private readonly errorMessages;
|
|
51
|
+
private engine;
|
|
52
|
+
private everInitialized;
|
|
53
|
+
private internalSpeaking;
|
|
54
|
+
private errorTimer;
|
|
55
|
+
private speechGeneration;
|
|
56
|
+
constructor(options?: SpeechSynthesisOptions);
|
|
57
|
+
/**
|
|
58
|
+
* Detects the API and starts tracking voices. Safe to call again; `speak` calls it
|
|
59
|
+
* lazily if the host never did. Re-initializing onto a *different* engine (a
|
|
60
|
+
* `getSynthesisEngine` swap, not merely calling this twice against the same one) tears the
|
|
61
|
+
* previous engine down first -- otherwise it would keep speaking with its `onvoiceschanged`
|
|
62
|
+
* handler still live, and a later `stop()`/`destroy()` would only ever reach the new engine.
|
|
63
|
+
*/
|
|
64
|
+
initialize(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Speaks `text`. Calling this while already speaking stops instead of starting a new
|
|
67
|
+
* utterance -- the same toggle-to-stop behavior as a mic button's start/stop click.
|
|
68
|
+
*/
|
|
69
|
+
speak(text: string, options?: SpeechUtteranceOptions): void;
|
|
70
|
+
stop(): void;
|
|
71
|
+
/** Clears the toast timer and tears the engine connection down. */
|
|
72
|
+
destroy(): void;
|
|
73
|
+
/** Stops a superseded engine and detaches its listener; invalidates any utterance still in flight on it. */
|
|
74
|
+
private teardownEngine;
|
|
75
|
+
private refreshVoices;
|
|
76
|
+
/**
|
|
77
|
+
* Runs voice selection (the caller-supplied `selectVoice` hook, or the built-in default)
|
|
78
|
+
* guarded the same way `getSynthesisEngine` is guarded in `initialize()`: a throwing hook
|
|
79
|
+
* must not propagate out of `speak()` and strand the caller's utterance, so it is reported
|
|
80
|
+
* via `onDiagnostic` and selection falls back to the built-in default instead.
|
|
81
|
+
*/
|
|
82
|
+
private resolveVoice;
|
|
83
|
+
private handleStart;
|
|
84
|
+
private handleEnd;
|
|
85
|
+
private handleUtteranceError;
|
|
86
|
+
private setSpeaking;
|
|
87
|
+
private showError;
|
|
88
|
+
private diagnose;
|
|
89
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// The engine fires these when OUR OWN stop()/re-speak cancels an in-flight utterance --
|
|
2
|
+
// expected traffic (per the Web Speech spec, cancel() interrupts via onerror, not onend),
|
|
3
|
+
// not a failure worth surfacing as a toast.
|
|
4
|
+
const SILENT_ERROR_CODES = ['canceled', 'interrupted'];
|
|
5
|
+
const DEFAULT_ERROR_MESSAGES = {
|
|
6
|
+
'not-allowed': 'Speech playback is blocked. Allow audio in your browser settings to hear this read aloud.',
|
|
7
|
+
'audio-busy': 'Audio output is busy. Please try again.',
|
|
8
|
+
'audio-hardware': 'No audio output device found.',
|
|
9
|
+
network: 'A network error interrupted speech playback. Please check your connection and try again.',
|
|
10
|
+
'synthesis-failed': 'Could not read this text aloud. Please try again.',
|
|
11
|
+
'synthesis-unavailable': 'Speech playback is unavailable right now. Please try again.',
|
|
12
|
+
'language-unavailable': 'The requested language is not available for speech playback.',
|
|
13
|
+
'voice-unavailable': 'The requested voice is not available for speech playback.',
|
|
14
|
+
'text-too-long': 'This text is too long to read aloud.',
|
|
15
|
+
'invalid-argument': 'Could not read this text aloud due to an invalid option.'
|
|
16
|
+
};
|
|
17
|
+
const DEFAULT_MESSAGES = {
|
|
18
|
+
unsupported: 'Speech playback is not supported in this browser.',
|
|
19
|
+
fallback: 'Could not read this text aloud. Please try again.'
|
|
20
|
+
};
|
|
21
|
+
export const DEFAULT_ERROR_TIMEOUT_MS = 4000;
|
|
22
|
+
// Web Speech spec ranges for SpeechSynthesisUtterance.rate/pitch/volume -- a value outside
|
|
23
|
+
// these throws or is silently ignored depending on the browser, so the controller clamps
|
|
24
|
+
// instead of leaving that behavior to chance.
|
|
25
|
+
const RATE_RANGE = { min: 0.1, max: 10, fallback: 1 };
|
|
26
|
+
const PITCH_RANGE = { min: 0, max: 2, fallback: 1 };
|
|
27
|
+
const VOLUME_RANGE = { min: 0, max: 1, fallback: 1 };
|
|
28
|
+
function errorDetail(raw) {
|
|
29
|
+
return raw instanceof Error ? raw.message : String(raw);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Clamps a caller-supplied numeric option into `[min, max]`, falling back to `fallback` for
|
|
33
|
+
* a non-finite value (`NaN`, `+/-Infinity`) rather than clamping garbage into range silently.
|
|
34
|
+
* Exported (and kept free of the class) so range enforcement is unit-testable without a
|
|
35
|
+
* browser or an engine.
|
|
36
|
+
*/
|
|
37
|
+
export function clampSpeechNumber(value, range) {
|
|
38
|
+
if (!Number.isFinite(value)) {
|
|
39
|
+
return range.fallback;
|
|
40
|
+
}
|
|
41
|
+
return Math.min(range.max, Math.max(range.min, value));
|
|
42
|
+
}
|
|
43
|
+
/** Floors a caller-supplied toast delay at zero, falling back to the default for non-finite input. */
|
|
44
|
+
export function clampErrorTimeoutMs(value) {
|
|
45
|
+
if (!Number.isFinite(value)) {
|
|
46
|
+
return DEFAULT_ERROR_TIMEOUT_MS;
|
|
47
|
+
}
|
|
48
|
+
return Math.max(0, value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wraps the real `speechSynthesis` global so the controller only ever talks to
|
|
52
|
+
* `SpeechSynthesisEngineLike` -- never a live `SpeechSynthesisUtterance` (which extends
|
|
53
|
+
* EventTarget). `speak()` builds and wires a real utterance internally per call; nothing
|
|
54
|
+
* outside this function ever sees it.
|
|
55
|
+
*
|
|
56
|
+
* `window.speechSynthesis` is a single page-wide singleton, so voice-change notification is
|
|
57
|
+
* wired with `addEventListener('voiceschanged', ...)` rather than the single-slot
|
|
58
|
+
* `.onvoiceschanged =` property -- the latter would let a second controller's registration
|
|
59
|
+
* silently overwrite a first controller's, since both would be assigning the same DOM
|
|
60
|
+
* property on the same object. `addEventListener` registration is additive: each call to
|
|
61
|
+
* this function adds its own listener, and the engine's own `onvoiceschanged` setter -- the
|
|
62
|
+
* injected-engine contract stays a plain property, unrelated to how this wrapper talks to
|
|
63
|
+
* the real DOM object underneath -- removes exactly that listener when the controller tears
|
|
64
|
+
* down (`set onvoiceschanged(null)`), never another instance's.
|
|
65
|
+
*/
|
|
66
|
+
export function createBrowserEngine(synth) {
|
|
67
|
+
let voicesChangedHandler = null;
|
|
68
|
+
let listening = false;
|
|
69
|
+
const dispatch = () => {
|
|
70
|
+
voicesChangedHandler?.();
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
get speaking() {
|
|
74
|
+
return synth.speaking;
|
|
75
|
+
},
|
|
76
|
+
get onvoiceschanged() {
|
|
77
|
+
return voicesChangedHandler;
|
|
78
|
+
},
|
|
79
|
+
set onvoiceschanged(handler) {
|
|
80
|
+
voicesChangedHandler = handler;
|
|
81
|
+
if (handler === null) {
|
|
82
|
+
if (listening) {
|
|
83
|
+
synth.removeEventListener('voiceschanged', dispatch);
|
|
84
|
+
listening = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (!listening) {
|
|
88
|
+
synth.addEventListener('voiceschanged', dispatch);
|
|
89
|
+
listening = true;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
getVoices: () => synth.getVoices(),
|
|
93
|
+
cancel: () => synth.cancel(),
|
|
94
|
+
speak: (request, callbacks) => {
|
|
95
|
+
// `createBrowserEngine` is exported, so a caller can reach it directly and
|
|
96
|
+
// run it somewhere `SpeechSynthesisUtterance` does not exist. The
|
|
97
|
+
// controller's own path guards `window` before ever getting here; this
|
|
98
|
+
// guard is for the direct caller, and reports through the normal error
|
|
99
|
+
// channel rather than throwing out of `speak()`.
|
|
100
|
+
if (typeof SpeechSynthesisUtterance !== 'function') {
|
|
101
|
+
callbacks.onerror('synthesis-unavailable');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const utterance = new SpeechSynthesisUtterance(request.text);
|
|
105
|
+
utterance.lang = request.lang;
|
|
106
|
+
utterance.rate = request.rate;
|
|
107
|
+
utterance.pitch = request.pitch;
|
|
108
|
+
utterance.volume = request.volume;
|
|
109
|
+
utterance.voice = request.voice;
|
|
110
|
+
utterance.onstart = () => callbacks.onstart();
|
|
111
|
+
utterance.onend = () => callbacks.onend();
|
|
112
|
+
utterance.onerror = (event) => callbacks.onerror(event.error);
|
|
113
|
+
synth.speak(utterance);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function defaultSynthesisEngine() {
|
|
118
|
+
if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return createBrowserEngine(window.speechSynthesis);
|
|
122
|
+
}
|
|
123
|
+
/** The primary language subtag (BCP 47), lowercased -- the part before the first '-', which
|
|
124
|
+
* may be two letters ('en') or three ('fil', 'haw', 'yue'), not always the first two characters. */
|
|
125
|
+
function primarySubtag(lang) {
|
|
126
|
+
const dashIndex = lang.indexOf('-');
|
|
127
|
+
const subtag = dashIndex === -1 ? lang : lang.slice(0, dashIndex);
|
|
128
|
+
return subtag.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
/** Platform default among voices matching `lang`'s primary subtag, else the first such match, else the platform default overall, else null. */
|
|
131
|
+
function defaultSelectVoice(voices, lang) {
|
|
132
|
+
const subtag = primarySubtag(lang);
|
|
133
|
+
const matching = subtag.length > 0 ? voices.filter((voice) => primarySubtag(voice.lang) === subtag) : [];
|
|
134
|
+
const pool = matching.length > 0 ? matching : voices;
|
|
135
|
+
const preferred = pool.find((voice) => voice.default);
|
|
136
|
+
return preferred ?? pool[0] ?? null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Headless text-to-speech over the browser's `speechSynthesis` API. The symmetric
|
|
140
|
+
* counterpart to `SpeechToTextController`: support detection, the voiceschanged dance for
|
|
141
|
+
* voice enumeration, toggle-to-stop orchestration (calling `speak()` while already speaking
|
|
142
|
+
* stops instead of starting a new utterance), and a self-hiding error toast. Rendering
|
|
143
|
+
* stays entirely with the host.
|
|
144
|
+
*/
|
|
145
|
+
export class SpeechSynthesisController {
|
|
146
|
+
/** True between the confirmed start and end of an utterance. */
|
|
147
|
+
speaking = $state(false);
|
|
148
|
+
/** Often empty until the platform's voiceschanged fires -- see `initialize()`. */
|
|
149
|
+
voices = $state([]);
|
|
150
|
+
errorMessage = $state('');
|
|
151
|
+
errorVisible = $state(false);
|
|
152
|
+
supported = $state(false);
|
|
153
|
+
options;
|
|
154
|
+
messages;
|
|
155
|
+
errorMessages;
|
|
156
|
+
engine = null;
|
|
157
|
+
everInitialized = false;
|
|
158
|
+
internalSpeaking = false;
|
|
159
|
+
errorTimer = null;
|
|
160
|
+
// Bumped per speak()/stop(); callbacks captured by a superseded utterance compare
|
|
161
|
+
// against it so a late onend/onerror cannot affect state for a newer utterance
|
|
162
|
+
// (mirrors SpeechToTextController's instanceGeneration guard).
|
|
163
|
+
speechGeneration = 0;
|
|
164
|
+
constructor(options = {}) {
|
|
165
|
+
this.options = options;
|
|
166
|
+
this.messages = { ...DEFAULT_MESSAGES, ...options.messages };
|
|
167
|
+
this.errorMessages = { ...DEFAULT_ERROR_MESSAGES, ...options.errorMessages };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Detects the API and starts tracking voices. Safe to call again; `speak` calls it
|
|
171
|
+
* lazily if the host never did. Re-initializing onto a *different* engine (a
|
|
172
|
+
* `getSynthesisEngine` swap, not merely calling this twice against the same one) tears the
|
|
173
|
+
* previous engine down first -- otherwise it would keep speaking with its `onvoiceschanged`
|
|
174
|
+
* handler still live, and a later `stop()`/`destroy()` would only ever reach the new engine.
|
|
175
|
+
*/
|
|
176
|
+
initialize() {
|
|
177
|
+
this.everInitialized = true;
|
|
178
|
+
const getEngine = this.options.getSynthesisEngine ?? defaultSynthesisEngine;
|
|
179
|
+
let engine;
|
|
180
|
+
try {
|
|
181
|
+
engine = getEngine();
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
this.diagnose('initializeFailed', { error: errorDetail(error) });
|
|
185
|
+
engine = null;
|
|
186
|
+
}
|
|
187
|
+
if (this.engine !== null && this.engine !== engine) {
|
|
188
|
+
this.teardownEngine(this.engine);
|
|
189
|
+
}
|
|
190
|
+
this.supported = engine !== null;
|
|
191
|
+
this.engine = engine;
|
|
192
|
+
if (engine === null) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
engine.onvoiceschanged = () => {
|
|
196
|
+
this.refreshVoices();
|
|
197
|
+
};
|
|
198
|
+
this.refreshVoices();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Speaks `text`. Calling this while already speaking stops instead of starting a new
|
|
203
|
+
* utterance -- the same toggle-to-stop behavior as a mic button's start/stop click.
|
|
204
|
+
*/
|
|
205
|
+
speak(text, options = {}) {
|
|
206
|
+
if (this.internalSpeaking) {
|
|
207
|
+
this.stop();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!this.everInitialized) {
|
|
211
|
+
this.initialize();
|
|
212
|
+
}
|
|
213
|
+
if (!this.supported || this.engine === null) {
|
|
214
|
+
this.showError(this.messages.unsupported);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (text.length === 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const lang = options.lang ?? this.options.lang ?? '';
|
|
221
|
+
const request = {
|
|
222
|
+
text,
|
|
223
|
+
lang,
|
|
224
|
+
rate: clampSpeechNumber(options.rate ?? this.options.rate ?? RATE_RANGE.fallback, RATE_RANGE),
|
|
225
|
+
pitch: clampSpeechNumber(options.pitch ?? this.options.pitch ?? PITCH_RANGE.fallback, PITCH_RANGE),
|
|
226
|
+
volume: clampSpeechNumber(options.volume ?? this.options.volume ?? VOLUME_RANGE.fallback, VOLUME_RANGE),
|
|
227
|
+
// `??` would treat an explicit `voice: null` (opt out of voice selection) the same as
|
|
228
|
+
// an absent option, silently overriding it with resolveVoice() -- checked against the
|
|
229
|
+
// key being unset instead, so `null` passes through untouched.
|
|
230
|
+
voice: typeof options.voice === 'undefined' ? this.resolveVoice(lang) : options.voice
|
|
231
|
+
};
|
|
232
|
+
this.speechGeneration += 1;
|
|
233
|
+
const generation = this.speechGeneration;
|
|
234
|
+
const isCurrent = () => generation === this.speechGeneration;
|
|
235
|
+
const callbacks = {
|
|
236
|
+
onstart: () => {
|
|
237
|
+
if (isCurrent()) {
|
|
238
|
+
this.handleStart();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
onend: () => {
|
|
242
|
+
if (isCurrent()) {
|
|
243
|
+
this.handleEnd();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
onerror: (code) => {
|
|
247
|
+
if (isCurrent()) {
|
|
248
|
+
this.handleUtteranceError(code);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
this.internalSpeaking = true;
|
|
253
|
+
this.errorMessage = '';
|
|
254
|
+
this.errorVisible = false;
|
|
255
|
+
try {
|
|
256
|
+
this.engine.speak(request, callbacks);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
this.internalSpeaking = false;
|
|
260
|
+
this.diagnose('speakThrew', { error: errorDetail(error) });
|
|
261
|
+
this.showError(this.messages.fallback);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
stop() {
|
|
265
|
+
// Invalidate any in-flight utterance's callbacks before cancel() can fire them -- cancel()
|
|
266
|
+
// itself may synchronously trigger onerror('canceled'), which must NOT re-run through
|
|
267
|
+
// handleUtteranceError (that would double-fire onSpeakingChange with a redundant `false`).
|
|
268
|
+
// The diagnostic for a self-initiated stop is emitted here instead, since the generation
|
|
269
|
+
// bump means the engine round-trip can no longer be trusted to deliver it.
|
|
270
|
+
this.speechGeneration += 1;
|
|
271
|
+
if (this.internalSpeaking) {
|
|
272
|
+
this.internalSpeaking = false;
|
|
273
|
+
this.setSpeaking(false);
|
|
274
|
+
this.diagnose('utteranceCanceled', { code: 'canceled' });
|
|
275
|
+
}
|
|
276
|
+
if (this.engine !== null) {
|
|
277
|
+
this.engine.cancel();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Clears the toast timer and tears the engine connection down. */
|
|
281
|
+
destroy() {
|
|
282
|
+
this.speechGeneration += 1;
|
|
283
|
+
if (this.errorTimer !== null) {
|
|
284
|
+
clearTimeout(this.errorTimer);
|
|
285
|
+
this.errorTimer = null;
|
|
286
|
+
}
|
|
287
|
+
if (this.engine !== null) {
|
|
288
|
+
try {
|
|
289
|
+
this.engine.cancel();
|
|
290
|
+
}
|
|
291
|
+
catch (cancelError) {
|
|
292
|
+
this.diagnose('cancelErrorOnDestroy', { error: errorDetail(cancelError) });
|
|
293
|
+
}
|
|
294
|
+
this.engine.onvoiceschanged = null;
|
|
295
|
+
this.engine = null;
|
|
296
|
+
}
|
|
297
|
+
this.internalSpeaking = false;
|
|
298
|
+
// cancel() firing onerror is not guaranteed (a superseded generation may swallow it),
|
|
299
|
+
// so clear the public state too -- a consumer's speaking indicator must not survive teardown.
|
|
300
|
+
if (this.speaking) {
|
|
301
|
+
this.setSpeaking(false);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Stops a superseded engine and detaches its listener; invalidates any utterance still in flight on it. */
|
|
305
|
+
teardownEngine(engine) {
|
|
306
|
+
this.speechGeneration += 1;
|
|
307
|
+
if (this.internalSpeaking) {
|
|
308
|
+
this.internalSpeaking = false;
|
|
309
|
+
this.setSpeaking(false);
|
|
310
|
+
this.diagnose('utteranceCanceled', { code: 'canceled' });
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
engine.cancel();
|
|
314
|
+
}
|
|
315
|
+
catch (cancelError) {
|
|
316
|
+
this.diagnose('cancelErrorOnReplace', { error: errorDetail(cancelError) });
|
|
317
|
+
}
|
|
318
|
+
engine.onvoiceschanged = null;
|
|
319
|
+
}
|
|
320
|
+
refreshVoices() {
|
|
321
|
+
if (this.engine === null) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Copied rather than assigned through. `getSynthesisEngine` is a documented
|
|
325
|
+
// seam for tests, demos and non-browser hosts, so an engine returning a
|
|
326
|
+
// stable array it mutates in place is a supported shape -- and a raw array
|
|
327
|
+
// mutated behind a `$state` proxy is not reliably observed. The real
|
|
328
|
+
// `speechSynthesis.getVoices()` returns a fresh array (WebIDL sequence),
|
|
329
|
+
// which is why the browser path never showed this. A copy costs nothing and
|
|
330
|
+
// removes the aliasing entirely.
|
|
331
|
+
this.voices = [...this.engine.getVoices()];
|
|
332
|
+
this.diagnose('voicesChanged', { count: this.voices.length });
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Runs voice selection (the caller-supplied `selectVoice` hook, or the built-in default)
|
|
336
|
+
* guarded the same way `getSynthesisEngine` is guarded in `initialize()`: a throwing hook
|
|
337
|
+
* must not propagate out of `speak()` and strand the caller's utterance, so it is reported
|
|
338
|
+
* via `onDiagnostic` and selection falls back to the built-in default instead.
|
|
339
|
+
*/
|
|
340
|
+
resolveVoice(lang) {
|
|
341
|
+
const selectVoice = this.options.selectVoice ?? defaultSelectVoice;
|
|
342
|
+
try {
|
|
343
|
+
return selectVoice(this.voices, lang);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
this.diagnose('selectVoiceThrew', { error: errorDetail(error) });
|
|
347
|
+
return defaultSelectVoice(this.voices, lang);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
handleStart() {
|
|
351
|
+
this.internalSpeaking = true;
|
|
352
|
+
this.setSpeaking(true);
|
|
353
|
+
}
|
|
354
|
+
handleEnd() {
|
|
355
|
+
this.internalSpeaking = false;
|
|
356
|
+
this.setSpeaking(false);
|
|
357
|
+
}
|
|
358
|
+
handleUtteranceError(code) {
|
|
359
|
+
this.internalSpeaking = false;
|
|
360
|
+
this.setSpeaking(false);
|
|
361
|
+
if (SILENT_ERROR_CODES.includes(code)) {
|
|
362
|
+
this.diagnose('utteranceCanceled', { code });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.showError(this.errorMessages[code] ?? this.messages.fallback, code);
|
|
366
|
+
}
|
|
367
|
+
setSpeaking(speaking) {
|
|
368
|
+
this.speaking = speaking;
|
|
369
|
+
this.options.onSpeakingChange?.(speaking);
|
|
370
|
+
}
|
|
371
|
+
showError(message, code = null) {
|
|
372
|
+
this.errorMessage = message;
|
|
373
|
+
this.errorVisible = true;
|
|
374
|
+
if (this.errorTimer !== null) {
|
|
375
|
+
clearTimeout(this.errorTimer);
|
|
376
|
+
}
|
|
377
|
+
this.errorTimer = setTimeout(() => {
|
|
378
|
+
this.errorVisible = false;
|
|
379
|
+
}, clampErrorTimeoutMs(this.options.errorTimeoutMs ?? DEFAULT_ERROR_TIMEOUT_MS));
|
|
380
|
+
this.options.onError?.(message, code);
|
|
381
|
+
}
|
|
382
|
+
diagnose(event, detail) {
|
|
383
|
+
this.options.onDiagnostic?.(event, detail);
|
|
384
|
+
}
|
|
385
|
+
}
|