Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
20 KiB
JavaScript
419 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 parts = safe.match(/[^.!?]+[.!?]+\s*/g) || [];
|
|
const last = safe.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
|
|
if (last) parts.push(last);
|
|
const restore = s => s.replace(/\x00/g, '.');
|
|
if (!parts.length) return [text];
|
|
const chunks = [];
|
|
let cur = '';
|
|
for (const p of parts) {
|
|
if ((cur + p).length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = p; }
|
|
else cur += p;
|
|
}
|
|
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();
|
|
});
|
|
|