tts-voice-creator-clone-and.../static/js/generation.js
mARTin-B78 345bec8a05 Fix chunked TTS failing on newline-delimited text (e.g. German bullet lists)
splitTextIntoChunks only split on sentence terminators (.!?), so structured
text separated by newlines was never chunked — the full page went as one
request, causing a TCP timeout that the browser reported as "Failed to fetch".

Fix: process each line individually before applying the sentence regex so
newline-separated segments become their own split points. Also move
generation.js from deferred batch E into the main feature batch C so
generateChunkedTts is always defined before the user can click Generate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 03:41:31 +02:00

428 lines
20 KiB
JavaScript

// ── WAV merge utility (chunked TTS + playlist export) ──────────────────────
async function mergeWavBlobs(blobs) {
if (!blobs || blobs.length === 0) return null;
if (blobs.length === 1) return blobs[0];
function parseWav(bytes) {
const v = new DataView(bytes.buffer);
let off = 12, fmt = null, dataOff = 0, dataSize = 0;
while (off + 8 <= bytes.length) {
const id = v.getUint32(off, false);
const sz = v.getUint32(off + 4, true);
if (id === 0x666d7420) {
fmt = { channels: v.getUint16(off+10,true), sampleRate: v.getUint32(off+12,true), bitDepth: v.getUint16(off+22,true) };
} else if (id === 0x64617461) {
dataOff = off + 8; dataSize = sz;
}
off += 8 + sz;
}
return { fmt, dataOff, dataSize };
}
const parsed = [];
for (const b of blobs) {
const bytes = new Uint8Array(await b.arrayBuffer());
const p = parseWav(bytes);
if (!p.fmt) throw new Error('Invalid WAV in chunk');
parsed.push({ bytes, ...p });
}
const ref = parsed[0].fmt;
const totalPcm = parsed.reduce((s, p) => s + p.dataSize, 0);
const out = new Uint8Array(44 + totalPcm);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x52494646, false);
dv.setUint32(4, 36 + totalPcm, true);
dv.setUint32(8, 0x57415645, false);
dv.setUint32(12, 0x666d7420, false);
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true);
dv.setUint16(22, ref.channels, true);
dv.setUint32(24, ref.sampleRate, true);
dv.setUint32(28, ref.sampleRate * ref.channels * (ref.bitDepth >> 3), true);
dv.setUint16(32, ref.channels * (ref.bitDepth >> 3), true);
dv.setUint16(34, ref.bitDepth, true);
dv.setUint32(36, 0x64617461, false);
dv.setUint32(40, totalPcm, true);
let pos = 44;
for (const p of parsed) {
out.set(p.bytes.slice(p.dataOff, p.dataOff + p.dataSize), pos);
pos += p.dataSize;
}
return new Blob([out], { type: 'audio/wav' });
}
// ── Chunked TTS ────────────────────────────────────────────────────────────
function splitTextIntoChunks(text, maxLen = 800) {
const abbrev = /\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g;
const safe = text.replace(abbrev, m => m.replace('.', '\x00'));
const restore = s => s.replace(/\x00/g, '.');
// Gather segments from each line: sentences first, then any remaining line text.
// This ensures newline-delimited text (e.g. German bullet lists) gets split too.
const segments = [];
for (const line of safe.split('\n')) {
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
segments.push(...sentences);
if (rest) segments.push(rest);
}
if (!segments.length) return [text];
const chunks = [];
let cur = '';
for (const seg of segments) {
const joined = cur ? cur + ' ' + seg.trim() : seg.trim();
if (joined.length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = seg.trim(); }
else cur = joined;
}
if (cur.trim()) chunks.push(restore(cur.trim()));
return chunks.length ? chunks : [text];
}
async function generateChunkedTts(voice, text, backend, instruct) {
const chunks = splitTextIntoChunks(text);
const prog = $('preview-chunk-progress');
if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}`; }
const blobs = [];
for (let i = 0; i < chunks.length; i++) {
if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}`;
blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend));
}
if (prog) prog.textContent = 'Merging…';
const merged = await mergeWavBlobs(blobs);
if (prog) { prog.hidden = true; prog.textContent = ''; }
return { url: URL.createObjectURL(merged), blob: merged, streaming: false, label: 'chunked' };
}
// ── Generation history ─────────────────────────────────────────────────────
const _genHistory = [];
function historyPush(voice, text, backend, blob, url) {
const id = Date.now() + '-' + Math.random().toString(36).slice(2, 6);
_genHistory.unshift({ id, ts: Date.now(), voice, text: text.slice(0, 200), backend, blob, url });
if (_genHistory.length > 20) _genHistory.pop();
renderHistory();
}
function _histAvatarColor(id) {
const palette = ['#3b82f6','#10b981','#8b5cf6','#f59e0b','#ef4444','#ec4899','#06b6d4','#84cc16'];
let h = 0; for (let i = 0; i < (id||'').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function renderHistory() {
const list = $('history-list');
if (!list) return;
if (!_genHistory.length) {
list.innerHTML = '<div class="history-empty">No generations yet.</div>';
return;
}
list.innerHTML = _genHistory.map(item => {
const t = new Date(item.ts);
const ts = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0');
const preview = escHtml(item.text.length > 90 ? item.text.slice(0,90) + '…' : item.text);
const v = (window._voices || []).find(vx => vx.id === item.voice);
const avatar = v?.has_picture
? `<img src="/api/voice/picture/${encodeURIComponent(item.voice)}" class="hist-avatar" alt="">`
: `<span class="hist-avatar hist-avatar-init" style="background:${_histAvatarColor(item.voice)}">${(item.voice||'?')[0].toUpperCase()}</span>`;
return `<div class="history-item" data-hid="${escHtml(item.id)}">
<div class="history-item-row1">
${avatar}
<span class="history-voice">${escHtml(item.voice)}</span>
<span class="history-backend">${escHtml(item.backend)}</span>
<span class="history-time">${ts}</span>
<button class="hist-del-btn" title="Delete"><span class="mdi mdi-close"></span></button>
</div>
<div class="history-item-text">${preview}</div>
<div class="history-item-actions">
<button class="btn-secondary btn-xs hist-play-btn" ${item.blob?'':'disabled'}><span class="mdi mdi-play"></span> Play</button>
<button class="btn-secondary btn-xs hist-reuse-btn"><span class="mdi mdi-restore"></span> Reuse</button>
<button class="btn-secondary btn-xs hist-playlist-btn" ${item.blob?'':'disabled'}><span class="mdi mdi-plus"></span> Playlist</button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.hist-del-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const hid = btn.closest('[data-hid]')?.dataset.hid;
const idx = _genHistory.findIndex(h => h.id === hid);
if (idx !== -1) { _genHistory.splice(idx, 1); renderHistory(); }
});
});
list.querySelectorAll('.hist-play-btn').forEach(btn => {
btn.addEventListener('click', () => {
const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid);
if (!item?.url) return;
const audio = $('preview-audio');
audio.src = item.url; audio.style.display = ''; audio.play().catch(()=>{});
});
});
list.querySelectorAll('.hist-reuse-btn').forEach(btn => {
btn.addEventListener('click', () => {
const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid);
if (!item) return;
$('preview-text-area').value = item.text;
const bSel = $('tts-backend-select');
if (bSel) [...bSel.options].forEach(o => { if (o.value === item.backend) bSel.value = item.backend; });
const vSel = $('tts-voice-select');
if (vSel) [...vSel.options].forEach(o => { if (o.value === item.voice) vSel.value = item.voice; });
toast('Settings restored from history', 'success');
});
});
list.querySelectorAll('.hist-playlist-btn').forEach(btn => {
btn.addEventListener('click', () => {
const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid);
if (item?.blob) playlistAdd(item.voice, item.text, item.blob, item.url);
});
});
}
$('history-clear-btn')?.addEventListener('click', () => {
_genHistory.length = 0; renderHistory();
toast('History cleared', 'success');
});
// ── Playlist ───────────────────────────────────────────────────────────────
const _playlist = [];
function playlistAdd(voice, text, blob, url) {
const id = 'pl-' + Date.now() + '-' + Math.random().toString(36).slice(2,5);
_playlist.push({ id, voice, text: text.slice(0, 120), blob, url });
renderPlaylist();
if ($('playlist-export-btn')) $('playlist-export-btn').disabled = false;
toast('Added to playlist', 'success');
}
function renderPlaylist() {
const list = $('playlist-list');
if (!list) return;
if (!_playlist.length) {
list.innerHTML = '<div class="history-empty">No clips in playlist. Use <strong>+ Playlist</strong> after generating.</div>';
if ($('playlist-export-btn')) $('playlist-export-btn').disabled = true;
return;
}
list.innerHTML = _playlist.map((item, i) => `
<div class="playlist-item" data-pid="${escHtml(item.id)}">
<span class="playlist-num">${i + 1}</span>
<div class="playlist-info">
<span class="history-voice">${escHtml(item.voice)}</span>
<span class="playlist-text">${escHtml(item.text.length > 70 ? item.text.slice(0,70)+'…' : item.text)}</span>
</div>
<div class="playlist-item-actions">
<button class="btn-secondary btn-xs pl-up" ${i===0?'disabled':''}><span class="mdi mdi-upload"></span></button>
<button class="btn-secondary btn-xs pl-dn" ${i===_playlist.length-1?'disabled':''}><span class="mdi mdi-download"></span></button>
<button class="btn-secondary btn-xs pl-rm"><span class="mdi mdi-close"></span></button>
</div>
</div>`).join('');
list.querySelectorAll('.pl-rm').forEach(btn => {
btn.addEventListener('click', () => {
const pid = btn.closest('[data-pid]')?.dataset.pid;
const idx = _playlist.findIndex(p => p.id === pid);
if (idx >= 0) { _playlist.splice(idx, 1); renderPlaylist(); }
});
});
list.querySelectorAll('.pl-up').forEach(btn => {
btn.addEventListener('click', () => {
const pid = btn.closest('[data-pid]')?.dataset.pid;
const idx = _playlist.findIndex(p => p.id === pid);
if (idx > 0) { [_playlist[idx-1], _playlist[idx]] = [_playlist[idx], _playlist[idx-1]]; renderPlaylist(); }
});
});
list.querySelectorAll('.pl-dn').forEach(btn => {
btn.addEventListener('click', () => {
const pid = btn.closest('[data-pid]')?.dataset.pid;
const idx = _playlist.findIndex(p => p.id === pid);
if (idx < _playlist.length - 1) { [_playlist[idx], _playlist[idx+1]] = [_playlist[idx+1], _playlist[idx]]; renderPlaylist(); }
});
});
}
$('add-to-playlist-btn')?.addEventListener('click', () => {
if (!previewBlob) { toast('Generate audio first', 'error'); return; }
playlistAdd(
window._previewVoice || $('tts-voice-select')?.value || '',
window._previewText || $('preview-text-area')?.value || '',
previewBlob,
$('preview-audio')?.src || ''
);
});
$('playlist-export-btn')?.addEventListener('click', async () => {
if (!_playlist.length) return;
const btn = $('playlist-export-btn'), orig = btn.textContent;
btn.disabled = true; btn.textContent = 'Merging…';
try {
const merged = await mergeWavBlobs(_playlist.map(p => p.blob).filter(Boolean));
if (!merged) throw new Error('No audio to export');
const a = document.createElement('a');
a.href = URL.createObjectURL(merged);
a.download = 'playlist_' + Date.now() + '.wav'; a.click();
toast('Playlist exported as WAV', 'success');
} catch(e) { toast('Export failed: ' + e.message, 'error'); }
finally { btn.disabled = _playlist.length === 0; btn.textContent = orig; }
});
$('playlist-clear-btn')?.addEventListener('click', () => {
_playlist.length = 0; renderPlaylist();
toast('Playlist cleared', 'success');
});
// ── Audio effects panel ────────────────────────────────────────────────────
const _FX_PRESETS = {
studio: { reverb: { on:true, room_size:0.6, wet:0.35 },
compressor: { on:true, threshold_db:-18, ratio:3 } },
broadcast: { compressor: { on:true, threshold_db:-12, ratio:6 } },
telephone: { compressor: { on:true, threshold_db:-10, ratio:8 } },
warm: { reverb: { on:true, room_size:0.2, wet:0.15 },
compressor: { on:true, threshold_db:-20, ratio:2 } },
radio: { compressor: { on:true, threshold_db:-14, ratio:5 } },
};
function fxSliderBind(sliderId, labelId, fmt) {
const s = $(sliderId), l = $(labelId);
if (!s || !l) return;
const upd = () => { l.textContent = fmt(s.value); };
upd(); s.addEventListener('input', upd);
}
fxSliderBind('fx-reverb-room', 'fx-reverb-room-val', v => parseFloat(v).toFixed(2));
fxSliderBind('fx-reverb-wet', 'fx-reverb-wet-val', v => parseFloat(v).toFixed(2));
fxSliderBind('fx-comp-thresh', 'fx-comp-thresh-val', v => v + ' dB');
fxSliderBind('fx-comp-ratio', 'fx-comp-ratio-val', v => v + ':1');
fxSliderBind('fx-chorus-rate', 'fx-chorus-rate-val', v => parseFloat(v).toFixed(1) + ' Hz');
fxSliderBind('fx-chorus-mix', 'fx-chorus-mix-val', v => parseFloat(v).toFixed(2));
fxSliderBind('fx-pitch-semi', 'fx-pitch-semi-val', v => (parseFloat(v) >= 0 ? '+' : '') + v + ' st');
$('effects-preset')?.addEventListener('change', () => {
const preset = _FX_PRESETS[$('effects-preset').value];
if (!preset) return;
['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on'].forEach(id => { const el=$(id); if(el) el.checked=false; });
if (preset.reverb) {
$('fx-reverb-on').checked = !!preset.reverb.on;
if (preset.reverb.room_size != null) $('fx-reverb-room').value = preset.reverb.room_size;
if (preset.reverb.wet != null) $('fx-reverb-wet').value = preset.reverb.wet;
}
if (preset.compressor) {
$('fx-compressor-on').checked = !!preset.compressor.on;
if (preset.compressor.threshold_db != null) $('fx-comp-thresh').value = preset.compressor.threshold_db;
if (preset.compressor.ratio != null) $('fx-comp-ratio').value = preset.compressor.ratio;
}
['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi']
.forEach(id => $(id)?.dispatchEvent(new Event('input')));
});
$('effects-reset-btn')?.addEventListener('click', () => {
$('effects-preset').value = '';
['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on'].forEach(id => { const el=$(id); if(el) el.checked=false; });
$('fx-reverb-room').value = '0.35'; $('fx-reverb-wet').value = '0.25';
$('fx-comp-thresh').value = '-20'; $('fx-comp-ratio').value = '4';
$('fx-chorus-rate').value = '1'; $('fx-chorus-mix').value = '0.5';
$('fx-pitch-semi').value = '0';
['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi']
.forEach(id => $(id)?.dispatchEvent(new Event('input')));
});
let _effectsSourceBlob = null;
$('effects-apply-btn')?.addEventListener('click', async () => {
const blob = previewBlob || _effectsSourceBlob;
if (!blob) { toast('Generate audio first', 'error'); return; }
const chain = [];
if ($('fx-reverb-on')?.checked) chain.push({ type:'reverb', params: { room_size: +$('fx-reverb-room').value, wet: +$('fx-reverb-wet').value, dry: 1 - +$('fx-reverb-wet').value } });
if ($('fx-compressor-on')?.checked) chain.push({ type:'compressor', params: { threshold_db: +$('fx-comp-thresh').value, ratio: +$('fx-comp-ratio').value } });
if ($('fx-chorus-on')?.checked) chain.push({ type:'chorus', params: { rate_hz: +$('fx-chorus-rate').value, mix: +$('fx-chorus-mix').value } });
if ($('fx-pitch-on')?.checked) chain.push({ type:'pitch_shift', params: { semitones: +$('fx-pitch-semi').value } });
if (!chain.length) { toast('Enable at least one effect', 'error'); return; }
const btn = $('effects-apply-btn'), st = $('effects-status');
btn.disabled = true; if (st) st.textContent = 'Processing…';
try {
const fd = new FormData();
fd.append('audio', blob, 'audio.wav');
fd.append('effects', JSON.stringify(chain));
const resp = await fetch('/api/audio/effects', { method:'POST', body: fd });
if (!resp.ok) { const e = await resp.json().catch(()=>({})); throw new Error(e.detail || resp.statusText); }
const out = await resp.blob();
if (!_effectsSourceBlob) _effectsSourceBlob = previewBlob;
previewBlob = out;
const audio = $('preview-audio');
audio.src = URL.createObjectURL(out); audio.style.display = ''; audio.play().catch(()=>{});
if (st) st.textContent = 'Applied.';
toast('Effects applied', 'success');
} catch(e) { if (st) st.textContent = ''; toast('Effects failed: ' + e.message, 'error'); }
finally { btn.disabled = false; }
});
// ── LLM refinement ─────────────────────────────────────────────────────────
let _refineOriginal = null;
(function initLlmRefinement() {
const inp = $('refine-llm-url');
if (!inp) return;
// loadSettings() will overwrite with the server value; localStorage is the fast initial fallback
const saved = (_appSettings && _appSettings.refine_llm_url) || localStorage.getItem('refine-llm-url');
if (saved) inp.value = saved;
else inp.value = (_appSettings && _appSettings.llm_url) || (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls['ollama']) || localStorage.getItem('llm-local-url-ollama') || 'http://localhost:11434/v1';
inp.addEventListener('input', () => {
localStorage.setItem('refine-llm-url', inp.value);
_patchSettings({ refine_llm_url: inp.value });
if (_appSettings) _appSettings.refine_llm_url = inp.value;
});
})();
function updateRefineButtonState() {
const btn = $('refine-btn');
if (btn) btn.disabled = !$('stt-tts-text')?.value?.trim();
}
$('stt-tts-text')?.addEventListener('input', updateRefineButtonState);
$('refine-btn')?.addEventListener('click', async () => {
const text = $('stt-tts-text')?.value?.trim();
if (!text) return;
const btn = $('refine-btn'), st = $('refine-status');
btn.disabled = true; if (st) st.textContent = 'Refining…';
try {
const r = await fetch('/api/refine-text', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
text,
llm_url: $('refine-llm-url')?.value?.trim() || _appSettings?.llm_url || 'http://localhost:11434/v1',
model: $('refine-model')?.value?.trim() || _appSettings?.refine_model || _appSettings?.llm_model || '',
toggles: {
fillers: $('refine-fillers')?.checked ?? true,
repetitions: $('refine-repetitions')?.checked ?? true,
corrections: $('refine-corrections')?.checked ?? true,
punctuation: $('refine-punctuation')?.checked ?? true,
},
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_refineOriginal = text;
$('stt-tts-text').value = d.text;
if ($('refine-restore-btn')) $('refine-restore-btn').disabled = false;
if (st) st.textContent = 'Done.';
toast('Transcription refined', 'success');
} catch(e) { if (st) st.textContent = ''; toast('Refinement failed: ' + e.message, 'error'); }
finally { btn.disabled = !$('stt-tts-text')?.value?.trim(); }
});
$('refine-restore-btn')?.addEventListener('click', () => {
if (!_refineOriginal) return;
$('stt-tts-text').value = _refineOriginal; _refineOriginal = null;
if ($('refine-restore-btn')) $('refine-restore-btn').disabled = true;
if ($('refine-status')) $('refine-status').textContent = '';
toast('Original transcription restored', 'success');
});
// Update style-support warning when user types in style field
$('preview-style-instruction')?.addEventListener('input', () => {
if (typeof updateBackendHelp === 'function') updateBackendHelp();
});