PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.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 4.7.0 All 43 releases
speechkit / src / editor / components / select-voice / classic-metabox.js

classic-metabox.js in BeyondWords – AI audio for publishers 7.0.0, at src/editor/components/select-voice/classic-metabox.js

734 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* global beyondwordsData */
2
3 /*
4 * Classic-editor Customize + Language + Accent + Model + Voice behaviour. Mirrors
5 * the block editor's voice-section.js + helpers.js. Language lists names while
6 * Accent carries the submitted language CODE. The selects live in the post
7 * <form> and submit on save, so no autosave/Heartbeat hook is needed.
8 */
9 ( function () {
10 'use strict';
11
12 const data =
13 typeof beyondwordsData !== 'undefined' ? beyondwordsData : null;
14
15 const LANGUAGES = ( data && data.languages ) || [];
16 const ELEVENLABS = ( data && data.elevenLabs ) || 'ElevenLabs';
17 const DEFAULT_MODEL_ID =
18 ( data && data.defaultModelId ) || 'eleven_multilingual_v2';
19 const SELECT_VOICE = ( data && data.selectVoice ) || 'Select a voice';
20 const SELECT_MODEL = ( data && data.selectModel ) || 'Select a model';
21 const STANDARD_MODEL = ( data && data.standardModel ) || 'Legacy';
22 const MODEL_LABELS = ( data && data.voiceModelLabels ) || {};
23
24 // Bucket key for voices without an ElevenLabs model_id (e.g. standard voices).
25 const STANDARD_MODEL_KEY = 'standard';
26
27 /**
28 * Human label for a model_id slug.
29 *
30 * @param {string} modelId The model_id slug.
31 *
32 * @return {string} A display label.
33 */
34 const modelLabel = ( modelId ) => {
35 if ( MODEL_LABELS[ modelId ] ) {
36 return MODEL_LABELS[ modelId ];
37 }
38 return String( modelId )
39 .replace( /^eleven_/, '' )
40 .replace( /_/g, ' ' )
41 .replace( /\b\w/g, ( c ) => c.toUpperCase() );
42 };
43
44 /**
45 * The model bucket key for a voice: its ElevenLabs model_id, else Standard.
46 *
47 * @param {Object} voice A voice record.
48 *
49 * @return {string} The model bucket key.
50 */
51 const voiceModelKey = ( voice ) => {
52 if (
53 voice &&
54 voice.service === ELEVENLABS &&
55 typeof voice.model_id === 'string'
56 ) {
57 return voice.model_id;
58 }
59 return STANDARD_MODEL_KEY;
60 };
61
62 /**
63 * A voice's primary (native) language code.
64 *
65 * @param {Object} voice A voice record.
66 *
67 * @return {string} The primary language code, or ''.
68 */
69 const voicePrimaryCode = ( voice ) => {
70 const language = voice && voice.language;
71 if ( typeof language === 'string' ) {
72 return language;
73 }
74 if ( language && typeof language === 'object' && language.code ) {
75 return language.code;
76 }
77 return (
78 ( voice && voice.languages && voice.languages[ 0 ]
79 ? voice.languages[ 0 ].code
80 : '' ) || ''
81 );
82 };
83
84 /**
85 * Whether a voice is native to a language code. A voice with no
86 * determinable primary language is treated as native, so it is never hidden.
87 *
88 * @param {Object} voice A voice record.
89 * @param {string} code The language code.
90 *
91 * @return {boolean} Whether the voice is native to the code.
92 */
93 const voiceIsNative = ( voice, code ) => {
94 const primary = voicePrimaryCode( voice );
95 if ( ! primary ) {
96 return true;
97 }
98 return String( primary ) === String( code );
99 };
100
101 /**
102 * The distinct model buckets across a language's voices, for the Model dropdown.
103 *
104 * @param {Array<Object>} voices All voices for the current language.
105 *
106 * @return {Array<{key: string, label: string}>} The Model dropdown options.
107 */
108 const languageModels = ( voices ) => {
109 const modelIds = [];
110 let hasStandard = false;
111
112 ( voices || [] ).forEach( ( voice ) => {
113 const key = voiceModelKey( voice );
114 if ( key === STANDARD_MODEL_KEY ) {
115 hasStandard = true;
116 } else if ( ! modelIds.includes( key ) ) {
117 modelIds.push( key );
118 }
119 } );
120
121 modelIds.sort( ( a, b ) => {
122 if ( a === DEFAULT_MODEL_ID ) {
123 return -1;
124 }
125 if ( b === DEFAULT_MODEL_ID ) {
126 return 1;
127 }
128 return 0;
129 } );
130
131 const models = modelIds.map( ( key ) => ( {
132 key,
133 label: modelLabel( key ),
134 } ) );
135
136 if ( hasStandard ) {
137 models.push( { key: STANDARD_MODEL_KEY, label: STANDARD_MODEL } );
138 }
139
140 return models;
141 };
142
143 const option = ( value, text ) => {
144 const el = document.createElement( 'option' );
145 el.value = value;
146 el.textContent = text;
147 return el;
148 };
149
150 const byId = ( id ) => document.getElementById( id );
151
152 /**
153 * The language rows (accents) for a language name, in API order.
154 *
155 * @param {string} name The language name, or ''.
156 *
157 * @return {Array<Object>} The matching slim language rows.
158 */
159 const accentsForName = ( name ) =>
160 name ? LANGUAGES.filter( ( language ) => language.name === name ) : [];
161
162 /**
163 * Find a slim language row by its code.
164 *
165 * @param {string} code The language code.
166 *
167 * @return {Object|null} The matching row, or null.
168 */
169 const findLanguageByCode = ( code ) =>
170 LANGUAGES.find(
171 ( language ) => String( language.code ) === String( code )
172 ) || null;
173
174 /**
175 * An Accent <option> for a language row: the CODE as its value, carrying
176 * the language's default body voice id for the voice-seeding flow.
177 *
178 * @param {Object} language A slim language row.
179 *
180 * @return {HTMLOptionElement} The option element.
181 */
182 const accentOption = ( language ) => {
183 const el = option( String( language.code ), language.accent );
184 el.setAttribute(
185 'data-default-voice-id',
186 language.defaultVoiceId ? String( language.defaultVoiceId ) : ''
187 );
188 return el;
189 };
190
191 const toggleLoader = ( show ) => {
192 const loader = document.querySelector(
193 '.beyondwords-settings__loader'
194 );
195 if ( loader ) {
196 loader.style.display = show ? '' : 'none';
197 }
198 };
199
200 const selectVoice = {
201 voices: [],
202 voicesReq: 0,
203
204 init() {
205 if ( ! data ) {
206 // eslint-disable-next-line no-console
207 console.log( '🔊 Unable to retrive WP REST API settings' );
208 return;
209 }
210
211 const customize = byId( 'beyondwords_customize' );
212 const languageName = byId( 'beyondwords_language_name' );
213 const language = byId( 'beyondwords_language_code' );
214 const native = byId( 'beyondwords_native' );
215 const model = byId( 'beyondwords_model' );
216
217 if ( customize ) {
218 customize.addEventListener( 'change', ( event ) => {
219 this.toggleCustomize( event.target.checked );
220 } );
221 }
222
223 if ( native ) {
224 native.addEventListener( 'change', () => {
225 const voiceSelect = byId( 'beyondwords_voice_id' );
226 this.renderModels( voiceSelect ? voiceSelect.value : '' );
227 } );
228 }
229
230 if ( languageName ) {
231 languageName.addEventListener( 'change', ( event ) => {
232 this.onLanguageNameChange( event.target.value );
233 } );
234 }
235
236 if ( language ) {
237 language.addEventListener( 'change', ( event ) => {
238 const select = event.target;
239 const selected = select.options[ select.selectedIndex ];
240 const defaultVoiceId = selected
241 ? selected.getAttribute( 'data-default-voice-id' )
242 : '';
243 this.getVoices( select.value, defaultVoiceId );
244 } );
245 }
246
247 if ( model ) {
248 model.addEventListener( 'change', ( event ) => {
249 this.onModelChange( event.target.value );
250 } );
251 }
252
253 // Seed this.voices so the Model filter has data before the user interacts.
254 this.hydrate();
255 },
256
257 /**
258 * Show/hide the language/model/voice fields.
259 *
260 * Customize off clears the selects so they submit empty and save()
261 * removes the meta, reverting the post to the project defaults.
262 *
263 * @param {boolean} on Whether Customize is enabled.
264 */
265 toggleCustomize( on ) {
266 const fields = byId( 'beyondwords-metabox-select-voice--fields' );
267 if ( fields ) {
268 fields.style.display = on ? '' : 'none';
269 }
270
271 if ( on ) {
272 this.applyProjectDefaultLanguage();
273 return;
274 }
275
276 const languageName = byId( 'beyondwords_language_name' );
277 if ( languageName ) {
278 languageName.value = '';
279 }
280
281 // Leaves a single empty option, so it submits '' and save() removes the meta.
282 this.renderAccents( '', '' );
283
284 this.voices = [];
285 this.renderModels( '' );
286 },
287
288 /**
289 * Pick a language NAME → rebuild the Accent select, auto-select its
290 * first accent and seed that language's default body voice.
291 *
292 * @param {string} name The language name, or '' for the placeholder.
293 */
294 onLanguageNameChange( name ) {
295 const first = this.renderAccents( name, '' );
296
297 if ( first ) {
298 this.getVoices(
299 String( first.code ),
300 first.defaultVoiceId ? String( first.defaultVoiceId ) : ''
301 );
302 } else {
303 this.getVoices( '', '' );
304 }
305 },
306
307 /**
308 * Rebuild the Accent select for a language name, selecting selectedCode
309 * or the first accent. The wrapper is hidden for a single accent, but
310 * the select stays mounted so it still submits that accent's code.
311 *
312 * @param {string} name The language name, or ''.
313 * @param {string} selectedCode The language code to select, or ''.
314 *
315 * @return {Object|null} The selected slim language row, or null.
316 */
317 renderAccents( name, selectedCode ) {
318 const wrapper = byId( 'beyondwords-metabox-select-voice--accent' );
319 const accentSelect = byId( 'beyondwords_language_code' );
320 const accents = accentsForName( name );
321
322 let selected = null;
323
324 if ( accents.length ) {
325 selected =
326 accents.find(
327 ( language ) =>
328 String( language.code ) === String( selectedCode )
329 ) || accents[ 0 ];
330 }
331
332 if ( accentSelect ) {
333 if ( selected ) {
334 accentSelect.replaceChildren(
335 ...accents.map( accentOption )
336 );
337 accentSelect.value = String( selected.code );
338 } else {
339 accentSelect.replaceChildren( option( '', '' ) );
340 accentSelect.value = '';
341 }
342 }
343
344 if ( wrapper ) {
345 wrapper.style.display = accents.length > 1 ? '' : 'none';
346 }
347
348 return selected;
349 },
350
351 /**
352 * Programmatically select a language code across the Language + Accent
353 * selects and fetch its voices.
354 *
355 * @param {string} code The language code.
356 * @param {string} seedVoiceId The voice id to seed, or '' for none.
357 *
358 * @return {boolean} Whether the code matched a known language.
359 */
360 selectCode( code, seedVoiceId ) {
361 const row = findLanguageByCode( code );
362
363 if ( ! row ) {
364 return false;
365 }
366
367 const nameSelect = byId( 'beyondwords_language_name' );
368 if ( nameSelect ) {
369 nameSelect.value = row.name;
370 }
371
372 this.renderAccents( row.name, String( code ) );
373 this.getVoices( String( code ), seedVoiceId || '' );
374
375 return true;
376 },
377
378 /**
379 * On Customize-on, fetch the project's default language and pre-select it.
380 *
381 * Only the language is seeded — the user picks the Model + Voice; on
382 * failure we fall back to the manual "pick a language" flow.
383 */
384 applyProjectDefaultLanguage() {
385 const language = byId( 'beyondwords_language_code' );
386
387 // Only pre-fill a fresh post; never override an existing choice.
388 if ( ! language || language.value || ! data.projectId ) {
389 return;
390 }
391
392 toggleLoader( true );
393
394 const endpoint = `${ data.root }beyondwords/v1/projects/${ data.projectId }`;
395
396 window
397 .fetch( endpoint, {
398 method: 'GET',
399 headers: { 'X-WP-Nonce': data.nonce },
400 } )
401 .then( ( response ) => response.json() )
402 .then( ( project ) => {
403 // Bail if Customize was switched off, or a language chosen, while
404 // in flight — else we'd persist a language on an un-customised post.
405 const customize = byId( 'beyondwords_customize' );
406 if (
407 ! customize ||
408 ! customize.checked ||
409 language.value
410 ) {
411 toggleLoader( false );
412 return;
413 }
414
415 const lang = project && project.language;
416 if ( ! lang || ! this.selectCode( String( lang ), '' ) ) {
417 toggleLoader( false );
418 }
419 } )
420 .catch( () => {
421 toggleLoader( false );
422 } );
423 },
424
425 /**
426 * Hydrate this.voices for an already-customized saved post.
427 *
428 * Without it the first Model change runs against empty state and save()
429 * drops the stored voice. Loads without clearing, so the server-rendered
430 * dropdowns stay put until the fetch re-renders the same selection.
431 */
432 hydrate() {
433 const customize = byId( 'beyondwords_customize' );
434 const language = byId( 'beyondwords_language_code' );
435
436 // Only a saved customized post needs hydrating; a fresh one fetches on demand.
437 if (
438 ! customize ||
439 ! customize.checked ||
440 ! language ||
441 ! language.value
442 ) {
443 return;
444 }
445
446 const voiceSelect = byId( 'beyondwords_voice_id' );
447 const savedVoiceId = voiceSelect ? voiceSelect.value : '';
448
449 // Disable the Model filter until voices load so a change can't run
450 // against empty state; it carries no name, so submits are unaffected.
451 const modelSelect = byId( 'beyondwords_model' );
452 if ( modelSelect ) {
453 modelSelect.disabled = true;
454 }
455
456 this.loadVoices( language.value )
457 .then( ( applied ) => {
458 if ( applied ) {
459 this.renderModels( savedVoiceId );
460 }
461 } )
462 .finally( () => {
463 if ( modelSelect ) {
464 modelSelect.disabled = false;
465 }
466 } );
467 },
468
469 /**
470 * Get voices for a language, then rebuild the Model + Voice dropdowns.
471 *
472 * A supplied default voice pre-selects its model and voice; otherwise the
473 * Model opens on its placeholder and Voice stays hidden until one is picked.
474 *
475 * @param {string} languageCode The language code.
476 * @param {string} defaultVoiceId The language's default body voice id.
477 */
478 getVoices( languageCode, defaultVoiceId ) {
479 // Clear the stale UI while the new language's voices resolve.
480 this.voices = [];
481 this.renderModels( '' );
482
483 this.loadVoices( languageCode ).then( ( applied ) => {
484 if ( applied ) {
485 this.renderModels( defaultVoiceId );
486 }
487 } );
488 },
489
490 /**
491 * Fetch a language's voices into this.voices. Does not render.
492 *
493 * Resolves true when this (latest) fetch applied; false when superseded,
494 * on error, or when no language is given.
495 *
496 * @param {string} languageCode The language code.
497 *
498 * @return {Promise<boolean>} Whether this fetch applied.
499 */
500 loadVoices( languageCode ) {
501 toggleLoader( true );
502
503 if ( ! languageCode ) {
504 toggleLoader( false );
505 return Promise.resolve( false );
506 }
507
508 // Serialise concurrent fetches — only the latest one applies.
509 const reqId = ++this.voicesReq;
510
511 const endpoint = `${ data.root }beyondwords/v1/languages/${ languageCode }/voices`;
512
513 return window
514 .fetch( endpoint, {
515 method: 'GET',
516 headers: { 'X-WP-Nonce': data.nonce },
517 } )
518 .then( ( response ) => {
519 // fetch doesn't reject on HTTP errors; a REST error (e.g. expired
520 // nonce → 403) resolves with a JSON object, so throw into the catch.
521 if ( ! response.ok ) {
522 return response
523 .json()
524 .catch( () => null )
525 .then( ( body ) => {
526 throw new Error(
527 ( body && body.message ) ||
528 `HTTP ${ response.status }`
529 );
530 } );
531 }
532 return response.json();
533 } )
534 .then( ( voices ) => {
535 if ( reqId !== this.voicesReq ) {
536 return false;
537 }
538 this.voices = Array.isArray( voices ) ? voices : [];
539 return true;
540 } )
541 .catch( ( error ) => {
542 if ( reqId !== this.voicesReq ) {
543 return false;
544 }
545 // eslint-disable-next-line no-console
546 console.log( '🔊 Unable to load voices', error );
547 this.voices = [];
548 return false;
549 } )
550 .finally( () => {
551 if ( reqId === this.voicesReq ) {
552 toggleLoader( false );
553 }
554 } );
555 },
556
557 /**
558 * The current voices narrowed by the Native filter. keepId is always
559 * kept, so toggling the filter never drops the current selection.
560 *
561 * @param {string} keepId The voice id to always keep, or ''.
562 *
563 * @return {Array<Object>} The native-scoped voices.
564 */
565 scopedVoices( keepId ) {
566 const nativeSelect = byId( 'beyondwords_native' );
567 const codeSelect = byId( 'beyondwords_language_code' );
568 const nativeFilter = nativeSelect ? nativeSelect.value : 'native';
569 const code = codeSelect ? codeSelect.value : '';
570
571 let result =
572 nativeFilter === 'all'
573 ? this.voices
574 : this.voices.filter( ( voice ) =>
575 voiceIsNative( voice, code )
576 );
577
578 if (
579 keepId &&
580 ! result.some(
581 ( voice ) => String( voice.id ) === String( keepId )
582 )
583 ) {
584 const saved = this.voices.find(
585 ( voice ) => String( voice.id ) === String( keepId )
586 );
587 if ( saved ) {
588 result = result.concat( [ saved ] );
589 }
590 }
591
592 return result;
593 },
594
595 /**
596 * Rebuild the Model dropdown from the native-scoped voices, then the Voice
597 * dropdown. The Model dropdown is hidden when the scoped set offers one bucket.
598 *
599 * @param {string} selectedVoiceId The voice id to pre-select, or ''.
600 */
601 renderModels( selectedVoiceId ) {
602 const modelWrapper = byId(
603 'beyondwords-metabox-select-voice--model'
604 );
605 const modelSelect = byId( 'beyondwords_model' );
606
607 const voices = this.scopedVoices( selectedVoiceId );
608 const models = languageModels( voices );
609 const showModel = models.length > 1;
610
611 if ( modelSelect ) {
612 modelSelect.replaceChildren(
613 option( '', SELECT_MODEL ),
614 ...models.map( ( model ) =>
615 option( model.key, model.label )
616 )
617 );
618 }
619
620 const selectedVoice = selectedVoiceId
621 ? voices.find(
622 ( voice ) =>
623 String( voice.id ) === String( selectedVoiceId )
624 )
625 : null;
626 const selectedKey = selectedVoice
627 ? voiceModelKey( selectedVoice )
628 : '';
629
630 if ( modelSelect ) {
631 modelSelect.value = showModel ? selectedKey : '';
632 }
633 if ( modelWrapper ) {
634 modelWrapper.style.display = showModel ? '' : 'none';
635 }
636
637 this.renderVoices(
638 selectedKey,
639 selectedVoiceId,
640 showModel,
641 voices
642 );
643 },
644
645 /**
646 * Rebuild the Voice dropdown for a model bucket and select a voice.
647 *
648 * Hidden while gated with no model chosen; a single bucket lists every voice.
649 *
650 * @param {string} modelKey The selected model bucket key, or ''.
651 * @param {string} preselectId The voice id to select if in the bucket.
652 * @param {boolean} showModel Whether the Model dropdown is shown.
653 * @param {Array<Object>} voices The native-scoped voices to list.
654 */
655 renderVoices( modelKey, preselectId, showModel, voices ) {
656 const voiceWrapper = byId(
657 'beyondwords-metabox-select-voice--voice-id'
658 );
659 const voiceSelect = byId( 'beyondwords_voice_id' );
660
661 if ( showModel && '' === modelKey ) {
662 if ( voiceSelect ) {
663 voiceSelect.replaceChildren( option( '', SELECT_VOICE ) );
664 voiceSelect.value = '';
665 }
666 if ( voiceWrapper ) {
667 voiceWrapper.style.display = 'none';
668 }
669 return;
670 }
671
672 const bucketVoices = showModel
673 ? voices.filter(
674 ( voice ) => voiceModelKey( voice ) === modelKey
675 )
676 : voices;
677
678 if ( voiceSelect ) {
679 voiceSelect.replaceChildren(
680 option( '', SELECT_VOICE ),
681 ...bucketVoices.map( ( voice ) =>
682 option( String( voice.id ), voice.name )
683 )
684 );
685
686 const inBucket = bucketVoices.some(
687 ( voice ) => String( voice.id ) === String( preselectId )
688 );
689 voiceSelect.value = inBucket ? String( preselectId ) : '';
690 }
691
692 if ( voiceWrapper ) {
693 voiceWrapper.style.display = bucketVoices.length ? '' : 'none';
694 }
695 },
696
697 /**
698 * Pick a model: list that bucket's voices and select the first.
699 *
700 * A concrete voice id is always submitted (the voice carries the model).
701 *
702 * @param {string} modelKey The selected model bucket key.
703 */
704 onModelChange( modelKey ) {
705 const voiceSelect = byId( 'beyondwords_voice_id' );
706 const voices = this.scopedVoices(
707 voiceSelect ? voiceSelect.value : ''
708 );
709
710 if ( ! modelKey ) {
711 this.renderVoices( '', '', true, voices );
712 return;
713 }
714 const first = voices.find(
715 ( voice ) => voiceModelKey( voice ) === modelKey
716 );
717 this.renderVoices(
718 modelKey,
719 first ? String( first.id ) : '',
720 true,
721 voices
722 );
723 },
724 };
725
726 if ( document.readyState !== 'loading' ) {
727 selectVoice.init();
728 } else {
729 document.addEventListener( 'DOMContentLoaded', () =>
730 selectVoice.init()
731 );
732 }
733 } )();
734