"""Shared backend: LLM client, debate script generation, TTS generation.""" import asyncio import base64 import json import os import tempfile from pathlib import Path import edge_tts from huggingface_hub import InferenceClient HF_TOKEN = os.environ.get("HF_TOKEN", "") TEXT_MODEL = "Qwen/Qwen2.5-72B-Instruct" VISION_MODEL = "Qwen/Qwen2.5-VL-72B-Instruct" client = InferenceClient(token=HF_TOKEN) # Edge-TTS voice pairs for distinct speakers + chair voice VOICE_PAIRS = { "core_talk": ("en-US-GuyNeural", "en-US-JennyNeural"), "log_doctor": ("en-GB-RyanNeural", "en-GB-SoniaNeural"), "field_trip": ("en-US-ChristopherNeural", "en-US-AriaNeural"), } # Chair/host voice (neutral, authoritative) CHAIR_VOICE = "en-US-AndrewNeural" def encode_image(image_path: str) -> str: """Encode image to base64, resizing if too large.""" from PIL import Image import io img = Image.open(image_path) # Resize if larger than 1024px on any side (keeps API happy) max_dim = 1024 if max(img.size) > max_dim: img.thumbnail((max_dim, max_dim), Image.LANCZOS) # Convert to JPEG for consistent format and smaller size buffer = io.BytesIO() img_format = "JPEG" if img.mode == "RGB" else "PNG" if img.mode == "RGBA": img_format = "PNG" elif img.mode != "RGB": img = img.convert("RGB") img_format = "JPEG" img.save(buffer, format=img_format, quality=85) return base64.b64encode(buffer.getvalue()).decode("utf-8") def describe_image(image_path: str, context: str) -> str: """Use vision model to describe an uploaded image. Retries on transient errors.""" import time from PIL import Image b64 = encode_image(image_path) # Determine mime based on what encode_image outputs img = Image.open(image_path) if img.mode == "RGBA": mime = "image/png" else: mime = "image/jpeg" max_retries = 2 for attempt in range(max_retries + 1): try: response = client.chat_completion( model=VISION_MODEL, messages=[ { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}, {"type": "text", "text": context}, ], } ], max_tokens=500, ) return response.choices[0].message.content except Exception as e: if attempt < max_retries and ("504" in str(e) or "500" in str(e) or "timeout" in str(e).lower()): time.sleep(3) continue raise def generate_debate_script(system_prompt: str, user_prompt: str) -> list[dict]: """Generate a debate script as a list of {speaker, line} dicts.""" response = client.chat_completion( model=TEXT_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], max_tokens=2000, ) raw = response.choices[0].message.content # Extract JSON from response try: start = raw.index("[") end = raw.rindex("]") + 1 script = json.loads(raw[start:end]) except (ValueError, json.JSONDecodeError): # Fallback: treat as plain text conversation script = [{"speaker": "Narrator", "line": raw}] return script async def _generate_tts(text: str, voice: str, output_path: str): communicate = edge_tts.Communicate(text, voice) await communicate.save(output_path) def _run_async(coro): """Run a coroutine from a sync context, safe even when called from a worker thread.""" loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) finally: loop.close() def _get_audio_duration(audio_path: str) -> float: """Estimate MP3 duration in seconds from file size and bitrate. Edge-TTS outputs ~48kbps MP3. Falls back to word-count estimate. """ try: file_size = os.path.getsize(audio_path) # Edge-TTS uses ~48kbps audio; duration ≈ file_size / (bitrate/8) duration = file_size / (48000 / 8) return max(duration, 0.5) except OSError: return 3.0 # fallback def generate_audio_for_script(script: list[dict], mode: str) -> list[dict]: """Generate TTS audio files for each line in the script. Returns list of {speaker, line, audio_path, duration, speakerIdx} dicts. speakerIdx: 0=left, 1=right, 2=chair/center """ voice_a, voice_b = VOICE_PAIRS.get(mode, VOICE_PAIRS["core_talk"]) speakers = list(dict.fromkeys(item["speaker"] for item in script)) # First speaker is the Chair (idx 2), next two are the debaters (idx 0, 1) voice_map = {} speaker_idx_map = {} debater_count = 0 for speaker in speakers: if "chair" in speaker.lower() or "host" in speaker.lower() or "moderator" in speaker.lower(): voice_map[speaker] = CHAIR_VOICE speaker_idx_map[speaker] = 2 # center/chair else: voice_map[speaker] = voice_a if debater_count % 2 == 0 else voice_b speaker_idx_map[speaker] = debater_count % 2 debater_count += 1 tmp_dir = tempfile.mkdtemp(prefix="geotalk_") results = [] for i, item in enumerate(script): audio_path = os.path.join(tmp_dir, f"line_{i:03d}.mp3") voice = voice_map.get(item["speaker"], voice_a) _run_async(_generate_tts(item["line"], voice, audio_path)) duration = _get_audio_duration(audio_path) results.append({ "speaker": item["speaker"], "line": item["line"], "audio_path": audio_path, "duration": duration, "speakerIdx": speaker_idx_map.get(item["speaker"], 0), }) return results def combine_audio_files(results: list[dict]) -> str: """Concatenate all MP3 files into a single file.""" tmp_dir = tempfile.mkdtemp(prefix="geotalk_combined_") combined_path = os.path.join(tmp_dir, "full_episode.mp3") with open(combined_path, "wb") as outfile: for item in results: with open(item["audio_path"], "rb") as infile: outfile.write(infile.read()) return combined_path def format_transcript(results: list[dict]) -> str: """Format the debate as a readable transcript.""" lines = [] for item in results: lines.append(f"**{item['speaker']}:** {item['line']}") return "\n\n".join(lines)