lovegold/supertonic-2
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 formatStatValueWithSuffix(value, suffix, options = {}) {53 const { firstLabel = false } = options;54 if (value === undefined || value === null) {55 return '';56 }57 if (!suffix) {58 const raw = `${value}`;59 return escapeHtml(raw);60 }61 const raw = `${value}`.trim();62 if (!raw || raw === '--' || raw === '-' || raw.toLowerCase() === 'error') {63 return escapeHtml(raw);64 }65 const appendSuffix = (segment, includePrefix = false) => {66 const trimmed = segment.trim();67 if (!trimmed) {68 return '';69 }70 const escapedValue = `<span class="stat-value-number">${escapeHtml(trimmed)}</span>`;71 const suffixSpan = `<span class="stat-label stat-suffix">${escapeHtml(suffix)}</span>`;72 const prefixSpan = includePrefix && firstLabel73 ? `<span class="stat-label stat-suffix stat-prefix">First</span>`74 : '';75 const segmentClass = includePrefix && firstLabel76 ? 'stat-value-segment has-prefix'77 : 'stat-value-segment';78 return `<span class="${segmentClass}">${prefixSpan}${escapedValue}${suffixSpan}</span>`;79 };80 if (raw.includes('/')) {81 const parts = raw.split('/');82 const segments = parts.map((part, index) => appendSuffix(part, index === 0));83 return segments.join(' / ');84 }85 return appendSuffix(raw);86}87 88/**89 * Unicode text processor90 */91export class UnicodeProcessor {92 constructor(indexer) {93 this.indexer = indexer;94 }95 96 call(textList, lang = null) {97 const processedTexts = textList.map(t => preprocessText(t, lang));98 const textIdsLengths = processedTexts.map(t => t.length);99 const maxLen = Math.max(...textIdsLengths);100 101 const textIds = [];102 const unsupportedChars = new Set();103 104 for (let i = 0; i < processedTexts.length; i++) {105 const row = new Array(maxLen).fill(0);106 const unicodeVals = textToUnicodeValues(processedTexts[i]);107 for (let j = 0; j < unicodeVals.length; j++) {108 const indexValue = this.indexer[unicodeVals[j]];109 // Check if character is supported (not -1, undefined, or null)110 if (indexValue === undefined || indexValue === null || indexValue === -1) {111 unsupportedChars.add(processedTexts[i][j]);112 row[j] = 0; // Use 0 as fallback113 } else {114 row[j] = indexValue;115 }116 }117 textIds.push(row);118 }119 120 const textMask = getTextMask(textIdsLengths);121 return { textIds, textMask, unsupportedChars: Array.from(unsupportedChars) };122 }123}124 125const AVAILABLE_LANGS = ["en", "ko", "es", "pt", "fr"];126 127/**128 * Language detection based on character patterns and language-specific markers129 * Returns the detected language code or null if uncertain130 */131export function detectLanguage(text) {132 if (!text || text.trim().length < 3) {133 return null;134 }135 136 // Only consider last 100 characters for efficiency137 const sampleText = text.length > 100 ? text.substring(text.length - 100) : text;138 139 // Normalize text for analysis140 const normalizedText = sampleText.normalize('NFC').toLowerCase();141 142 // Korean detection: Hangul characters (most reliable)143 const koreanRegex = /[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF]/g;144 const koreanMatches = normalizedText.match(koreanRegex) || [];145 if (koreanMatches.length >= 2) {146 return 'ko';147 }148 149 // Scoring system for Latin-based languages150 const scores = { en: 0, es: 0, fr: 0, pt: 0 };151 152 // 1. Highly distinctive characters (definitive markers)153 if (/ñ/.test(normalizedText)) scores.es += 15;154 if (/[¿¡]/.test(normalizedText)) scores.es += 12;155 if (/ã/.test(normalizedText)) scores.pt += 15;156 if (/õ/.test(normalizedText)) scores.pt += 15;157 if (/œ/.test(normalizedText)) scores.fr += 15;158 if (/[ùû]/.test(normalizedText)) scores.fr += 10;159 160 // ç is shared between French and Portuguese161 if (/ç/.test(normalizedText)) {162 scores.fr += 4;163 scores.pt += 4;164 }165 166 // French-specific accent patterns167 if (/[èêë]/.test(normalizedText)) scores.fr += 5;168 if (/[àâ]/.test(normalizedText)) scores.fr += 3;169 if (/[îï]/.test(normalizedText)) scores.fr += 4;170 if (/ô/.test(normalizedText)) scores.fr += 3;171 172 // 2. Exclusive stopwords (words unique to one language)173 const exclusiveWords = {174 en: ['the', 'is', 'are', 'was', 'were', 'have', 'has', 'been', 'will', 'would', 'could', 'should', 'this', 'that', 'with', 'from', 'they', 'what', 'which', 'there', 'their', 'about', 'these', 'other', 'into', 'just', 'your', 'some', 'than', 'them', 'then', 'only', 'being', 'through', 'after', 'before'],175 es: ['el', 'los', 'las', 'es', 'está', 'están', 'porque', 'pero', 'muy', 'también', 'más', 'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'yo', 'tú', 'nosotros', 'ellos', 'ellas', 'hola', 'gracias', 'buenos', 'buenas', 'ahora', 'siempre', 'nunca', 'todo', 'nada', 'algo', 'alguien'],176 fr: ['le', 'les', 'est', 'sont', 'dans', 'ce', 'cette', 'ces', 'il', 'elle', 'ils', 'elles', 'je', 'tu', 'nous', 'vous', 'avec', 'sur', 'ne', 'pas', 'plus', 'tout', 'bien', 'fait', 'être', 'avoir', 'donc', 'car', 'ni', 'jamais', 'toujours', 'rien', 'quelque', 'encore', 'aussi', 'très', 'peu', 'ici'],177 pt: ['os', 'as', 'é', 'são', 'está', 'estão', 'não', 'na', 'no', 'da', 'do', 'das', 'dos', 'ao', 'aos', 'ele', 'ela', 'eles', 'elas', 'eu', 'nós', 'você', 'vocês', 'seu', 'sua', 'seus', 'suas', 'muito', 'também', 'já', 'foi', 'só', 'mesmo', 'ter', 'até', 'isso', 'olá', 'obrigado', 'obrigada', 'bom', 'boa', 'agora', 'sempre', 'nunca', 'tudo', 'nada', 'algo', 'alguém']178 };179 180 // Extract words from text181 const words = normalizedText.match(/[a-záàâãäåçéèêëíìîïñóòôõöúùûüýÿœæ]+/g) || [];182 183 for (const word of words) {184 for (const [lang, wordList] of Object.entries(exclusiveWords)) {185 if (wordList.includes(word)) {186 scores[lang] += 3;187 }188 }189 }190 191 // 3. Common n-grams (character patterns)192 const ngramPatterns = {193 en: [/th/g, /ing/g, /tion/g, /ight/g, /ould/g],194 es: [/ción/g, /mente/g, /ado/g, /ido/g],195 fr: [/tion/g, /ment/g, /eau/g, /aux/g, /eux/g, /oir/g, /ais/g, /ait/g, /ont/g],196 pt: [/ção/g, /ões/g, /mente/g, /ado/g, /ido/g, /nh/g, /lh/g]197 };198 199 for (const [lang, patterns] of Object.entries(ngramPatterns)) {200 for (const pattern of patterns) {201 const matches = normalizedText.match(pattern) || [];202 scores[lang] += matches.length * 2;203 }204 }205 206 // 4. French contractions and apostrophes207 const frenchContractions = /[cdjlmnst]'[aeiouéèêàâîïôûù]/g;208 const frenchContractionMatches = normalizedText.match(frenchContractions) || [];209 scores.fr += frenchContractionMatches.length * 5;210 211 // 5. Article patterns that help distinguish212 // "the" is very English, "el/la" Spanish, "le/la" French, "o/a" Portuguese213 if (/\bthe\b/.test(normalizedText)) scores.en += 5;214 if (/\b(el|los)\b/.test(normalizedText)) scores.es += 4;215 if (/\b(le|les)\b/.test(normalizedText)) scores.fr += 4;216 if (/\b(o|os)\b/.test(normalizedText)) scores.pt += 3;217 218 // Find the language with the highest score219 let maxScore = 0;220 let detectedLang = null;221 222 for (const [lang, score] of Object.entries(scores)) {223 if (score > maxScore) {224 maxScore = score;225 detectedLang = lang;226 }227 }228 229 // Only return if we have enough confidence (minimum threshold)230 if (maxScore >= 4) {231 return detectedLang;232 }233 234 return null;235}236 237// Language display names for toast notification238const LANGUAGE_NAMES = {239 'en': 'English',240 'ko': 'Korean',241 'es': 'Spanish',242 'pt': 'Portuguese',243 'fr': 'French'244};245 246export function preprocessText(text, lang = null) {247 // Normalize unicode characters248 text = text.normalize('NFKD');249 250 // Remove emojis251 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, '');252 253 // Replace various dashes and symbols254 const replacements = {255 "–": "-",256 "‑": "-",257 "—": "-",258 "_": " ",259 "\u201C": '"', // "260 "\u201D": '"', // "261 "\u2018": "'", // '262 "\u2019": "'", // '263 "´": "'",264 "`": "'",265 "[": " ",266 "]": " ",267 "|": " ",268 "/": " ", // FIXME: `/` should be pronounced.269 "#": " ", // FIXME: `#` should be pronounced.270 "→": " ",271 "←": " ",272 };273 274 for (const [k, v] of Object.entries(replacements)) {275 text = text.replaceAll(k, v);276 }277 278 // Remove special symbols279 text = text.replace(/[♥☆♡©\\]/g, "");280 281 // Replace known expressions282 const exprReplacements = {283 "@": " at ",284 "e.g.,": "for example,",285 "i.e.,": "that is,",286 };287 288 for (const [k, v] of Object.entries(exprReplacements)) {289 text = text.replaceAll(k, v);290 }291 292 // Fix spacing around punctuation293 text = text.replace(/ ,/g, ",");294 text = text.replace(/ \./g, ".");295 text = text.replace(/ !/g, "!");296 text = text.replace(/ \?/g, "?");297 text = text.replace(/ ;/g, ";");298 text = text.replace(/ :/g, ":");299 text = text.replace(/ '/g, "'");300 301 // Remove duplicate quotes302 while (text.includes('""')) {303 text = text.replace(/""/g, '"');304 }305 while (text.includes("''")) {306 text = text.replace(/''/g, "'");307 }308 while (text.includes("``")) {309 text = text.replace(/``/g, "`");310 }311 312 // Remove extra spaces313 text = text.replace(/\s+/g, " ").trim();314 315 // If text doesn't end with punctuation, quotes, or closing brackets, add a period316 if (!/[.!?;:,'"')\]}…。」』】〉》›»]$/.test(text)) {317 text += ".";318 }319 320 // Add language tags321 if (lang !== null) {322 if (!AVAILABLE_LANGS.includes(lang)) {323 throw new Error(`Invalid language: ${lang}`);324 }325 text = `<${lang}>` + text + `</${lang}>`;326 } else {327 text = `<na>` + text + `</na>`;328 }329 330 return text;331}332 333export function textToUnicodeValues(text) {334 return Array.from(text).map(char => char.charCodeAt(0));335}336 337export function lengthToMask(lengths, maxLen = null) {338 maxLen = maxLen || Math.max(...lengths);339 const mask = [];340 for (let i = 0; i < lengths.length; i++) {341 const row = [];342 for (let j = 0; j < maxLen; j++) {343 row.push(j < lengths[i] ? 1.0 : 0.0);344 }345 mask.push([row]);346 }347 return mask;348}349 350export function getTextMask(textIdsLengths) {351 return lengthToMask(textIdsLengths);352}353 354export function getLatentMask(wavLengths, cfgs) {355 const baseChunkSize = cfgs.ae.base_chunk_size;356 const chunkCompressFactor = cfgs.ttl.chunk_compress_factor;357 const latentSize = baseChunkSize * chunkCompressFactor;358 const latentLengths = wavLengths.map(len => 359 Math.floor((len + latentSize - 1) / latentSize)360 );361 return lengthToMask(latentLengths);362}363 364export function sampleNoisyLatent(duration, cfgs) {365 const sampleRate = cfgs.ae.sample_rate;366 const baseChunkSize = cfgs.ae.base_chunk_size;367 const chunkCompressFactor = cfgs.ttl.chunk_compress_factor;368 const ldim = cfgs.ttl.latent_dim;369 370 const wavLenMax = Math.max(...duration.map(d => d[0][0])) * sampleRate;371 const wavLengths = duration.map(d => Math.floor(d[0][0] * sampleRate));372 const chunkSize = baseChunkSize * chunkCompressFactor;373 const latentLen = Math.floor((wavLenMax + chunkSize - 1) / chunkSize);374 const latentDim = ldim * chunkCompressFactor;375 376 const noisyLatent = [];377 for (let b = 0; b < duration.length; b++) {378 const batch = [];379 for (let d = 0; d < latentDim; d++) {380 const row = [];381 for (let t = 0; t < latentLen; t++) {382 const u1 = Math.random();383 const u2 = Math.random();384 const randNormal = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);385 row.push(randNormal);386 }387 batch.push(row);388 }389 noisyLatent.push(batch);390 }391 392 const latentMask = getLatentMask(wavLengths, cfgs);393 394 for (let b = 0; b < noisyLatent.length; b++) {395 for (let d = 0; d < noisyLatent[b].length; d++) {396 for (let t = 0; t < noisyLatent[b][d].length; t++) {397 noisyLatent[b][d][t] *= latentMask[b][0][t];398 }399 }400 }401 402 return { noisyLatent, latentMask };403}404 405export async function loadOnnx(onnxPath, opts) {406 return await ort.InferenceSession.create(onnxPath, opts);407}408 409export async function loadOnnxAll(basePath, opts, onProgress) {410 const models = [411 { name: 'Duration Predictor', path: `${basePath}/duration_predictor.onnx`, key: 'dpOrt' },412 { name: 'Text Encoder', path: `${basePath}/text_encoder.onnx`, key: 'textEncOrt' },413 { name: 'Vector Estimator', path: `${basePath}/vector_estimator.onnx`, key: 'vectorEstOrt' },414 { name: 'Vocoder', path: `${basePath}/vocoder.onnx`, key: 'vocoderOrt' }415 ];416 417 const result = {};418 let loadedCount = 0;419 420 // Load all models in parallel421 const loadPromises = models.map(async (model) => {422 const session = await loadOnnx(model.path, opts);423 loadedCount++;424 if (onProgress) {425 onProgress(model.name, loadedCount, models.length);426 }427 return { key: model.key, session };428 });429 430 // Wait for all models to load431 const loadedModels = await Promise.all(loadPromises);432 433 // Organize results434 loadedModels.forEach(({ key, session }) => {435 result[key] = session;436 });437 438 try {439 // Download counting440 await fetch('https://huggingface.co/Supertone/supertonic-2/resolve/main/config.json');441 } catch (error) {442 console.warn('Failed to update download count:', error);443 }444 return result;445}446 447export async function loadCfgs(basePath) {448 const response = await fetch(`${basePath}/tts.json`);449 return await response.json();450}451 452export async function loadProcessors(basePath) {453 const response = await fetch(`${basePath}/unicode_indexer.json`);454 const unicodeIndexerData = await response.json();455 const textProcessor = new UnicodeProcessor(unicodeIndexerData);456 457 return { textProcessor };458}459 460function parseWavFile(buffer) {461 const view = new DataView(buffer);462 463 // Check RIFF header464 const riff = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));465 if (riff !== 'RIFF') {466 throw new Error('Not a valid WAV file');467 }468 469 const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11));470 if (wave !== 'WAVE') {471 throw new Error('Not a valid WAV file');472 }473 474 let offset = 12;475 let fmtChunk = null;476 let dataChunk = null;477 478 while (offset < buffer.byteLength) {479 const chunkId = String.fromCharCode(480 view.getUint8(offset), 481 view.getUint8(offset + 1), 482 view.getUint8(offset + 2), 483 view.getUint8(offset + 3)484 );485 const chunkSize = view.getUint32(offset + 4, true);486 487 if (chunkId === 'fmt ') {488 fmtChunk = {489 audioFormat: view.getUint16(offset + 8, true),490 numChannels: view.getUint16(offset + 10, true),491 sampleRate: view.getUint32(offset + 12, true),492 bitsPerSample: view.getUint16(offset + 22, true)493 };494 } else if (chunkId === 'data') {495 dataChunk = {496 offset: offset + 8,497 size: chunkSize498 };499 break;500 }501 502 offset += 8 + chunkSize;503 }504 505 if (!fmtChunk || !dataChunk) {506 throw new Error('Invalid WAV file format');507 }508 509 const bytesPerSample = fmtChunk.bitsPerSample / 8;510 const numSamples = Math.floor(dataChunk.size / (bytesPerSample * fmtChunk.numChannels));511 const audioData = new Float32Array(numSamples);512 513 if (fmtChunk.bitsPerSample === 16) {514 for (let i = 0; i < numSamples; i++) {515 let sample = 0;516 for (let ch = 0; ch < fmtChunk.numChannels; ch++) {517 const sampleOffset = dataChunk.offset + (i * fmtChunk.numChannels + ch) * 2;518 sample += view.getInt16(sampleOffset, true);519 }520 audioData[i] = (sample / fmtChunk.numChannels) / 32768.0;521 }522 } else if (fmtChunk.bitsPerSample === 24) {523 // Support 24-bit PCM524 for (let i = 0; i < numSamples; i++) {525 let sample = 0;526 for (let ch = 0; ch < fmtChunk.numChannels; ch++) {527 const sampleOffset = dataChunk.offset + (i * fmtChunk.numChannels + ch) * 3;528 // Read 3 bytes and convert to signed 24-bit integer529 const byte1 = view.getUint8(sampleOffset);530 const byte2 = view.getUint8(sampleOffset + 1);531 const byte3 = view.getUint8(sampleOffset + 2);532 let value = (byte3 << 16) | (byte2 << 8) | byte1;533 // Convert to signed (two's complement)534 if (value & 0x800000) {535 value = value - 0x1000000;536 }537 sample += value;538 }539 audioData[i] = (sample / fmtChunk.numChannels) / 8388608.0; // 2^23540 }541 } else if (fmtChunk.bitsPerSample === 32) {542 for (let i = 0; i < numSamples; i++) {543 let sample = 0;544 for (let ch = 0; ch < fmtChunk.numChannels; ch++) {545 const sampleOffset = dataChunk.offset + (i * fmtChunk.numChannels + ch) * 4;546 sample += view.getFloat32(sampleOffset, true);547 }548 audioData[i] = sample / fmtChunk.numChannels;549 }550 } else {551 throw new Error(`Unsupported bit depth: ${fmtChunk.bitsPerSample}. Supported formats: 16-bit, 24-bit, 32-bit`);552 }553 554 return {555 sampleRate: fmtChunk.sampleRate,556 audioData: audioData557 };558}559 560export function arrayToTensor(array, dims) {561 const flat = array.flat(Infinity);562 return new ort.Tensor('float32', Float32Array.from(flat), dims);563}564 565export function intArrayToTensor(array, dims) {566 const flat = array.flat(Infinity);567 return new ort.Tensor('int64', BigInt64Array.from(flat.map(x => BigInt(x))), dims);568}569 570export function writeWavFile(audioData, sampleRate) {571 const numChannels = 1;572 const bitsPerSample = 16;573 const byteRate = sampleRate * numChannels * bitsPerSample / 8;574 const blockAlign = numChannels * bitsPerSample / 8;575 const dataSize = audioData.length * bitsPerSample / 8;576 577 const buffer = new ArrayBuffer(44 + dataSize);578 const view = new DataView(buffer);579 580 // RIFF header581 view.setUint8(0, 'R'.charCodeAt(0));582 view.setUint8(1, 'I'.charCodeAt(0));583 view.setUint8(2, 'F'.charCodeAt(0));584 view.setUint8(3, 'F'.charCodeAt(0));585 view.setUint32(4, 36 + dataSize, true);586 view.setUint8(8, 'W'.charCodeAt(0));587 view.setUint8(9, 'A'.charCodeAt(0));588 view.setUint8(10, 'V'.charCodeAt(0));589 view.setUint8(11, 'E'.charCodeAt(0));590 591 // fmt chunk592 view.setUint8(12, 'f'.charCodeAt(0));593 view.setUint8(13, 'm'.charCodeAt(0));594 view.setUint8(14, 't'.charCodeAt(0));595 view.setUint8(15, ' '.charCodeAt(0));596 view.setUint32(16, 16, true);597 view.setUint16(20, 1, true); // PCM598 view.setUint16(22, numChannels, true);599 view.setUint32(24, sampleRate, true);600 view.setUint32(28, byteRate, true);601 view.setUint16(32, blockAlign, true);602 view.setUint16(34, bitsPerSample, true);603 604 // data chunk605 view.setUint8(36, 'd'.charCodeAt(0));606 view.setUint8(37, 'a'.charCodeAt(0));607 view.setUint8(38, 't'.charCodeAt(0));608 view.setUint8(39, 'a'.charCodeAt(0));609 view.setUint32(40, dataSize, true);610 611 // Write audio data612 for (let i = 0; i < audioData.length; i++) {613 const sample = Math.max(-1, Math.min(1, audioData[i]));614 const intSample = Math.floor(sample * 32767);615 view.setInt16(44 + i * 2, intSample, true);616 }617 618 return buffer;619}620 621 622 623// Smooth scroll functionality624document.addEventListener('DOMContentLoaded', () => {625 // Smooth scroll for anchor links626 document.querySelectorAll('a[href^="#"]').forEach(anchor => {627 anchor.addEventListener('click', function (e) {628 e.preventDefault();629 const href = this.getAttribute('href');630 const target = document.querySelector(href);631 if (target) {632 // Update URL with anchor633 if (history.pushState) {634 history.pushState(null, null, href);635 }636 target.scrollIntoView({637 behavior: 'smooth',638 block: 'start'639 });640 }641 });642 });643 644 // Add scroll animation for sections645 const observerOptions = {646 threshold: 0.1,647 rootMargin: '0px 0px -100px 0px'648 };649 650 const observer = new IntersectionObserver((entries) => {651 entries.forEach(entry => {652 if (entry.isIntersecting) {653 entry.target.style.opacity = '1';654 entry.target.style.transform = 'translateY(0)';655 }656 });657 }, observerOptions);658 659});660 661// TTS Demo functionality662(async function() {663 // Check if we're on a page with the TTS demo664 const demoTextInput = document.getElementById('demoTextInput');665 if (!demoTextInput) return;666 667 // Configure ONNX Runtime for WebGPU support668 ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.0/dist/';669 ort.env.wasm.numThreads = 1;670 671 672 // Configuration673 const REF_EMBEDDING_PATHS = {674 'F1': 'assets/voice_styles/F1.json',675 'F2': 'assets/voice_styles/F2.json',676 'F3': 'assets/voice_styles/F3.json',677 'F4': 'assets/voice_styles/F4.json',678 'F5': 'assets/voice_styles/F5.json',679 'M1': 'assets/voice_styles/M1.json',680 'M2': 'assets/voice_styles/M2.json',681 'M3': 'assets/voice_styles/M3.json',682 'M4': 'assets/voice_styles/M4.json',683 'M5': 'assets/voice_styles/M5.json'684 };685 686 // Voice descriptions687 const VOICE_DESCRIPTIONS = {688 'F1': 'Sarah - A calm female voice with a slightly low tone; steady and composed.',689 'F2': 'Lily - A bright, cheerful female voice; lively, playful, and youthful with spirited energy.',690 'F3': 'Jessica - A clear, professional announcer-style female voice; articulate and broadcast-ready.',691 'F4': 'Olivia - A crisp, confident female voice; distinct and expressive with strong delivery.',692 'F5': 'Emily - A kind, gentle female voice; soft-spoken, calm, and naturally soothing.',693 'M1': 'Alex - A lively, upbeat male voice with confident energy and a standard, clear tone.',694 'M2': 'James - A deep, robust male voice; calm, composed, and serious with a grounded presence.',695 'M3': 'Robert - A polished, authoritative male voice; confident and trustworthy with strong presentation quality.',696 'M4': 'Sam - A soft, neutral-toned male voice; gentle and approachable with a youthful, friendly quality.',697 'M5': 'Daniel - A warm, soft-spoken male voice; calm and soothing with a natural storytelling quality.'698 };699 700 // Global state701 let models = null;702 let cfgs = null;703 let processors = null;704 let currentVoice = 'M3'; // Default to Robert voice705 706 // Detect browser language and set initial language707 function detectBrowserLanguage() {708 // Get browser language (works in Chrome, Safari, Edge, Firefox, Opera, Samsung Internet)709 const browserLang = navigator.language || navigator.userLanguage || 'en';710 711 // Extract language code (e.g., 'en-US' -> 'en', 'ko-KR' -> 'ko')712 const langCode = browserLang.split('-')[0].toLowerCase();713 714 // Supported languages715 const supportedLangs = ['en', 'es', 'pt', 'fr', 'ko'];716 717 // Return detected language if supported, otherwise default to English718 return supportedLangs.includes(langCode) ? langCode : 'en';719 }720 721 let currentLanguage = detectBrowserLanguage(); // Auto-detect from browser722 let refEmbeddingCache = {}; // Cache for embeddings723 let currentStyleTtlTensor = null;724 let currentStyleDpTensor = null;725 let modelsLoading = false; // Track if models are currently loading726 let modelsLoaded = false; // Track if models are fully loaded727 let modelsLoadPromise = null; // Promise for model loading728 729 // UI Elements730 const demoStatusBox = document.getElementById('demoStatusBox');731 const demoStatusText = document.getElementById('demoStatusText');732 const wasmWarningBanner = document.getElementById('wasmWarningBanner');733 const demoGenerateBtn = document.getElementById('demoGenerateBtn');734 const demoTotalSteps = document.getElementById('demoTotalSteps');735 const demoSpeed = document.getElementById('demoSpeed');736 const demoTotalStepsValue = document.getElementById('demoTotalStepsValue');737 const demoSpeedValue = document.getElementById('demoSpeedValue');738 const demoResults = document.getElementById('demoResults');739 const demoError = document.getElementById('demoError');740 const demoCharCount = document.getElementById('demoCharCount');741 const demoCharCounter = document.getElementById('demoCharCounter');742 const demoCharWarning = document.getElementById('demoCharWarning');743 744 // Text validation constants745 const MIN_CHARS = 10;746 const MAX_CHUNK_LENGTH_DEFAULT = 300; // Maximum length for each chunk (default)747 const MAX_CHUNK_LENGTH_KO = 120; // Maximum length for Korean748 function getMaxChunkLength() {749 return currentLanguage === 'ko' ? MAX_CHUNK_LENGTH_KO : MAX_CHUNK_LENGTH_DEFAULT;750 }751 752 // Custom audio player state (shared across generations)753 let audioContext = null;754 let scheduledSources = [];755 let audioChunks = [];756 let totalDuration = 0;757 let startTime = 0;758 let pauseTime = 0;759 let isPaused = false;760 let isPlaying = false;761 let animationFrameId = null;762 let playPauseBtn = null;763 let progressBar = null;764 let currentTimeDisplay = null;765 let durationDisplay = null;766 let progressFill = null;767 let firstChunkGenerationTime = 0; // Processing time for first chunk768 let totalChunks = 0;769 let nextScheduledTime = 0; // Next time to schedule audio chunk770 let currentGenerationTextLength = 0;771 let supertonicPlayerRecord = null; // Supertonic player record for cross-player pause management772 let isGenerating = false; // Track if speech generation is in progress773 774 // Track all custom audio players775 let customAudioPlayers = [];776 777 const isMobileViewport = () => window.matchMedia('(max-width: 768px)').matches;778 // Check if device actually supports touch (not just viewport size)779 const isTouchDevice = () => 'ontouchstart' in window || navigator.maxTouchPoints > 0;780 const trimDecimalsForMobile = (formatted) => {781 if (!formatted) return formatted;782 return isMobileViewport() ? formatted.replace(/\.\d{2}$/, '') : formatted;783 };784 785 function pauseAllPlayersExcept(currentPlayer) {786 customAudioPlayers.forEach(player => {787 if (player !== currentPlayer && player && typeof player.pausePlayback === 'function') {788 player.pausePlayback();789 }790 });791 }792 793 794 /**795 * Chunk text into smaller pieces based on sentence boundaries796 * @param {string} text - The text to chunk797 * @param {number} maxLen - Maximum length for each chunk798 * @returns {Array<string>} - Array of text chunks799 */800 function chunkText(text, maxLen = getMaxChunkLength()) {801 // Split by paragraph (two or more newlines)802 const paragraphs = text.trim().split(/\n\s*\n+/).filter(p => p.trim());803 804 const chunks = [];805 806 for (let paragraph of paragraphs) {807 paragraph = paragraph.trim();808 if (!paragraph) continue;809 810 // Split by sentence boundaries (period, question mark, exclamation mark followed by space)811 // But exclude common abbreviations like Mr., Mrs., Dr., etc. and single capital letters like F.812 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+/);813 814 let currentChunk = "";815 816 for (let sentence of sentences) {817 if (currentChunk.length + sentence.length + 1 <= maxLen) {818 currentChunk += (currentChunk ? " " : "") + sentence;819 } else {820 if (currentChunk) {821 chunks.push(currentChunk.trim());822 }823 currentChunk = sentence;824 }825 }826 827 if (currentChunk) {828 chunks.push(currentChunk.trim());829 }830 }831 832 return chunks;833 }834 835 function showDemoStatus(message, type = 'info', progress = null) {836 demoStatusText.innerHTML = message;837 demoStatusBox.className = 'demo-status-box';838 demoStatusBox.style.removeProperty('--status-progress');839 demoStatusBox.style.display = ''; // Show the status box840 841 if (type === 'success') {842 demoStatusBox.classList.add('success');843 } else if (type === 'error') {844 demoStatusBox.classList.add('error');845 }846 847 // Update progress bar848 if (progress !== null && progress >= 0 && progress <= 100) {849 const clampedProgress = Math.max(0, Math.min(progress, 100));850 demoStatusBox.style.setProperty('--status-progress', `${clampedProgress}%`);851 demoStatusBox.classList.toggle('complete', clampedProgress >= 100);852 } else if (type === 'success' || type === 'error') {853 demoStatusBox.style.removeProperty('--status-progress');854 demoStatusBox.classList.remove('complete');855 } else {856 demoStatusBox.style.removeProperty('--status-progress');857 demoStatusBox.classList.remove('complete');858 }859 }860 861 function hideDemoStatus() {862 demoStatusBox.style.display = 'none';863 }864 865 function showDemoError(message) {866 demoError.textContent = message;867 demoError.classList.add('active');868 }869 870 function hideDemoError() {871 demoError.classList.remove('active');872 }873 874 // Language toast notification875 const languageToast = document.getElementById('languageToast');876 const languageToastMessage = document.getElementById('languageToastMessage');877 let languageToastTimeout = null;878 879 function showLanguageToast(fromLang, toLang) {880 if (!languageToast || !languageToastMessage) return;881 882 const fromName = LANGUAGE_NAMES[fromLang] || fromLang;883 const toName = LANGUAGE_NAMES[toLang] || toLang;884 885 languageToastMessage.innerHTML = `Language auto-detected: <strong>${toName}</strong>`;886 887 // Clear any existing timeout888 if (languageToastTimeout) {889 clearTimeout(languageToastTimeout);890 }891 892 // Show toast893 languageToast.classList.add('show');894 895 // Hide after 3 seconds896 languageToastTimeout = setTimeout(() => {897 languageToast.classList.remove('show');898 }, 3000);899 }900 901 function showWasmWarning() {902 if (wasmWarningBanner) {903 wasmWarningBanner.style.display = 'flex';904 }905 }906 907 // Validate characters in text908 function validateCharacters(text) {909 if (!processors || !processors.textProcessor) {910 return { valid: true, unsupportedChars: [] };911 }912 913 try {914 // Extract unique characters to minimize preprocessText calls915 const uniqueChars = [...new Set(text)];916 917 // Build mapping for unique chars only (much faster for long texts)918 // For example, Korean '간' -> 'ㄱㅏㄴ', so we map 'ㄱ','ㅏ','ㄴ' -> '간'919 const processedToOriginal = new Map();920 const charToProcessed = new Map();921 922 for (const char of uniqueChars) {923 const processedChar = preprocessText(char);924 charToProcessed.set(char, processedChar);925 926 // Map each processed character back to its original927 for (const pc of processedChar) {928 if (!processedToOriginal.has(pc)) {929 processedToOriginal.set(pc, new Set());930 }931 processedToOriginal.get(pc).add(char);932 }933 }934 935 // Build full processed text using cached mappings936 const fullProcessedText = Array.from(text).map(c => charToProcessed.get(c)).join('');937 938 // Check the entire processed text once (efficient)939 const { unsupportedChars } = processors.textProcessor.call([fullProcessedText]);940 941 // Map unsupported processed chars back to original chars942 const unsupportedOriginalChars = new Set();943 if (unsupportedChars && unsupportedChars.length > 0) {944 for (const unsupportedChar of unsupportedChars) {945 const originalChars = processedToOriginal.get(unsupportedChar);946 if (originalChars) {947 originalChars.forEach(c => unsupportedOriginalChars.add(c));948 }949 }950 }951 952 const unsupportedCharsArray = Array.from(unsupportedOriginalChars);953 return { 954 valid: unsupportedCharsArray.length === 0, 955 unsupportedChars: unsupportedCharsArray956 };957 } catch (error) {958 return { valid: true, unsupportedChars: [] };959 }960 }961 962 // Update character counter and validate text length963 function updateCharCounter() {964 const rawText = demoTextInput.textContent || demoTextInput.innerText || '';965 const text = rawText.replace(/\n$/g, ''); // Remove trailing newline that browsers may add966 const length = text.length;967 968 demoCharCount.textContent = length;969 970 // Get the actual width of the textarea971 const textareaWidth = demoTextInput.offsetWidth;972 // Max width reference: 1280px (container max-width) / 2 (grid column) - padding/gap ≈ 638px973 // Using 640px as reference for easier calculation974 const maxWidthRef = 640;975 976 // Calculate font size based on width ratio977 // Original rem values at max-width (640px):978 // 5rem = 80px @ 16px base → 80/640 = 12.5%979 // 4rem = 64px → 64/640 = 10%980 // 3rem = 48px → 48/640 = 7.5%981 // 2.5rem = 40px → 40/640 = 6.25%982 // 2rem = 32px → 32/640 = 5%983 // 1.5rem = 24px → 24/640 = 3.75%984 // 1rem = 16px → 16/640 = 2.5%985 986 // Check if mobile (572px or less) for 2x font size scaling987 const isMobile = window.innerWidth <= 572;988 const mobileMultiplier = isMobile ? 2 : 1;989 990 let fontSizeRatio;991 if (length <= 100) {992 fontSizeRatio = 0.055 * mobileMultiplier; // 5.5% of width993 } else if (length <= 200) {994 fontSizeRatio = 0.04 * mobileMultiplier; // 4% of width995 } else if (length < 240) {996 fontSizeRatio = 0.053125 * mobileMultiplier; // ~5.3125% of width (scaled from 2.5rem)997 } else if (length < 400) {998 fontSizeRatio = 0.0425 * mobileMultiplier; // ~4.25% of width (scaled from 2rem)999 } else if (length < 700) {1000 fontSizeRatio = 0.031875 * mobileMultiplier; // ~3.1875% of width (scaled from 1.5rem)1001 } else {1002 fontSizeRatio = 0.025 * mobileMultiplier; // 2.5% of width (minimum stays the same)1003 }1004 1005 // Calculate font size based on actual width1006 const fontSize = textareaWidth * fontSizeRatio;1007 demoTextInput.style.fontSize = `${fontSize}px`;1008 1009 // Remove all status classes1010 demoCharCounter.classList.remove('error', 'warning', 'valid');1011 1012 // Check for unsupported characters first (only if models are loaded)1013 let hasUnsupportedChars = false;1014 if (models && processors && length > 0) {1015 const validation = validateCharacters(text);1016 if (!validation.valid && validation.unsupportedChars.length > 0) {1017 hasUnsupportedChars = true;1018 const charList = validation.unsupportedChars.slice(0, 5).map(c => `"${c}"`).join(', ');1019 const moreChars = validation.unsupportedChars.length > 5 ? ` and ${validation.unsupportedChars.length - 5} more` : '';1020 showDemoError(`Unsupported characters detected: ${charList}${moreChars}. Please remove them before generating speech.`);1021 } else {1022 hideDemoError();1023 }1024 }1025 1026 // Update status based on length and character validation1027 if (length < MIN_CHARS) {1028 demoCharCounter.classList.add('error');1029 demoCharWarning.textContent = '(At least 10 characters)';1030 demoGenerateBtn.disabled = true;1031 } else if (hasUnsupportedChars) {1032 demoCharCounter.classList.add('error');1033 demoCharWarning.textContent = '(Unsupported characters)';1034 demoGenerateBtn.disabled = true;1035 } else {1036 demoCharCounter.classList.add('valid');1037 demoCharWarning.textContent = '';1038 // Enable only if models are loaded AND not currently generating1039 demoGenerateBtn.disabled = !models || isGenerating;1040 }1041 }1042 1043 // Validate text input1044 function validateTextInput(text) {1045 if (!text || text.trim().length === 0) {1046 return { valid: false, message: 'Please enter some text.' };1047 }1048 if (text.length < MIN_CHARS) {1049 return { valid: false, message: `Text must be at least ${MIN_CHARS} characters long. (Currently ${text.length})` };1050 }1051 return { valid: true };1052 }1053 1054 // Load pre-extracted style embeddings from JSON1055 async function loadStyleEmbeddings(voice) {1056 try {1057 // Check if already cached1058 if (refEmbeddingCache[voice]) {1059 return refEmbeddingCache[voice];1060 }1061 1062 const embeddingPath = REF_EMBEDDING_PATHS[voice];1063 if (!embeddingPath) {1064 throw new Error(`No embedding path configured for voice: ${voice}`);1065 }1066 1067 const response = await fetch(embeddingPath);1068 if (!response.ok) {1069 throw new Error(`Failed to fetch embedding: ${response.statusText}`);1070 }1071 1072 const embeddingData = await response.json();1073 1074 // Convert JSON data to ONNX tensors1075 // Flatten nested arrays before creating Float32Array1076 const styleTtlData = embeddingData.style_ttl.data.flat(Infinity);1077 const styleTtlTensor = new ort.Tensor(1078 embeddingData.style_ttl.type || 'float32',1079 Float32Array.from(styleTtlData),1080 embeddingData.style_ttl.dims1081 );1082 1083 const styleDpData = embeddingData.style_dp.data.flat(Infinity);1084 const styleDpTensor = new ort.Tensor(1085 embeddingData.style_dp.type || 'float32',1086 Float32Array.from(styleDpData),1087 embeddingData.style_dp.dims1088 );1089 1090 const embeddings = {1091 styleTtl: styleTtlTensor,1092 styleDp: styleDpTensor1093 };1094 1095 // Cache the embeddings1096 refEmbeddingCache[voice] = embeddings;1097 1098 return embeddings;1099 } catch (error) {1100 throw error;1101 }1102 }1103 1104 // Switch to a different voice1105 async function switchVoice(voice) {1106 try {1107 const embeddings = await loadStyleEmbeddings(voice);1108 1109 currentStyleTtlTensor = embeddings.styleTtl;1110 currentStyleDpTensor = embeddings.styleDp;1111 currentVoice = voice;1112 1113 // Update active speaker in UI1114 if (typeof window.updateActiveSpeaker === 'function') {1115 window.updateActiveSpeaker(voice);1116 }1117 1118 // Re-validate text after switching voice1119 updateCharCounter();1120 } catch (error) {1121 showDemoError(`Failed to load voice ${voice}: ${error.message}`);1122 throw error;1123 }1124 }1125 1126 // Check WebGPU support more thoroughly1127 async function checkWebGPUSupport() {1128 try {1129 // Detect iOS/Safari1130 const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || 1131 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);1132 const isSafari = /^((?!chrome|crios|android|edg|firefox).)*safari/i.test(navigator.userAgent);1133 1134 // iOS and Safari have incomplete WebGPU support1135 if (isIOS) {1136 return { supported: false, reason: 'iOS does not support the required WebGPU features' };1137 }1138 if (isSafari) {1139 // Desktop Safari might work, but check carefully1140 return { supported: false, reason: 'Safari does not support the required WebGPU features' };1141 }1142 1143 // Check if WebGPU is available in the browser1144 if (!navigator.gpu) {1145 return { supported: false, reason: 'WebGPU not available in this browser' };1146 }1147 1148 // Request adapter1149 const adapter = await navigator.gpu.requestAdapter();1150 if (!adapter) {1151 return { supported: false, reason: 'No WebGPU adapter found' };1152 }1153 1154 // Check adapter info1155 try {1156 const adapterInfo = await adapter.requestAdapterInfo();1157 } catch (infoError) {1158 // Ignore adapter info errors1159 }1160 1161 // Request device to test if it actually works1162 const device = await adapter.requestDevice();1163 if (!device) {1164 return { supported: false, reason: 'Failed to create WebGPU device' };1165 }1166 1167 return { supported: true, adapter, device };1168 } catch (error) {1169 // Handle specific iOS/Safari errors1170 const errorMsg = error.message || '';1171 if (errorMsg.includes('subgroupMinSize') || errorMsg.includes('subgroup')) {1172 return { supported: false, reason: 'iOS/Safari does not support required WebGPU features (subgroup operations)' };1173 }1174 return { supported: false, reason: error.message };1175 }1176 }1177 1178 // Warmup models with dummy inference (no audio playback, no UI updates)1179 async function warmupModels() {1180 try {1181 const dummyText = 'Looking to integrate Supertonic into your product? We offer customized on-device SDK solutions tailored to your business needs. Our lightweight, high-performance TTS technology can be seamlessly integrated into mobile apps, IoT devices, automotive systems, and more. Try it now, and enjoy its speed.';1182 const totalStep = 5; // Use minimal steps for faster warmup1183 const durationFactor = 1.0;1184 1185 const textList = [dummyText];1186 const bsz = 1;1187 1188 // Use pre-computed style embeddings1189 const styleTtlTensor = currentStyleTtlTensor;1190 const styleDpTensor = currentStyleDpTensor;1191 1192 // Step 1: Estimate duration1193 const { textIds, textMask } = processors.textProcessor.call(textList, currentLanguage);1194 1195 const textIdsShape = [bsz, textIds[0].length];1196 const textMaskShape = [bsz, 1, textMask[0][0].length];1197 const textMaskTensor = arrayToTensor(textMask, textMaskShape);1198 1199 const dpResult = await models.dpOrt.run({1200 text_ids: intArrayToTensor(textIds, textIdsShape),