corrected pronounciation
This commit is contained in:
parent
44fa4da187
commit
5e168ad74b
8 changed files with 161 additions and 40 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)...")
|
||||
|
|
@ -347,6 +392,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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
||||
# ─── 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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue