Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 168 additions & 106 deletions MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/p5_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
import re
import contextlib
import tempfile

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -91,8 +93,12 @@
# text (tags included) is unchanged, so its hash -- the other cache key --
# cannot see the fix; only this sentinel re-renders the glued narrators
# (measured: 16/20 sampled segments carried omissions traced to glue
# points like "route,[short pause] quand" -> "route,quand").
_CV3_NARRATOR_REFERENCE_ID: str = "cosyvoice3-zeroshot-narrator-fr-v3"
# points like "route,[short pause] quand" -> "route,quand"). v4 = the
# attack guard (#19739): a chunk that passes the duration floor can still
# start mid-text (onset-drop), and the guard re-rolls it -- segments the
# pre-guard run shipped with a dropped attack must not stay cached as
# "generated".
_CV3_NARRATOR_REFERENCE_ID: str = "cosyvoice3-zeroshot-narrator-fr-v4"

# Engine -> reference_id sentinel. The narrator cache is keyed on it
# (#19692): an MP3 rendered by one engine is never served for another.
Expand Down Expand Up @@ -1371,15 +1377,77 @@ class NarratorCosyVoice3Unavailable(RuntimeError):
# bit-identically and only a degenerate one is perturbed.
_CV3_RETRY_SEED_STRIDE = 104_729 # prime, unrelated to seed + seg_index

# Per-chunk attack guard (#19739, phase 2 of the #19692 narrator routing).
# The duration floor above is a GROSS backstop and can never see an onset-drop:
# a render that is a plausible length but simply started mid-text. Only the ASR
# attack guard (v4.attack_guard — the per-chunk extension of p7's omission
# organ) catches that class, so it is wired into the render loop as a second
# acceptance gate: a chunk that clears the floor is re-rolled when its ASR
# shows the attack missing. Off by default — it costs one Whisper call per
# chunk and needs the ASR server, so it is opt-in via the env flag.
_CV3_ATTACK_GUARD: bool = os.getenv("NARRATOR_CV3_ATTACK_GUARD", "0") == "1"
# Attack guard (#19739, phase 2): a chunk that passes the duration floor can
# still start mid-text -- the onset-drop measured in phase 1 (PR #19699) is
# seed-dependent and invisible to any duration check (the chars/s bands of
# clean and omitting renders overlap fully), so only the ASR can see it. The
# verdict organ is v4.attack_guard (delivered by #21459); this loop is where
# it plugs in: a floor-passing render is accepted only if the ASR hears the
# chunk's first words, otherwise the same spaced-seed re-roll that answers a
# degenerate render answers the dropped attack. V4_ATTACK_GUARD=off restores
# the pre-guard acceptance (one ASR call less) -- that is the A/B arm the
# measured comparison needs, not a production mode: a run with the guard off
# ships the onset-drop the issue exists to close.
_ATTACK_GUARD_ENV = "V4_ATTACK_GUARD"
_ATTACK_GUARD_ASR_BASE_URL_ENV = "V4_ASR_BASE_URL"
# Same endpoint and model as the omission organ p7 (localhost:8190,
# large-v3-turbo): the guard must measure exactly what p7 measures, so an
# "attack dropped" verdict and a p7 omission span cannot diverge.
_ATTACK_GUARD_ASR_BASE_URL = "http://localhost:8190"


def _attack_guard_enabled() -> bool:
"""V4_ATTACK_GUARD reads on, off, 0/1 -- default ON (the correction is the
phase-2 deliverable, not an opt-in)."""
return os.getenv(_ATTACK_GUARD_ENV, "on").strip().lower() not in {
"0", "off", "no", "false",
}


def _resolve_attack_asr():
"""wav path -> transcript, on the organ's http client (asr_http).

The callable is resolved per segment, not at import: tests inject their
own, and the run can point V4_ASR_BASE_URL at the Whisper server of the
machine that carries the services.
"""
from .attack_guard import asr_http

base_url = os.getenv(
_ATTACK_GUARD_ASR_BASE_URL_ENV, _ATTACK_GUARD_ASR_BASE_URL
)

def _transcribe(wav_path) -> str:
return asr_http({"audio": str(wav_path)}, base_url=base_url)

return _transcribe


def _cv3_attack_verdict(candidate, sr, chunk, chunk_index, chunk_seed, asr_fn, tmp_dir):
"""Floor passed -- does the ASR hear the chunk's first words? (#19739)

Writes the candidate to a scratch wav (the organ's clients read a file),
transcribes it with the injected asr_fn and delegates the verdict to
attack_guard.evaluate_chunk. Returns the organ's verdict dict; the caller
decides what a dropped attack costs.
"""
import torchaudio

from .attack_guard import evaluate_chunk

wav_path = Path(tmp_dir) / f"chunk_{chunk_index}_attack.wav"
torchaudio.save(str(wav_path), candidate, sr, format="wav")
transcript = asr_fn(wav_path)
return evaluate_chunk(
{
"chunk_index": chunk_index,
"text": chunk,
"seed": chunk_seed,
"engine": "cosyvoice3",
"audio": str(wav_path),
},
transcript,
)

# CosyVoice3 AutoModel: ~3.4 GB VRAM, tens of seconds to load — one instance
# per process, not per segment.
Expand Down Expand Up @@ -1600,44 +1668,6 @@ def _cv3_min_expected_s(text: str) -> float:
return max(_CV3_MIN_ABS_S, len(text) / _CV3_MIN_CHARS_PER_SEC)


def _cv3_chunk_asr(wav_path: str) -> str:
"""Transcribe one rendered chunk via the p7 Whisper server.

Organ-first: this is `attack_guard.asr_http` (the same
`localhost:8190/v1/audio/transcriptions` path p7 uses), wrapped so the
render loop passes a bare path. It is injected into
`_cv3_chunk_attack_dropped` so hermetic tests substitute a stub and the
loop never reaches for the network.
"""
from . import attack_guard

return attack_guard.asr_http({"audio": wav_path})


def _cv3_chunk_attack_dropped(wav, sr: int, chunk_text: str, asr_fn) -> bool:
"""Inline per-chunk attack guard (#19739).

Saves the candidate to a temp WAV, transcribes it with `asr_fn`, and
reports whether the attack is missing. The verdict is
`attack_guard.detect_missing_attack` — the positional head-loss detector
built on p7's `_missing_word_spans` — invoked organ-first, not
re-implemented here. `asr_fn` is injected so the loop stays testable
without a server or a GPU.
"""
import tempfile
import torchaudio

from . import attack_guard

with tempfile.TemporaryDirectory(prefix="cv3_attack_") as tmp:
wav_path = str(Path(tmp) / "chunk.wav")
torchaudio.save(wav_path, wav, sr)
transcript = asr_fn(wav_path)
return bool(
attack_guard.detect_missing_attack(chunk_text, transcript)["attack_dropped"]
)


def _synthesize_narrator_cosyvoice3(
seg: AnnotatedSegment,
fishaudio_text: str,
Expand Down Expand Up @@ -1679,74 +1709,106 @@ def _synthesize_narrator_cosyvoice3(
prompt_text = cv3_client.PROMPT_TEXT_ZH + cv3_client.ENDOFPROMPT
sr = int(model.sample_rate)

# Attack guard (#19739): on by default -- see _attack_guard_enabled. The
# ASR callable is resolved once per segment; the scratch dir holds the
# candidate wavs the organ's clients transcribe (system temp, never the
# repo, and it goes away with the segment).
attack_asr = _resolve_attack_asr() if _attack_guard_enabled() else None
tmp_ctx = (
tempfile.TemporaryDirectory(prefix="v4_attack_guard_")
if attack_asr is not None
else contextlib.nullcontext(None)
)

t0 = time.time()
wavs = []
attempts = 0
for i, chunk in enumerate(chunks):
floor_s = _cv3_min_expected_s(chunk)
wav = None
last_dur = 0.0
last_exc: Exception | None = None
for attempt in range(_CV3_RENDER_ATTEMPTS):
attempts += 1
# attempt 0 keeps the pre-guard seed, so a healthy chunk is
# unchanged and only a degenerate one is perturbed.
torch.manual_seed(seed + i + attempt * _CV3_RETRY_SEED_STRIDE)
attack_dropped = False
try:
gen = model.inference_zero_shot(
chunk, prompt_text, str(asset_wav), stream=False
)
parts = [j["tts_speech"] for j in gen]
candidate = torch.cat(parts, dim=-1) if len(parts) > 1 else parts[0]
except Exception as exc: # noqa: BLE001
# The crash and the silent 0.04 s are the same event: too few
# speech tokens. Here the mel was short enough to break the f0
# predictor's conv1d before anything was yielded, so it is the
# same re-roll that answers it.
last_exc, last_dur = exc, 0.0
else:
last_dur = candidate.shape[-1] / sr
# A render that clears the duration floor is accepted only when
# the ASR attack guard also passes: the floor is a gross
# backstop that cannot see an onset-drop (a plausible-length
# render that started late), so the guard is the fidelity organ
# for that class (#19739). Off by default, so a plain run keeps
# the pre-guard behaviour and no Whisper dependency.
attack_dropped = (
_CV3_ATTACK_GUARD
and last_dur >= floor_s
and _cv3_chunk_attack_dropped(candidate, sr, chunk, _cv3_chunk_asr)
)
if last_dur >= floor_s and not attack_dropped:
wav = candidate
break
if attack_dropped:
logger.warning(
"[CV3] seg %s chunk %d/%d attempt %d/%d: onset-drop "
"(attack not spoken per ASR guard) - re-rolling seed",
seg.seg_index, i + 1, len(chunks), attempt + 1,
_CV3_RENDER_ATTEMPTS,
)
else:
attack_checks = 0
attack_rerolls = 0
with tmp_ctx as attack_tmp:
for i, chunk in enumerate(chunks):
floor_s = _cv3_min_expected_s(chunk)
wav = None
last_dur = 0.0
last_exc: Exception | None = None
last_attack: dict | None = None
for attempt in range(_CV3_RENDER_ATTEMPTS):
attempts += 1
# attempt 0 keeps the pre-guard seed, so a healthy chunk is
# unchanged and only a degenerate one is perturbed. The same
# formula answers a dropped attack (attack_guard.spaced_seed).
chunk_seed = seed + i + attempt * _CV3_RETRY_SEED_STRIDE
torch.manual_seed(chunk_seed)
try:
gen = model.inference_zero_shot(
chunk, prompt_text, str(asset_wav), stream=False
)
parts = [j["tts_speech"] for j in gen]
candidate = (
torch.cat(parts, dim=-1) if len(parts) > 1 else parts[0]
)
except Exception as exc: # noqa: BLE001
# The crash and the silent 0.04 s are the same event: too few
# speech tokens. Here the mel was short enough to break the f0
# predictor's conv1d before anything was yielded, so it is the
# same re-roll that answers it.
last_exc, last_dur = exc, 0.0
else:
last_dur = candidate.shape[-1] / sr
if last_dur >= floor_s:
if attack_asr is None:
wav = candidate
break
attack_checks += 1
verdict = _cv3_attack_verdict(
candidate, sr, chunk, i, chunk_seed,
attack_asr, attack_tmp,
)
if verdict["attack_dropped"]:
attack_rerolls += 1
last_attack = verdict
logger.warning(
"[CV3] seg %s chunk %d/%d attempt %d/%d: attack "
"dropped (%s, %d leading word(s) lost) - re-"
"rolling seed (#19739)",
seg.seg_index, i + 1, len(chunks), attempt + 1,
_CV3_RENDER_ATTEMPTS, verdict["reason"],
verdict["n_missing_leading"],
)
continue
wav = candidate
break
logger.warning(
"[CV3] seg %s chunk %d/%d attempt %d/%d: degenerate render "
"(%.2fs for %d chars, floor %.2fs%s) - re-rolling seed",
seg.seg_index, i + 1, len(chunks), attempt + 1,
_CV3_RENDER_ATTEMPTS, last_dur, len(chunk), floor_s,
f", {type(last_exc).__name__}" if last_exc else "",
)
if wav is None:
# Hard failure, never a silent short MP3: the run counts it in
# Failed, which is honest, where a 0.10 s file is not.
raise NarratorCosyVoice3Unavailable(
f"seg {seg.seg_index}: chunk {i + 1}/{len(chunks)} still "
f"degenerate after {_CV3_RENDER_ATTEMPTS} attempts (last "
f"{last_dur:.2f}s for {len(chunk)} chars, floor "
f"{floor_s:.2f}s): {chunk[:60]!r}"
) from last_exc
wavs.append(wav)
if wav is None:
# Hard failure, never a silent short MP3: the run counts it in
# Failed, which is honest, where a 0.10 s file is not.
if last_attack is not None and last_dur >= floor_s:
raise NarratorCosyVoice3Unavailable(
f"seg {seg.seg_index}: chunk {i + 1}/{len(chunks)} "
f"attack still dropped after {_CV3_RENDER_ATTEMPTS} "
f"attempts (last verdict: {last_attack['reason']}, "
f"{last_attack['n_missing_leading']} leading word(s) "
f"lost): {chunk[:60]!r} (#19739)"
)
raise NarratorCosyVoice3Unavailable(
f"seg {seg.seg_index}: chunk {i + 1}/{len(chunks)} still "
f"degenerate after {_CV3_RENDER_ATTEMPTS} attempts (last "
f"{last_dur:.2f}s for {len(chunk)} chars, floor "
f"{floor_s:.2f}s): {chunk[:60]!r}"
) from last_exc
wavs.append(wav)
elapsed = time.time() - t0
if attack_asr is not None:
logger.info(
"[CV3] seg %s: attack guard checked %d render(s), re-rolled %d (#19739)",
seg.seg_index, attack_checks, attack_rerolls,
)

pause = torch.zeros(1, int(_CV3_PAUSE_S * sr))
parts_audio: list[torch.Tensor] = []
Expand Down
Loading
Loading