PluginProbe
BeyondWords – AI audio for publishers / 7.1.0
BeyondWords – AI audio for publishers v7.1.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.1.0, at src/editor/components/voice-picker/index.js

349 lines 9.3 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 useEffect( () => {
142 if ( nativeSeeded.current || ! enabled || voicesResolving ) {
143 return;
144 }
145 const saved = ( voices ?? [] ).find(
146 ( voice ) => String( voice.id ) === String( voiceId )
147 );
148 if ( voiceId && ! saved ) {
149 return;
150 }
151 nativeSeeded.current = true;
152 if ( saved && languageCode && ! voiceIsNative( saved, languageCode ) ) {
153 setNativeFilter( NATIVE_ALL );
154 }
155 }, [ enabled, voicesResolving, voices, voiceId, languageCode ] );
156
157 const setVoiceId = ( value ) => {
158 onChange( { languageCode, voiceId: value } );
159 };
160
161 // Seeds the language's default body voice, so a stored language always
162 // carries a voice that can speak it.
163 const setLanguageCode = ( value ) => {
164 const language = ( languages ?? [] ).find(
165 ( item ) => decodeEntities( item.code ) === value
166 );
167 const defaultVoiceId = language?.default_voices?.body?.id;
168
169 onChange( {
170 languageCode: value,
171 voiceId: defaultVoiceId ? String( defaultVoiceId ) : '',
172 } );
173 };
174
175 const setLanguageName = ( name ) => {
176 const first = getAccentsForName( languages, name )[ 0 ];
177 setLanguageCode( first ? first.value : '' );
178 };
179
180 // The Accent select carries the language CODE — it is the stored value, and
181 // a (name, accent) pair maps to exactly one code.
182 const selectedLanguage = findLanguageByCode( languages, languageCode );
183 const languageName = selectedLanguage
184 ? decodeEntities( selectedLanguage.name )
185 : '';
186
187 const languageNameOptions = [
188 { label: __( 'Select a language…', 'speechkit' ), value: '' },
189 ...getLanguageNames( languages ).map( ( name ) => ( {
190 label: name,
191 value: name,
192 } ) ),
193 ];
194
195 const accentOptions = getAccentsForName( languages, languageName );
196
197 const showAccent = accentOptions.length > 1;
198
199 const filteredVoices = filterVoicesByNative(
200 voices,
201 languageCode,
202 nativeFilter,
203 voiceId
204 );
205
206 // "Model" is a language-level filter over the voices; with a single bucket
207 // there is no Model dropdown and every voice is listed.
208 const models = getLanguageModels( filteredVoices );
209 const showModel = models.length > 1;
210
211 const selectedVoice = filteredVoices.find(
212 ( voice ) => String( voice.id ) === String( voiceId )
213 );
214 // Derived from the selected voice — we persist only the voice id.
215 const selectedModelKey = selectedVoice
216 ? voiceModelKey( selectedVoice )
217 : '';
218
219 const bucketVoices = showModel
220 ? filteredVoices.filter(
221 ( voice ) => voiceModelKey( voice ) === selectedModelKey
222 )
223 : filteredVoices;
224
225 const hasVoices = filteredVoices.length > 0;
226
227 // Model gates the Voice list: hide Voice until a model is chosen.
228 const showVoice = hasVoices && ( ! showModel || '' !== selectedModelKey );
229
230 const modelOptions = [
231 { label: __( 'Select a model', 'speechkit' ), value: '' },
232 ...models.map( ( model ) => ( {
233 label: decodeEntities( model.label ),
234 value: model.key,
235 } ) ),
236 ];
237
238 const voiceOptions = [
239 { label: __( 'Select a voice', 'speechkit' ), value: '' },
240 ...bucketVoices.map( ( voice ) => ( {
241 label: decodeEntities( voice.name ),
242 value: String( voice.id ),
243 } ) ),
244 ];
245
246 // Picking a Model selects that bucket's first voice, so a concrete voice is
247 // always stored (the voice carries the model).
248 const setModel = ( key ) => {
249 const first = filteredVoices.find(
250 ( voice ) => voiceModelKey( voice ) === key
251 );
252 setVoiceId( first ? String( first.id ) : '' );
253 };
254
255 if ( ! enabled ) {
256 return null;
257 }
258
259 if ( languagesResolving ) {
260 return (
261 <div className="beyondwords--languages-spinner">
262 <Spinner />
263 </div>
264 );
265 }
266
267 return (
268 <Stack>
269 <SelectControl
270 className="beyondwords--language"
271 label={ __( 'Language', 'speechkit' ) }
272 options={ languageNameOptions }
273 value={ languageName }
274 onChange={ setLanguageName }
275 __nextHasNoMarginBottom
276 __next40pxDefaultSize
277 />
278 { showAccent && (
279 <SelectControl
280 className="beyondwords--accent"
281 label={ __( 'Accent', 'speechkit' ) }
282 options={ accentOptions }
283 value={ languageCode }
284 onChange={ setLanguageCode }
285 __nextHasNoMarginBottom
286 __next40pxDefaultSize
287 />
288 ) }
289 { languageCode && (
290 <SelectControl
291 className="beyondwords--native"
292 label={ __( 'Native', 'speechkit' ) }
293 options={ [
294 {
295 label: __( 'Native', 'speechkit' ),
296 value: NATIVE_ONLY,
297 },
298 { label: __( 'All', 'speechkit' ), value: NATIVE_ALL },
299 ] }
300 value={ nativeFilter }
301 onChange={ setNativeFilter }
302 __nextHasNoMarginBottom
303 __next40pxDefaultSize
304 />
305 ) }
306 { languageCode && ( showModel || showVoice ) && (
307 // Hidden with an inline style rather than unmounted, so the
308 // <select> can't detach mid-interaction or lose to component CSS.
309 <div
310 className="beyondwords--voice-fields"
311 style={ voicesResolving ? { display: 'none' } : undefined }
312 >
313 <Stack>
314 { showModel && (
315 <SelectControl
316 className="beyondwords--model"
317 label={ __( 'Model', 'speechkit' ) }
318 options={ modelOptions }
319 value={ selectedModelKey }
320 onChange={ setModel }
321 __nextHasNoMarginBottom
322 __next40pxDefaultSize
323 />
324 ) }
325 { showVoice && (
326 <SelectControl
327 className="beyondwords--voice"
328 label={ __( 'Voice', 'speechkit' ) }
329 options={ voiceOptions }
330 value={ String( voiceId ) }
331 onChange={ setVoiceId }
332 __nextHasNoMarginBottom
333 __next40pxDefaultSize
334 />
335 ) }
336 </Stack>
337 </div>
338 ) }
339 { languageCode && voicesResolving && (
340 <div className="beyondwords--voice-spinner">
341 <Spinner />
342 </div>
343 ) }
344 </Stack>
345 );
346 }
347
348 export default VoicePicker;
349