Spaces:
Sleeping
Sleeping
File size: 6,624 Bytes
5376294 b657956 d823dea 5376294 808cfd0 5376294 77a5ef5 5376294 51fa180 5376294 77a5ef5 5376294 a5f0ded 5376294 e61ec84 a5f0ded 5376294 a5f0ded 5376294 e61ec84 5376294 e61ec84 5376294 8b98e34 5376294 8b98e34 5376294 8eec23c 5376294 8eec23c 77a5ef5 5376294 77a5ef5 5376294 8eec23c 77a5ef5 5376294 8eec23c 5376294 8eec23c 5376294 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | """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)
|