| 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 |
|