@fusion.dev/helix-core 0.221.0 → 0.223.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -159,7 +159,7 @@ export { slotFilled, slotNamed } from './utils/slot-filled/slot-filled.js';
159
159
  export { SlottedGlyph } from './utils/slotted-glyph/slotted-glyph.js';
160
160
  export { slottedWords } from './utils/slotted-words/slotted-words.js';
161
161
  export { ChartSound, HIGH, LOW, panOf, pitchOf, type Sound, type SoundSeries, sweepPlan, TIMBRES, } from './utils/sonify/sonify.js';
162
- export { browserSpeech, pageSpeechEngine, registerSpeechEngine, type SpeechEngine, type SpeechListener, type SpeechSession, } from './utils/speech/speech.js';
162
+ export { browserSpeech, pageSpeechEngine, recognizeSpeech, registerSpeechEngine, type SpeechEngine, type SpeechListener, type SpeechSession, } from './utils/speech/speech.js';
163
163
  export { splitValues } from './utils/split-values/split-values.js';
164
164
  export { captureState, type ElementState, elementAt, exposeState, type PageState, pathOf, type RestoreReport, restoreOn, restoreState, STATE_SLOT, snapshotOf, } from './utils/state/state.js';
165
165
  export { stopPress } from './utils/stop-press/stop-press.js';
package/dist/index.js CHANGED
@@ -159,7 +159,7 @@ export { slotFilled, slotNamed } from './utils/slot-filled/slot-filled.js';
159
159
  export { SlottedGlyph } from './utils/slotted-glyph/slotted-glyph.js';
160
160
  export { slottedWords } from './utils/slotted-words/slotted-words.js';
161
161
  export { ChartSound, HIGH, LOW, panOf, pitchOf, sweepPlan, TIMBRES, } from './utils/sonify/sonify.js';
162
- export { browserSpeech, pageSpeechEngine, registerSpeechEngine, } from './utils/speech/speech.js';
162
+ export { browserSpeech, pageSpeechEngine, recognizeSpeech, registerSpeechEngine, } from './utils/speech/speech.js';
163
163
  export { splitValues } from './utils/split-values/split-values.js';
164
164
  export { captureState, elementAt, exposeState, pathOf, restoreOn, restoreState, STATE_SLOT, snapshotOf, } from './utils/state/state.js';
165
165
  export { stopPress } from './utils/stop-press/stop-press.js';
@@ -3,6 +3,9 @@ import type { HelixElement } from '../../helix-element/helix-element.js';
3
3
  import { type SpeechEngine } from '../../utils/speech/speech.js';
4
4
  type Constructor<T> = new (...args: any[]) => T;
5
5
  export declare class VoiceElement {
6
+ dictationDeniedLabel: string;
7
+ dictationFailedLabel: string;
8
+ dictationUnreachableLabel: string;
6
9
  /** Whether the field is listening now. */
7
10
  readonly listening: boolean;
8
11
  listeningLabel: string;
@@ -17,6 +20,8 @@ export declare class VoiceElement {
17
20
  stopListening(): void;
18
21
  /** The dictation button and its status, behind a seam a tier can move. */
19
22
  protected renderVoice(): TemplateResult | typeof nothing;
23
+ /** What the dictation button draws: the microphone, or a level meter while listening. */
24
+ protected renderVoiceGlyph(): TemplateResult;
20
25
  /** The native field dictation types into. */
21
26
  protected voiceField(): HTMLInputElement | HTMLTextAreaElement | null;
22
27
  /** The type of the field dictation types into. Dictation listens only in `text`, `search`, `tel` and `url`. */
@@ -1,7 +1,8 @@
1
1
  import { __decorate } from "tslib";
2
2
  import { microphone } from '@fusion.dev/helix-icons';
3
- import { html, nothing } from 'lit';
3
+ import { nothing } from 'lit';
4
4
  import { property, state } from 'lit/decorators.js';
5
+ import { html } from 'lit/static-html.js';
5
6
  import { HelixBeforeListenEvent, HelixListenEvent } from '../../events/events.js';
6
7
  import { drawIcon } from '../../utils/draw-icon/draw-icon.js';
7
8
  import { pageSpeechEngine, } from '../../utils/speech/speech.js';
@@ -14,6 +15,9 @@ export const MixinVoice = (Base) => {
14
15
  class Voice extends Base {
15
16
  constructor() {
16
17
  super(...arguments);
18
+ this.dictationDeniedLabel = 'Dictation needs the microphone';
19
+ this.dictationFailedLabel = 'Dictation stopped with an error';
20
+ this.dictationUnreachableLabel = 'Dictation could not reach the speech service';
17
21
  this.listeningLabel = 'Listening';
18
22
  this.speechEngine = undefined;
19
23
  this.stoppedListeningLabel = 'Stopped listening';
@@ -89,35 +93,66 @@ export const MixinVoice = (Base) => {
89
93
  this.toggleState('listening', true);
90
94
  field.addEventListener('keydown', this.handleVoiceKey);
91
95
  field.addEventListener('pointerdown', this.handleVoicePointer);
92
- this.session = this.engine.listen({
93
- lang: wordsLocale(this),
94
- listener: {
95
- end: () => this.ended(),
96
- final: (text) => this.place(text, true),
97
- level: (level) => {
98
- this.style.setProperty('--dictation-level', level.toFixed(3));
96
+ let session;
97
+ try {
98
+ session = this.engine.listen({
99
+ lang: wordsLocale(this),
100
+ listener: {
101
+ end: (reason, message) => this.ended(reason, message),
102
+ final: (text) => this.place(text, true),
103
+ level: (level) => {
104
+ this.style.setProperty('--dictation-level', level.toFixed(3));
105
+ const meter = this.partNamed('voice-level');
106
+ if (meter) {
107
+ meter.level = level;
108
+ }
109
+ },
110
+ partial: (text) => this.place(text, false),
99
111
  },
100
- partial: (text) => this.place(text, false),
101
- },
102
- });
112
+ });
113
+ }
114
+ catch {
115
+ this.ended();
116
+ return false;
117
+ }
118
+ if (!this.heard) {
119
+ try {
120
+ session.stop();
121
+ }
122
+ catch {
123
+ }
124
+ return false;
125
+ }
126
+ this.session = session;
103
127
  return true;
104
128
  }
105
129
  stopListening() {
106
130
  this.session?.stop();
107
131
  this.ended();
108
132
  }
109
- ended() {
133
+ ended(reason, message) {
110
134
  if (!this.heard) {
111
135
  return;
112
136
  }
113
137
  this.session = undefined;
114
138
  this.heard = false;
115
- this.said = this.stoppedListeningLabel;
139
+ this.said = reason === 'error' ? this.failure(message) : this.stoppedListeningLabel;
116
140
  this.style.removeProperty('--dictation-level');
117
141
  this.toggleState('listening', false);
118
142
  this.voiceField()?.removeEventListener('keydown', this.handleVoiceKey);
119
143
  this.voiceField()?.removeEventListener('pointerdown', this.handleVoicePointer);
120
144
  }
145
+ failure(message) {
146
+ if (message === 'network') {
147
+ return this.dictationUnreachableLabel;
148
+ }
149
+ if (message === 'not-allowed' ||
150
+ message === 'service-not-allowed' ||
151
+ message === 'audio-capture') {
152
+ return this.dictationDeniedLabel;
153
+ }
154
+ return this.dictationFailedLabel;
155
+ }
121
156
  place(text, settled) {
122
157
  const field = this.voiceField();
123
158
  if (!field || !this.heard) {
@@ -140,6 +175,14 @@ export const MixinVoice = (Base) => {
140
175
  this.dispatchEvent(new HelixListenEvent({ text }));
141
176
  }
142
177
  }
178
+ renderVoiceGlyph() {
179
+ const tier = this.constructor.tier;
180
+ if (this.heard && customElements.get(`${tier}voice-level`)) {
181
+ const meter = this.tag('voice-level');
182
+ return html `<${meter} part="voice-level" exportparts="canvas: voice-level-canvas" aria-hidden="true" variant="spectrum"></${meter}>`;
183
+ }
184
+ return drawIcon(microphone);
185
+ }
143
186
  renderVoice() {
144
187
  if (!this.voice || !this.voiceReady) {
145
188
  return nothing;
@@ -161,11 +204,23 @@ export const MixinVoice = (Base) => {
161
204
  }
162
205
  }}
163
206
  >
164
- ${drawIcon(microphone)}
207
+ ${this.renderVoiceGlyph()}
165
208
  </button
166
209
  ><span part="voice-status" data-spoken role="status">${this.said}</span>`;
167
210
  }
168
211
  }
212
+ __decorate([
213
+ words,
214
+ property({ attribute: 'dictation-denied-label' })
215
+ ], Voice.prototype, "dictationDeniedLabel", void 0);
216
+ __decorate([
217
+ words,
218
+ property({ attribute: 'dictation-failed-label' })
219
+ ], Voice.prototype, "dictationFailedLabel", void 0);
220
+ __decorate([
221
+ words,
222
+ property({ attribute: 'dictation-unreachable-label' })
223
+ ], Voice.prototype, "dictationUnreachableLabel", void 0);
169
224
  __decorate([
170
225
  words,
171
226
  property({ attribute: 'listening-label' })
@@ -32,13 +32,36 @@ export interface SpeechEngine {
32
32
  listener: SpeechListener;
33
33
  }): SpeechSession;
34
34
  }
35
+ /**
36
+ * Listens with the browser's recognizer in this page, or in `scope`, a window
37
+ * of the same origin. A field in a sandboxed frame cannot: its origin is
38
+ * opaque, and the recognizer refuses it.
39
+ *
40
+ * **It recognizes on the device where the language is installed**, and
41
+ * through the browser's service where it is not. A press starts the install
42
+ * where the browser offers one. Chrome's service is Google's, and Chromium browsers without Google's
43
+ * keys, such as Brave, reach no service at all and end with `network`.
44
+ *
45
+ * `meter` reads loudness for the dictation button's level from a second
46
+ * capture of the microphone, opened on the recognizer's `audiostart`. Opened
47
+ * at the same time as the recognizer, that capture ended recognition after the
48
+ * permission prompt. Turn it off with `meter: false`.
49
+ */
50
+ export declare const recognizeSpeech: (lang: string, listener: SpeechListener, options?: {
51
+ meter?: boolean;
52
+ scope?: Window;
53
+ }) => SpeechSession;
35
54
  /**
36
55
  * The browser's own recognizer, `SpeechRecognition`, or `webkitSpeechRecognition`
37
56
  * in Safari. Not available in Firefox.
38
57
  *
39
- * **Chrome sends the audio to Google's servers to recognize it**, and Safari
40
- * to Apple's unless the device recognizes on the device. A page that cannot
41
- * send what is said registers an engine of its own.
58
+ * **It recognizes on the device where the browser can**, and Chrome downloads
59
+ * the language from the first press, which listens through the service. Where it cannot, Chrome sends the audio to
60
+ * Google's servers, and Safari to Apple's. A page that cannot send what is
61
+ * said registers an engine of its own.
62
+ *
63
+ * **A sandboxed frame has an opaque origin, and the recognizer refuses that.**
64
+ * The field asks the page that framed it to listen, and that page has an origin.
42
65
  */
43
66
  export declare const browserSpeech: SpeechEngine;
44
67
  /**
@@ -1,6 +1,6 @@
1
- const recognizer = () => {
2
- const scope = globalThis;
3
- return scope.SpeechRecognition ?? scope.webkitSpeechRecognition;
1
+ const recognizer = (scope = globalThis) => {
2
+ const here = scope;
3
+ return here.SpeechRecognition ?? here.webkitSpeechRecognition;
4
4
  };
5
5
  const meter = (report) => {
6
6
  let stopped = false;
@@ -59,22 +59,75 @@ const meter = (report) => {
59
59
  release();
60
60
  };
61
61
  };
62
- export const browserSpeech = {
63
- available: () => recognizer() !== undefined,
64
- listen: ({ lang, listener }) => {
65
- const Constructor = recognizer();
66
- if (!Constructor) {
67
- listener.end('error', 'This browser has no speech recognition.');
68
- return { stop: () => { } };
69
- }
70
- const recognition = new Constructor();
71
- recognition.continuous = true;
72
- recognition.interimResults = true;
73
- recognition.lang = lang;
74
- let asked = false;
62
+ const installing = new Set();
63
+ const listenLocally = async (Constructor, lang) => {
64
+ const local = Constructor;
65
+ if (!local.available || !local.install) {
66
+ return false;
67
+ }
68
+ const options = { langs: [lang], processLocally: true };
69
+ try {
70
+ const state = await local.available(options);
71
+ if (state === 'available') {
72
+ return true;
73
+ }
74
+ if (state === 'unavailable') {
75
+ return false;
76
+ }
77
+ if (!installing.has(lang)) {
78
+ installing.add(lang);
79
+ void local
80
+ .install(options)
81
+ .catch(() => false)
82
+ .then(() => installing.delete(lang));
83
+ }
84
+ return false;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ };
90
+ export const recognizeSpeech = (lang, listener, options) => {
91
+ const Constructor = recognizer(options?.scope ?? globalThis);
92
+ if (!Constructor) {
93
+ listener.end('error', 'This browser has no speech recognition.');
94
+ return { stop: () => { } };
95
+ }
96
+ let asked = false;
97
+ let ended = false;
98
+ let recognition;
99
+ let quiet = () => { };
100
+ let metering = false;
101
+ const startMeter = () => {
102
+ if (options?.meter === false || metering || ended) {
103
+ return;
104
+ }
105
+ metering = true;
106
+ quiet = meter((level, bands) => listener.level?.(level, bands));
107
+ };
108
+ const end = (reason, message) => {
109
+ if (ended) {
110
+ return;
111
+ }
112
+ ended = true;
113
+ quiet();
114
+ listener.end(reason, message);
115
+ };
116
+ const listen = (locally) => {
117
+ const current = new Constructor();
118
+ recognition = current;
119
+ current.continuous = true;
120
+ current.interimResults = true;
121
+ current.lang = lang;
122
+ if (locally) {
123
+ ;
124
+ current.processLocally = true;
125
+ }
75
126
  let failed;
76
- const quiet = meter((level, bands) => listener.level?.(level, bands));
77
- recognition.onresult = (event) => {
127
+ let heard = false;
128
+ current.onaudiostart = startMeter;
129
+ current.onresult = (event) => {
130
+ heard = true;
78
131
  let guess = '';
79
132
  for (let at = event.resultIndex; at < event.results.length; at += 1) {
80
133
  const result = event.results[at];
@@ -90,21 +143,94 @@ export const browserSpeech = {
90
143
  listener.partial(guess.trim());
91
144
  }
92
145
  };
93
- recognition.onerror = (event) => {
94
- failed = event.error;
146
+ current.onerror = (event) => {
147
+ if (event.error !== 'no-speech' && event.error !== 'aborted') {
148
+ failed = event.error;
149
+ }
95
150
  };
96
- recognition.onend = () => {
97
- quiet();
98
- listener.end(failed && failed !== 'aborted' ? 'error' : asked ? 'stopped' : 'ended', failed);
151
+ current.onend = () => {
152
+ if (locally && !asked && !heard && failed === 'language-not-supported') {
153
+ listen(false);
154
+ return;
155
+ }
156
+ end(failed ? 'error' : asked ? 'stopped' : 'ended', failed);
99
157
  };
100
- recognition.start();
101
- return {
102
- stop: () => {
103
- asked = true;
158
+ try {
159
+ current.start();
160
+ }
161
+ catch (error) {
162
+ end('error', error instanceof Error ? error.message : 'not-allowed');
163
+ }
164
+ };
165
+ void listenLocally(Constructor, lang).then((locally) => {
166
+ if (asked) {
167
+ end('stopped');
168
+ return;
169
+ }
170
+ listen(locally);
171
+ });
172
+ return {
173
+ stop: () => {
174
+ asked = true;
175
+ if (recognition) {
104
176
  recognition.stop();
105
- },
106
- };
107
- },
177
+ }
178
+ },
179
+ };
180
+ };
181
+ const opaqueFrame = () => {
182
+ const here = globalThis;
183
+ return here.origin === 'null' && here.parent !== undefined && here.parent !== here;
184
+ };
185
+ let relayId = 0;
186
+ const relaySpeech = (lang, listener) => {
187
+ relayId += 1;
188
+ const id = relayId;
189
+ const parent = globalThis.parent;
190
+ const channel = new MessageChannel();
191
+ const take = (data) => {
192
+ if (data?.type !== 'helix-speech-heard' || data.id !== id) {
193
+ return;
194
+ }
195
+ if (data.kind === 'partial' && data.text) {
196
+ listener.partial(data.text);
197
+ }
198
+ else if (data.kind === 'level' && typeof data.level === 'number') {
199
+ listener.level?.(data.level);
200
+ }
201
+ else if (data.kind === 'final' && data.text) {
202
+ listener.final(data.text);
203
+ }
204
+ else if (data.kind === 'error' || data.kind === 'end' || data.kind === 'stopped') {
205
+ channel.port1.onmessage = null;
206
+ window.removeEventListener('message', onMessage);
207
+ listener.end(data.kind === 'error' ? 'error' : data.kind === 'stopped' ? 'stopped' : 'ended', data.error);
208
+ }
209
+ };
210
+ const onMessage = (event) => {
211
+ const data = event.data;
212
+ if (data?.type !== 'helix-speech-heard' || data.id !== id) {
213
+ return;
214
+ }
215
+ if (event.source !== parent && event.origin === 'null') {
216
+ return;
217
+ }
218
+ take(data);
219
+ };
220
+ channel.port1.onmessage = (event) => {
221
+ take(event.data);
222
+ };
223
+ window.addEventListener('message', onMessage);
224
+ parent.postMessage({ id, lang, type: 'helix-speech-start' }, '*', [channel.port2]);
225
+ return {
226
+ stop: () => {
227
+ parent.postMessage({ id, type: 'helix-speech-stop' }, '*');
228
+ },
229
+ };
230
+ };
231
+ export const browserSpeech = {
232
+ available: () => recognizer() !== undefined,
233
+ listen: ({ lang, listener }) => opaqueFrame() ? relaySpeech(lang, listener) : recognizeSpeech(lang, listener),
108
234
  };
109
235
  const KEY = Symbol.for('fusion.helix.speech');
110
236
  const globals = globalThis;
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.221.0";
9
+ export declare const VERSION = "0.223.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.221.0';
1
+ export const VERSION = '0.223.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-core",
3
3
  "description": "Helix element primitives: base classes, mixins, decorators, utilities.",
4
- "version": "0.221.0",
4
+ "version": "0.223.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -81,12 +81,12 @@
81
81
  "bugs": "https://github.com/project-fusion/fusion/issues",
82
82
  "dependencies": {
83
83
  "@floating-ui/dom": "^1.8.0",
84
- "@fusion.dev/helix-icons": "0.221.0",
84
+ "@fusion.dev/helix-icons": "0.223.0",
85
85
  "tslib": "^2.8.1"
86
86
  },
87
87
  "devDependencies": {
88
- "@fusion.dev/helix-cem": "0.221.0",
89
- "@fusion.dev/helix-testing": "0.221.0",
88
+ "@fusion.dev/helix-cem": "0.223.0",
89
+ "@fusion.dev/helix-testing": "0.223.0",
90
90
  "@lit-labs/ssr": "^4.1.0",
91
91
  "lit": "^3.2.1"
92
92
  },