PluginProbe
BeyondWords – AI audio for publishers / 7.2.0
BeyondWords – AI audio for publishers v7.2.0
7.2.0 7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 All 44 releases
speechkit / src / editor / components / voice-picker / index.js

index.js in BeyondWords – AI audio for publishers 7.2.0, at src/editor/components/voice-picker/index.js

360 lines 9.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WordPress dependencies
3 */
4 import { __ } from '@wordpress/i18n';
5 import { SelectControl, Spinner } from '@wordpress/components';
6 import { useSelect } from '@wordpress/data';
7 import { useEffect, useRef, useState } from '@wordpress/element';
8 import { decodeEntities } from '@wordpress/html-entities';
9
10 /**
11 * Internal dependencies
12 */
13 import {
14 NATIVE_ALL,
15 NATIVE_ONLY,
16 filterVoicesByNative,
17 findLanguageByCode,
18 getAccentsForName,
19 getLanguageModels,
20 getLanguageNames,
21 voiceIsNative,
22 voiceModelKey,
23 } from '../settings-panel/helpers';
24 import Stack from '../stack';
25
26 /**
27 * The Language → Accent → Native → Model → Voice pickers.
28 *
29 * Shared by the post sidebar and the block inspector; the caller owns where the
30 * pair is stored and when it is fetched.
31 *
32 * @param {Object} props
33 * @param {boolean} props.enabled Whether to fetch and show the fields.
34 * @param {string} props.languageCode The selected language code.
35 * @param {string} props.voiceId The selected voice id.
36 * @param {Object} [props.seed] A { languageCode, voiceId } pair to start from.
37 * @param {Function} props.onChange Passed the next { languageCode, voiceId } pair.
38 *
39 * @return {Element|null} The pickers.
40 */
41 export function VoicePicker( {
42 enabled = true,
43 languageCode = '',
44 voiceId = '',
45 seed,
46 onChange,
47 } ) {
48 // Fetched lazily behind `enabled` — a default post makes no language/voice API calls.
49 const languages = useSelect(
50 ( select ) =>
51 enabled ? select( 'beyondwords/settings' ).getLanguages() : [],
52 [ enabled ]
53 );
54
55 const voices = useSelect(
56 ( select ) =>
57 enabled && languageCode
58 ? select( 'beyondwords/settings' ).getVoices( languageCode )
59 : [],
60 [ enabled, languageCode ]
61 );
62
63 // `hasFinishedResolution` is monotonic; `isResolving` flip-flops and leaves a
64 // one-frame gap where stale voices show.
65 const languagesResolving = useSelect(
66 ( select ) =>
67 enabled &&
68 ! select( 'beyondwords/settings' ).hasFinishedResolution(
69 'getLanguages',
70 []
71 ),
72 [ enabled ]
73 );
74
75 const voicesResolving = useSelect(
76 ( select ) =>
77 enabled &&
78 !! languageCode &&
79 ! select( 'beyondwords/settings' ).hasFinishedResolution(
80 'getVoices',
81 [ languageCode ]
82 ),
83 [ enabled, languageCode ]
84 );
85
86 // Start from what the caller says this already inherits, so picking a
87 // different voice in the same language costs one click, not four.
88 //
89 // Two steps, because the voice can only be trusted once the language's own
90 // list is back to confirm it offers it: 0 = untouched, 1 = language in,
91 // 2 = settled.
92 const seedStep = useRef( 0 );
93
94 useEffect( () => {
95 if ( ! enabled || seedStep.current !== 0 ) {
96 return;
97 }
98
99 // Anything already chosen wins over the seed.
100 if ( languageCode || voiceId ) {
101 seedStep.current = 2;
102 return;
103 }
104
105 if ( ! seed?.languageCode ) {
106 return;
107 }
108
109 seedStep.current = 1;
110 onChange( { languageCode: seed.languageCode, voiceId: '' } );
111 // eslint-disable-next-line react-hooks/exhaustive-deps
112 }, [ enabled, seed?.languageCode, languageCode, voiceId ] );
113
114 useEffect( () => {
115 if ( seedStep.current !== 1 || voicesResolving ) {
116 return;
117 }
118
119 if ( ! seed?.voiceId || languageCode !== seed.languageCode ) {
120 return;
121 }
122
123 seedStep.current = 2;
124
125 // A project's default voice need not speak the project's language.
126 const offered = ( voices ?? [] ).some(
127 ( voice ) => String( voice.id ) === String( seed.voiceId )
128 );
129
130 if ( offered ) {
131 onChange( { languageCode, voiceId: String( seed.voiceId ) } );
132 }
133 // eslint-disable-next-line react-hooks/exhaustive-deps
134 }, [ seed, languageCode, voices, voicesResolving ] );
135
136 const [ nativeFilter, setNativeFilter ] = useState( NATIVE_ONLY );
137
138 // Open on "All" when the saved voice is not native to the language, so that
139 // voice stays visible in the list.
140 const nativeSeeded = useRef( false );
141
142 // Disabled stays mounted, so reset by hand or Customize off → on never re-seeds.
143 // The filter resets too: the seeding effect only ever opens it to "All".
144 useEffect( () => {
145 if ( ! enabled ) {
146 seedStep.current = 0;
147 nativeSeeded.current = false;
148 setNativeFilter( NATIVE_ONLY );
149 }
150 }, [ enabled ] );
151
152 useEffect( () => {
153 if ( nativeSeeded.current || ! enabled || voicesResolving ) {
154 return;
155 }
156 const saved = ( voices ?? [] ).find(
157 ( voice ) => String( voice.id ) === String( voiceId )
158 );
159 if ( voiceId && ! saved ) {
160 return;
161 }
162 nativeSeeded.current = true;
163 if ( saved && languageCode && ! voiceIsNative( saved, languageCode ) ) {
164 setNativeFilter( NATIVE_ALL );
165 }
166 }, [ enabled, voicesResolving, voices, voiceId, languageCode ] );
167
168 const setVoiceId = ( value ) => {
169 onChange( { languageCode, voiceId: value } );
170 };
171
172 // Seeds the language's default body voice, so a stored language always
173 // carries a voice that can speak it.
174 const setLanguageCode = ( value ) => {
175 const language = ( languages ?? [] ).find(
176 ( item ) => decodeEntities( item.code ) === value
177 );
178 const defaultVoiceId = language?.default_voices?.body?.id;
179
180 onChange( {
181 languageCode: value,
182 voiceId: defaultVoiceId ? String( defaultVoiceId ) : '',
183 } );
184 };
185
186 const setLanguageName = ( name ) => {
187 const first = getAccentsForName( languages, name )[ 0 ];
188 setLanguageCode( first ? first.value : '' );
189 };
190
191 // The Accent select carries the language CODE — it is the stored value, and
192 // a (name, accent) pair maps to exactly one code.
193 const selectedLanguage = findLanguageByCode( languages, languageCode );
194 const languageName = selectedLanguage
195 ? decodeEntities( selectedLanguage.name )
196 : '';
197
198 const languageNameOptions = [
199 { label: __( 'Select a language…', 'speechkit' ), value: '' },
200 ...getLanguageNames( languages ).map( ( name ) => ( {
201 label: name,
202 value: name,
203 } ) ),
204 ];
205
206 const accentOptions = getAccentsForName( languages, languageName );
207
208 const showAccent = accentOptions.length > 1;
209
210 const filteredVoices = filterVoicesByNative(
211 voices,
212 languageCode,
213 nativeFilter,
214 voiceId
215 );
216
217 // "Model" is a language-level filter over the voices; with a single bucket
218 // there is no Model dropdown and every voice is listed.
219 const models = getLanguageModels( filteredVoices );
220 const showModel = models.length > 1;
221
222 const selectedVoice = filteredVoices.find(
223 ( voice ) => String( voice.id ) === String( voiceId )
224 );
225 // Derived from the selected voice — we persist only the voice id.
226 const selectedModelKey = selectedVoice
227 ? voiceModelKey( selectedVoice )
228 : '';
229
230 const bucketVoices = showModel
231 ? filteredVoices.filter(
232 ( voice ) => voiceModelKey( voice ) === selectedModelKey
233 )
234 : filteredVoices;
235
236 const hasVoices = filteredVoices.length > 0;
237
238 // Model gates the Voice list: hide Voice until a model is chosen.
239 const showVoice = hasVoices && ( ! showModel || '' !== selectedModelKey );
240
241 const modelOptions = [
242 { label: __( 'Select a model', 'speechkit' ), value: '' },
243 ...models.map( ( model ) => ( {
244 label: decodeEntities( model.label ),
245 value: model.key,
246 } ) ),
247 ];
248
249 const voiceOptions = [
250 { label: __( 'Select a voice', 'speechkit' ), value: '' },
251 ...bucketVoices.map( ( voice ) => ( {
252 label: decodeEntities( voice.name ),
253 value: String( voice.id ),
254 } ) ),
255 ];
256
257 // Picking a Model selects that bucket's first voice, so a concrete voice is
258 // always stored (the voice carries the model).
259 const setModel = ( key ) => {
260 const first = filteredVoices.find(
261 ( voice ) => voiceModelKey( voice ) === key
262 );
263 setVoiceId( first ? String( first.id ) : '' );
264 };
265
266 if ( ! enabled ) {
267 return null;
268 }
269
270 if ( languagesResolving ) {
271 return (
272 <div className="beyondwords--languages-spinner">
273 <Spinner />
274 </div>
275 );
276 }
277
278 return (
279 <Stack>
280 <SelectControl
281 className="beyondwords--language"
282 label={ __( 'Language', 'speechkit' ) }
283 options={ languageNameOptions }
284 value={ languageName }
285 onChange={ setLanguageName }
286 __nextHasNoMarginBottom
287 __next40pxDefaultSize
288 />
289 { showAccent && (
290 <SelectControl
291 className="beyondwords--accent"
292 label={ __( 'Accent', 'speechkit' ) }
293 options={ accentOptions }
294 value={ languageCode }
295 onChange={ setLanguageCode }
296 __nextHasNoMarginBottom
297 __next40pxDefaultSize
298 />
299 ) }
300 { languageCode && (
301 <SelectControl
302 className="beyondwords--native"
303 label={ __( 'Native', 'speechkit' ) }
304 options={ [
305 {
306 label: __( 'Native', 'speechkit' ),
307 value: NATIVE_ONLY,
308 },
309 { label: __( 'All', 'speechkit' ), value: NATIVE_ALL },
310 ] }
311 value={ nativeFilter }
312 onChange={ setNativeFilter }
313 __nextHasNoMarginBottom
314 __next40pxDefaultSize
315 />
316 ) }
317 { languageCode && ( showModel || showVoice ) && (
318 // Hidden with an inline style rather than unmounted, so the
319 // <select> can't detach mid-interaction or lose to component CSS.
320 <div
321 className="beyondwords--voice-fields"
322 style={ voicesResolving ? { display: 'none' } : undefined }
323 >
324 <Stack>
325 { showModel && (
326 <SelectControl
327 className="beyondwords--model"
328 label={ __( 'Model', 'speechkit' ) }
329 options={ modelOptions }
330 value={ selectedModelKey }
331 onChange={ setModel }
332 __nextHasNoMarginBottom
333 __next40pxDefaultSize
334 />
335 ) }
336 { showVoice && (
337 <SelectControl
338 className="beyondwords--voice"
339 label={ __( 'Voice', 'speechkit' ) }
340 options={ voiceOptions }
341 value={ String( voiceId ) }
342 onChange={ setVoiceId }
343 __nextHasNoMarginBottom
344 __next40pxDefaultSize
345 />
346 ) }
347 </Stack>
348 </div>
349 ) }
350 { languageCode && voicesResolving && (
351 <div className="beyondwords--voice-spinner">
352 <Spinner />
353 </div>
354 ) }
355 </Stack>
356 );
357 }
358
359 export default VoicePicker;
360