@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.
@@ -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