@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/custom-elements.json +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mixins/mixin-voice/mixin-voice.d.ts +3 -0
- package/dist/mixins/mixin-voice/mixin-voice.js +54 -12
- package/dist/utils/speech/speech.d.ts +25 -3
- package/dist/utils/speech/speech.js +149 -30
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.
|
|
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
|
-
|
|
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
|
-
* **
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
3
|
-
return
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
94
|
-
|
|
139
|
+
current.onerror = (event) => {
|
|
140
|
+
if (event.error !== 'no-speech' && event.error !== 'aborted') {
|
|
141
|
+
failed = event.error;
|
|
142
|
+
}
|
|
95
143
|
};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '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.
|
|
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.
|
|
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.
|
|
89
|
-
"@fusion.dev/helix-testing": "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
|
},
|