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 = 'None — select manually ' +
+ (window._voices || []).map(v => `${escHtml(v.display_name || v.name)} `).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 `${bars} `;
+}
+
(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 / Time Backend Voice
+ Avg latency Min Avg 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 @@
Try It Out
+
+ Benchmark
+
Setup
@@ -85,6 +88,7 @@
General
Connections
Playback
+
Captures
Payloads
Storage
API Keys
@@ -106,6 +110,7 @@
+
diff --git a/static/loader.js b/static/loader.js
index 2910927..7382d18 100644
--- a/static/loader.js
+++ b/static/loader.js
@@ -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) {
diff --git a/static/sections/s-connect.html b/static/sections/s-connect.html
index bc0f176..d3a3b8d 100644
--- a/static/sections/s-connect.html
+++ b/static/sections/s-connect.html
@@ -2,7 +2,7 @@
Connect Your Apps
-
Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, and more.
+
Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, MCP agents, and more.
@@ -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.
-
Copy Open WebUI sample
+
Copy Open WebUI config
Home Assistant
@@ -48,6 +48,16 @@
Copy virtual voice sample
+
+
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.
+
+
+ Copy MCP server
+ Copy Claude Code config
+
+
+
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.
+
+
+ Backend
+ Checking backends…
+
+
+
Voice
+
+ Fetch voices
+ — select after fetch —
+
+
+
+ Runs
+
+ 1 run
+ 3 runs
+ 5 runs
+ 10 runs
+
+
+
+
+ Sample text
+
+
+
+ Run benchmark
+ Clear results
+
+
+
+
+
+
+
Results
+
Latency per run. RTF = synthesis time ÷ audio duration (lower is better, <1.0 is real-time capable).
+
+
+ vs. previous session:
+
+
+
+
+
+
+
+
+
+ # Backend Voice
+ Latency (ms) Audio (s) RTF Status
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+ Show current backend & voice only
+
+ Clear history
+
+
+
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
+
+
+
+ Default language
+
+ Auto-detect
+ English
+ German
+ French
+ Spanish
+ Italian
+ Portuguese
+ Dutch
+ Russian
+ Japanese
+ Chinese
+ Hindi
+
+ Language hint passed to the STT backend. Auto-detect works well in most cases.
+
+
+ Preferred STT backend
+
+ Use active STT URL (from Connections)
+ Groq Whisper — cloud, fastest
+ faster-whisper-server — GPU local
+ whisper.cpp — CPU/CUDA local
+ NVIDIA Parakeet — GPU local
+
+ Overrides the active STT URL for STT-TTS panel captures.
+
+
+
+
+
+
+ LLM refinement defaults
+ Automatic text cleanup after transcription
+
+
+
+ Auto-refine after transcription
+
+ Off — show raw transcription
+ On — refine automatically
+
+ When on, the LLM refinement runs immediately after each capture.
+
+
+ Refinement model
+
+ 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
+
+
+
+ Default voice
+
+ None — select manually
+
+ Pre-selects this voice in the STT-TTS panel on load.
+
+
+
+
+
+ Save settings
+ Reload settings
+
+
+
+
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.
-
-
- Backend
- Checking backends...
-
-
-
Voice
-
- Fetch voices
- — select after fetch —
-
-
-
- Runs
-
- 1 run
- 3 runs
- 5 runs
- 10 runs
-
-
-
-
- Sample text
-
-
-
- Run benchmark
- Clear results
-
-
-
-
-
Results
-
Latency per run. RTF = synthesis time / audio duration (lower is better).
-
-
-
-
-
- # Backend Voice
- Latency (ms) Audio (s) RTF Status
-
-
-
-
-
-
-
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 {