fix(audio): stop near-silent renders becoming blank noise + guard archetype renders (#204)

* fix(audio): stop near-silent renders becoming "blank noise" + guard archetypes

Root cause of the blank/hiss voices: normalize_audio peak-normalized to -2 dBFS
whenever max(|audio|) > 0. When the model emits a near-silent clip (peak at the
noise floor, e.g. 1e-4), that applies thousands of × of gain and lifts the
noise floor to full scale — silence turned into loud hiss. This affected every
generation path (clone/dub/design/archetypes), which is why "some voices" came
out as blank noise.

- services/audio_dsp.py: normalize_audio gains a -50 dBFS silence floor. At or
  below it the audio is left untouched (stays inaudible) instead of being
  amplified. Real speech — even a whisper — peaks well above the floor, so
  normal output is unchanged.
- api/routers/archetypes.py: after rendering, _is_blank_audio() detects a dead
  clip (empty / non-finite / peak < 0.02 — a real normalized clip peaks ~0.79).
  The render retries once with a different seed, then fails loudly (503 via the
  existing handlers) so a blank preview or voice profile is never cached/saved.
  Also extracts the script with a non-empty fallback.
- core/archetypes.py: _build never falls back to an empty script (empty text
  synthesizes to silence).

Tests (tests/, runs in CI): normalize_audio doesn't amplify silence but still
normalizes real audio to target; _is_blank_audio flags dead renders and passes
real audio; every archetype carries a non-empty sample script.

Verified: full tests/ suite 601 passed incl. 8 new (the 2 test_supertonic3
failures are pre-existing on main — local .venv engine/license state, green in
CI — and unrelated to this diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gallery): static log message in blank-render retry (clears py/clear-text-logging)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-05-31 13:27:30 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent e1850b4bd9
commit fd6f213517
5 changed files with 154 additions and 27 deletions
+67 -24
View File
@@ -51,11 +51,42 @@ def _preview_key(a: dict) -> str:
).hexdigest()[:16]
# A non-empty script is always required — synthesizing empty text yields
# silence. Every archetype carries a use-case script, but guard the render path
# too so a malformed archetype can never drive a blank render.
_FALLBACK_SCRIPT = "Here's a quick sample of this voice so you can hear how it sounds."
def _is_blank_audio(audio_tensor) -> bool:
"""True if a render came back effectively silent / empty / non-finite.
After ``normalize_audio``'s silence-floor guard a dead render stays at the
noise floor instead of being amplified to hiss, so a near-zero peak is a
reliable "no audible speech" signal. A real, normalized clip peaks near
-2 dBFS (~0.79), so the 0.02 threshold has a wide margin and won't flag
legitimately quiet (e.g. whisper) voices.
"""
try:
import torch
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
if t.numel() == 0:
return True
t = t.detach().to("cpu", dtype=torch.float32)
if not torch.isfinite(t).all():
return True
return t.abs().max().item() < 0.02
except Exception: # never let the checker itself block a render
return False
async def _render_archetype_wav(a: dict, out_path: Path) -> None:
"""Render an archetype's sample script to ``out_path`` using the live engine.
Reuses generation.py's inference primitives so there is exactly one TTS
code path. Heavy deps are imported here, never at module load.
Reuses generation.py's inference primitives so there is exactly one TTS code
path. Heavy deps are imported here, never at module load. If the engine
returns a blank/silent clip we retry once with a different seed, then fail
loudly — a blank preview or voice profile must never be cached or saved.
"""
from api.routers.generation import ( # noqa: WPS433 — intentional lazy import
get_model,
@@ -68,30 +99,42 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
language = a["language"]
if language in (None, "", "Auto"):
language = None
text = (a.get("sample_script") or "").strip() or _FALLBACK_SCRIPT
loop = asyncio.get_running_loop()
audio_tensor = await loop.run_in_executor(
_gpu_pool,
_run_inference,
model, # _model
a["sample_script"], # text
language, # language
None, # ref_audio_path (design mode — no reference)
None, # ref_text
a["instruct"], # instruct
None, # duration
16, # num_step
2.0, # guidance_scale
1.0, # speed
None, # t_shift
True, # denoise
True, # postprocess_output
None, # layer_penalty_factor
None, # position_temperature
None, # class_temperature
_PREVIEW_SEED, # seed
"broadcast", # effect_preset
)
def _infer(seed: int):
return _run_inference(
model, # _model
text, # text
language, # language
None, # ref_audio_path (design mode — no reference)
None, # ref_text
a["instruct"], # instruct
None, # duration
16, # num_step
2.0, # guidance_scale
1.0, # speed
None, # t_shift
True, # denoise
True, # postprocess_output
None, # layer_penalty_factor
None, # position_temperature
None, # class_temperature
seed, # seed
"broadcast", # effect_preset
)
audio_tensor = await loop.run_in_executor(_gpu_pool, _infer, _PREVIEW_SEED)
if _is_blank_audio(audio_tensor):
# Static message only — the archetype id derives from the request path
# param, and CodeQL flags logging request-derived data (clear-text /
# log-injection). The seed is a module constant, safe to log.
logger.warning("Archetype rendered blank at seed %d — retrying once", _PREVIEW_SEED)
audio_tensor = await loop.run_in_executor(_gpu_pool, _infer, _PREVIEW_SEED + 1)
if _is_blank_audio(audio_tensor):
raise RuntimeError("the voice engine returned no audible audio for this archetype")
out_path.parent.mkdir(parents=True, exist_ok=True)
_safe_torchaudio_save(str(out_path), audio_tensor, model.sampling_rate)
+2 -1
View File
@@ -215,7 +215,8 @@ def _build(gender, age, pitch, *, accent=None, dialect=None, whisper=False,
instruct = ", ".join(toks)
if script is None:
script = _ZH_SAMPLE if language == "Chinese" else _SCRIPTS.get(use_case, "")
# Never fall back to an empty script — empty text synthesizes to silence.
script = _ZH_SAMPLE if language == "Chinese" else (_SCRIPTS.get(use_case) or _SCRIPTS["narration"])
if featured:
aid = fid
+13 -2
View File
@@ -121,11 +121,22 @@ def apply_mastering(audio_tensor, sample_rate=24000):
def normalize_audio(audio_tensor, target_dBFS=-2.0):
"""Peak-normalizes the audio to a standard broadcasting level (-2 dB) to fix F5TTS volume fluctuations."""
"""Peak-normalizes the audio to a standard broadcasting level (-2 dB) to fix F5TTS volume fluctuations.
Never amplifies a near-silent signal. A failed/empty render sits at the
noise floor; blindly scaling its peak up to -2 dBFS applies thousands of
times of gain and turns silence into full-scale hiss — the "blank noise"
some generated voices exhibited. Below a -50 dBFS silence floor we leave the
audio untouched so it stays inaudible (and downstream guards can treat it as
a dead render) instead of shipping amplified noise. Real speech — even a
whisper — peaks well above this floor, so normal output is unaffected.
"""
if audio_tensor.numel() == 0:
return audio_tensor
max_val = torch.abs(audio_tensor).max()
if max_val > 0:
# -50 dBFS ≈ 0.00316 linear. Anything at/below this is silence / noise floor.
silence_floor = 10 ** (-50.0 / 20.0)
if max_val > silence_floor:
target_amp = 10 ** (target_dBFS / 20.0)
audio_tensor = audio_tensor * (target_amp / max_val)
return audio_tensor
+30
View File
@@ -0,0 +1,30 @@
"""Archetype render must never serve/save a blank clip.
Two layers: every archetype carries a non-empty sample script (empty text
synthesizes to silence), and `_is_blank_audio` flags dead renders so the render
path can retry once and then fail loudly instead of caching/saving silence.
"""
import torch
from api.routers.archetypes import _is_blank_audio
from core import archetypes
def test_is_blank_flags_dead_renders():
assert _is_blank_audio(torch.zeros(1, 16000)) is True
assert _is_blank_audio(torch.full((1, 16000), 1e-4)) is True # noise floor
assert _is_blank_audio(torch.zeros(0)) is True # empty
assert _is_blank_audio(torch.full((1, 100), float("nan"))) is True # non-finite
def test_is_blank_passes_real_audio():
sig = torch.zeros(1, 16000)
sig[0, ::50] = 0.8 # a normalized clip peaks near -2 dBFS
assert _is_blank_audio(sig) is False
def test_every_archetype_has_a_nonempty_script():
items = archetypes.list_archetypes()
assert items, "expected a non-empty archetype catalog"
blank = [a["id"] for a in items if not (a.get("sample_script") or "").strip()]
assert not blank, f"archetypes with empty sample_script: {blank[:10]}"
+42
View File
@@ -0,0 +1,42 @@
"""normalize_audio must never amplify a near-silent render into hiss.
Regression for the "blank noise" some generated voices exhibited: the model
occasionally emits near-silence, and peak-normalizing that to -2 dBFS applies
thousands of × of gain, lifting the noise floor to full scale. The silence
floor in normalize_audio prevents that while leaving real audio untouched.
"""
import torch
from services.audio_dsp import normalize_audio
def test_silence_is_not_amplified():
# A dead render sitting at ~-80 dBFS must stay inaudible, not get scaled up.
quiet = torch.full((1, 16000), 1e-4, dtype=torch.float32)
out = normalize_audio(quiet, target_dBFS=-2.0)
assert out.abs().max().item() < 0.01, "near-silent input must not be amplified to hiss"
def test_all_zeros_stays_zero():
out = normalize_audio(torch.zeros(1, 8000, dtype=torch.float32))
assert out.abs().max().item() == 0.0
def test_real_audio_is_normalized_to_target():
sig = torch.zeros(1, 16000, dtype=torch.float32)
sig[0, ::100] = 0.1 # real signal peaking well above the silence floor
out = normalize_audio(sig, target_dBFS=-2.0)
target = 10 ** (-2.0 / 20.0) # ~0.794
assert abs(out.abs().max().item() - target) < 0.02
def test_just_above_floor_is_normalized():
# 0.01 (-40 dBFS) is above the -50 dBFS floor → should still be normalized.
sig = torch.zeros(1, 16000, dtype=torch.float32)
sig[0, 0] = 0.01
out = normalize_audio(sig, target_dBFS=-2.0)
assert out.abs().max().item() > 0.5
def test_empty_passthrough():
assert normalize_audio(torch.zeros(0)).numel() == 0