Add Benchmark page, MCP snippets, Captures settings, and history tracking
- New s-performance section: dedicated Benchmark nav entry with run form, per-session results table, RTF trend badge (faster/slower/stable), SVG sparkline chart, and a History card backed by localStorage (last 50 sessions) - Performance tab removed from Try It Out; element IDs unchanged so JS works - renderIntegrationSnippets: adds Python MCP server + Claude Code .mcp.json config snippets to the Connect Apps page (integration-card-wide styling) - Save handler: persists all Captures settings fields (stt_language, stt_preferred_backend, auto_refine, refine_model, refine_* toggles, captures_default_voice) alongside existing settings - CSS: integration-card-wide accent border, benchmark history rows, trend badges, sparkline wrapper, bench-history-toolbar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a72e49b807
commit
dc47aa0431
15
server.py
15
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:
|
||||
|
||||
218
static/app.js
218
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 = '<option value="">None — select manually</option>' +
|
||||
(window._voices || []).map(v => `<option value="${escHtml(v.name)}" ${v.name === cur ? 'selected' : ''}>${escHtml(v.display_name || v.name)}</option>`).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 `<rect x="${x}" y="${y}" width="${barW}" height="${bh}" rx="1" fill="${col}" opacity=".8"/>`;
|
||||
}).join('');
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`;
|
||||
}
|
||||
|
||||
(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 = '<span class="mdi mdi-minus"></span> Stable'; }
|
||||
else if (delta < 0) { cls = 'perf-trend-better'; label = `<span class="mdi mdi-arrow-down"></span> ${pct.toFixed(0)}% faster`; }
|
||||
else { cls = 'perf-trend-worse'; label = `<span class="mdi mdi-arrow-up"></span> ${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 = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>';
|
||||
return;
|
||||
}
|
||||
const head = `<div class="perf-history-row perf-history-head">
|
||||
<span>Date / Time</span><span>Backend</span><span>Voice</span>
|
||||
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span>
|
||||
</div>`;
|
||||
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 `<div class="perf-history-row" data-idx="${i}">
|
||||
<span class="perf-history-ts">${escHtml(dt)}</span>
|
||||
<span>${escHtml(e.backend)}</span>
|
||||
<span>${escHtml(e.voice)}</span>
|
||||
<span>${Math.round(e.avgLatencyMs)} ms</span>
|
||||
<span>${Math.round(e.minLatencyMs)} ms</span>
|
||||
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
|
||||
<span class="perf-history-del" data-ts="${e.ts}" title="Remove this entry"><span class="mdi mdi-close"></span></span>
|
||||
</div>`;
|
||||
}).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 = `
|
||||
<span class="perf-stat"><strong>${Math.round(avg)} ms</strong> avg latency</span>
|
||||
<span class="perf-stat"><strong>${min} ms</strong> best</span>
|
||||
<span class="perf-stat"><strong>${max} ms</strong> worst</span>
|
||||
<span class="perf-stat"><strong>${minL} ms</strong> best</span>
|
||||
<span class="perf-stat"><strong>${maxL} ms</strong> worst</span>
|
||||
<span class="perf-stat"><strong>${avgRtf.toFixed(2)}</strong> avg RTF</span>
|
||||
<span class="perf-stat ${avgRtf < 1 ? 'perf-good' : 'perf-slow'}">${avgRtf < 1 ? '<span class="mdi mdi-check-circle-outline"></span> Real-time capable' : '<span class="mdi mdi-alert-outline"></span> Slower than real-time'}</span>
|
||||
`;
|
||||
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 = '<span class="perf-err">All runs failed</span>'; }
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -65,6 +65,9 @@
|
||||
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')">
|
||||
<span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out
|
||||
</div>
|
||||
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')">
|
||||
<span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark
|
||||
</div>
|
||||
|
||||
<div class="nav-group-label" style="margin-top:6px">Setup</div>
|
||||
<div class="nav-item" data-nav-section="s-llms" onclick="navTo('s-llms')">
|
||||
@ -85,6 +88,7 @@
|
||||
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')">General</div>
|
||||
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')">Connections</div>
|
||||
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')">Playback</div>
|
||||
<div class="nav-tree-item" data-settings-cat="captures" onclick="navSettingsCat('captures')">Captures</div>
|
||||
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')">Payloads</div>
|
||||
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')">Storage</div>
|
||||
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')">API Keys</div>
|
||||
@ -106,6 +110,7 @@
|
||||
<section class="page-section" id="s-design"></section>
|
||||
<section class="page-section" id="s-studio"></section>
|
||||
<section class="page-section" id="s-tryout"></section>
|
||||
<section class="page-section" id="s-performance"></section>
|
||||
<section class="page-section" id="s-routing"></section>
|
||||
<section class="page-section" id="s-connect"></section>
|
||||
<section class="page-section" id="s-settings"></section>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings', 's-llms'];
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms'];
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<span class="section-icon"><span class="mdi mdi-api"></span></span>
|
||||
<div class="section-title">
|
||||
<h2>Connect Your Apps</h2>
|
||||
<p>Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, and more.</p>
|
||||
<p>Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, MCP agents, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -26,9 +26,9 @@
|
||||
</div>
|
||||
<div class="integration-card">
|
||||
<h3>Open WebUI</h3>
|
||||
<p>Configure TTS as OpenAI-compatible audio. Use the creator proxy if you want Routing rules such as incoming voice <code>default</code> mapped by language.</p>
|
||||
<p>Enable TTS in Open WebUI under <strong>Settings → Audio</strong>. Set API base URL to this app's proxy and pick any active voice name. STT also works via the same proxy.</p>
|
||||
<pre><code id="snippet-open-webui"></code></pre>
|
||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-open-webui">Copy Open WebUI sample</button>
|
||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-open-webui">Copy Open WebUI config</button>
|
||||
</div>
|
||||
<div class="integration-card">
|
||||
<h3>Home Assistant</h3>
|
||||
@ -48,6 +48,16 @@
|
||||
<pre><code id="snippet-voice-design-proxy"></code></pre>
|
||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-voice-design-proxy">Copy virtual voice sample</button>
|
||||
</div>
|
||||
<div class="integration-card integration-card-wide">
|
||||
<h3><span class="mdi mdi-robot-outline"></span> MCP — AI agents</h3>
|
||||
<p>Expose TTS and STT as tools for Claude Code, Cursor, or any MCP-compatible agent. Copy the Python MCP server below, install it with <code>pip install mcp httpx</code>, then point your agent config at it.</p>
|
||||
<pre><code id="snippet-mcp-server"></code></pre>
|
||||
<div class="btn-row" style="gap:8px;flex-wrap:wrap">
|
||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-mcp-server">Copy MCP server</button>
|
||||
<button class="btn-secondary copy-snippet" data-snippet="snippet-mcp-claude-config">Copy Claude Code config</button>
|
||||
</div>
|
||||
<pre style="margin-top:10px"><code id="snippet-mcp-claude-config"></code></pre>
|
||||
</div>
|
||||
<div class="integration-card">
|
||||
<h3>Streaming TTS</h3>
|
||||
<p>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.</p>
|
||||
|
||||
85
static/sections/s-performance.html
Normal file
85
static/sections/s-performance.html
Normal file
@ -0,0 +1,85 @@
|
||||
<div class="section-head">
|
||||
<span class="section-icon"><span class="mdi mdi-speedometer"></span></span>
|
||||
<div class="section-title">
|
||||
<h2>Benchmark</h2>
|
||||
<p>Measure synthesis latency and real-time factor. Track trends across backends and voices over time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run benchmark -->
|
||||
<div class="card">
|
||||
<h2>Run benchmark</h2>
|
||||
<p class="card-subtitle">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.</p>
|
||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||
<div class="field">
|
||||
<label>Backend</label>
|
||||
<select id="perf-backend-select"><option value="">Checking backends…</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Voice</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-secondary" id="perf-fetch-voices-btn" type="button">Fetch voices</button>
|
||||
<select id="perf-voice-select"><option value="">— select after fetch —</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Runs</label>
|
||||
<select id="perf-runs">
|
||||
<option value="1">1 run</option>
|
||||
<option value="3" selected>3 runs</option>
|
||||
<option value="5">5 runs</option>
|
||||
<option value="10">10 runs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Sample text</label>
|
||||
<textarea id="perf-text" rows="3">Hello, how are you today? Please read this sample clearly for a fair voice benchmark.</textarea>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" id="perf-run-btn" type="button"><span class="mdi mdi-play"></span> Run benchmark</button>
|
||||
<button class="btn-secondary" id="perf-clear-btn" type="button">Clear results</button>
|
||||
</div>
|
||||
<div id="perf-progress" class="note" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Current results -->
|
||||
<div class="card" id="perf-results-card" style="display:none">
|
||||
<h2>Results <span id="perf-results-label" class="s-label-note"></span></h2>
|
||||
<p class="card-subtitle">Latency per run. RTF = synthesis time ÷ audio duration (lower is better, <1.0 is real-time capable).</p>
|
||||
<div class="perf-summary" id="perf-summary"></div>
|
||||
<div class="perf-trend-row" id="perf-trend-row" style="display:none">
|
||||
<span class="perf-trend-label">vs. previous session:</span>
|
||||
<span id="perf-trend-badge" class="perf-trend-badge"></span>
|
||||
<span class="perf-sparkline-wrap" title="RTF history for this backend/voice (oldest → newest)">
|
||||
<svg id="perf-sparkline" class="perf-sparkline" viewBox="0 0 120 32"></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="perf-table-wrap">
|
||||
<table class="perf-table" id="perf-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Backend</th><th>Voice</th>
|
||||
<th>Latency (ms)</th><th>Audio (s)</th><th>RTF</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="perf-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="card" id="perf-history-card">
|
||||
<h2>History</h2>
|
||||
<p class="card-subtitle">Last 50 benchmark sessions saved in your browser. Each row is one run session — click the backend/voice to pre-fill the form above.</p>
|
||||
<div class="bench-history-toolbar">
|
||||
<label class="bench-history-filter">
|
||||
<input type="checkbox" id="perf-history-filter-current">
|
||||
Show current backend & voice only
|
||||
</label>
|
||||
<button class="btn-secondary btn-sm" id="perf-history-clear-btn" type="button">Clear history</button>
|
||||
</div>
|
||||
<div id="perf-history-list" class="perf-history-list">
|
||||
<div class="perf-history-empty">No benchmark history yet. Run a benchmark above to start tracking.</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -189,6 +189,127 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Captures ────────────────────────────────────────────── -->
|
||||
<div class="s-settings-page" data-page="captures">
|
||||
<div class="card">
|
||||
<div class="s-page-head">
|
||||
<h3>Captures</h3>
|
||||
<p>Default behaviour for STT transcription, LLM text refinement, and playback.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-group">
|
||||
<div class="s-group-head">
|
||||
<strong>Transcription</strong>
|
||||
<span>Default language and preferred STT backend</span>
|
||||
</div>
|
||||
<div class="settings-grid settings-behavior-grid">
|
||||
<div class="s-field">
|
||||
<label>Default language</label>
|
||||
<select id="s-stt-language">
|
||||
<option value="">Auto-detect</option>
|
||||
<option value="en">English</option>
|
||||
<option value="de">German</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="it">Italian</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="nl">Dutch</option>
|
||||
<option value="ru">Russian</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="zh">Chinese</option>
|
||||
<option value="hi">Hindi</option>
|
||||
</select>
|
||||
<span class="s-hint">Language hint passed to the STT backend. Auto-detect works well in most cases.</span>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label>Preferred STT backend</label>
|
||||
<select id="s-stt-preferred-backend">
|
||||
<option value="">Use active STT URL (from Connections)</option>
|
||||
<option value="groq">Groq Whisper — cloud, fastest</option>
|
||||
<option value="faster-whisper">faster-whisper-server — GPU local</option>
|
||||
<option value="whisper-cpp">whisper.cpp — CPU/CUDA local</option>
|
||||
<option value="parakeet">NVIDIA Parakeet — GPU local</option>
|
||||
</select>
|
||||
<span class="s-hint">Overrides the active STT URL for STT-TTS panel captures.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-group">
|
||||
<div class="s-group-head">
|
||||
<strong>LLM refinement defaults</strong>
|
||||
<span>Automatic text cleanup after transcription</span>
|
||||
</div>
|
||||
<div class="settings-grid settings-behavior-grid">
|
||||
<div class="s-field">
|
||||
<label>Auto-refine after transcription</label>
|
||||
<select id="s-auto-refine">
|
||||
<option value="off">Off — show raw transcription</option>
|
||||
<option value="on">On — refine automatically</option>
|
||||
</select>
|
||||
<span class="s-hint">When on, the LLM refinement runs immediately after each capture.</span>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label>Refinement model</label>
|
||||
<input type="text" id="s-refine-model" placeholder="e.g. llama3.2, qwen3:8b, gpt-4o-mini">
|
||||
<span class="s-hint">Model name sent to the LLM URL. Leave empty to use the app's current default.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-toggle-grid">
|
||||
<label class="s-toggle-row">
|
||||
<input type="checkbox" id="s-refine-fillers">
|
||||
<div>
|
||||
<span class="s-toggle-title">Remove filler words</span>
|
||||
<span class="s-hint">Strips um, uh, like, you know, sort of.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="s-toggle-row">
|
||||
<input type="checkbox" id="s-refine-repetitions">
|
||||
<div>
|
||||
<span class="s-toggle-title">Remove repetitions</span>
|
||||
<span class="s-hint">Collapses repeated words and phrases.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="s-toggle-row">
|
||||
<input type="checkbox" id="s-refine-corrections">
|
||||
<div>
|
||||
<span class="s-toggle-title">Fix grammar & spelling</span>
|
||||
<span class="s-hint">Light corrections without changing meaning.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="s-toggle-row">
|
||||
<input type="checkbox" id="s-refine-punctuation">
|
||||
<div>
|
||||
<span class="s-toggle-title">Fix punctuation</span>
|
||||
<span class="s-hint">Adds missing commas, periods, and sentence casing.</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-group">
|
||||
<div class="s-group-head">
|
||||
<strong>Default playback voice</strong>
|
||||
<span>Pre-selected voice in the STT-TTS panel</span>
|
||||
</div>
|
||||
<div class="settings-grid settings-behavior-grid">
|
||||
<div class="s-field">
|
||||
<label>Default voice</label>
|
||||
<select id="s-captures-default-voice">
|
||||
<option value="">None — select manually</option>
|
||||
</select>
|
||||
<span class="s-hint">Pre-selects this voice in the STT-TTS panel on load.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-page-actions">
|
||||
<button class="btn-primary s-save-btn">Save settings</button>
|
||||
<button class="btn-secondary s-reload-btn">Reload settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Payloads ─────────────────────────────────────────────── -->
|
||||
<div class="s-settings-page" data-page="payloads">
|
||||
<div class="card">
|
||||
|
||||
@ -272,58 +272,3 @@
|
||||
<audio id="stt-tts-output-audio" controls style="display:none"></audio>
|
||||
</div>
|
||||
</div><!-- /tab-stt-tts -->
|
||||
|
||||
<!-- Performance benchmarking -->
|
||||
<div class="tab-content" id="tab-performance">
|
||||
<div class="card">
|
||||
<h2>Performance benchmark</h2>
|
||||
<p class="card-subtitle">Measure synthesis latency and real-time factor for any backend and voice.</p>
|
||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||
<div class="field">
|
||||
<label>Backend</label>
|
||||
<select id="perf-backend-select"><option value="">Checking backends...</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Voice</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-secondary" id="perf-fetch-voices-btn">Fetch voices</button>
|
||||
<select id="perf-voice-select"><option value="">— select after fetch —</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Runs</label>
|
||||
<select id="perf-runs">
|
||||
<option value="1">1 run</option>
|
||||
<option value="3" selected>3 runs</option>
|
||||
<option value="5">5 runs</option>
|
||||
<option value="10">10 runs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Sample text</label>
|
||||
<textarea id="perf-text" rows="3">Hello, how are you today? Please read this sample clearly for a fair voice benchmark.</textarea>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" id="perf-run-btn"><span class="mdi mdi-play"></span> Run benchmark</button>
|
||||
<button class="btn-secondary" id="perf-clear-btn">Clear results</button>
|
||||
</div>
|
||||
<div id="perf-progress" class="note" style="display:none"></div>
|
||||
</div>
|
||||
<div class="card" id="perf-results-card" style="display:none">
|
||||
<h2>Results</h2>
|
||||
<p class="card-subtitle">Latency per run. RTF = synthesis time / audio duration (lower is better).</p>
|
||||
<div class="perf-summary" id="perf-summary"></div>
|
||||
<div class="perf-table-wrap">
|
||||
<table class="perf-table" id="perf-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Backend</th><th>Voice</th>
|
||||
<th>Latency (ms)</th><th>Audio (s)</th><th>RTF</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="perf-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-performance -->
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user