corrected pronounciation

This commit is contained in:
badmark 2026-08-16 11:26:57 -06:00
parent 44fa4da187
commit 5e168ad74b
8 changed files with 161 additions and 40 deletions

View file

@ -50,15 +50,23 @@ def check_python_packages(auto_install: bool = True) -> bool:
return False return False
logger.info("[DEPS] Automatically installing missing Python packages via pip...") logger.info("[DEPS] Automatically installing missing Python packages via pip...")
try: install_commands = [
cmd = [sys.executable, "-m", "pip", "install", "--upgrade"] + missing [sys.executable, "-m", "pip", "install", "--upgrade"] + missing,
r = subprocess.run(cmd, check=True, text=True) [sys.executable, "-m", "pip", "install", "--break-system-packages", "--upgrade"] + missing,
logger.info("[DEPS] All Python packages successfully installed!") [sys.executable, "-m", "pip", "install", "--user", "--break-system-packages"] + missing,
return True ]
except subprocess.CalledProcessError as e:
logger.error(f"[DEPS] Automatic pip install failed: {e}") for cmd in install_commands:
logger.error(f"Please run manually: {sys.executable} -m pip install -r requirements.txt") try:
return False r = subprocess.run(cmd, check=True, capture_output=True, text=True)
logger.info("[DEPS] All Python packages successfully installed!")
return True
except subprocess.CalledProcessError:
continue
logger.error(f"[DEPS] Automatic pip install failed.")
logger.error(f"Please run manually: {sys.executable} -m pip install --break-system-packages -r requirements.txt")
return False
def check_system_binaries(auto_install: bool = True) -> bool: def check_system_binaries(auto_install: bool = True) -> bool:
"""Check for required system binaries (ffmpeg, ssh, scp, etc.) and attempt self-install.""" """Check for required system binaries (ffmpeg, ssh, scp, etc.) and attempt self-install."""

View file

@ -37,7 +37,7 @@ For EACH segment provide:
1. "chapter_title": Short 2-4 word chapter title (e.g., "The Ancient Mystery", "The Critical Discovery") 1. "chapter_title": Short 2-4 word chapter title (e.g., "The Ancient Mystery", "The Critical Discovery")
2. "text": English spoken narration (rich 2-4 sentence narrative paragraph, natural pacing, compelling storytelling) 2. "text": English spoken narration (rich 2-4 sentence narrative paragraph, natural pacing, compelling storytelling)
3. "visual_prompt": Highly detailed visual scene description for an AI video/image generator (cinematic lighting, camera angle, 8k documentary style) 3. "visual_prompt": Highly detailed visual scene description for an AI video/image generator (cinematic lighting, camera angle, 8k documentary style)
4. "stock_query": 2-4 word search term to find relevant stock b-roll footage online (e.g., "ancient library books", "desert ruins aerial") 4. "stock_query": 2-4 concrete, high-precision visual search keywords describing physical objects/scenery that exist in stock video libraries (e.g., "ancient temple monoliths", "medieval cathedral altar", "archaeological excavation desert", "prehistoric stone tools", "ancient scrolls manuscript"). Never use abstract concepts, full sentences, or obscure person names.
5. "on_screen_text": Optional 2-5 word lower-third label, key statistic, date, or scene title to display on screen (e.g., "Depth: 11,034m", "Year: 1960", "Pacific Ocean Abyss", or "" if none) 5. "on_screen_text": Optional 2-5 word lower-third label, key statistic, date, or scene title to display on screen (e.g., "Depth: 11,034m", "Year: 1960", "Pacific Ocean Abyss", or "" if none)
6. "is_historical": boolean (true if this segment depicts past events, ancient civilizations, historical figures, archaeological discoveries, or vintage eras; false if modern/contemporary) 6. "is_historical": boolean (true if this segment depicts past events, ancient civilizations, historical figures, archaeological discoveries, or vintage eras; false if modern/contemporary)
7. "duration_estimate": Estimated duration in seconds ({max(10, duration // num_segments)}s) 7. "duration_estimate": Estimated duration in seconds ({max(10, duration // num_segments)}s)
@ -300,6 +300,51 @@ Return STRICT JSON only: {{"titles_es": [...], "description_es": "...", "segment
return script_data return script_data
def _generate_phonetic_pronunciation_lexicon(self, pc: RemotePC, script_data: Dict[str, Any], topic: str) -> Dict[str, Dict[str, str]]:
"""
Scan script for foreign, ancient, archaeological, mythological, or diacritic-heavy words
(e.g., 'Göbeklitepe', 'Derinkuyu', 'Tutankhamun', 'Quetzalcoatl') and look up phonetic respellings
for English and Spanish voice synthesizers.
"""
logger.info("[SCRIPT:PHONETICS] Analyzing and looking up proper noun phonetic pronunciations...")
segments = script_data.get("script_segments", [])
sample_texts = [seg.get("text", "") for seg in segments[:10]] + [topic]
combined_text = "\n".join(sample_texts)
prompt = f"""You are an expert linguistic phonetics engine and voice synthesis director.
Analyze the following documentary script for topic "{topic}".
OBJECTIVE:
Identify any foreign, ancient, archaeological, mythological, scientific, non-standard, or diacritic-heavy words (e.g., "Göbeklitepe", "Derinkuyu", "Tutankhamun", "Quetzalcoatl", "Chichen Itza", "Oppenheimer", "Oumuamua", "Tiahuanaco", "Mohenjo-Daro", etc.) that a Text-to-Speech (TTS) voice synthesizer might struggle to pronounce correctly.
For EACH identified word, provide:
1. "en": Phonetic respelling for English TTS (using clear syllables, e.g., "Göbeklitepe" -> "Goh-beck-lee Teh-peh", "Chichen Itza" -> "Chee-chen Eet-zah")
2. "es": Phonetic respelling for Spanish TTS (using Spanish phonetic rules, e.g., "Göbeklitepe" -> "Guebekli Tepe", "Stonehenge" -> "Ston-jench")
If no difficult words exist, return empty objects.
Script text:
{combined_text}
STRICT JSON OUTPUT:
{{
"phonetic_mappings": {{"en": {{"word": "phonetic_respelling_en"}}, "es": {{"word": "phonetic_respelling_es"}}}}
}}"""
try:
raw_text = self._chat(pc, prompt, max_tokens=2048)
if raw_text:
parsed = self._extract_json(raw_text)
mappings = parsed.get("phonetic_mappings", {})
if mappings and (mappings.get("en") or mappings.get("es")):
logger.info(f"[SCRIPT:PHONETICS] Discovered {len(mappings.get('en', {}))} phonetic pronunciation overrides.")
return mappings
except Exception as e:
logger.debug(f"[SCRIPT:PHONETICS] Phonetic lookup parse note: {e}")
return {"en": {}, "es": {}}
def generate(self, topic: str, duration: int) -> Dict[str, Any]: def generate(self, topic: str, duration: int) -> Dict[str, Any]:
"""Generate master bilingual script with secondary LLM verification & proofreading passes.""" """Generate master bilingual script with secondary LLM verification & proofreading passes."""
logger.info(f"[SCRIPT] Generating script for topic: '{topic}' (~{duration}s)...") logger.info(f"[SCRIPT] Generating script for topic: '{topic}' (~{duration}s)...")
@ -347,6 +392,9 @@ Return STRICT JSON only: {{"titles_es": [...], "description_es": "...", "segment
# ── Step 1.7: Secondary LLM Spanish Translation Audit ── # ── Step 1.7: Secondary LLM Spanish Translation Audit ──
data = self._proofread_spanish_translation(pc, data, topic) data = self._proofread_spanish_translation(pc, data, topic)
# ── Step 1.8: Phonetic Pronunciation Lookup for Non-Standard Vocabulary ──
data["phonetic_mappings"] = self._generate_phonetic_pronunciation_lexicon(pc, data, topic)
logger.info(f"[SCRIPT] Master script successfully created & audited: '{data['title']}' ({len(data.get('script_segments', []))} segments)") logger.info(f"[SCRIPT] Master script successfully created & audited: '{data['title']}' ({len(data.get('script_segments', []))} segments)")
return data return data

View file

@ -124,6 +124,18 @@ def _run_edge_tts(text: str, voice: str, out_wav_path: str, rate: str = "-4%"):
if os.path.exists(tmp_mp3): if os.path.exists(tmp_mp3):
os.remove(tmp_mp3) os.remove(tmp_mp3)
def apply_phonetic_respellings(text: str, mappings: Dict[str, str]) -> str:
"""Substitute difficult / non-standard proper nouns with phonetic respellings for TTS audio synthesis."""
if not mappings:
return text
t = text
for word, phonetic in mappings.items():
if not word or not phonetic:
continue
pattern = re.compile(rf'\b{re.escape(word)}\b', re.IGNORECASE)
t = pattern.sub(phonetic, t)
return t
class TTSGen: class TTSGen:
def __init__(self, pcs: Dict[str, RemotePC], voices: Dict[str, str], tts_port: int = 8003): def __init__(self, pcs: Dict[str, RemotePC], voices: Dict[str, str], tts_port: int = 8003):
self.pcs = pcs self.pcs = pcs
@ -134,12 +146,16 @@ class TTSGen:
"""Generate voiceover audio for all segments in the specified language.""" """Generate voiceover audio for all segments in the specified language."""
segments = script_data.get("script_segments", []) segments = script_data.get("script_segments", [])
text_key = "text" if lang == "en" else "text_es" text_key = "text" if lang == "en" else "text_es"
default_voice = "af_heart" if lang == "en" else "es-MX-JorgeNeural" default_voice = "af_heart" if lang == "en" else "es-US-AlonsoNeural"
voice = self.voices.get(lang, default_voice) voice = self.voices.get(lang, default_voice)
# Ensure Spanish uses native neural voice if an old Kokoro voice was configured # Ensure Spanish uses native neural voice if an old Kokoro voice was configured
if lang == "es" and voice in ("ef_dora", "em_alex", "ef_clara", "em_santa"): if lang == "es" and voice in ("ef_dora", "em_alex", "ef_clara", "em_santa"):
voice = "es-MX-JorgeNeural" voice = "es-US-AlonsoNeural"
phonetic_map = (script_data.get("phonetic_mappings") or {}).get(lang, {})
if phonetic_map:
logger.info(f"[TTS:{lang.upper()}] Applied {len(phonetic_map)} phonetic pronunciation rules for voice synthesis.")
logger.info(f"[TTS:{lang.upper()}] Synthesizing narration with voice '{voice}'...") logger.info(f"[TTS:{lang.upper()}] Synthesizing narration with voice '{voice}'...")
@ -153,16 +169,19 @@ class TTSGen:
is_neural = voice.startswith(("es-", "en-", "fr-", "de-", "it-", "pt-")) is_neural = voice.startswith(("es-", "en-", "fr-", "de-", "it-", "pt-"))
for i, seg in enumerate(segments): for i, seg in enumerate(segments):
text = seg.get(text_key, "").strip() raw_text = seg.get(text_key, "").strip()
if not text: if not raw_text:
text = seg.get("text", f"Segment {i+1}") raw_text = seg.get("text", f"Segment {i+1}")
# Phonetic respelling applied specifically for spoken audio
spoken_text = apply_phonetic_respellings(raw_text, phonetic_map)
seg_path = os.path.join(abs_job_dir, f"tts_{lang}_{i:03d}.wav") seg_path = os.path.join(abs_job_dir, f"tts_{lang}_{i:03d}.wav")
if is_neural: if is_neural:
# Use native neural speech engine # Use native neural speech engine
try: try:
_run_edge_tts(text, voice, seg_path) _run_edge_tts(spoken_text, voice, seg_path)
except Exception as e: except Exception as e:
logger.warning(f"[TTS:{lang.upper()}] Neural TTS failed: {e}, attempting Kokoro fallback...") logger.warning(f"[TTS:{lang.upper()}] Neural TTS failed: {e}, attempting Kokoro fallback...")
is_neural = False is_neural = False
@ -174,7 +193,7 @@ class TTSGen:
raise RuntimeError(f"IntelLLM Kokoro TTS not reachable on port {self.tts_port}") raise RuntimeError(f"IntelLLM Kokoro TTS not reachable on port {self.tts_port}")
payload = { payload = {
"input": text, "input": spoken_text,
"voice": "af_heart" if lang == "en" else "ef_dora", "voice": "af_heart" if lang == "en" else "ef_dora",
"speed": 1.0, "speed": 1.0,
"lang": "e" if lang == "es" else "a" "lang": "e" if lang == "es" else "a"

View file

@ -45,6 +45,27 @@ def h3_snap_length(seconds: float) -> int:
# ─── Stock Media Fetcher ──────────────────────────────────────────────────── # ─── Stock Media Fetcher ────────────────────────────────────────────────────
STOP_WORDS = {
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with",
"by", "about", "against", "between", "into", "through", "during", "before", "after",
"above", "below", "from", "up", "down", "out", "off", "over", "under", "again",
"further", "then", "once", "here", "there", "when", "where", "why", "how", "all",
"any", "both", "each", "few", "more", "most", "other", "some", "such", "no", "nor",
"not", "only", "own", "same", "so", "than", "too", "very", "can", "will", "just",
"should", "now", "what", "reveals", "stopped", "being", "mankind", "humanity",
"established", "used", "throughout", "history", "origins", "world", "worlds",
"has", "have", "had", "been", "was", "were", "are", "is", "its", "their", "they",
"them", "subjugate", "subjugating", "subjugation", "revealed", "revealing", "secrets",
"truth", "behind", "explained", "documentary", "mystery", "mysteries"
}
def clean_search_keywords(text: str) -> str:
"""Extract clean, concrete visual search terms from a descriptive prompt or query."""
words = re.findall(r'[A-Za-z0-9]+', text.lower())
filtered = [w for w in words if w not in STOP_WORDS and len(w) > 2]
# Keep up to 3 most specific visual keywords
return " ".join(filtered[:3]) if filtered else text.strip()
class StockMediaFetcher: class StockMediaFetcher:
def __init__(self, pexels_key: Optional[str] = None, pixabay_key: Optional[str] = None): def __init__(self, pexels_key: Optional[str] = None, pixabay_key: Optional[str] = None):
self.pexels_key = pexels_key self.pexels_key = pexels_key
@ -176,28 +197,39 @@ class StockMediaFetcher:
pass pass
return False return False
def search_and_download(self, query: str, out_path: str, prefer_video: bool = False) -> Optional[str]: def search_and_download(self, queries: Any, out_path: str, prefer_video: bool = False) -> Optional[str]:
"""Search across stock engines and download asset.""" """Search across stock engines with candidate query cascade and download asset."""
logger.info(f"[STOCK] Searching stock media for: '{query}' (video={prefer_video})...") if isinstance(queries, str):
if prefer_video: candidate_list = [queries]
for label, fn in (("Pexels", self._query_pexels_video), else:
("Pixabay", self._query_pixabay_video), candidate_list = list(queries)
("Wikimedia", lambda q: self._query_wikimedia(q, is_video=True))):
v_url = fn(query)
if v_url and self.download_media(v_url, out_path):
logger.info(f"[STOCK] Downloaded {label} video for '{query}'")
return out_path
return None
for label, fn in (("Pexels", self._query_pexels_photo), for raw_q in candidate_list:
("Pixabay", self._query_pixabay_photo), if not raw_q:
("Wikimedia", lambda q: self._query_wikimedia(q, is_video=False))): continue
p_url = fn(query) cleaned_q = clean_search_keywords(raw_q)
if p_url and self.download_media(p_url, out_path): if not cleaned_q:
logger.info(f"[STOCK] Downloaded {label} photo for '{query}'") continue
return out_path
logger.info(f"[STOCK] Searching stock media for: '{cleaned_q}' (video={prefer_video})...")
if prefer_video:
for label, fn in (("Pexels", self._query_pexels_video),
("Pixabay", self._query_pixabay_video),
("Wikimedia", lambda q: self._query_wikimedia(q, is_video=True))):
v_url = fn(cleaned_q)
if v_url and self.download_media(v_url, out_path):
logger.info(f"[STOCK] Downloaded {label} video for '{cleaned_q}'")
return out_path
else:
for label, fn in (("Pexels", self._query_pexels_photo),
("Pixabay", self._query_pixabay_photo),
("Wikimedia", lambda q: self._query_wikimedia(q, is_video=False))):
p_url = fn(cleaned_q)
if p_url and self.download_media(p_url, out_path):
logger.info(f"[STOCK] Downloaded {label} photo for '{cleaned_q}'")
return out_path
logger.debug(f"[STOCK] No stock media found for: '{query}'") logger.debug(f"[STOCK] No matching stock media found for queries: {candidate_list}")
return None return None
# ─── Ken Burns Motion Engine ──────────────────────────────────────────────── # ─── Ken Burns Motion Engine ────────────────────────────────────────────────
@ -529,7 +561,21 @@ class VisualGen:
Each source falls back to the next one in the rotation on failure. Each source falls back to the next one in the rotation on failure.
""" """
prompt = (seg.get("visual_prompt") or seg.get("text", "")) + ", cinematic documentary style, 8k, detailed" prompt = (seg.get("visual_prompt") or seg.get("text", "")) + ", cinematic documentary style, 8k, detailed"
stock_query = seg.get("stock_query") or seg.get("chapter_title") or seg.get("text", "")[:40] primary_stock = seg.get("stock_query") or ""
chapter_stock = seg.get("chapter_title") or ""
topic_anchor = clean_search_keywords(getattr(self.cfg, "topic", "") or "")
# Build prioritized topical query cascade
query_candidates = []
if primary_stock:
query_candidates.append(primary_stock)
if chapter_stock and topic_anchor:
query_candidates.append(f"{topic_anchor} {chapter_stock}")
elif chapter_stock:
query_candidates.append(chapter_stock)
if topic_anchor:
query_candidates.append(topic_anchor)
seed = zlib.crc32(prompt.encode()) % (2**31) seed = zlib.crc32(prompt.encode()) % (2**31)
final_clip_path = os.path.join(out_dir, f"clip_{idx:03d}.mp4") final_clip_path = os.path.join(out_dir, f"clip_{idx:03d}.mp4")
@ -539,9 +585,9 @@ class VisualGen:
strategy = getattr(self.cfg, "visual_strategy", "hybrid") strategy = getattr(self.cfg, "visual_strategy", "hybrid")
attempts = { attempts = {
"ai_video": lambda: self._try_ai_video(pc, prompt, seed, duration_sec, raw_video, final_clip_path, idx), "ai_video": lambda: self._try_ai_video(pc, prompt, seed, duration_sec, raw_video, final_clip_path, idx),
"stock_video": lambda: self._try_stock(stock_query, duration_sec, raw_video, final_clip_path, idx, prefer_video=True), "stock_video": lambda: self._try_stock(query_candidates, duration_sec, raw_video, final_clip_path, idx, prefer_video=True),
"ai_image": lambda: self._try_ai_image(pc, prompt, seed, duration_sec, raw_img, final_clip_path, idx), "ai_image": lambda: self._try_ai_image(pc, prompt, seed, duration_sec, raw_img, final_clip_path, idx),
"stock_image": lambda: self._try_stock(stock_query, duration_sec, raw_img, final_clip_path, idx, prefer_video=False), "stock_image": lambda: self._try_stock(query_candidates, duration_sec, raw_img, final_clip_path, idx, prefer_video=False),
} }
if strategy == "all_ai": if strategy == "all_ai":
order = ["ai_video", "ai_image", "stock_video", "stock_image"] order = ["ai_video", "ai_image", "stock_video", "stock_image"]