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:
co-authored by
Claude Opus 4.8
parent
e1850b4bd9
commit
fd6f213517
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user