From 21a219f816744d3690847b3a8f3ae014e1a78491 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 27 May 2026 02:01:31 +0200 Subject: [PATCH] Add Voicebox-inspired features: effects, history, chunked TTS, playlist, persona, STT refinement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Audio effects panel (reverb, compressor, chorus, pitch shift via Spotify pedalboard) with presets (Studio, Broadcast, Telephone, Warm, Radio) — /api/audio/effects endpoint - Generation history: last 20 sessions, play/reuse/add to playlist - Chunked TTS: sentence-boundary splitting for unlimited-length text + WAV merge - Playlist: ordered clip queue with up/down reorder + Export merged WAV - LLM STT refinement: clean transcripts with local LLM (remove fillers/repetitions/corrections) — /api/refine-text endpoint, configurable toggles, restore-original button - Voice persona: character description stored in voice meta.json, shown in inspector, "Rewrite with persona" button in TryOut via /api/rewrite-with-persona endpoint - Voices export/import: ZIP backup+restore via /api/voices/export + /api/voices/import - More whitespace, cleaner UI throughout all new panels Co-Authored-By: Claude Sonnet 4.6 --- server.py | 290 ++++++++++++++++++- static/app.js | 493 +++++++++++++++++++++++++++++++- static/sections/s-settings.html | 13 + static/sections/s-tryout.html | 114 ++++++++ static/style.css | 122 ++++++++ 5 files changed, 1029 insertions(+), 3 deletions(-) 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. +
+ +
+ + +
+ `; + 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}
+
+ + + +
+
`; + }).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 @@ + +
+

Backup & restore

+

Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.

+
+
+ ↓ Export voices + + +
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. +
+ +
+
+ + + +
+

Audio effects

+

Apply post-processing to the last generated audio. Non-destructive — re-generate to reset.

+
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+

Generation history

+

Last 20 generations this session. Audio is preserved in memory until you reload.

+
+
No generations yet.
+
+
+ +
+
+ + +
+

Playlist

+

Queue clips from history. Reorder by dragging, then export as one merged WAV.

+
+
No clips in playlist. Use + Playlist after generating.
+
+
+ + +
+
+ @@ -118,6 +204,34 @@ + + +
+

Refine with LLM

+

Clean up the transcription using a local language model — remove fillers, fix repetitions, and normalise punctuation.

+
+
+ + + OpenAI-compatible endpoint (Ollama, LM Studio, vLLM…) +
+
+ + +
+
+
+ + + + +
+
+ + + +
+

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; }