diff --git a/server.py b/server.py index 779b787..04b01cf 100644 --- a/server.py +++ b/server.py @@ -316,6 +316,11 @@ _SETTINGS_KEYS = { "faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key", "tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend", + # Captures settings + "stt_language", "stt_preferred_backend", + "auto_refine", "refine_model", + "refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation", + "captures_default_voice", } @@ -497,6 +502,16 @@ def _load_settings() -> dict: "tts_stability_enabled": True, "tts_extra_params": _TTS_STABILITY_DEFAULT, "tts_extra_params_by_backend": _TTS_STABILITY_BY_BACKEND_DEFAULT, + # Captures + "stt_language": "", + "stt_preferred_backend": "", + "auto_refine": "off", + "refine_model": "", + "refine_fillers": True, + "refine_repetitions": True, + "refine_corrections": True, + "refine_punctuation": True, + "captures_default_voice": "", } if CONFIG_FILE.exists(): try: diff --git a/static/app.js b/static/app.js index 0a5511f..8195934 100644 --- a/static/app.js +++ b/static/app.js @@ -1188,6 +1188,59 @@ curl -s "${proxyV1}/audio/speech" \\ -H "Content-Type: application/json" \\ -d '{"model":"tts-1","voice":"${vdVoice}","input":"This line is generated through the VoiceDesign container.","response_format":"wav"}' \\ --output voicedesign-virtual.wav`; + + if ($('snippet-mcp-server')) $('snippet-mcp-server').textContent = +`#!/usr/bin/env python3 +"""MCP server — exposes TTS Voice Creator as tools for AI agents. +Install: pip install mcp httpx +Run: python3 tts_mcp_server.py +""" +import base64, httpx +from mcp.server.fastmcp import FastMCP + +BASE = "${proxyV1}" +mcp = FastMCP("tts-voice-creator") + +@mcp.tool() +async def list_voices() -> list: + """Return all active TTS voice IDs.""" + async with httpx.AsyncClient() as c: + r = await c.get(f"{BASE}/models") + return [m["id"] for m in r.json().get("data", [])] + +@mcp.tool() +async def speak(text: str, voice: str = "${voice}", format: str = "mp3") -> str: + """Synthesize text to speech. Returns base64-encoded audio bytes.""" + async with httpx.AsyncClient(timeout=60) as c: + r = await c.post(f"{BASE}/audio/speech", + json={"model": "tts-1", "voice": voice, + "input": text, "response_format": format}) + r.raise_for_status() + return base64.b64encode(r.content).decode() + +@mcp.tool() +async def transcribe(audio_b64: str, language: str = "") -> str: + """Transcribe base64-encoded audio to text via the configured STT endpoint.""" + audio = base64.b64decode(audio_b64) + async with httpx.AsyncClient(timeout=60) as c: + files = {"file": ("audio.wav", audio, "audio/wav")} + data = {"language": language} if language else {} + r = await c.post(f"{BASE}/audio/transcriptions", files=files, data=data) + r.raise_for_status() + return r.json().get("text", "") + +if __name__ == "__main__": + mcp.run()`; + + if ($('snippet-mcp-claude-config')) $('snippet-mcp-claude-config').textContent = +`{ + "mcpServers": { + "tts-voice-creator": { + "command": "python3", + "args": ["/path/to/tts_mcp_server.py"] + } + } +}`; } document.querySelectorAll('.copy-snippet').forEach(btn => btn.addEventListener('click', async () => { const el = $(btn.dataset.snippet); @@ -1948,6 +2001,22 @@ async function loadSettings() { $('s-output-dir').value = s.output_dir || ''; const themeEl = $('s-theme-select'); if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark'; + // Captures settings + const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || ''; + const sttPref = $('s-stt-preferred-backend'); if (sttPref) sttPref.value = s.stt_preferred_backend || ''; + const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off'; + const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || ''; + const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false; + const rfRep = $('s-refine-repetitions'); if (rfRep) rfRep.checked = s.refine_repetitions !== false; + const rfCorr = $('s-refine-corrections'); if (rfCorr) rfCorr.checked = s.refine_corrections !== false; + const rfPunc = $('s-refine-punctuation'); if (rfPunc) rfPunc.checked = s.refine_punctuation !== false; + // Default capture voice dropdown + const cvSel = $('s-captures-default-voice'); + if (cvSel && window._voices) { + const cur = s.captures_default_voice || ''; + cvSel.innerHTML = '' + + (window._voices || []).map(v => ``).join(''); + } await refreshTtsBackendAvailability(); renderSettingsAbout(); } @@ -2023,6 +2092,15 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b voice_design_api_key: $('s-vd-key').value, voices_scan_dir: $('s-voices-scan-dir').value, output_dir: $('s-output-dir').value, + stt_language: $('s-stt-language')?.value || '', + stt_preferred_backend: $('s-stt-preferred-backend')?.value || '', + auto_refine: $('s-auto-refine')?.value || 'off', + refine_model: $('s-refine-model')?.value || '', + refine_fillers: $('s-refine-fillers')?.checked ?? true, + refine_repetitions: $('s-refine-repetitions')?.checked ?? true, + refine_corrections: $('s-refine-corrections')?.checked ?? true, + refine_punctuation: $('s-refine-punctuation')?.checked ?? true, + captures_default_voice: $('s-captures-default-voice')?.value || '', }) }); _appSettings.tts_stream_url = $('s-tts-stream-url').value; _appSettings.customvoice_url = $('s-customvoice-url').value; @@ -5784,6 +5862,35 @@ $('save-preview-btn').addEventListener('click', () => { // ── Performance benchmark ───────────────────────────────────────────────── +const PERF_HISTORY_KEY = 'vcf-perf-history'; +const PERF_HISTORY_MAX = 50; + +function perfHistoryLoad() { + try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; } +} +function perfHistorySave(entries) { + try { localStorage.setItem(PERF_HISTORY_KEY, JSON.stringify(entries.slice(-PERF_HISTORY_MAX))); } catch(_) {} +} +function perfHistoryAdd(entry) { + const h = perfHistoryLoad(); + h.push(entry); + perfHistorySave(h); +} + +function perfSparklineSvg(rtfValues) { + if (!rtfValues.length) return ''; + const W = 120, H = 32, PAD = 2, barW = Math.max(4, Math.floor((W - PAD * 2) / rtfValues.length) - 1); + const maxV = Math.max(...rtfValues, 1); + const bars = rtfValues.map((v, i) => { + const bh = Math.max(3, Math.round((v / maxV) * (H - PAD * 2))); + const x = PAD + i * (barW + 1); + const y = H - PAD - bh; + const col = v < 1 ? 'var(--green)' : 'var(--yellow)'; + return ``; + }).join(''); + return ``; +} + (function initPerfBenchmark() { const perfBackendSel = $('perf-backend-select'); const perfVoiceSel = $('perf-voice-select'); @@ -5824,7 +5931,70 @@ $('save-preview-btn').addEventListener('click', () => { finally { perfFetchBtn.disabled = false; } }); - function renderPerfTable() { + function updateTrendDisplay(backend, voice, currentAvgRtf) { + const trendRow = $('perf-trend-row'); + const trendBadge = $('perf-trend-badge'); + const sparkWrap = $('perf-sparkline-wrap') || trendRow?.querySelector('.perf-sparkline-wrap'); + if (!trendRow) return; + const history = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number'); + if (history.length === 0) { trendRow.style.display = 'none'; return; } + const prevRtf = history[history.length - 1].avgRtf; + const delta = currentAvgRtf - prevRtf; + const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100; + let cls, label; + if (pct < 5) { cls = 'perf-trend-stable'; label = ' Stable'; } + else if (delta < 0) { cls = 'perf-trend-better'; label = ` ${pct.toFixed(0)}% faster`; } + else { cls = 'perf-trend-worse'; label = ` ${pct.toFixed(0)}% slower`; } + trendBadge.className = 'perf-trend-badge ' + cls; + trendBadge.innerHTML = label; + const rtfValues = [...history.slice(-9).map(e => e.avgRtf), currentAvgRtf]; + if (sparkWrap) sparkWrap.innerHTML = perfSparklineSvg(rtfValues); + trendRow.style.display = ''; + } + + function renderPerfHistory() { + const histList = $('perf-history-list'); + if (!histList) return; + const filterEl = $('perf-history-filter-current'); + const filterOn = filterEl?.checked; + const curBack = perfBackendSel?.value; + const curVoice = perfVoiceSel?.value; + let entries = perfHistoryLoad().slice().reverse(); + if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice); + if (!entries.length) { + histList.innerHTML = '
' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '
'; + return; + } + const head = `
+ Date / TimeBackendVoice + Avg latencyMinAvg RTF +
`; + const rows = entries.map((e, i) => { + const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); + const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow'; + return `
+ ${escHtml(dt)} + ${escHtml(e.backend)} + ${escHtml(e.voice)} + ${Math.round(e.avgLatencyMs)} ms + ${Math.round(e.minLatencyMs)} ms + ${e.avgRtf.toFixed(2)} + +
`; + }).join(''); + histList.innerHTML = head + rows; + + histList.querySelectorAll('.perf-history-del').forEach(btn => { + btn.addEventListener('click', () => { + const ts = Number(btn.dataset.ts); + const updated = perfHistoryLoad().filter(e => e.ts !== ts); + perfHistorySave(updated); + renderPerfHistory(); + }); + }); + } + + function renderPerfTable(sessionDone = false) { if (!perfRows.length) { perfResultsCard.style.display='none'; return; } perfResultsCard.style.display = ''; perfTbody.innerHTML = perfRows.map((r, i) => { @@ -5842,23 +6012,43 @@ $('save-preview-btn').addEventListener('click', () => { }).join(''); const ok = perfRows.filter(r => r.ok); if (ok.length) { - const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length; - const min = Math.min(...ok.map(r => r.latencyMs)); - const max = Math.max(...ok.map(r => r.latencyMs)); - const avgRtf = ok.filter(r=>r.audioDuration>0).reduce((s,r)=>s+(r.latencyMs/1000/r.audioDuration),0) / Math.max(1,ok.filter(r=>r.audioDuration>0).length); + const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length; + const minL = Math.min(...ok.map(r => r.latencyMs)); + const maxL = Math.max(...ok.map(r => r.latencyMs)); + const rtfArr = ok.filter(r=>r.audioDuration>0).map(r=>r.latencyMs/1000/r.audioDuration); + const avgRtf = rtfArr.length ? rtfArr.reduce((s,v)=>s+v,0)/rtfArr.length : 0; + const labelEl = $('perf-results-label'); + if (labelEl) labelEl.textContent = `${ok.length} run${ok.length>1?'s':''} — ${perfBackendSel.value} / ${perfVoiceSel.value}`; perfSummary.innerHTML = ` ${Math.round(avg)} ms avg latency - ${min} ms best - ${max} ms worst + ${minL} ms best + ${maxL} ms worst ${avgRtf.toFixed(2)} avg RTF ${avgRtf < 1 ? ' Real-time capable' : ' Slower than real-time'} `; + if (sessionDone && rtfArr.length) { + updateTrendDisplay(perfBackendSel.value, perfVoiceSel.value, avgRtf); + perfHistoryAdd({ + ts: Date.now(), + backend: perfBackendSel.value, + voice: perfVoiceSel.value, + textLen: perfText.value.trim().length, + avgLatencyMs: avg, + minLatencyMs: minL, + maxLatencyMs: maxL, + avgRtf, + runCount: ok.length, + allOk: ok.length === perfRows.length, + }); + renderPerfHistory(); + } } else { perfSummary.innerHTML = 'All runs failed'; } } perfClearBtn.addEventListener('click', () => { perfRows = []; renderPerfTable(); + if ($('perf-trend-row')) $('perf-trend-row').style.display = 'none'; perfProgress.style.display = 'none'; }); @@ -5870,6 +6060,7 @@ $('save-preview-btn').addEventListener('click', () => { if (!backend) { toast('Select a backend first', 'error'); return; } if (!voice) { toast('Fetch and select a voice first', 'error'); return; } if (!text) { toast('Enter sample text', 'error'); return; } + perfRows = []; perfRunBtn.disabled = true; perfProgress.style.display = ''; for (let i = 0; i < runs; i++) { @@ -5888,11 +6079,22 @@ $('save-preview-btn').addEventListener('click', () => { } catch(_) {} } catch(e) { row.error = e.message; } perfRows.push(row); - renderPerfTable(); + renderPerfTable(false); } perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`; + renderPerfTable(true); perfRunBtn.disabled = false; }); + + // History filter toggle + $('perf-history-filter-current')?.addEventListener('change', renderPerfHistory); + $('perf-history-clear-btn')?.addEventListener('click', () => { + perfHistorySave([]); + renderPerfHistory(); + toast('Benchmark history cleared', 'success'); + }); + + renderPerfHistory(); })(); // ── STT -> TTS ─────────────────────────────────────────────────────────── diff --git a/static/index.html b/static/index.html index a0f7422..41122b0 100644 --- a/static/index.html +++ b/static/index.html @@ -65,6 +65,9 @@ + @@ -26,9 +26,9 @@

Open WebUI

-

Configure TTS as OpenAI-compatible audio. Use the creator proxy if you want Routing rules such as incoming voice default mapped by language.

+

Enable TTS in Open WebUI under Settings → Audio. Set API base URL to this app's proxy and pick any active voice name. STT also works via the same proxy.

- +

Home Assistant

@@ -48,6 +48,16 @@
+
+

MCP — AI agents

+

Expose TTS and STT as tools for Claude Code, Cursor, or any MCP-compatible agent. Copy the Python MCP server below, install it with pip install mcp httpx, then point your agent config at it.

+
+
+ + +
+
+

Streaming TTS

Use this when the target app can play audio progressively. For routed streaming, keep response format WAV and avoid before/after route sounds, otherwise the proxy must buffer before playback.

diff --git a/static/sections/s-performance.html b/static/sections/s-performance.html new file mode 100644 index 0000000..cf9b57b --- /dev/null +++ b/static/sections/s-performance.html @@ -0,0 +1,85 @@ +
+ +
+

Benchmark

+

Measure synthesis latency and real-time factor. Track trends across backends and voices over time.

+
+
+ + +
+

Run benchmark

+

Pick a backend and voice, set run count, then measure synthesis latency and real-time factor (RTF). RTF < 1.0 means the backend generates faster than real-time.

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

History

+

Last 50 benchmark sessions saved in your browser. Each row is one run session — click the backend/voice to pre-fill the form above.

+
+ + +
+
+
No benchmark history yet. Run a benchmark above to start tracking.
+
+
diff --git a/static/sections/s-settings.html b/static/sections/s-settings.html index b2470b4..1512e20 100644 --- a/static/sections/s-settings.html +++ b/static/sections/s-settings.html @@ -189,6 +189,127 @@
+ +
+
+
+

Captures

+

Default behaviour for STT transcription, LLM text refinement, and playback.

+
+ +
+
+ Transcription + Default language and preferred STT backend +
+
+
+ + + Language hint passed to the STT backend. Auto-detect works well in most cases. +
+
+ + + Overrides the active STT URL for STT-TTS panel captures. +
+
+
+ +
+
+ LLM refinement defaults + Automatic text cleanup after transcription +
+
+
+ + + When on, the LLM refinement runs immediately after each capture. +
+
+ + + Model name sent to the LLM URL. Leave empty to use the app's current default. +
+
+
+ + + + +
+
+ +
+
+ Default playback voice + Pre-selected voice in the STT-TTS panel +
+
+
+ + + Pre-selects this voice in the STT-TTS panel on load. +
+
+
+ +
+ + +
+
+
+
diff --git a/static/sections/s-tryout.html b/static/sections/s-tryout.html index 5502e6c..d7c4e8b 100644 --- a/static/sections/s-tryout.html +++ b/static/sections/s-tryout.html @@ -272,58 +272,3 @@
- - -
-
-

Performance benchmark

-

Measure synthesis latency and real-time factor for any backend and voice.

-
-
- - -
-
- -
- - -
-
-
- - -
-
-
- - -
-
- - -
- -
- -
diff --git a/static/style.css b/static/style.css index f2a03b5..688f88c 100644 --- a/static/style.css +++ b/static/style.css @@ -360,6 +360,14 @@ audio { width: 100%; } .settings-mini-actions { margin-top:4px; gap:8px; flex-wrap:wrap; } .settings-mini-actions button { min-height:32px; padding:6px 11px; font-size:12px; } +/* Captures toggle grid */ +.s-toggle-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px,1fr)); gap:10px; } +.s-toggle-row { display:flex; align-items:flex-start; gap:12px; cursor:pointer; padding:10px 12px; border:1px solid var(--border); border-radius:var(--radius); background:var(--panel); transition:border-color .15s; } +.s-toggle-row:hover { border-color:var(--accent); } +.s-toggle-row input[type="checkbox"] { margin-top:2px; flex-shrink:0; accent-color:var(--accent); width:16px; height:16px; cursor:pointer; } +.s-toggle-title { display:block; font-size:13px; font-weight:600; color:var(--text); margin-bottom:2px; } +.s-toggle-row .s-hint { display:block; margin:0; } + /* Page save/reload actions */ .s-page-actions { border-top:1px solid var(--border); padding-top:14px; display:flex; gap:10px; } .s-page-actions button { min-width:120px; } @@ -713,6 +721,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .integration-card p { font-size: 13px; color: var(--subtext); line-height: 1.5; margin: 0; } .integration-card pre { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 10px 12px; font-size: 12px; line-height: 1.45; overflow: auto; min-height: 118px; max-height: 360px; white-space: pre-wrap; } .integration-card code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: none; padding: 0; border-radius: 0; } +.integration-card-wide { border-left: 3px solid var(--accent); } +.integration-card-wide pre { max-height: 480px; } +.btn-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } /* ── Routing ────────────────────────────────────────────────────────────── */ .routing-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } @@ -1705,6 +1716,24 @@ 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); } +.perf-trend-row { display: flex; align-items: center; gap: 10px; padding: 6px 0 2px; flex-wrap: wrap; font-size: 13px; } +.perf-trend-label { color: var(--subtext); } +.perf-trend-badge { display: inline-flex; align-items: center; gap: 4px; font-weight: 600; padding: 2px 8px; border-radius: 12px; font-size: 12px; } +.perf-trend-better { background: rgba(var(--green-rgb,72,199,116),.15); color: var(--green); } +.perf-trend-worse { background: rgba(var(--red-rgb,255,91,91),.15); color: var(--red); } +.perf-trend-stable { background: var(--panel); color: var(--subtext); } +.perf-sparkline-wrap { display: flex; align-items: center; } +.perf-sparkline { width: 120px; height: 32px; display: block; } +.bench-history-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } +.bench-history-filter { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--subtext); cursor: pointer; } +.perf-history-list { display: flex; flex-direction: column; gap: 0; } +.perf-history-empty { font-size: 13px; color: var(--subtext); padding: 16px 0; } +.perf-history-row { display: grid; grid-template-columns: 120px 1fr 1fr 80px 70px 70px 80px; gap: 0 10px; padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 12px; align-items: center; } +.perf-history-row:last-child { border-bottom: none; } +.perf-history-head { font-size: 11px; color: var(--subtext); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; } +.perf-history-ts { color: var(--subtext); font-size: 11px; } +.perf-history-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; } +.perf-history-del:hover { color: var(--red); } /* ── Chunked TTS toggle ──────────────────────────────────────────────────── */ .chunk-toggle-label {