feat(voices): show audio problems in the voice library

Single-voice health endpoint (GET /api/voices/{id}/health), problems stored in
meta on import, shown in the Try-out voice panel; architecture note and docs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 16:35:01 +02:00
parent 67537310d0
commit a0cd7b867f
13 changed files with 110 additions and 12 deletions

View File

@ -8,7 +8,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### Added
- **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. Tests: `tests/test_audio_problems.py`. - **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`.
### Changed ### Changed
- **Magazine interviews: journalist/guest voices.** Interview labels such as `c't:` (also `c’t`, `ct`, OCR `Cc't`), `Redaktion`, `Interviewer`, `Frage` are the interviewing side and are read in the narrator voice; every other recurring label is a guest and gets the second voice. Unlabelled blocks continue the current speaker's turn; headings, decks and bylines stay narrator. Detection stays strict (label starts a block and recurs at least twice). Audio-timing records now store each segment's voice and speaker. Tests: `tests/test_magazine.py` (`InterviewJournalistTests`). - **Magazine interviews: journalist/guest voices.** Interview labels such as `c't:` (also `c’t`, `ct`, OCR `Cc't`), `Redaktion`, `Interviewer`, `Frage` are the interviewing side and are read in the narrator voice; every other recurring label is a guest and gets the second voice. Unlabelled blocks continue the current speaker's turn; headings, decks and bylines stay narrator. Detection stays strict (label starts a block and recurs at least twice). Audio-timing records now store each segment's voice and speaker. Tests: `tests/test_magazine.py` (`InterviewJournalistTests`).

View File

@ -16,10 +16,10 @@ Living manual. Every feature updates this file in the same commit as the code.
## Voice health: audio problems ## Voice health: audio problems
**Purpose:** the routing test (Settings, Routing, Test) checks the routed voice's reference audio and lists problems with timestamps: `silence` (over 1.5 s of silence inside the clip), `clipping` (peaks at full scale for 0.1 s or more), `level jump` (15 dB or more change between adjacent half-second windows). **Purpose:** reference audio is checked for `silence` (over 1.5 s inside the clip), `clipping` (peaks at full scale for 0.1 s or more) and `level jump` (15 dB or more between adjacent half-second windows).
**Usage:** run the route test; the result line shows "Audio problems: clipping at 2.0s–2.4s (peak at 0.0 dBFS); ..." (first 8). Each kind also adds a warning. The text label carries the meaning, not colour. **Where:** (1) Settings, Routing, Test: the result line shows "Audio problems: clipping at 2s-2.4s (peak at 0.0 dBFS); ..." (first 8). (2) Try-out, after selecting a library voice: the voice panel shows the same text under the warnings (screen-reader announced; wording carries the meaning, not colour). Each kind also adds a warning.
**Errors:** unreadable audio yields no problems. Only the first 120 s are scanned; results are cached per file modification time. The voice list does not run this check (it returns `problems: []`) to stay fast. **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.
**Privacy/offline:** local analysis only, nothing is sent anywhere. **Errors:** unreadable audio yields no problems; only the first 120 s are scanned. **Privacy/offline:** local analysis only.
## Audiobook casting (Read Aloud → Cast as Audiobook) ## Audiobook casting (Read Aloud → Cast as Audiobook)

View File

@ -0,0 +1,7 @@
# Voice health and audio problems
- `core/voice.py::_voice_health(p, analyze=False)` returns the metadata checks (`ok`, `warnings`, `duration`, `word_count`, `words_per_sec`, `loudness`) plus `problems`.
- `core/audio.py::audio_problems` decodes with pydub in 50 ms chunks (first 120 s only) and returns `[{kind: silence|clipping|level_jump, start_s, end_s, detail}]`. Thresholds are `PROBLEM_*` constants. No new dependencies.
- Cost model: decoding is the expensive part, so the voice list never analyses (`analyze=False` returns `meta["audio_problems"]`, empty if absent). Fresh analysis runs for `GET /api/voices/{id}/health` and the routing test, cached in-process by (path, mtime). Imports store the result in meta in `routes/sources.py::_prepare_imported_voice`; failures there never block the import.
- UI: `fmtAudioProblems` (static/js/utils.js) renders text for the routing result and the Try-out voice panel (`#preview-match-problems`, `role=status`).
- Limits: heuristics (RMS/peak per chunk); legacy voices show problems only after the health endpoint is called.

View File

@ -165,6 +165,8 @@ def _validate_voice_meta(meta: dict) -> None:
for field in ("enabled", "needs_tts_restart"): for field in ("enabled", "needs_tts_restart"):
if field in meta and not isinstance(meta[field], bool): if field in meta and not isinstance(meta[field], bool):
raise ValueError(f"Voice metadata field '{field}' must be boolean") raise ValueError(f"Voice metadata field '{field}' must be boolean")
if "audio_problems" in meta and not isinstance(meta["audio_problems"], list):
raise ValueError("Voice metadata field 'audio_problems' must be a list")
for field in ("loudness", "benchmark"): for field in ("loudness", "benchmark"):
if field in meta and meta[field] is not None and not isinstance(meta[field], dict): if field in meta and meta[field] is not None and not isinstance(meta[field], dict):
raise ValueError(f"Voice metadata field '{field}' must be an object") raise ValueError(f"Voice metadata field '{field}' must be an object")
@ -391,7 +393,7 @@ def _voice_health(
if peak is not None and float(peak) > -0.1: if peak is not None and float(peak) > -0.1:
warnings.append("Reference audio is clipping or too loud") warnings.append("Reference audio is clipping or too loud")
# Decoding audio is too slow for the voice list; only single-voice checks opt in. # Decoding audio is too slow for the voice list; only single-voice checks opt in.
problems = _voice_problems(p) if analyze else [] problems = _voice_problems(p) if analyze else list(meta.get("audio_problems") or [])
for kind in dict.fromkeys(q["kind"] for q in problems): for kind in dict.fromkeys(q["kind"] for q in problems):
warnings.append(_PROBLEM_LABEL[kind]) warnings.append(_PROBLEM_LABEL[kind])
return { return {

View File

@ -429,6 +429,14 @@ async def list_voices(refresh: bool = False):
return await asyncio.to_thread(indexed_voices, settings, refresh=refresh) return await asyncio.to_thread(indexed_voices, settings, refresh=refresh)
@router.get("/api/voices/{voice_id}/health")
async def get_voice_health(voice_id: str):
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio = _find_voice_audio(voice_id, scan_dir)
return await asyncio.to_thread(_voice_health, audio, analyze=True)
@router.get("/api/voices/index") @router.get("/api/voices/index")
async def get_voice_index_info(): async def get_voice_index_info():
return voice_index_info() return voice_index_info()

View File

@ -60,6 +60,11 @@ async def _prepare_imported_voice(audio: Path, settings: dict) -> None:
local service is captured in metadata rather than making the import fail. local service is captured in metadata rather than making the import fail.
""" """
meta = _load_meta(audio) meta = _load_meta(audio)
try:
from core.audio import audio_problems
meta["audio_problems"] = await asyncio.to_thread(audio_problems, audio)
except Exception:
pass # analysis is advisory; never block the import
meta["import_preparation"] = "transcribing" meta["import_preparation"] = "transcribing"
meta.pop("import_preparation_error", None) meta.pop("import_preparation_error", None)
_save_meta(audio, meta) _save_meta(audio, meta)

File diff suppressed because one or more lines are too long

View File

@ -396,9 +396,7 @@ function renderRouteTestResult(d) {
} }
const problems = Array.isArray(health.problems) ? health.problems : []; const problems = Array.isArray(health.problems) ? health.problems : [];
if (problems.length) { if (problems.length) {
parts.push('Audio problems: ' + problems.slice(0, 8).map(q => parts.push('Audio problems: ' + escHtml(fmtAudioProblems(problems)));
`${escHtml(String(q.kind).replace('_', ' '))} at ${escHtml(String(q.start_s))}s` +
(q.end_s > q.start_s ? `–${escHtml(String(q.end_s))}s` : '') + ` (${escHtml(q.detail)})`).join('; '));
} }
const sounds = d.sounds || {}; const sounds = d.sounds || {};
for (const [key, sound] of Object.entries(sounds)) { for (const [key, sound] of Object.entries(sounds)) {

View File

@ -61,6 +61,18 @@ function previewVoiceWarnings(v) {
return warnings; return warnings;
} }
async function showVoiceProblems(id) {
const el = $('preview-match-problems');
if (!el) return;
el.textContent = '';
try {
const h = await fetch(`/api/voices/${encodeURIComponent(id)}/health`).then(r => r.ok ? r.json() : null);
const text = h ? fmtAudioProblems(h.problems) : '';
if ($('tts-voice-select')?.value === id) el.textContent = text ? 'Audio problems: ' + text : '';
} catch (_) { /* advisory only */ }
}
window.showVoiceProblems = showVoiceProblems;
function updatePreviewVoiceMatchPanel() { function updatePreviewVoiceMatchPanel() {
const panel = $('preview-match-panel'); const panel = $('preview-match-panel');
if (!panel) return; if (!panel) return;
@ -73,6 +85,7 @@ function updatePreviewVoiceMatchPanel() {
const dur = v.duration ? fmtDuration(v.duration) : '-'; const dur = v.duration ? fmtDuration(v.duration) : '-';
$('preview-match-title').textContent = v.id; $('preview-match-title').textContent = v.id;
$('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`; $('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`;
showVoiceProblems(v.id);
const warnings = previewVoiceWarnings(v); const warnings = previewVoiceWarnings(v);
$('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.'; $('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.';
const transcript = cleanReferenceText(v.transcript || ''); const transcript = cleanReferenceText(v.transcript || '');

View File

@ -1455,3 +1455,10 @@ Object.assign(root.TTVC.utils, {
memory: { isLow: ttvcIsLowMemoryDevice }, memory: { isLow: ttvcIsLowMemoryDevice },
}); });
})(window); })(window);
// Human-readable, timestamped audio problems (text only, no colour cue).
function fmtAudioProblems(problems) {
return (Array.isArray(problems) ? problems : []).slice(0, 8).map(q =>
`${String(q.kind).replace('_', ' ')} at ${q.start_s}s` + (q.end_s > q.start_s ? `\u2013${q.end_s}s` : '') + ` (${q.detail})`).join('; ');
}
window.fmtAudioProblems = fmtAudioProblems;

View File

@ -42,6 +42,7 @@
<div class="preview-match-title" id="preview-match-title">Reference voice</div> <div class="preview-match-title" id="preview-match-title">Reference voice</div>
<div class="preview-match-detail" id="preview-match-detail"></div> <div class="preview-match-detail" id="preview-match-detail"></div>
<div class="preview-match-warning" id="preview-match-warning"></div> <div class="preview-match-warning" id="preview-match-warning"></div>
<div class="preview-match-problems" id="preview-match-problems" role="status" aria-live="polite"></div>
<div class="preview-match-actions"> <div class="preview-match-actions">
<button class="btn-secondary" id="preview-ref-play" type="button">Play reference WAV</button> <button class="btn-secondary" id="preview-ref-play" type="button">Play reference WAV</button>
<button class="btn-secondary" id="preview-ref-use-text" type="button">Use reference text</button> <button class="btn-secondary" id="preview-ref-use-text" type="button">Use reference text</button>

View File

@ -49,5 +49,22 @@ class AudioProblemTests(unittest.TestCase):
self.assertAlmostEqual(p[0]["start_s"], 2.0, delta=0.1) self.assertAlmostEqual(p[0]["start_s"], 2.0, delta=0.1)
class VoiceHealthTests(unittest.TestCase):
def test_analyze_and_stored(self):
import tempfile
from core.voice import _voice_health, _validate_voice_meta
with tempfile.TemporaryDirectory() as d:
wav = Path(d) / "EN_F_test.wav"
(tone(2000) + AudioSegment.silent(2000, frame_rate=SR) + tone(2000)).export(str(wav), format="wav")
h = _voice_health(wav, meta={}, transcript="hi", duration=6.0, analyze=True)
self.assertEqual([p["kind"] for p in h["problems"]], ["silence"])
self.assertIn("Long silence in reference audio", h["warnings"])
stored = _voice_health(wav, meta={"audio_problems": h["problems"]}, transcript="hi", duration=6.0)
self.assertEqual(stored["problems"], h["problems"])
self.assertEqual(_voice_health(wav, meta={}, transcript="hi", duration=6.0)["problems"], [])
with self.assertRaises(ValueError):
_validate_voice_meta({"audio_problems": "x"})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1,40 @@
// @ts-check
// Audio problems in the routing test result and the library voice panel (mocked routes only).
const { test, expect } = require('@playwright/test');
const PROBLEMS = [
{ kind: 'silence', start_s: 2.0, end_s: 4.0, detail: '2.0s of silence' },
{ kind: 'level_jump', start_s: 6.5, end_s: 6.5, detail: '+25 dB level change' },
];
async function setup(page) {
await page.route('**/api/voices/*/health', route => route.fulfill({ json: { warnings: [], problems: PROBLEMS } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function' && typeof window.fmtAudioProblems === 'function', null, { timeout: 15_000 });
}
test('fmtAudioProblems gives timestamped text', async ({ page }) => {
await setup(page);
const text = await page.evaluate(p => window.fmtAudioProblems(p), PROBLEMS);
expect(text).toBe('silence at 2s–4s (2.0s of silence); level jump at 6.5s (+25 dB level change)');
});
test('routing test result lists audio problems as text', async ({ page }) => {
await setup(page);
await page.evaluate(() => {
window.renderRouteTestResult({ requested_voice: 'a', routed_voice: 'b', voice_health: { warnings: ['Long silence in reference audio'], problems: [{ kind: 'silence', start_s: 2, end_s: 4, detail: '2.0s of silence' }] }, sounds: {} });
});
await expect(page.locator('#routing-test-result')).toContainText('Audio problems: silence at 2s–4s (2.0s of silence)');
});
test('library panel shows problems from the single-voice health endpoint', async ({ page }) => {
await setup(page);
await page.evaluate(() => {
const sel = document.getElementById('tts-voice-select');
sel.innerHTML = '<option value="EN_F_x">EN_F_x</option>'; sel.value = 'EN_F_x';
});
await page.evaluate(() => window.showVoiceProblems('EN_F_x'));
await expect(page.locator('#preview-match-problems')).toContainText('Audio problems: silence at 2s');
});