diff --git a/server.py b/server.py
index bce8663..1b1ad8e 100644
--- a/server.py
+++ b/server.py
@@ -2106,7 +2106,7 @@ async def update_voice_meta(request: Request):
meta = _load_meta(wav)
meta["enabled"] = enabled
- for field in ("note", "rating", "flag", "gender", "loudness"):
+ for field in ("note", "rating", "flag", "gender", "loudness", "persona"):
if field in data:
meta[field] = data[field]
if "transcript" in data:
@@ -4422,6 +4422,294 @@ async def openai_speech_proxy_v1_shortcut(request: Request):
return await openai_speech_proxy(request)
+# ── LLM text refinement ───────────────────────────────────────────────────────
+
+@app.post("/api/refine-text")
+async def refine_text(request: Request):
+ """Clean up raw STT transcription using a local OpenAI-compatible LLM."""
+ data = await request.json()
+ text: str = (data.get("text") or "").strip()
+ llm_url: str = (data.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
+ model: str = (data.get("model") or "").strip()
+ toggles: dict = data.get("toggles") or {}
+
+ if not text:
+ raise HTTPException(400, "No text to refine")
+
+ rules = []
+ if toggles.get("fillers", True):
+ rules.append("Remove filler words (um, uh, like, you know, basically, literally, I mean, so, right, etc.)")
+ if toggles.get("repetitions", True):
+ rules.append("Remove repeated words and false starts (e.g. 'the the dog' → 'the dog', 'I was- I was going' → 'I was going')")
+ if toggles.get("corrections", True):
+ rules.append("Remove self-corrections and restarts, keeping only the final intended phrasing")
+ if toggles.get("punctuation", True):
+ rules.append("Fix punctuation, capitalisation, and sentence boundaries")
+ if not rules:
+ return {"text": text, "original": text}
+
+ system = (
+ "You are a transcription cleanup assistant. "
+ "Apply ONLY the following rules to the user's text. "
+ "Return ONLY the cleaned text — no explanations, no quotes, no markdown:\n"
+ + "\n".join(f"- {r}" for r in rules)
+ )
+ payload: dict = {
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": text},
+ ],
+ "temperature": 0.1,
+ "max_tokens": 2048,
+ }
+ if model:
+ payload["model"] = model
+
+ try:
+ resp = requests.post(
+ f"{llm_url}/chat/completions",
+ json=payload,
+ headers={"Authorization": "Bearer no-key"},
+ timeout=60,
+ )
+ resp.raise_for_status()
+ refined = resp.json()["choices"][0]["message"]["content"].strip()
+ if refined.startswith('"') and refined.endswith('"'):
+ refined = refined[1:-1].strip()
+ return {"text": refined, "original": text}
+ except Exception as e:
+ raise HTTPException(502, f"LLM refinement failed: {e}")
+
+
+# ── LLM persona rewrite ───────────────────────────────────────────────────────
+
+@app.post("/api/rewrite-with-persona")
+async def rewrite_with_persona(request: Request):
+ """Rewrite user text in a voice persona's character using a local LLM."""
+ data = await request.json()
+ text: str = (data.get("text") or "").strip()
+ persona: str = (data.get("persona") or "").strip()
+ llm_url: str = (data.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
+ model: str = (data.get("model") or "").strip()
+ mode: str = (data.get("mode") or "rewrite").strip()
+
+ if not persona:
+ raise HTTPException(400, "No persona defined for this voice")
+ if not text and mode != "compose":
+ raise HTTPException(400, "No text provided")
+
+ if mode == "compose":
+ system = (
+ f"You are a voice assistant with this character: {persona}\n"
+ "Write a single natural utterance in this character's voice about the topic given. "
+ "Return ONLY the utterance — no quotes, no explanation."
+ )
+ user_msg = text or "Introduce yourself briefly."
+ temp = 0.9
+ else:
+ system = (
+ f"Rephrase the user's text as if spoken by this character: {persona}\n"
+ "Keep the same meaning but adapt vocabulary, tone, and style to the character. "
+ "Return ONLY the rephrased text — no quotes, no explanation."
+ )
+ user_msg = text
+ temp = 0.3
+
+ payload: dict = {
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user_msg},
+ ],
+ "temperature": temp,
+ "max_tokens": 512,
+ }
+ if model:
+ payload["model"] = model
+
+ try:
+ resp = requests.post(
+ f"{llm_url}/chat/completions",
+ json=payload,
+ headers={"Authorization": "Bearer no-key"},
+ timeout=60,
+ )
+ resp.raise_for_status()
+ result = resp.json()["choices"][0]["message"]["content"].strip()
+ if result.startswith('"') and result.endswith('"'):
+ result = result[1:-1].strip()
+ return {"text": result, "original": text, "persona": persona}
+ except Exception as e:
+ raise HTTPException(502, f"LLM persona rewrite failed: {e}")
+
+
+# ── Audio effects ─────────────────────────────────────────────────────────────
+
+def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
+ try:
+ from pedalboard import Pedalboard, Reverb, Chorus, Delay, Compressor, Gain, HighpassFilter, LowpassFilter, PitchShift # type: ignore
+ import numpy as np # type: ignore
+ except ImportError:
+ raise RuntimeError("pedalboard is not installed — run: pip install pedalboard numpy")
+
+ with io.BytesIO(audio_bytes) as buf:
+ with wave.open(buf, "rb") as wf:
+ n_channels = wf.getnchannels()
+ sample_rate = wf.getframerate()
+ n_frames = wf.getnframes()
+ raw = wf.readframes(n_frames)
+ sampwidth = wf.getsampwidth()
+
+ import numpy as np # noqa: F811
+ dtype = {1: np.int8, 2: np.int16, 4: np.int32}.get(sampwidth, np.int16)
+ samples = np.frombuffer(raw, dtype=dtype).astype(np.float32) / float(np.iinfo(dtype).max)
+ samples = samples.reshape(1, -1) if n_channels == 1 else samples.reshape(-1, n_channels).T
+
+ board = []
+ for fx in effects:
+ t = fx.get("type", "")
+ p = fx.get("params", {})
+ if t == "reverb":
+ board.append(Reverb(
+ room_size=float(p.get("room_size", 0.35)),
+ damping=float(p.get("damping", 0.5)),
+ wet_level=float(p.get("wet", 0.25)),
+ dry_level=float(p.get("dry", 0.8)),
+ ))
+ elif t == "chorus":
+ board.append(Chorus(
+ rate_hz=float(p.get("rate_hz", 1.0)),
+ depth=float(p.get("depth", 0.25)),
+ mix=float(p.get("mix", 0.5)),
+ ))
+ elif t == "delay":
+ board.append(Delay(
+ delay_seconds=float(p.get("delay_s", 0.25)),
+ feedback=float(p.get("feedback", 0.3)),
+ mix=float(p.get("mix", 0.4)),
+ ))
+ elif t == "compressor":
+ board.append(Compressor(
+ threshold_db=float(p.get("threshold_db", -20.0)),
+ ratio=float(p.get("ratio", 4.0)),
+ attack_ms=float(p.get("attack_ms", 10.0)),
+ release_ms=float(p.get("release_ms", 100.0)),
+ ))
+ elif t == "gain":
+ board.append(Gain(gain_db=float(p.get("gain_db", 0.0))))
+ elif t == "highpass":
+ board.append(HighpassFilter(cutoff_frequency_hz=float(p.get("cutoff_hz", 80.0))))
+ elif t == "lowpass":
+ board.append(LowpassFilter(cutoff_frequency_hz=float(p.get("cutoff_hz", 8000.0))))
+ elif t == "pitch_shift":
+ board.append(PitchShift(semitones=float(p.get("semitones", 0.0))))
+
+ if board:
+ samples = Pedalboard(board)(samples, sample_rate)
+
+ out = np.clip(samples, -1.0, 1.0)
+ pcm = ((out[0] if out.shape[0] == 1 else out.T.reshape(-1)) * 32767).astype(np.int16).tobytes()
+ buf_out = io.BytesIO()
+ with wave.open(buf_out, "wb") as wf:
+ wf.setnchannels(n_channels)
+ wf.setsampwidth(2)
+ wf.setframerate(sample_rate)
+ wf.writeframes(pcm)
+ return buf_out.getvalue()
+
+
+@app.post("/api/audio/effects")
+async def audio_effects(request: Request):
+ """Apply an effects chain to a WAV file."""
+ form = await request.form()
+ audio_file = form.get("audio")
+ effects_json = str(form.get("effects") or "[]")
+
+ if audio_file is None:
+ raise HTTPException(400, "No audio file provided")
+ audio_bytes = await audio_file.read()
+ try:
+ effects = json.loads(effects_json)
+ except Exception:
+ raise HTTPException(400, "Invalid effects JSON")
+
+ try:
+ result = await asyncio.to_thread(_apply_audio_effects, audio_bytes, effects)
+ return Response(content=result, media_type="audio/wav")
+ except RuntimeError as e:
+ raise HTTPException(501, str(e))
+ except Exception as e:
+ raise HTTPException(500, f"Effects processing failed: {e}")
+
+
+# ── Voices export / import ────────────────────────────────────────────────────
+
+_EXPORT_SKIP_KEYS = {"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key"}
+_IMPORT_ALLOWED_SUFFIXES = set(_AUDIO_EXTS + [".reference.txt", ".meta.json", ".jpg", ".jpeg", ".png", ".webp"])
+
+
+@app.get("/api/voices/export")
+async def voices_export():
+ """Export all voices + non-sensitive settings as a ZIP archive."""
+ import zipfile
+ settings = _load_settings()
+ scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ safe_settings = {k: v for k, v in settings.items() if k not in _EXPORT_SKIP_KEYS}
+ zf.writestr("settings.json", json.dumps(safe_settings, indent=2))
+ if scan_dir.exists():
+ for f in scan_dir.rglob("*"):
+ if f.is_file():
+ try:
+ zf.write(f, str(f.relative_to(scan_dir.parent)))
+ except Exception:
+ pass
+ buf.seek(0)
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+ return Response(
+ content=buf.read(),
+ media_type="application/zip",
+ headers={"Content-Disposition": f'attachment; filename="voices_export_{ts}.zip"'},
+ )
+
+
+@app.post("/api/voices/import")
+async def voices_import(file: UploadFile = File(...)):
+ """Import voices from a ZIP archive (skips settings.json and unsafe paths)."""
+ import zipfile
+ settings = _load_settings()
+ scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
+ scan_dir.mkdir(parents=True, exist_ok=True)
+
+ content = await file.read()
+ if len(content) > _MAX_UPLOAD_BYTES:
+ raise HTTPException(413, "ZIP file too large")
+
+ try:
+ imported = 0
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
+ for info in zf.infolist():
+ if info.is_dir():
+ continue
+ parts = Path(info.filename).parts
+ if any(p in ("..", "") for p in parts) or Path(info.filename).name == "settings.json":
+ continue
+ if Path(info.filename).suffix.lower() not in _IMPORT_ALLOWED_SUFFIXES:
+ continue
+ rel = parts[1:] if len(parts) > 1 else parts
+ dest = scan_dir / Path(*rel)
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(zf.read(info))
+ imported += 1
+ except zipfile.BadZipFile:
+ raise HTTPException(400, "Not a valid ZIP file")
+ except Exception as e:
+ raise HTTPException(500, f"Import failed: {e}")
+
+ return {"ok": True, "imported": imported}
+
+
# ── Static ────────────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
diff --git a/static/app.js b/static/app.js
index 5b87175..a7773a2 100644
--- a/static/app.js
+++ b/static/app.js
@@ -355,6 +355,37 @@ function selectVoice(wrap) {
if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e));
+ // ── Persona panel ─────────────────────────────────────────────────────────
+ const personaVal = v.persona || '';
+ const personaPanel = document.createElement('div');
+ personaPanel.className = 'insp-persona-panel';
+ personaPanel.innerHTML = `
+
+ Character persona
+ Used by LLM to rewrite text in this voice's style. Leave empty to skip.
+
+
+
+ Save persona
+
+
+ `;
+ body.appendChild(personaPanel);
+
+ const personaTextarea = personaPanel.querySelector('.insp-persona-input');
+ const personaStatus = personaPanel.querySelector('.insp-persona-status');
+ personaPanel.querySelector('.insp-persona-save-btn').addEventListener('click', async () => {
+ const pText = personaTextarea.value.trim();
+ try {
+ await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ voice_id: voiceId, persona: pText }) });
+ v.persona = pText;
+ personaStatus.textContent = 'Saved.';
+ setTimeout(() => { personaStatus.textContent = ''; }, 2000);
+ toast('Persona saved', 'success');
+ } catch(e) { toast('Save failed: ' + e.message, 'error'); }
+ });
+
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
if (maintTitle) {
maintTitle.innerHTML = `Loudness Current ${escHtml(dbfs)} dBFS `;
@@ -5518,6 +5549,39 @@ function updatePreviewVoiceMatchPanel() {
audio.src = expected;
audio.dataset.src = expected;
}
+
+ // Persona rewrite button — shown only when voice has a persona
+ const actionsEl = panel.querySelector('.preview-match-actions');
+ let personaBtn = panel.querySelector('.preview-persona-btn');
+ if (v.persona) {
+ if (!personaBtn) {
+ personaBtn = document.createElement('button');
+ personaBtn.className = 'btn-secondary preview-persona-btn';
+ personaBtn.type = 'button';
+ personaBtn.textContent = 'Rewrite with persona';
+ actionsEl?.appendChild(personaBtn);
+ personaBtn.addEventListener('click', async () => {
+ const text = $('preview-text-area').value.trim();
+ if (!text) { toast('Enter text to rewrite', 'error'); return; }
+ const lv = selectedPreviewLibraryVoice();
+ if (!lv?.persona) { toast('This voice has no persona', 'error'); return; }
+ personaBtn.disabled = true;
+ personaBtn.textContent = 'Rewriting…';
+ try {
+ const llmUrl = localStorage.getItem('refine-llm-url') || 'http://localhost:11434/v1';
+ const r = await fetch('/api/rewrite-with-persona', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ text, persona: lv.persona, llm_url: llmUrl, mode:'rewrite' }) });
+ if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
+ const d = await r.json();
+ $('preview-text-area').value = d.text;
+ toast('Text rewritten in persona style', 'success');
+ } catch(e) { toast('Persona rewrite failed: ' + e.message, 'error'); }
+ finally { personaBtn.disabled = false; personaBtn.textContent = 'Rewrite with persona'; }
+ });
+ }
+ } else {
+ personaBtn?.remove();
+ }
}
async function synthesizeSelectedReferenceText() {
@@ -5651,22 +5715,36 @@ $('preview-text-area').addEventListener('focus', () => {
if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = '';
}, { once:true });
+function _onPreviewGenerated(source, voice, text, backend) {
+ if (typeof effectsSourceBlob !== 'undefined') window._effectsSourceBlob = null;
+ const ea = $('effects-apply-btn'); if (ea && source.blob) ea.disabled = false;
+ const ap = $('add-to-playlist-btn'); if (ap && source.blob) ap.disabled = false;
+ if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url);
+}
+
$('preview-btn').addEventListener('click', async () => {
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
if(!backend) { toast('No available TTS backend','error'); return; }
if(!voice) { toast('Select a TTS voice','error'); return; }
if(!text) { toast('Enter preview text','error'); return; }
$('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true;
+ if($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true;
+ if($('effects-apply-btn')) $('effects-apply-btn').disabled=true;
try {
const audio = $('preview-audio');
- const source = await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct);
+ const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
+ const source = useChunked
+ ? await generateChunkedTts(voice, text, backend, instruct)
+ : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct);
previewBlob = source.blob;
+ window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
audio.src = source.url;
audio.style.display='';
await audio.play();
$('save-preview-mp3-btn').disabled = false;
$('save-preview-btn').disabled = source.streaming;
- toast(source.streaming ? 'Streaming preview playing' : 'Preview playing', 'success');
+ _onPreviewGenerated(source, voice, text, backend);
+ toast(source.streaming ? 'Streaming preview playing' : source.label === 'chunked' ? `Chunked (${text.length} chars) playing` : 'Preview playing', 'success');
} catch(e) { toast('TTS failed: '+e.message,'error'); }
finally { $('preview-btn').disabled=false; }
});
@@ -6001,6 +6079,7 @@ $('stt-tts-transcribe-btn')?.addEventListener('click', async () => {
$('stt-tts-copy-preview-btn').disabled = !(d.text || '').trim();
const used = d.backend ? ' via ' + d.backend : '';
$('stt-tts-source-status').textContent = 'Transcription ready' + used + '.';
+ if (typeof updateRefineButtonState === 'function') updateRefineButtonState();
toast('Transcription ready', 'success');
} catch (e) {
$('stt-tts-source-status').textContent = 'Transcription failed.';
@@ -6665,3 +6744,413 @@ $('dc-refresh-btn')?.addEventListener('click', loadLocalContainers);
});
});
})();
+
+// ── 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 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);
+ return `
+
+ ${escHtml(item.voice)}
+ ${escHtml(item.backend)}
+ ${ts}
+
+
${preview}
+
+ ▶ Play
+ ↵ Reuse
+ + Playlist
+
+
`;
+ }).join('');
+
+ 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;
+ const saved = localStorage.getItem('refine-llm-url');
+ if (saved) inp.value = saved;
+ else inp.value = localStorage.getItem('llm-local-url-ollama') || 'http://localhost:11434/v1';
+ inp.addEventListener('input', () => localStorage.setItem('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() || 'http://localhost:11434/v1',
+ model: $('refine-model')?.value?.trim() || '',
+ 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');
+});
+
+// ── Voices import ──────────────────────────────────────────────────────────
+
+$('s-import-voices-file')?.addEventListener('change', async function () {
+ const file = this.files?.[0];
+ if (!file) return;
+ const st = $('s-import-status');
+ if (st) st.textContent = 'Uploading…';
+ try {
+ const fd = new FormData();
+ fd.append('file', file);
+ const r = await fetch('/api/voices/import', { method:'POST', body: fd });
+ if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
+ const d = await r.json();
+ if (st) st.textContent = `Imported ${d.imported} files.`;
+ toast(`Imported ${d.imported} voice files`, 'success');
+ this.value = '';
+ } catch(e) {
+ if (st) st.textContent = 'Import failed.';
+ toast('Import failed: ' + e.message, 'error');
+ }
+});
diff --git a/static/sections/s-settings.html b/static/sections/s-settings.html
index 0964cac..ee02a4b 100644
--- a/static/sections/s-settings.html
+++ b/static/sections/s-settings.html
@@ -271,5 +271,18 @@
Save settings
Reload settings
+
+
+
Backup & restore
+
Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.
+
+
diff --git a/static/sections/s-tryout.html b/static/sections/s-tryout.html
index 34e38cf..edfa67b 100644
--- a/static/sections/s-tryout.html
+++ b/static/sections/s-tryout.html
@@ -66,13 +66,99 @@
This is sent as instruct. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware.
+
+
+
+ Chunked generation
+ (long text)
+
+
▶ Generate & play
↓ Save MP3
↓ Save WAV
+ + Playlist
+
+
+
+
+
Audio effects
+
Apply post-processing to the last generated audio. Non-destructive — re-generate to reset.
+
+ Preset
+
+ — Custom —
+ Studio Reverb
+ Broadcast (compressed)
+ Telephone
+ Warm & Intimate
+ Radio Voice
+
+ Reset
+
+
+
+ Apply effects
+
+
+
+
+
+
+
Generation history
+
Last 20 generations this session. Audio is preserved in memory until you reload.
+
+
+ Clear history
+
+
+
+
+
+
Playlist
+
Queue clips from history. Reorder by dragging, then export as one merged WAV.
+
+
No clips in playlist. Use + Playlist after generating.
+
+
+ ↓ Export merged WAV
+ Clear
+
+
+
@@ -118,6 +204,34 @@
+
+
+
Synthesize transcription
Choose a TTS backend and voice, then generate audio from the transcribed text.
diff --git a/static/style.css b/static/style.css
index dd55fa4..1fb2cb8 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1664,3 +1664,125 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.perf-stat { display: flex; align-items: center; gap: 5px; }
.perf-good { color: var(--green); }
.perf-slow { color: var(--yellow); }
+
+/* ── Chunked TTS toggle ──────────────────────────────────────────────────── */
+.chunk-toggle-label {
+ display: inline-flex; align-items: center; gap: 6px;
+ font-size: 13px; color: var(--subtext); cursor: pointer; user-select: none;
+}
+.chunk-toggle-label input { cursor: pointer; }
+.chunk-progress {
+ font-size: 12px; color: var(--accent); padding: 6px 0 0;
+ display: flex; align-items: center; gap: 6px;
+}
+.chunk-progress::before { content: '⏳'; font-size: 13px; }
+
+/* ── Effects panel ───────────────────────────────────────────────────────── */
+.effects-preset-row {
+ display: flex; align-items: center; gap: 10px;
+ flex-wrap: wrap; margin-bottom: 16px;
+}
+.field-label-sm { font-size: 12px; font-weight: 600; color: var(--subtext); white-space: nowrap; }
+.effects-grid { display: flex; flex-direction: column; gap: 14px; }
+.fx-row { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
+.fx-toggle {
+ display: flex; align-items: center; gap: 6px;
+ font-size: 13px; font-weight: 600; color: var(--text);
+ min-width: 120px; flex-shrink: 0; cursor: pointer; user-select: none;
+ padding: 4px 0;
+}
+.fx-toggle input { cursor: pointer; }
+.fx-sliders {
+ display: flex; flex-wrap: wrap; gap: 8px 20px; flex: 1; min-width: 0;
+}
+.fx-sliders label {
+ display: flex; align-items: center; gap: 8px;
+ font-size: 12px; color: var(--subtext); white-space: nowrap;
+}
+.fx-sliders input[type=range] { width: 110px; cursor: pointer; }
+.fx-sliders span { font-size: 11px; color: var(--text); font-variant-numeric: tabular-nums; min-width: 44px; }
+
+/* ── History panel ───────────────────────────────────────────────────────── */
+.history-list { display: flex; flex-direction: column; gap: 8px; }
+.history-empty { font-size: 13px; color: var(--subtext); padding: 12px 0; }
+.history-item {
+ background: var(--panel); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 10px 12px;
+ display: flex; flex-direction: column; gap: 5px;
+ transition: border-color .15s;
+}
+.history-item:hover { border-color: rgba(37,99,235,.3); }
+.history-item-row1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.history-voice {
+ font-size: 12px; font-weight: 700; color: var(--accent);
+ background: rgba(37,99,235,.07); border: 1px solid rgba(37,99,235,.2);
+ border-radius: 4px; padding: 1px 7px;
+}
+.history-backend { font-size: 11px; color: var(--subtext); }
+.history-time { font-size: 11px; color: var(--subtext); margin-left: auto; font-variant-numeric: tabular-nums; }
+.history-item-text { font-size: 12px; color: var(--text); line-height: 1.4; }
+.history-item-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
+.btn-xs {
+ font-size: 11px; padding: 3px 9px; border-radius: 5px;
+}
+
+/* ── Playlist panel ──────────────────────────────────────────────────────── */
+.playlist-list { display: flex; flex-direction: column; gap: 6px; }
+.playlist-item {
+ display: flex; align-items: center; gap: 10px;
+ background: var(--panel); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 8px 12px;
+ transition: border-color .15s;
+}
+.playlist-item:hover { border-color: rgba(37,99,235,.3); }
+.playlist-num {
+ font-size: 12px; font-weight: 700; color: var(--subtext);
+ min-width: 18px; text-align: center;
+}
+.playlist-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
+.playlist-text { font-size: 12px; color: var(--subtext); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.playlist-item-actions { display: flex; gap: 4px; flex-shrink: 0; }
+
+/* ── Refine panel ────────────────────────────────────────────────────────── */
+.refine-config {
+ display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-start;
+ margin-bottom: 14px;
+}
+.refine-toggles {
+ display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 4px;
+}
+.refine-toggle {
+ display: inline-flex; align-items: center; gap: 6px;
+ font-size: 13px; color: var(--text); cursor: pointer; user-select: none;
+}
+.refine-toggle input { cursor: pointer; }
+
+/* ── Persona inspector panel ─────────────────────────────────────────────── */
+.insp-persona-panel {
+ background: var(--panel); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 14px 16px;
+ margin: 12px 0; display: flex; flex-direction: column; gap: 10px;
+}
+.insp-persona-head { display: flex; flex-direction: column; gap: 3px; }
+.insp-persona-title { font-size: 13px; font-weight: 700; color: var(--text); }
+.insp-persona-hint { font-size: 11px; color: var(--subtext); line-height: 1.4; }
+.insp-persona-input {
+ width: 100%; font-size: 13px; padding: 8px 10px;
+ border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--surface); color: var(--text);
+ font-family: var(--font); resize: vertical; min-height: 72px;
+ transition: border-color .15s;
+}
+.insp-persona-input:focus { border-color: var(--accent); outline: none; }
+.insp-persona-actions { display: flex; align-items: center; gap: 10px; }
+.insp-persona-status { font-size: 11px; color: var(--green); }
+
+/* ── Preview persona button ──────────────────────────────────────────────── */
+.preview-persona-btn {
+ font-size: 12px; padding: 4px 11px;
+ border: 1px solid var(--teal); color: var(--teal);
+ background: rgba(13,148,136,.06); border-radius: 5px;
+ cursor: pointer; font-family: var(--font); transition: background .15s;
+}
+.preview-persona-btn:hover { background: rgba(13,148,136,.12); }
+.preview-persona-btn:disabled { opacity: .5; cursor: default; }