@fusion.dev/helix-core 0.220.0 → 0.222.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;
@@ -14,6 +14,9 @@ export const MixinVoice = (Base) => {
14
14
  class Voice extends Base {
15
15
  constructor() {
16
16
  super(...arguments);
17
+ this.dictationDeniedLabel = 'Dictation needs the microphone';
18
+ this.dictationFailedLabel = 'Dictation stopped with an error';
19
+ this.dictationUnreachableLabel = 'Dictation could not reach the speech service';
17
20
  this.listeningLabel = 'Listening';
18
21
  this.speechEngine = undefined;
19
22
  this.stoppedListeningLabel = 'Stopped listening';
@@ -89,35 +92,62 @@ export const MixinVoice = (Base) => {
89
92
  this.toggleState('listening', true);
90
93
  field.addEventListener('keydown', this.handleVoiceKey);
91
94
  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));
95
+ let session;
96
+ try {
97
+ session = this.engine.listen({
98
+ lang: wordsLocale(this),
99
+ listener: {
100
+ end: (reason, message) => this.ended(reason, message),
101
+ final: (text) => this.place(text, true),
102
+ level: (level) => {
103
+ this.style.setProperty('--dictation-level', level.toFixed(3));
104
+ },
105
+ partial: (text) => this.place(text, false),
99
106
  },
100
- partial: (text) => this.place(text, false),
101
- },
102
- });
107
+ });
108
+ }
109
+ catch {
110
+ this.ended();
111
+ return false;
112
+ }
113
+ if (!this.heard) {
114
+ try {
115
+ session.stop();
116
+ }
117
+ catch {
118
+ }
119
+ return false;
120
+ }
121
+ this.session = session;
103
122
  return true;
104
123
  }
105
124
  stopListening() {
106
125
  this.session?.stop();
107
126
  this.ended();
108
127
  }
109
- ended() {
128
+ ended(reason, message) {
110
129
  if (!this.heard) {
111
130
  return;
112
131
  }
113
132
  this.session = undefined;
114
133
  this.heard = false;
115
- this.said = this.stoppedListeningLabel;
134
+ this.said = reason === 'error' ? this.failure(message) : this.stoppedListeningLabel;
116
135
  this.style.removeProperty('--dictation-level');
117
136
  this.toggleState('listening', false);
118
137
  this.voiceField()?.removeEventListener('keydown', this.handleVoiceKey);
119
138
  this.voiceField()?.removeEventListener('pointerdown', this.handleVoicePointer);
120
139
  }
140
+ failure(message) {
141
+ if (message === 'network') {
142
+ return this.dictationUnreachableLabel;
143
+ }
144
+ if (message === 'not-allowed' ||
145
+ message === 'service-not-allowed' ||
146
+ message === 'audio-capture') {
147
+ return this.dictationDeniedLabel;
148
+ }
149
+ return this.dictationFailedLabel;
150
+ }
121
151
  place(text, settled) {
122
152
  const field = this.voiceField();
123
153
  if (!field || !this.heard) {
@@ -166,6 +196,18 @@ export const MixinVoice = (Base) => {
166
196
  ><span part="voice-status" data-spoken role="status">${this.said}</span>`;
167
197
  }
168
198
  }
199
+ __decorate([
200
+ words,
201
+ property({ attribute: 'dictation-denied-label' })
202
+ ], Voice.prototype, "dictationDeniedLabel", void 0);
203
+ __decorate([
204
+ words,
205
+ property({ attribute: 'dictation-failed-label' })
206
+ ], Voice.prototype, "dictationFailedLabel", void 0);
207
+ __decorate([
208
+ words,
209
+ property({ attribute: 'dictation-unreachable-label' })
210
+ ], Voice.prototype, "dictationUnreachableLabel", void 0);
169
211
  __decorate([
170
212
  words,
171
213
  property({ attribute: 'listening-label' })
@@ -32,13 +32,35 @@ 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 ring. Leave it off where this page is
46
+ * listening for a frame. A second capture of the microphone ends the
47
+ * recognizer, and the prompt the reader sees belongs to that capture.
48
+ */
49
+ export declare const recognizeSpeech: (lang: string, listener: SpeechListener, options?: {
50
+ meter?: boolean;
51
+ scope?: Window;
52
+ }) => SpeechSession;
35
53
  /**
36
54
  * The browser's own recognizer, `SpeechRecognition`, or `webkitSpeechRecognition`
37
55
  * in Safari. Not available in Firefox.
38
56
  *
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.
57
+ * **It recognizes on the device where the browser can**, and Chrome downloads
58
+ * the language from the first press, which listens through the service. Where it cannot, Chrome sends the audio to
59
+ * Google's servers, and Safari to Apple's. A page that cannot send what is
60
+ * said registers an engine of its own.
61
+ *
62
+ * **A sandboxed frame has an opaque origin, and the recognizer refuses that.**
63
+ * The field asks the page that framed it to listen, and that page has an origin.
42
64
  */
43
65
  export declare const browserSpeech: SpeechEngine;
44
66
  /**
@@ -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,68 @@ 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
+ const quiet = options?.meter === false
100
+ ? () => { }
101
+ : meter((level, bands) => listener.level?.(level, bands));
102
+ const end = (reason, message) => {
103
+ if (ended) {
104
+ return;
105
+ }
106
+ ended = true;
107
+ quiet();
108
+ listener.end(reason, message);
109
+ };
110
+ const listen = (locally) => {
111
+ const current = new Constructor();
112
+ recognition = current;
113
+ current.continuous = true;
114
+ current.interimResults = true;
115
+ current.lang = lang;
116
+ if (locally) {
117
+ ;
118
+ current.processLocally = true;
119
+ }
75
120
  let failed;
76
- const quiet = meter((level, bands) => listener.level?.(level, bands));
77
- recognition.onresult = (event) => {
121
+ let heard = false;
122
+ current.onresult = (event) => {
123
+ heard = true;
78
124
  let guess = '';
79
125
  for (let at = event.resultIndex; at < event.results.length; at += 1) {
80
126
  const result = event.results[at];
@@ -90,21 +136,94 @@ export const browserSpeech = {
90
136
  listener.partial(guess.trim());
91
137
  }
92
138
  };
93
- recognition.onerror = (event) => {
94
- failed = event.error;
139
+ current.onerror = (event) => {
140
+ if (event.error !== 'no-speech' && event.error !== 'aborted') {
141
+ failed = event.error;
142
+ }
95
143
  };
96
- recognition.onend = () => {
97
- quiet();
98
- listener.end(failed && failed !== 'aborted' ? 'error' : asked ? 'stopped' : 'ended', failed);
144
+ current.onend = () => {
145
+ if (locally && !asked && !heard && failed === 'language-not-supported') {
146
+ listen(false);
147
+ return;
148
+ }
149
+ end(failed ? 'error' : asked ? 'stopped' : 'ended', failed);
99
150
  };
100
- recognition.start();
101
- return {
102
- stop: () => {
103
- asked = true;
151
+ try {
152
+ current.start();
153
+ }
154
+ catch (error) {
155
+ end('error', error instanceof Error ? error.message : 'not-allowed');
156
+ }
157
+ };
158
+ void listenLocally(Constructor, lang).then((locally) => {
159
+ if (asked) {
160
+ end('stopped');
161
+ return;
162
+ }
163
+ listen(locally);
164
+ });
165
+ return {
166
+ stop: () => {
167
+ asked = true;
168
+ if (recognition) {
104
169
  recognition.stop();
105
- },
106
- };
107
- },
170
+ }
171
+ },
172
+ };
173
+ };
174
+ const opaqueFrame = () => {
175
+ const here = globalThis;
176
+ return here.origin === 'null' && here.parent !== undefined && here.parent !== here;
177
+ };
178
+ let relayId = 0;
179
+ const relaySpeech = (lang, listener) => {
180
+ relayId += 1;
181
+ const id = relayId;
182
+ const parent = globalThis.parent;
183
+ const channel = new MessageChannel();
184
+ const take = (data) => {
185
+ if (data?.type !== 'helix-speech-heard' || data.id !== id) {
186
+ return;
187
+ }
188
+ if (data.kind === 'partial' && data.text) {
189
+ listener.partial(data.text);
190
+ }
191
+ else if (data.kind === 'level' && typeof data.level === 'number') {
192
+ listener.level?.(data.level);
193
+ }
194
+ else if (data.kind === 'final' && data.text) {
195
+ listener.final(data.text);
196
+ }
197
+ else if (data.kind === 'error' || data.kind === 'end' || data.kind === 'stopped') {
198
+ channel.port1.onmessage = null;
199
+ window.removeEventListener('message', onMessage);
200
+ listener.end(data.kind === 'error' ? 'error' : data.kind === 'stopped' ? 'stopped' : 'ended', data.error);
201
+ }
202
+ };
203
+ const onMessage = (event) => {
204
+ const data = event.data;
205
+ if (data?.type !== 'helix-speech-heard' || data.id !== id) {
206
+ return;
207
+ }
208
+ if (event.source !== parent && event.origin === 'null') {
209
+ return;
210
+ }
211
+ take(data);
212
+ };
213
+ channel.port1.onmessage = (event) => {
214
+ take(event.data);
215
+ };
216
+ window.addEventListener('message', onMessage);
217
+ parent.postMessage({ id, lang, type: 'helix-speech-start' }, '*', [channel.port2]);
218
+ return {
219
+ stop: () => {
220
+ parent.postMessage({ id, type: 'helix-speech-stop' }, '*');
221
+ },
222
+ };
223
+ };
224
+ export const browserSpeech = {
225
+ available: () => recognizer() !== undefined,
226
+ listen: ({ lang, listener }) => opaqueFrame() ? relaySpeech(lang, listener) : recognizeSpeech(lang, listener),
108
227
  };
109
228
  const KEY = Symbol.for('fusion.helix.speech');
110
229
  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.220.0";
9
+ export declare const VERSION = "0.222.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.220.0';
1
+ export const VERSION = '0.222.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.220.0",
4
+ "version": "0.222.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.220.0",
84
+ "@fusion.dev/helix-icons": "0.222.0",
85
85
  "tslib": "^2.8.1"
86
86
  },
87
87
  "devDependencies": {
88
- "@fusion.dev/helix-cem": "0.220.0",
89
- "@fusion.dev/helix-testing": "0.220.0",
88
+ "@fusion.dev/helix-cem": "0.222.0",
89
+ "@fusion.dev/helix-testing": "0.222.0",
90
90
  "@lit-labs/ssr": "^4.1.0",
91
91
  "lit": "^3.2.1"
92
92
  },