@wcstack/speech 1.13.1
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/README.ja.md +186 -0
- package/README.md +187 -0
- package/dist/auto.js +3 -0
- package/dist/auto.min.js +1 -0
- package/dist/index.d.ts +493 -0
- package/dist/index.esm.js +1313 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.esm.min.js +2 -0
- package/dist/index.esm.min.js.map +1 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
const _config = {
|
|
2
|
+
autoTrigger: true,
|
|
3
|
+
triggerAttribute: "data-speaktarget",
|
|
4
|
+
listenTriggerAttribute: "data-listentarget",
|
|
5
|
+
tagNames: {
|
|
6
|
+
speak: "wcs-speak",
|
|
7
|
+
listen: "wcs-listen",
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
function deepFreeze(obj) {
|
|
11
|
+
if (obj === null || typeof obj !== "object")
|
|
12
|
+
return obj;
|
|
13
|
+
Object.freeze(obj);
|
|
14
|
+
for (const key of Object.keys(obj)) {
|
|
15
|
+
deepFreeze(obj[key]);
|
|
16
|
+
}
|
|
17
|
+
return obj;
|
|
18
|
+
}
|
|
19
|
+
function deepClone(obj) {
|
|
20
|
+
if (obj === null || typeof obj !== "object")
|
|
21
|
+
return obj;
|
|
22
|
+
const clone = {};
|
|
23
|
+
for (const key of Object.keys(obj)) {
|
|
24
|
+
clone[key] = deepClone(obj[key]);
|
|
25
|
+
}
|
|
26
|
+
return clone;
|
|
27
|
+
}
|
|
28
|
+
let frozenConfig = null;
|
|
29
|
+
// Internal, mutable live config used by the components/autoTriggers (they read it
|
|
30
|
+
// at call time so setConfig() takes effect without re-import). Typed as the
|
|
31
|
+
// readonly IConfig at the export boundary — the `as IConfig` is a compile-time
|
|
32
|
+
// view only and does NOT freeze the object, so this export must stay
|
|
33
|
+
// package-internal (it is not re-exported from exports.ts). Public consumers get
|
|
34
|
+
// the deep-frozen clone from getConfig() instead, which is the only safe
|
|
35
|
+
// read-only handle.
|
|
36
|
+
const config = _config;
|
|
37
|
+
function getConfig() {
|
|
38
|
+
if (!frozenConfig) {
|
|
39
|
+
frozenConfig = deepFreeze(deepClone(_config));
|
|
40
|
+
}
|
|
41
|
+
return frozenConfig;
|
|
42
|
+
}
|
|
43
|
+
function setConfig(partialConfig) {
|
|
44
|
+
if (typeof partialConfig.autoTrigger === "boolean") {
|
|
45
|
+
_config.autoTrigger = partialConfig.autoTrigger;
|
|
46
|
+
}
|
|
47
|
+
if (typeof partialConfig.triggerAttribute === "string") {
|
|
48
|
+
_config.triggerAttribute = partialConfig.triggerAttribute;
|
|
49
|
+
}
|
|
50
|
+
if (typeof partialConfig.listenTriggerAttribute === "string") {
|
|
51
|
+
_config.listenTriggerAttribute = partialConfig.listenTriggerAttribute;
|
|
52
|
+
}
|
|
53
|
+
if (partialConfig.tagNames) {
|
|
54
|
+
Object.assign(_config.tagNames, partialConfig.tagNames);
|
|
55
|
+
}
|
|
56
|
+
frozenConfig = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Headless text-to-speech primitive. A thin, framework-agnostic wrapper around
|
|
61
|
+
* the SpeechSynthesis API exposed through the wc-bindable protocol.
|
|
62
|
+
*
|
|
63
|
+
* It is the "command" half of the speech package (the recognition half is
|
|
64
|
+
* ListenCore): state drives the element, never the reverse, except for the
|
|
65
|
+
* observable progress/status it publishes back.
|
|
66
|
+
*
|
|
67
|
+
* - **speak(text, options)** queues an utterance. Like the native API, multiple
|
|
68
|
+
* calls queue; `cancel()` clears the queue and stops the current utterance.
|
|
69
|
+
* - **pause() / resume()** suspend and resume the queue.
|
|
70
|
+
* - The observable surface mirrors the live SpeechSynthesis flags
|
|
71
|
+
* (`speaking` / `paused` / `pending`) and exposes voice-list loading
|
|
72
|
+
* (`voices`, which the API populates asynchronously via `voiceschanged`) plus
|
|
73
|
+
* word-boundary progress (`charIndex` / `spokenWord`) for karaoke-style
|
|
74
|
+
* highlighting.
|
|
75
|
+
*
|
|
76
|
+
* Unlike geolocation/clipboard there is no permission gate — synthesis needs no
|
|
77
|
+
* user grant. Failures never throw: they surface through the `error` property so
|
|
78
|
+
* they flow into the declarative state.
|
|
79
|
+
*/
|
|
80
|
+
class SpeakCore extends EventTarget {
|
|
81
|
+
static wcBindable = {
|
|
82
|
+
protocol: "wc-bindable",
|
|
83
|
+
version: 1,
|
|
84
|
+
properties: [
|
|
85
|
+
{ name: "voices", event: "wcs-speak:voices-changed" },
|
|
86
|
+
{ name: "speaking", event: "wcs-speak:speaking-changed" },
|
|
87
|
+
{ name: "paused", event: "wcs-speak:paused-changed" },
|
|
88
|
+
{ name: "pending", event: "wcs-speak:pending-changed" },
|
|
89
|
+
{ name: "charIndex", event: "wcs-speak:boundary", getter: (e) => e.detail?.charIndex ?? null },
|
|
90
|
+
{ name: "spokenWord", event: "wcs-speak:boundary", getter: (e) => e.detail?.word ?? null },
|
|
91
|
+
{ name: "error", event: "wcs-speak:error" },
|
|
92
|
+
{ name: "unsupported", event: "wcs-speak:unsupported-changed" },
|
|
93
|
+
],
|
|
94
|
+
commands: [
|
|
95
|
+
{ name: "speak" },
|
|
96
|
+
{ name: "cancel" },
|
|
97
|
+
{ name: "pause" },
|
|
98
|
+
{ name: "resume" },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
_target;
|
|
102
|
+
_voices = [];
|
|
103
|
+
_rawVoices = [];
|
|
104
|
+
_speaking = false;
|
|
105
|
+
_paused = false;
|
|
106
|
+
_pending = false;
|
|
107
|
+
_charIndex = null;
|
|
108
|
+
_spokenWord = null;
|
|
109
|
+
_error = null;
|
|
110
|
+
_unsupported = false;
|
|
111
|
+
// Count of utterances submitted via speak() but not yet started, and of
|
|
112
|
+
// utterances started but not yet ended/errored. `pending`/`speaking` are
|
|
113
|
+
// derived from these so the queue model is reflected accurately even when
|
|
114
|
+
// several utterances are in flight.
|
|
115
|
+
_queued = 0;
|
|
116
|
+
_started = 0;
|
|
117
|
+
// Monotonic id of the current synthesis lifecycle. Bumped by cancel() and
|
|
118
|
+
// dispose(). Each speak() captures it; every utterance event handler bails if
|
|
119
|
+
// it is stale, so a queued/canceled utterance's late callback (notably the
|
|
120
|
+
// "canceled" error the browser fires from cancel()) never mutates state or
|
|
121
|
+
// dispatches on a torn-down element.
|
|
122
|
+
_gen = 0;
|
|
123
|
+
// True once the voiceschanged subscription has been (or is being) established;
|
|
124
|
+
// reset by dispose(). Guards reinitVoices() so the first connect after
|
|
125
|
+
// construction does not double-subscribe, while a reconnect after dispose()
|
|
126
|
+
// does re-subscribe.
|
|
127
|
+
_voicesSubscribed = false;
|
|
128
|
+
constructor(target) {
|
|
129
|
+
super();
|
|
130
|
+
this._target = target ?? this;
|
|
131
|
+
// Probe support up front so observers see the real flag before the first read.
|
|
132
|
+
// Routed through the setter (not a direct assignment) so the field starts at
|
|
133
|
+
// its `false` default and the unsupported case actually transitions
|
|
134
|
+
// false→true; the supported case is same-value guarded and dispatches nothing.
|
|
135
|
+
this._setUnsupported(!this._hasApi());
|
|
136
|
+
this._initVoices();
|
|
137
|
+
}
|
|
138
|
+
get voices() {
|
|
139
|
+
return this._voices;
|
|
140
|
+
}
|
|
141
|
+
get speaking() {
|
|
142
|
+
return this._speaking;
|
|
143
|
+
}
|
|
144
|
+
get paused() {
|
|
145
|
+
return this._paused;
|
|
146
|
+
}
|
|
147
|
+
get pending() {
|
|
148
|
+
return this._pending;
|
|
149
|
+
}
|
|
150
|
+
get charIndex() {
|
|
151
|
+
return this._charIndex;
|
|
152
|
+
}
|
|
153
|
+
get spokenWord() {
|
|
154
|
+
return this._spokenWord;
|
|
155
|
+
}
|
|
156
|
+
get error() {
|
|
157
|
+
return this._error;
|
|
158
|
+
}
|
|
159
|
+
// Resolved once in the constructor (`_setUnsupported(!_hasApi())`) and never
|
|
160
|
+
// re-evaluated: the speechSynthesis API's presence is immutable for the
|
|
161
|
+
// lifetime of a document, so there's nothing to re-check.
|
|
162
|
+
get unsupported() {
|
|
163
|
+
return this._unsupported;
|
|
164
|
+
}
|
|
165
|
+
// --- State setters with event dispatch ---
|
|
166
|
+
_setVoices(voices) {
|
|
167
|
+
// Same-value guard, like the other setters. `voiceschanged` can fire several
|
|
168
|
+
// times with an identical list (engines re-announce after warm-up); compare
|
|
169
|
+
// the normalized snapshot content so a redundant re-announcement does not
|
|
170
|
+
// re-dispatch voices-changed. A genuine list change (length or any field)
|
|
171
|
+
// still fires.
|
|
172
|
+
if (this._voicesEqual(this._voices, voices))
|
|
173
|
+
return;
|
|
174
|
+
this._voices = voices;
|
|
175
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:voices-changed", {
|
|
176
|
+
detail: voices,
|
|
177
|
+
bubbles: true,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
_voicesEqual(a, b) {
|
|
181
|
+
if (a.length !== b.length)
|
|
182
|
+
return false;
|
|
183
|
+
for (let i = 0; i < a.length; i++) {
|
|
184
|
+
const x = a[i];
|
|
185
|
+
const y = b[i];
|
|
186
|
+
if (x.name !== y.name || x.lang !== y.lang || x.default !== y.default
|
|
187
|
+
|| x.localService !== y.localService || x.voiceURI !== y.voiceURI) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
_setSpeaking(speaking) {
|
|
194
|
+
if (this._speaking === speaking)
|
|
195
|
+
return;
|
|
196
|
+
this._speaking = speaking;
|
|
197
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:speaking-changed", {
|
|
198
|
+
detail: speaking,
|
|
199
|
+
bubbles: true,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
_setPaused(paused) {
|
|
203
|
+
if (this._paused === paused)
|
|
204
|
+
return;
|
|
205
|
+
this._paused = paused;
|
|
206
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:paused-changed", {
|
|
207
|
+
detail: paused,
|
|
208
|
+
bubbles: true,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
_setPending(pending) {
|
|
212
|
+
if (this._pending === pending)
|
|
213
|
+
return;
|
|
214
|
+
this._pending = pending;
|
|
215
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:pending-changed", {
|
|
216
|
+
detail: pending,
|
|
217
|
+
bubbles: true,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
_setBoundary(charIndex, word) {
|
|
221
|
+
// Boundary events stream rapidly with changing offsets; dispatch each. The
|
|
222
|
+
// guard only suppresses redundant resets (e.g. an end after an already-null
|
|
223
|
+
// boundary) so a cleared highlight does not re-fire.
|
|
224
|
+
if (this._charIndex === charIndex && this._spokenWord === word)
|
|
225
|
+
return;
|
|
226
|
+
this._charIndex = charIndex;
|
|
227
|
+
this._spokenWord = word;
|
|
228
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:boundary", {
|
|
229
|
+
detail: { charIndex, word },
|
|
230
|
+
bubbles: true,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
_setError(error) {
|
|
234
|
+
if (this._error === error)
|
|
235
|
+
return;
|
|
236
|
+
this._error = error;
|
|
237
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:error", {
|
|
238
|
+
detail: error,
|
|
239
|
+
bubbles: true,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
_setUnsupported(unsupported) {
|
|
243
|
+
if (this._unsupported === unsupported)
|
|
244
|
+
return;
|
|
245
|
+
this._unsupported = unsupported;
|
|
246
|
+
this._target.dispatchEvent(new CustomEvent("wcs-speak:unsupported-changed", {
|
|
247
|
+
detail: unsupported,
|
|
248
|
+
bubbles: true,
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
// --- Public API ---
|
|
252
|
+
/**
|
|
253
|
+
* Queue an utterance for `text` with optional per-utterance parameters. Never
|
|
254
|
+
* throws: when the API is unavailable it surfaces an `error` and returns. An
|
|
255
|
+
* empty/whitespace-only `text` is a no-op (the browser would not fire start).
|
|
256
|
+
*/
|
|
257
|
+
speak(text, options = {}) {
|
|
258
|
+
if (!this._hasApi()) {
|
|
259
|
+
this._setError(this._unsupportedError());
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const synth = window.speechSynthesis;
|
|
266
|
+
const utterance = new window.SpeechSynthesisUtterance(text);
|
|
267
|
+
if (typeof options.rate === "number")
|
|
268
|
+
utterance.rate = options.rate;
|
|
269
|
+
if (typeof options.pitch === "number")
|
|
270
|
+
utterance.pitch = options.pitch;
|
|
271
|
+
if (typeof options.volume === "number")
|
|
272
|
+
utterance.volume = options.volume;
|
|
273
|
+
if (typeof options.lang === "string" && options.lang !== "")
|
|
274
|
+
utterance.lang = options.lang;
|
|
275
|
+
if (typeof options.voice === "string" && options.voice !== "") {
|
|
276
|
+
const match = this._rawVoices.find((v) => v.name === options.voice);
|
|
277
|
+
if (match)
|
|
278
|
+
utterance.voice = match;
|
|
279
|
+
}
|
|
280
|
+
const gen = this._gen;
|
|
281
|
+
// Per-utterance "has started" flag. The browser can fire onerror/onend
|
|
282
|
+
// *before* onstart (e.g. a `synthesis-unavailable` / `audio-busy` failure on
|
|
283
|
+
// a still-queued utterance). In that case the utterance only ever counted
|
|
284
|
+
// toward `_queued`, so the terminal handler must decrement `_queued` — not
|
|
285
|
+
// `_started` — otherwise `pending` (derived from `_queued > 0`) sticks true
|
|
286
|
+
// forever. onstart sets this flag so the terminal handler knows which counter
|
|
287
|
+
// to release.
|
|
288
|
+
let started = false;
|
|
289
|
+
utterance.onstart = () => {
|
|
290
|
+
if (gen !== this._gen)
|
|
291
|
+
return;
|
|
292
|
+
started = true;
|
|
293
|
+
this._queued = Math.max(0, this._queued - 1);
|
|
294
|
+
this._started++;
|
|
295
|
+
this._setSpeaking(true);
|
|
296
|
+
this._setPending(this._queued > 0);
|
|
297
|
+
this._setBoundary(null, null);
|
|
298
|
+
};
|
|
299
|
+
utterance.onboundary = (event) => {
|
|
300
|
+
if (gen !== this._gen)
|
|
301
|
+
return;
|
|
302
|
+
try {
|
|
303
|
+
const charIndex = event.charIndex;
|
|
304
|
+
const length = event.charLength;
|
|
305
|
+
// Prefer the engine-provided word length. Some engines omit `charLength`
|
|
306
|
+
// on word boundaries; fall back to the run of non-whitespace at charIndex
|
|
307
|
+
// so `spokenWord` (the karaoke highlight) still works there.
|
|
308
|
+
const word = (typeof length === "number" && length > 0)
|
|
309
|
+
? text.substring(charIndex, charIndex + length)
|
|
310
|
+
: (text.slice(charIndex).match(/^\S+/)?.[0] ?? "");
|
|
311
|
+
this._setBoundary(charIndex, word);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// A malformed boundary event must not escape the browser callback.
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
utterance.onpause = () => {
|
|
318
|
+
if (gen !== this._gen)
|
|
319
|
+
return;
|
|
320
|
+
this._setPaused(true);
|
|
321
|
+
};
|
|
322
|
+
utterance.onresume = () => {
|
|
323
|
+
if (gen !== this._gen)
|
|
324
|
+
return;
|
|
325
|
+
this._setPaused(false);
|
|
326
|
+
};
|
|
327
|
+
utterance.onend = () => {
|
|
328
|
+
if (gen !== this._gen)
|
|
329
|
+
return;
|
|
330
|
+
this._finishUtterance(started);
|
|
331
|
+
};
|
|
332
|
+
utterance.onerror = (event) => {
|
|
333
|
+
if (gen !== this._gen)
|
|
334
|
+
return;
|
|
335
|
+
this._setError(this._normalizeError(event));
|
|
336
|
+
this._finishUtterance(started);
|
|
337
|
+
};
|
|
338
|
+
this._setError(null);
|
|
339
|
+
this._queued++;
|
|
340
|
+
this._setPending(true);
|
|
341
|
+
synth.speak(utterance);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Clear the queue and stop the current utterance immediately. Resets all
|
|
345
|
+
* progress state synchronously and invalidates in-flight utterance callbacks
|
|
346
|
+
* (the browser fires a "canceled" error per utterance) so they do not surface
|
|
347
|
+
* as real errors.
|
|
348
|
+
*/
|
|
349
|
+
cancel() {
|
|
350
|
+
if (!this._hasApi())
|
|
351
|
+
return;
|
|
352
|
+
// Neutralize every in-flight utterance's pending callbacks before triggering
|
|
353
|
+
// the native cancel (which fires "canceled" onerror/onend on each).
|
|
354
|
+
this._gen++;
|
|
355
|
+
// Chrome quirk: cancelling while the engine is paused can leave the synth in
|
|
356
|
+
// a state where the *next* speak() produces no audio. Resume first so cancel
|
|
357
|
+
// happens from a running state. resume() on an idle/non-paused engine is a
|
|
358
|
+
// harmless no-op, so guarding on the tracked `_paused` flag is sufficient.
|
|
359
|
+
if (this._paused) {
|
|
360
|
+
window.speechSynthesis.resume();
|
|
361
|
+
}
|
|
362
|
+
window.speechSynthesis.cancel();
|
|
363
|
+
this._queued = 0;
|
|
364
|
+
this._started = 0;
|
|
365
|
+
this._setSpeaking(false);
|
|
366
|
+
this._setPending(false);
|
|
367
|
+
this._setPaused(false);
|
|
368
|
+
this._setBoundary(null, null);
|
|
369
|
+
}
|
|
370
|
+
pause() {
|
|
371
|
+
if (!this._hasApi())
|
|
372
|
+
return;
|
|
373
|
+
window.speechSynthesis.pause();
|
|
374
|
+
}
|
|
375
|
+
resume() {
|
|
376
|
+
if (!this._hasApi())
|
|
377
|
+
return;
|
|
378
|
+
window.speechSynthesis.resume();
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Re-establish the voiceschanged subscription after a dispose() — e.g. the
|
|
382
|
+
* Shell element was disconnected and then reconnected (reparented). No-op while
|
|
383
|
+
* a subscription is already live, so the first connect after construction does
|
|
384
|
+
* not double-subscribe.
|
|
385
|
+
*/
|
|
386
|
+
reinitVoices() {
|
|
387
|
+
if (!this._voicesSubscribed) {
|
|
388
|
+
this._initVoices();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Detach the live voiceschanged listener and neutralize any in-flight
|
|
393
|
+
* utterance callbacks. Call from the Shell's `disconnectedCallback`.
|
|
394
|
+
*/
|
|
395
|
+
dispose() {
|
|
396
|
+
this._voicesSubscribed = false;
|
|
397
|
+
this._gen++;
|
|
398
|
+
// Reset the queue bookkeeping silently (no dispatch on a disposed element);
|
|
399
|
+
// a reconnect starts fresh. The observable snapshot (error / charIndex /
|
|
400
|
+
// spokenWord) is intentionally *kept* so a reparented element preserves its
|
|
401
|
+
// last state, mirroring GeolocationCore.dispose(). The next speak() resets
|
|
402
|
+
// error / boundary for its own lifecycle.
|
|
403
|
+
this._queued = 0;
|
|
404
|
+
this._started = 0;
|
|
405
|
+
this._speaking = false;
|
|
406
|
+
this._paused = false;
|
|
407
|
+
this._pending = false;
|
|
408
|
+
if (this._hasApi()) {
|
|
409
|
+
window.speechSynthesis.removeEventListener("voiceschanged", this._onVoicesChanged);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// --- Internal ---
|
|
413
|
+
// `started` is the per-utterance flag set by its onstart. An utterance that
|
|
414
|
+
// ended/errored after starting releases a `_started` slot; one that never
|
|
415
|
+
// started (terminal event before onstart) releases its `_queued` slot instead,
|
|
416
|
+
// so `pending` correctly returns to false.
|
|
417
|
+
_finishUtterance(started) {
|
|
418
|
+
if (started) {
|
|
419
|
+
this._started = Math.max(0, this._started - 1);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
this._queued = Math.max(0, this._queued - 1);
|
|
423
|
+
}
|
|
424
|
+
this._setSpeaking(this._started > 0);
|
|
425
|
+
this._setPending(this._queued > 0);
|
|
426
|
+
if (this._started === 0 && this._queued === 0) {
|
|
427
|
+
this._setPaused(false);
|
|
428
|
+
this._setBoundary(null, null);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
_hasApi() {
|
|
432
|
+
return typeof window !== "undefined"
|
|
433
|
+
&& !!window.speechSynthesis
|
|
434
|
+
&& typeof window.SpeechSynthesisUtterance === "function";
|
|
435
|
+
}
|
|
436
|
+
_initVoices() {
|
|
437
|
+
if (!this._hasApi())
|
|
438
|
+
return;
|
|
439
|
+
this._voicesSubscribed = true;
|
|
440
|
+
this._loadVoices();
|
|
441
|
+
window.speechSynthesis.addEventListener("voiceschanged", this._onVoicesChanged);
|
|
442
|
+
}
|
|
443
|
+
_onVoicesChanged = () => {
|
|
444
|
+
this._loadVoices();
|
|
445
|
+
};
|
|
446
|
+
_loadVoices() {
|
|
447
|
+
const raw = window.speechSynthesis.getVoices() ?? [];
|
|
448
|
+
this._rawVoices = raw;
|
|
449
|
+
this._setVoices(raw.map((v) => this._normalizeVoice(v)));
|
|
450
|
+
}
|
|
451
|
+
_normalizeVoice(voice) {
|
|
452
|
+
return {
|
|
453
|
+
name: voice.name,
|
|
454
|
+
lang: voice.lang,
|
|
455
|
+
default: voice.default,
|
|
456
|
+
localService: voice.localService,
|
|
457
|
+
voiceURI: voice.voiceURI,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
_normalizeError(event) {
|
|
461
|
+
const error = event.error ?? "synthesis-failed";
|
|
462
|
+
return { error, message: `Speech synthesis failed: ${error}.` };
|
|
463
|
+
}
|
|
464
|
+
_unsupportedError() {
|
|
465
|
+
return { error: "unsupported", message: "SpeechSynthesis API is not available in this environment." };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let registered$1 = false;
|
|
470
|
+
function handleClick$1(event) {
|
|
471
|
+
const target = event.target;
|
|
472
|
+
if (!(target instanceof Element))
|
|
473
|
+
return;
|
|
474
|
+
// A misconfigured triggerAttribute (e.g. one with a space) makes the attribute
|
|
475
|
+
// selector invalid and closest() throw SyntaxError; guard so a bad config
|
|
476
|
+
// disables only this shortcut rather than killing every document click handler.
|
|
477
|
+
let triggerElement;
|
|
478
|
+
try {
|
|
479
|
+
triggerElement = target.closest(`[${config.triggerAttribute}]`);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!triggerElement)
|
|
485
|
+
return;
|
|
486
|
+
const speakId = triggerElement.getAttribute(config.triggerAttribute);
|
|
487
|
+
if (!speakId)
|
|
488
|
+
return;
|
|
489
|
+
// Resolve the registered constructor at call time instead of importing Speak as
|
|
490
|
+
// a value, avoiding a components/Speak.ts ⇄ autoTrigger.ts cycle
|
|
491
|
+
// (Speak.connectedCallback() calls registerAutoTrigger()). instanceof against
|
|
492
|
+
// the customElements registry keeps the same identity guarantee.
|
|
493
|
+
const SpeakCtor = customElements.get(config.tagNames.speak);
|
|
494
|
+
const speakElement = document.getElementById(speakId);
|
|
495
|
+
if (!SpeakCtor || !(speakElement instanceof SpeakCtor))
|
|
496
|
+
return;
|
|
497
|
+
// The text to speak comes from the trigger element: an explicit `data-speaktext`
|
|
498
|
+
// attribute wins, otherwise the element's text content. This keeps the
|
|
499
|
+
// click-driven shortcut declarative without inventing a payload channel.
|
|
500
|
+
const explicit = triggerElement.getAttribute("data-speaktext");
|
|
501
|
+
// textContent is always a string for an Element; the cast avoids an
|
|
502
|
+
// unreachable null-coalesce branch. speak() tolerates a non-string anyway.
|
|
503
|
+
// The textContent fallback is trimmed (HTML indentation otherwise leaks leading
|
|
504
|
+
// / trailing whitespace into the utterance); an explicit data-speaktext is kept
|
|
505
|
+
// verbatim so an author can deliberately include surrounding spaces.
|
|
506
|
+
const text = explicit !== null ? explicit : triggerElement.textContent.trim();
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
speakElement.speak(text);
|
|
509
|
+
}
|
|
510
|
+
function registerAutoTrigger() {
|
|
511
|
+
if (registered$1)
|
|
512
|
+
return;
|
|
513
|
+
registered$1 = true;
|
|
514
|
+
document.addEventListener("click", handleClick$1);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* `<wcs-speak>` — declarative text-to-speech. Wraps SpeakCore and exposes:
|
|
519
|
+
*
|
|
520
|
+
* - **`say`** (reactive input): writing a value speaks it, suppressing same-value
|
|
521
|
+
* writes so it fires only when the bound source actually changes. The
|
|
522
|
+
* imperative `speak` command instead speaks on demand (even the same text
|
|
523
|
+
* again). See `docs/speech-tag-design.md` § 5.
|
|
524
|
+
* - per-utterance parameters (`rate` / `pitch` / `volume` / `voice` / `lang`) as
|
|
525
|
+
* mirrored attributes.
|
|
526
|
+
* - the Core's observable surface (voices / speaking / paused / pending /
|
|
527
|
+
* charIndex / spokenWord / error / unsupported) via delegated getters.
|
|
528
|
+
*/
|
|
529
|
+
class WcsSpeak extends HTMLElement {
|
|
530
|
+
static wcBindable = {
|
|
531
|
+
...SpeakCore.wcBindable,
|
|
532
|
+
// Shell-level settable surface. `say` is a momentary reactive command-property
|
|
533
|
+
// with no mirrored attribute (it carries dynamic text, not declarative config),
|
|
534
|
+
// mirroring how <wcs-geo>'s `trigger` has no attribute. The rest mirror their
|
|
535
|
+
// HTML attributes idempotently.
|
|
536
|
+
inputs: [
|
|
537
|
+
{ name: "say" },
|
|
538
|
+
{ name: "rate", attribute: "rate" },
|
|
539
|
+
{ name: "pitch", attribute: "pitch" },
|
|
540
|
+
{ name: "volume", attribute: "volume" },
|
|
541
|
+
{ name: "voice", attribute: "voice" },
|
|
542
|
+
{ name: "lang", attribute: "lang" },
|
|
543
|
+
{ name: "manual", attribute: "manual" },
|
|
544
|
+
],
|
|
545
|
+
commands: SpeakCore.wcBindable.commands,
|
|
546
|
+
};
|
|
547
|
+
_core;
|
|
548
|
+
_say = "";
|
|
549
|
+
constructor() {
|
|
550
|
+
super();
|
|
551
|
+
this._core = new SpeakCore(this);
|
|
552
|
+
}
|
|
553
|
+
// --- Attribute accessors ---
|
|
554
|
+
get rate() {
|
|
555
|
+
return this._numberAttr("rate", 1);
|
|
556
|
+
}
|
|
557
|
+
set rate(value) {
|
|
558
|
+
this.setAttribute("rate", String(value));
|
|
559
|
+
}
|
|
560
|
+
get pitch() {
|
|
561
|
+
return this._numberAttr("pitch", 1);
|
|
562
|
+
}
|
|
563
|
+
set pitch(value) {
|
|
564
|
+
this.setAttribute("pitch", String(value));
|
|
565
|
+
}
|
|
566
|
+
get volume() {
|
|
567
|
+
return this._numberAttr("volume", 1);
|
|
568
|
+
}
|
|
569
|
+
set volume(value) {
|
|
570
|
+
this.setAttribute("volume", String(value));
|
|
571
|
+
}
|
|
572
|
+
get voice() {
|
|
573
|
+
return this.getAttribute("voice") ?? "";
|
|
574
|
+
}
|
|
575
|
+
set voice(value) {
|
|
576
|
+
if (value == null) {
|
|
577
|
+
this.removeAttribute("voice");
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
this.setAttribute("voice", String(value));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
get lang() {
|
|
584
|
+
return this.getAttribute("lang") ?? "";
|
|
585
|
+
}
|
|
586
|
+
set lang(value) {
|
|
587
|
+
if (value == null) {
|
|
588
|
+
this.removeAttribute("lang");
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
this.setAttribute("lang", String(value));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
get manual() {
|
|
595
|
+
return this.hasAttribute("manual");
|
|
596
|
+
}
|
|
597
|
+
set manual(value) {
|
|
598
|
+
if (value) {
|
|
599
|
+
this.setAttribute("manual", "");
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
this.removeAttribute("manual");
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// --- Reactive command-property ---
|
|
606
|
+
get say() {
|
|
607
|
+
return this._say;
|
|
608
|
+
}
|
|
609
|
+
set say(value) {
|
|
610
|
+
// Reactive: writing a new value speaks it. `manual` mutes the path entirely
|
|
611
|
+
// (the imperative `speak` command still works) — both an opt-out and the hook
|
|
612
|
+
// used to avoid a recognition echo loop while listening. A conforming binder
|
|
613
|
+
// never delivers `undefined` (it skips the write), but a direct assignment
|
|
614
|
+
// can, so normalize null/undefined to a no-op.
|
|
615
|
+
//
|
|
616
|
+
// ECHO-LOOP WARNING: when wiring <wcs-listen> → state → `say`, the synthesized
|
|
617
|
+
// audio will be re-recognized unless speech is muted while listening. There is
|
|
618
|
+
// no code-level interlock here (the two tags are decoupled): the consumer MUST
|
|
619
|
+
// wire it — bind `manual` to the listening flag (or gate the bound source).
|
|
620
|
+
// See README "Echo loop" and the speech-echo example.
|
|
621
|
+
if (value == null)
|
|
622
|
+
return;
|
|
623
|
+
if (this.manual)
|
|
624
|
+
return;
|
|
625
|
+
const v = String(value);
|
|
626
|
+
// Same-value guard: only speak when the bound source actually changes. For
|
|
627
|
+
// "speak the same text again on demand", use the `speak` command instead.
|
|
628
|
+
if (v === this._say)
|
|
629
|
+
return;
|
|
630
|
+
this._say = v;
|
|
631
|
+
this.speak(v);
|
|
632
|
+
}
|
|
633
|
+
// --- Core delegated getters ---
|
|
634
|
+
get voices() {
|
|
635
|
+
return this._core.voices;
|
|
636
|
+
}
|
|
637
|
+
get speaking() {
|
|
638
|
+
return this._core.speaking;
|
|
639
|
+
}
|
|
640
|
+
get paused() {
|
|
641
|
+
return this._core.paused;
|
|
642
|
+
}
|
|
643
|
+
get pending() {
|
|
644
|
+
return this._core.pending;
|
|
645
|
+
}
|
|
646
|
+
get charIndex() {
|
|
647
|
+
return this._core.charIndex;
|
|
648
|
+
}
|
|
649
|
+
get spokenWord() {
|
|
650
|
+
return this._core.spokenWord;
|
|
651
|
+
}
|
|
652
|
+
get error() {
|
|
653
|
+
return this._core.error;
|
|
654
|
+
}
|
|
655
|
+
get unsupported() {
|
|
656
|
+
return this._core.unsupported;
|
|
657
|
+
}
|
|
658
|
+
// --- Commands ---
|
|
659
|
+
speak(text) {
|
|
660
|
+
this._core.speak(text, this._options());
|
|
661
|
+
}
|
|
662
|
+
cancel() {
|
|
663
|
+
this._core.cancel();
|
|
664
|
+
}
|
|
665
|
+
pause() {
|
|
666
|
+
this._core.pause();
|
|
667
|
+
}
|
|
668
|
+
resume() {
|
|
669
|
+
this._core.resume();
|
|
670
|
+
}
|
|
671
|
+
// --- Internal ---
|
|
672
|
+
_numberAttr(name, fallback) {
|
|
673
|
+
const attr = this.getAttribute(name);
|
|
674
|
+
if (attr === null || attr.trim() === "")
|
|
675
|
+
return fallback;
|
|
676
|
+
// Strict parse via Number() (unlike parseInt, "1px" -> NaN, not 1). Fall back
|
|
677
|
+
// to the API default for any non-finite value, matching the geolocation
|
|
678
|
+
// "invalid values fall back to default" convention.
|
|
679
|
+
const parsed = Number(attr);
|
|
680
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
681
|
+
}
|
|
682
|
+
_options() {
|
|
683
|
+
return {
|
|
684
|
+
rate: this.rate,
|
|
685
|
+
pitch: this.pitch,
|
|
686
|
+
volume: this.volume,
|
|
687
|
+
voice: this.voice,
|
|
688
|
+
lang: this.lang,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
// --- Lifecycle ---
|
|
692
|
+
connectedCallback() {
|
|
693
|
+
this.style.display = "none";
|
|
694
|
+
if (config.autoTrigger) {
|
|
695
|
+
registerAutoTrigger();
|
|
696
|
+
}
|
|
697
|
+
// Revive the voiceschanged subscription after a reconnect (reparenting).
|
|
698
|
+
// No-op on the first connect since the constructor already subscribed.
|
|
699
|
+
this._core.reinitVoices();
|
|
700
|
+
}
|
|
701
|
+
disconnectedCallback() {
|
|
702
|
+
// Detach event subscriptions and neutralize in-flight utterance callbacks.
|
|
703
|
+
// Any utterance already speaking finishes naturally (SpeechSynthesis is a
|
|
704
|
+
// global singleton; cancelling here would stop other <wcs-speak> elements
|
|
705
|
+
// too). Call `cancel()` explicitly to stop audio.
|
|
706
|
+
this._core.dispose();
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Headless speech-to-text primitive. A thin, framework-agnostic wrapper around
|
|
712
|
+
* the SpeechRecognition API (vendor-prefixed `webkitSpeechRecognition` in
|
|
713
|
+
* Chrome) exposed through the wc-bindable protocol.
|
|
714
|
+
*
|
|
715
|
+
* It is the "event" half of the speech package (the synthesis half is
|
|
716
|
+
* SpeakCore): recognition results flow element → state.
|
|
717
|
+
*
|
|
718
|
+
* Two phases mirror geolocation:
|
|
719
|
+
* - **one-shot** (`continuous = false`) — recognize until the first `end`.
|
|
720
|
+
* - **continuous** (`continuous = true`) — keep a single session open across
|
|
721
|
+
* phrases. The browser still ends a session on silence; auto-restart bridges
|
|
722
|
+
* that gap **but is opt-in via `maxRestarts`**: with the default `maxRestarts
|
|
723
|
+
* = 0` a continuous session is *not* restarted on `end` (the safe default —
|
|
724
|
+
* unbounded restart is the infinite-loop risk we guard against). Set
|
|
725
|
+
* `maxRestarts > 0` to bridge N silences. The cap also stops a persistent
|
|
726
|
+
* failure (e.g. `not-allowed`) from spinning forever or exhausting quota; a
|
|
727
|
+
* real result resets the budget so only consecutive empty restarts count.
|
|
728
|
+
*
|
|
729
|
+
* A microphone permission gate (like geolocation's) reflects
|
|
730
|
+
* `navigator.permissions.query({ name: "microphone" })`. Failures never throw —
|
|
731
|
+
* they surface through the `error` property.
|
|
732
|
+
*/
|
|
733
|
+
class ListenCore extends EventTarget {
|
|
734
|
+
static wcBindable = {
|
|
735
|
+
protocol: "wc-bindable",
|
|
736
|
+
version: 1,
|
|
737
|
+
properties: [
|
|
738
|
+
{ name: "interimTranscript", event: "wcs-listen:interim-changed" },
|
|
739
|
+
{ name: "finalTranscript", event: "wcs-listen:final-changed" },
|
|
740
|
+
{ name: "result", event: "wcs-listen:result" },
|
|
741
|
+
{ name: "listening", event: "wcs-listen:listening-changed" },
|
|
742
|
+
{ name: "permission", event: "wcs-listen:permission-changed" },
|
|
743
|
+
{ name: "error", event: "wcs-listen:error" },
|
|
744
|
+
{ name: "unsupported", event: "wcs-listen:unsupported-changed" },
|
|
745
|
+
],
|
|
746
|
+
commands: [
|
|
747
|
+
{ name: "start" },
|
|
748
|
+
{ name: "stop" },
|
|
749
|
+
{ name: "abort" },
|
|
750
|
+
],
|
|
751
|
+
};
|
|
752
|
+
_target;
|
|
753
|
+
_recognition = null;
|
|
754
|
+
_interimTranscript = "";
|
|
755
|
+
_finalTranscript = "";
|
|
756
|
+
_result = null;
|
|
757
|
+
_listening = false;
|
|
758
|
+
_permission = "prompt";
|
|
759
|
+
_error = null;
|
|
760
|
+
_unsupported = false;
|
|
761
|
+
// Intent flag: true between start() and stop()/abort()/terminal-error. Gates
|
|
762
|
+
// the auto-restart loop so a session that ended because the user stopped it
|
|
763
|
+
// does not restart.
|
|
764
|
+
_active = false;
|
|
765
|
+
_continuous = false;
|
|
766
|
+
_maxRestarts = 0;
|
|
767
|
+
_restartCount = 0;
|
|
768
|
+
// Permission tracking — same machinery as GeolocationCore.
|
|
769
|
+
_permissionStatus = null;
|
|
770
|
+
_permissionSubscribed = false;
|
|
771
|
+
_permGen = 0;
|
|
772
|
+
constructor(target) {
|
|
773
|
+
super();
|
|
774
|
+
this._target = target ?? this;
|
|
775
|
+
const Ctor = this._getCtor();
|
|
776
|
+
this._setUnsupported(!Ctor);
|
|
777
|
+
if (Ctor) {
|
|
778
|
+
this._recognition = new Ctor();
|
|
779
|
+
this._attachHandlers(this._recognition);
|
|
780
|
+
}
|
|
781
|
+
this._initPermission();
|
|
782
|
+
}
|
|
783
|
+
get interimTranscript() {
|
|
784
|
+
return this._interimTranscript;
|
|
785
|
+
}
|
|
786
|
+
get finalTranscript() {
|
|
787
|
+
return this._finalTranscript;
|
|
788
|
+
}
|
|
789
|
+
get result() {
|
|
790
|
+
return this._result;
|
|
791
|
+
}
|
|
792
|
+
get listening() {
|
|
793
|
+
return this._listening;
|
|
794
|
+
}
|
|
795
|
+
get permission() {
|
|
796
|
+
return this._permission;
|
|
797
|
+
}
|
|
798
|
+
get error() {
|
|
799
|
+
return this._error;
|
|
800
|
+
}
|
|
801
|
+
// Resolved once in the constructor (`_setUnsupported(!Ctor)`) and never
|
|
802
|
+
// re-evaluated: the SpeechRecognition API's presence is immutable for the
|
|
803
|
+
// lifetime of a document, so there's nothing to re-check.
|
|
804
|
+
get unsupported() {
|
|
805
|
+
return this._unsupported;
|
|
806
|
+
}
|
|
807
|
+
// --- State setters with event dispatch ---
|
|
808
|
+
_setInterim(value) {
|
|
809
|
+
if (this._interimTranscript === value)
|
|
810
|
+
return;
|
|
811
|
+
this._interimTranscript = value;
|
|
812
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:interim-changed", { detail: value, bubbles: true }));
|
|
813
|
+
}
|
|
814
|
+
_setFinal(value) {
|
|
815
|
+
if (this._finalTranscript === value)
|
|
816
|
+
return;
|
|
817
|
+
this._finalTranscript = value;
|
|
818
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:final-changed", { detail: value, bubbles: true }));
|
|
819
|
+
}
|
|
820
|
+
_setResult(value) {
|
|
821
|
+
this._result = value;
|
|
822
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:result", { detail: value, bubbles: true }));
|
|
823
|
+
}
|
|
824
|
+
_setListening(value) {
|
|
825
|
+
if (this._listening === value)
|
|
826
|
+
return;
|
|
827
|
+
this._listening = value;
|
|
828
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:listening-changed", { detail: value, bubbles: true }));
|
|
829
|
+
}
|
|
830
|
+
_setPermission(value) {
|
|
831
|
+
if (this._permission === value)
|
|
832
|
+
return;
|
|
833
|
+
this._permission = value;
|
|
834
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:permission-changed", { detail: value, bubbles: true }));
|
|
835
|
+
}
|
|
836
|
+
_setError(value) {
|
|
837
|
+
if (this._error === value)
|
|
838
|
+
return;
|
|
839
|
+
this._error = value;
|
|
840
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:error", { detail: value, bubbles: true }));
|
|
841
|
+
}
|
|
842
|
+
_setUnsupported(value) {
|
|
843
|
+
if (this._unsupported === value)
|
|
844
|
+
return;
|
|
845
|
+
this._unsupported = value;
|
|
846
|
+
this._target.dispatchEvent(new CustomEvent("wcs-listen:unsupported-changed", { detail: value, bubbles: true }));
|
|
847
|
+
}
|
|
848
|
+
// --- Public API ---
|
|
849
|
+
/**
|
|
850
|
+
* Begin a recognition session. Resets the transcripts (a fresh, user-initiated
|
|
851
|
+
* listen), applies options, and starts. Idempotent while already listening: a
|
|
852
|
+
* redundant start() is ignored so the browser does not throw "recognition has
|
|
853
|
+
* already started".
|
|
854
|
+
*/
|
|
855
|
+
start(options = {}) {
|
|
856
|
+
if (!this._recognition) {
|
|
857
|
+
this._setError(this._unsupportedError());
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (this._active)
|
|
861
|
+
return;
|
|
862
|
+
this._continuous = options.continuous ?? false;
|
|
863
|
+
// `maxRestarts` is a restart *count*, so floor any fractional input to an
|
|
864
|
+
// integer (e.g. 2.5 → 2). `_restartCount` increments by 1, so a fractional
|
|
865
|
+
// cap would otherwise compare inconsistently. Non-finite/negative → 0.
|
|
866
|
+
this._maxRestarts = typeof options.maxRestarts === "number" && options.maxRestarts >= 0
|
|
867
|
+
? Math.floor(options.maxRestarts)
|
|
868
|
+
: 0;
|
|
869
|
+
this._restartCount = 0;
|
|
870
|
+
this._recognition.lang = options.lang ?? "";
|
|
871
|
+
this._recognition.continuous = this._continuous;
|
|
872
|
+
this._recognition.interimResults = options.interimResults ?? false;
|
|
873
|
+
if (typeof options.maxAlternatives === "number") {
|
|
874
|
+
this._recognition.maxAlternatives = options.maxAlternatives;
|
|
875
|
+
}
|
|
876
|
+
// Fresh session: clear prior transcripts and error.
|
|
877
|
+
this._setInterim("");
|
|
878
|
+
this._setFinal("");
|
|
879
|
+
this._setError(null);
|
|
880
|
+
this._active = true;
|
|
881
|
+
this._safeStart();
|
|
882
|
+
}
|
|
883
|
+
stop() {
|
|
884
|
+
if (!this._recognition)
|
|
885
|
+
return;
|
|
886
|
+
// Clear intent first so the end handler does not auto-restart.
|
|
887
|
+
this._active = false;
|
|
888
|
+
this._recognition.stop();
|
|
889
|
+
}
|
|
890
|
+
abort() {
|
|
891
|
+
if (!this._recognition)
|
|
892
|
+
return;
|
|
893
|
+
this._active = false;
|
|
894
|
+
this._recognition.abort();
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Re-establish the permission `change` subscription after a dispose().
|
|
898
|
+
*/
|
|
899
|
+
reinitPermission() {
|
|
900
|
+
if (!this._permissionSubscribed) {
|
|
901
|
+
this._initPermission();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Stop recognition and detach the live permission listener. Call from the
|
|
906
|
+
* Shell's `disconnectedCallback`.
|
|
907
|
+
*/
|
|
908
|
+
dispose() {
|
|
909
|
+
// Only the live subscriptions and the listening shadow are reset here. The
|
|
910
|
+
// observable snapshot (transcripts / result / error) is intentionally *kept*
|
|
911
|
+
// so a reparented element preserves its last state, mirroring how
|
|
912
|
+
// GeolocationCore.dispose() leaves `position` / `error` intact. The next
|
|
913
|
+
// start() clears the transcripts and error for its fresh session anyway.
|
|
914
|
+
this._active = false;
|
|
915
|
+
this._permissionSubscribed = false;
|
|
916
|
+
this._permGen++;
|
|
917
|
+
if (this._recognition) {
|
|
918
|
+
// abort() is the immediate teardown; guard against environments where it
|
|
919
|
+
// throws on an idle recognizer.
|
|
920
|
+
try {
|
|
921
|
+
this._recognition.abort();
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
// ignore — teardown is best-effort.
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// Reset the listening shadow silently (no dispatch on a disposed element),
|
|
928
|
+
// mirroring GeolocationCore's `_loading` reset. The abort() above neutralizes
|
|
929
|
+
// the recognizer but its `end` (which would clear listening via the setter)
|
|
930
|
+
// may not have fired yet; forcing false here means a reconnect+start's
|
|
931
|
+
// `onstart` still transitions false→true through the same-value guard, so the
|
|
932
|
+
// state never desyncs to a stale `true`.
|
|
933
|
+
this._listening = false;
|
|
934
|
+
if (this._permissionStatus) {
|
|
935
|
+
this._permissionStatus.removeEventListener("change", this._onPermissionChange);
|
|
936
|
+
this._permissionStatus = null;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// --- Internal: recognition lifecycle ---
|
|
940
|
+
_attachHandlers(recognition) {
|
|
941
|
+
recognition.onstart = () => {
|
|
942
|
+
this._setListening(true);
|
|
943
|
+
};
|
|
944
|
+
recognition.onresult = (event) => {
|
|
945
|
+
try {
|
|
946
|
+
this._handleResult(event);
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
// A malformed result event must not escape the browser callback.
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
recognition.onerror = (event) => {
|
|
953
|
+
this._setError(this._normalizeError(event));
|
|
954
|
+
// Terminal errors must not be retried — they would spin the restart loop.
|
|
955
|
+
// The set is deliberately limited to the permission-class errors that can
|
|
956
|
+
// never self-recover within a session. Transient failures
|
|
957
|
+
// (`network` / `audio-capture` / `no-speech`) are intentionally *not*
|
|
958
|
+
// terminal: they are recoverable, so a continuous session restarts through
|
|
959
|
+
// them, bounded by `maxRestarts` (the cap is the guard against a persistent
|
|
960
|
+
// transient failure spinning forever).
|
|
961
|
+
if (event && (event.error === "not-allowed" || event.error === "service-not-allowed")) {
|
|
962
|
+
this._active = false;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
recognition.onend = () => {
|
|
966
|
+
if (this._active && this._continuous && this._restartCount < this._maxRestarts) {
|
|
967
|
+
// Auto-restart bridges a silence-induced `end`. Keep `listening` true
|
|
968
|
+
// across the gap rather than flickering true→false→true: from the
|
|
969
|
+
// consumer's perspective the continuous session never stopped. The
|
|
970
|
+
// immediately-following start()'s `onstart` re-sets true (same-value
|
|
971
|
+
// guarded → no-op), so the flag stays steady. A genuine stop (no
|
|
972
|
+
// restart) still drops to false below.
|
|
973
|
+
this._restartCount++;
|
|
974
|
+
this._safeStart();
|
|
975
|
+
// _safeStart() clears _active if the restart threw; in that case the
|
|
976
|
+
// session is over, so reflect listening=false rather than leaving it
|
|
977
|
+
// stuck true.
|
|
978
|
+
if (!this._active)
|
|
979
|
+
this._setListening(false);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
// No restart: the session is fully over.
|
|
983
|
+
this._setListening(false);
|
|
984
|
+
this._active = false;
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
_handleResult(event) {
|
|
988
|
+
const results = event.results;
|
|
989
|
+
let interim = "";
|
|
990
|
+
let finalChunk = "";
|
|
991
|
+
// Per the Web Speech spec, `resultIndex` is the lowest index in `results`
|
|
992
|
+
// that changed in this event, so we only fold in `[resultIndex, length)` and
|
|
993
|
+
// accumulate finals (`this._finalTranscript + finalChunk`). This assumes the
|
|
994
|
+
// engine advances `resultIndex` past already-finalized results. A nonconforming
|
|
995
|
+
// engine that omits `resultIndex` (`?? 0`) or re-reports finalized results at
|
|
996
|
+
// index 0 on every event could double-accumulate the same final chunk; standard
|
|
997
|
+
// browser engines don't, so this is not hardened against here.
|
|
998
|
+
for (let i = event.resultIndex ?? 0; i < results.length; i++) {
|
|
999
|
+
const res = results[i];
|
|
1000
|
+
const transcript = res?.[0]?.transcript ?? "";
|
|
1001
|
+
if (res?.isFinal) {
|
|
1002
|
+
finalChunk += transcript;
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
interim += transcript;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (finalChunk !== "") {
|
|
1009
|
+
this._setFinal(this._finalTranscript + finalChunk);
|
|
1010
|
+
}
|
|
1011
|
+
this._setInterim(interim);
|
|
1012
|
+
// Any result is progress — reset the restart budget so only *consecutive*
|
|
1013
|
+
// empty restarts count toward the cap.
|
|
1014
|
+
this._restartCount = 0;
|
|
1015
|
+
const last = results[results.length - 1];
|
|
1016
|
+
if (last) {
|
|
1017
|
+
this._setResult(this._normalizeResult(last));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
_normalizeResult(result) {
|
|
1021
|
+
const alternatives = [];
|
|
1022
|
+
for (let i = 0; i < result.length; i++) {
|
|
1023
|
+
alternatives.push({
|
|
1024
|
+
transcript: result[i]?.transcript ?? "",
|
|
1025
|
+
confidence: result[i]?.confidence ?? 0,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
const top = alternatives[0] ?? { transcript: "", confidence: 0 };
|
|
1029
|
+
return {
|
|
1030
|
+
transcript: top.transcript,
|
|
1031
|
+
confidence: top.confidence,
|
|
1032
|
+
isFinal: !!result.isFinal,
|
|
1033
|
+
alternatives,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
_safeStart() {
|
|
1037
|
+
try {
|
|
1038
|
+
this._recognition.start();
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
// start() throws if already started; surface nothing — the live session
|
|
1042
|
+
// continues. Reset intent so state stays consistent.
|
|
1043
|
+
this._active = false;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
// --- Internal: feature detection & permission (mirrors GeolocationCore) ---
|
|
1047
|
+
_getCtor() {
|
|
1048
|
+
// Guard window access without a separate (in-browser unreachable) early
|
|
1049
|
+
// return, mirroring SpeakCore's `_hasApi` style.
|
|
1050
|
+
const w = (typeof window === "undefined" ? undefined : window);
|
|
1051
|
+
return w?.SpeechRecognition ?? w?.webkitSpeechRecognition ?? null;
|
|
1052
|
+
}
|
|
1053
|
+
_initPermission() {
|
|
1054
|
+
if (typeof navigator === "undefined" || !navigator.permissions || typeof navigator.permissions.query !== "function") {
|
|
1055
|
+
this._setPermission("unsupported");
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
this._permissionSubscribed = true;
|
|
1059
|
+
const gen = ++this._permGen;
|
|
1060
|
+
navigator.permissions.query({ name: "microphone" }).then((status) => {
|
|
1061
|
+
if (gen !== this._permGen)
|
|
1062
|
+
return;
|
|
1063
|
+
this._permissionStatus = status;
|
|
1064
|
+
this._setPermission(status.state);
|
|
1065
|
+
status.addEventListener("change", this._onPermissionChange);
|
|
1066
|
+
}, () => {
|
|
1067
|
+
if (gen !== this._permGen)
|
|
1068
|
+
return;
|
|
1069
|
+
this._setPermission("unsupported");
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
_onPermissionChange = (event) => {
|
|
1073
|
+
const status = event.target;
|
|
1074
|
+
this._setPermission(status.state);
|
|
1075
|
+
};
|
|
1076
|
+
_normalizeError(event) {
|
|
1077
|
+
const error = (event && event.error) ? event.error : "aborted";
|
|
1078
|
+
return { error, message: `Speech recognition failed: ${error}.` };
|
|
1079
|
+
}
|
|
1080
|
+
_unsupportedError() {
|
|
1081
|
+
return { error: "unsupported", message: "SpeechRecognition API is not available in this environment." };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let registered = false;
|
|
1086
|
+
function handleClick(event) {
|
|
1087
|
+
const target = event.target;
|
|
1088
|
+
if (!(target instanceof Element))
|
|
1089
|
+
return;
|
|
1090
|
+
// A misconfigured listenTriggerAttribute (e.g. one with a space) makes the
|
|
1091
|
+
// attribute selector invalid and closest() throw SyntaxError; guard so a bad
|
|
1092
|
+
// config disables only this shortcut rather than killing every click handler.
|
|
1093
|
+
let triggerElement;
|
|
1094
|
+
try {
|
|
1095
|
+
triggerElement = target.closest(`[${config.listenTriggerAttribute}]`);
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (!triggerElement)
|
|
1101
|
+
return;
|
|
1102
|
+
const listenId = triggerElement.getAttribute(config.listenTriggerAttribute);
|
|
1103
|
+
if (!listenId)
|
|
1104
|
+
return;
|
|
1105
|
+
const ListenCtor = customElements.get(config.tagNames.listen);
|
|
1106
|
+
const listenElement = document.getElementById(listenId);
|
|
1107
|
+
if (!ListenCtor || !(listenElement instanceof ListenCtor))
|
|
1108
|
+
return;
|
|
1109
|
+
event.preventDefault();
|
|
1110
|
+
// Toggle: clicking starts a session, clicking again while listening stops it.
|
|
1111
|
+
const el = listenElement;
|
|
1112
|
+
if (el.listening) {
|
|
1113
|
+
el.stop();
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
el.start();
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
function registerListenAutoTrigger() {
|
|
1120
|
+
if (registered)
|
|
1121
|
+
return;
|
|
1122
|
+
registered = true;
|
|
1123
|
+
document.addEventListener("click", handleClick);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* `<wcs-listen>` — declarative speech-to-text. Wraps ListenCore and exposes the
|
|
1128
|
+
* recognition surface (interim/final transcripts, structured result, listening
|
|
1129
|
+
* flag, microphone permission, error) plus the two-phase start/stop/abort
|
|
1130
|
+
* commands and a momentary `trigger` for DOM-driven starts.
|
|
1131
|
+
*
|
|
1132
|
+
* Mirrors `<wcs-geo>`: `manual` suppresses the connect-time auto-start, and the
|
|
1133
|
+
* `continuous` attribute selects the auto-restarting session phase.
|
|
1134
|
+
*/
|
|
1135
|
+
class WcsListen extends HTMLElement {
|
|
1136
|
+
static wcBindable = {
|
|
1137
|
+
...ListenCore.wcBindable,
|
|
1138
|
+
properties: [
|
|
1139
|
+
...ListenCore.wcBindable.properties,
|
|
1140
|
+
{ name: "trigger", event: "wcs-listen:trigger-changed" },
|
|
1141
|
+
],
|
|
1142
|
+
inputs: [
|
|
1143
|
+
{ name: "lang", attribute: "lang" },
|
|
1144
|
+
{ name: "continuous", attribute: "continuous" },
|
|
1145
|
+
{ name: "interim", attribute: "interim" },
|
|
1146
|
+
{ name: "maxRestarts", attribute: "max-restarts" },
|
|
1147
|
+
{ name: "manual", attribute: "manual" },
|
|
1148
|
+
{ name: "trigger" },
|
|
1149
|
+
],
|
|
1150
|
+
commands: ListenCore.wcBindable.commands,
|
|
1151
|
+
};
|
|
1152
|
+
_core;
|
|
1153
|
+
_trigger = false;
|
|
1154
|
+
constructor() {
|
|
1155
|
+
super();
|
|
1156
|
+
this._core = new ListenCore(this);
|
|
1157
|
+
}
|
|
1158
|
+
// --- Attribute accessors ---
|
|
1159
|
+
get lang() {
|
|
1160
|
+
return this.getAttribute("lang") ?? "";
|
|
1161
|
+
}
|
|
1162
|
+
set lang(value) {
|
|
1163
|
+
if (value == null) {
|
|
1164
|
+
this.removeAttribute("lang");
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
this.setAttribute("lang", String(value));
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
get continuous() {
|
|
1171
|
+
return this.hasAttribute("continuous");
|
|
1172
|
+
}
|
|
1173
|
+
set continuous(value) {
|
|
1174
|
+
if (value) {
|
|
1175
|
+
this.setAttribute("continuous", "");
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
this.removeAttribute("continuous");
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
get interim() {
|
|
1182
|
+
return this.hasAttribute("interim");
|
|
1183
|
+
}
|
|
1184
|
+
set interim(value) {
|
|
1185
|
+
if (value) {
|
|
1186
|
+
this.setAttribute("interim", "");
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
this.removeAttribute("interim");
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
get maxRestarts() {
|
|
1193
|
+
const attr = this.getAttribute("max-restarts");
|
|
1194
|
+
if (attr === null || attr.trim() === "")
|
|
1195
|
+
return 0;
|
|
1196
|
+
const parsed = Number(attr);
|
|
1197
|
+
// A restart *count* is an integer, so floor fractional input (e.g. 1.9 → 1)
|
|
1198
|
+
// here too, keeping the getter's value identical to the effective cap the
|
|
1199
|
+
// Core applies (ListenCore.start floors it as well). Non-finite/negative → 0.
|
|
1200
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
|
|
1201
|
+
}
|
|
1202
|
+
set maxRestarts(value) {
|
|
1203
|
+
this.setAttribute("max-restarts", String(value));
|
|
1204
|
+
}
|
|
1205
|
+
get manual() {
|
|
1206
|
+
return this.hasAttribute("manual");
|
|
1207
|
+
}
|
|
1208
|
+
set manual(value) {
|
|
1209
|
+
if (value) {
|
|
1210
|
+
this.setAttribute("manual", "");
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
this.removeAttribute("manual");
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// --- Core delegated getters ---
|
|
1217
|
+
get interimTranscript() {
|
|
1218
|
+
return this._core.interimTranscript;
|
|
1219
|
+
}
|
|
1220
|
+
get finalTranscript() {
|
|
1221
|
+
return this._core.finalTranscript;
|
|
1222
|
+
}
|
|
1223
|
+
get result() {
|
|
1224
|
+
return this._core.result;
|
|
1225
|
+
}
|
|
1226
|
+
get listening() {
|
|
1227
|
+
return this._core.listening;
|
|
1228
|
+
}
|
|
1229
|
+
get permission() {
|
|
1230
|
+
return this._core.permission;
|
|
1231
|
+
}
|
|
1232
|
+
get error() {
|
|
1233
|
+
return this._core.error;
|
|
1234
|
+
}
|
|
1235
|
+
get unsupported() {
|
|
1236
|
+
return this._core.unsupported;
|
|
1237
|
+
}
|
|
1238
|
+
// --- Command property ---
|
|
1239
|
+
get trigger() {
|
|
1240
|
+
return this._trigger;
|
|
1241
|
+
}
|
|
1242
|
+
set trigger(value) {
|
|
1243
|
+
// Momentary command-property: a false→true write starts a session. Mirrors
|
|
1244
|
+
// <wcs-geo>'s trigger. Prefer the command-token protocol (`command.start:
|
|
1245
|
+
// $command.listen`) for state-driven starts; this exists for DOM triggers and
|
|
1246
|
+
// simple boolean bindings.
|
|
1247
|
+
const v = !!value;
|
|
1248
|
+
if (v) {
|
|
1249
|
+
this._trigger = true;
|
|
1250
|
+
this.start();
|
|
1251
|
+
this._trigger = false;
|
|
1252
|
+
this.dispatchEvent(new CustomEvent("wcs-listen:trigger-changed", { detail: false, bubbles: true }));
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// --- Commands ---
|
|
1256
|
+
start() {
|
|
1257
|
+
this._core.start(this._options());
|
|
1258
|
+
}
|
|
1259
|
+
stop() {
|
|
1260
|
+
this._core.stop();
|
|
1261
|
+
}
|
|
1262
|
+
abort() {
|
|
1263
|
+
this._core.abort();
|
|
1264
|
+
}
|
|
1265
|
+
// --- Internal ---
|
|
1266
|
+
_options() {
|
|
1267
|
+
return {
|
|
1268
|
+
lang: this.lang,
|
|
1269
|
+
continuous: this.continuous,
|
|
1270
|
+
interimResults: this.interim,
|
|
1271
|
+
maxRestarts: this.maxRestarts,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
// --- Lifecycle ---
|
|
1275
|
+
connectedCallback() {
|
|
1276
|
+
this.style.display = "none";
|
|
1277
|
+
if (config.autoTrigger) {
|
|
1278
|
+
registerListenAutoTrigger();
|
|
1279
|
+
}
|
|
1280
|
+
this._core.reinitPermission();
|
|
1281
|
+
if (!this.manual) {
|
|
1282
|
+
// Non-blocking auto-start, mirroring <wcs-geo>: start() is fired
|
|
1283
|
+
// unconditionally without first awaiting/inspecting the (async) permission
|
|
1284
|
+
// state. A `denied` mic surfaces as a `not-allowed` error via the `error`
|
|
1285
|
+
// property (and stops auto-restart), rather than the connect path silently
|
|
1286
|
+
// suppressing the start. This keeps the permission model declarative and
|
|
1287
|
+
// consistent with geolocation. Use `manual` to require an explicit start.
|
|
1288
|
+
this.start();
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
disconnectedCallback() {
|
|
1292
|
+
this._core.dispose();
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function registerComponents() {
|
|
1297
|
+
if (!customElements.get(config.tagNames.speak)) {
|
|
1298
|
+
customElements.define(config.tagNames.speak, WcsSpeak);
|
|
1299
|
+
}
|
|
1300
|
+
if (!customElements.get(config.tagNames.listen)) {
|
|
1301
|
+
customElements.define(config.tagNames.listen, WcsListen);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function bootstrapSpeech(userConfig) {
|
|
1306
|
+
if (userConfig) {
|
|
1307
|
+
setConfig(userConfig);
|
|
1308
|
+
}
|
|
1309
|
+
registerComponents();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export { ListenCore, SpeakCore, WcsListen, WcsSpeak, bootstrapSpeech, getConfig };
|
|
1313
|
+
//# sourceMappingURL=index.esm.js.map
|