feat(design): auto-pick the best seed by scored candidates

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 16:37:58 +02:00
parent a0cd7b867f
commit b36423457b
10 changed files with 433 additions and 4 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Seed Finder: Auto-pick best.** `POST /api/seed-autopick {voice, text, seeds|count}` (default 8, max 16) generates candidates sequentially through the existing temporary-voice path (voices.json is always restored), scores each 0-100 (ASR word accuracy, loudness/clipping, `audio_problems`) and returns all sub-scores plus the winner. Degrades to loudness/problems when STT is unavailable. The UI highlights the winner; saving stays a click on "Use seed". Tests: `tests/test_seed_autopick.py`, `tests/seed-autopick.spec.js`.
- **Voice Design: attribute chips.** Toggle chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion; German + English labels) above the design prompt add or remove only their own phrase in the free-text description. The textarea stays fully free-form. Test: `tests/design-chips.spec.js`. - **Voice Design: attribute chips.** Toggle chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion; German + English labels) above the design prompt add or remove only their own phrase in the free-text description. The textarea stays fully free-form. Test: `tests/design-chips.spec.js`.
- **Voice health: timestamped audio problems.** `core.audio.audio_problems` flags long interior silence, clipping and sudden level jumps (`problems` list with `kind`, `start_s`, `end_s`, `detail`, plus one warning per kind) in the route-test voice health; existing keys unchanged. Computed on demand and cached by file mtime, not in the voice list. Shown in the routing test result and in the Try-out voice panel (new `GET /api/voices/{id}/health`); imports store the result in meta (`audio_problems`). Tests: `tests/test_audio_problems.py`, `tests/voice-health.spec.js`. - **Voice health: timestamped audio problems.** `core.audio.audio_problems` flags long interior silence, clipping and sudden level jumps (`problems` list with `kind`, `start_s`, `end_s`, `detail`, plus one warning per kind) in the route-test voice health; existing keys unchanged. Computed on demand and cached by file mtime, not in the voice list. Shown in the routing test result and in the Try-out voice panel (new `GET /api/voices/{id}/health`); imports store the result in meta (`audio_problems`). Tests: `tests/test_audio_problems.py`, `tests/voice-health.spec.js`.

View File

@ -21,6 +21,16 @@ Living manual. Every feature updates this file in the same commit as the code.
**When computed:** on import (stored in the voice's meta as `audio_problems`), and fresh on demand via `GET /api/voices/{id}/health` (cached by file modification time). The voice list only returns the stored result. **When computed:** on import (stored in the voice's meta as `audio_problems`), and fresh on demand via `GET /api/voices/{id}/health` (cached by file modification time). The voice list only returns the stored result.
**Errors:** unreadable audio yields no problems; only the first 120 s are scanned. **Privacy/offline:** local analysis only. **Errors:** unreadable audio yields no problems; only the first 120 s are scanned. **Privacy/offline:** local analysis only.
## Seed Finder: Auto-pick best
**Purpose.** Finds a good seed for a voice without listening to every sample first.
**Prerequisites.** A voice with a reference clip (Voice Clone backend); a speech-to-text engine is optional.
**Steps.** Open the voice, then *Seed Finder*. Set the test sentence and the seed range (at most 16 seeds are tried), then click *Auto-pick best*. The server generates each candidate one after another, scores it and the best row is marked "Best". Play any row, then click *Use seed N* to save it. Nothing is saved automatically.
**Score (0-100).** Weighted mean of: spoken-back word accuracy (50%, text read back by speech-to-text), loudness and clipping (20%), audio problems such as long silences and sudden level jumps (30%). Weights and penalties are constants in `core/seed_score.py`.
**Errors.** If speech-to-text is down, the result says so and scores by loudness and audio problems only. A failed seed shows its error and is skipped. *Cancel* stops after the running candidate; the voice configuration is always restored.
**Mobile and accessibility.** Progress and the result are announced in a polite live region; the button is keyboard operable. German and English labels.
**Offline.** Needs the TTS server (and optionally the STT server) on your network. **Privacy.** The sentence is sent only to your own TTS/STT servers.
## Audiobook casting (Read Aloud → Cast as Audiobook) ## Audiobook casting (Read Aloud → Cast as Audiobook)
### Purpose ### Purpose

View File

@ -0,0 +1,6 @@
# Seed auto-pick
- `POST /api/seed-autopick` (routes/tts.py) validates input (`core/seed_score.seed_list`: explicit seeds 0-9999 or `count` 1-16, default 8), then for each seed calls `_seed_finder_request_audio` in a worker thread (shared `_SEED_FINDER_VOICE_LOCK`, voices.json restored in its `finally`), scores it in a second thread and moves on. Per-candidate timeout 240 s; a timeout ends the run (the worker still holds the lock until it restores). Client disconnect is checked between candidates (cancel = abort the fetch).
- Scoring: `core/seed_score.py` (constants at top, formula in the docstring). Sub-scores come from `routes.stt._transcribe_audio` + `_word_accuracy` (imported lazily), `core.audio._loudness_info`, `core.audio.audio_problems`. First STT failure disables ASR for the rest of the run (`degraded: true`, `stt.note`).
- Never saves. The UI confirms via the existing `/api/tts-voice-seed`.
- Limits: sequential, so 16 candidates take minutes; audio returns base64 in the JSON (about 0.5 MB each).

72
core/seed_score.py Normal file
View File

@ -0,0 +1,72 @@
"""Seed auto-pick scoring: combine ASR accuracy, loudness/clipping and audio problems into 0-100.
score = weighted mean of the available sub-scores (each 0-100). Without ASR the
accuracy weight is dropped and the rest re-normalised ("degraded" result).
accuracy = ASR round-trip word accuracy (routes.stt._word_accuracy)
loudness = 100 - |dBFS - target| * LOUD_PEN_PER_DB (capped) - CLIP_PEN if peak hits full scale
problems = 100 - sum of per-kind penalties from core.audio.audio_problems
"""
from __future__ import annotations
W_ACCURACY = 0.5
W_LOUDNESS = 0.2
W_PROBLEMS = 0.3
LOUD_PEN_PER_DB = 4.0 # points lost per dB away from the target level
LOUD_PEN_MAX = 60.0
CLIP_PEN = 30.0 # peak at/above CLIP_PEAK_DBFS
CLIP_PEAK_DBFS = -0.1
PROBLEM_PENALTY = {"silence": 25.0, "level_jump": 20.0, "clipping": 10.0}
PROBLEM_PENALTY_DEFAULT = 10.0
DEFAULT_COUNT = 8
MAX_COUNT = 16
def loudness_score(info: dict) -> float:
dbfs, peak, target = info.get("dbfs"), info.get("peak_dbfs"), info.get("target_dbfs", -20.0)
if dbfs is None:
return 0.0
s = 100.0 - min(LOUD_PEN_MAX, abs(dbfs - target) * LOUD_PEN_PER_DB)
if peak is not None and peak >= CLIP_PEAK_DBFS:
s -= CLIP_PEN
return max(0.0, s)
def problems_score(problems: list[dict]) -> float:
pen = sum(PROBLEM_PENALTY.get(p.get("kind"), PROBLEM_PENALTY_DEFAULT) for p in problems)
return max(0.0, 100.0 - pen)
def combine(accuracy: float | None, loudness: float, problems: float) -> float:
parts = [(W_LOUDNESS, loudness), (W_PROBLEMS, problems)]
if accuracy is not None:
parts.append((W_ACCURACY, accuracy))
return round(sum(w * v for w, v in parts) / sum(w for w, _ in parts), 1)
def pick_winner(candidates: list[dict]) -> int | None:
"""Seed with the highest score (ties: lowest seed); errored candidates are skipped."""
ok = [c for c in candidates if not c.get("error") and c.get("score") is not None]
return min(ok, key=lambda c: (-c["score"], c["seed"]))["seed"] if ok else None
def seed_list(seeds, count) -> list[int]:
"""Validate the request: explicit seeds win, else 1..count; bounded to MAX_COUNT."""
if seeds is not None:
if not isinstance(seeds, list) or not seeds:
raise ValueError("seeds must be a non-empty list")
out = []
for s in seeds:
if isinstance(s, bool) or not isinstance(s, int) or not 0 <= s <= 9999:
raise ValueError("seeds must be integers 0-9999")
if s not in out:
out.append(s)
if len(out) > MAX_COUNT:
raise ValueError(f"at most {MAX_COUNT} seeds")
return out
n = DEFAULT_COUNT if count is None else count
if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= MAX_COUNT:
raise ValueError(f"count must be 1-{MAX_COUNT}")
return list(range(1, n + 1))

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import io import io
import json import json
import logging import logging
@ -1314,6 +1315,92 @@ async def tts_voice_seed(request: Request):
raise HTTPException(e.response.status_code, detail or str(e)) raise HTTPException(e.response.status_code, detail or str(e))
_AUTOPICK_CANDIDATE_TIMEOUT_S = 240
def _autopick_score_audio(audio: bytes, text: str, settings: dict, stt_ok: bool) -> dict:
"""Blocking: write the sample to a temp WAV, run ASR (if available) + loudness + problems."""
from core.audio import _loudness_info, audio_problems
from core import seed_score as sc
tmp = TEMP_DIR / f"autopick_{uuid.uuid4().hex}.wav"
tmp.write_bytes(audio)
try:
accuracy, stt_error = None, None
if stt_ok:
from routes import stt as _stt # lazy: avoids import-order coupling
try:
transcript, _ = _stt._transcribe_audio(tmp, settings)
accuracy = _stt._word_accuracy(text, transcript)
except Exception as exc:
stt_error = str(exc)[:200]
loud = _loudness_info(tmp)
problems = audio_problems(tmp)
finally:
tmp.unlink(missing_ok=True)
loud_s, prob_s = sc.loudness_score(loud), sc.problems_score(problems)
return {
"score": sc.combine(accuracy, loud_s, prob_s),
"accuracy": None if accuracy is None else round(accuracy, 1),
"loudness_score": round(loud_s, 1), "problems_score": round(prob_s, 1),
"loudness": loud, "problems": problems, "stt_error": stt_error,
}
@router.post("/api/seed-autopick")
async def seed_autopick(request: Request):
"""Generate N seed candidates sequentially, score them, return all plus the winner.
Body: {"voice", "text", "seeds": [..] | "count": 1-16 (default 8)}. Never saves a seed;
the UI confirms via /api/tts-voice-seed. Stops early if the client disconnects.
"""
from core import seed_score as sc
data = await request.json()
if not isinstance(data, dict):
raise HTTPException(400, "JSON object expected")
voice, text = data.get("voice"), data.get("text")
if not isinstance(voice, str) or not voice.strip() or len(voice) > 200:
raise HTTPException(400, "voice required")
if not isinstance(text, str) or not text.strip() or len(text) > 1500:
raise HTTPException(400, "text required (max 1500 characters)")
try:
seeds = sc.seed_list(data.get("seeds"), data.get("count"))
except ValueError as exc:
raise HTTPException(400, str(exc))
settings = _load_settings()
stt_ok, stt_note, cancelled = True, "", False
candidates: list[dict] = []
for seed in seeds:
if await request.is_disconnected():
cancelled = True
break
cand: dict = {"seed": seed}
try:
audio, media_type = await asyncio.wait_for(
asyncio.to_thread(_seed_finder_request_audio, text.strip(), voice, settings, seed),
_AUTOPICK_CANDIDATE_TIMEOUT_S)
cand.update(await asyncio.to_thread(_autopick_score_audio, audio, text.strip(), settings, stt_ok))
if stt_ok and cand["accuracy"] is None:
stt_ok, stt_note = False, cand["stt_error"] or "speech-to-text unavailable"
cand["media_type"] = media_type
cand["audio_b64"] = base64.b64encode(audio).decode("ascii")
except asyncio.TimeoutError:
# The worker thread still holds the voices.json lock and restores it when done;
# starting another candidate now would only queue behind it.
candidates.append({**cand, "error": "timeout"})
break
except Exception as exc:
logger.warning("Seed auto-pick seed %s failed: %s", seed, exc)
cand["error"] = str(exc)[:200] or "generation failed"
candidates.append(cand)
return {
"voice": voice, "winner": sc.pick_winner(candidates), "candidates": candidates,
"requested": len(seeds), "cancelled": cancelled,
"stt": {"available": stt_ok, "note": stt_note},
"degraded": not stt_ok,
"weights": {"accuracy": sc.W_ACCURACY, "loudness": sc.W_LOUDNESS, "problems": sc.W_PROBLEMS},
}
@router.get("/api/seed-samples/{voice_name}") @router.get("/api/seed-samples/{voice_name}")
async def list_seed_samples(voice_name: str): async def list_seed_samples(voice_name: str):
"""List pre-generated seed sample numbers for a voice (batch script output).""" """List pre-generated seed sample numbers for a voice (batch script output)."""

File diff suppressed because one or more lines are too long

View File

@ -21,6 +21,22 @@ const I18N_LANGS = {
// English for languages that don't have it yet (see window.t / applyI18n). // English for languages that don't have it yet (see window.t / applyI18n).
const I18N_DICT = { const I18N_DICT = {
de: { de: {
// Seed Finder auto-pick
'Generates up to 8 candidates, scores them and highlights the best. Nothing is saved until you click Use seed.': 'Erzeugt bis zu 8 Kandidaten, bewertet sie und hebt den besten hervor. Gespeichert wird erst nach Klick auf „Seed verwenden“.',
'Auto-pick best': 'Besten automatisch wählen',
'Enter a test sentence.': 'Testsatz eingeben.',
'Scoring': 'Bewerte',
'candidates… this can take a few minutes.': 'Kandidaten … das kann einige Minuten dauern.',
'words': 'Wörter',
'audio problems': 'Audioprobleme',
'score': 'Wertung',
'level': 'Pegel',
'Best': 'Bester',
'Best seed': 'Bester Seed',
'Listen, then click Use seed to save.': 'Anhören, dann „Seed verwenden“ zum Speichern.',
'No candidate could be generated.': 'Es konnte kein Kandidat erzeugt werden.',
'Speech-to-text unavailable: scored by level and audio problems only.': 'Sprache-zu-Text nicht verfügbar: Wertung nur nach Pegel und Audioproblemen.',
'Auto-pick failed': 'Automatische Wahl fehlgeschlagen',
// Brand / sidebar groups // Brand / sidebar groups
'Voice Creator': 'Voice Creator', 'Voice Creator': 'Voice Creator',
'Clone · Design · Deploy': 'Klonen · Entwerfen · Bereitstellen', 'Clone · Design · Deploy': 'Klonen · Entwerfen · Bereitstellen',

View File

@ -21,6 +21,8 @@ const SEED_FINDER_TEXT_DE = 'Die dreitausendfünfhundertsiebenundsechzig neuen G
const SEED_FINDER_TEXT_EN = 'The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.'; const SEED_FINDER_TEXT_EN = 'The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.';
const SEED_FINDER_TEXT_TORTURE = 'Die 3.567 neuen High-End Geräte für das Server-Update benötigen eine außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance bei großer Last zu gewährleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Schöne Grüße! — Madonna - Träume beginnt mit einem positiven Mindset.'; const SEED_FINDER_TEXT_TORTURE = 'Die 3.567 neuen High-End Geräte für das Server-Update benötigen eine außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance bei großer Last zu gewährleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Schöne Grüße! — Madonna - Träume beginnt mit einem positiven Mindset.';
const _sfT = s => (typeof window.t === 'function' ? window.t(s) : s);
function _seedFinderDefaultText(voiceId) { function _seedFinderDefaultText(voiceId) {
if (window._appSettings && window._appSettings.seed_finder_text) { if (window._appSettings && window._appSettings.seed_finder_text) {
return window._appSettings.seed_finder_text; return window._appSettings.seed_finder_text;
@ -114,10 +116,12 @@ function attachSeedFinder(voiceId, body) {
</div> </div>
<div class="seed-finder-actions"> <div class="seed-finder-actions">
<button class="btn-primary seed-finder-run-btn">▶ Run Seed Finder</button> <button class="btn-primary seed-finder-run-btn">▶ Run Seed Finder</button>
<button class="btn-secondary seed-finder-autopick-btn" title="${_sfT('Generates up to 8 candidates, scores them and highlights the best. Nothing is saved until you click Use seed.')}">✨ ${_sfT('Auto-pick best')}</button>
<button class="btn-secondary seed-finder-cancel-btn" style="display:none">✕ Cancel</button> <button class="btn-secondary seed-finder-cancel-btn" style="display:none">✕ Cancel</button>
<button class="btn-secondary seed-finder-clear-btn" style="display:none" title="Delete the saved samples cached for this voice">🗑 Clear saved</button> <button class="btn-secondary seed-finder-clear-btn" style="display:none" title="Delete the saved samples cached for this voice">🗑 Clear saved</button>
<span class="seed-finder-status"></span> <span class="seed-finder-status"></span>
</div> </div>
<div class="seed-finder-autopick-status" role="status" aria-live="polite" style="min-height:1.2em"></div>
<div class="seed-finder-row seed-finder-pin"> <div class="seed-finder-row seed-finder-pin">
<label class="seed-finder-label">Or fix this voice to a specific seed</label> <label class="seed-finder-label">Or fix this voice to a specific seed</label>
<div class="seed-finder-pin-controls"> <div class="seed-finder-pin-controls">
@ -168,6 +172,8 @@ function attachSeedFinder(voiceId, body) {
const progCount = panel.querySelector('.seed-finder-progress-count'); const progCount = panel.querySelector('.seed-finder-progress-count');
const progBar = panel.querySelector('.seed-finder-bar'); const progBar = panel.querySelector('.seed-finder-bar');
const resultsEl = panel.querySelector('.seed-finder-results'); const resultsEl = panel.querySelector('.seed-finder-results');
const autoBtn = panel.querySelector('.seed-finder-autopick-btn');
const autoStatus = panel.querySelector('.seed-finder-autopick-status');
textEl.value = _seedFinderDefaultText(voiceId); textEl.value = _seedFinderDefaultText(voiceId);
@ -313,6 +319,7 @@ function attachSeedFinder(voiceId, body) {
if (data.genSec) parts.push(`gen ${data.genSec.toFixed(1)}s`); if (data.genSec) parts.push(`gen ${data.genSec.toFixed(1)}s`);
if (data.rtf) parts.push(`${data.rtf.toFixed(2)}x`); if (data.rtf) parts.push(`${data.rtf.toFixed(2)}x`);
if (data.cached) parts.push('saved'); if (data.cached) parts.push('saved');
if (data.note) parts.push(data.note);
return parts.join(' · '); return parts.join(' · ');
} }
@ -431,6 +438,62 @@ function attachSeedFinder(voiceId, body) {
if (seedRows.size) clearBtn.style.display = ''; if (seedRows.size) clearBtn.style.display = '';
} }
// ── Auto-pick best: server generates + scores N candidates; the user confirms ──
let _autoAbort = null;
function _b64Blob(b64, type) {
const bin = atob(b64), u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new Blob([u8], { type: type || 'audio/wav' });
}
async function _runAutoPick() {
const text = textEl.value.trim();
if (!text) { autoStatus.textContent = _sfT('Enter a test sentence.'); return; }
const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1);
const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 8);
const seeds = [];
for (let s = seedFrom; s <= Math.min(seedTo, seedFrom + 15); s++) seeds.push(s);
_autoAbort = new AbortController();
autoBtn.disabled = runBtn.disabled = true;
cancelBtn.style.display = '';
cancelBtn.onclick = () => { _cancelled = true; if (_autoAbort) _autoAbort.abort(); };
resultsEl.innerHTML = ''; seedRows.clear();
autoStatus.textContent = `${_sfT('Scoring')} ${seeds.length} ${_sfT('candidates… this can take a few minutes.')}`;
try {
const r = await fetch('/api/seed-autopick', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceId, text, seeds }), signal: _autoAbort.signal,
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const res = await r.json();
for (const c of res.candidates) {
if (c.error) { _renderSeed(c.seed, { err: c.error }); continue; }
const acc = c.accuracy === null ? '' : `${_sfT('words')} ${Math.round(c.accuracy)}% · `;
const probs = c.problems && c.problems.length ? ` · ${c.problems.length} ${_sfT('audio problems')}` : '';
_renderSeed(c.seed, { blob: _b64Blob(c.audio_b64, c.media_type), dur: 0, note: `${_sfT('score')} ${c.score}/100 (${acc}${_sfT('level')} ${Math.round(c.loudness_score)}${probs})` });
}
const wRow = res.winner !== null ? seedRows.get(res.winner) : null;
if (wRow) {
wRow.classList.add('sfr-winner');
wRow.style.outline = '2px solid var(--accent, #4caf50)';
wRow.querySelector('.sfr-seed').insertAdjacentHTML('afterend', `<strong class="sfr-badge">★ ${_sfT('Best')}</strong>`);
wRow.querySelector('.sfr-play-btn').focus();
}
let msg = res.winner !== null
? `${_sfT('Best seed')}: ${res.winner} (${_sfT('score')} ${res.candidates.find(c => c.seed === res.winner).score}/100). ${_sfT('Listen, then click Use seed to save.')}`
: _sfT('No candidate could be generated.');
if (res.degraded) msg += ` ${_sfT('Speech-to-text unavailable: scored by level and audio problems only.')}`;
if (res.cancelled) msg += ` ${_sfT('Cancelled.')}`;
autoStatus.textContent = msg;
if (seedRows.size) clearBtn.style.display = '';
} catch (e) {
autoStatus.textContent = e.name === 'AbortError' ? _sfT('Cancelled.') : `${_sfT('Auto-pick failed')}: ${e.message}`;
} finally {
autoBtn.disabled = runBtn.disabled = false; cancelBtn.style.display = 'none'; _autoAbort = null; cancelBtn.onclick = null;
}
}
autoBtn.addEventListener('click', _runAutoPick);
runBtn.addEventListener('click', _runFinder); runBtn.addEventListener('click', _runFinder);
cancelBtn.addEventListener('click', () => { _cancelled = true; }); cancelBtn.addEventListener('click', () => { _cancelled = true; });
clearBtn.addEventListener('click', async () => { clearBtn.addEventListener('click', async () => {

View File

@ -0,0 +1,39 @@
// @ts-check
// Seed Finder "Auto-pick best": all /api routes mocked, nothing is ever saved or generated for real.
const { test, expect } = require('@playwright/test');
const WAV = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA='; // empty 16-bit wav
test('auto-pick highlights the winner; saving needs an explicit click', async ({ page }) => {
const saves = [];
const cand = (seed, score) => ({ seed, score, accuracy: 90, loudness_score: 80, problems_score: 100, problems: [], audio_b64: WAV, media_type: 'audio/wav' });
await page.route('**/api/**', route => route.request().method() === 'GET'
? route.fulfill({ json: [] }) : route.fulfill({ json: { ok: true } }));
await page.route('**/api/seed-autopick', route => route.fulfill({ json: {
winner: 2, degraded: true, cancelled: false, stt: { available: false, note: 'down' },
candidates: [cand(1, 60), cand(2, 88), { seed: 3, error: 'boom' }] } }));
await page.route('**/api/tts-voice-seed', route => { saves.push(route.request().postData()); route.fulfill({ json: { ok: true } }); });
await page.route('**/static/dist/main.min.js*', r => r.abort());
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.attachSeedFinder === 'function', null, { timeout: 15_000 });
await page.evaluate(() => {
const host = document.createElement('div'); host.id = 'sf-host'; document.body.prepend(host);
window.attachSeedFinder('DE_test', host);
host.querySelector('.seed-finder-group').classList.add('open');
});
const btn = page.getByRole('button', { name: /Auto-pick best/ });
await btn.focus();
await page.keyboard.press('Enter');
const status = page.locator('.seed-finder-autopick-status');
await expect(status).toHaveAttribute('aria-live', 'polite');
await expect(status).toContainText('Best seed: 2');
await expect(status).toContainText('level and audio problems only');
await expect(page.locator('.sfr-winner')).toContainText('Best');
await expect(page.locator('.sfr-winner')).toContainText('score 88/100');
await expect(page.locator('.seed-finder-result-error')).toContainText('boom');
expect(saves).toHaveLength(0);
await page.locator('.sfr-winner .sfr-use-btn').click();
await expect(page.locator('.sfr-winner .sfr-saved')).toContainText('Saved');
expect(saves).toHaveLength(1);
expect(JSON.parse(saves[0])).toEqual({ voice: 'DE_test', seed: 2 });
});

111
tests/test_seed_autopick.py Normal file
View File

@ -0,0 +1,111 @@
"""Seed auto-pick: scoring, selection and the endpoint with all I/O mocked."""
import asyncio
import os
import tempfile
import io
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-autopick-test-"))
from fastapi import HTTPException
from pydub.generators import Sine
from core import seed_score as sc
import routes.tts as tts
def wav(gain=-20, gap=False):
seg = Sine(300, sample_rate=24000).to_audio_segment(1500).apply_gain(gain)
if gap:
seg = seg + seg.__class__.silent(2000, 24000) + seg
buf = io.BytesIO()
seg.export(buf, format="wav")
return buf.getvalue()
class FakeReq:
def __init__(self, body, disconnect_after=None):
self.body, self.calls, self.after = body, 0, disconnect_after
async def json(self):
return self.body
async def is_disconnected(self):
self.calls += 1
return self.after is not None and self.calls > self.after
def run(req, gen, stt):
with mock.patch.object(tts, "_seed_finder_request_audio", side_effect=gen), \
mock.patch.object(tts, "_load_settings", return_value={}), \
mock.patch("routes.stt._transcribe_audio", side_effect=stt):
return asyncio.run(tts.seed_autopick(req))
class ScoringTests(unittest.TestCase):
def test_combine_and_degraded(self):
self.assertEqual(sc.combine(100, 100, 100), 100.0)
self.assertEqual(sc.combine(None, 100, 50), 70.0) # (0.2*100+0.3*50)/0.5
self.assertLess(sc.loudness_score({"dbfs": -5, "peak_dbfs": 0, "target_dbfs": -20}), 20)
self.assertEqual(sc.loudness_score({"dbfs": None}), 0.0)
self.assertEqual(sc.problems_score([{"kind": "silence"}] * 5), 0.0)
def test_winner_tiebreak_and_errors(self):
c = [{"seed": 3, "score": 80}, {"seed": 2, "score": 80}, {"seed": 1, "error": "x"}]
self.assertEqual(sc.pick_winner(c), 2)
self.assertIsNone(sc.pick_winner([{"seed": 1, "error": "x"}]))
def test_seed_list_bounds(self):
self.assertEqual(sc.seed_list(None, None), list(range(1, 9)))
self.assertEqual(sc.seed_list([5, 5, 7], None), [5, 7])
for bad in ((None, 17), (None, 0), ([], None), ([-1], None), (list(range(17)), None), (["a"], None)):
with self.assertRaises(ValueError):
sc.seed_list(*bad)
class EndpointTests(unittest.TestCase):
body = {"voice": "DE_x", "text": "hallo welt", "seeds": [1, 2, 3]}
def test_picks_best_and_never_saves(self):
audios = {1: wav(-20, gap=True), 2: wav(-20), 3: wav(-3)}
gen = lambda text, voice, settings, seed: (audios[seed], "audio/wav")
stt = lambda *a, **k: ("hallo welt", "x")
out = run(FakeReq(self.body), gen, stt)
self.assertEqual(out["winner"], 2)
self.assertFalse(out["degraded"])
self.assertEqual([c["seed"] for c in out["candidates"]], [1, 2, 3])
self.assertTrue(all("audio_b64" in c and "problems" in c for c in out["candidates"]))
self.assertGreater(out["candidates"][1]["score"], out["candidates"][0]["score"])
def test_stt_down_degrades(self):
def stt(*a, **k):
raise RuntimeError("no stt")
out = run(FakeReq(self.body), lambda *a: (wav(), "audio/wav"), stt)
self.assertTrue(out["degraded"])
self.assertIn("no stt", out["stt"]["note"])
self.assertIsNotNone(out["winner"])
self.assertIsNone(out["candidates"][0]["accuracy"])
def test_error_candidate_and_cancel(self):
def gen(text, voice, settings, seed):
if seed == 1:
raise RuntimeError("boom")
return wav(), "audio/wav"
out = run(FakeReq(self.body, disconnect_after=2), gen, lambda *a, **k: ("hallo welt", "x"))
self.assertEqual(out["candidates"][0]["error"], "boom")
self.assertEqual(out["winner"], 2)
self.assertTrue(out["cancelled"])
self.assertEqual(len(out["candidates"]), 2)
def test_invalid_input(self):
for body in ({"voice": "", "text": "a"}, {"voice": "v", "text": " "}, {"voice": "v", "text": "a", "count": 99}):
with self.assertRaises(HTTPException):
run(FakeReq(body), lambda *a: (wav(), "audio/wav"), lambda *a, **k: ("", ""))
if __name__ == "__main__":
unittest.main()