snowy-0wl/supertonic-3
0
1import * as ort from 'onnxruntime-web';2const presetTexts = window.presetTexts || {};3 4const PLAY_ICON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7-11-7z"></path></svg>`;5const PAUSE_ICON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M8 6h3v12H8V6zm5 0h3v12h-3V6z"></path></svg>`;6const STOP_ICON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M7 7h10v10H7V7z"></path></svg>`;7 8// Lightning background parallax9(function initLightningParallax() {10 if (typeof document === 'undefined') {11 return;12 }13 14 const runBlink = (className, onComplete) => {15 let remaining = 1 + Math.round(Math.random());16 const blink = () => {17 if (remaining-- <= 0) {18 if (typeof onComplete === 'function') {19 onComplete();20 }21 return;22 }23 const wait = 20 + Math.random() * 80;24 document.body.classList.add(className);25 setTimeout(() => {26 document.body.classList.remove(className);27 setTimeout(blink, wait);28 }, wait);29 };30 blink();31 };32 33 const schedule = () => {34 setTimeout(() => runBlink('lightning-flicker', schedule), Math.random() * 10000);35 };36 schedule();37})();38 39function escapeHtml(value) {40 return value.replace(/[&<>"']/g, (match) => {41 switch (match) {42 case '&': return '&';43 case '<': return '<';44 case '>': return '>';45 case '"': return '"';46 case "'": return ''';47 default: return match;48 }49 });50}51 52function getErrorMessage(error) {53 if (error instanceof Error && error.message) {54 return error.message;55 }56 if (typeof error === 'string') {57 return error;58 }59 if (error === undefined || error === null) {60 return 'Unknown error';61 }62 try {63 return JSON.stringify(error);64 } catch (_) {65 return String(error);66 }67}68 69function formatStatValueWithSuffix(value, suffix, options = {}) {70 const { firstLabel = false } = options;71 if (value === undefined || value === null) {72 return '';73 }74 if (!suffix) {75 const raw = `${value}`;76 return escapeHtml(raw);77 }78 const raw = `${value}`.trim();79 if (!raw || raw === '--' || raw === '-' || raw.toLowerCase() === 'error') {80 return escapeHtml(raw);81 }82 const appendSuffix = (segment, includePrefix = false) => {83 const trimmed = segment.trim();84 if (!trimmed) {85 return '';86 }87 const escapedValue = `<span class="stat-value-number">${escapeHtml(trimmed)}</span>`;88 const suffixSpan = `<span class="stat-label stat-suffix">${escapeHtml(suffix)}</span>`;89 const prefixSpan = includePrefix && firstLabel90 ? `<span class="stat-label stat-suffix stat-prefix">First</span>`91 : '';92 const segmentClass = includePrefix && firstLabel93 ? 'stat-value-segment has-prefix'94 : 'stat-value-segment';95 return `<span class="${segmentClass}">${prefixSpan}${escapedValue}${suffixSpan}</span>`;96 };97 if (raw.includes('/')) {98 const parts = raw.split('/');99 const segments = parts.map((part, index) => appendSuffix(part, index === 0));100 return segments.join(' / ');101 }102 return appendSuffix(raw);103}104 105/**106 * Unicode text processor107 */108export class UnicodeProcessor {109 constructor(indexer) {110 this.indexer = indexer;111 }112 113 call(textList, lang = null) {114 const processedTexts = textList.map(t => preprocessText(t, lang));115 const textIdsLengths = processedTexts.map(t => t.length);116 const maxLen = Math.max(...textIdsLengths);117 118 const textIds = [];119 const unsupportedChars = new Set();120 121 for (let i = 0; i < processedTexts.length; i++) {122 const row = new Array(maxLen).fill(0);123 const unicodeVals = textToUnicodeValues(processedTexts[i]);124 for (let j = 0; j < unicodeVals.length; j++) {125 const indexValue = this.indexer[unicodeVals[j]];126 // Check if character is supported (not -1, undefined, or null)127 if (indexValue === undefined || indexValue === null || indexValue === -1) {128 unsupportedChars.add(processedTexts[i][j]);129 row[j] = 0; // Use 0 as fallback130 } else {131 row[j] = indexValue;132 }133 }134 textIds.push(row);135 }136 137 const textMask = getTextMask(textIdsLengths);138 return { textIds, textMask, unsupportedChars: Array.from(unsupportedChars) };139 }140}141 142const AVAILABLE_LANGS = ["en", "ko", "ja", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hi", "hr", "hu", "id", "it", "lt", "lv", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "tr", "uk", "vi"];143 144/**145 * Language detection \u2014 Unicode-script first, then Latin scoring.146 *147 * Strategy:148 * 1. Detect non-Latin scripts via Unicode block matches (high confidence).149 * Hangul \u2192 ko, Hiragana/Katakana \u2192 ja, Arabic \u2192 ar, Devanagari \u2192 hi,150 * Greek \u2192 el, Cyrillic \u2192 bg/ru/uk via stopword tiebreak.151 * 2. For Latin script, score against a curated set of distinctive152 * characters and stopwords for the most common languages we cover.153 * Languages without a high-signal scoring rule (e.g. et, fi, hu, sk,154 * sl, ro, lt, lv, hr, id) fall through; the caller then uses the155 * dropdown selection.156 *157 * The model itself is language-agnostic, so misdetection is graceful:158 * the wrong tag still produces understandable speech.159 */160export function detectLanguage(text) {161 if (!text || text.trim().length < 3) {162 return null;163 }164 165 const sampleText = text.length > 200 ? text.substring(text.length - 200) : text;166 const normalizedText = sampleText.normalize('NFC').toLowerCase();167 168 // 1) Non-Latin scripts via Unicode blocks (definitive)169 if (/[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/.test(normalizedText)) return 'ko';170 if (/[\u3040-\u30FF]/.test(normalizedText)) return 'ja'; // Hiragana/Katakana171 if (/[\u0600-\u06FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(normalizedText)) return 'ar';172 if (/[\u0900-\u097F]/.test(normalizedText)) return 'hi'; // Devanagari173 if (/[\u0370-\u03FF]/.test(normalizedText)) return 'el'; // Greek174 175 // Cyrillic \u2014 distinguish bg/ru/uk via stopwords + diacritics176 if (/[\u0400-\u04FF]/.test(normalizedText)) {177 const cyrText = ' ' + normalizedText + ' ';178 let ru = 0, uk = 0, bg = 0;179 for (const w of ['\u0438', '\u043D\u0435', '\u044D\u0442\u043E', '\u0447\u0442\u043E', '\u043A\u0430\u043A', '\u0434\u043B\u044F', '\u043D\u043E', '\u0432\u0441\u0435']) if (cyrText.includes(' ' + w + ' ')) ru += 2;180 for (const w of ['\u0442\u0430', '\u043D\u0435', '\u0449\u043E', '\u044F\u043A', '\u0434\u043B\u044F', '\u0430\u043B\u0435', '\u0432\u0441\u0435', '\u0430\u0431\u043E']) if (cyrText.includes(' ' + w + ' ')) uk += 2;181 for (const w of ['\u0438', '\u043D\u0430', '\u043D\u0435', '\u0447\u0435', '\u043A\u0430\u0442\u043E', '\u0441\u044A\u0441', '\u0441\u044A\u0449\u043E']) if (cyrText.includes(' ' + w + ' ')) bg += 2;182 if (/[\u0456\u0457\u0454\u0491]/.test(normalizedText)) uk += 6;183 if (/\u044A/.test(normalizedText)) bg += 4;184 if (/[\u044B\u044D\u0451]/.test(normalizedText)) ru += 4;185 if (uk > ru && uk > bg) return 'uk';186 if (bg > ru && bg > uk) return 'bg';187 return 'ru';188 }189 190 // 2) Latin script scoring191 const scores = { en: 0, es: 0, fr: 0, pt: 0, de: 0, it: 0, nl: 0, pl: 0, sv: 0, da: 0, tr: 0, vi: 0 };192 193 // Highly distinctive characters194 if (/\u00F1/.test(normalizedText)) scores.es += 15;195 if (/[\u00BF\u00A1]/.test(normalizedText)) scores.es += 12;196 if (/\u00E3/.test(normalizedText)) scores.pt += 15;197 if (/\u00F5/.test(normalizedText)) scores.pt += 15;198 if (/\u0153/.test(normalizedText)) scores.fr += 15;199 if (/[\u00F9\u00FB]/.test(normalizedText)) scores.fr += 10;200 if (/\u00E7/.test(normalizedText)) { scores.fr += 4; scores.pt += 4; scores.tr += 4; }201 if (/[\u00E8\u00EA\u00EB]/.test(normalizedText)) scores.fr += 5;202 if (/[\u00E0\u00E2]/.test(normalizedText)) scores.fr += 3;203 if (/[\u00EE\u00EF]/.test(normalizedText)) scores.fr += 4;204 if (/\u00F4/.test(normalizedText)) scores.fr += 3;205 if (/\u00DF/.test(normalizedText)) scores.de += 15;206 if (/[\u00E4\u00F6\u00FC]/.test(normalizedText)) { scores.de += 4; scores.sv += 2; }207 if (/\u00E5/.test(normalizedText)) { scores.sv += 8; scores.da += 8; }208 if (/[\u00E6\u00F8]/.test(normalizedText)) scores.da += 12;209 if (/[\u0105\u0119\u0107\u0142\u0144\u015B\u017A\u017C]/.test(normalizedText)) scores.pl += 12;210 if (/[\u011F\u015F\u0131\u0130]/.test(normalizedText)) scores.tr += 12;211 if (/[\u01A1\u01B0\u0103\u0111]/.test(normalizedText)) scores.vi += 12;212 if (/[\u00E0\u1EA3\u00E3\u00E1\u1EA1\u1EB1\u1EAF\u1EB3\u1EB5\u1EB7\u00E2\u1EA7\u1EA5\u1EA9\u1EAB\u1EAD\u00E8\u1EBB\u1EBD\u00E9\u1EB9\u00EA\u1EC1\u1EBF\u1EC3\u1EC5\u1EC7\u00EC\u1EC9\u0129\u00ED\u1ECB\u00F2\u1ECF\u00F5\u00F3\u1ECD\u00F4\u1ED3\u1ED1\u1ED5\u1ED7\u1ED9\u01A1\u1EDD\u1EDB\u1EDF\u1EE1\u1EE3\u00F9\u1EE7\u0169\u00FA\u1EE5\u01B0\u1EEB\u1EE9\u1EED\u1EEF\u1EF1\u1EF3\u1EF7\u1EF9\u00FD\u1EF5]/.test(normalizedText)) scores.vi += 6;213 214 // Exclusive stopwords (highest signal per language)215 const exclusiveWords = {216 en: ['the', 'is', 'are', 'was', 'were', 'have', 'has', 'been', 'will', 'would', 'this', 'that', 'with', 'from', 'they', 'what', 'which', 'there', 'their', 'about', 'these', 'other', 'into', 'just', 'your', 'some', 'than', 'them', 'then', 'only', 'being', 'through', 'after', 'before'],217 es: ['el', 'los', 'las', 'est\u00E1', 'est\u00E1n', 'porque', 'pero', 'muy', 'tambi\u00E9n', 'm\u00E1s', 'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'nosotros', 'ellos', 'ellas', 'hola', 'gracias', 'ahora', 'siempre', 'nunca'],218 fr: ['le', 'les', 'est', 'sont', 'dans', 'ce', 'cette', 'ces', 'elle', 'ils', 'elles', 'nous', 'vous', 'avec', 'sur', 'pas', 'plus', 'tout', 'bien', 'fait', '\u00EAtre', 'avoir', 'donc', 'car', 'jamais', 'toujours', 'aussi', 'tr\u00E8s'],219 pt: ['os', 'as', 's\u00E3o', 'est\u00E3o', 'n\u00E3o', 'na', 'no', 'da', 'do', 'das', 'dos', 'ao', 'aos', 'ele', 'ela', 'eles', 'elas', 'n\u00F3s', 'voc\u00EA', 'voc\u00EAs', 'seu', 'sua', 'muito', 'tamb\u00E9m', 'foi', 'mesmo', 'at\u00E9', 'isso', 'ol\u00E1', 'obrigado', 'obrigada'],220 de: ['der', 'die', 'das', 'und', 'ist', 'sind', 'nicht', 'ich', 'wir', 'sie', 'er', 'mit', 'f\u00FCr', 'auf', 'eine', 'einen', 'einem', 'auch', 'aber', 'doch', 'noch', 'nur', 'sehr', 'so', 'oder', 'wenn', 'weil', 'als'],221 it: ['il', 'la', 'gli', 'le', '\u00E8', 'sono', 'non', 'che', 'di', 'per', 'con', 'una', 'uno', 'noi', 'voi', 'loro', 'questo', 'questa', 'anche', 'ma', 'pi\u00F9', 'molto', 'sempre', 'mai'],222 nl: ['de', 'het', 'een', 'en', 'is', 'zijn', 'niet', 'van', 'voor', 'met', 'op', 'aan', 'om', 'maar', 'ook', 'wel', 'nog', 'als', 'dan', 'wat', 'wie', 'hoe', 'omdat', 'altijd', 'nooit'],223 pl: ['jest', 's\u0105', 'nie', 'si\u0119', 'tak', 'czy', 'ale', 'oraz', 'jak', 'tym', 'tego', 'tej', 'jeszcze', 'tylko', 'bardzo', 'zawsze', 'nigdy'],224 sv: ['\u00E4r', 'och', 'inte', 'det', 'att', 'f\u00F6r', 'p\u00E5', 'med', 'som', 'jag', 'vi', 'ni', 'de', 'eller', 'men', 'ocks\u00E5', 'alltid', 'aldrig', 'bara'],225 da: ['er', 'og', 'ikke', 'det', 'at', 'for', 'p\u00E5', 'med', 'som', 'jeg', 'vi', 'de', 'eller', 'men', 'ogs\u00E5', 'altid', 'aldrig', 'bare'],226 tr: ['ve', 'ile', 'i\u00E7in', 'bir', 'bu', '\u015Fu', 'de\u011Fil', 'gibi', '\u00E7ok', 'ama', 'her', 'hi\u00E7', 'yine', 'daha'],227 vi: ['v\u00E0', 'l\u00E0', 'c\u1EE7a', 'kh\u00F4ng', 'm\u1ED9t', 'nh\u1EEFng', 'n\u00E0y', '\u0111\u00F3', 'c\u0169ng', 'v\u1EDBi', 'nh\u01B0', '\u0111\u1EC3', 'nh\u01B0ng', 'r\u1EA5t', 'lu\u00F4n', 'bao']228 };229 230 const words = normalizedText.match(/[\p{Letter}']+/gu) || [];231 for (const word of words) {232 for (const [lang, wordList] of Object.entries(exclusiveWords)) {233 if (wordList.includes(word)) {234 scores[lang] += 3;235 }236 }237 }238 239 // 3. Common n-grams (character patterns)240 const ngramPatterns = {241 en: [/th/g, /ing/g, /tion/g, /ight/g, /ould/g],242 es: [/ción/g, /mente/g, /ado/g, /ido/g],243 fr: [/tion/g, /ment/g, /eau/g, /aux/g, /eux/g, /oir/g, /ais/g, /ait/g, /ont/g],244 pt: [/ção/g, /ões/g, /mente/g, /ado/g, /ido/g, /nh/g, /lh/g],245 de: [/sch/g, /chen/g, /lich/g, /ung/g, /ein/g],246 it: [/zione/g, /mente/g, /ono/g, /are/g, /ere/g],247 nl: [/sch/g, /eer/g, /ijk/g, /aar/g],248 pl: [/cz/g, /sz/g, /rz/g, /dzie/g],249 sv: [/skt/g, /tion/g],250 da: [/skt/g, /tion/g, /tt/g],251 tr: [/lar/g, /ler/g, /siz/g, /lik/g],252 vi: [/ng/g, /nh/g, /th/g]253 };254 255 for (const [lang, patterns] of Object.entries(ngramPatterns)) {256 for (const pattern of patterns) {257 const matches = normalizedText.match(pattern) || [];258 scores[lang] += matches.length * 2;259 }260 }261 262 // 4. French apostrophe contractions263 const frenchContractions = /[cdjlmnst]'[aeiouéèêàâîïôûù]/g;264 const frenchContractionMatches = normalizedText.match(frenchContractions) || [];265 scores.fr += frenchContractionMatches.length * 5;266 267 // 5. Definite-article anchors268 if (/\bthe\b/.test(normalizedText)) scores.en += 5;269 if (/\b(el|los)\b/.test(normalizedText)) scores.es += 4;270 if (/\b(le|les)\b/.test(normalizedText)) scores.fr += 4;271 if (/\b(o|os)\b/.test(normalizedText)) scores.pt += 3;272 if (/\b(der|die|das)\b/.test(normalizedText)) scores.de += 5;273 274 // Pick winner with confidence threshold275 let maxScore = 0;276 let detectedLang = null;277 278 for (const [lang, score] of Object.entries(scores)) {279 if (score > maxScore) {280 maxScore = score;281 detectedLang = lang;282 }283 }284 285 if (maxScore >= 4) {286 return detectedLang;287 }288 289 return null;290}291 292// Language display names for toast notification (31 languages)293const LANGUAGE_NAMES = {294 'en': 'English',295 'ko': 'Korean',296 'ja': 'Japanese',297 'ar': 'Arabic',298 'bg': 'Bulgarian',299 'cs': 'Czech',300 'da': 'Danish',301 'de': 'German',302 'el': 'Greek',303 'es': 'Spanish',304 'et': 'Estonian',305 'fi': 'Finnish',306 'fr': 'French',307 'hi': 'Hindi',308 'hr': 'Croatian',309 'hu': 'Hungarian',310 'id': 'Indonesian',311 'it': 'Italian',312 'lt': 'Lithuanian',313 'lv': 'Latvian',314 'nl': 'Dutch',315 'pl': 'Polish',316 'pt': 'Portuguese',317 'ro': 'Romanian',318 'ru': 'Russian',319 'sk': 'Slovak',320 'sl': 'Slovenian',321 'sv': 'Swedish',322 'tr': 'Turkish',323 'uk': 'Ukrainian',324 'vi': 'Vietnamese'325};326 327export function preprocessText(text, lang = null) {328 // Normalize unicode characters329 text = text.normalize('NFKD');330 331 // Remove emojis332 text = text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}]+/gu, '');333 334 // Replace various dashes and symbols335 const replacements = {336 "–": "-",337 "‑": "-",338 "—": "-",339 "_": " ",340 "\u201C": '"', // "341 "\u201D": '"', // "342 "\u2018": "'", // '343 "\u2019": "'", // '344 "´": "'",345 "`": "'",346 "[": " ",347 "]": " ",348 "|": " ",349 "/": " ", // FIXME: `/` should be pronounced.350 "#": " ", // FIXME: `#` should be pronounced.351 "→": " ",352 "←": " ",353 };354 355 for (const [k, v] of Object.entries(replacements)) {356 text = text.replaceAll(k, v);357 }358 359 // Remove special symbols360 text = text.replace(/[♥☆♡©\\]/g, "");361 362 // Replace known expressions363 const exprReplacements = {364 "@": " at ",365 "e.g.,": "for example,",366 "i.e.,": "that is,",367 };368 369 for (const [k, v] of Object.entries(exprReplacements)) {370 text = text.replaceAll(k, v);371 }372 373 // Fix spacing around punctuation374 text = text.replace(/ ,/g, ",");375 text = text.replace(/ \./g, ".");376 text = text.replace(/ !/g, "!");377 text = text.replace(/ \?/g, "?");378 text = text.replace(/ ;/g, ";");379 text = text.replace(/ :/g, ":");380 text = text.replace(/ '/g, "'");381 382 // Remove duplicate quotes383 while (text.includes('""')) {384 text = text.replace(/""/g, '"');385 }386 while (text.includes("''")) {387 text = text.replace(/''/g, "'");388 }389 while (text.includes("``")) {390 text = text.replace(/``/g, "`");391 }392 393 // Remove extra spaces394 text = text.replace(/\s+/g, " ").trim();395 396 // If text doesn't end with punctuation, quotes, or closing brackets, add a period397 if (!/[.!?;:,'"')\]}…。」』】〉》›»]$/.test(text)) {398 text += ".";399 }400 401 // Add language tags402 if (lang !== null) {403 if (!AVAILABLE_LANGS.includes(lang)) {404 throw new Error(`Invalid language: ${lang}`);405 }406 text = `<${lang}>` + text + `</${lang}>`;407 } else {408 text = `<na>` + text + `</na>`;409 }410 411 return text;412}413 414export function textToUnicodeValues(text) {415 return Array.from(text).map(char => char.charCodeAt(0));416}417 418export function lengthToMask(lengths, maxLen = null) {419 maxLen = maxLen || Math.max(...lengths);420 const mask = [];421 for (let i = 0; i < lengths.length; i++) {422 const row = [];423 for (let j = 0; j < maxLen; j++) {424 row.push(j < lengths[i] ? 1.0 : 0.0);425 }426 mask.push([row]);427 }428 return mask;429}430 431export function getTextMask(textIdsLengths) {432 return lengthToMask(textIdsLengths);433}434 435export function getLatentMask(wavLengths, cfgs) {436 const baseChunkSize = cfgs.ae.base_chunk_size;437 const chunkCompressFactor = cfgs.ttl.chunk_compress_factor;438 const latentSize = baseChunkSize * chunkCompressFactor;439 const latentLengths = wavLengths.map(len => 440 Math.floor((len + latentSize - 1) / latentSize)441 );442 return lengthToMask(latentLengths);443}444 445export function sampleNoisyLatent(duration, cfgs) {446 const sampleRate = cfgs.ae.sample_rate;447 const baseChunkSize = cfgs.ae.base_chunk_size;448 const chunkCompressFactor = cfgs.ttl.chunk_compress_factor;449 const ldim = cfgs.ttl.latent_dim;450 451 const wavLenMax = Math.max(...duration.map(d => d[0][0])) * sampleRate;452 const wavLengths = duration.map(d => Math.floor(d[0][0] * sampleRate));453 const chunkSize = baseChunkSize * chunkCompressFactor;454 const latentLen = Math.floor((wavLenMax + chunkSize - 1) / chunkSize);455 const latentDim = ldim * chunkCompressFactor;456 457 const noisyLatent = [];458 for (let b = 0; b < duration.length; b++) {459 const batch = [];460 for (let d = 0; d < latentDim; d++) {461 const row = [];462 for (let t = 0; t < latentLen; t++) {463 const u1 = Math.random();464 const u2 = Math.random();465 const randNormal = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);466 row.push(randNormal);467 }468 batch.push(row);469 }470 noisyLatent.push(batch);471 }472 473 const latentMask = getLatentMask(wavLengths, cfgs);474 475 for (let b = 0; b < noisyLatent.length; b++) {476 for (let d = 0; d < noisyLatent[b].length; d++) {477 for (let t = 0; t < noisyLatent[b][d].length; t++) {478 noisyLatent[b][d][t] *= latentMask[b][0][t];479 }480 }481 }482 483 return { noisyLatent, latentMask };484}485 486export async function loadOnnx(onnxPath, opts) {487 return await ort.InferenceSession.create(onnxPath, opts);488}489 490export async function loadOnnxAll(basePath, opts, onProgress) {491 const models = [492 { name: 'Duration Predictor', path: `${basePath}/duration_predictor.onnx`, key: 'dpOrt' },493 { name: 'Text Encoder', path: `${basePath}/text_encoder.onnx`, key: 'textEncOrt' },494 { name: 'Vector Estimator', path: `${basePath}/vector_estimator.onnx`, key: 'vectorEstOrt' },495 { name: 'Vocoder', path: `${basePath}/vocoder.onnx`, key: 'vocoderOrt' }496 ];497 498 const result = {};499 let loadedCount = 0;500 501 // Load all models in parallel502 const loadPromises = models.map(async (model) => {503 const session = await loadOnnx(model.path, opts);504 loadedCount++;505 if (onProgress) {506 onProgress(model.name, loadedCount, models.length);507 }508 return { key: model.key, session };509 });510 511 // Wait for all models to load512 const loadedModels = await Promise.all(loadPromises);513 514 // Organize results515 loadedModels.forEach(({ key, session }) => {516 result[key] = session;517 });518 519 try {520 // Download counting. Skip localhost to avoid noisy requests during local testing.521 const hostname = typeof window !== 'undefined' ? window.location.hostname : '';522 const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(hostname);523 if (!isLocalhost) {524 await fetch('https://huggingface.co/snowy-0wl/supertonic-3/resolve/main/config.json', {525 mode: 'no-cors',526 cache: 'no-store'527 });528 }529 } catch (error) {530 console.warn('Failed to update download count:', error);531 }532 return result;533}534 535export async function loadCfgs(basePath) {536 const response = await fetch(`${basePath}/tts.json`);537 return await response.json();538}539 540export async function loadProcessors(basePath) {541 const response = await fetch(`${basePath}/unicode_indexer.json`);542 const unicodeIndexerData = await response.json();543 const textProcessor = new UnicodeProcessor(unicodeIndexerData);544 545 return { textProcessor };546}547 548function parseWavFile(buffer) {549 const view = new DataView(buffer);550 551 // Check RIFF header552 const riff = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));553 if (riff !== 'RIFF') {554 throw new Error('Not a valid WAV file');555 }556 557 const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11));558 if (wave !== 'WAVE') {559 throw new Error('Not a valid WAV file');560 }561 562 let offset = 12;563 let fmtChunk = null;564 let dataChunk = null;565 566 while (offset < buffer.byteLength) {567 const chunkId = String.fromCharCode(568 view.getUint8(offset), 569 view.getUint8(offset + 1), 570 view.getUint8(offset + 2), 571 view.getUint8(offset + 3)572 );573 const chunkSize = view.getUint32(offset + 4, true);574 575 if (chunkId === 'fmt ') {576 fmtChunk = {577 audioFormat: view.getUint16(offset + 8, true),578 numChannels: view.getUint16(offset + 10, true),579 sampleRate: view.getUint32(offset + 12, true),580 bitsPerSample: view.getUint16(offset + 22, true)581 };582 } else if (chunkId === 'data') {583 dataChunk = {584 offset: offset + 8,585 size: chunkSize586 };587 break;588 }589 590 offset += 8 + chunkSize;591 }592 593 if (!fmtChunk || !dataChunk) {594 throw new Error('Invalid WAV file format');595 }596 597 const bytesPerSample = fmtChunk.bitsPerSample / 8;598 const numSamples = Math.floor(dataChunk.size / (bytesPerSample * fmtChunk.numChannels));599 const audioData = new Float32Array(numSamples);600 601 if (fmtChunk.bitsPerSample === 16) {602 for (let i = 0; i < numSamples; i++) {603 let sample = 0;604 for (let ch = 0; ch < fmtChunk.numChannels; ch++) {605 const sampleOffset = dataChunk.offset + (i * fmtChunk.numChannels + ch) * 2;606 sample += view.getInt16(sampleOffset, true);607 }608 audioData[i] = (sample / fmtChunk.numChannels) / 32768.0;609 }610 } else if (fmtChunk.bitsPerSample === 24) {611 // Support 24-bit PCM612 for (let i = 0; i < numSamples; i++) {613 let sample = 0;614 for (let ch = 0; ch < fmtChunk.numChannels; ch++) {615 const sampleOffset = dataChunk.offset + (i * fmtChunk.numChannels + ch) * 3;616 // Read 3 bytes and convert to signed 24-bit integer617 const byte1 = view.getUint8(sampleOffset);618 const byte2 = view.getUint8(sampleOffset + 1);619 const byte3 = view.getUint8(sampleOffset + 2);620 let value = (byte3 << 16) | (byte2 << 8) | byte1;621 // Convert to signed (two's complement)622 if (value & 0x800000) {623 value = value - 0x1000000;624 }625 sample += value;626 }627 audioData[i] = (sample / fmtChunk.numChannels) / 8388608.0; // 2^23628 }629 } else if (fmtChunk.bitsPerSample === 32) {630 for (let i = 0; i < numSamples; i++) {631 let sample = 0;632 for (let ch = 0; ch < fmtChunk.numChannels; ch++) {633 const sampleOffset = dataChunk.offset + (i * fmtChunk.numChannels + ch) * 4;634 sample += view.getFloat32(sampleOffset, true);635 }636 audioData[i] = sample / fmtChunk.numChannels;637 }638 } else {639 throw new Error(`Unsupported bit depth: ${fmtChunk.bitsPerSample}. Supported formats: 16-bit, 24-bit, 32-bit`);640 }641 642 return {643 sampleRate: fmtChunk.sampleRate,644 audioData: audioData645 };646}647 648export function arrayToTensor(array, dims) {649 const flat = array.flat(Infinity);650 return new ort.Tensor('float32', Float32Array.from(flat), dims);651}652 653export function intArrayToTensor(array, dims) {654 const flat = array.flat(Infinity);655 return new ort.Tensor('int64', BigInt64Array.from(flat.map(x => BigInt(x))), dims);656}657 658export function writeWavFile(audioData, sampleRate) {659 const numChannels = 1;660 const bitsPerSample = 16;661 const byteRate = sampleRate * numChannels * bitsPerSample / 8;662 const blockAlign = numChannels * bitsPerSample / 8;663 const dataSize = audioData.length * bitsPerSample / 8;664 665 const buffer = new ArrayBuffer(44 + dataSize);666 const view = new DataView(buffer);667 668 // RIFF header669 view.setUint8(0, 'R'.charCodeAt(0));670 view.setUint8(1, 'I'.charCodeAt(0));671 view.setUint8(2, 'F'.charCodeAt(0));672 view.setUint8(3, 'F'.charCodeAt(0));673 view.setUint32(4, 36 + dataSize, true);674 view.setUint8(8, 'W'.charCodeAt(0));675 view.setUint8(9, 'A'.charCodeAt(0));676 view.setUint8(10, 'V'.charCodeAt(0));677 view.setUint8(11, 'E'.charCodeAt(0));678 679 // fmt chunk680 view.setUint8(12, 'f'.charCodeAt(0));681 view.setUint8(13, 'm'.charCodeAt(0));682 view.setUint8(14, 't'.charCodeAt(0));683 view.setUint8(15, ' '.charCodeAt(0));684 view.setUint32(16, 16, true);685 view.setUint16(20, 1, true); // PCM686 view.setUint16(22, numChannels, true);687 view.setUint32(24, sampleRate, true);688 view.setUint32(28, byteRate, true);689 view.setUint16(32, blockAlign, true);690 view.setUint16(34, bitsPerSample, true);691 692 // data chunk693 view.setUint8(36, 'd'.charCodeAt(0));694 view.setUint8(37, 'a'.charCodeAt(0));695 view.setUint8(38, 't'.charCodeAt(0));696 view.setUint8(39, 'a'.charCodeAt(0));697 view.setUint32(40, dataSize, true);698 699 // Write audio data700 for (let i = 0; i < audioData.length; i++) {701 const sample = Math.max(-1, Math.min(1, audioData[i]));702 const intSample = Math.floor(sample * 32767);703 view.setInt16(44 + i * 2, intSample, true);704 }705 706 return buffer;707}708 709 710 711// Smooth scroll functionality712document.addEventListener('DOMContentLoaded', () => {713 // Smooth scroll for anchor links714 document.querySelectorAll('a[href^="#"]').forEach(anchor => {715 anchor.addEventListener('click', function (e) {716 e.preventDefault();717 const href = this.getAttribute('href');718 const target = document.querySelector(href);719 if (target) {720 // Update URL with anchor721 if (history.pushState) {722 history.pushState(null, null, href);723 }724 target.scrollIntoView({725 behavior: 'smooth',726 block: 'start'727 });728 }729 });730 });731 732 // Add scroll animation for sections733 const observerOptions = {734 threshold: 0.1,735 rootMargin: '0px 0px -100px 0px'736 };737 738 const observer = new IntersectionObserver((entries) => {739 entries.forEach(entry => {740 if (entry.isIntersecting) {741 entry.target.style.opacity = '1';742 entry.target.style.transform = 'translateY(0)';743 }744 });745 }, observerOptions);746 747});748 749// TTS Demo functionality750(async function() {751 // Check if we're on a page with the TTS demo752 const demoTextInput = document.getElementById('demoTextInput');753 if (!demoTextInput) return;754 755 // Configure ONNX Runtime WASM assets756 ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.0/dist/';757 ort.env.wasm.numThreads = 1;758 759 760 // Configuration761 const REF_EMBEDDING_PATHS = {762 'F1': 'assets/voice_styles/F1.json',763 'F2': 'assets/voice_styles/F2.json',764 'F3': 'assets/voice_styles/F3.json',765 'F4': 'assets/voice_styles/F4.json',766 'F5': 'assets/voice_styles/F5.json',767 'M1': 'assets/voice_styles/M1.json',768 'M2': 'assets/voice_styles/M2.json',769 'M3': 'assets/voice_styles/M3.json',770 'M4': 'assets/voice_styles/M4.json',771 'M5': 'assets/voice_styles/M5.json'772 };773 774 // Voice descriptions775 const VOICE_DESCRIPTIONS = {776 'F1': 'Sarah - A calm female voice with a slightly low tone; steady and composed.',777 'F2': 'Lily - A bright, cheerful female voice; lively, playful, and youthful with spirited energy.',778 'F3': 'Jessica - A clear, professional announcer-style female voice; articulate and broadcast-ready.',779 'F4': 'Olivia - A crisp, confident female voice; distinct and expressive with strong delivery.',780 'F5': 'Emily - A kind, gentle female voice; soft-spoken, calm, and naturally soothing.',781 'M1': 'Alex - A lively, upbeat male voice with confident energy and a standard, clear tone.',782 'M2': 'James - A deep, robust male voice; calm, composed, and serious with a grounded presence.',783 'M3': 'Robert - A polished, authoritative male voice; confident and trustworthy with strong presentation quality.',784 'M4': 'Sam - A soft, neutral-toned male voice; gentle and approachable with a youthful, friendly quality.',785 'M5': 'Daniel - A warm, soft-spoken male voice; calm and soothing with a natural storytelling quality.'786 };787 788 // Global state789 let models = null;790 let cfgs = null;791 let processors = null;792 let currentVoice = 'M3'; // Default to Robert voice793 794 // Detect browser language and set initial language795 function detectBrowserLanguage() {796 // Get browser language (works in Chrome, Safari, Edge, Firefox, Opera, Samsung Internet)797 const browserLang = navigator.language || navigator.userLanguage || 'en';798 799 // Extract language code (e.g., 'en-US' -> 'en', 'ko-KR' -> 'ko')800 const langCode = browserLang.split('-')[0].toLowerCase();801 802 // Reuse the 31-language list defined at module top-level803 return AVAILABLE_LANGS.includes(langCode) ? langCode : 'en';804 }805 806 let currentLanguage = detectBrowserLanguage(); // Auto-detect from browser807 let refEmbeddingCache = {}; // Cache for embeddings808 let currentStyleTtlTensor = null;809 let currentStyleDpTensor = null;810 let modelsLoading = false; // Track if models are currently loading811 let modelsLoaded = false; // Track if models are fully loaded812 let modelsLoadPromise = null; // Promise for model loading813 814 // UI Elements815 const demoStatusBox = document.getElementById('demoStatusBox');816 const demoStatusText = document.getElementById('demoStatusText');817 const demoGenerateBtn = document.getElementById('demoGenerateBtn');818 const demoTotalSteps = document.getElementById('demoTotalSteps');819 const demoSpeed = document.getElementById('demoSpeed');820 const demoTotalStepsValue = document.getElementById('demoTotalStepsValue');821 const demoSpeedValue = document.getElementById('demoSpeedValue');822 const demoResults = document.getElementById('demoResults');823 const demoError = document.getElementById('demoError');824 const demoCharCount = document.getElementById('demoCharCount');825 const demoCharCounter = document.getElementById('demoCharCounter');826 const demoCharWarning = document.getElementById('demoCharWarning');827 const fixedFontPresets = new Set(['paragraph', 'script']);828 let currentPreset = 'quote'; // Initialize with quote829 830 // Text validation constants831 const MIN_CHARS = 10;832 const MAX_CHUNK_LENGTH_DEFAULT = 300; // Maximum length for each chunk (default)833 const MAX_CHUNK_LENGTH_CJK = 120; // Maximum length for Korean/Japanese834 function getMaxChunkLength() {835 return (currentLanguage === 'ko' || currentLanguage === 'ja') ? MAX_CHUNK_LENGTH_CJK : MAX_CHUNK_LENGTH_DEFAULT;836 }837 838 // Custom audio player state (shared across generations)839 let audioContext = null;840 let scheduledSources = [];841 let audioChunks = [];842 let totalDuration = 0;843 let startTime = 0;844 let pauseTime = 0;845 let isPaused = false;846 let isPlaying = false;847 let animationFrameId = null;848 let playPauseBtn = null;849 let progressBar = null;850 let currentTimeDisplay = null;851 let durationDisplay = null;852 let progressFill = null;853 let firstChunkGenerationTime = 0; // Processing time for first chunk854 let totalChunks = 0;855 let nextScheduledTime = 0; // Next time to schedule audio chunk856 let currentGenerationTextLength = 0;857 let supertonicPlayerRecord = null; // Supertonic player record for cross-player pause management858 let isGenerating = false; // Track if speech generation is in progress859 860 // Track all custom audio players861 let customAudioPlayers = [];862 863 const isMobileViewport = () => window.matchMedia('(max-width: 768px)').matches;864 // Check if device actually supports touch (not just viewport size)865 const isTouchDevice = () => 'ontouchstart' in window || navigator.maxTouchPoints > 0;866 const trimDecimalsForMobile = (formatted) => {867 if (!formatted) return formatted;868 return isMobileViewport() ? formatted.replace(/\.\d{2}$/, '') : formatted;869 };870 871 function pauseAllPlayersExcept(currentPlayer) {872 customAudioPlayers.forEach(player => {873 if (player !== currentPlayer && player && typeof player.pausePlayback === 'function') {874 player.pausePlayback();875 }876 });877 }878 879 880 /**881 * Chunk text into smaller pieces based on sentence boundaries882 * @param {string} text - The text to chunk883 * @param {number} maxLen - Maximum length for each chunk884 * @returns {Array<string>} - Array of text chunks885 */886 function chunkText(text, maxLen = getMaxChunkLength()) {887 // Split by paragraph (two or more newlines)888 const paragraphs = text.trim().split(/\n\s*\n+/).filter(p => p.trim());889 890 const chunks = [];891 892 for (let paragraph of paragraphs) {893 paragraph = paragraph.trim();894 if (!paragraph) continue;895 896 // Split by sentence boundaries (period, question mark, exclamation mark followed by space)897 // But exclude common abbreviations like Mr., Mrs., Dr., etc. and single capital letters like F.898 const sentences = paragraph.split(/(?<!Mr\.|Mrs\.|Ms\.|Dr\.|Prof\.|Sr\.|Jr\.|Ph\.D\.|etc\.|e\.g\.|i\.e\.|vs\.|Inc\.|Ltd\.|Co\.|Corp\.|St\.|Ave\.|Blvd\.)(?<!\b[A-Z]\.)(?<=[.!?])\s+/);899 900 let currentChunk = "";901 902 for (let sentence of sentences) {903 if (currentChunk.length + sentence.length + 1 <= maxLen) {904 currentChunk += (currentChunk ? " " : "") + sentence;905 } else {906 if (currentChunk) {907 chunks.push(currentChunk.trim());908 }909 currentChunk = sentence;910 }911 }912 913 if (currentChunk) {914 chunks.push(currentChunk.trim());915 }916 }917 918 return chunks;919 }920 921 function showDemoStatus(message, type = 'info', progress = null) {922 demoStatusText.innerHTML = message;923 demoStatusBox.className = 'demo-status-box';924 demoStatusBox.style.removeProperty('--status-progress');925 demoStatusBox.style.display = ''; // Show the status box926 927 if (type === 'success') {928 demoStatusBox.classList.add('success');929 } else if (type === 'error') {930 demoStatusBox.classList.add('error');931 }932 933 // Update progress bar934 if (progress !== null && progress >= 0 && progress <= 100) {935 const clampedProgress = Math.max(0, Math.min(progress, 100));936 demoStatusBox.style.setProperty('--status-progress', `${clampedProgress}%`);937 demoStatusBox.classList.toggle('complete', clampedProgress >= 100);938 } else if (type === 'success' || type === 'error') {939 demoStatusBox.style.removeProperty('--status-progress');940 demoStatusBox.classList.remove('complete');941 } else {942 demoStatusBox.style.removeProperty('--status-progress');943 demoStatusBox.classList.remove('complete');944 }945 }946 947 function hideDemoStatus() {948 demoStatusBox.style.display = 'none';949 }950 951 function showDemoError(message) {952 demoError.textContent = message;953 demoError.classList.add('active');954 }955 956 function hideDemoError() {957 demoError.classList.remove('active');958 }959 960 // Language toast notification961 const languageToast = document.getElementById('languageToast');962 const languageToastMessage = document.getElementById('languageToastMessage');963 let languageToastTimeout = null;964 965 function showLanguageToast(fromLang, toLang) {966 if (!languageToast || !languageToastMessage) return;967 968 const fromName = LANGUAGE_NAMES[fromLang] || fromLang;969 const toName = LANGUAGE_NAMES[toLang] || toLang;970 971 languageToastMessage.innerHTML = `Language auto-detected: <strong>${toName}</strong>`;972 973 // Clear any existing timeout974 if (languageToastTimeout) {975 clearTimeout(languageToastTimeout);976 }977 978 // Show toast979 languageToast.classList.add('show');980 981 // Hide after 3 seconds982 languageToastTimeout = setTimeout(() => {983 languageToast.classList.remove('show');984 }, 3000);985 }986 987 // Validate characters in text988 function validateCharacters(text) {989 if (!processors || !processors.textProcessor) {990 return { valid: true, unsupportedChars: [] };991 }992 993 try {994 // Extract unique characters to minimize preprocessText calls995 const uniqueChars = [...new Set(text)];996 997 // Build mapping for unique chars only (much faster for long texts)998 // For example, Korean '간' -> 'ㄱㅏㄴ', so we map 'ㄱ','ㅏ','ㄴ' -> '간'999 const processedToOriginal = new Map();1000 const charToProcessed = new Map();1001 1002 for (const char of uniqueChars) {1003 const processedChar = preprocessText(char);1004 charToProcessed.set(char, processedChar);1005 1006 // Map each processed character back to its original1007 for (const pc of processedChar) {1008 if (!processedToOriginal.has(pc)) {1009 processedToOriginal.set(pc, new Set());1010 }1011 processedToOriginal.get(pc).add(char);1012 }1013 }1014 1015 // Build full processed text using cached mappings1016 const fullProcessedText = Array.from(text).map(c => charToProcessed.get(c)).join('');1017 1018 // Check the entire processed text once (efficient)1019 const { unsupportedChars } = processors.textProcessor.call([fullProcessedText]);1020 1021 // Map unsupported processed chars back to original chars1022 const unsupportedOriginalChars = new Set();1023 if (unsupportedChars && unsupportedChars.length > 0) {1024 for (const unsupportedChar of unsupportedChars) {1025 const originalChars = processedToOriginal.get(unsupportedChar);1026 if (originalChars) {1027 originalChars.forEach(c => unsupportedOriginalChars.add(c));1028 }1029 }1030 }1031 1032 const unsupportedCharsArray = Array.from(unsupportedOriginalChars);1033 return { 1034 valid: unsupportedCharsArray.length === 0, 1035 unsupportedChars: unsupportedCharsArray1036 };1037 } catch (error) {1038 return { valid: true, unsupportedChars: [] };1039 }1040 }1041 1042 // Update character counter and validate text length1043 function updateCharCounter() {1044 const rawText = demoTextInput.textContent || demoTextInput.innerText || '';1045 const text = rawText.replace(/\n$/g, ''); // Remove trailing newline that browsers may add1046 const length = text.length;1047 1048 demoCharCount.textContent = length;1049 1050 if (fixedFontPresets.has(currentPreset)) {1051 demoTextInput.style.fontSize = '1.5rem';1052 } else {1053 // Get the actual width of the textarea1054 const textareaWidth = demoTextInput.offsetWidth;1055 // Check if mobile (572px or less) for 2x font size scaling1056 const isMobile = window.innerWidth <= 572;1057 const mobileMultiplier = isMobile ? 2 : 1;1058 1059 let fontSizeRatio;1060 if (length <= 100) {1061 fontSizeRatio = 0.055 * mobileMultiplier; // 5.5% of width1062 } else if (length <= 200) {1063 fontSizeRatio = 0.04 * mobileMultiplier; // 4% of width1064 } else if (length < 240) {1065 fontSizeRatio = 0.053125 * mobileMultiplier; // ~5.3125% of width1066 } else if (length < 400) {1067 fontSizeRatio = 0.0425 * mobileMultiplier; // ~4.25% of width1068 } else if (length < 700) {1069 fontSizeRatio = 0.031875 * mobileMultiplier; // ~3.1875% of width1070 } else {1071 fontSizeRatio = 0.025 * mobileMultiplier; // 2.5% of width1072 }1073 1074 // Calculate font size based on actual width1075 const fontSize = textareaWidth * fontSizeRatio;1076 demoTextInput.style.fontSize = `${fontSize}px`;1077 }1078 1079 // Remove all status classes1080 demoCharCounter.classList.remove('error', 'warning', 'valid');1081 1082 // Check for unsupported characters first (only if models are loaded)1083 let hasUnsupportedChars = false;1084 if (models && processors && length > 0) {1085 const validation = validateCharacters(text);1086 if (!validation.valid && validation.unsupportedChars.length > 0) {1087 hasUnsupportedChars = true;1088 const charList = validation.unsupportedChars.slice(0, 5).map(c => `"${c}"`).join(', ');1089 const moreChars = validation.unsupportedChars.length > 5 ? ` and ${validation.unsupportedChars.length - 5} more` : '';1090 showDemoError(`Unsupported characters detected: ${charList}${moreChars}. Please remove them before generating speech.`);1091 } else {1092 hideDemoError();1093 }1094 }1095 1096 // Update status based on length and character validation1097 if (length < MIN_CHARS) {1098 demoCharCounter.classList.add('error');1099 demoCharWarning.textContent = '(At least 10 characters)';1100 demoGenerateBtn.disabled = true;1101 } else if (hasUnsupportedChars) {1102 demoCharCounter.classList.add('error');1103 demoCharWarning.textContent = '(Unsupported characters)';1104 demoGenerateBtn.disabled = true;1105 } else {1106 demoCharCounter.classList.add('valid');1107 demoCharWarning.textContent = '';1108 // Enable only if models are loaded AND not currently generating1109 demoGenerateBtn.disabled = !models || isGenerating;1110 }1111 }1112 1113 // Validate text input1114 function validateTextInput(text) {1115 if (!text || text.trim().length === 0) {1116 return { valid: false, message: 'Please enter some text.' };1117 }1118 if (text.length < MIN_CHARS) {1119 return { valid: false, message: `Text must be at least ${MIN_CHARS} characters long. (Currently ${text.length})` };1120 }1121 return { valid: true };1122 }1123 1124 // Load pre-extracted style embeddings from JSON1125 async function loadStyleEmbeddings(voice) {1126 try {1127 // Check if already cached1128 if (refEmbeddingCache[voice]) {1129 return refEmbeddingCache[voice];1130 }1131 1132 const embeddingPath = REF_EMBEDDING_PATHS[voice];1133 if (!embeddingPath) {1134 throw new Error(`No embedding path configured for voice: ${voice}`);1135 }1136 1137 const response = await fetch(embeddingPath);1138 if (!response.ok) {1139 throw new Error(`Failed to fetch embedding: ${response.statusText}`);1140 }1141 1142 const embeddingData = await response.json();1143 1144 // Convert JSON data to ONNX tensors1145 // Flatten nested arrays before creating Float32Array1146 const styleTtlData = embeddingData.style_ttl.data.flat(Infinity);1147 const styleTtlTensor = new ort.Tensor(1148 embeddingData.style_ttl.type || 'float32',1149 Float32Array.from(styleTtlData),1150 embeddingData.style_ttl.dims1151 );1152 1153 const styleDpData = embeddingData.style_dp.data.flat(Infinity);1154 const styleDpTensor = new ort.Tensor(1155 embeddingData.style_dp.type || 'float32',1156 Float32Array.from(styleDpData),1157 embeddingData.style_dp.dims1158 );1159 1160 const embeddings = {1161 styleTtl: styleTtlTensor,1162 styleDp: styleDpTensor1163 };1164 1165 // Cache the embeddings1166 refEmbeddingCache[voice] = embeddings;1167 1168 return embeddings;1169 } catch (error) {1170 throw error;1171 }1172 }1173 1174 // Switch to a different voice1175 async function switchVoice(voice) {1176 try {1177 const embeddings = await loadStyleEmbeddings(voice);1178 1179 currentStyleTtlTensor = embeddings.styleTtl;1180 currentStyleDpTensor = embeddings.styleDp;1181 currentVoice = voice;1182 1183 // Update active speaker in UI1184 if (typeof window.updateActiveSpeaker === 'function') {1185 window.updateActiveSpeaker(voice);1186 }1187 1188 // Re-validate text after switching voice1189 updateCharCounter();1190 } catch (error) {1191 showDemoError(`Failed to load voice ${voice}: ${getErrorMessage(error)}`);1192 throw error;1193 }1194 }1195 1196 // Warmup models with dummy inference (no audio playback, no UI updates)1197 async function warmupModels() {1198 try {1199 const dummyText = 'Hello, this is a quick warmup.';1200 const totalStep = 1;