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:
parent
a0cd7b867f
commit
b36423457b
@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
## [Unreleased]
|
||||
### 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 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`.
|
||||
|
||||
|
||||
10
Manual.md
10
Manual.md
@ -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.
|
||||
**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)
|
||||
|
||||
### Purpose
|
||||
|
||||
6
architecture/seed-autopick.md
Normal file
6
architecture/seed-autopick.md
Normal 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
72
core/seed_score.py
Normal 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))
|
||||
@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@ -1314,6 +1315,92 @@ async def tts_voice_seed(request: Request):
|
||||
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}")
|
||||
async def list_seed_samples(voice_name: str):
|
||||
"""List pre-generated seed sample numbers for a voice (batch script output)."""
|
||||
|
||||
32
static/dist/main.min.js
vendored
32
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -21,6 +21,22 @@ const I18N_LANGS = {
|
||||
// English for languages that don't have it yet (see window.t / applyI18n).
|
||||
const I18N_DICT = {
|
||||
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
|
||||
'Voice Creator': 'Voice Creator',
|
||||
'Clone · Design · Deploy': 'Klonen · Entwerfen · Bereitstellen',
|
||||
|
||||
@ -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_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) {
|
||||
if (window._appSettings && window._appSettings.seed_finder_text) {
|
||||
return window._appSettings.seed_finder_text;
|
||||
@ -114,10 +116,12 @@ function attachSeedFinder(voiceId, body) {
|
||||
</div>
|
||||
<div class="seed-finder-actions">
|
||||
<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-clear-btn" style="display:none" title="Delete the saved samples cached for this voice">🗑 Clear saved</button>
|
||||
<span class="seed-finder-status"></span>
|
||||
</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">
|
||||
<label class="seed-finder-label">Or fix this voice to a specific seed</label>
|
||||
<div class="seed-finder-pin-controls">
|
||||
@ -168,6 +172,8 @@ function attachSeedFinder(voiceId, body) {
|
||||
const progCount = panel.querySelector('.seed-finder-progress-count');
|
||||
const progBar = panel.querySelector('.seed-finder-bar');
|
||||
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);
|
||||
|
||||
@ -313,6 +319,7 @@ function attachSeedFinder(voiceId, body) {
|
||||
if (data.genSec) parts.push(`gen ${data.genSec.toFixed(1)}s`);
|
||||
if (data.rtf) parts.push(`${data.rtf.toFixed(2)}x`);
|
||||
if (data.cached) parts.push('saved');
|
||||
if (data.note) parts.push(data.note);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@ -431,6 +438,62 @@ function attachSeedFinder(voiceId, body) {
|
||||
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);
|
||||
cancelBtn.addEventListener('click', () => { _cancelled = true; });
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
|
||||
39
tests/seed-autopick.spec.js
Normal file
39
tests/seed-autopick.spec.js
Normal 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
111
tests/test_seed_autopick.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user