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:
parent
67537310d0
commit
a0cd7b867f
@ -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`).
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
7
architecture/voice-health.md
Normal file
7
architecture/voice-health.md
Normal 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.
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
6
static/dist/main.min.js
vendored
6
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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)) {
|
||||||
|
|||||||
@ -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 || '');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
40
tests/voice-health.spec.js
Normal file
40
tests/voice-health.spec.js
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user