// ── 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, extra = null) { 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, false, extra)); } 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 = '
No generations yet.
'; 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 ? `` : `${(item.voice||'?')[0].toUpperCase()}`; return `
${avatar} ${escHtml(item.voice)} ${escHtml(item.backend)} ${ts}
${preview}
`; }).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 = '
No clips in playlist. Use + Playlist after generating.
'; if ($('playlist-export-btn')) $('playlist-export-btn').disabled = true; return; } list.innerHTML = _playlist.map((item, i) => `
${i + 1}
${escHtml(item.voice)} ${escHtml(item.text.length > 70 ? item.text.slice(0,70)+'…' : item.text)}
`).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(); });