scn-consultation-api / src /tools /audio_utils.py
SS-2005's picture
Added Multi-lingual naration in users preferred language
5576989
Raw
History Blame Contribute Delete
48.4 kB
# src/tools/audio_utils.py
import re
import os
from dotenv import load_dotenv
import zipfile
import logging
logger = logging.getLogger(__name__)
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
# Exports - ensure these names are importable
__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 environment variables and configure Gemini
load_dotenv()
GEMINI_API_KEY = os.environ.get('GEMINI_API_KEY')
# Optionally allow overriding technical terms through env var (comma separated)
_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
# Configure Gemini API globally if key present
if GEMINI_API_KEY:
try:
configure(api_key=GEMINI_API_KEY)
gemini = GenerativeModel('gemini-2.5-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,
}
# ---------------- Helpers ----------------
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', '') # zero-width space
return s
def _clean_text_for_tts(text: str) -> str:
"""
Light cleanup for TTS input.
Removes double-quote characters and backticks only.
Apostrophes inside contractions (we'll, don't, you're) are preserved so the
TTS model pronounces them correctly. Only standalone/surrounding apostrophes
that are acting as quote marks are removed.
"""
if text is None:
return ""
# Remove straight and curly double quotes
text = re.sub(r'["\u201c\u201d\u201e\u201f]', '', text)
# Remove backticks
text = text.replace('`', '')
# Remove apostrophes used as standalone quote marks (not inside a word).
# Pattern: apostrophe NOT flanked by word characters on both sides.
text = re.sub(r"(?<!\w)'|'(?!\w)", '', text)
# Fix orphaned sentence-terminal punctuation after quote removal.
# When a quoted phrase like "karo!" is stripped of its quotes, the ! sits
# mid-sentence (e.g. "...karo! she says..."). Gemini TTS interprets ! as a
# hard stop and goes silent for the rest of the narration.
# Detection: ! or ? followed by whitespace then a lowercase letter means it
# is mid-sentence (not a real sentence boundary). Replace with a comma.
text = re.sub(r'([!?])(\s+[a-z])', r',\2', text)
# Remove markdown bold/italic markers that may appear in generated narration
text = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', text)
# Normalize whitespace
text = ' '.join(text.split())
# Replace slash with spoken "or"
text = text.replace('/', ' or ')
return text.strip()
def _extract_audio_bytes(response) -> bytes:
"""
Extract audio bytes from Gemini TTS responses.
Supports multiple SDK response formats.
"""
chunks = []
def add_chunk(value):
if not value:
return
if isinstance(value, (bytes, bytearray)):
chunks.append(bytes(value))
return
if isinstance(value, str):
try:
import base64
decoded = base64.b64decode(value)
if decoded:
chunks.append(decoded)
except Exception:
pass
# Candidate → Content → Parts
for candidate in getattr(response, "candidates", []) or []:
content = getattr(candidate, "content", None)
parts = getattr(content, "parts", []) if content else []
for part in parts:
inline_data = getattr(part, "inline_data", None)
if inline_data:
add_chunk(getattr(inline_data, "data", None))
add_chunk(getattr(inline_data, "_raw_data", None))
audio_data = getattr(part, "audio_data", None)
if audio_data:
add_chunk(getattr(audio_data, "data", None))
add_chunk(getattr(audio_data, "_raw_data", None))
# Top-level fallback
for attr in ["audio_data", "audio", "data"]:
obj = getattr(response, attr, None)
if obj:
add_chunk(getattr(obj, "data", None))
add_chunk(getattr(obj, "_raw_data", None))
return b"".join(chunks)
# Utility: create safe markers for preserving tokens
def _make_marker(term: str) -> str:
# safe marker unlikely to be touched by translation
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
# Sort by length to protect longest terms first
terms_sorted = sorted(TECHNICAL_TERMS, key=lambda x: -len(x))
protected = text
for term in terms_sorted:
# Word-boundary, case-insensitive
# allow terms containing spaces (e.g., "Linear Regression")
pattern = re.compile(re.escape(term), re.IGNORECASE)
def _repl(m):
orig = m.group(0)
marker = _make_marker(term)
mapping[marker] = orig # store original matched case
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
# Heuristic to decide whether to keep a token as English / technical
def _looks_like_english_token(token: str) -> bool:
if not token:
return False
# If it's explicitly in technical terms (case-insensitive)
for t in TECHNICAL_TERMS:
if token.lower() == t.lower():
return True
# Mostly ASCII chars
ascii_ratio = sum(1 for ch in token if ord(ch) < 128) / max(1, len(token))
if ascii_ratio > 0.85:
return True
# if contains digits or common code symbols or CamelCase
if re.search(r'[A-Z][a-z]+[A-Z]', token) or re.search(r'[\d\(\)\=\+\-_/\.]', token):
return True
# Contains dot notation or file extensions
if '.' in token and len(token) <= 40:
return True
return False
# --- Keep English named entities as-is for Hinglish ---
def transliterate_english_to_native_script(sentence, target_language):
if sentence is None:
return ""
if isinstance(sentence, str):
# If the token looks like an english/technical token, keep as-is
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
# ---------------- Translation helpers (Gemini) ----------------
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:
# protect technical terms with markers so translator won't alter them
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
# attempt parse JSON
try:
result = json.loads(raw_text)
translated = result.get("translation", text)
# restore protected terms
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
# ---------------- Named entity identification ----------------
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}'''"
# ---------------- Text -> dictionary parsing (shared) ----------------
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
# ---------------- Dictionary -> final text (Hinglish behavior) ----------------
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"):
# Protect technical terms, translate, then restore
translated = translate_to_regional(target_language, value)
# After translation, ensure technical terms remain original if translation altered them
# Best-effort: try restoring markers (translate_to_regional already restores markers)
outputs.append(translated.strip())
elif key.startswith("english"):
# keep english as-is (NER tagged)
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
# ---------------- TTS helpers for retry / chunking ----------------
class _TTSContentRejected(Exception):
"""
Raised when Gemini TTS returns FinishReason.OTHER with no audio payload.
This means the model actively rejected the input content, so retrying
with the identical text will not help — the caller must transform the text.
"""
pass
def _normalize_text_for_tts_retry(text: str, attempt: int) -> str:
"""
Apply progressive, generic text normalizations before a TTS retry
that follows a content-rejection (FinishReason.OTHER).
Rules are format-based — nothing is hardcoded to specific words.
Each attempt produces meaningfully different text so the model has a real
chance to accept content it rejected on the previous attempt.
"""
normalized = text
# Remove comma grouping from numbers: 5,999 → 5999, 1,00,000 → 100000
# This is the most common trigger for Gemini TTS content rejection on financial text.
normalized = re.sub(r'(?<!\d)(\d{1,3}),(\d{3})(?!\d)', r'\1\2', normalized)
# Indian lakh-style formatting: 1,00,000
normalized = re.sub(r'(?<!\d)(\d{1,2}),(\d{2}),(\d{3})(?!\d)', r'\1\2\3', normalized)
if attempt >= 2:
# Strip Devanagari Unicode block (U+0900–U+097F).
# Mixed Devanagari + Romanized Hindi in the same utterance can trigger
# Gemini TTS content rejection even when each language alone would pass.
# The Romanized Hinglish tokens carry the same spoken meaning, so
# removing the Devanagari characters does not lose the narration intent.
normalized = re.sub(r'[\u0900-\u097F]+', '', normalized)
# Strip markdown bold/italic markers (**text** or *text*) that the
# script generator occasionally emits — these can confuse the model.
normalized = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', normalized)
# Re-normalize whitespace after character removal
normalized = ' '.join(normalized.split())
if attempt >= 3:
# Last full-text attempt: also replace ? in Hinglish questions with a period
# so the model treats them as statements rather than interrogative sequences.
normalized = re.sub(r'\?', '.', normalized)
return normalized
def _generate_tts_in_chunks(
text: str,
voice_name: str,
output_path: str,
tts_gender: str = None
) -> str:
"""
Sentence-level chunked TTS generation used as a last-resort fallback.
When the full text is consistently rejected by Gemini TTS, split it at
sentence boundaries and generate audio for each sentence independently,
then concatenate. Sentences that still fail are silently skipped so the
rest of the beat audio is preserved rather than crashing the pipeline.
"""
sentences = [
s.strip()
for s in re.split(r'(?<=[.!?])\s+', text.strip())
if s.strip()
]
if len(sentences) <= 1:
# Cannot split further — apply normalization and try once more.
normalized = _normalize_text_for_tts_retry(text, 3)
return generate_english_tts_with_gemini(normalized, voice_name, output_path, tts_gender)
temp_dir = tempfile.mkdtemp()
chunk_paths = []
try:
for i, sentence in enumerate(sentences):
chunk_path = os.path.join(temp_dir, f"tts_chunk_{i:03d}.mp3")
norm_sentence = _normalize_text_for_tts_retry(sentence, 3)
for chunk_attempt in range(2):
try:
result = generate_english_tts_with_gemini(
norm_sentence, voice_name, chunk_path, tts_gender
)
if result and os.path.exists(result):
chunk_paths.append(chunk_path)
break
except _TTSContentRejected:
logger.warning(
"[TTS] Chunk %d content-rejected, skipping: %s...",
i, sentence[:60]
)
break
except Exception:
time.sleep(1.0)
if not chunk_paths:
raise ValueError("All sentence chunks failed TTS generation in chunked fallback.")
# Concatenate all successful chunk audio files
combined = AudioSegment.from_file(chunk_paths[0])
for p in chunk_paths[1:]:
combined = combined + AudioSegment.from_file(p)
combined.export(output_path, format="mp3", bitrate="128k")
logger.info(
"[TTS] Chunked TTS produced %d/%d sentences → %s",
len(chunk_paths), len(sentences), output_path
)
return output_path
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
# ---------------- TTS: Gemini English + regional path ----------------
def _extract_english_fallback(text: str) -> str:
"""
Produce a clean, English-safe version of the narration as an absolute
last-resort TTS input when all retry attempts and the chunked fallback
have been exhausted.
Transformations applied (all generic, no hardcoded words):
- Strip Devanagari Unicode block so mixed-script content doesn't trigger
the TTS content filter.
- Remove markdown bold/italic markers.
- Expand comma-grouped numbers (5,999 → 5999 and lakh-format 1,00,000 → 100000).
- Replace ? with . to avoid interrogative prosody in borderline content.
- Normalize whitespace.
Returns the sanitized string, or the original if nothing meaningfully changed.
"""
if not text:
return text
sanitized = text
# Strip Devanagari
sanitized = re.sub(r'[\u0900-\u097F]+', '', sanitized)
# Strip markdown
sanitized = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', sanitized)
# Expand comma-grouped numbers
sanitized = re.sub(r'(?<!\d)(\d{1,3}),(\d{3})(?!\d)', r'\1\2', sanitized)
sanitized = re.sub(r'(?<!\d)(\d{1,2}),(\d{2}),(\d{3})(?!\d)', r'\1\2\3', sanitized)
# Replace ? with .
sanitized = re.sub(r'\?', '.', sanitized)
# Normalize whitespace
sanitized = ' '.join(sanitized.split())
return sanitized.strip()
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 mapping: only voices supported by Gemini TTS API
# Based on API test results: charon, fenrir, puck, aoede work
# Removed: titan, lyra, aura, sol (not supported by API)
# Additional supported voices: achernar, achird, algenib, algieba, alnilam, autonoe,
# callirrhoe, despina, enceladus, erinome, gacrux, iapetus, kore, laomedeia, leda,
# orus, pulcherrima, rasalgethi, sadachbia, sadaltager, schedar, sulafat, umbriel,
# vindemiatrix, zephyr, zubenelgenubi
# Map user-friendly voice names to Gemini voice keys (male/female map to same prebuilt voice)
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 list based on actual Gemini TTS API supported voices (lowercase)
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()
# Default to 'female' if no gender provided to match wrapper defaults (audio_fn_from_string default is 'female')
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:
# If user supplied a direct Gemini voice name, accept it (lowercase)
gemini_voice = requested.lower()
# Validate against allowed voices (case-insensitive); if invalid, fallback to a safe default.
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:
# Ensure voice name is lowercase as API expects lowercase
gemini_voice = gemini_voice.lower()
try:
client = genai.Client(api_key=GEMINI_API_KEY)
logger.info(
"[TTS] Generating audio | voice=%s | chars=%s",
gemini_voice,
len(text)
)
logger.info(
"[TTS] Preview: %s",
text[:250]
)
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
)
)
)
)
)
audio_data = _extract_audio_bytes(response)
if not audio_data:
logger.error(
"[TTS] Empty audio payload.\nResponse=%s",
str(response)
)
# Distinguish content rejection (FinishReason.OTHER) from a transient failure.
# When the model rejects content, retrying with the same text will never work —
# the caller must transform the text before retrying.
candidates = getattr(response, "candidates", []) or []
finish_reasons = [
str(getattr(c, "finish_reason", "")) for c in candidates
]
if any("OTHER" in r for r in finish_reasons):
raise _TTSContentRejected(
f"Gemini TTS rejected content (FinishReason.OTHER) "
f"for voice={voice_name}. Text must be modified before retry."
)
raise ValueError(
"No audio data received from Gemini API."
)
temp_wav = output_path.replace(
".mp3",
"_temp.wav"
)
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)
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:
logger.error(
"[TTS] FFmpeg conversion failed: %s",
e.stderr
)
with open(output_path, "wb") as f:
f.write(audio_data)
return output_path
except Exception:
traceback.print_exc()
raise
# ---------------- Legacy batching (no-op kept) ----------------
def process_tts_in_batches(text, target_language, gender, final_speech_file_path, char_limit=300):
return None
# ---------------- Higher-level generation helpers (Hinglish by default) ----------------
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)
# ---------------- Main Serverless TTS ----------------
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] Empty input text 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"
}
# English TTS path — pass multilingual text directly to Gemini TTS.
# gemini-2.5-flash-preview-tts handles mixed-script content natively:
# Devanagari, Romanized Hindi (Hinglish), and English all render with correct
# pronunciation without any pre-transliteration on our side.
if target_language_lower in {"english", "en"}:
pass # No text transformation; the model handles mixed scripts natively.
# Regional TTS path
else:
try:
if use_hinglish:
text_with_entities = indentify_named_entities(
clean_text
)
text_dict = process_text_into_dictionary(
text_with_entities
)
clean_text = process_dictionary(
text_dict,
target_language
)
else:
clean_text = translate_to_regional(
target_language,
clean_text
)
except Exception as e:
logger.warning(
"[TTS] Translation failed. Using original text. %s",
str(e)
)
_content_rejected = False
for attempt in range(1, 4):
try:
# After a content rejection, apply progressive text normalization
# (e.g. remove comma grouping from numbers) so the retry uses
# transformed text, not the exact same input that was just rejected.
attempt_text = (
_normalize_text_for_tts_retry(clean_text, attempt)
if _content_rejected
else clean_text
)
result = generate_english_tts_with_gemini(
attempt_text,
tts_voice_name or "aoede",
speech_file_path,
tts_gender
)
if result and os.path.exists(result):
return result
except _TTSContentRejected as e:
_content_rejected = True
print(f"[ERROR] TTS attempt {attempt} content rejected: {e}")
if attempt == 3:
# Last resort pass 1: generate audio sentence-by-sentence and stitch.
# Avoids crashing the full pipeline when one beat's text
# consistently triggers Gemini's content filter.
try:
logger.info("[TTS] Falling back to chunked sentence-level TTS")
result = _generate_tts_in_chunks(
clean_text,
tts_voice_name or "aoede",
speech_file_path,
tts_gender
)
if result and os.path.exists(result):
return result
except Exception as chunk_e:
print(f"[ERROR] TTS chunked fallback also failed: {chunk_e}")
# Last resort pass 2: English-only sanitized version of the text.
# Strips Devanagari, markdown, and problematic punctuation so the
# TTS model receives a clean, unambiguous English utterance.
# This guarantees the beat has audio and the pipeline continues.
try:
logger.info("[TTS] Final fallback: English-only sanitized TTS for beat")
eng_fallback = _extract_english_fallback(clean_text)
if eng_fallback and eng_fallback.strip():
result = _generate_tts_in_chunks(
eng_fallback,
tts_voice_name or "aoede",
speech_file_path,
tts_gender
)
if result and os.path.exists(result):
logger.info("[TTS] English-only final fallback succeeded for beat")
return result
except Exception as final_e:
logger.error("[TTS] English-only final fallback failed: %s", final_e)
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: "
f"{clean_text[:100]}..."
)
# ---------------- Wrappers (Hinglish default) ----------------
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)
# ---------------- pun_ wrappers (consistent behavior) ----------------
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
# ---------------- Utilities: PowerPoint audio inspection & trimming ----------------
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
# ---------------- Media combine ----------------
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
# ---------------- Segment-level audio assembly (pun_) ----------------
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