Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/workflows/scripts-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ on:
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py'
# #17586 narrateur CustomVoice (Qwen3-TTS, opt-in, defaut OFF). Hermes
# Finding 1 (review sur 69de2cdf) : sans ces 3 entrees, une PR qui ne
# touche QUE le routage narrateur ne declenche aucun run CI (#10416
# pattern) -- 8 tests de routage invisibles jusqu'au prochain innocent
# qui touche scripts/**. Mirrored under pull_request: below.
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/p5_tts.py'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/qwen_tts_client.py'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_p5_narrator_routing.py'
# #11835 no-op guard: scripts/tests/test_pr_gate_rerun_noop_guard.py pins
# the job-level if: of pr-gate-rerun.yml. Without this entry a PR touching
# ONLY the workflow fires no scripts-tests run -- the guard is blind to
Expand Down Expand Up @@ -166,6 +174,10 @@ on:
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bank_schema_v1.json'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/bootstrap_bank.py'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py'
# #17586 narrateur CustomVoice (mirror of the push path above).
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/p5_tts.py'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/qwen_tts_client.py'
- 'MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_p5_narrator_routing.py'
# #11835 no-op guard (mirror of the push path above).
- '.github/workflows/pr-gate-rerun.yml'
# Mirror the push path above so fork workflow-only PRs execute the policy.
Expand Down Expand Up @@ -414,6 +426,7 @@ jobs:
MyIA.AI.Notebooks/SymbolicAI/Lean/agent_tests/tests/test_prover_forensic_guards.py \
MyIA.AI.Notebooks/SymbolicAI/Lean/agent_tests/tests/test_provider_gate_18709.py \
MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_bake_bank.py \
MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/prosody_lab/test_p5_narrator_routing.py \
MyIA.AI.Notebooks/ML/DataScienceWithAgents/01-PythonForDataScience/tests \
scripts/secrets/tests \
scripts/quantconnect/tests \
Expand Down
127 changes: 127 additions & 0 deletions MyIA.AI.Notebooks/GenAI/Audio/04-Applications/v4/p5_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,46 @@
# points like "route,[short pause] quand" -> "route,quand").
_CV3_NARRATOR_REFERENCE_ID: str = "cosyvoice3-zeroshot-narrator-fr-v3"

# Phase 2 of the narrator pivot (#17586): a third candidate engine,
# Qwen3-TTS CustomVoice (1.7B, Apache-2.0), synthesizes the narrator with a
# named native speaker + a neutral style instruction. The DEFAULT IS
# UNCHANGED — flag "0": the engine is opt-in until the user confirms
# (acceptance #6 of #15002: never a silent swap). Same exclusivity
# contract as CosyVoice3 above: combining NARRATOR_QWEN_CUSTOMVOICE_ROUTING
# with another active flag is a configuration error, not a silent
# precedence.
_NARRATOR_QWEN_CUSTOMVOICE_ROUTING: bool = (
os.getenv("NARRATOR_QWEN_CUSTOMVOICE_ROUTING", "0") == "1"
)
# Speaker name + neutral style instruction. Defaults match the
# A0C-retenu render parameters of the 12/10 phase (speaker + neutral
# instruction + sentence-level chunking) — overridable via env so the
# coordinator can pin the exact render if the user later changes the
# chosen voice. The cache sentinel embeds the speaker name: re-pointing
# NARRATOR_QWEN_CUSTOMVOICE_SPEAKER also invalidates the cache, by
# design (a clean env override alone re-renders, no manual bump).
_QWEN_CUSTOMVOICE_SPEAKER: str = os.getenv(
"NARRATOR_QWEN_CUSTOMVOICE_SPEAKER", "aiden"
)
_QWEN_CUSTOMVOICE_INSTRUCTIONS: str = os.getenv(
"NARRATOR_QWEN_CUSTOMVOICE_INSTRUCTIONS",
"Voix masculine francaise, posee, sans emotion particuliere, "
"debit modere, ton narratif de roman du XIXe siecle, "
"registre audiobook litteraire.",
)
_CVOICE_NARRATOR_REFERENCE_ID: str = (
f"qwen3tts-customvoice-{_QWEN_CUSTOMVOICE_SPEAKER}-narrateur-fr-v1"
)

# Engine -> reference_id sentinel. The narrator cache is keyed on it
# (#19692): an MP3 rendered by one engine is never served for another.
# The CustomVoice sentinel embeds the speaker name so that re-pointing
# NARRATOR_QWEN_CUSTOMVOICE_SPEAKER also invalidates the cache without
# a manual bump (a clean env override alone re-renders, by design).
_NARRATOR_ENGINE_REFERENCE_IDS: dict[str, str] = {
"qwen_voicedesign": _QWEN_NARRATOR_REFERENCE_ID,
"cosyvoice3": _CV3_NARRATOR_REFERENCE_ID,
"qwen_customvoice": _CVOICE_NARRATOR_REFERENCE_ID,
}

# VoiceDesign `instructions` prompt — describes the desired voice and delivery
Expand Down Expand Up @@ -909,6 +944,14 @@ def _synthesize_segment(seg: AnnotatedSegment, fishaudio_text: str) -> TTSResult
seed=seed,
text_hash=current_hash,
)
if _should_route_narrator_to("qwen_customvoice", seg):
return _synthesize_narrator_customvoice(
seg=seg,
fishaudio_text=fishaudio_text,
mp3_path=mp3_path,
seed=seed,
text_hash=current_hash,
)

return _synthesize_fishaudio_path(
seg=seg,
Expand Down Expand Up @@ -1026,6 +1069,7 @@ def _narrator_engine_flags() -> dict[str, bool]:
return {
"qwen_voicedesign": _NARRATOR_QWEN_ROUTING,
"cosyvoice3": _NARRATOR_COSYVOICE3_ROUTING,
"qwen_customvoice": _NARRATOR_QWEN_CUSTOMVOICE_ROUTING,
}


Expand Down Expand Up @@ -1484,6 +1528,89 @@ def _synthesize_narrator_cosyvoice3(
)


# ---------------------------------------------------------------------------
# Phase 2 (#17586) — Narrator routing to Qwen3-TTS CustomVoice
# ---------------------------------------------------------------------------
# Reuses the same NarratorQwenUnavailable contract: hard failure on gateway
# unreachable / render error, never a silent fall back to FishAudio (#15002
# acceptance 6). The default is OFF: the engine is opt-in until the user
# confirms, and combining it with another active flag is a configuration
# error caught at flag resolution time (see _selected_narrator_engine).


def _synthesize_narrator_customvoice(
seg: AnnotatedSegment,
fishaudio_text: str,
mp3_path: Path,
seed: int,
text_hash: str,
) -> TTSResult:
"""Synthesize a narrator segment via Qwen3-TTS CustomVoice (port :8196).

Named native speaker + neutral style instruction (defaults overridable
via env). Chunked by sentence using the same splitter as VoiceDesign
so a long passage renders in pieces under the gateway timeout. Returns
a TTSResult with status="generated" on success; raises
NarratorQwenUnavailable on any gateway / render failure (no silent
fall back to FishAudio — #15002 acceptance 6).
"""
# Imported here (not at module top) so this module remains usable even
# if the prosody_lab package is absent in a slim environment.
from .prosody_lab.qwen_tts_client import qwen_tts_customvoice_chunked

plain_text = _strip_brackets_for_qwen(fishaudio_text)
if not plain_text:
raise NarratorQwenUnavailable(
f"seg {seg.seg_index}: empty text after stripping brackets"
)

wav_bytes = qwen_tts_customvoice_chunked(
plain_text,
voice=_QWEN_CUSTOMVOICE_SPEAKER,
instructions=_QWEN_CUSTOMVOICE_INSTRUCTIONS,
language="French",
speed=1.0,
max_chars=_MAX_CHUNK_CHARS,
per_chunk_timeout=290,
gap_ms=120,
)
if not wav_bytes:
raise NarratorQwenUnavailable(
f"seg {seg.seg_index}: Qwen CustomVoice returned None (gateway "
f"unreachable, voice={_QWEN_CUSTOMVOICE_SPEAKER} rejected, or "
f"render failed)"
)

# CustomVoice returns WAV; downstream pipeline writes MP3 (same naming
# convention as VoiceDesign and CosyVoice3 paths). Convert via pydub at
# 192 kbps to match.
try:
from pydub import AudioSegment
audio = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav")
buf = io.BytesIO()
audio.export(buf, format="mp3", bitrate="192k")
mp3_bytes = buf.getvalue()
except Exception as exc: # pragma: no cover — pydub/ffmpeg missing
raise NarratorQwenUnavailable(
f"seg {seg.seg_index}: WAV->MP3 conversion failed: {exc}"
) from exc

mp3_path.write_bytes(mp3_bytes)
duration = audio_duration_mp3(mp3_bytes)

return TTSResult(
seg_index=seg.seg_index,
speaker=seg.speaker,
reference_id=_CVOICE_NARRATOR_REFERENCE_ID,
mp3_path=str(mp3_path),
duration_s=duration,
seed=seed,
status="generated",
attempts=1,
text_hash=text_hash,
)


def _load_cached_hashes() -> dict[int, str]:
"""Load text hashes from tts_results.json once (O(N) not O(N²))."""
results_path = BASE_DIR / "outputs" / "tts_results.json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,60 @@ def qwen_tts_customvoice(
return _post_speech(payload, timeout)


def qwen_tts_customvoice_chunked(
text: str,
voice: str = "Vivian",
instructions: str = "",
language: str = "French",
speed: float = 1.0,
max_chars: int = 160,
per_chunk_timeout: int = 290,
gap_ms: int = 120,
) -> bytes | None:
"""CustomVoice render of long text, chunked to stay under the gateway timeout.

Mirrors :func:`qwen_tts_voicedesign_chunked`: same sentence-level
chunker (max_chars default = 160), same per-chunk timeout / inter-chunk
silence, same hard failure on any chunk error. Returns the concatenated
WAV bytes, or ``None`` if any chunk fails (no partial passage).
"""
from io import BytesIO

from pydub import AudioSegment

chunks = _split_sentences(text, max_chars=max_chars)
if not chunks:
return None
logger.info(
"CustomVoice chunked render: %d chunk(s), voice=%s", len(chunks), voice
)
segments: list[AudioSegment] = []
for i, ch in enumerate(chunks, start=1):
audio = qwen_tts_customvoice(
ch,
voice=voice,
instructions=instructions,
language=language,
response_format="wav",
speed=speed,
timeout=per_chunk_timeout,
)
if not audio:
logger.error(
"CustomVoice chunk %d/%d failed (%d chars, voice=%s): %r",
i, len(chunks), len(ch), voice, ch[:60],
)
return None
segments.append(AudioSegment.from_file(BytesIO(audio), format="wav"))
combined = segments[0]
gap = AudioSegment.silent(duration=gap_ms)
for seg in segments[1:]:
combined = combined + gap + seg
buf = BytesIO()
combined.export(buf, format="wav")
return buf.getvalue()


def qwen_tts_clone(
text: str,
ref_audio: str,
Expand Down
Loading
Loading