// ── Clone: recommended sample sentences ────────────────────────────────── const CLONE_SAMPLE_TEXTS = { EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.', DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.', IT: "La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d'argento, una mela rossa e il ritmo leggero della pioggia alla finestra.", ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.', FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.', PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.', NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.', PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.', }; (function initCloneSampleText() { const sel = $('clone-sample-lang'); const txt = $('clone-sample-text'); if (!sel || !txt) return; txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; sel.addEventListener('change', () => { txt.value = CLONE_SAMPLE_TEXTS[sel.value] || CLONE_SAMPLE_TEXTS.EN; }); })(); // ── WaveSurfer ──────────────────────────────────────────────────────────── let ws = null, wsRegions = null, currentFileId = null, trimmedFileId = null, designedFileId = null, editingVoiceId = null, editingVoicePath = null; function initWaveSurfer() { if (ws) { ws.destroy(); ws = null; wsRegions = null; } wsRegions = WaveSurfer.Regions.create(); ws = WaveSurfer.create({ container:'#waveform', waveColor:'#45475a', progressColor:'#89b4fa', cursorColor:'#cba6f7', height:90, normalize:true, plugins:[wsRegions] }); ws.on('ready', () => { const dur = ws.getDuration(); $('trim-end').value = dur.toFixed(2); $('trim-end').max = dur.toFixed(2); $('trim-start').max = dur.toFixed(2); updateRegion(); }); wsRegions.on('region-updated', r => { $('trim-start').value = r.start.toFixed(2); $('trim-end').value = r.end.toFixed(2); updateDurationLabel(); }); } function updateRegion() { wsRegions.clearRegions(); const s = parseFloat($('trim-start').value)||0, e = parseFloat($('trim-end').value)||(ws?ws.getDuration():0); wsRegions.addRegion({ start:s, end:e, color:'rgba(137,180,250,0.25)', drag:true, resize:true }); updateDurationLabel(); } function updateDurationLabel() { const d = Math.max(0, (parseFloat($('trim-end').value)||0) - (parseFloat($('trim-start').value)||0)); const el = $('trim-duration'); el.textContent = d.toFixed(1)+' s'; el.className = d>=5&&d<=20 ? 'dur-ok' : d>20 ? 'dur-warn' : 'dur-bad'; } ['trim-start','trim-end'].forEach(id => $(id).addEventListener('input', () => { if(ws) updateRegion(); })); $('play-btn').addEventListener('click', () => { if(ws) ws.playPause(); }); $('play-selection-btn').addEventListener('click', () => { if (!ws) return; ws.play(parseFloat($('trim-start').value)||0, parseFloat($('trim-end').value)||ws.getDuration()); }); $('auto-trim-btn').addEventListener('click', async () => { if (!currentFileId) { toast('No audio loaded','error'); return; } $('auto-trim-btn').disabled = true; status('Finding best TTS reference segment…'); try { const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:currentFileId})}); let d; if (r.ok) { d = await r.json(); } else if (r.status === 404 || r.status === 405) { status('Backend auto trim unavailable; analysing audio in browser…'); d = await clientAutoTrimBounds(currentFileId); } else { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText || 'Auto trim failed'); } $('trim-start').value = Number(d.start).toFixed(2); $('trim-end').value = Number(d.end).toFixed(2); if (ws) updateRegion(); toast('Auto trim set: '+Number(d.duration).toFixed(1)+' s','success'); status(d.reason || 'Auto trim ready'); } catch(e) { toast('Auto trim failed: '+e.message,'error'); status('Auto trim failed'); } finally { $('auto-trim-btn').disabled = false; } }); function loadAudioId(id, dur, opts = {}) { currentFileId = id; trimmedFileId = null; designedFileId = null; editingVoiceId = opts.editingVoiceId || null; editingVoicePath = opts.editingVoicePath || null; $('trim-start').value='0'; $('trim-end').value=dur.toFixed(2); $('waveform-card').style.display=''; initWaveSurfer(); ws.load('/api/audio/'+id); $('save-result').style.display='none'; $('trim-audio').style.display='none'; $('no-audio-hint').style.display=''; if (editingVoiceId) { $('voice-id-input').value = editingVoiceId; $('voice-id-input').dispatchEvent(new Event('input')); $('transcript-area').value = opts.transcript || ''; status('Editing existing voice: ' + editingVoiceId); } } // ── Drop zone ───────────────────────────────────────────────────────────── const dropZone = $('drop-zone'), fileInput = $('file-input'); dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', () => { if(fileInput.files.length) uploadFile(fileInput.files[0]); }); async function uploadFile(file) { status('Uploading '+file.name+'…'); const fd = new FormData(); fd.append('file', file); try { const r = await fetch('/api/upload', { method:'POST', body:fd }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail||r.statusText); } const d = await r.json(); loadAudioId(d.id, d.duration); status('Loaded: '+file.name+' ('+d.duration.toFixed(1)+' s)'); toast('File loaded', 'success'); } catch(e) { toast('Upload failed: '+e.message, 'error'); status('Upload failed'); } } async function loadLibraryVoiceAudio(v) { const audioResp = await fetch(voiceFileUrl(v), {cache:'no-store'}); if (!audioResp.ok) { const e = await audioResp.json().catch(() => ({})); throw new Error(e.detail || audioResp.statusText); } const blob = await audioResp.blob(); const ext = (v.file_type || 'wav').toLowerCase(); const fd = new FormData(); fd.append('file', new File([blob], `${v.id}.${ext}`, {type:blob.type || 'audio/wav'})); const upload = await fetch('/api/upload', { method:'POST', body:fd }); if (!upload.ok) { const e = await upload.json().catch(() => ({})); throw new Error(e.detail || upload.statusText); } const d = await upload.json(); return { id:d.id, voice_id:v.id, duration:d.duration, transcript:v.transcript || '', file_type:ext, path:v.path }; } // ── YouTube ─────────────────────────────────────────────────────────────── $('yt-btn').addEventListener('click', () => { const url = $('yt-url').value.trim(); if(!url) return; $('yt-btn').disabled=true; $('yt-progress').textContent='Starting download…'; const es = new EventSource('/api/download-yt?url='+encodeURIComponent(url)); es.onmessage = e => { const d = JSON.parse(e.data); if (d.error) { toast('Download failed: '+d.error,'error'); $('yt-progress').textContent=d.error; $('yt-btn').disabled=false; es.close(); } else if (d.done) { es.close(); $('yt-btn').disabled=false; $('yt-progress').textContent='Done!'; loadAudioId(d.id,d.duration); toast('YouTube audio loaded','success'); } else { $('yt-progress').textContent=d.msg||''; if(d.pct) status('Downloading… '+d.pct+'%'); } }; es.onerror = () => { es.close(); $('yt-btn').disabled=false; }; }); // ── Microphone ──────────────────────────────────────────────────────────── const RAW_MIC_CONSTRAINTS = { echoCancellation:false, noiseSuppression:false, autoGainControl:false }; async function visibleMicrophoneCount() { if (!navigator.mediaDevices?.enumerateDevices) return null; try { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(device => device.kind === 'audioinput').length; } catch(e) { return null; } } async function microphoneErrorMessage(error) { const name = error?.name || ''; const message = error?.message || ''; const lowerMessage = message.toLowerCase(); const micCount = await visibleMicrophoneCount(); if (name === 'NotFoundError' || lowerMessage.includes('requested device not found')) { return micCount === 0 ? 'No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.' : 'The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.'; } if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { return 'Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.'; } if (name === 'NotReadableError') { return 'The microphone is busy or unavailable. Close other apps using it, then try again.'; } if (name === 'SecurityError') { return 'Microphone access requires localhost or HTTPS.'; } return message || 'Microphone failed.'; } async function requestMicrophoneStream(options = {}) { if (!navigator.mediaDevices?.getUserMedia) { throw new Error('Microphone requires HTTPS. Open the app via https://... or access it on localhost.'); } if (!options.raw) return navigator.mediaDevices.getUserMedia({audio:true}); try { return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS}); } catch(e) { if (e?.name === 'OverconstrainedError' || e?.name === 'NotFoundError') { return navigator.mediaDevices.getUserMedia({audio:true}); } throw e; } } let mediaRec=null, recChunks=[], recTimer=null, recSecs=0; $('rec-start-btn').addEventListener('click', async () => { try { const stream = await requestMicrophoneStream(); recChunks=[]; recSecs=0; $('rec-time').textContent='0:00'; $('rec-indicator').classList.add('active'); $('rec-start-btn').disabled=true; $('rec-stop-btn').disabled=false; recTimer = setInterval(() => { recSecs++; $('rec-time').textContent=Math.floor(recSecs/60)+':'+String(recSecs%60).padStart(2,'0'); }, 1000); mediaRec = new MediaRecorder(stream); mediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data); }; mediaRec.onstop = async () => { clearInterval(recTimer); $('rec-indicator').classList.remove('active'); stream.getTracks().forEach(t=>t.stop()); const blob = new Blob(recChunks, {type:mediaRec.mimeType||'audio/webm'}); const ext = (mediaRec.mimeType||'').includes('ogg') ? '.ogg' : '.webm'; await uploadFile(new File([blob], 'recording'+ext, {type:blob.type})); }; mediaRec.start(100); status('Recording…'); } catch(e) { toast(await microphoneErrorMessage(e), 'error'); } }); $('rec-stop-btn').addEventListener('click', () => { if(mediaRec&&mediaRec.state!=='inactive') mediaRec.stop(); $('rec-start-btn').disabled=false; $('rec-stop-btn').disabled=true; }); // ── Trim ────────────────────────────────────────────────────────────────── $('trim-btn').addEventListener('click', async () => { if (!currentFileId) { toast('No audio loaded','error'); return; } try { const r = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:currentFileId, start:parseFloat($('trim-start').value)||0, end:parseFloat($('trim-end').value)||0}) }); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); trimmedFileId=d.id; designedFileId=null; $('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none'; switchTab('save'); toast('Trim done','success'); } catch(e) { toast('Trim failed: '+e.message,'error'); } }); // ── Voice design naming helpers ──────────────────────────────────────────── const DESIGN_LANG_CODE = { Auto:'EN', English:'EN', Chinese:'ZH', Japanese:'JA', Korean:'KO', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Portuguese:'PT', Russian:'RU', }; const DESIGN_GENDER_WORD = { F:'female', M:'male', N:'neutral' }; const DESIGN_PRESET_KEY = 'vcf-design-presets'; const DESIGN_PRESET_SEEDED_KEY = 'vcf-design-presets-seeded-v2'; const DEFAULT_DESIGN_PRESETS = { 'EN_M_Young_Energetic': { description: 'Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.', sample_text: 'Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.', language: 'English', gender: 'M', }, 'EN_F_Warm_Narrator': { description: 'Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.', sample_text: 'The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.', language: 'English', gender: 'F', }, 'DE_M_Elderly_Documentary': { description: 'Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.', sample_text: 'Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.', language: 'German', gender: 'M', }, 'DE_F_Young_Friendly': { description: 'Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.', sample_text: 'Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.', language: 'German', gender: 'F', }, 'EN_N_Old_Wise_Assistant': { description: 'Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.', sample_text: 'Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.', language: 'English', gender: 'N', }, }; const QWEN_DESIGN_SAMPLES = { 'qwen-timbre-reuse': { title: 'Qwen Timbre Reuse', summary: 'Reference clip for designing a reusable teen character timbre.', description: 'Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous', text: "H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?", language: 'English', gender: 'M', }, 'acoustic-sausage-announcer': { title: 'Acoustic Attribute Control - British announcer', summary: 'Fast, loud, articulate British male delivery with excitement and performative authority.', description: `gender: Male. pitch: Low male pitch with significant upward inflections for emphasis and excitement. speed: Fast-paced delivery with deliberate pauses for dramatic effect. volume: Loud and projecting, increasing notably during moments of praise and announcements. age: Young adult to middle-aged adult. clarity: Highly articulate and distinct pronunciation. fluency: Very fluent speech with no hesitations. accent: British English. texture: Bright and clear vocal texture. emotion: Enthusiastic and excited, especially when complimenting. tone: Upbeat, authoritative, and performative. personality: Confident, extroverted, and engaging.`, text: 'Nine different, exciting ways of cooking sausage. Incredible. There were three outstanding deliveries in terms of the sausage being the hero. The first dish that we want to dissect, this individual smartly combined different proteins in their sausage. Great seasoning. The blend was absolutely spot on. Congratulations. Please step forward. Natasha.', language: 'English', gender: 'M', }, 'acoustic-character-laugh': { title: 'Acoustic Attribute Control - theatrical character', summary: 'Artificially high male character voice shifting from loud forced amusement to deliberate resignation.', description: `gender: Male. pitch: Artificially high-pitched, slightly lowering after the initial laugh. speed: Rapid during the laugh, then slowing to a deliberate pace. volume: Loud laugh transitioning to a standard conversational level. age: Young adult to middle-aged, performing a character voice. clarity: Clear and distinct articulation. fluency: Fluent delivery without hesitation. accent: American English. texture: Slightly strained and somewhat nasal quality. emotion: Forced amusement shifting to feigned resignation. tone: Initially playful, then shifts to a slightly put-upon tone. personality: Theatrical and expressive.`, text: "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.", language: 'English', gender: 'M', }, 'age-control-surly-elvis': { title: 'Age Control - middle-aged gravel', summary: 'Low, resonant, slightly gravelly American male voice with a commanding opening.', description: `gender: Male. pitch: Low male pitch, generally stable. speed: Deliberate pace, slowing slightly after the initial exclamation. volume: Starts loud, then transitions to a projected conversational volume. age: Middle-aged adult. clarity: High clarity with distinct pronunciation. fluency: Highly fluent. accent: American English. texture: Resonant and slightly gravelly. emotion: Initially commanding, shifting to narrative amusement. tone: Authoritative start, moving to an engaging, descriptive tone. personality: Confident and performative.`, text: 'Older gentleman, 110, maybe 111 years old, sort of a surly Elvis thing happening with him. He smiles like this. Seen him around?', language: 'English', gender: 'M', }, 'gradual-control-anger': { title: 'Gradual Control - emotional escalation', summary: 'Female voice that begins neutral and quickly escalates into sharp anger and accusation.', description: `gender: Female. pitch: Mid-range female pitch, rising sharply with frustration. speed: Starts measured, then accelerates rapidly during emotional outburst. volume: Begins conversational, escalates quickly to loud and forceful. age: Young adult to middle-aged. clarity: High clarity and distinct articulation throughout. fluency: Highly fluent with no significant pauses or fillers. accent: General American English. texture: Bright and clear vocal quality. emotion: Shifts abruptly from neutral acceptance to intense resentment and anger. tone: Initially accepting, becomes sharply accusatory and confrontational. personality: Assertive and emotionally expressive when provoked.`, text: 'Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you-', language: 'English', gender: 'F', }, 'human-likeness-digital-nomad': { title: 'Human-likeness - casual self-aware monologue', summary: 'Warm male conversational voice with natural laughter, hesitations, and self-deprecating humor.', description: 'A relaxed, naturally expressive male voice in his late twenties to early thirties, with a moderately low pitch, casual speaking rate, and conversational volume; deliver lines with a light, self-deprecating tone, breaking into genuine, easygoing laughter at moments of embarrassment, while maintaining clear articulation and an overall warm, approachable clarity.', text: `Yeah, so--uh--I'm a digital nomad, right? So... pretty much all my communication is just, like, texts and messages. And now, you know, there's these AI agents that can, uh... reply for you? Which is--heh--convenient, sure, I guess? But also... kinda delicate, you know? Like, you'll type something super short--like, "Yep, sounds good"--and it'll turn that into this whole... warm, polished paragraph. Like, way nicer than I'd ever write myself. huh... ha Seriously, I sound like a Hallmark card all of a sudden. But then... once you outsource that... what's the other person actually hearing? Are they hearing me... or just some... generic, friendly-bot voice? Man, that's weird to even say out loud.`, language: 'English', gender: 'M', }, 'background-marcus-cole': { title: 'Background Information - Marcus Cole', summary: 'Broadcast booth announcer profile with bright, agile, urgent delivery.', description: `Character Name: Marcus Cole Voice Profile: A bright, agile male voice with a natural upward lift, delivering lines at a brisk, energetic pace. Pitch leans high with spark, volume projects clearly--near-shouting at peaks--to convey urgency and excitement. Speech flows seamlessly, fluently, each word sharply defined, riding a current of dynamic rhythm. Background: Longtime broadcast booth announcer for national television, specializing in live interstitials and public engagement spots. His voice bridges segments, rallies action, and keeps momentum alive--from voter drives to entertainment news. Presence: Late 50s, neatly groomed, dressed in a crisp shirt under studio lights. Moves with practiced ease, eyes locked on the script, energy coiled and ready. Personality: Energetic, precise, inherently engaging. He doesn't just read--he propels. Behind the speed is intent: to inform fast, to move people to act. Whether it's "text VOTE to 5703" or a star-studded tease, he makes it feel immediate, vital.`, text: "Lot being you watching. 1-866-IDLE-03 for JPL. That's 1-866-436-5703. Or text the word VOTE to 5703. Diana DeGarmo's next with more from the movies right after this brief intermission on American Idol.", language: 'English', gender: 'M', }, 'timbre-reuse-lucas-mia': { title: 'Timbre Reuse - Lucas and Mia', summary: 'Two-character teen dialogue using native VoiceDesign speaker-profile switching.', description: `"Lucas": "Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous" "Mia": "Female, 16 years old, mezzo-soprano range, softening - lowering register to intimate speaking voice, consonants softening"`, text: `Lucas:H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe? Mia:Oh wow, my mortal enemy - Mr. Thompson's problem sets. Thanks for rescuing me from that F. Lucas:No problem! I actually... kinda finished those already? If you want to compare answers or something... Mia:Is this your sneaky way of saying you want to study together, Lucas? Because I saw you staring during lab partners sign-up. Lucas:What? No! I mean yes but not like... I just think you're... your titration technique is really precise! Mia:That's the nerdiest compliment I've ever gotten. Tell you what - help me survive pre-calc and I'll teach you how to actually flirt. Lucas:Wow, harsh. And here I thought my titration line was smooth. Mia:It was adorable. Like when you tripped over your shoelaces in the hall yesterday. Or that time you- Lucas:Okay okay! I get it, I'm a disaster. So... library after school? I'll bring the graphing calculators? Mia:Only if you promise not to spill coffee on my notes again... though I guess watching you panic-clean was pretty cute.`, language: 'English', gender: 'N', dialogue: true, }, }; let currentDesignSource = null; function loadDesignPresets() { try { return JSON.parse(localStorage.getItem(DESIGN_PRESET_KEY) || '{}'); } catch { return {}; } } function saveDesignPresets(presets) { localStorage.setItem(DESIGN_PRESET_KEY, JSON.stringify(presets)); } async function syncDesignPresetsToServer() { try { await fetch('/api/voice-design-presets', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(loadDesignPresets()), }); renderIntegrationSnippets(); } catch(e) { status('Voice Design preset sync failed: ' + e.message); } } function seedDesignPresets() { const presets = loadDesignPresets(); let changed = false; Object.entries(DEFAULT_DESIGN_PRESETS).forEach(([name, preset]) => { if (!presets[name]) { presets[name] = preset; changed = true; return; } ['description', 'sample_text', 'language', 'gender'].forEach(key => { if (!presets[name][key] && preset[key]) { presets[name][key] = preset[key]; changed = true; } }); }); if (changed || !localStorage.getItem(DESIGN_PRESET_SEEDED_KEY)) saveDesignPresets(presets); localStorage.setItem(DESIGN_PRESET_SEEDED_KEY, '1'); if (changed) syncDesignPresetsToServer(); } function refreshDesignPresetSelect() { const presets = loadDesignPresets(); const sel = $('design-preset-select'); const prev = sel.value; sel.innerHTML = ''; Object.keys(presets).sort((a,b)=>a.localeCompare(b)).forEach(name => { const opt = document.createElement('option'); opt.value = opt.textContent = name; sel.appendChild(opt); }); if (presets[prev]) sel.value = prev; renderDesignPresetLibrary(); } function applyDesignPreset(name) { const preset = loadDesignPresets()[name]; if (!preset) { toast('Preset not found', 'error'); return; } $('design-instruct').value = preset.description || ''; $('design-sample-text').value = preset.sample_text || preset.text || $('design-sample-text').value || ''; $('design-language').value = preset.language || 'Auto'; $('design-gender').value = preset.gender || 'N'; $('design-preset-name').value = name; $('design-preset-select').value = name; currentDesignSource = { name, gender:preset.gender || 'N', language:preset.language || 'Auto', text:$('design-sample-text').value || '', description:preset.description || '' }; toast('Preset loaded: ' + name, 'success'); } function renderDesignPresetLibrary() { const lib = $('design-preset-library'); if (!lib) return; const presets = loadDesignPresets(); const names = Object.keys(presets).sort((a,b)=>a.localeCompare(b)); if (!names.length) { lib.innerHTML = '