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:
parent
08a63ef2d9
commit
21a219f816
290
server.py
290
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")
|
||||
|
||||
493
static/app.js
493
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 = `
|
||||
<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');
|
||||
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>`;
|
||||
@ -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 = '<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'}>▶ Play</button>
|
||||
<button class="btn-secondary btn-xs hist-reuse-btn">↵ Reuse</button>
|
||||
<button class="btn-secondary btn-xs hist-playlist-btn" ${item.blob?'':'disabled'}>+ 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':''}>↑</button>
|
||||
<button class="btn-secondary btn-xs pl-dn" ${i===_playlist.length-1?'disabled':''}>↓</button>
|
||||
<button class="btn-secondary btn-xs pl-rm">✕</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');
|
||||
}
|
||||
});
|
||||
|
||||
@ -271,5 +271,18 @@
|
||||
<button class="btn-primary" id="s-save-btn">Save settings</button>
|
||||
<button class="btn-secondary" id="s-close-btn">Reload settings</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section-head" style="margin-top:28px">
|
||||
<h3>Backup & 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>↓ Export voices</a>
|
||||
<label class="btn-secondary" style="cursor:pointer" title="Import a voices ZIP">
|
||||
↑ 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><!-- /tab-settings -->
|
||||
|
||||
@ -66,13 +66,99 @@
|
||||
<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>
|
||||
</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">
|
||||
<button class="btn-primary" id="preview-btn">▶ Generate & play</button>
|
||||
<button class="btn-secondary" id="save-preview-mp3-btn" disabled>↓ Save MP3</button>
|
||||
<button class="btn-secondary" id="save-preview-btn" disabled>↓ Save WAV</button>
|
||||
<button class="btn-secondary" id="add-to-playlist-btn" disabled>+ Playlist</button>
|
||||
</div>
|
||||
<audio id="preview-audio" controls style="display:none"></audio>
|
||||
<div id="preview-chunk-progress" class="chunk-progress" hidden></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 & 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>↓ Export merged WAV</button>
|
||||
<button class="btn-secondary btn-sm" id="playlist-clear-btn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /tab-generation -->
|
||||
|
||||
<!-- STT -> TTS -->
|
||||
@ -118,6 +204,34 @@
|
||||
<textarea id="stt-tts-text" placeholder="Transcribed text appears here. Edit before synthesis if needed."></textarea>
|
||||
</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…)</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>✨ 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">
|
||||
<h2>Synthesize transcription</h2>
|
||||
<p class="card-subtitle">Choose a TTS backend and voice, then generate audio from the transcribed text.</p>
|
||||
|
||||
122
static/style.css
122
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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user