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:
mARTin-B78 2026-05-27 09:57:55 +02:00
parent a72e49b807
commit dc47aa0431
9 changed files with 479 additions and 67 deletions

View File

@ -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:

View File

@ -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 ───────────────────────────────────────────────────────────

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View 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 &lt; 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, &lt;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 &amp; 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>

View File

@ -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 &amp; 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">

View File

@ -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 -->

View File

@ -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 {