kempo-ui 0.4.4 → 0.4.5
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/components/TextToSpeech.js +4 -4
- package/dist/components/VoiceSelector.js +49 -0
- package/dist/utils/voice.js +1 -0
- package/docs/components/accordion.html +7 -0
- package/docs/components/aside.html +7 -0
- package/docs/components/calendar.html +7 -0
- package/docs/components/card.html +7 -0
- package/docs/components/code-editor.html +7 -0
- package/docs/components/color-picker.html +7 -0
- package/docs/components/combobox.html +7 -0
- package/docs/components/content-slider.html +7 -0
- package/docs/components/context.html +7 -0
- package/docs/components/dialog.html +7 -0
- package/docs/components/dropdown.html +7 -0
- package/docs/components/filter-list.html +7 -0
- package/docs/components/focus-capture.html +7 -0
- package/docs/components/html-editor.html +7 -0
- package/docs/components/hybrid-component.html +7 -0
- package/docs/components/icon.html +7 -0
- package/docs/components/import.html +7 -0
- package/docs/components/light-component.html +7 -0
- package/docs/components/nav-spacer.html +7 -0
- package/docs/components/nav.html +7 -0
- package/docs/components/photo-viewer.html +7 -0
- package/docs/components/progress.html +7 -0
- package/docs/components/resize.html +7 -0
- package/docs/components/shadow-component.html +7 -0
- package/docs/components/show-more.html +7 -0
- package/docs/components/slider.html +7 -0
- package/docs/components/sortable.html +7 -0
- package/docs/components/speech-to-text.html +7 -0
- package/docs/components/spinner.html +7 -0
- package/docs/components/split.html +7 -0
- package/docs/components/table.html +7 -0
- package/docs/components/tableControls.html +7 -0
- package/docs/components/tableCustomFields.html +7 -0
- package/docs/components/tableFetchRecords.html +7 -0
- package/docs/components/tableFieldSortHide.html +7 -0
- package/docs/components/tablePagination.html +7 -0
- package/docs/components/tablePlaceholder.html +7 -0
- package/docs/components/tableRecordEditing.html +7 -0
- package/docs/components/tableRecordFiltering.html +7 -0
- package/docs/components/tableRecordHiding.html +7 -0
- package/docs/components/tableRecordSearching.html +7 -0
- package/docs/components/tableRecordSelection.html +7 -0
- package/docs/components/tableRowControls.html +7 -0
- package/docs/components/tableServerSync.html +7 -0
- package/docs/components/tableSorting.html +7 -0
- package/docs/components/tabs.html +7 -0
- package/docs/components/tags.html +7 -0
- package/docs/components/text-to-speech.html +51 -1
- package/docs/components/theme-select.html +7 -0
- package/docs/components/theme-switcher.html +7 -0
- package/docs/components/time.html +7 -0
- package/docs/components/timestamp.html +7 -0
- package/docs/components/toast.html +7 -0
- package/docs/components/toggle.html +7 -0
- package/docs/components/tree.html +7 -0
- package/docs/components/voice-selector.html +598 -0
- package/docs/index.html +19 -0
- package/docs/src/components/TextToSpeech.js +4 -4
- package/docs/src/components/VoiceSelector.js +49 -0
- package/docs/src/utils/voice.js +1 -0
- package/docs/utils/context.html +7 -0
- package/docs/utils/cookie.html +7 -0
- package/docs/utils/debounce.html +7 -0
- package/docs/utils/drag.html +7 -0
- package/docs/utils/elevation.html +7 -0
- package/docs/utils/formatTimestamp.html +7 -0
- package/docs/utils/object.html +7 -0
- package/docs/utils/propConverters.html +7 -0
- package/docs/utils/string.html +7 -0
- package/docs/utils/theme.html +7 -0
- package/docs/utils/toTitleCase.html +7 -0
- package/docs/utils/type.html +7 -0
- package/docs/utils/voice.html +536 -0
- package/docs/utils/wait.html +7 -0
- package/docs-src/components/text-to-speech.page.html +44 -1
- package/docs-src/components/voice-selector.page.html +125 -0
- package/docs-src/index.page.html +12 -0
- package/docs-src/nav.fragment.html +7 -0
- package/docs-src/utils/voice.page.html +66 -0
- package/llms.txt +2 -0
- package/package.json +1 -1
- package/src/components/TextToSpeech.js +13 -2
- package/src/components/VoiceSelector.js +271 -0
- package/src/utils/voice.js +103 -0
- package/tests/components/TextToSpeech.browser-test.js +150 -1
- package/tests/components/VoiceSelector.browser-test.js +374 -0
- package/tests/utils/voice.browser-test.js +128 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { html, css } from '../lit-all.min.js';
|
|
2
|
+
import ShadowComponent from './ShadowComponent.js';
|
|
3
|
+
import {
|
|
4
|
+
getVoice,
|
|
5
|
+
setVoice,
|
|
6
|
+
subscribeToVoice,
|
|
7
|
+
subscribeToAvailableVoices
|
|
8
|
+
} from '../utils/voice.js';
|
|
9
|
+
|
|
10
|
+
const primaryLang = (tag) => (tag || '').split('-')[0].toLowerCase();
|
|
11
|
+
|
|
12
|
+
const browserLanguage = () => {
|
|
13
|
+
if(typeof navigator === 'undefined') return 'en';
|
|
14
|
+
return primaryLang(navigator.language || 'en') || 'en';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let displayNames = null;
|
|
18
|
+
try {
|
|
19
|
+
if(typeof Intl !== 'undefined' && Intl.DisplayNames){
|
|
20
|
+
const locale = (typeof navigator !== 'undefined' && navigator.language) || 'en';
|
|
21
|
+
displayNames = new Intl.DisplayNames([locale], { type: 'language' });
|
|
22
|
+
}
|
|
23
|
+
} catch(e) {}
|
|
24
|
+
|
|
25
|
+
// Cache for native-name lookups (Intl.DisplayNames in the language's own locale)
|
|
26
|
+
// so we don't construct a new instance per option per render.
|
|
27
|
+
const nativeNameCache = new Map();
|
|
28
|
+
const getNativeName = (code) => {
|
|
29
|
+
if(nativeNameCache.has(code)) return nativeNameCache.get(code);
|
|
30
|
+
let native = '';
|
|
31
|
+
try {
|
|
32
|
+
if(typeof Intl !== 'undefined' && Intl.DisplayNames){
|
|
33
|
+
const dn = new Intl.DisplayNames([code], { type: 'language' });
|
|
34
|
+
native = dn.of(code) || '';
|
|
35
|
+
}
|
|
36
|
+
} catch(e) {}
|
|
37
|
+
// Capitalize the first character so dropdown labels look consistent —
|
|
38
|
+
// Intl.DisplayNames returns "español"/"français" lowercase, which looks
|
|
39
|
+
// off next to the capitalized localized name.
|
|
40
|
+
if(native){
|
|
41
|
+
native = native.charAt(0).toUpperCase() + native.slice(1);
|
|
42
|
+
}
|
|
43
|
+
nativeNameCache.set(code, native);
|
|
44
|
+
return native;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Render a language code as "Localized (Native)" — e.g. "Spanish (Español)",
|
|
48
|
+
// "Japanese (日本語)". The native name is a deliberate accessibility escape
|
|
49
|
+
// hatch: if the user accidentally has the page in a language they can't read,
|
|
50
|
+
// they can still recognize their own language by its native name. When the
|
|
51
|
+
// localized and native names match (user already in that language) we skip
|
|
52
|
+
// the duplicate.
|
|
53
|
+
const formatLanguage = (code) => {
|
|
54
|
+
let localized = code;
|
|
55
|
+
if(displayNames){
|
|
56
|
+
try {
|
|
57
|
+
localized = displayNames.of(code) || code;
|
|
58
|
+
} catch(e) {}
|
|
59
|
+
}
|
|
60
|
+
const native = getNativeName(code);
|
|
61
|
+
if(native && native.toLowerCase() !== localized.toLowerCase()){
|
|
62
|
+
return `${localized} (${native})`;
|
|
63
|
+
}
|
|
64
|
+
return localized;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Strip the parenthetical "(language (region))" that OS-level voices often
|
|
68
|
+
// append to their name (e.g. "Eddy (German (Germany))" → "Eddy"). The actual
|
|
69
|
+
// `value` we submit to SpeechSynthesis still uses the full name.
|
|
70
|
+
const displayVoiceName = (name) => {
|
|
71
|
+
const cleaned = name.split('(')[0].trim();
|
|
72
|
+
return cleaned || name;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default class VoiceSelector extends ShadowComponent {
|
|
76
|
+
static properties = {
|
|
77
|
+
language: { type: String, reflect: true },
|
|
78
|
+
placeholder: { type: String, reflect: true },
|
|
79
|
+
disabled: { type: Boolean, reflect: true },
|
|
80
|
+
voices: { state: true },
|
|
81
|
+
selected: { state: true },
|
|
82
|
+
currentLang: { state: true }
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
#unsubVoices = null;
|
|
86
|
+
#unsubSelected = null;
|
|
87
|
+
#unsupported = false;
|
|
88
|
+
#langInitialized = false;
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
Lifecycle Callbacks
|
|
92
|
+
*/
|
|
93
|
+
constructor() {
|
|
94
|
+
super();
|
|
95
|
+
this.language = '';
|
|
96
|
+
this.placeholder = 'Browser default';
|
|
97
|
+
this.disabled = false;
|
|
98
|
+
this.voices = [];
|
|
99
|
+
this.selected = '';
|
|
100
|
+
this.currentLang = '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
connectedCallback() {
|
|
104
|
+
super.connectedCallback();
|
|
105
|
+
if(typeof window === 'undefined' || !window.speechSynthesis){
|
|
106
|
+
this.#unsupported = true;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.#unsubVoices = subscribeToAvailableVoices(list => {
|
|
110
|
+
this.voices = list;
|
|
111
|
+
this.#initLanguageOnce();
|
|
112
|
+
});
|
|
113
|
+
this.#unsubSelected = subscribeToVoice(name => {
|
|
114
|
+
this.selected = name || '';
|
|
115
|
+
// Keep the language dropdown in sync with externally-set voices
|
|
116
|
+
if(name){
|
|
117
|
+
const match = this.voices.find(v => v.name === name);
|
|
118
|
+
if(match && match.lang){
|
|
119
|
+
this.currentLang = primaryLang(match.lang);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
disconnectedCallback() {
|
|
126
|
+
super.disconnectedCallback();
|
|
127
|
+
if(this.#unsubVoices) this.#unsubVoices();
|
|
128
|
+
if(this.#unsubSelected) this.#unsubSelected();
|
|
129
|
+
this.#unsubVoices = null;
|
|
130
|
+
this.#unsubSelected = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
Utility
|
|
135
|
+
*/
|
|
136
|
+
#initLanguageOnce = () => {
|
|
137
|
+
if(this.#langInitialized || this.voices.length === 0) return;
|
|
138
|
+
this.#langInitialized = true;
|
|
139
|
+
// Priority: existing voice's language > attribute > navigator > first available
|
|
140
|
+
const fromVoice = this.selected
|
|
141
|
+
? this.voices.find(v => v.name === this.selected)
|
|
142
|
+
: null;
|
|
143
|
+
if(fromVoice && fromVoice.lang){
|
|
144
|
+
this.currentLang = primaryLang(fromVoice.lang);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if(this.language){
|
|
148
|
+
this.currentLang = primaryLang(this.language);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const navLang = browserLanguage();
|
|
152
|
+
const langs = this.availableLanguages;
|
|
153
|
+
if(langs.includes(navLang)){
|
|
154
|
+
this.currentLang = navLang;
|
|
155
|
+
} else if(langs.length > 0){
|
|
156
|
+
this.currentLang = langs[0];
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
get availableLanguages() {
|
|
161
|
+
const langs = new Set();
|
|
162
|
+
for(const v of this.voices){
|
|
163
|
+
if(v.lang) langs.add(primaryLang(v.lang));
|
|
164
|
+
}
|
|
165
|
+
return [...langs].sort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get voicesForCurrentLanguage() {
|
|
169
|
+
if(!this.currentLang) return this.voices;
|
|
170
|
+
return this.voices.filter(v => primaryLang(v.lang) === this.currentLang);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
Event Handlers
|
|
175
|
+
*/
|
|
176
|
+
handleLanguageChange = (e) => {
|
|
177
|
+
if(this.disabled) return;
|
|
178
|
+
this.currentLang = e.target.value;
|
|
179
|
+
// If the saved voice no longer exists in this language, clear it
|
|
180
|
+
const stillValid = this.voicesForCurrentLanguage.some(v => v.name === this.selected);
|
|
181
|
+
if(!stillValid){
|
|
182
|
+
setVoice('');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
handleVoiceChange = (e) => {
|
|
187
|
+
if(this.disabled) return;
|
|
188
|
+
const value = e.target.value;
|
|
189
|
+
setVoice(value);
|
|
190
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
191
|
+
detail: { voice: value, language: this.currentLang },
|
|
192
|
+
bubbles: true
|
|
193
|
+
}));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/*
|
|
197
|
+
Public Methods
|
|
198
|
+
*/
|
|
199
|
+
refresh = () => {
|
|
200
|
+
if(this.#unsupported) return;
|
|
201
|
+
this.voices = window.speechSynthesis.getVoices();
|
|
202
|
+
this.#initLanguageOnce();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
Rendering
|
|
207
|
+
*/
|
|
208
|
+
render() {
|
|
209
|
+
if(this.#unsupported){
|
|
210
|
+
return html`
|
|
211
|
+
<select disabled aria-label="Voice selector (unsupported)">
|
|
212
|
+
<option>Speech synthesis not supported</option>
|
|
213
|
+
</select>
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
const langs = this.availableLanguages;
|
|
217
|
+
const voicesInLang = this.voicesForCurrentLanguage;
|
|
218
|
+
return html`
|
|
219
|
+
<div class="row">
|
|
220
|
+
<div class="col" style="min-width: 10rem; flex: 0 0 auto">
|
|
221
|
+
<select
|
|
222
|
+
class="lang"
|
|
223
|
+
aria-label="Language"
|
|
224
|
+
?disabled=${this.disabled || langs.length === 0}
|
|
225
|
+
.value=${this.currentLang}
|
|
226
|
+
@change=${this.handleLanguageChange}
|
|
227
|
+
>
|
|
228
|
+
${langs.map(code => html`
|
|
229
|
+
<option value=${code} ?selected=${code === this.currentLang}>${formatLanguage(code)}</option>
|
|
230
|
+
`)}
|
|
231
|
+
</select>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="col">
|
|
234
|
+
<select
|
|
235
|
+
class="voice"
|
|
236
|
+
aria-label="Voice"
|
|
237
|
+
?disabled=${this.disabled}
|
|
238
|
+
.value=${this.selected}
|
|
239
|
+
@change=${this.handleVoiceChange}
|
|
240
|
+
>
|
|
241
|
+
<option value="">${this.placeholder}</option>
|
|
242
|
+
${voicesInLang.map(v => html`
|
|
243
|
+
<option
|
|
244
|
+
value=${v.name}
|
|
245
|
+
?selected=${v.name === this.selected}
|
|
246
|
+
>${displayVoiceName(v.name)}</option>
|
|
247
|
+
`)}
|
|
248
|
+
</select>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/*
|
|
255
|
+
Styles
|
|
256
|
+
*/
|
|
257
|
+
static styles = css`
|
|
258
|
+
:host {
|
|
259
|
+
display: block;
|
|
260
|
+
}
|
|
261
|
+
:host([disabled]) {
|
|
262
|
+
opacity: 0.5;
|
|
263
|
+
pointer-events: none;
|
|
264
|
+
}
|
|
265
|
+
select {
|
|
266
|
+
width: 100%;
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
customElements.define('k-voice-selector', VoiceSelector);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Voice Utility
|
|
3
|
+
- Manage the user's preferred speech-synthesis voice
|
|
4
|
+
- Subscribe to voice changes
|
|
5
|
+
- Persist voice to localStorage
|
|
6
|
+
- Sync across tabs via the storage event
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import createContext from './context.js';
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'k-voice';
|
|
12
|
+
|
|
13
|
+
const getInitialVoice = () => {
|
|
14
|
+
try {
|
|
15
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
return stored || '';
|
|
17
|
+
} catch(e){
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const voiceContext = createContext('voice', getInitialVoice());
|
|
23
|
+
|
|
24
|
+
const persist = voice => {
|
|
25
|
+
try {
|
|
26
|
+
if(voice) localStorage.setItem(STORAGE_KEY, voice);
|
|
27
|
+
else localStorage.removeItem(STORAGE_KEY);
|
|
28
|
+
} catch(e) {}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
voiceContext.subscribe(persist);
|
|
32
|
+
|
|
33
|
+
if(typeof window !== 'undefined'){
|
|
34
|
+
window.addEventListener('storage', event => {
|
|
35
|
+
if(event.key === STORAGE_KEY){
|
|
36
|
+
voiceContext.set(event.newValue || '');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const setVoice = name => voiceContext.set(name || '');
|
|
42
|
+
|
|
43
|
+
export const getVoice = () => voiceContext.get();
|
|
44
|
+
|
|
45
|
+
export const subscribeToVoice = callback => voiceContext.subscribe(callback);
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
Helpers for enumerating available voices from the browser.
|
|
49
|
+
getVoices() may return [] on the first call; the browser fires a
|
|
50
|
+
`voiceschanged` event when the list is ready. waitForVoices() returns a
|
|
51
|
+
promise that resolves once the list is populated (or after a small timeout
|
|
52
|
+
if the browser never fires the event).
|
|
53
|
+
*/
|
|
54
|
+
export const getAvailableVoices = () => {
|
|
55
|
+
if(typeof window === 'undefined' || !window.speechSynthesis) return [];
|
|
56
|
+
return window.speechSynthesis.getVoices();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const waitForVoices = (timeoutMs = 2000) => {
|
|
60
|
+
if(typeof window === 'undefined' || !window.speechSynthesis){
|
|
61
|
+
return Promise.resolve([]);
|
|
62
|
+
}
|
|
63
|
+
const synth = window.speechSynthesis;
|
|
64
|
+
const initial = synth.getVoices();
|
|
65
|
+
if(initial.length > 0) return Promise.resolve(initial);
|
|
66
|
+
return new Promise(resolve => {
|
|
67
|
+
let settled = false;
|
|
68
|
+
const handler = () => {
|
|
69
|
+
if(settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
synth.removeEventListener('voiceschanged', handler);
|
|
72
|
+
resolve(synth.getVoices());
|
|
73
|
+
};
|
|
74
|
+
synth.addEventListener('voiceschanged', handler);
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if(settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
synth.removeEventListener('voiceschanged', handler);
|
|
79
|
+
resolve(synth.getVoices());
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const subscribeToAvailableVoices = callback => {
|
|
85
|
+
if(typeof window === 'undefined' || !window.speechSynthesis){
|
|
86
|
+
callback([]);
|
|
87
|
+
return () => {};
|
|
88
|
+
}
|
|
89
|
+
const synth = window.speechSynthesis;
|
|
90
|
+
const fire = () => callback(synth.getVoices());
|
|
91
|
+
synth.addEventListener('voiceschanged', fire);
|
|
92
|
+
fire();
|
|
93
|
+
return () => synth.removeEventListener('voiceschanged', fire);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default {
|
|
97
|
+
get: getVoice,
|
|
98
|
+
set: setVoice,
|
|
99
|
+
subscribe: subscribeToVoice,
|
|
100
|
+
getAvailable: getAvailableVoices,
|
|
101
|
+
waitForVoices,
|
|
102
|
+
subscribeAvailable: subscribeToAvailableVoices
|
|
103
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import TextToSpeech from '../../src/components/TextToSpeech.js';
|
|
2
|
+
import { setVoice } from '../../src/utils/voice.js';
|
|
2
3
|
|
|
3
4
|
const createTextToSpeech = async (attrs = {}, slot = '') => {
|
|
4
5
|
const container = document.createElement('div');
|
|
@@ -26,7 +27,7 @@ const cleanup = (container) => {
|
|
|
26
27
|
// Stub window.speechSynthesis (which is a read-only getter in Chrome) by
|
|
27
28
|
// patching its methods on the live object. SpeechSynthesisUtterance is a
|
|
28
29
|
// constructor, swapped via Object.defineProperty.
|
|
29
|
-
const installSynthesisStub = () => {
|
|
30
|
+
const installSynthesisStub = (voicesList = []) => {
|
|
30
31
|
const captured = {
|
|
31
32
|
spoken: [],
|
|
32
33
|
cancelled: 0
|
|
@@ -34,6 +35,7 @@ const installSynthesisStub = () => {
|
|
|
34
35
|
const synth = window.speechSynthesis;
|
|
35
36
|
const originalSpeak = synth.speak.bind(synth);
|
|
36
37
|
const originalCancel = synth.cancel.bind(synth);
|
|
38
|
+
const originalGetVoices = synth.getVoices.bind(synth);
|
|
37
39
|
synth.speak = (utterance) => {
|
|
38
40
|
captured.spoken.push(utterance);
|
|
39
41
|
queueMicrotask(() => {
|
|
@@ -41,6 +43,7 @@ const installSynthesisStub = () => {
|
|
|
41
43
|
});
|
|
42
44
|
};
|
|
43
45
|
synth.cancel = () => { captured.cancelled++; };
|
|
46
|
+
synth.getVoices = () => voicesList;
|
|
44
47
|
|
|
45
48
|
const OriginalUtterance = window.SpeechSynthesisUtterance;
|
|
46
49
|
function StubUtterance(text){
|
|
@@ -65,6 +68,7 @@ const installSynthesisStub = () => {
|
|
|
65
68
|
restore: () => {
|
|
66
69
|
synth.speak = originalSpeak;
|
|
67
70
|
synth.cancel = originalCancel;
|
|
71
|
+
synth.getVoices = originalGetVoices;
|
|
68
72
|
Object.defineProperty(window, 'SpeechSynthesisUtterance', {
|
|
69
73
|
value: OriginalUtterance,
|
|
70
74
|
configurable: true,
|
|
@@ -407,6 +411,151 @@ export default {
|
|
|
407
411
|
pass('error event fires with detail.error');
|
|
408
412
|
},
|
|
409
413
|
|
|
414
|
+
/*
|
|
415
|
+
Voice Fallback Chain
|
|
416
|
+
*/
|
|
417
|
+
'should pick first matching voice from a fallback chain': async ({pass, fail}) => {
|
|
418
|
+
const stub = installSynthesisStub([
|
|
419
|
+
{ name: 'Google US English' },
|
|
420
|
+
{ name: 'Microsoft Zira' }
|
|
421
|
+
]);
|
|
422
|
+
const { container, el } = await createTextToSpeech({
|
|
423
|
+
text: 'hi',
|
|
424
|
+
voice: 'Samantha, Google US English, Microsoft Zira'
|
|
425
|
+
});
|
|
426
|
+
el.speak();
|
|
427
|
+
const u = stub.captured.spoken[0];
|
|
428
|
+
if(!u.voice || u.voice.name !== 'Google US English'){
|
|
429
|
+
stub.restore();
|
|
430
|
+
cleanup(container);
|
|
431
|
+
return fail(`Expected voice "Google US English", got ${u.voice ? u.voice.name : 'null'}`);
|
|
432
|
+
}
|
|
433
|
+
stub.restore();
|
|
434
|
+
cleanup(container);
|
|
435
|
+
pass('First installed voice in chain is selected');
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
'should fall through earlier missing entries in the chain': async ({pass, fail}) => {
|
|
439
|
+
const stub = installSynthesisStub([
|
|
440
|
+
{ name: 'Microsoft Zira' }
|
|
441
|
+
]);
|
|
442
|
+
const { container, el } = await createTextToSpeech({
|
|
443
|
+
text: 'hi',
|
|
444
|
+
voice: 'Samantha, Google US English, Microsoft Zira'
|
|
445
|
+
});
|
|
446
|
+
el.speak();
|
|
447
|
+
const u = stub.captured.spoken[0];
|
|
448
|
+
if(!u.voice || u.voice.name !== 'Microsoft Zira'){
|
|
449
|
+
stub.restore();
|
|
450
|
+
cleanup(container);
|
|
451
|
+
return fail(`Expected voice "Microsoft Zira", got ${u.voice ? u.voice.name : 'null'}`);
|
|
452
|
+
}
|
|
453
|
+
stub.restore();
|
|
454
|
+
cleanup(container);
|
|
455
|
+
pass('Falls through to a later installed voice');
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
'should not set voice when no candidate matches': async ({pass, fail}) => {
|
|
459
|
+
const stub = installSynthesisStub([
|
|
460
|
+
{ name: 'Karen' }
|
|
461
|
+
]);
|
|
462
|
+
const { container, el } = await createTextToSpeech({
|
|
463
|
+
text: 'hi',
|
|
464
|
+
voice: 'Samantha, Google US English'
|
|
465
|
+
});
|
|
466
|
+
el.speak();
|
|
467
|
+
const u = stub.captured.spoken[0];
|
|
468
|
+
if(u.voice !== null){
|
|
469
|
+
stub.restore();
|
|
470
|
+
cleanup(container);
|
|
471
|
+
return fail(`Expected voice null when no chain entry matches, got ${u.voice && u.voice.name}`);
|
|
472
|
+
}
|
|
473
|
+
stub.restore();
|
|
474
|
+
cleanup(container);
|
|
475
|
+
pass('voice stays null when no chain entry is installed');
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
'should ignore extra whitespace around chain entries': async ({pass, fail}) => {
|
|
479
|
+
const stub = installSynthesisStub([
|
|
480
|
+
{ name: 'Google US English' }
|
|
481
|
+
]);
|
|
482
|
+
const { container, el } = await createTextToSpeech({
|
|
483
|
+
text: 'hi',
|
|
484
|
+
voice: ' Samantha , Google US English '
|
|
485
|
+
});
|
|
486
|
+
el.speak();
|
|
487
|
+
const u = stub.captured.spoken[0];
|
|
488
|
+
if(!u.voice || u.voice.name !== 'Google US English'){
|
|
489
|
+
stub.restore();
|
|
490
|
+
cleanup(container);
|
|
491
|
+
return fail(`Expected voice "Google US English" after trim, got ${u.voice && u.voice.name}`);
|
|
492
|
+
}
|
|
493
|
+
stub.restore();
|
|
494
|
+
cleanup(container);
|
|
495
|
+
pass('Whitespace around comma-separated entries is ignored');
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
'should fall back to saved voice preference when no voice attr is set': async ({pass, fail}) => {
|
|
499
|
+
const stub = installSynthesisStub([
|
|
500
|
+
{ name: 'Karen', lang: 'en-AU' }
|
|
501
|
+
]);
|
|
502
|
+
setVoice('Karen');
|
|
503
|
+
const { container, el } = await createTextToSpeech({ text: 'hi' });
|
|
504
|
+
el.speak();
|
|
505
|
+
const u = stub.captured.spoken[0];
|
|
506
|
+
if(!u.voice || u.voice.name !== 'Karen'){
|
|
507
|
+
stub.restore();
|
|
508
|
+
setVoice('');
|
|
509
|
+
cleanup(container);
|
|
510
|
+
return fail(`Expected fallback to saved voice "Karen", got ${u.voice && u.voice.name}`);
|
|
511
|
+
}
|
|
512
|
+
stub.restore();
|
|
513
|
+
setVoice('');
|
|
514
|
+
cleanup(container);
|
|
515
|
+
pass('Saved voice preference is used when voice attr is empty');
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
'should ignore saved voice preference when voice attr is set': async ({pass, fail}) => {
|
|
519
|
+
const stub = installSynthesisStub([
|
|
520
|
+
{ name: 'Karen', lang: 'en-AU' },
|
|
521
|
+
{ name: 'Samantha', lang: 'en-US' }
|
|
522
|
+
]);
|
|
523
|
+
setVoice('Karen');
|
|
524
|
+
const { container, el } = await createTextToSpeech({ text: 'hi', voice: 'Samantha' });
|
|
525
|
+
el.speak();
|
|
526
|
+
const u = stub.captured.spoken[0];
|
|
527
|
+
if(!u.voice || u.voice.name !== 'Samantha'){
|
|
528
|
+
stub.restore();
|
|
529
|
+
setVoice('');
|
|
530
|
+
cleanup(container);
|
|
531
|
+
return fail(`Expected explicit voice "Samantha" to win over saved "Karen", got ${u.voice && u.voice.name}`);
|
|
532
|
+
}
|
|
533
|
+
stub.restore();
|
|
534
|
+
setVoice('');
|
|
535
|
+
cleanup(container);
|
|
536
|
+
pass('Explicit voice attr overrides saved preference');
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
'should still match a single voice name with no commas': async ({pass, fail}) => {
|
|
540
|
+
const stub = installSynthesisStub([
|
|
541
|
+
{ name: 'Samantha' }
|
|
542
|
+
]);
|
|
543
|
+
const { container, el } = await createTextToSpeech({
|
|
544
|
+
text: 'hi',
|
|
545
|
+
voice: 'Samantha'
|
|
546
|
+
});
|
|
547
|
+
el.speak();
|
|
548
|
+
const u = stub.captured.spoken[0];
|
|
549
|
+
if(!u.voice || u.voice.name !== 'Samantha'){
|
|
550
|
+
stub.restore();
|
|
551
|
+
cleanup(container);
|
|
552
|
+
return fail(`Expected voice "Samantha", got ${u.voice && u.voice.name}`);
|
|
553
|
+
}
|
|
554
|
+
stub.restore();
|
|
555
|
+
cleanup(container);
|
|
556
|
+
pass('Single voice name still works');
|
|
557
|
+
},
|
|
558
|
+
|
|
410
559
|
/*
|
|
411
560
|
Browser support detection
|
|
412
561
|
*/
|