diff --git a/backend/api/routers/archetypes.py b/backend/api/routers/archetypes.py index bc84eccf..8b14ee1a 100644 --- a/backend/api/routers/archetypes.py +++ b/backend/api/routers/archetypes.py @@ -57,21 +57,12 @@ _PREVIEW_SEED = 42 # 32 reliably converges to speech across the gallery's instruct/script space # at a one-time (cached) render cost. _PREVIEW_NUM_STEP = 32 -# Spectral-flatness floor below which a render is a degenerate tonal artifact -# rather than speech. Real, mastered speech sits ~0.04–0.07; a tonal buzz -# collapses to <0.005. 0.015 separates the two with wide margin and sits well -# below even breathy/whisper voices (which are broadband → high flatness). -# Reject below this MEAN FRAMED spectral flatness (see _spectral_flatness). -# Calibrated against measurements, not intuition — the previous 0.015 was set -# from a synthetic speech stand-in and sat in the middle of the real-speech -# range, so it rejected most legitimate renders (Japanese/Korean/English -# previews alike). Measured on this engine's own output: -# pure tone 80 Hz 2.6e-10 | two-tone buzz 3.3e-09 -# quietest REAL speech 2.0e-04 (VoxCPM2 ko, verified by ASR) -# 1e-5 sits ~3000x above the tonal cases and ~20x below the quietest real -# render — wide margin on both sides. Keep it there unless new measurements -# (not synthetic signals) say otherwise. -_DEGENERATE_FLATNESS = 1e-5 +# Reject near-pure tonal artifacts using mean framed spectral flatness. +# Calibrated against the tracked speech demos exercised by +# test_archetype_preview_quality.py: the quietest (Mandarin dubbing, 44.1 kHz) +# measures ~7.7e-6, while the worst tested tonal buzz measures ~3.3e-9. +# 1e-7 leaves >10x margin on both sides without rejecting low-flatness speech. +_DEGENERATE_FLATNESS = 1e-7 def _preview_key(a: dict) -> str: diff --git a/backend/tests/test_archetype_preview_quality.py b/backend/tests/test_archetype_preview_quality.py index ec3a2904..16236c89 100644 --- a/backend/tests/test_archetype_preview_quality.py +++ b/backend/tests/test_archetype_preview_quality.py @@ -7,13 +7,15 @@ old silence-only guard missed it (the buzz is loud, not silent) so the garbage was cached and served. These tests cover the fix *without the 5 GB model / a GPU*: they drive the pure -``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic signals, -and assert the render constants didn't regress. The real end-to-end render is -verified manually (spectral flatness back in the speech range + Whisper ASR). +``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic tones and +tracked speech demo renders, and assert the render constants did not regress. """ from __future__ import annotations import math +from pathlib import Path + +import soundfile as sf import pytest @@ -93,14 +95,9 @@ def test_speech_like_is_usable(): # ── Threshold stays between the two things it has to separate ─────────────── -# The original 0.015 was calibrated against `_speech_like()` below, which is two -# orders of magnitude flatter than real speech, so the threshold landed inside -# the real-speech range and rejected legitimate previews (Japanese, Korean and -# English alike — ASR transcribed every one of them correctly). -#: Flattest REAL render observed, measured on engine output and confirmed to be -#: speech by transcribing it (VoxCPM2, ko). Re-measure with real renders — never -#: with a synthetic signal — before changing it. -MEASURED_SPEECH_FLOOR = 2.0e-4 +# Synthetic broadband speech has much higher flatness than real voiced audio. +# Measure both sides of the threshold against actual inputs, including the +# existing demo renders that the old thresholds rejected. def test_tonal_ceiling_is_measured_not_assumed(): @@ -119,17 +116,33 @@ def test_tonal_ceiling_is_measured_not_assumed(): assert max(tones) * 10 < arch._DEGENERATE_FLATNESS -def test_threshold_clears_the_measured_real_speech_floor(): - """The speech side of the margin cannot be synthesized — that IS the bug. +_SAMPLES = Path(__file__).resolve().parents[1] / "assets" / "samples" +_SPEECH_FIXTURES = [ + "demo_voice.wav", + "demo_clone_output.wav", + *[f"voice_design/demo_voice_design_{name}.wav" for name in ( + "audiobook_uk_narrator", "aussie_podcaster", "bedtime_storyteller", + "gravelly_villain", "indian_support_agent", "mandarin_sichuan", "us_news_anchor", + )], + *[f"dictation/{name}.wav" for name in ( + "en_conversational", "en_technical", "fr_reservation", + )], + *[f"demo/dubbing/{name}.src.wav" for name in ( + "source", "dubbed_es", "dubbed_fr", "dubbed_ja", "dubbed_zh", + )], +] - Shipping a real render as a fixture would add a binary to a suite whose - whole point is running without one, so the measurement is recorded in - ``MEASURED_SPEECH_FLOOR`` and the assumption behind it is asserted here: - the synthetic stand-in sits nowhere near the real floor, which is exactly - why it cannot stand in for it. - """ - assert arch._DEGENERATE_FLATNESS < MEASURED_SPEECH_FLOOR / 10 - assert arch._spectral_flatness(_speech_like()) > MEASURED_SPEECH_FLOOR * 10 + +@pytest.mark.parametrize("fixture", _SPEECH_FIXTURES) +def test_real_shipped_speech_clears_quality_floor(fixture): + # These are existing tracked demo renders, not synthesized stand-ins or + # asserted measurements. Loading PCM needs neither a model nor a network. + audio, _sample_rate = sf.read(_SAMPLES / fixture, dtype="float32", always_2d=True) + speech = torch.from_numpy(audio.T) + flatness = arch._spectral_flatness(speech) + assert flatness is not None + assert flatness > arch._DEGENERATE_FLATNESS * 10 + assert arch._is_unusable_audio(speech) is False def test_flatness_is_not_clip_length_dependent(): diff --git a/docs/voice-design.md b/docs/voice-design.md index 8efa6c10..c0baa934 100644 --- a/docs/voice-design.md +++ b/docs/voice-design.md @@ -128,3 +128,7 @@ for Chinese — the model auto-fixes mismatches). - **Case-insensitive**: `"Male"`, `"MALE"`, and `"male"` are all accepted, the code will normalize them to lower case. - **Accent vs Dialect**: English accents are only applied to English speech, Chinese dialects are only applied to Chinese speech. + +Gallery previews reject silent output and near-pure tonal buzz. The quality +check measures short audio frames rather than the whole clip, so longer or +softly voiced speech is not rejected merely for having low spectral flatness.