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 / settings-panel / helpers.js

helpers.js in BeyondWords – AI audio for publishers 7.0.0, at src/editor/components/settings-panel/helpers.js

406 lines 10.8 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 { decodeEntities } from '@wordpress/html-entities';
5 import { __ } from '@wordpress/i18n';
6
7 export const SOURCE_POST = 'post';
8 export const SOURCE_POST_AND_SCRIPT = 'post_and_script';
9
10 // Source removed in 7.0.0; see doc/legacy-meta-migration.md.
11 export const LEGACY_SOURCE_SCRIPT = 'script';
12
13 export const OUTPUT_AUDIO = 'audio';
14 export const OUTPUT_VIDEO = 'video';
15 export const OUTPUT_AUDIO_AND_VIDEO = 'audio_and_video';
16
17 export const EMBED_NONE = 'none';
18 export const EMBED_AUDIO_POST = 'audio_post';
19 export const EMBED_AUDIO_SCRIPT = 'audio_script';
20 export const EMBED_VIDEO_POST = 'video_post';
21 export const EMBED_VIDEO_SCRIPT = 'video_script';
22
23 // Empty value = defer to the project setting — the plugin omits the field from
24 // the content payload so the BeyondWords backend applies the project default.
25 export const PROJECT_DEFAULT_VALUE = '';
26
27 export function projectDefaultOption() {
28 return { label: __( 'Project default', 'speechkit' ), value: '' };
29 }
30
31 // A language row is a (name, accent, code) triple: Language selects the name,
32 // Accent selects the row, and the row's CODE is the stored value.
33 const isValidLanguage = ( language ) =>
34 !! ( language?.code && language?.name && language?.accent );
35
36 /**
37 * The distinct language names across the /languages rows, in API order.
38 *
39 * @param {Array<Object>} languages The language rows.
40 *
41 * @return {Array<string>} The decoded language names.
42 */
43 export function getLanguageNames( languages ) {
44 const names = [];
45
46 ( languages ?? [] ).forEach( ( language ) => {
47 if ( ! isValidLanguage( language ) ) {
48 return;
49 }
50 const name = decodeEntities( language.name );
51 if ( ! names.includes( name ) ) {
52 names.push( name );
53 }
54 } );
55
56 return names;
57 }
58
59 /**
60 * The accents for a language name, as options carrying the language CODE.
61 *
62 * @param {Array<Object>} languages The language rows.
63 * @param {string} name The decoded language name.
64 *
65 * @return {Array<{label: string, value: string}>} The Accent dropdown options.
66 */
67 export function getAccentsForName( languages, name ) {
68 if ( ! name ) {
69 return [];
70 }
71
72 return ( languages ?? [] )
73 .filter(
74 ( language ) =>
75 isValidLanguage( language ) &&
76 decodeEntities( language.name ) === name
77 )
78 .map( ( language ) => ( {
79 label: decodeEntities( language.accent ),
80 value: decodeEntities( language.code ),
81 } ) );
82 }
83
84 /**
85 * Find a language row by its code.
86 *
87 * @param {Array<Object>} languages The language rows.
88 * @param {string} code The language code.
89 *
90 * @return {Object|null} The matching row, or null.
91 */
92 export function findLanguageByCode( languages, code ) {
93 if ( ! code ) {
94 return null;
95 }
96
97 return (
98 ( languages ?? [] ).find(
99 ( language ) =>
100 isValidLanguage( language ) &&
101 decodeEntities( language.code ) === code
102 ) ?? null
103 );
104 }
105
106 // Native = the language is the voice's primary one; multilingual voices merely
107 // support it as a secondary language and only show under "All".
108 export const NATIVE_ONLY = 'native';
109 export const NATIVE_ALL = 'all';
110
111 /**
112 * A voice's primary (native) language code.
113 *
114 * @param {Object} voice A voice record.
115 *
116 * @return {string} The primary language code, or '' when unknown.
117 */
118 export function voicePrimaryCode( voice ) {
119 const language = voice?.language;
120
121 if ( typeof language === 'string' ) {
122 return language;
123 }
124 if ( language && typeof language === 'object' && language.code ) {
125 return language.code;
126 }
127 return voice?.languages?.[ 0 ]?.code || '';
128 }
129
130 /**
131 * Whether a voice is native to a language code. A voice with no determinable
132 * primary language counts as native, so we never hide what we cannot classify.
133 *
134 * @param {Object} voice A voice record.
135 * @param {string} code The language code.
136 *
137 * @return {boolean} Whether the voice is native to the code.
138 */
139 export function voiceIsNative( voice, code ) {
140 const primary = voicePrimaryCode( voice );
141 if ( ! primary ) {
142 return true;
143 }
144 return String( primary ) === String( code );
145 }
146
147 /**
148 * Apply the Native filter to a language's voices.
149 *
150 * `keepId` is always kept, so changing the filter never drops the saved voice.
151 *
152 * @param {Array<Object>} voices All fetched voices for the language.
153 * @param {string} code The selected language code.
154 * @param {string} nativeFilter NATIVE_ONLY or NATIVE_ALL.
155 * @param {string} keepId The voice id to always keep, or ''.
156 *
157 * @return {Array<Object>} The filtered voices.
158 */
159 export function filterVoicesByNative( voices, code, nativeFilter, keepId ) {
160 const list = voices ?? [];
161
162 let result =
163 nativeFilter === NATIVE_ALL
164 ? list
165 : list.filter( ( voice ) => voiceIsNative( voice, code ) );
166
167 if (
168 keepId &&
169 ! result.some( ( voice ) => String( voice.id ) === String( keepId ) )
170 ) {
171 const saved = list.find(
172 ( voice ) => String( voice.id ) === String( keepId )
173 );
174 if ( saved ) {
175 result = [ ...result, saved ];
176 }
177 }
178
179 return result;
180 }
181
182 // Voice "models" only exist for ElevenLabs voices; each (name, model_id) pair is
183 // a distinct voice record, and the chosen voice id is the only value sent to the API.
184 export const ELEVENLABS_SERVICE = 'ElevenLabs';
185
186 // The model listed first in the Model dropdown.
187 export const DEFAULT_ELEVENLABS_VOICE_MODEL_ID = 'eleven_multilingual_v2';
188
189 // Bucket key for voices without an ElevenLabs `model_id` (e.g. standard voices).
190 export const STANDARD_MODEL_KEY = 'standard';
191
192 // Human labels for the known ElevenLabs model slugs.
193 const VOICE_MODEL_LABELS = {
194 eleven_v3: __( 'v3', 'speechkit' ),
195 eleven_multilingual_v2: __( 'Multilingual v2', 'speechkit' ),
196 eleven_flash_v2_5: __( 'Flash v2.5', 'speechkit' ),
197 eleven_turbo_v2_5: __( 'Turbo v2.5', 'speechkit' ),
198 };
199
200 /**
201 * Human label for a voice model_id slug.
202 *
203 * @param {string} modelId The model_id slug (e.g. `eleven_flash_v2_5`).
204 *
205 * @return {string} A display label.
206 */
207 export function voiceModelLabel( modelId ) {
208 if ( VOICE_MODEL_LABELS[ modelId ] ) {
209 return VOICE_MODEL_LABELS[ modelId ];
210 }
211 return String( modelId )
212 .replace( /^eleven_/, '' )
213 .replace( /_/g, ' ' )
214 .replace( /\b\w/g, ( c ) => c.toUpperCase() );
215 }
216
217 /**
218 * The model bucket key for a voice.
219 *
220 * ElevenLabs voices key by `model_id`; all others share the Standard bucket.
221 *
222 * @param {Object} voice A voice record.
223 *
224 * @return {string} The model bucket key.
225 */
226 export function voiceModelKey( voice ) {
227 if (
228 voice?.service === ELEVENLABS_SERVICE &&
229 typeof voice?.model_id === 'string'
230 ) {
231 return voice.model_id;
232 }
233 return STANDARD_MODEL_KEY;
234 }
235
236 /**
237 * The distinct model buckets across a language's voices, for the Model dropdown.
238 *
239 * ElevenLabs models first (the default leading), then a single Standard bucket.
240 *
241 * @param {Array<Object>} voices All voices for the current language.
242 *
243 * @return {Array<{key: string, label: string}>} The Model dropdown options.
244 */
245 export function getLanguageModels( voices ) {
246 const modelIds = [];
247 let hasStandard = false;
248
249 ( voices ?? [] ).forEach( ( voice ) => {
250 const key = voiceModelKey( voice );
251 if ( key === STANDARD_MODEL_KEY ) {
252 hasStandard = true;
253 } else if ( ! modelIds.includes( key ) ) {
254 modelIds.push( key );
255 }
256 } );
257
258 // Stable sort (V8): the default model leads, the rest keep API order.
259 modelIds.sort( ( a, b ) => {
260 if ( a === DEFAULT_ELEVENLABS_VOICE_MODEL_ID ) {
261 return -1;
262 }
263 if ( b === DEFAULT_ELEVENLABS_VOICE_MODEL_ID ) {
264 return 1;
265 }
266 return 0;
267 } );
268
269 const models = modelIds.map( ( key ) => ( {
270 key,
271 label: voiceModelLabel( key ),
272 } ) );
273
274 if ( hasStandard ) {
275 models.push( {
276 key: STANDARD_MODEL_KEY,
277 label: __( 'Legacy', 'speechkit' ),
278 } );
279 }
280
281 return models;
282 }
283
284 export function getSourceOptions() {
285 return [
286 { label: __( 'Post', 'speechkit' ), value: SOURCE_POST },
287 {
288 label: __( 'Post + script', 'speechkit' ),
289 value: SOURCE_POST_AND_SCRIPT,
290 },
291 ];
292 }
293
294 export function getOutputOptions() {
295 return [
296 { label: __( 'Audio', 'speechkit' ), value: OUTPUT_AUDIO },
297 { label: __( 'Video', 'speechkit' ), value: OUTPUT_VIDEO },
298 {
299 label: __( 'Audio + video', 'speechkit' ),
300 value: OUTPUT_AUDIO_AND_VIDEO,
301 },
302 ];
303 }
304
305 /**
306 * Resolve a stored source to one the dropdown offers, so it never shows blank.
307 *
308 * @param {string} source The stored value.
309 *
310 * @return {string} SOURCE_POST or SOURCE_POST_AND_SCRIPT.
311 */
312 export function normalizeSource( source ) {
313 return source === SOURCE_POST_AND_SCRIPT || source === LEGACY_SOURCE_SCRIPT
314 ? SOURCE_POST_AND_SCRIPT
315 : SOURCE_POST;
316 }
317
318 export function sourceIncludesScript( source ) {
319 return normalizeSource( source ) === SOURCE_POST_AND_SCRIPT;
320 }
321
322 export function outputIncludesAudio( output ) {
323 return output === OUTPUT_AUDIO || output === OUTPUT_AUDIO_AND_VIDEO;
324 }
325
326 export function outputIncludesVideo( output ) {
327 return output === OUTPUT_VIDEO || output === OUTPUT_AUDIO_AND_VIDEO;
328 }
329
330 /**
331 * Derive the valid "Embed" dropdown options from the current Source × Output.
332 *
333 * Returns None plus one entry per asset the current source/output would produce.
334 * Post assets are unconditional — every source generates the post.
335 *
336 * @param {string} source One of SOURCE_*.
337 * @param {string} output One of OUTPUT_*.
338 *
339 * @return {Array<{label: string, value: string}>} SelectControl options.
340 */
341 export function getEmbedOptions( source, output ) {
342 const options = [ { label: __( 'None', 'speechkit' ), value: EMBED_NONE } ];
343 const includesScript = sourceIncludesScript( source );
344
345 if ( outputIncludesAudio( output ) ) {
346 options.push( {
347 label: __( 'Audio (post)', 'speechkit' ),
348 value: EMBED_AUDIO_POST,
349 } );
350 if ( includesScript ) {
351 options.push( {
352 label: __( 'Audio (script)', 'speechkit' ),
353 value: EMBED_AUDIO_SCRIPT,
354 } );
355 }
356 }
357
358 if ( outputIncludesVideo( output ) ) {
359 options.push( {
360 label: __( 'Video (post)', 'speechkit' ),
361 value: EMBED_VIDEO_POST,
362 } );
363 if ( includesScript ) {
364 options.push( {
365 label: __( 'Video (script)', 'speechkit' ),
366 value: EMBED_VIDEO_SCRIPT,
367 } );
368 }
369 }
370
371 return options;
372 }
373
374 /**
375 * Whether the given embed value is selectable for the current Source × Output.
376 *
377 * @param {string} embed One of EMBED_*.
378 * @param {string} source One of SOURCE_*.
379 * @param {string} output One of OUTPUT_*.
380 *
381 * @return {boolean} True when embed is in the current option list.
382 */
383 export function isEmbedValid( embed, source, output ) {
384 return getEmbedOptions( source, output ).some(
385 ( option ) => option.value === embed
386 );
387 }
388
389 /**
390 * The default Embed for a post that hasn't chosen one: the first produced asset.
391 *
392 * Keeps the player visible by default — "None" is the deliberate opt-out.
393 *
394 * @param {string} source One of SOURCE_*.
395 * @param {string} output One of OUTPUT_*.
396 *
397 * @return {string} The default embed value.
398 */
399 export function getDefaultEmbed( source, output ) {
400 const asset = getEmbedOptions( source, output ).find(
401 ( option ) => option.value !== EMBED_NONE
402 );
403
404 return asset ? asset.value : EMBED_NONE;
405 }
406