Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- START OF FILE app.py ---
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import asyncio
|
| 5 |
+
import uuid
|
| 6 |
+
import wave
|
| 7 |
+
import re
|
| 8 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
| 9 |
+
from fastapi.responses import HTMLResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from fastapi.templating import Jinja2Templates
|
| 12 |
+
from google import genai
|
| 13 |
+
from google.genai import types
|
| 14 |
+
|
| 15 |
+
# --- مدیریت کلیدهای API ---
|
| 16 |
+
class ApiKeyManager:
|
| 17 |
+
def __init__(self, api_keys_str: str):
|
| 18 |
+
if not api_keys_str:
|
| 19 |
+
raise ValueError("متغیر ALL_GEMINI_API_KEYS پیدا نشد یا خالی است!")
|
| 20 |
+
self.keys = [key.strip() for key in api_keys_str.split(',') if key.strip()]
|
| 21 |
+
if not self.keys:
|
| 22 |
+
raise ValueError("هیچ کلید معتبری در متغیر ALL_GEMINI_API_KEYS یافت نشد.")
|
| 23 |
+
print(f"تعداد {len(self.keys)} کلید API با موفقیت بارگذاری شد.")
|
| 24 |
+
self._index = 0
|
| 25 |
+
self._lock = asyncio.Lock()
|
| 26 |
+
|
| 27 |
+
async def get_next_key(self) -> tuple[int, str]:
|
| 28 |
+
async with self._lock:
|
| 29 |
+
key_index = self._index
|
| 30 |
+
api_key = self.keys[key_index]
|
| 31 |
+
self._index = (self._index + 1) % len(self.keys)
|
| 32 |
+
return key_index, api_key
|
| 33 |
+
|
| 34 |
+
ALL_API_KEYS = os.environ.get("ALL_GEMINI_API_KEYS")
|
| 35 |
+
api_key_manager = ApiKeyManager(ALL_API_KEYS)
|
| 36 |
+
|
| 37 |
+
# --- تنظیمات عمومی Gemini ---
|
| 38 |
+
MODEL = "models/gemini-2.5-flash-native-audio-preview-09-2025"
|
| 39 |
+
CONFIG = types.LiveConnectConfig(
|
| 40 |
+
response_modalities=["AUDIO"],
|
| 41 |
+
speech_config=types.SpeechConfig(
|
| 42 |
+
voice_config=types.VoiceConfig(
|
| 43 |
+
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Zephyr")
|
| 44 |
+
)
|
| 45 |
+
),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
app = FastAPI()
|
| 49 |
+
|
| 50 |
+
TEMP_DIR = "/tmp/temp_audio"
|
| 51 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 52 |
+
app.mount("/audio", StaticFiles(directory=TEMP_DIR), name="audio")
|
| 53 |
+
|
| 54 |
+
templates = Jinja2Templates(directory="templates")
|
| 55 |
+
|
| 56 |
+
# --- تنظیمات فایل صوتی ---
|
| 57 |
+
SAMPLE_RATE = 24000
|
| 58 |
+
CHANNELS = 1
|
| 59 |
+
SAMPLE_WIDTH = 2
|
| 60 |
+
AUDIO_BUFFER_SIZE = 8192
|
| 61 |
+
|
| 62 |
+
def split_text_into_chunks(text: str, max_chunk_size: int = 800):
|
| 63 |
+
if len(text) <= max_chunk_size:
|
| 64 |
+
return [text]
|
| 65 |
+
sentences = re.split(r'(?<=[.!?؟])\s+', text.strip())
|
| 66 |
+
chunks, current_chunk = [], ""
|
| 67 |
+
for sentence in sentences:
|
| 68 |
+
if not sentence: continue
|
| 69 |
+
if len(current_chunk) + len(sentence) + 1 > max_chunk_size and current_chunk:
|
| 70 |
+
chunks.append(current_chunk.strip())
|
| 71 |
+
current_chunk = sentence
|
| 72 |
+
else:
|
| 73 |
+
current_chunk += (" " + sentence) if current_chunk else sentence
|
| 74 |
+
if current_chunk: chunks.append(current_chunk.strip())
|
| 75 |
+
return chunks
|
| 76 |
+
|
| 77 |
+
async def process_and_stream_one_chunk(websocket: WebSocket, chunk: str, request_id: str, chunk_num: int, total_chunks: int):
|
| 78 |
+
"""
|
| 79 |
+
این تابع مسئولیت پردازش یک قطعه متن را بر عهده دارد.
|
| 80 |
+
آنقدر با کلیدهای مختلف تلاش میکند تا بالاخره موفق شود.
|
| 81 |
+
اگر با تمام کلیدها ناموفق بود، یک استثنا (Exception) ایجاد میکند.
|
| 82 |
+
"""
|
| 83 |
+
max_retries = len(api_key_manager.keys)
|
| 84 |
+
log_prefix = f"[{request_id}][قطعه {chunk_num}/{total_chunks}]"
|
| 85 |
+
|
| 86 |
+
for attempt in range(max_retries):
|
| 87 |
+
key_index, api_key = await api_key_manager.get_next_key()
|
| 88 |
+
|
| 89 |
+
# شناسه منحصر به فرد برای جلوگیری از کش شدن در سمت سرور
|
| 90 |
+
unique_id_for_chunk = str(uuid.uuid4())[:8]
|
| 91 |
+
tts_prompt = f"Please ignore the ID in brackets and just read the text: '{chunk}' [ID: {unique_id_for_chunk}]"
|
| 92 |
+
|
| 93 |
+
print(f"{log_prefix} تلاش {attempt + 1}/{max_retries} با کلید {key_index}...")
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
client = genai.Client(http_options={"api_version": "v1beta"}, api_key=api_key)
|
| 97 |
+
audio_buffer = bytearray()
|
| 98 |
+
full_chunk_audio = []
|
| 99 |
+
|
| 100 |
+
async with client.aio.live.connect(model=MODEL, config=CONFIG) as session:
|
| 101 |
+
await session.send(input=tts_prompt, end_of_turn=True)
|
| 102 |
+
turn = session.receive()
|
| 103 |
+
|
| 104 |
+
# *** تغییر کلیدی: افزایش Timeout به ۱۰ ثانیه طبق درخواست ***
|
| 105 |
+
first_response = await asyncio.wait_for(anext(turn), timeout=10.0)
|
| 106 |
+
|
| 107 |
+
print(f"{log_prefix} ارتباط موفق با کلید {key_index}. شروع استریم...")
|
| 108 |
+
|
| 109 |
+
if data := first_response.data:
|
| 110 |
+
full_chunk_audio.append(data)
|
| 111 |
+
audio_buffer.extend(data)
|
| 112 |
+
|
| 113 |
+
async for response in turn:
|
| 114 |
+
if data := response.data:
|
| 115 |
+
full_chunk_audio.append(data)
|
| 116 |
+
audio_buffer.extend(data)
|
| 117 |
+
if len(audio_buffer) >= AUDIO_BUFFER_SIZE:
|
| 118 |
+
await websocket.send_bytes(audio_buffer)
|
| 119 |
+
audio_buffer = bytearray()
|
| 120 |
+
|
| 121 |
+
if audio_buffer:
|
| 122 |
+
await websocket.send_bytes(audio_buffer)
|
| 123 |
+
|
| 124 |
+
# اگر پردازش موفق بود، داده صوتی را برگردان تا ذخیره شود و از تابع خارج شو
|
| 125 |
+
return b"".join(full_chunk_audio)
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
print(f"{log_prefix} خطا با کلید {key_index}: {e}. تلاش با کلید بعدی...")
|
| 129 |
+
# اگر این آخرین کلید بود و باز هم خطا داد، در دور بعدی حلقه خطا ایجاد خواهد شد
|
| 130 |
+
|
| 131 |
+
# اگر حلقه تمام شد و هیچ کلیدی موفق نبود، یک خطای نهایی ایجاد کن
|
| 132 |
+
raise Exception(f"پردازش {log_prefix} با تمام {max_retries} کلید API ناموفق بود.")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@app.get("/", response_class=HTMLResponse)
|
| 136 |
+
async def read_root(request: Request):
|
| 137 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 138 |
+
|
| 139 |
+
@app.websocket("/ws")
|
| 140 |
+
async def websocket_endpoint(websocket: WebSocket):
|
| 141 |
+
await websocket.accept()
|
| 142 |
+
client_id = str(uuid.uuid4())[:6]
|
| 143 |
+
print(f"کلاینت جدید [{client_id}] متصل شد.")
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
while True:
|
| 147 |
+
text_prompt_from_user = await websocket.receive_text()
|
| 148 |
+
request_id = f"{client_id}-{str(uuid.uuid4())[:6]}"
|
| 149 |
+
print(f"[{request_id}] درخواست جدید دریافت شد.")
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
text_chunks = split_text_into_chunks(text_prompt_from_user)
|
| 153 |
+
full_audio_for_file = []
|
| 154 |
+
|
| 155 |
+
# *** منطق کلیدی جدید: پردازش ترتیبی و تضمینی ***
|
| 156 |
+
for i, chunk in enumerate(text_chunks):
|
| 157 |
+
# این تابع تا زمانی که موفق نشود یا تمام کلیدها را تمام نکند، تمام نمیشود
|
| 158 |
+
chunk_audio = await process_and_stream_one_chunk(
|
| 159 |
+
websocket, chunk, request_id, i + 1, len(text_chunks)
|
| 160 |
+
)
|
| 161 |
+
full_audio_for_file.append(chunk_audio)
|
| 162 |
+
|
| 163 |
+
# این بخش فقط در صورتی اجرا میشود که تمام قطعات با موفقیت پردازش شده باشند
|
| 164 |
+
filename = f"{request_id}.wav"
|
| 165 |
+
filepath = os.path.join(TEMP_DIR, filename)
|
| 166 |
+
with wave.open(filepath, 'wb') as wf:
|
| 167 |
+
wf.setnchannels(CHANNELS); wf.setsampwidth(SAMPLE_WIDTH)
|
| 168 |
+
wf.setframerate(SAMPLE_RATE); wf.writeframes(b"".join(full_audio_for_file))
|
| 169 |
+
print(f"[{request_id}] فایل صوتی کامل در {filepath} ذخیره شد.")
|
| 170 |
+
await websocket.send_json({"event": "STREAM_ENDED", "url": f"/audio/{filename}"})
|
| 171 |
+
|
| 172 |
+
print(f"[{request_id}] استریم با موفقیت به پایان رسید.")
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
# این خطا زمانی رخ میدهد که یک قطعه با تمام کلیدها ناموفق باشد
|
| 176 |
+
error_message = f"خطای بحرانی: {e}"
|
| 177 |
+
print(f"[{request_id}] {error_message}")
|
| 178 |
+
await websocket.send_json({"event": "ERROR", "message": str(e)})
|
| 179 |
+
|
| 180 |
+
except WebSocketDisconnect:
|
| 181 |
+
print(f"کلاینت [{client_id}] اتصال را قطع کرد.")
|
| 182 |
+
except Exception as e:
|
| 183 |
+
print(f"[{client_id}] یک خطای پیشبینینشده در WebSocket رخ داد: {e}")
|
| 184 |
+
finally:
|
| 185 |
+
print(f"ارتباط با کلاینت [{client_id}] بسته شد.")
|
| 186 |
+
|
| 187 |
+
# --- END OF FILE app.py ---
|