diff --git a/factory_config.yaml b/factory_config.yaml index bd34350..5c47e6e 100644 --- a/factory_config.yaml +++ b/factory_config.yaml @@ -47,7 +47,7 @@ pipeline: # Spanish: es-MX-JorgeNeural, es-MX-DaliaNeural, es-US-AlonsoNeural, es-ES-AlvaroNeural voices: en: "af_heart" - es: "es-MX-JorgeNeural" + es: "es-US-AlonsoNeural" # A/B Testing Thumbnails (generates N visual concepts in EN, ES, and Clean) thumbnail_count: 3 diff --git a/pipeline/__pycache__/script_gen.cpython-314.pyc b/pipeline/__pycache__/script_gen.cpython-314.pyc index 39b4585..8e68608 100644 Binary files a/pipeline/__pycache__/script_gen.cpython-314.pyc and b/pipeline/__pycache__/script_gen.cpython-314.pyc differ diff --git a/pipeline/__pycache__/tts_gen.cpython-314.pyc b/pipeline/__pycache__/tts_gen.cpython-314.pyc index d5fe7d7..e987689 100644 Binary files a/pipeline/__pycache__/tts_gen.cpython-314.pyc and b/pipeline/__pycache__/tts_gen.cpython-314.pyc differ diff --git a/pipeline/script_gen.py b/pipeline/script_gen.py index d1c2b3b..36f3060 100644 --- a/pipeline/script_gen.py +++ b/pipeline/script_gen.py @@ -139,20 +139,29 @@ JSON Schema: "description": desc } - prompt = f"""You are a professional documentary translator. -Translate the following English YouTube documentary package into fluent, natural Latin-American Spanish for voiceover and YouTube upload. + prompt = f"""You are a master Spanish documentary voiceover narrator and localization expert (National Geographic / Discovery Channel en Español). +Translate the following English YouTube documentary package into captivating, natural, spoken Latin-American Spanish for voiceover narration and YouTube publishing. English Data: {json.dumps(translation_payload, ensure_ascii=False, indent=2)} -Requirements: -- "titles_es": 3 catchy Spanish titles matching the English titles -- "narrations_es": array of exact same length containing the Spanish voiceover text -- "chapters_es": array of exact same length containing short 2-4 word Spanish chapter titles -- "on_screen_texts_es": array of exact same length containing Spanish translations of the on-screen text labels (keep empty if original is empty) -- "description_es": full Spanish translation of the YouTube description with Spanish hashtags -- Output STRICT JSON only with keys: {{"titles_es": [...], "narrations_es": [...], "chapters_es": [...], "on_screen_texts_es": [...], "description_es": "..."}} -- No markdown formatting or extra text.""" +CRITICAL SPOKEN VOICE REQUIREMENTS: +1. Spoken Eloquence & Natural Flow: + - Write as if spoken by a native Latin-American documentary orator with rich, gripping cadence. + - Use natural spoken Spanish sentence connectors ("Sin embargo,", "A lo largo de los siglos,", "Lo más fascinante es que...", "No obstante,"). + - Avoid rigid word-for-word literal translations from English. +2. Punctuation for Breathing & Timing: + - Use commas and question marks (¿...?) thoughtfully so the text-to-speech engine pauses with natural human breathing. +3. Proper Nouns & Accuracy: + - Keep recognized proper names accurate ("Whitechapel", "Londres", "Alejandría", "Göbeklitepe"). +4. "titles_es": 3 high-CTR Spanish titles +5. "narrations_es": array of exact same length containing the rich Spanish voiceover text +6. "chapters_es": array of exact same length containing short 2-4 word Spanish chapter titles +7. "on_screen_texts_es": array of exact same length containing Spanish lower-third labels +8. "description_es": full Spanish description with Spanish hashtags + +STRICT OUTPUT FORMAT: +Output STRICT JSON only: {{"titles_es": [...], "narrations_es": [...], "chapters_es": [...], "on_screen_texts_es": [...], "description_es": "..."}}""" for attempt in (1, 2): text = self._chat(pc, prompt, max_tokens=4096) @@ -251,20 +260,25 @@ Return ONLY the corrected script as a single valid JSON object starting with {{ ] } - prompt = f"""You are a master Spanish documentary editor and translator. -Review and polish this Spanish translation for topic "{topic}". + prompt = f"""You are a senior Spanish documentary voiceover director and phonetic editor. +Review, polish, and perfect this Spanish documentary narration for topic "{topic}". -OBJECTIVES: -1. Fix proper nouns (keep standard names like "Whitechapel", "Jack el Destripador", "Londres", "Alejandría" accurate). -2. Correct any phonetic spelling mistakes or awkward phrasing. -3. Ensure natural Latin-American documentary narration tone and correct accent marks (tildes). -4. Preserve the exact number of segments. +OBJECTIVES FOR 100% NATURAL SPOKEN SPANISH: +1. Native Cadence & Phrasing: + - Eliminate any awkward literal translations or robotic sentence structures. + - Refine sentence flow so every line sounds natural, authoritative, and compelling when spoken aloud. +2. Proper Punctuation & Breathing Pauses: + - Ensure natural comma placement to give the voice synthesizer natural breathing pauses. +3. Proper Noun & Pronunciation Accuracy: + - Ensure historical figures, locations, and proper nouns are written in their correct standard Spanish forms (e.g., "Whitechapel", "Jack el Destripador", "Londres", "Alejandría", "Göbeklitepe"). +4. Preserve Structure: + - Maintain the exact same number of segments. Spanish Data: {json.dumps(payload, ensure_ascii=False, indent=2)} STRICT OUTPUT FORMAT: -Return STRICT JSON only with keys: {{"titles_es": [...], "description_es": "...", "segments_es": [{{"chapter_title_es": "...", "text_es": "...", "on_screen_text_es": "..."}}, ...]}}""" +Return STRICT JSON only: {{"titles_es": [...], "description_es": "...", "segments_es": [{{"chapter_title_es": "...", "text_es": "...", "on_screen_text_es": "..."}}, ...]}}""" try: raw_text = self._chat(pc, prompt, max_tokens=8192) diff --git a/pipeline/tts_gen.py b/pipeline/tts_gen.py index 8a88f5f..6362b5a 100644 --- a/pipeline/tts_gen.py +++ b/pipeline/tts_gen.py @@ -4,6 +4,9 @@ Generates crystal-clear narration in English and Spanish with accurate per-segme """ import os +import sys +import re +import shutil import subprocess import logging import wave @@ -38,8 +41,35 @@ try: except ImportError: edge_tts = None -def _run_edge_tts(text: str, voice: str, out_wav_path: str): - """Synthesize high-quality natural neural voiceover directly via module or CLI.""" +def clean_and_format_spanish_for_tts(text: str) -> str: + """ + Format Spanish narration text to sound 100% natural when spoken: + - Expands historical abbreviations (a.C., d.C., siglos, etc.) + - Formats numbers, units, and punctuation for natural human breathing pauses. + """ + t = text.strip() + # Normalize historical dates and eras + t = re.sub(r'\b(?:a\.\s*C\.|a\s*C|A\.\s*C\.|A\s*C)\b', 'antes de Cristo', t) + t = re.sub(r'\b(?:d\.\s*C\.|d\s*C|D\.\s*C\.|D\s*C)\b', 'después de Cristo', t) + t = re.sub(r'\bsiglo\s+XXI\b', 'siglo veintiuno', t, flags=re.IGNORECASE) + t = re.sub(r'\bsiglo\s+XX\b', 'siglo veinte', t, flags=re.IGNORECASE) + t = re.sub(r'\bsiglo\s+XIX\b', 'siglo diecinueve', t, flags=re.IGNORECASE) + t = re.sub(r'\bsiglo\s+XVIII\b', 'siglo dieciocho', t, flags=re.IGNORECASE) + t = re.sub(r'\bsiglo\s+XV\b', 'siglo quince', t, flags=re.IGNORECASE) + t = re.sub(r'\bEE\.?\s*UU\.?\b', 'Estados Unidos', t) + t = re.sub(r'\bkm/h\b', 'kilómetros por hora', t) + t = re.sub(r'(\d+)\s*km\b', r'\1 kilómetros', t) + t = re.sub(r'(\d+)\s*m\b', r'\1 metros', t) + t = re.sub(r'(\d+)\s*ha\b', r'\1 hectáreas', t) + + # Soften ellipses and replace multiple hyphens with clean commas for natural pausing + t = re.sub(r'\.{3,}', ', ', t) + t = re.sub(r'\s*--+\s*', ', ', t) + t = re.sub(r'\s+', ' ', t) + return t + +def _run_edge_tts(text: str, voice: str, out_wav_path: str, rate: str = "-4%"): + """Synthesize high-quality natural neural voiceover directly via module or CLI with studio mastering.""" global edge_tts if edge_tts is None: try: @@ -51,28 +81,44 @@ def _run_edge_tts(text: str, voice: str, out_wav_path: str): except Exception as e: logger.warning(f"[TTS] Dynamic edge-tts install failed: {e}") + # Preprocess Spanish text for maximum naturalness + if voice.startswith("es-") or "Neural" in voice: + spoken_text = clean_and_format_spanish_for_tts(text) + else: + spoken_text = text + tmp_mp3 = out_wav_path.replace(".wav", "_temp.mp3") if edge_tts is not None: async def _speak(): - communicate = edge_tts.Communicate(text, voice) + communicate = edge_tts.Communicate(spoken_text, voice, rate=rate) await communicate.save(tmp_mp3) asyncio.run(_speak()) else: # Fallback to sys.executable -m edge_tts - cmd = [sys.executable, "-m", "edge_tts", "--voice", voice, "--text", text, "--write-media", tmp_mp3] + cmd = [sys.executable, "-m", "edge_tts", "--voice", voice, "--rate", rate, "--text", spoken_text, "--write-media", tmp_mp3] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: edge_bin = shutil.which("edge-tts") or os.path.expanduser("~/.local/bin/edge-tts") or os.path.expanduser("~/miniconda3/bin/edge-tts") if edge_bin and os.path.exists(edge_bin): - r2 = subprocess.run([edge_bin, "--voice", voice, "--text", text, "--write-media", tmp_mp3], capture_output=True, text=True) + r2 = subprocess.run([edge_bin, "--voice", voice, "--rate", rate, "--text", spoken_text, "--write-media", tmp_mp3], capture_output=True, text=True) if r2.returncode != 0: raise RuntimeError(f"edge-tts failed: {r.stderr or r2.stderr}") else: raise RuntimeError(f"edge-tts execution error: {r.stderr}") + # Broadcast vocal mastering filter (warm low-mid EQ, subtle presence, transparent leveling) + vocal_af = ( + "highpass=f=75," + "equalizer=f=200:t=q:w=1.2:g=1.8," + "equalizer=f=3500:t=q:w=1.5:g=1.0," + "compand=attacks=0.03:decays=0.15:points=-80/-80|-40/-30|-20/-14|0/-2:gain=2," + "volume=1.05" + ) + subprocess.run([ "ffmpeg", "-y", "-i", tmp_mp3, + "-af", vocal_af, "-ar", "44100", "-ac", "2", "-c:a", "pcm_s16le", out_wav_path ], check=True, capture_output=True) if os.path.exists(tmp_mp3): @@ -81,7 +127,7 @@ def _run_edge_tts(text: str, voice: str, out_wav_path: str): class TTSGen: def __init__(self, pcs: Dict[str, RemotePC], voices: Dict[str, str], tts_port: int = 8003): self.pcs = pcs - self.voices = voices or {"en": "af_heart", "es": "es-MX-JorgeNeural"} + self.voices = voices or {"en": "af_heart", "es": "es-US-AlonsoNeural"} self.tts_port = tts_port def generate(self, script_data: Dict[str, Any], job_dir: str, lang: str = "en") -> Dict[str, Any]: