| |
| import re |
| import os |
| from dotenv import load_dotenv |
| import zipfile |
| import requests |
| from indic_transliteration.sanscript import transliterate |
| from indic_transliteration import sanscript |
| import json |
| import time |
| from google.generativeai import GenerativeModel, configure |
| from google.generativeai.types import GenerationConfig |
| try: |
| from google import genai |
| from google.genai import types |
| except ImportError: |
| genai = None |
| types = None |
| print("Warning: google-genai package not installed. Gemini TTS will not work. Install with: pip install google-genai") |
| from src.tools.prompt_utils import get_regional_translation_prompt, get_named_entity_identification_prompt, get_pun_translation_prompt |
|
|
| from typing import Literal |
|
|
| import subprocess |
| import wave |
| import io |
| from pydub import AudioSegment |
| import tempfile |
| import shutil |
| import traceback |
|
|
| |
| __all__ = [ |
| "audio_fn_from_string", |
| "audio_fn", |
| "pun_audio_fn_from_string", |
| "trim_audio_to_max_duration", |
| "pad_audio_to_duration", |
| "combine_audio_with_video", |
| "pun_generate_segment_audios", |
| "pun_check_audio_files", |
| "pun_get_audio_duration" |
| ] |
|
|
| |
| load_dotenv() |
| GEMINI_API_KEY = os.environ.get('GEMINI_API_KEY') |
|
|
| |
| _env_terms = os.environ.get("TECHNICAL_TERMS", "") |
| DEFAULT_TECHNICAL_TERMS = [ |
| "CNN", "RNN", "LSTM", "GRU", "Transformer", "Transformer-based", "BERT", "GPT", |
| "OpenAI", "Gemini", "ffmpeg", "Playwright", "TensorFlow", "PyTorch", "scikit-learn", |
| "API", "MSE", "R-squared", "R2", "accuracy", "precision", "recall", "F1", "BLEU", |
| "AUC", "ROC", "Linear Regression", "logistic regression", "SGD", "Adam", "epoch", |
| "batch", "GPU", "TPU", "embedding", "token", "tokenizer" |
| ] |
| TECHNICAL_TERMS = [t.strip() for t in (_env_terms.split(",") if _env_terms else []) if t.strip()] or DEFAULT_TECHNICAL_TERMS |
|
|
| |
| if GEMINI_API_KEY: |
| try: |
| configure(api_key=GEMINI_API_KEY) |
| gemini = GenerativeModel('gemini-2.0-flash') |
| except Exception: |
| gemini = None |
| else: |
| gemini = None |
| print('Warning: GEMINI_API_KEY not found in environment variables. Some functions may not work.') |
|
|
| LANGUAGE_TO_SCRIPT_MAP = { |
| "hindi": sanscript.DEVANAGARI, |
| "punjabi": sanscript.GURMUKHI, |
| "gujarati": sanscript.GUJARATI, |
| "marathi": sanscript.DEVANAGARI, |
| "kannada": sanscript.KANNADA, |
| } |
|
|
| |
| def get_temp_dir(): |
| return tempfile.gettempdir() |
|
|
| def pun_remove_special_characters(s: str) -> str: |
| if s is None: |
| return s |
| s = s.replace('`', '').replace('\xa0', '').replace(' ', '') |
| s = s.replace('\u200b', '') |
| return s |
|
|
| def _clean_text_for_tts(text: str) -> str: |
| if text is None: |
| return "" |
| text = text.replace('"', '') |
| text = text.replace("'", '') |
| text = text.replace('`', '') |
| text = ' '.join(text.split()) |
| text = text.replace('/', ' or ') |
| return text.strip() |
|
|
| |
| def _make_marker(term: str) -> str: |
| |
| safe = "__KEEPTERM__" + re.sub(r'\W+', '_', term) + "__" |
| return safe |
|
|
| def _protect_technical_terms(text: str) -> (str, dict): |
| """ |
| Replace occurrences of technical terms with markers, return (protected_text, mapping) |
| mapping: marker -> original term |
| """ |
| mapping = {} |
| if not text: |
| return text, mapping |
| |
| terms_sorted = sorted(TECHNICAL_TERMS, key=lambda x: -len(x)) |
| protected = text |
| for term in terms_sorted: |
| |
| |
| pattern = re.compile(re.escape(term), re.IGNORECASE) |
| def _repl(m): |
| orig = m.group(0) |
| marker = _make_marker(term) |
| mapping[marker] = orig |
| return marker |
| protected = pattern.sub(_repl, protected) |
| return protected, mapping |
|
|
| def _restore_markers(text: str, mapping: dict) -> str: |
| if not mapping: |
| return text |
| restored = text |
| for marker, orig in mapping.items(): |
| restored = restored.replace(marker, orig) |
| return restored |
|
|
| |
| def _looks_like_english_token(token: str) -> bool: |
| if not token: |
| return False |
| |
| for t in TECHNICAL_TERMS: |
| if token.lower() == t.lower(): |
| return True |
| |
| ascii_ratio = sum(1 for ch in token if ord(ch) < 128) / max(1, len(token)) |
| if ascii_ratio > 0.85: |
| return True |
| |
| if re.search(r'[A-Z][a-z]+[A-Z]', token) or re.search(r'[\d\(\)\=\+\-_/\.]', token): |
| return True |
| |
| if '.' in token and len(token) <= 40: |
| return True |
| return False |
|
|
| |
| def transliterate_english_to_native_script(sentence, target_language): |
| if sentence is None: |
| return "" |
| if isinstance(sentence, str): |
| |
| if _looks_like_english_token(sentence): |
| return sentence.strip() |
| ascii_ratio = sum(1 for ch in sentence if ord(ch) < 128) / max(1, len(sentence)) |
| if ascii_ratio > 0.8: |
| return sentence.strip() |
| script = LANGUAGE_TO_SCRIPT_MAP.get((target_language or "").lower()) |
| if not script: |
| return sentence |
| try: |
| return transliterate(sentence, sanscript.ITRANS, script) |
| except Exception: |
| return sentence |
|
|
| |
| def translate_to_regional(target_language, text): |
| if not GEMINI_API_KEY or not gemini: |
| print("Warning: Gemini not configured; translate_to_regional returning original text.") |
| return text |
| try: |
| |
| protected_text, mapping = _protect_technical_terms(text or "") |
| cleaned_text = re.sub(r'``````', '', protected_text or '', flags=re.DOTALL) |
| cleaned_text = re.sub(r'```.*?```', '', cleaned_text, flags=re.DOTALL) |
| prompt = get_regional_translation_prompt(target_language, cleaned_text) |
| response = gemini.generate_content( |
| prompt, |
| generation_config=GenerationConfig(response_mime_type="application/json") |
| ) |
| raw_text = response.text |
| |
| try: |
| result = json.loads(raw_text) |
| translated = result.get("translation", text) |
| |
| return _restore_markers(translated, mapping) |
| except json.JSONDecodeError: |
| match = re.search(r"\{.*\}", raw_text, re.DOTALL) |
| if match: |
| try: |
| result = json.loads(match.group(0)) |
| translated = result.get("translation", text) |
| return _restore_markers(translated, mapping) |
| except Exception: |
| pass |
| if (target_language or "").lower() not in ["english", "en"]: |
| return _restore_markers(raw_text.strip(), mapping) |
| return text |
| except Exception as e: |
| print(f":x: Gemini error: {str(e)}") |
| try: |
| if 'raw_text' in locals() and (target_language or "").lower() not in ["english", "en"]: |
| return _restore_markers(raw_text.strip(), {}) |
| except Exception: |
| pass |
| return text |
|
|
| def pun_translate_to_regional(target_language: str, text: str) -> str: |
| if not GEMINI_API_KEY or not gemini: |
| print("Warning: Gemini not configured; pun_translate_to_regional returning original text.") |
| return text |
| try: |
| protected_text, mapping = _protect_technical_terms(text or "") |
| cleaned_text = re.sub(r'```python.*?```', '', protected_text or '', flags=re.DOTALL) |
| cleaned_text = re.sub(r'```.*?```', '', cleaned_text, flags=re.DOTALL) |
| prompt = get_pun_translation_prompt(target_language, cleaned_text) |
| response = gemini.generate_content(prompt) |
| raw_text = response.text.strip() |
| match = re.search(r'\{.*\}', raw_text, re.DOTALL) |
| if match: |
| try: |
| result = json.loads(match.group(0)) |
| translated = result.get('translation', text) |
| return _restore_markers(translated, mapping) |
| except Exception: |
| pass |
| return _restore_markers(raw_text, mapping) |
| except Exception as e: |
| print(f':x: Gemini error: {str(e)}') |
| try: |
| if 'raw_text' in locals() and (target_language or "").lower() not in ["english", "en"]: |
| return _restore_markers(raw_text.strip(), {}) |
| except Exception: |
| pass |
| return text |
|
|
| |
| def indentify_named_entities(input_text): |
| prompt = get_named_entity_identification_prompt() |
| if not GEMINI_API_KEY or not gemini: |
| print('Warning: GEMINI_API_KEY not found. Returning input wrapped in triple quotes.') |
| return f"'''{input_text}'''" |
| try: |
| response = gemini.generate_content(f"{prompt}\n\nText to process:\n{input_text}") |
| oration = response.text |
| return oration |
| except Exception as e: |
| print(f'Error identifying named entities with Gemini: {e}') |
| return f"'''{input_text}'''" |
|
|
| def pun_indentify_named_entities(input_text: str) -> str: |
| prompt = get_named_entity_identification_prompt() |
| if not GEMINI_API_KEY or not gemini: |
| print('Warning: GEMINI_API_KEY not found. Returning input wrapped in triple quotes.') |
| return f"'''{input_text}'''" |
| try: |
| response = gemini.generate_content(f"{prompt}\n\nText to process:\n{input_text}") |
| oration = response.text |
| return oration |
| except Exception as e: |
| print(f'Error identifying named entities with Gemini: {e}') |
| return f"'''{input_text}'''" |
|
|
| |
| def process_text_into_dictionary(input_text): |
| main_text_match = re.search(r"'''(.*?)'''", input_text, re.DOTALL) |
| if not main_text_match: |
| raise ValueError("No text found within triple single quotes.") |
| main_text = main_text_match.group(1).strip() |
| pattern = r"(<named_entities>(.*?)</named_entities>)|([^<]+)" |
| result = {} |
| hindi_counter = 1 |
| english_counter = 1 |
| for match in re.finditer(pattern, main_text): |
| if match.group(2): |
| result[f"english_{english_counter}"] = match.group(2).strip() |
| english_counter += 1 |
| elif match.group(3): |
| hindi_text = match.group(3).strip() |
| if hindi_text: |
| result[f"hindi_{hindi_counter}"] = hindi_text |
| hindi_counter += 1 |
| return result |
|
|
| def pun_process_text_into_dictionary(input_text: str) -> dict: |
| main_text_match = re.search(r"'''(.*?)'''", input_text, re.DOTALL) |
| if not main_text_match: |
| main_text = input_text.strip() |
| else: |
| main_text = main_text_match.group(1).strip() |
| pattern = r'(<named_entities>(.*?)</named_entities>)|([^<]+)' |
| result = {} |
| hindi_counter = 1 |
| english_counter = 1 |
| for match in re.finditer(pattern, main_text): |
| if match.group(2): |
| result[f'english_{english_counter}'] = match.group(2).strip() |
| english_counter += 1 |
| elif match.group(3): |
| hindi_text = match.group(3).strip() |
| if hindi_text: |
| result[f'hindi_{hindi_counter}'] = hindi_text |
| hindi_counter += 1 |
| return result |
|
|
| |
| def process_dictionary(input_dict, target_language): |
| outputs = [] |
| for key, value in sorted(input_dict.items(), key=lambda kv: kv[0]): |
| if key.startswith("hindi") or key.startswith("regional") or key.startswith("marathi") or key.startswith("kannada"): |
| |
| translated = translate_to_regional(target_language, value) |
| |
| |
| outputs.append(translated.strip()) |
| elif key.startswith("english"): |
| |
| outputs.append(value.strip()) |
| else: |
| outputs.append(value.strip()) |
| return " ".join([o for o in outputs if o]) |
|
|
| def pun_process_dictionary(input_dict: dict, target_language: str) -> str: |
| outputs = [] |
| for key, value in input_dict.items(): |
| if key.startswith('hindi'): |
| outputs.append(pun_translate_to_regional(text=value, target_language=target_language)) |
| elif key.startswith('english'): |
| outputs.append(value.strip()) |
| return ' '.join(outputs) |
|
|
| def pun_convert_english_to_devanaigiri_script(sentence): |
| try: |
| devanagari_text = transliterate(sentence, sanscript.ITRANS, sanscript.DEVANAGARI) |
| return devanagari_text |
| except Exception: |
| return sentence |
|
|
| |
| def generate_english_tts_with_gemini(text: str, voice_name: str, output_path: str, tts_gender: str = None) -> str: |
| if not genai or not types: |
| raise ImportError("google-genai package is required for Gemini TTS. Install with: pip install google-genai") |
| if not GEMINI_API_KEY: |
| raise ValueError("GEMINI_API_KEY not found in environment variables") |
| |
| |
| |
| |
| |
| |
| |
| |
| voice_map = { |
| "achernar": {"male": "achernar", "female": "achernar"}, |
| "achird": {"male": "achird", "female": "achird"}, |
| "algenib": {"male": "algenib", "female": "algenib"}, |
| "algieba": {"male": "algieba", "female": "algieba"}, |
| "alnilam": {"male": "alnilam", "female": "alnilam"}, |
| "aoede": {"male": "aoede", "female": "aoede"}, |
| "autonoe": {"male": "autonoe", "female": "autonoe"}, |
| "callirrhoe": {"male": "callirrhoe", "female": "callirrhoe"}, |
| "charon": {"male": "charon", "female": "charon"}, |
| "despina": {"male": "despina", "female": "despina"}, |
| "enceladus": {"male": "enceladus", "female": "enceladus"}, |
| "erinome": {"male": "erinome", "female": "erinome"}, |
| "fenrir": {"male": "fenrir", "female": "fenrir"}, |
| "gacrux": {"male": "gacrux", "female": "gacrux"}, |
| "iapetus": {"male": "iapetus", "female": "iapetus"}, |
| "kore": {"male": "kore", "female": "kore"}, |
| "laomedeia": {"male": "laomedeia", "female": "laomedeia"}, |
| "leda": {"male": "leda", "female": "leda"}, |
| "orus": {"male": "orus", "female": "orus"}, |
| "puck": {"male": "puck", "female": "puck"}, |
| "pulcherrima": {"male": "pulcherrima", "female": "pulcherrima"}, |
| "rasalgethi": {"male": "rasalgethi", "female": "rasalgethi"}, |
| "sadachbia": {"male": "sadachbia", "female": "sadachbia"}, |
| "sadaltager": {"male": "sadaltager", "female": "sadaltager"}, |
| "schedar": {"male": "schedar", "female": "schedar"}, |
| "sulafat": {"male": "sulafat", "female": "sulafat"}, |
| "umbriel": {"male": "umbriel", "female": "umbriel"}, |
| "vindemiatrix":{"male": "vindemiatrix", "female": "vindemiatrix"}, |
| "zephyr": {"male": "zephyr", "female": "zephyr"}, |
| "zubenelgenubi": {"male": "zubenelgenubi", "female": "zubenelgenubi"}, |
| } |
|
|
| |
| allowed_voices = { |
| "achernar", "achird", "algenib", "algieba", "alnilam", "aoede", "autonoe", |
| "callirrhoe", "charon", "despina", "enceladus", "erinome", "fenrir", "gacrux", |
| "iapetus", "kore", "laomedeia", "leda", "orus", "puck", "pulcherrima", |
| "rasalgethi", "sadachbia", "sadaltager", "schedar", "sulafat", "umbriel", |
| "vindemiatrix", "zephyr", "zubenelgenubi" |
| } |
|
|
| requested = (voice_name or "").strip() |
| |
| gender_key = 'male' if str(tts_gender or '').lower().startswith('m') else 'female' |
|
|
| if not requested: |
| gemini_voice = 'aoede' |
| else: |
| mapping = voice_map.get(requested.lower()) |
| if mapping: |
| gemini_voice = mapping.get(gender_key) |
| else: |
| |
| gemini_voice = requested.lower() |
|
|
| |
| if gemini_voice.lower() not in allowed_voices: |
| print(f"[WARN] Requested Gemini voice '{gemini_voice}' not in allowed list; falling back to 'aoede'.") |
| gemini_voice = 'aoede' |
| else: |
| |
| gemini_voice = gemini_voice.lower() |
| |
| try: |
| client = genai.Client(api_key=GEMINI_API_KEY) |
| response = client.models.generate_content( |
| model='gemini-2.5-flash-preview-tts', |
| contents=text, |
| config=types.GenerateContentConfig( |
| response_modalities=["AUDIO"], |
| speech_config=types.SpeechConfig( |
| voice_config=types.VoiceConfig( |
| prebuilt_voice_config=types.PrebuiltVoiceConfig( |
| voice_name=gemini_voice.lower() |
| ) |
| ) |
| ) |
| ) |
| ) |
| audio_data = b'' |
| if hasattr(response, 'candidates') and response.candidates: |
| for candidate in response.candidates: |
| if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'): |
| for part in candidate.content.parts: |
| if hasattr(part, 'inline_data') and part.inline_data: |
| if hasattr(part.inline_data, 'data'): |
| audio_data += part.inline_data.data |
| elif hasattr(part.inline_data, '_raw_data'): |
| audio_data += part.inline_data._raw_data |
| if not audio_data and hasattr(response, 'text'): |
| import base64 |
| try: |
| audio_data = base64.b64decode(response.text) |
| except Exception: |
| pass |
| if not audio_data: |
| raise ValueError('No audio data received from Gemini API. The response format may have changed.') |
| temp_wav = output_path.replace('.mp3', '_temp.wav') |
| try: |
| with wave.open(temp_wav, 'wb') as wav_file: |
| wav_file.setnchannels(1) |
| wav_file.setsampwidth(2) |
| wav_file.setframerate(24000) |
| wav_file.writeframes(audio_data) |
| except Exception as e: |
| print(f"[WARN] Could not write temp WAV: {e}") |
| with open(output_path, 'wb') as f: |
| f.write(audio_data) |
| return output_path |
| try: |
| subprocess.run([ |
| 'ffmpeg', '-y', '-i', temp_wav, |
| '-acodec', 'libmp3lame', '-ar', '44100', '-ac', '2', '-b:a', '128k', |
| output_path |
| ], check=True, capture_output=True, text=True) |
| if os.path.exists(temp_wav): |
| os.remove(temp_wav) |
| return output_path |
| except subprocess.CalledProcessError as e: |
| print(f'[ERROR] FFmpeg conversion failed: {e.stderr}') |
| with open(output_path, 'wb') as f: |
| f.write(audio_data) |
| return output_path |
| except FileNotFoundError: |
| print('[ERROR] ffmpeg not found. Please install ffmpeg and add it to PATH') |
| with open(output_path, 'wb') as f: |
| f.write(audio_data) |
| return output_path |
| except Exception as e: |
| traceback.print_exc() |
| raise |
|
|
| |
| def process_tts_in_batches(text, target_language, gender, final_speech_file_path, char_limit=300): |
| return None |
|
|
| |
| def generate_tts_static_words(input_text, target_language, gender, speech_file_path): |
| text = indentify_named_entities(input_text) |
| dictionary = process_text_into_dictionary(text) |
| final_text = process_dictionary(dictionary, target_language) |
| return process_tts_in_batches(final_text, target_language, gender, speech_file_path) |
|
|
| def generate_tts_indian(input_text, target_language, gender, speech_file_path): |
| regional_text = translate_to_regional(target_language, input_text) |
| if regional_text is None: |
| return None |
| return process_tts_in_batches(regional_text, target_language, gender, speech_file_path) |
|
|
| def generate_tts_hinglish(input_text, target_language, gender, speech_file_path): |
| return generate_tts_static_words(input_text, target_language, gender, speech_file_path) |
|
|
| def generate_tts_hinglish(input_text, target_language, gender, speech_file_path): |
| """Generate TTS for Hinglish (mix of Hindi/regional + English named entities).""" |
| return generate_tts_static_words(input_text, target_language, gender, speech_file_path) |
|
|
|
|
| |
| def _generate_speech(input_text, speech_file_path, tts_client, target_language, tts_gender, tts_voice_name, toggle_hinglish): |
| if not input_text or not input_text.strip(): |
| print(f"[WARN] Input text empty, skipping TTS for {speech_file_path}") |
| return None |
| clean_text = _clean_text_for_tts(input_text) |
| target_language_lower = (target_language or "english").lower() |
| use_hinglish = str(toggle_hinglish).lower() in ['true', '1', 'yes', 'y'] |
| for attempt in range(1, 4): |
| try: |
| if target_language_lower in ["english", "en"]: |
| result = generate_english_tts_with_gemini(clean_text, tts_voice_name or 'nova', speech_file_path, tts_gender) |
| if result and os.path.exists(result): |
| return speech_file_path |
| else: |
| if use_hinglish: |
| text_with_entities = indentify_named_entities(clean_text) |
| text_dict = process_text_into_dictionary(text_with_entities) |
| final_text = process_dictionary(text_dict, target_language) |
| else: |
| final_text = translate_to_regional(target_language, clean_text) |
| final_text = final_text or clean_text |
| result = generate_english_tts_with_gemini(final_text, tts_voice_name or 'nova', speech_file_path, tts_gender) |
| if result and os.path.exists(result): |
| return result |
| except Exception as e: |
| print(f"[ERROR] TTS attempt {attempt} failed: {e}") |
| time.sleep(1.5) |
| raise RuntimeError(f"[FAIL] TTS failed after all attempts for text: {clean_text[:50]}...") |
|
|
| |
| def audio_fn_from_string(input_text, folder_path, target_language='english', tts_gender='female', tts_voice_name='Puck', toggle_hinglish=True, client=None, file_name_prefix="speech"): |
| folder_path = folder_path if folder_path and os.access(folder_path, os.W_OK) else get_temp_dir() |
| os.makedirs(folder_path, exist_ok=True) |
| speech_file_path = os.path.join(folder_path, f"{file_name_prefix}.mp3") |
| return _generate_speech(input_text, speech_file_path, client, target_language, tts_gender, tts_voice_name, toggle_hinglish) |
|
|
| def audio_fn(text_file_path, target_language='english', tts_gender='female', tts_voice_name='Puck', toggle_hinglish=True, client=None): |
| with open(text_file_path, "r", encoding="utf-8") as f: |
| input_text = f.read() |
| folder_path = os.path.dirname(text_file_path) or get_temp_dir() |
| base_filename = os.path.splitext(os.path.basename(text_file_path))[0] |
| speech_file_path = os.path.join(folder_path, f"{base_filename}.mp3") |
| return _generate_speech(input_text, speech_file_path, client, target_language, tts_gender, tts_voice_name, toggle_hinglish) |
|
|
| |
| def pun_process_dictionary(input_dict: dict, target_language: str) -> str: |
| outputs = [] |
| for key, value in input_dict.items(): |
| if key.startswith('hindi'): |
| outputs.append(pun_translate_to_regional(text=value, target_language=target_language)) |
| elif key.startswith('english'): |
| outputs.append(value.strip()) |
| return ' '.join(outputs) |
|
|
| def pun_audio_fn_from_string(text_input: str, client = None, |
| target_language: str = 'english', tts_gender: str = 'female', tts_voice_name: str = 'nova', |
| toggle_hinglish: bool = True, text_source_type: Literal['file', 'string']='file', |
| file_name_prefix: str='speech', output_dir: str | None = None) -> (str | None): |
| print('==============================================') |
| try: |
| if text_source_type == 'file': |
| with open(text_input, 'r', encoding='utf-8') as file: |
| input_text = file.read() |
| if not input_text.strip(): |
| print(f'Warning: Input text file is empty, skipping TTS for {text_input}') |
| return None |
| directory = os.path.dirname(text_input) |
| base_filename = os.path.splitext(os.path.basename(text_input))[0] |
| speech_file_path = os.path.join(directory, f'{base_filename}.mp3') |
| elif text_source_type == 'string': |
| input_text = text_input |
| if not output_dir: |
| raise ValueError("output_dir must be provided when text_source_type is 'string'") |
| os.makedirs(output_dir, exist_ok=True) |
| speech_file_path = os.path.join(output_dir, f'{file_name_prefix}.mp3') |
| else: |
| print(f"Invalid text_source_type: {text_source_type}") |
| return None |
| return _generate_speech(input_text, speech_file_path, None, target_language, tts_gender, tts_voice_name, toggle_hinglish) |
| except FileNotFoundError: |
| print(f'Error: Text file not found at {text_input}') |
| return None |
| except Exception as e: |
| print(f'Error generating audio: {e}') |
| return None |
|
|
| |
| def pun_check_audio_files(pptx_path, output_dir): |
| print(f'Checking audio files in: {pptx_path}') |
| os.makedirs(output_dir, exist_ok=True) |
| audio_mapping = {} |
| with zipfile.ZipFile(pptx_path, 'r') as z: |
| media_files = [f for f in z.namelist() if f.startswith('ppt/media/')] |
| audio_files = [f for f in media_files if f.endswith('.mp3')] |
| slide_files = [f for f in z.namelist() if f.startswith('ppt/slides/slide') and f.endswith('.xml')] |
| slide_files.sort(key=lambda x: int(re.search('slide(\\d+)\\.xml$', x).group(1))) |
| for audio_file in audio_files: |
| match = re.search('media(\\d+)\\.mp3$', audio_file) |
| if match: |
| slide_num = int(match.group(1)) |
| if slide_num <= len(slide_files): |
| audio_mapping[slide_num] = audio_file |
| output_path = os.path.join(output_dir, f'slide_{slide_num}_{os.path.basename(audio_file)}') |
| with open(output_path, 'wb') as f: |
| f.write(z.read(audio_file)) |
| return audio_mapping |
|
|
| def pun_get_audio_duration(file_path: str) -> float: |
| if not os.path.exists(file_path): |
| return 0.0 |
| try: |
| audio = AudioSegment.from_file(file_path) |
| return len(audio) / 1000.0 |
| except Exception: |
| return 0.0 |
|
|
| def trim_audio_to_max_duration(input_path: str, max_seconds: float) -> str: |
| if not os.path.exists(input_path): |
| return input_path |
| try: |
| duration = pun_get_audio_duration(input_path) |
| if duration <= max_seconds: |
| return input_path |
| except Exception: |
| pass |
| temp_out = f"{input_path}.trim.mp3" |
| ffmpeg_bin = shutil.which("ffmpeg") or "ffmpeg" |
| cmd = [ffmpeg_bin, "-y", "-i", input_path, "-ss", "0", "-t", str(max_seconds), "-c:a", "libmp3lame", "-b:a", "128k", temp_out] |
| try: |
| subprocess.run(cmd, check=True, capture_output=True, text=True) |
| os.replace(temp_out, input_path) |
| return input_path |
| except Exception: |
| if os.path.exists(temp_out): |
| os.remove(temp_out) |
| return input_path |
|
|
| def pad_audio_to_duration(input_path: str, target_seconds: float) -> str: |
| """Ensure `input_path` audio is at least `target_seconds` long. |
| If shorter, append silence to reach target duration. Returns the path to the (possibly modified) file. |
| Operates in-place (creates a temporary file and replaces original). |
| """ |
| if not os.path.exists(input_path): |
| return input_path |
| try: |
| audio = AudioSegment.from_file(input_path) |
| current = len(audio) / 1000.0 |
| except Exception: |
| return input_path |
| if current >= float(target_seconds): |
| return input_path |
| silence_ms = int((float(target_seconds) - current) * 1000) |
| padding = AudioSegment.silent(duration=silence_ms) |
| new_audio = audio + padding |
| temp_out = f"{input_path}.pad.mp3" |
| try: |
| new_audio.export(temp_out, format='mp3', bitrate='128k') |
| os.replace(temp_out, input_path) |
| return input_path |
| except Exception: |
| if os.path.exists(temp_out): |
| os.remove(temp_out) |
| return input_path |
|
|
| |
| def get_media_duration(file_path: str) -> float: |
| try: |
| cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file_path] |
| result = subprocess.run(cmd, check=True, capture_output=True, text=True) |
| return float(result.stdout.strip()) |
| except Exception: |
| return 0.0 |
|
|
| def combine_audio_with_video(video_path: str, audio_path: str, output_path: str) -> bool: |
| try: |
| if not os.path.exists(video_path) or not os.path.exists(audio_path): |
| return False |
| video_duration = get_media_duration(video_path) |
| audio_duration = get_media_duration(audio_path) |
| ffmpeg_bin = shutil.which("ffmpeg") or "ffmpeg" |
| if audio_duration > video_duration: |
| pad = audio_duration - video_duration |
| command = [ |
| ffmpeg_bin, "-y", |
| "-i", video_path, |
| "-i", audio_path, |
| "-filter_complex", f"[0:v]tpad=stop_mode=clone:stop_duration={pad}[v]", |
| "-map", "[v]", |
| "-map", "1:a:0", |
| "-c:v", "libx264", |
| "-preset", "medium", |
| "-crf", "23", |
| "-c:a", "aac", |
| "-shortest", |
| output_path |
| ] |
| else: |
| command = [ |
| ffmpeg_bin, "-y", |
| "-i", video_path, |
| "-i", audio_path, |
| "-map", "0:v:0", |
| "-map", "1:a:0", |
| "-c:v", "copy", |
| "-c:a", "aac", |
| "-shortest", |
| output_path |
| ] |
| subprocess.run(command, check=True, capture_output=True, text=True) |
| return os.path.exists(output_path) and os.path.getsize(output_path) > 0 |
| except subprocess.CalledProcessError as e: |
| print(f"FFmpeg failed: {e.stderr}") |
| return False |
| except Exception as e: |
| print(f"Unexpected combine error: {e}") |
| return False |
|
|
| |
| def pun_generate_segment_audios(code_segments: list, temp_audio_dir: str, |
| client = None, txt_src_typ: Literal['file', 'string']='file', |
| target_language: str='english', tts_gender: str='female', |
| tts_voice_name: str='nova', toggle_hinglish: bool=False, delay_ms: int=1000, default_chars_per_second: int=25, extra_padding_s: float=0.5) -> tuple[str | None, float]: |
| os.makedirs(temp_audio_dir, exist_ok=True) |
| final_audio_clips_for_master = [] |
| total_duration = 0.0 |
| for i, segment in enumerate(code_segments): |
| if segment.explanation: |
| try: |
| generated_audio_path = pun_audio_fn_from_string( |
| text_input=pun_remove_special_characters(segment.explanation), |
| client=client, |
| target_language=target_language, |
| tts_gender=tts_gender, |
| tts_voice_name=tts_voice_name, |
| toggle_hinglish=toggle_hinglish, |
| file_name_prefix=f'segment_{i:02d}', |
| text_source_type=txt_src_typ |
| ) |
| if generated_audio_path: |
| segment.audio_path = generated_audio_path |
| segment.audio_duration = pun_get_audio_duration(generated_audio_path) |
| else: |
| segment.audio_duration = 0.0 |
| except Exception: |
| segment.audio_duration = 0.0 |
| else: |
| segment.audio_duration = 0.0 |
| typing_duration = len(segment.code_snippet) / default_chars_per_second if default_chars_per_second > 0 else 0.1 |
| segment_visual_duration = max(segment.audio_duration, typing_duration) + extra_padding_s |
| segment_visual_duration_ms = int(segment_visual_duration * 1000) |
| segment_audio_block = AudioSegment.silent(duration=segment_visual_duration_ms) |
| if segment.audio_duration > 0 and os.path.exists(segment.audio_path): |
| try: |
| explanation_audio = AudioSegment.from_file(segment.audio_path) |
| segment_audio_block = segment_audio_block.overlay(explanation_audio, position=0) |
| except Exception: |
| pass |
| final_audio_clips_for_master.append(segment_audio_block) |
| total_duration += segment_visual_duration |
| if i < len(code_segments) - 1: |
| final_audio_clips_for_master.append(AudioSegment.silent(duration=delay_ms)) |
| total_duration += delay_ms / 1000.0 |
| master_audio_path = os.path.join(temp_audio_dir, 'master_explanation.mp3') |
| if final_audio_clips_for_master: |
| try: |
| combined_audio = sum(final_audio_clips_for_master) |
| combined_audio.export(master_audio_path, format='mp3') |
| return master_audio_path, total_duration |
| except Exception: |
| return None, 0.0 |
| else: |
| return None, 0.0 |