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 / class-select-voice.php

class-select-voice.php in BeyondWords – AI audio for publishers 7.0.0, at src/editor/components/select-voice/class-select-voice.php

955 lines 26.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare( strict_types = 1 );
4
5 /**
6 * BeyondWords Component: Select Voice
7 *
8 * @package BeyondWords\Editor\Components
9 * @author Stuart McAlpine <stu@beyondwords.io>
10 * @since 4.0.0
11 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
12 */
13
14 namespace BeyondWords\Editor\Components;
15
16 /**
17 * SelectVoice
18 *
19 * @since 4.0.0
20 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
21 */
22 defined( 'ABSPATH' ) || exit;
23
24 class SelectVoice {
25
26 /**
27 * The only voice service whose voices carry a selectable `model_id`.
28 *
29 * @since 7.0.0
30 */
31 public const ELEVENLABS_SERVICE = 'ElevenLabs';
32
33 /**
34 * The model listed first in the Model dropdown.
35 *
36 * @since 7.0.0
37 */
38 public const DEFAULT_ELEVENLABS_VOICE_MODEL_ID = 'eleven_multilingual_v2';
39
40 /**
41 * Bucket key for voices without an ElevenLabs model_id (e.g. standard voices).
42 *
43 * @since 7.0.0
44 */
45 public const STANDARD_MODEL_KEY = 'standard';
46
47 /**
48 * Init.
49 *
50 * @since 4.0.0
51 * @since 6.0.0 Make static.
52 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
53 */
54 public static function init() {
55 add_action( 'rest_api_init', [ self::class, 'rest_api_init_callback'] );
56
57 add_action(
58 'wp_loaded',
59 function (): void {
60 $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
61
62 if ( is_array( $post_types ) ) {
63 foreach ( $post_types as $post_type ) {
64 add_action( "save_post_{$post_type}", [ self::class, 'save'], 10 );
65 }
66 }
67 }
68 );
69 }
70
71 /**
72 * HTML output for this component.
73 *
74 * @since 4.0.0
75 * @since 4.5.1 Hide element if no language data exists.
76 * @since 5.4.0 Always display all languages and associated voices.
77 * @since 6.0.0 Make static.
78 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
79 *
80 * @param \WP_Post $post The post object.
81 *
82 * @return string|null
83 */
84 public static function element( $post ) {
85 $language_code = self::get_language_code( $post->ID );
86 $voice_id = self::get_voice_id( $post->ID );
87 $languages = self::get_languages();
88 $voices = self::get_voices_for_language( $language_code );
89
90 $native_filter = self::default_native_filter( $voices, $language_code, $voice_id );
91 $filtered_voices = self::filter_voices_by_native( $voices, $language_code, $native_filter, $voice_id );
92
93 // "Customize" is opt-in: a post is customised once it has an explicit
94 // language or voice; when off we store nothing so project defaults apply.
95 $customize = '' !== (string) $language_code || '' !== (string) $voice_id;
96 $fields_style = $customize ? '' : 'display: none;';
97
98 wp_nonce_field( 'beyondwords_select_voice', 'beyondwords_select_voice_nonce' );
99
100 self::render_customize_toggle( $customize );
101 ?>
102 <div id="beyondwords-metabox-select-voice--fields" style="<?php echo esc_attr( $fields_style ); ?>">
103 <?php
104 self::render_language_name_select( $languages, $language_code );
105 self::render_accent_select( $languages, $language_code );
106 self::render_native_select( $native_filter );
107 self::render_model_select( $filtered_voices, $voice_id );
108 self::render_voice_select( $filtered_voices, $voice_id );
109 self::render_loading_spinner();
110 ?>
111 </div>
112 <?php
113 }
114
115 /**
116 * Render the "Customize" toggle.
117 *
118 * When unchecked, classic-metabox.js hides the fields and clears the selects
119 * so save() removes the meta and the project defaults apply.
120 *
121 * @since 7.0.0
122 *
123 * @param bool $customize Whether Customize is currently enabled.
124 */
125 private static function render_customize_toggle( bool $customize ): void {
126 ?>
127 <p
128 id="beyondwords-metabox-select-voice--customize"
129 class="post-attributes-label-wrapper page-template-label-wrapper"
130 >
131 <label class="post-attributes-label" for="beyondwords_customize">
132 <?php esc_html_e( 'Customize', 'speechkit' ); ?>
133 </label>
134 </p>
135 <input
136 type="checkbox"
137 id="beyondwords_customize"
138 name="beyondwords_customize"
139 value="1"
140 <?php checked( $customize ); ?>
141 />
142 <?php
143 }
144
145 /**
146 * Get the language code for a post.
147 *
148 * @since 6.0.0
149 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
150 *
151 * @param int $post_id The post ID.
152 * @return string|false The language code or false if not set.
153 */
154 private static function get_language_code( int $post_id ) {
155 $post_language_code = get_post_meta( $post_id, 'beyondwords_language_code', true );
156 return $post_language_code ?: '';
157 }
158
159 /**
160 * Get the voice ID for a post.
161 *
162 * @since 6.0.0
163 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
164 *
165 * @param int $post_id The post ID.
166 * @return string|false The voice ID or false if not set.
167 */
168 private static function get_voice_id( int $post_id ) {
169 $post_voice_id = get_post_meta( $post_id, 'beyondwords_body_voice_id', true );
170 return $post_voice_id ?: '';
171 }
172
173 /**
174 * Get all available languages.
175 *
176 * Coerced to a list of language records so an API failure (null/false, or a
177 * decoded error body) degrades to an empty dropdown instead of a TypeError.
178 *
179 * @since 7.0.0
180 *
181 * @return array The languages array, or an empty array on API failure.
182 */
183 private static function get_languages(): array {
184 $languages = \BeyondWords\Api\Client::get_languages();
185
186 if ( ! is_array( $languages ) ) {
187 return [];
188 }
189
190 return array_values( array_filter( $languages, 'is_array' ) );
191 }
192
193 /**
194 * Get voices for a language code.
195 *
196 * Coerced to a list of voice records so an API failure (null/false, or a
197 * decoded error body like a 429's) degrades to empty instead of fataling.
198 *
199 * @since 6.0.0
200 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
201 * @since 7.0.0 Drop non-array elements so an API error body can't fatal the render.
202 *
203 * @param string|false $language_code The language code.
204 * @return array The voices array, or an empty array on API failure.
205 */
206 private static function get_voices_for_language( $language_code ): array {
207 if ( $language_code === false || $language_code === '' ) {
208 return [];
209 }
210
211 $voices = \BeyondWords\Api\Client::get_voices( $language_code );
212
213 if ( ! is_array( $voices ) ) {
214 return [];
215 }
216
217 return array_values( array_filter( $voices, 'is_array' ) );
218 }
219
220 /**
221 * Whether a language row carries the fields the pickers need.
222 *
223 * @since 7.0.0
224 *
225 * @param mixed $language A language record.
226 *
227 * @return bool
228 */
229 private static function is_valid_language( $language ): bool {
230 return is_array( $language )
231 && ! empty( $language['code'] )
232 && ! empty( $language['name'] )
233 && ! empty( $language['accent'] );
234 }
235
236 /**
237 * The distinct language names across the language rows, in API order.
238 *
239 * @since 7.0.0
240 *
241 * @param array $languages The languages array.
242 *
243 * @return string[] The language names.
244 */
245 public static function language_names( array $languages ): array {
246 $names = [];
247
248 foreach ( $languages as $language ) {
249 if ( ! self::is_valid_language( $language ) ) {
250 continue;
251 }
252 if ( ! in_array( $language['name'], $names, true ) ) {
253 $names[] = $language['name'];
254 }
255 }
256
257 return $names;
258 }
259
260 /**
261 * The language rows (accents) for a language name, in API order.
262 *
263 * @since 7.0.0
264 *
265 * @param array $languages The languages array.
266 * @param string $name The language name.
267 *
268 * @return array The matching language rows.
269 */
270 public static function accents_for_name( array $languages, string $name ): array {
271 if ( '' === $name ) {
272 return [];
273 }
274
275 return array_values(
276 array_filter(
277 $languages,
278 static function ( $language ) use ( $name ) {
279 return self::is_valid_language( $language ) && $language['name'] === $name;
280 }
281 )
282 );
283 }
284
285 /**
286 * Find a language row by its code.
287 *
288 * @since 7.0.0
289 *
290 * @param array $languages The languages array.
291 * @param string|false $code The language code.
292 *
293 * @return array|null The matching language row, or null.
294 */
295 public static function find_language_by_code( array $languages, $code ): ?array {
296 foreach ( $languages as $language ) {
297 if ( self::is_valid_language( $language ) && strval( $language['code'] ) === strval( $code ) ) {
298 return $language;
299 }
300 }
301
302 return null;
303 }
304
305 /**
306 * Slim language rows for the classic-editor script, so it can rebuild the
307 * Accent dropdown client-side without another API round-trip.
308 *
309 * @since 7.0.0
310 *
311 * @return array The slim language rows.
312 */
313 public static function languages_for_script(): array {
314 $rows = [];
315
316 foreach ( self::get_languages() as $language ) {
317 if ( ! self::is_valid_language( $language ) ) {
318 continue;
319 }
320
321 $rows[] = [
322 'code' => strval( $language['code'] ),
323 'name' => strval( $language['name'] ),
324 'accent' => strval( $language['accent'] ),
325 'defaultVoiceId' => strval( $language['default_voices']['body']['id'] ?? '' ),
326 ];
327 }
328
329 return $rows;
330 }
331
332 /**
333 * Render the Language select: one entry per language NAME (e.g. "English").
334 * Carries no `name` attribute, so it is not submitted — the Accent select
335 * holds the `beyondwords_language_code` field.
336 *
337 * @since 6.0.0 As render_language_select, combining name + accent.
338 * @since 7.0.0 Split into Language (name) + Accent selects.
339 *
340 * @param array $languages The languages array.
341 * @param string|false $selected_lang_code The selected language code.
342 */
343 private static function render_language_name_select( array $languages, $selected_lang_code ): void {
344 $selected_language = self::find_language_by_code( $languages, $selected_lang_code );
345 $selected_name = $selected_language['name'] ?? '';
346 ?>
347 <p
348 id="beyondwords-metabox-select-voice--language-name"
349 class="post-attributes-label-wrapper page-template-label-wrapper"
350 >
351 <label class="post-attributes-label" for="beyondwords_language_name">
352 <?php esc_html_e( 'Language', 'speechkit' ); ?>
353 </label>
354 </p>
355 <select id="beyondwords_language_name" style="width: 100%;">
356 <?php
357 printf(
358 '<option value="" %s>%s</option>',
359 selected( '', strval( $selected_name ), false ),
360 esc_html__( 'Select a language…', 'speechkit' )
361 );
362 foreach ( self::language_names( $languages ) as $name ) {
363 printf(
364 '<option value="%s" %s>%s</option>',
365 esc_attr( $name ),
366 selected( $name, strval( $selected_name ), false ),
367 esc_html( $name )
368 );
369 }
370 ?>
371 </select>
372 <?php
373 }
374
375 /**
376 * Render the Accent select — the submitted `beyondwords_language_code` field,
377 * since a (name, accent) pair maps to exactly one language code. Hidden, not
378 * omitted, for single-accent languages, and always renders an option because
379 * save() requires the key to be posted.
380 *
381 * @since 7.0.0
382 *
383 * @param array $languages The languages array.
384 * @param string|false $selected_lang_code The selected language code.
385 */
386 private static function render_accent_select( array $languages, $selected_lang_code ): void {
387 $selected_language = self::find_language_by_code( $languages, $selected_lang_code );
388 $selected_name = $selected_language['name'] ?? '';
389 $accents = self::accents_for_name( $languages, strval( $selected_name ) );
390
391 $show_accent = count( $accents ) > 1;
392 $accent_style = $show_accent ? '' : 'display: none;';
393 ?>
394 <div
395 id="beyondwords-metabox-select-voice--accent"
396 class="beyondwords-metabox-settings__field"
397 style="<?php echo esc_attr( $accent_style ); ?>"
398 >
399 <p class="post-attributes-label-wrapper page-template-label-wrapper">
400 <label class="post-attributes-label" for="beyondwords_language_code">
401 <?php esc_html_e( 'Accent', 'speechkit' ); ?>
402 </label>
403 </p>
404 <select id="beyondwords_language_code" name="beyondwords_language_code" style="width: 100%;">
405 <?php
406 if ( empty( $accents ) ) {
407 echo '<option value=""></option>';
408 }
409 foreach ( $accents as $language ) {
410 printf(
411 '<option value="%s" data-default-voice-id="%s" %s>%s</option>',
412 esc_attr( $language['code'] ),
413 esc_attr( $language['default_voices']['body']['id'] ?? '' ),
414 selected( strval( $language['code'] ), strval( $selected_lang_code ), false ),
415 esc_html( $language['accent'] )
416 );
417 }
418 ?>
419 </select>
420 </div>
421 <?php
422 }
423
424 /**
425 * Render the Native select: filter the Voice list to voices native to the
426 * language, or all. Carries no `name`, so it is not submitted.
427 *
428 * @since 7.0.0
429 *
430 * @param string $native_filter The selected filter ("native" or "all").
431 */
432 private static function render_native_select( string $native_filter ): void {
433 $options = [
434 'native' => __( 'Native', 'speechkit' ),
435 'all' => __( 'All', 'speechkit' ),
436 ];
437 ?>
438 <div
439 id="beyondwords-metabox-select-voice--native"
440 class="beyondwords-metabox-settings__field"
441 >
442 <p class="post-attributes-label-wrapper page-template-label-wrapper">
443 <label class="post-attributes-label" for="beyondwords_native">
444 <?php esc_html_e( 'Native', 'speechkit' ); ?>
445 </label>
446 </p>
447 <select id="beyondwords_native" style="width: 100%;">
448 <?php
449 foreach ( $options as $value => $label ) {
450 printf(
451 '<option value="%s" %s>%s</option>',
452 esc_attr( $value ),
453 selected( $value, $native_filter, false ),
454 esc_html( $label )
455 );
456 }
457 ?>
458 </select>
459 </div>
460 <?php
461 }
462
463 /**
464 * Render the Model select: a language-level filter over the voices.
465 *
466 * Carries no `name` — it is never submitted; the persisted value is the
467 * voice id. Hidden when a language offers a single bucket.
468 *
469 * @since 7.0.0
470 *
471 * @param array $voices The voices array.
472 * @param string|false $selected_voice_id The selected voice ID.
473 */
474 private static function render_model_select( array $voices, $selected_voice_id ): void {
475 $selected_voice = self::find_voice( $voices, $selected_voice_id );
476 $selected_key = $selected_voice ? self::voice_model_key( $selected_voice ) : '';
477
478 $models = self::language_models( $voices );
479 $show_model = count( $models ) > 1;
480 ?>
481 <div
482 id="beyondwords-metabox-select-voice--model"
483 class="beyondwords-metabox-settings__field"
484 <?php echo $show_model ? '' : 'style="display: none;"'; ?>
485 >
486 <p class="post-attributes-label-wrapper page-template-label-wrapper">
487 <label class="post-attributes-label" for="beyondwords_model">
488 <?php esc_html_e( 'Model', 'speechkit' ); ?>
489 </label>
490 </p>
491 <select id="beyondwords_model" style="width: 100%;">
492 <?php
493 printf(
494 '<option value="" %s>%s</option>',
495 selected( '', $show_model ? strval( $selected_key ) : '', false ),
496 esc_html__( 'Select a model', 'speechkit' )
497 );
498 foreach ( $models as $model ) {
499 printf(
500 '<option value="%s" %s>%s</option>',
501 esc_attr( $model['key'] ),
502 selected( strval( $model['key'] ), $show_model ? strval( $selected_key ) : '', false ),
503 esc_html( $model['label'] )
504 );
505 }
506 ?>
507 </select>
508 </div>
509 <?php
510 }
511
512 /**
513 * Render the Voice select: the voices in the currently selected model bucket.
514 *
515 * The saved field (`beyondwords_voice_id`) — the submitted voice id carries
516 * the model.
517 *
518 * @since 6.0.0
519 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
520 * @since 7.0.0 Model-first: scope the Voice list to the selected model.
521 *
522 * @param array $voices The voices array.
523 * @param string|false $selected_voice_id The selected voice ID.
524 */
525 private static function render_voice_select( array $voices, $selected_voice_id ): void {
526 $selected_voice = self::find_voice( $voices, $selected_voice_id );
527 $selected_key = $selected_voice ? self::voice_model_key( $selected_voice ) : '';
528
529 $models = self::language_models( $voices );
530 $show_model = count( $models ) > 1;
531
532 if ( $show_model ) {
533 $bucket_voices = array_values(
534 array_filter(
535 $voices,
536 static function ( $voice ) use ( $selected_key ) {
537 return self::voice_model_key( $voice ) === $selected_key;
538 }
539 )
540 );
541 } else {
542 $bucket_voices = array_values( $voices );
543 }
544
545 // Model gates the Voice list: hide it until a model is chosen.
546 $show_voice = count( $voices ) > 0 && ( ! $show_model || '' !== strval( $selected_key ) );
547 $voice_style = $show_voice ? '' : 'display: none;';
548 ?>
549 <div
550 id="beyondwords-metabox-select-voice--voice-id"
551 class="beyondwords-metabox-settings__field"
552 style="<?php echo esc_attr( $voice_style ); ?>"
553 >
554 <p class="post-attributes-label-wrapper page-template-label-wrapper">
555 <label class="post-attributes-label" for="beyondwords_voice_id">
556 <?php esc_html_e( 'Voice', 'speechkit' ); ?>
557 </label>
558 </p>
559 <select id="beyondwords_voice_id" name="beyondwords_voice_id" style="width: 100%;">
560 <?php
561 printf(
562 '<option value="" %s>%s</option>',
563 selected( '', strval( $selected_voice_id ), false ),
564 esc_html__( 'Select a voice', 'speechkit' )
565 );
566 foreach ( $bucket_voices as $voice ) {
567 printf(
568 '<option value="%s" %s>%s</option>',
569 esc_attr( $voice['id'] ?? '' ),
570 selected( strval( $voice['id'] ?? '' ), strval( $selected_voice_id ), false ),
571 esc_html( $voice['name'] ?? '' )
572 );
573 }
574 ?>
575 </select>
576 </div>
577 <?php
578 }
579
580 /**
581 * Find a voice record by id.
582 *
583 * @since 7.0.0
584 *
585 * @param array $voices The voices array.
586 * @param string|false $voice_id The voice ID to find.
587 *
588 * @return array|null The matching voice record, or null.
589 */
590 private static function find_voice( array $voices, $voice_id ): ?array {
591 foreach ( $voices as $voice ) {
592 if ( strval( $voice['id'] ?? '' ) === strval( $voice_id ) ) {
593 return $voice;
594 }
595 }
596 return null;
597 }
598
599 /**
600 * A voice's primary (native) language code. The API returns `language` as
601 * either a string or a `{ code }` object, with `languages[]` as a fallback.
602 *
603 * @since 7.0.0
604 *
605 * @param array $voice A voice record.
606 *
607 * @return string The primary language code, or '' when unknown.
608 */
609 public static function voice_primary_code( array $voice ): string {
610 $language = $voice['language'] ?? null;
611
612 if ( is_string( $language ) ) {
613 return $language;
614 }
615 if ( is_array( $language ) && ! empty( $language['code'] ) ) {
616 return (string) $language['code'];
617 }
618 return (string) ( $voice['languages'][0]['code'] ?? '' );
619 }
620
621 /**
622 * Whether a language code is a voice's primary language. A voice with no
623 * determinable primary language counts as native, so it is never hidden.
624 *
625 * @since 7.0.0
626 *
627 * @param array $voice A voice record.
628 * @param string $code The language code.
629 *
630 * @return bool
631 */
632 public static function voice_is_native( array $voice, string $code ): bool {
633 $primary = self::voice_primary_code( $voice );
634 if ( '' === $primary ) {
635 return true;
636 }
637 return $primary === $code;
638 }
639
640 /**
641 * Apply the Native filter to a language's voices. `$keep_id` is always kept
642 * so the saved voice is never dropped from the list.
643 *
644 * @since 7.0.0
645 *
646 * @param array $voices The voices array.
647 * @param string $code The language code.
648 * @param string $native_filter "native" or "all".
649 * @param string|false $keep_id The voice id to always keep.
650 *
651 * @return array The filtered voices.
652 */
653 public static function filter_voices_by_native( array $voices, string $code, string $native_filter, $keep_id ): array {
654 if ( 'all' === $native_filter ) {
655 $result = $voices;
656 } else {
657 $result = array_values(
658 array_filter(
659 $voices,
660 static function ( $voice ) use ( $code ) {
661 return self::voice_is_native( $voice, $code );
662 }
663 )
664 );
665 }
666
667 $keep_id = strval( $keep_id );
668 if ( '' === $keep_id ) {
669 return $result;
670 }
671
672 foreach ( $result as $voice ) {
673 if ( strval( $voice['id'] ?? '' ) === $keep_id ) {
674 return $result;
675 }
676 }
677
678 $saved = self::find_voice( $voices, $keep_id );
679 if ( $saved ) {
680 $result[] = $saved;
681 }
682
683 return $result;
684 }
685
686 /**
687 * The Native filter to open with: "all" when the saved voice is not native
688 * to the language (so it stays visible), otherwise "native".
689 *
690 * @since 7.0.0
691 *
692 * @param array $voices The voices array.
693 * @param string|false $language_code The language code.
694 * @param string|false $voice_id The saved voice id.
695 *
696 * @return string "native" or "all".
697 */
698 public static function default_native_filter( array $voices, $language_code, $voice_id ): string {
699 $saved = self::find_voice( $voices, $voice_id );
700
701 if ( $saved && '' !== strval( $language_code ) && ! self::voice_is_native( $saved, strval( $language_code ) ) ) {
702 return 'all';
703 }
704
705 return 'native';
706 }
707
708 /**
709 * The model bucket key for a voice: its ElevenLabs model_id, else Standard.
710 *
711 * Accepts any value so a scalar from a decoded API error body buckets as
712 * Standard rather than fataling. Mirrors settings-panel/helpers.js.
713 *
714 * @since 7.0.0
715 *
716 * @param mixed $voice A voice record (defensively, any value).
717 *
718 * @return string The model bucket key.
719 */
720 public static function voice_model_key( mixed $voice ): string {
721 if (
722 is_array( $voice ) &&
723 ( $voice['service'] ?? '' ) === self::ELEVENLABS_SERVICE &&
724 isset( $voice['model_id'] ) &&
725 is_string( $voice['model_id'] )
726 ) {
727 return $voice['model_id'];
728 }
729 return self::STANDARD_MODEL_KEY;
730 }
731
732 /**
733 * The distinct model buckets across a language's voices, for the Model dropdown.
734 *
735 * ElevenLabs models first (the default leading), then a single Standard
736 * bucket if present. Mirrors getLanguageModels() in settings-panel/helpers.js.
737 *
738 * @since 7.0.0
739 *
740 * @param array $voices All voices for the current language.
741 *
742 * @return array The Model dropdown options.
743 */
744 public static function language_models( array $voices ): array {
745 $model_ids = [];
746 $has_standard = false;
747
748 foreach ( $voices as $voice ) {
749 $key = self::voice_model_key( $voice );
750 if ( self::STANDARD_MODEL_KEY === $key ) {
751 $has_standard = true;
752 } elseif ( ! in_array( $key, $model_ids, true ) ) {
753 $model_ids[] = $key;
754 }
755 }
756
757 // Stable sort (PHP 8+): the default model leads, the rest keep API order.
758 usort(
759 $model_ids,
760 static function ( $a, $b ) {
761 if ( $a === self::DEFAULT_ELEVENLABS_VOICE_MODEL_ID ) {
762 return -1;
763 }
764 if ( $b === self::DEFAULT_ELEVENLABS_VOICE_MODEL_ID ) {
765 return 1;
766 }
767 return 0;
768 }
769 );
770
771 $models = array_map(
772 static function ( $key ) {
773 return [
774 'key' => $key,
775 'label' => self::voice_model_label( $key ),
776 ];
777 },
778 $model_ids
779 );
780
781 if ( $has_standard ) {
782 $models[] = [
783 'key' => self::STANDARD_MODEL_KEY,
784 'label' => __( 'Legacy', 'speechkit' ),
785 ];
786 }
787
788 return $models;
789 }
790
791 /**
792 * Human label for a voice model_id slug.
793 *
794 * Mirrors voiceModelLabel() in settings-panel/helpers.js.
795 *
796 * @since 7.0.0
797 *
798 * @param string $model_id The model_id slug (e.g. `eleven_flash_v2_5`).
799 *
800 * @return string A display label.
801 */
802 public static function voice_model_label( string $model_id ): string {
803 $labels = [
804 'eleven_v3' => __( 'v3', 'speechkit' ),
805 'eleven_multilingual_v2' => __( 'Multilingual v2', 'speechkit' ),
806 'eleven_flash_v2_5' => __( 'Flash v2.5', 'speechkit' ),
807 'eleven_turbo_v2_5' => __( 'Turbo v2.5', 'speechkit' ),
808 ];
809
810 if ( isset( $labels[ $model_id ] ) ) {
811 return $labels[ $model_id ];
812 }
813
814 $slug = preg_replace( '/^eleven_/', '', $model_id );
815 $slug = str_replace( '_', ' ', (string) $slug );
816
817 return ucwords( $slug );
818 }
819
820 /**
821 * Render the loading spinner.
822 *
823 * @since 6.0.0
824 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
825 */
826 private static function render_loading_spinner(): void {
827 ?>
828 <img
829 src="/wp-admin/images/spinner.gif"
830 class="beyondwords-settings__loader"
831 style="display:none; padding: 3px 0;"
832 />
833 <?php
834 }
835
836 /**
837 * Save the meta when the post is saved.
838 *
839 * @since 4.0.0
840 * @since 6.0.0 Make static.
841 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
842 *
843 * @param int $post_id The ID of the post being saved.
844 */
845 public static function save( $post_id ) {
846 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
847 return $post_id;
848 }
849
850 // "save_post" can be triggered at other times, so verify this request came from the our component
851 if (
852 ! isset( $_POST['beyondwords_language_code'] ) ||
853 ! isset( $_POST['beyondwords_voice_id'] ) ||
854 ! isset( $_POST['beyondwords_select_voice_nonce'] )
855 ) {
856 return $post_id;
857 }
858
859 if (
860 ! wp_verify_nonce(
861 sanitize_key( $_POST['beyondwords_select_voice_nonce'] ),
862 'beyondwords_select_voice'
863 )
864 ) {
865 return $post_id;
866 }
867
868 if ( ! current_user_can( 'edit_post', $post_id ) ) {
869 return $post_id;
870 }
871
872 $language_code = sanitize_text_field( wp_unslash( $_POST['beyondwords_language_code'] ) );
873
874 if ( ! empty( $language_code ) ) {
875 update_post_meta( $post_id, 'beyondwords_language_code', $language_code );
876 } else {
877 delete_post_meta( $post_id, 'beyondwords_language_code' );
878 }
879
880 $voice_id = sanitize_text_field( wp_unslash( $_POST['beyondwords_voice_id'] ) );
881
882 if ( ! empty( $voice_id ) ) {
883 update_post_meta( $post_id, 'beyondwords_body_voice_id', $voice_id );
884 } else {
885 delete_post_meta( $post_id, 'beyondwords_body_voice_id' );
886 }
887
888 return $post_id;
889 }
890
891 /**
892 * Register WP REST API route
893 *
894 * @since 4.0.0
895 * @since 6.0.0 Make static.
896 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
897 *
898 * @return void
899 */
900 public static function rest_api_init_callback() {
901 register_rest_route(
902 'beyondwords/v1',
903 '/languages',
904 [
905 'methods' => \WP_REST_Server::READABLE,
906 'callback' => [ self::class, 'languages_rest_api_response'],
907 'permission_callback' => fn() => current_user_can( 'edit_posts' ),
908 ]
909 );
910
911 register_rest_route(
912 'beyondwords/v1',
913 '/languages/(?P<languageCode>[a-zA-Z0-9-_]+)/voices',
914 [
915 'methods' => \WP_REST_Server::READABLE,
916 'callback' => [ self::class, 'voices_rest_api_response'],
917 'permission_callback' => fn() => current_user_can( 'edit_posts' ),
918 ]
919 );
920 }
921
922 /**
923 * "Languages" WP REST API response (required for the Gutenberg editor).
924 *
925 * @since 4.0.0
926 * @since 5.4.0 No longer filter by "Languages" plugin setting.
927 * @since 6.0.0 Make static.
928 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
929 *
930 * @return \WP_REST_Response
931 */
932 public static function languages_rest_api_response() {
933 $languages = \BeyondWords\Api\Client::get_languages();
934
935 return new \WP_REST_Response( $languages );
936 }
937
938 /**
939 * "Voices" WP REST API response (required for the Gutenberg editor).
940 *
941 * @since 4.0.0
942 * @since 6.0.0 Make static.
943 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
944 *
945 * @return \WP_REST_Response
946 */
947 public static function voices_rest_api_response( \WP_REST_Request $data ) {
948 $params = $data->get_url_params();
949
950 $voices = \BeyondWords\Api\Client::get_voices( $params['languageCode'] );
951
952 return new \WP_REST_Response( $voices );
953 }
954 }
955