tts-voice-creator-clone-and.../static/js/voice-clone.js
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

1042 lines
54 KiB
JavaScript

// ── Clone: recommended sample sentences ──────────────────────────────────
const CLONE_SAMPLE_TEXTS = {
EN: 'Hello! My name is Sam, and this is my voice. I can speak softly or with great strength. The crisp winter air, warm firelight, and the gentle sound of rain — these are the things I love. Can you hear how clearly I speak?',
DE: 'Hallo! Ich heiße Alex und das ist meine Stimme. Ich kann leise flüstern oder mit voller Kraft sprechen. Klare Winterluft, warmes Kerzenlicht und der Klang des Regens am Fenster — das liebe ich. Hörst du, wie deutlich ich spreche?',
IT: "Ciao! Mi chiamo Marco e questa è la mia voce. Posso parlare dolcemente o con grande forza. L'aria fresca d'inverno, la luce calda del fuoco e il suono della pioggia — queste sono le cose che amo. Senti come parlo chiaramente?",
ES: '¡Hola! Me llamo Carlos y esta es mi voz. Puedo hablar suavemente o con gran fuerza. El aire frío del invierno, la cálida luz del fuego y el suave sonido de la lluvia — estas son las cosas que amo. ¿Puedes oír lo claramente que hablo?',
FR: "Bonjour ! Je m'appelle Sophie et voici ma voix. Je peux parler doucement ou avec grande force. L'air vif de l'hiver, la douce lumière du feu et le son de la pluie — voilà ce que j'aime. Entends-tu comme je parle clairement ?",
PT: 'Olá! Meu nome é Ana e esta é a minha voz. Posso falar suavemente ou com grande força. O ar fresco do inverno, a luz quente do fogo e o som da chuva — estas são as coisas que amo. Consegues ouvir como falo claramente?',
NL: 'Hoi! Mijn naam is Laura en dit is mijn stem. Ik kan zacht fluisteren of met volle kracht spreken. De frisse winterlucht, het warme kaarslicht en het geluid van de regen — dat zijn de dingen die ik liefheb. Hoor je hoe helder ik spreek?',
PL: 'Cześć! Mam na imię Anna i to jest mój głos. Mogę mówić cicho lub z całą mocą. Mroźne zimowe powietrze, ciepłe światło ognia i dźwięk deszczu za oknem — to są rzeczy, które kocham. Czy słyszysz, jak wyraźnie mówię?',
};
// Placeholder name baked into each template — replaced with the user's voice name.
const CLONE_SAMPLE_NAMES = { EN: 'Sam', DE: 'Alex', IT: 'Marco', ES: 'Carlos', FR: 'Sophie', PT: 'Ana', NL: 'Laura', PL: 'Anna' };
// Build the sample sentence for a language, injecting the current voice name (if any)
// in place of that language's default name. Tracks the injected name on the textarea
// so a later language switch keeps the user's name instead of reverting to "Alex".
function cloneSampleForLang(lang) {
const txt = $('clone-sample-text');
const def = CLONE_SAMPLE_NAMES[lang] || '';
let t = CLONE_SAMPLE_TEXTS[lang] || CLONE_SAMPLE_TEXTS.EN;
const name = ($('clone-your-name')?.value || '').trim();
if (name && def) t = t.replace(new RegExp('\\b' + def.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b'), name);
if (txt) { txt.value = t; txt.dataset.sampleName = name || def; }
}
window.initCloneSampleText = function () {
const sel = $('clone-sample-lang');
const txt = $('clone-sample-text');
if (!sel || !txt) return;
cloneSampleForLang(sel.value);
if (!sel._cloneSampleBound) {
sel.addEventListener('change', () => cloneSampleForLang(sel.value));
sel._cloneSampleBound = true;
}
};
initCloneSampleText();
// ── 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;
}
}
// ── Clone mic monitor ─────────────────────────────────────────────────────
let _cloneMonState = {
stream:null, recordStream:null, audioCtx:null, sourceNode:null,
gainNode:null, analyser:null, meterRaf:null, waveRing:null, monitoring:false
};
function _cloneRenderMeter(level = 0, db = -Infinity, clipped = false) {
const meter = $('clone-mic-meter');
if (!meter) return;
if (!meter.children.length) {
for (let i = 0; i < 18; i++) { const b = document.createElement('div'); b.className = 'bar'; meter.appendChild(b); }
}
const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length);
[...meter.children].forEach((bar, i) => {
bar.className = 'bar';
bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px';
if (i < active) { bar.classList.add('on'); if (db > -12 && i > 11) bar.classList.add('hot'); if (clipped && i > 14) bar.classList.add('clip'); }
});
const el = $('clone-db-readout');
if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
}
function _cloneStartMeter() {
if (!_cloneMonState.analyser) return;
if (_cloneMonState.meterRaf) cancelAnimationFrame(_cloneMonState.meterRaf);
const data = new Float32Array(_cloneMonState.analyser.fftSize);
const canvas = $('clone-live-wave');
const RING = 300, ADD = 10;
_cloneMonState.waveRing = new Float32Array(RING);
const tick = () => {
_cloneMonState.analyser.getFloatTimeDomainData(data);
let sum = 0, peak = 0;
for (const s of data) { sum += s * s; peak = Math.max(peak, Math.abs(s)); }
const rms = Math.sqrt(sum / data.length);
const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
const level = Number.isFinite(db) ? (db + 60) / 60 : 0;
_cloneRenderMeter(level, db, peak > 0.98);
if (canvas && _cloneMonState.waveRing) {
const ring = _cloneMonState.waveRing;
ring.copyWithin(0, ADD);
for (let i = 0; i < ADD; i++) ring[RING - ADD + i] = data[Math.floor(i * data.length / ADD)];
const ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.beginPath();
ctx.strokeStyle = peak > 0.98 ? '#f38ba8' : db > -12 ? '#f9e2af' : '#a6e3a1';
ctx.lineWidth = 1.5;
const mid = h / 2;
for (let i = 0; i < RING; i++) {
const x = (i / RING) * w, y = mid - ring[i] * mid * 0.85;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
_cloneMonState.meterRaf = requestAnimationFrame(tick);
};
tick();
}
async function _cloneStartMonitor() {
if (_cloneMonState.recordStream) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
_cloneMonState.stream = await requestMicrophoneStream({raw:true});
if (AudioCtx) {
_cloneMonState.audioCtx = new AudioCtx();
_cloneMonState.sourceNode = _cloneMonState.audioCtx.createMediaStreamSource(_cloneMonState.stream);
_cloneMonState.gainNode = _cloneMonState.audioCtx.createGain();
_cloneMonState.analyser = _cloneMonState.audioCtx.createAnalyser();
_cloneMonState.analyser.fftSize = 1024;
const dest = _cloneMonState.audioCtx.createMediaStreamDestination();
const gainVal = parseFloat($('clone-mic-gain')?.value) || 1;
_cloneMonState.gainNode.gain.value = gainVal;
_cloneMonState.sourceNode.connect(_cloneMonState.gainNode);
_cloneMonState.gainNode.connect(_cloneMonState.analyser);
_cloneMonState.gainNode.connect(dest);
_cloneMonState.recordStream = dest.stream;
_cloneStartMeter();
} else {
_cloneMonState.recordStream = _cloneMonState.stream;
}
_cloneMonState.monitoring = true;
if ($('clone-monitor-btn')) $('clone-monitor-btn').disabled = true;
if ($('clone-monitor-stop')) $('clone-monitor-stop').disabled = false;
}
function _cloneStopMonitor() {
if (_cloneMonState.meterRaf) cancelAnimationFrame(_cloneMonState.meterRaf);
_cloneMonState.meterRaf = null;
[_cloneMonState.sourceNode, _cloneMonState.gainNode, _cloneMonState.analyser].forEach(n => { try { if(n) n.disconnect(); } catch(e){} });
if (_cloneMonState.stream) _cloneMonState.stream.getTracks().forEach(t => t.stop());
if (_cloneMonState.recordStream) _cloneMonState.recordStream.getTracks().forEach(t => t.stop());
if (_cloneMonState.audioCtx) _cloneMonState.audioCtx.close().catch(()=>{});
Object.assign(_cloneMonState, {stream:null, recordStream:null, sourceNode:null, gainNode:null, analyser:null, audioCtx:null, monitoring:false, waveRing:null});
_cloneRenderMeter(0, -Infinity, false);
const wc = $('clone-live-wave'); if (wc) wc.getContext('2d').clearRect(0, 0, wc.width, wc.height);
if ($('clone-monitor-btn')) $('clone-monitor-btn').disabled = false;
if ($('clone-monitor-stop')) $('clone-monitor-stop').disabled = true;
}
$('clone-monitor-btn')?.addEventListener('click', async () => {
try { await _cloneStartMonitor(); status('Mic level monitor active'); }
catch(e) { const m = await microphoneErrorMessage(e); toast(m, 'error'); }
});
$('clone-monitor-stop')?.addEventListener('click', () => { _cloneStopMonitor(); status('Mic level monitor stopped'); });
$('clone-mic-gain')?.addEventListener('input', () => {
const v = parseFloat($('clone-mic-gain').value) || 0;
if ($('clone-mic-gain-value')) $('clone-mic-gain-value').textContent = v.toFixed(2) + 'x';
if (_cloneMonState.gainNode) _cloneMonState.gainNode.gain.value = v;
});
_cloneRenderMeter();
let mediaRec=null, recChunks=[], recTimer=null, recSecs=0;
$('rec-start-btn').addEventListener('click', async () => {
try {
await _cloneStartMonitor();
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(_cloneMonState.recordStream || _cloneMonState.stream, {audioBitsPerSecond: 256000});
mediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data); };
mediaRec.onstop = async () => {
clearInterval(recTimer); $('rec-indicator').classList.remove('active');
const blob = new Blob(recChunks, {type:mediaRec.mimeType||'audio/webm'});
const ext = (mediaRec.mimeType||'').includes('ogg') ? '.ogg' : '.webm';
_cloneStopMonitor();
await uploadFile(new File([blob], 'recording'+ext, {type:blob.type}));
};
mediaRec.start(100); status('Recording…');
} catch(e) { _cloneStopMonitor(); 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');
// Bring the transcript section into view so the auto-transcribe is visibly happening
$('transcript-area')?.closest('.card')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window._cloneAutoTranscribe?.(); // streamlined flow: transcribe as soon as the clip is trimmed
} 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 = '<option value="">— preset —</option>';
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 = '<div class="note">No saved prompt presets yet.</div>';
return;
}
lib.innerHTML = '';
names.forEach(name => {
const p = presets[name];
const row = document.createElement('div');
row.className = 'design-preset-row';
row.innerHTML = `
<strong>${escHtml(name)}</strong>
<span>${escHtml(p.gender || 'N')}</span>
<span>${escHtml(p.language || 'Auto')}</span>
<span class="preset-desc" title="${escHtml(p.description || '')}">${escHtml(p.description || '')}</span>
<span class="preset-transcript" title="${escHtml(p.sample_text || p.text || '')}">${escHtml(p.sample_text || p.text || '')}</span>
<span class="preset-actions">
<button class="btn-secondary preset-use">Use</button>
<button class="btn-primary preset-preview">Preview</button>
<button class="btn-secondary preset-delete">Delete</button>
</span>
`;
row.querySelector('.preset-use').addEventListener('click', () => applyDesignPreset(name));
row.querySelector('.preset-delete').addEventListener('click', () => {
const all = loadDesignPresets();
delete all[name];
saveDesignPresets(all);
syncDesignPresetsToServer();
refreshDesignPresetSelect();
toast('Preset deleted: ' + name, 'success');
});
row.querySelector('.preset-preview').addEventListener('click', async e => {
e.currentTarget.disabled = true;
try {
const sampleText = p.sample_text || p.text || $('design-sample-text').value;
const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(voiceDesignPayload(p.description || '', sampleText, p.language || 'Auto', p))});
if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); }
const d = await r.json();
$('design-audio').src = '/api/audio/' + d.id;
$('design-result').style.display = 'flex';
$('design-audio').play().catch(()=>{});
} catch(err) { toast('Preset preview failed: ' + err.message, 'error'); }
finally { e.currentTarget.disabled = false; }
});
lib.appendChild(row);
});
}
function renderQwenSampleCards() {
const list = $('qwen-sample-list');
if (!list) return;
list.innerHTML = '';
Object.entries(QWEN_DESIGN_SAMPLES).forEach(([key, sample]) => {
const card = document.createElement('div');
card.className = 'qwen-sample design-sample-grid';
card.dataset.qwenSample = key;
card.innerHTML = `
<strong title="${escHtml(sample.summary || sample.title || key)}">${escHtml(sample.title || key)}</strong>
<span class="sample-sex">${escHtml(sample.gender || 'N')}</span>
<span class="sample-language">${escHtml(sample.language || 'Auto')}</span>
<span class="sample-desc" title="${escHtml(sample.description || '')}">${escHtml(sample.description || '')}</span>
<span class="sample-text" title="${escHtml(sample.text || '')}">${escHtml(sample.text || '')}</span>
<div class="qwen-sample-actions">
<button class="btn-primary qwen-preview">Preview</button>
<button class="btn-secondary qwen-use">Use</button>
<span class="note qwen-state"></span>
</div>
<audio controls></audio>
`;
list.appendChild(card);
});
}
function applyQwenSample(sample) {
$('design-instruct').value = sample.description;
$('design-sample-text').value = sample.text;
$('design-language').value = sample.language;
$('design-gender').value = sample.gender;
currentDesignSource = sample;
$('design-result').style.display = 'none';
$('design-save-result').style.display = 'none';
$('design-instruct').scrollIntoView({behavior:'smooth', block:'nearest'});
}
function isDialogueDesign(instruct, text, source = null) {
if (source && source.dialogue) return true;
const speakers = new Set();
String(instruct || '').split(/\n+/).forEach(line => {
const match = line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/);
if (match) speakers.add(match[1].trim());
});
if (speakers.size < 2) return false;
const turnSpeakers = new Set();
String(text || '').split(/\n+/).forEach(line => {
const match = line.trim().match(/^([^:]{1,40}):\s*(.+)$/);
if (match && speakers.has(match[1].trim())) turnSpeakers.add(match[1].trim());
});
return turnSpeakers.size >= 2;
}
function voiceDesignPayload(instruct, sampleText, language, source = null, gender = null) {
return {
instruct,
sample_text: sampleText,
language,
gender: gender || source?.gender || $('design-gender')?.value || '',
dialogue: isDialogueDesign(instruct, sampleText, source),
};
}
let _dVoiceIdManual = false;
function designSafeName(name) {
const base = name || 'VoiceDesign';
return (typeof _umlautSafe === 'function' ? _umlautSafe(base) : String(base))
.replace(/^[A-Z]{2}_[FMN]_/, '')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 42) || 'VoiceDesign';
}
function voiceIdSafePart(value, fallback = 'style') {
return (typeof _umlautSafe === 'function' ? _umlautSafe(value || fallback) : String(value || fallback))
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 32) || fallback;
}
function suggestedStyleVoiceId(baseId, style) {
const suffix = voiceIdSafePart(style || 'style');
return `${baseId}_${suffix}`.slice(0, 96);
}
function _updateDVoiceId() {
if (_dVoiceIdManual) return;
const lang = $('d-lang').value, gender = $('d-gender').value, name = $('d-name').value.trim();
$('d-voice-id').value = name ? `${lang}_${gender}_${name}` : '';
}
['d-lang','d-gender'].forEach(id => $(id).addEventListener('change', _updateDVoiceId));
$('d-name').addEventListener('input', () => { _dVoiceIdManual = false; _updateDVoiceId(); });
$('d-voice-id').addEventListener('input', () => { _dVoiceIdManual = true; });
seedDesignPresets();
refreshDesignPresetSelect();
renderQwenSampleCards();
syncDesignPresetsToServer();
$('design-preset-select').addEventListener('change', () => {
if ($('design-preset-select').value) applyDesignPreset($('design-preset-select').value);
});
$('design-preset-load').addEventListener('click', () => {
const name = $('design-preset-select').value || $('design-preset-name').value.trim();
if (!name) { toast('Select a preset first', 'error'); return; }
applyDesignPreset(name);
});
$('design-preset-save').addEventListener('click', () => {
const name = $('design-preset-name').value.trim() || $('design-preset-select').value;
if (!name) { toast('Enter a preset name', 'error'); $('design-preset-name').focus(); return; }
const presets = loadDesignPresets();
presets[name] = {
description: $('design-instruct').value,
sample_text: $('design-sample-text').value,
language: $('design-language').value,
gender: $('design-gender').value,
dialogue: isDialogueDesign($('design-instruct').value, $('design-sample-text').value, currentDesignSource),
};
saveDesignPresets(presets);
syncDesignPresetsToServer();
refreshDesignPresetSelect();
$('design-preset-select').value = name;
toast('Preset saved: ' + name, 'success');
});
$('design-preset-delete').addEventListener('click', () => {
const name = $('design-preset-select').value || $('design-preset-name').value.trim();
if (!name) { toast('Select a preset first', 'error'); return; }
const presets = loadDesignPresets();
if (!presets[name]) { toast('Preset not found', 'error'); return; }
delete presets[name];
saveDesignPresets(presets);
syncDesignPresetsToServer();
refreshDesignPresetSelect();
$('design-preset-name').value = '';
toast('Preset deleted: ' + name, 'success');
});
['design-instruct','design-sample-text'].forEach(id => $(id).addEventListener('input', () => {
currentDesignSource = null;
if (id === 'design-sample-text') $('d-transcript').value = $('design-sample-text').value;
}));
document.querySelectorAll('.qwen-sample').forEach(card => {
const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample];
const state = card.querySelector('.qwen-state');
const audio = card.querySelector('audio');
card.querySelector('.qwen-use').addEventListener('click', () => {
applyQwenSample(sample);
toast('Voice Design sample loaded', 'success');
});
card.querySelector('.qwen-preview').addEventListener('click', async e => {
const btn = e.currentTarget;
btn.disabled = true;
state.textContent = 'Generating preview…';
try {
const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(voiceDesignPayload(sample.description, sample.text, sample.language, sample))});
if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); }
const d = await r.json();
audio.src = '/api/audio/' + d.id;
audio.style.display = '';
audio.play().catch(()=>{});
state.textContent = 'Preview ready';
} catch(err) {
state.textContent = 'Preview failed';
toast('Sample preview failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
});
});
// ── Voice design ──────────────────────────────────────────────────────────
async function runVoiceDesign() {
const baseInstruct=$('design-instruct').value.trim(), sample=$('design-sample-text').value.trim();
const dialogue = isDialogueDesign(baseInstruct, sample, currentDesignSource);
const instruct = baseInstruct;
if (!instruct) { toast('Enter a voice description first','error'); return; }
$('design-generate-btn').disabled=true; $('design-status').textContent='Generating…';
$('design-result').style.display='none'; $('design-save-result').style.display='none';
status('Generating voice design…');
try {
const r = await fetch('/api/voice-design', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(voiceDesignPayload(instruct, sample, $('design-language').value, currentDesignSource, $('design-gender').value)) });
if (!r.ok) { const e=await r.json(); throw new Error(e.detail||r.statusText); }
const d = await r.json(); designedFileId=d.id; trimmedFileId=null; editingVoiceId=null;
$('design-audio').src='/api/audio/'+d.id;
$('design-result').style.display='flex';
$('design-status').textContent='Done ('+d.duration.toFixed(1)+' s)';
const langCode = DESIGN_LANG_CODE[$('design-language').value] || 'EN';
$('d-lang').value = langCode;
$('d-gender').value = $('design-gender').value;
$('d-name').value = designSafeName(currentDesignSource?.title || currentDesignSource?.name || $('design-preset-name').value || 'VoiceDesign');
_dVoiceIdManual = false; _updateDVoiceId();
$('d-transcript').value = sample;
$('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none';
if (!$('transcript-area').value) $('transcript-area').value=sample;
$('design-audio').play().catch(()=>{});
$('design-result').scrollIntoView({behavior:'smooth',block:'nearest'});
toast('Voice generated and export fields filled.','success');
status('Voice design ready');
} catch(e) {
$('design-status').textContent='Failed: '+e.message;
toast('Voice design failed: '+e.message,'error');
status('Voice design failed');
} finally { $('design-generate-btn').disabled=false; }
}
$('design-generate-btn').addEventListener('click', runVoiceDesign);
$('design-retry-btn').addEventListener('click', runVoiceDesign);
$('design-save-btn').addEventListener('click', async () => {
if (!designedFileId) { toast('No voice generated yet','error'); return; }
const voiceId = $('d-voice-id').value.trim();
if (!voiceId) { toast('Enter a Voice ID first','error'); $('d-name').focus(); return; }
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; }
$('design-save-btn').disabled=true;
try {
const r = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:designedFileId, voice_id:voiceId, transcript:$('d-transcript').value})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const saved = await r.json();
await saveMeta(saved.voice_id, {
gender: $('d-gender').value,
flag: LANG_FLAG_DEFAULT[$('d-lang').value] || undefined,
transcript: $('d-transcript').value,
note: 'Voice Design: ' + $('design-instruct').value.slice(0, 240),
}).catch(()=>{});
await loadVoiceLibrary().catch(()=>{});
$('design-save-result').style.display='flex';
$('design-save-result').scrollIntoView({behavior:'smooth',block:'nearest'});
toast('Exported to Voice Clone Library: '+saved.voice_id,'success');
status('Exported to Voice Clone Library: '+saved.voice_id);
} catch(e) { toast('Save failed: '+e.message,'error'); }
finally { $('design-save-btn').disabled=false; }
});
$('design-download-btn').addEventListener('click', () => {
if (!designedFileId) return;
const a=document.createElement('a'); a.href='/api/audio/'+designedFileId; a.download=($('d-voice-id').value.trim() || 'voice_design') + '.wav'; a.click();
});
// ── Transcribe ────────────────────────────────────────────────────────────
$('clone-refresh-stt-btn')?.addEventListener('click', async () => {
$('clone-refresh-stt-btn').disabled = true;
try { await refreshSttBackends($('clone-stt-backend')?.value); }
finally { $('clone-refresh-stt-btn').disabled = false; }
});
// STT dropdown is populated by init.js → refreshSttBackends() which runs after all
// feature modules (including stt.js) are loaded. No extra call needed here.
$('transcribe-btn').addEventListener('click', async () => {
const id = trimmedFileId||designedFileId||currentFileId;
if (!id) { toast('No audio to transcribe','error'); return; }
const btn = $('transcribe-btn'), status = $('transcribe-status'), area = $('transcript-area');
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Transcribing…';
if (status) { status.className = 'clone-tr-status working'; status.innerHTML = '<span class="reh-imsdb-spinner"></span> Listening to your recording…'; }
if (area) { area.classList.add('transcribing'); area.placeholder = 'Transcribing your audio — please wait…'; }
try {
const backend = $('clone-stt-backend')?.value || 'configured';
const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id, backend})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json();
if (area) area.value = d.text;
if (status) { status.className = 'clone-tr-status done'; status.innerHTML = '<span class="mdi mdi-check-circle"></span> Transcribed'; }
toast('Transcription complete','success');
window._cloneScheduleAutoSave?.(); // streamlined flow: save once transcript is in
} catch(e) {
if (status) { status.className = 'clone-tr-status error'; status.innerHTML = `<span class="mdi mdi-alert-circle"></span> Failed: ${escHtml(e.message || String(e))}`; }
toast('Transcription failed: '+e.message,'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
if (area) { area.classList.remove('transcribing'); area.placeholder = 'Type or auto-transcribe the spoken text…'; }
}
});
// ── Streamlined clone flow: name-first + auto transcribe / ID / save ─────────
(function () {
const g = id => document.getElementById(id);
let _idManual = false; // user hand-edited the final ID → stop auto-overwriting it
let _prevName = 'Sam'; // last name injected into the read-aloud sentence
let _autoSaveTimer = null;
let _lastAutoSaved = ''; // signature of last auto-save, avoids duplicate writes
function buildVoiceId() {
if (_idManual) { scheduleAutoSave(); return; }
const lang = g('lang-select')?.value || 'EN';
const gender = g('gender-select')?.value || 'N';
const name = (g('name-input')?.value || '').trim().replace(/\s+/g, '');
const vid = g('voice-id-input');
if (vid && name) { vid.value = `${lang}_${gender}_${name}`; vid.dispatchEvent(new Event('input')); }
scheduleAutoSave();
}
// ① Name-first field in the Read-aloud box
const nameField = g('clone-your-name');
nameField?.addEventListener('input', () => {
const name = nameField.value.trim();
if (!name) return;
const sample = g('clone-sample-text');
if (sample) {
// Replace whatever name is currently in the sentence — tracked across language
// switches via dataset.sampleName, falling back to the last-injected name.
const prev = sample.dataset.sampleName || _prevName;
const re = new RegExp('\\b' + prev.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
if (re.test(sample.value)) sample.value = sample.value.replace(re, name);
sample.dataset.sampleName = name;
}
_prevName = name;
const ni = g('name-input'); if (ni) ni.value = name.replace(/\s+/g, '');
buildVoiceId();
});
// Live ID building from lang / gender / name — no "Apply" click needed
['lang-select', 'gender-select'].forEach(id => g(id)?.addEventListener('change', buildVoiceId));
g('name-input')?.addEventListener('input', buildVoiceId);
// Respect a manually edited final ID (only real keystrokes set the manual flag)
g('voice-id-input')?.addEventListener('input', e => { if (e.isTrusted) _idManual = true; scheduleAutoSave(); });
g('transcript-area')?.addEventListener('input', scheduleAutoSave);
// Auto-transcribe once the clip is trimmed (kept if the user already typed a transcript)
window._cloneAutoTranscribe = function () {
const ta = g('transcript-area');
if (ta && ta.value.trim()) { scheduleAutoSave(); return; }
g('transcribe-btn')?.click();
};
function canAutoSave() {
const vid = (g('voice-id-input')?.value || '').trim();
const tr = (g('transcript-area')?.value || '').trim();
const hasAudio = (typeof trimmedFileId !== 'undefined' && trimmedFileId) ||
(typeof designedFileId !== 'undefined' && designedFileId);
return !!(hasAudio && vid && tr && (typeof validateVoiceId !== 'function' || validateVoiceId(vid)));
}
function scheduleAutoSave() {
const toggle = g('clone-autosave-toggle');
if (!toggle || !toggle.checked) return;
clearTimeout(_autoSaveTimer);
_autoSaveTimer = setTimeout(() => {
if (!canAutoSave()) return;
const sig = (g('voice-id-input').value + '|' + g('transcript-area').value).trim();
if (sig === _lastAutoSaved) return;
_lastAutoSaved = sig;
g('save-btn')?.click();
}, 1600);
}
window._cloneScheduleAutoSave = scheduleAutoSave;
})();
// ── Clone source picker — File / URL / Microphone (one at a time) ────────────
(function () {
const picker = document.getElementById('clone-src-picker');
if (!picker) return;
const cards = [...document.querySelectorAll('.clone-src-card')];
const tabs = [...picker.querySelectorAll('.clone-src-tab')];
const KEY = 'clone-src-choice';
function show(src) {
cards.forEach(c => { c.hidden = c.dataset.src !== src; });
tabs.forEach(t => t.classList.toggle('active', t.dataset.src === src));
try { localStorage.setItem(KEY, src); } catch (_) {}
}
tabs.forEach(t => t.addEventListener('click', () => show(t.dataset.src)));
show(localStorage.getItem(KEY) || 'mic');
})();