Add Voicebox-inspired features: effects, history, chunked TTS, playlist, persona, STT refinement

- 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-27 02:01:31 +02:00
parent 08a63ef2d9
commit 21a219f816
5 changed files with 1029 additions and 3 deletions

290
server.py
View File

@ -2106,7 +2106,7 @@ async def update_voice_meta(request: Request):
meta = _load_meta(wav) meta = _load_meta(wav)
meta["enabled"] = enabled meta["enabled"] = enabled
for field in ("note", "rating", "flag", "gender", "loudness"): for field in ("note", "rating", "flag", "gender", "loudness", "persona"):
if field in data: if field in data:
meta[field] = data[field] meta[field] = data[field]
if "transcript" in data: if "transcript" in data:
@ -4422,6 +4422,294 @@ async def openai_speech_proxy_v1_shortcut(request: Request):
return await openai_speech_proxy(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 ──────────────────────────────────────────────────────────────────── # ── Static ────────────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

View File

@ -355,6 +355,37 @@ function selectVoice(wrap) {
if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e)); 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 = `
<div class="insp-persona-head">
<span class="insp-persona-title">Character persona</span>
<span class="insp-persona-hint">Used by LLM to rewrite text in this voice's style. Leave empty to skip.</span>
</div>
<textarea class="insp-persona-input" rows="3" placeholder="e.g. calm British narrator with a warm tone, measured pace, formal vocabulary…">${escHtml(personaVal)}</textarea>
<div class="insp-persona-actions">
<button class="btn-primary btn-sm insp-persona-save-btn">Save persona</button>
<span class="insp-persona-status"></span>
</div>
`;
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'); const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
if (maintTitle) { if (maintTitle) {
maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`; maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
@ -5518,6 +5549,39 @@ function updatePreviewVoiceMatchPanel() {
audio.src = expected; audio.src = expected;
audio.dataset.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() { async function synthesizeSelectedReferenceText() {
@ -5651,22 +5715,36 @@ $('preview-text-area').addEventListener('focus', () => {
if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = ''; if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = '';
}, { once:true }); }, { 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 () => { $('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(); 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(!backend) { toast('No available TTS backend','error'); return; }
if(!voice) { toast('Select a TTS voice','error'); return; } if(!voice) { toast('Select a TTS voice','error'); return; }
if(!text) { toast('Enter preview text','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; $('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 { try {
const audio = $('preview-audio'); 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; previewBlob = source.blob;
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
audio.src = source.url; audio.src = source.url;
audio.style.display=''; audio.style.display='';
await audio.play(); await audio.play();
$('save-preview-mp3-btn').disabled = false; $('save-preview-mp3-btn').disabled = false;
$('save-preview-btn').disabled = source.streaming; $('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'); } } catch(e) { toast('TTS failed: '+e.message,'error'); }
finally { $('preview-btn').disabled=false; } 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(); $('stt-tts-copy-preview-btn').disabled = !(d.text || '').trim();
const used = d.backend ? ' via ' + d.backend : ''; const used = d.backend ? ' via ' + d.backend : '';
$('stt-tts-source-status').textContent = 'Transcription ready' + used + '.'; $('stt-tts-source-status').textContent = 'Transcription ready' + used + '.';
if (typeof updateRefineButtonState === 'function') updateRefineButtonState();
toast('Transcription ready', 'success'); toast('Transcription ready', 'success');
} catch (e) { } catch (e) {
$('stt-tts-source-status').textContent = 'Transcription failed.'; $('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 = '<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);
return `<div class="history-item" data-hid="${escHtml(item.id)}">
<div class="history-item-row1">
<span class="history-voice">${escHtml(item.voice)}</span>
<span class="history-backend">${escHtml(item.backend)}</span>
<span class="history-time">${ts}</span>
</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'}>&#9654; Play</button>
<button class="btn-secondary btn-xs hist-reuse-btn">&#8629; Reuse</button>
<button class="btn-secondary btn-xs hist-playlist-btn" ${item.blob?'':'disabled'}>&#43; Playlist</button>
</div>
</div>`;
}).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 = '<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':''}>&#8593;</button>
<button class="btn-secondary btn-xs pl-dn" ${i===_playlist.length-1?'disabled':''}>&#8595;</button>
<button class="btn-secondary btn-xs pl-rm">&#10005;</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;
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');
}
});

View File

@ -271,5 +271,18 @@
<button class="btn-primary" id="s-save-btn">Save settings</button> <button class="btn-primary" id="s-save-btn">Save settings</button>
<button class="btn-secondary" id="s-close-btn">Reload settings</button> <button class="btn-secondary" id="s-close-btn">Reload settings</button>
</div> </div>
<div class="settings-section-head" style="margin-top:28px">
<h3>Backup &amp; restore</h3>
<p class="s-hint" style="margin-top:4px">Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.</p>
</div>
<div class="btn-row" style="margin-top:12px;gap:10px;flex-wrap:wrap">
<a class="btn-secondary" id="s-export-voices-btn" href="/api/voices/export" download>&#8595; Export voices</a>
<label class="btn-secondary" style="cursor:pointer" title="Import a voices ZIP">
&#8593; Import voices
<input type="file" id="s-import-voices-file" accept=".zip" style="display:none">
</label>
<span class="note" id="s-import-status"></span>
</div>
</div> </div>
</div><!-- /tab-settings --> </div><!-- /tab-settings -->

View File

@ -66,13 +66,99 @@
<input type="text" id="preview-style-instruction" placeholder="Optional style/emotion instruction, e.g. speak slowly and calmly, excited tone"> <input type="text" id="preview-style-instruction" placeholder="Optional style/emotion instruction, e.g. speak slowly and calmly, excited tone">
<span class="note">This is sent as <code>instruct</code>. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware.</span> <span class="note">This is sent as <code>instruct</code>. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware.</span>
</div> </div>
<div class="btn-row">
<label class="chunk-toggle-label" title="Split long text at sentence boundaries and stitch chunks together">
<input type="checkbox" id="preview-chunked-toggle">
<span>Chunked generation</span>
<span class="note" style="margin-left:2px">(long text)</span>
</label>
</div>
<div class="btn-row"> <div class="btn-row">
<button class="btn-primary" id="preview-btn">&#9654; Generate &amp; play</button> <button class="btn-primary" id="preview-btn">&#9654; Generate &amp; play</button>
<button class="btn-secondary" id="save-preview-mp3-btn" disabled>&#8595; Save MP3</button> <button class="btn-secondary" id="save-preview-mp3-btn" disabled>&#8595; Save MP3</button>
<button class="btn-secondary" id="save-preview-btn" disabled>&#8595; Save WAV</button> <button class="btn-secondary" id="save-preview-btn" disabled>&#8595; Save WAV</button>
<button class="btn-secondary" id="add-to-playlist-btn" disabled>&#43; Playlist</button>
</div> </div>
<audio id="preview-audio" controls style="display:none"></audio> <audio id="preview-audio" controls style="display:none"></audio>
<div id="preview-chunk-progress" class="chunk-progress" hidden></div>
</div> </div>
<!-- Audio Effects -->
<div class="card" id="effects-card">
<h2>Audio effects</h2>
<p class="card-subtitle">Apply post-processing to the last generated audio. Non-destructive — re-generate to reset.</p>
<div class="effects-preset-row">
<label class="field-label-sm">Preset</label>
<select id="effects-preset">
<option value="">— Custom —</option>
<option value="studio">Studio Reverb</option>
<option value="broadcast">Broadcast (compressed)</option>
<option value="telephone">Telephone</option>
<option value="warm">Warm &amp; Intimate</option>
<option value="radio">Radio Voice</option>
</select>
<button class="btn-secondary btn-sm" id="effects-reset-btn">Reset</button>
</div>
<div class="effects-grid">
<div class="fx-row">
<label class="fx-toggle"><input type="checkbox" id="fx-reverb-on"> Reverb</label>
<div class="fx-sliders" id="fx-reverb-params">
<label>Room <input type="range" id="fx-reverb-room" min="0" max="1" step="0.01" value="0.35"><span id="fx-reverb-room-val">0.35</span></label>
<label>Wet <input type="range" id="fx-reverb-wet" min="0" max="1" step="0.01" value="0.25"><span id="fx-reverb-wet-val">0.25</span></label>
</div>
</div>
<div class="fx-row">
<label class="fx-toggle"><input type="checkbox" id="fx-compressor-on"> Compressor</label>
<div class="fx-sliders" id="fx-compressor-params">
<label>Threshold <input type="range" id="fx-comp-thresh" min="-60" max="0" step="1" value="-20"><span id="fx-comp-thresh-val">-20 dB</span></label>
<label>Ratio <input type="range" id="fx-comp-ratio" min="1" max="20" step="0.5" value="4"><span id="fx-comp-ratio-val">4:1</span></label>
</div>
</div>
<div class="fx-row">
<label class="fx-toggle"><input type="checkbox" id="fx-chorus-on"> Chorus</label>
<div class="fx-sliders" id="fx-chorus-params">
<label>Rate <input type="range" id="fx-chorus-rate" min="0.1" max="5" step="0.1" value="1"><span id="fx-chorus-rate-val">1.0 Hz</span></label>
<label>Mix <input type="range" id="fx-chorus-mix" min="0" max="1" step="0.01" value="0.5"><span id="fx-chorus-mix-val">0.50</span></label>
</div>
</div>
<div class="fx-row">
<label class="fx-toggle"><input type="checkbox" id="fx-pitch-on"> Pitch shift</label>
<div class="fx-sliders" id="fx-pitch-params">
<label>Semitones <input type="range" id="fx-pitch-semi" min="-12" max="12" step="0.5" value="0"><span id="fx-pitch-semi-val">0 st</span></label>
</div>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn-primary" id="effects-apply-btn" disabled>Apply effects</button>
<span class="note" id="effects-status"></span>
</div>
</div>
<!-- Generation History -->
<div class="card" id="history-card">
<h2>Generation history</h2>
<p class="card-subtitle">Last 20 generations this session. Audio is preserved in memory until you reload.</p>
<div id="history-list" class="history-list">
<div class="history-empty">No generations yet.</div>
</div>
<div class="btn-row" style="margin-top:8px">
<button class="btn-secondary btn-sm" id="history-clear-btn">Clear history</button>
</div>
</div>
<!-- Playlist -->
<div class="card" id="playlist-card">
<h2>Playlist</h2>
<p class="card-subtitle">Queue clips from history. Reorder by dragging, then export as one merged WAV.</p>
<div id="playlist-list" class="playlist-list">
<div class="history-empty">No clips in playlist. Use <strong>+ Playlist</strong> after generating.</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn-primary" id="playlist-export-btn" disabled>&#8595; Export merged WAV</button>
<button class="btn-secondary btn-sm" id="playlist-clear-btn">Clear</button>
</div>
</div>
</div><!-- /tab-generation --> </div><!-- /tab-generation -->
<!-- STT -> TTS --> <!-- STT -> TTS -->
@ -118,6 +204,34 @@
<textarea id="stt-tts-text" placeholder="Transcribed text appears here. Edit before synthesis if needed."></textarea> <textarea id="stt-tts-text" placeholder="Transcribed text appears here. Edit before synthesis if needed."></textarea>
</div> </div>
</div> </div>
<!-- LLM Refinement card -->
<div class="card" id="refine-card">
<h2>Refine with LLM</h2>
<p class="card-subtitle">Clean up the transcription using a local language model — remove fillers, fix repetitions, and normalise punctuation.</p>
<div class="refine-config">
<div class="field" style="flex:1;min-width:180px">
<label>LLM URL</label>
<input type="text" id="refine-llm-url" placeholder="http://localhost:11434/v1" spellcheck="false">
<span class="note">OpenAI-compatible endpoint (Ollama, LM Studio, vLLM&hellip;)</span>
</div>
<div class="field" style="flex:0 0 160px">
<label>Model <span style="font-weight:400">(optional)</span></label>
<input type="text" id="refine-model" placeholder="auto-detect">
</div>
</div>
<div class="refine-toggles">
<label class="refine-toggle"><input type="checkbox" id="refine-fillers" checked> Remove fillers</label>
<label class="refine-toggle"><input type="checkbox" id="refine-repetitions" checked> Fix repetitions</label>
<label class="refine-toggle"><input type="checkbox" id="refine-corrections" checked> Remove corrections</label>
<label class="refine-toggle"><input type="checkbox" id="refine-punctuation" checked> Fix punctuation</label>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn-primary" id="refine-btn" disabled>&#10024; Refine transcription</button>
<button class="btn-secondary" id="refine-restore-btn" disabled>Restore original</button>
<span class="note" id="refine-status"></span>
</div>
</div>
<div class="card"> <div class="card">
<h2>Synthesize transcription</h2> <h2>Synthesize transcription</h2>
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p> <p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>

View File

@ -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-stat { display: flex; align-items: center; gap: 5px; }
.perf-good { color: var(--green); } .perf-good { color: var(--green); }
.perf-slow { color: var(--yellow); } .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; }