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
logger.info("[DEPS] Automatically installing missing Python packages via pip...")
try:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade"] + missing
r = subprocess.run(cmd, check=True, text=True)
logger.info("[DEPS] All Python packages successfully installed!")
return True
except subprocess.CalledProcessError as e:
logger.error(f"[DEPS] Automatic pip install failed: {e}")
logger.error(f"Please run manually: {sys.executable} -m pip install -r requirements.txt")
return False
install_commands = [
[sys.executable, "-m", "pip", "install", "--upgrade"] + missing,
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--upgrade"] + missing,
[sys.executable, "-m", "pip", "install", "--user", "--break-system-packages"] + missing,
]
for cmd in install_commands:
try:
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:
"""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")
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)
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)
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)
@ -300,6 +300,51 @@ Return STRICT JSON only: {{"titles_es": [...], "description_es": "...", "segment
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]:
"""Generate master bilingual script with secondary LLM verification & proofreading passes."""
logger.info(f"[SCRIPT] Generating script for topic: '{topic}' (~{duration}s)...")
@ -348,6 +393,9 @@ Return STRICT JSON only: {{"titles_es": [...], "description_es": "...", "segment
# ── Step 1.7: Secondary LLM Spanish Translation Audit ──
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)")
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):
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:
def __init__(self, pcs: Dict[str, RemotePC], voices: Dict[str, str], tts_port: int = 8003):
self.pcs = pcs
@ -134,12 +146,16 @@ class TTSGen:
"""Generate voiceover audio for all segments in the specified language."""
segments = script_data.get("script_segments", [])
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)
# 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"):
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}'...")
@ -153,16 +169,19 @@ class TTSGen:
is_neural = voice.startswith(("es-", "en-", "fr-", "de-", "it-", "pt-"))
for i, seg in enumerate(segments):
text = seg.get(text_key, "").strip()
if not text:
text = seg.get("text", f"Segment {i+1}")
raw_text = seg.get(text_key, "").strip()
if not raw_text:
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")
if is_neural:
# Use native neural speech engine
try:
_run_edge_tts(text, voice, seg_path)
_run_edge_tts(spoken_text, voice, seg_path)
except Exception as e:
logger.warning(f"[TTS:{lang.upper()}] Neural TTS failed: {e}, attempting Kokoro fallback...")
is_neural = False
@ -174,7 +193,7 @@ class TTSGen:
raise RuntimeError(f"IntelLLM Kokoro TTS not reachable on port {self.tts_port}")
payload = {
"input": text,
"input": spoken_text,
"voice": "af_heart" if lang == "en" else "ef_dora",
"speed": 1.0,
"lang": "e" if lang == "es" else "a"

View file

@ -45,6 +45,27 @@ def h3_snap_length(seconds: float) -> int:
# ─── 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:
def __init__(self, pexels_key: Optional[str] = None, pixabay_key: Optional[str] = None):
self.pexels_key = pexels_key
@ -176,28 +197,39 @@ class StockMediaFetcher:
pass
return False
def search_and_download(self, query: str, out_path: str, prefer_video: bool = False) -> Optional[str]:
"""Search across stock engines and download asset."""
logger.info(f"[STOCK] Searching stock media for: '{query}' (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(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
def search_and_download(self, queries: Any, out_path: str, prefer_video: bool = False) -> Optional[str]:
"""Search across stock engines with candidate query cascade and download asset."""
if isinstance(queries, str):
candidate_list = [queries]
else:
candidate_list = list(queries)
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(query)
if p_url and self.download_media(p_url, out_path):
logger.info(f"[STOCK] Downloaded {label} photo for '{query}'")
return out_path
for raw_q in candidate_list:
if not raw_q:
continue
cleaned_q = clean_search_keywords(raw_q)
if not cleaned_q:
continue
logger.debug(f"[STOCK] No stock media found for: '{query}'")
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 matching stock media found for queries: {candidate_list}")
return None
# ─── Ken Burns Motion Engine ────────────────────────────────────────────────
@ -529,7 +561,21 @@ class VisualGen:
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"
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)
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")
attempts = {
"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),
"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":
order = ["ai_video", "ai_image", "stock_video", "stock_image"]