File size: 13,874 Bytes
eb4ceda
83ac874
db88be5
83ac874
 
db88be5
 
 
83ac874
 
 
 
 
eb4ceda
 
 
83ac874
 
42a6235
 
83ac874
d9cad98
4a1e151
 
 
 
 
 
af0842c
8150db4
eb4ceda
50ab425
 
83ac874
af0842c
8150db4
af0842c
 
e4cf6e1
af0842c
 
eb4ceda
af0842c
50ab425
 
eb4ceda
 
 
 
50ab425
 
 
 
 
 
eb4ceda
1ec58e2
eb4ceda
 
42a6235
eb4ceda
e4cf6e1
 
 
 
 
 
 
 
1ddb4e8
42a6235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb4ceda
 
 
42a6235
 
 
db88be5
e4cf6e1
 
42a6235
 
 
e4cf6e1
42a6235
4e27563
eb4ceda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50ab425
eb4ceda
 
 
 
 
 
 
 
 
 
cf1843b
eb4ceda
f7677f8
42a6235
 
 
 
50ab425
eb4ceda
42a6235
eb4ceda
42a6235
cf1843b
eb4ceda
 
3eb70c5
 
eb4ceda
 
42a6235
 
83ac874
eb4ceda
 
e4cf6e1
eb4ceda
 
 
 
 
e4cf6e1
eb4ceda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83ac874
42a6235
eb4ceda
 
 
e4cf6e1
42a6235
 
cf1843b
83ac874
af0842c
83ac874
42a6235
83ac874
 
 
 
 
eb4ceda
 
 
42a6235
83ac874
50ab425
83ac874
 
 
 
 
 
 
eb4ceda
 
 
 
83ac874
 
 
 
 
eb4ceda
83ac874
eb4ceda
83ac874
8150db4
83ac874
 
 
8150db4
73c0690
83ac874
50ab425
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# app.py - نسخه Worker بدون تقسیم متن (No Splitting)

import os
import sys
import traceback
import re
import struct
import time
import uuid
import shutil
import logging
import mimetypes
import threading
import random
import asyncio
import wave
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from google import genai
from google.genai import types
import uvicorn

try:
    from pydub import AudioSegment
    PYDUB_AVAILABLE = True
except ImportError:
    PYDUB_AVAILABLE = False

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')

# --- تنظیمات مدیریت کلیدها ---
GEMINI_CLIENTS_CACHE = {} 
CLIENT_CACHE_LOCK = threading.Lock() 

ALL_API_KEYS: list[str] = []

def _init_api_keys():
    global ALL_API_KEYS
    all_keys_string = os.environ.get("ALL_GEMINI_API_KEYS")
    if all_keys_string:
        ALL_API_KEYS = [key.strip() for key in all_keys_string.split(',') if key.strip()]
    logging.info(f"✅ تعداد {len(ALL_API_KEYS)} کلید API جیمینای شناسایی و بارگذاری شد.")
    if not ALL_API_KEYS:
        logging.warning("⛔️ هشدار: هیچ Secret با نام ALL_GEMINI_API_KEYS یافت نشد!")

def get_random_api_key_and_client():
    if not ALL_API_KEYS:
        return None, None
    key_to_use = random.choice(ALL_API_KEYS)
    with CLIENT_CACHE_LOCK:
        if key_to_use in GEMINI_CLIENTS_CACHE:
            client = GEMINI_CLIENTS_CACHE[key_to_use]
        else:
            client = genai.Client(api_key=key_to_use)
            GEMINI_CLIENTS_CACHE[key_to_use] = client
    return key_to_use, client

FIXED_MODEL_NAME_STANDARD = "gemini-2.5-flash-preview-tts"
FIXED_MODEL_NAME_LIVE = "models/gemini-2.5-flash-native-audio-preview-12-2025"
DEFAULT_MAX_CHUNK_SIZE = 3800
DEFAULT_SLEEP_BETWEEN_REQUESTS = 5

def save_binary_file(file_name, data):
    try:
        with open(file_name, "wb") as f: f.write(data)
        return file_name
    except Exception as e:
        logging.error(f"❌ خطا در ذخیره فایل {file_name}: {e}")
        return None

def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
    parameters = parse_audio_mime_type(mime_type)
    bits_per_sample, rate = parameters["bits_per_sample"], parameters["rate"]
    num_channels, data_size = 1, len(audio_data)
    bytes_per_sample, block_align = bits_per_sample // 8, num_channels * (bits_per_sample // 8)
    byte_rate, chunk_size = rate * block_align, 36 + data_size
    header = struct.pack("<4sI4s4sIHHIIHH4sI", b"RIFF", chunk_size, b"WAVE", b"fmt ", 16, 1, num_channels, rate, byte_rate, block_align, bits_per_sample, b"data", data_size)
    return header + audio_data

def parse_audio_mime_type(mime_type: str) -> dict[str, int]:
    bits, rate = 16, 24000
    for param in mime_type.split(";"):
        param = param.strip()
        if param.lower().startswith("rate="):
            try: rate = int(param.split("=", 1)[1])
            except: pass
        elif param.startswith("audio/L"):
            try: bits = int(param.split("L", 1)[1])
            except: pass
    return {"bits_per_sample": bits, "rate": rate}

def smart_text_split(text, max_size=3800):
    # تغییر مهم: حذف کامل تقسیم‌بندی متن
    # کل متن به عنوان یک تکه بازگردانده می‌شود تا هوش مصنوعی یکجا آن را پردازش کند
    return [text]

def merge_audio_files_func(file_paths, output_path):
    if not PYDUB_AVAILABLE: logging.warning("⚠️ pydub برای ادغام در دسترس نیست."); return False
    try:
        combined = AudioSegment.empty()
        for i, fp in enumerate(file_paths):
            if os.path.exists(fp): combined += AudioSegment.from_file(fp) + (AudioSegment.silent(duration=150) if i < len(file_paths) - 1 else AudioSegment.empty())
            else: logging.warning(f"⚠️ فایل برای ادغام پیدا نشد: {fp}")
        combined.export(output_path, format="wav")
        return True
    except Exception as e: logging.error(f"❌ خطا در ادغام فایل‌های صوتی: {e}"); return False

# --- منطق Gemini Live ---
async def generate_audio_live_with_retry(text, prompt, voice, session_id):
    MAX_RETRIES = 50 
    live_config = types.LiveConnectConfig(
        response_modalities=["AUDIO"],
        speech_config=types.SpeechConfig(
            voice_config=types.VoiceConfig(
                prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
            )
        ),
    )
    for attempt in range(MAX_RETRIES):
        selected_api_key, _ = get_random_api_key_and_client()
        if not selected_api_key: break
        client = genai.Client(http_options={"api_version": "v1beta"}, api_key=selected_api_key)
        unique_id_for_req = str(uuid.uuid4())[:8]
        tts_prompt = f"Please read the following text naturally: '{text}' [ID: {unique_id_for_req}]"
        if prompt: tts_prompt = f"With a {prompt} tone, please read: '{text}'"
        try:
            logging.info(f"[{session_id}] (Live) تلاش {attempt+1} با کلید ...{selected_api_key[-4:]}")
            audio_buffer = bytearray()
            async with client.aio.live.connect(model=FIXED_MODEL_NAME_LIVE, config=live_config) as session:
                await session.send(input=tts_prompt, end_of_turn=True)
                async for response in session.receive():
                    if response.data: audio_buffer.extend(response.data)
            if len(audio_buffer) > 0:
                logging.info(f"[{session_id}] ✅ (Live) موفقیت‌آمیز.")
                return audio_buffer
            else: raise Exception("بافر صوتی خالی بود.")
        except Exception as e:
            logging.warning(f"[{session_id}] ⚠️ (Live) خطا در تلاش {attempt+1}: {e}")
            time.sleep(0.5)
    return None

def save_pcm_to_wav(pcm_data, output_path):
    try:
        with wave.open(output_path, 'wb') as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(24000)
            wf.writeframes(pcm_data)
        return True
    except Exception as e:
        logging.error(f"خطا در تبدیل PCM به WAV: {e}")
        return False

# --- منطق Gemini Standard (اصلاح شده با retry_limit) ---
def generate_audio_chunk_standard_with_retry(chunk_text, prompt_text, voice, temp, session_id, retry_limit):
    if not ALL_API_KEYS: raise Exception("هیچ کلید API در دسترس نیست.")
    
    # استفاده از محدودیت تعیین شده توسط Manager
    MAX_RETRIES = retry_limit 
    
    for attempt in range(MAX_RETRIES):
        selected_api_key, client = get_random_api_key_and_client()
        if not client: break
        try:
            # logging.info(f"[{session_id}] (Standard) تلاش {attempt+1}/{MAX_RETRIES} با کلید ...{selected_api_key[-4:]}")
            final_text = f'{chunk_text}({prompt_text})' if prompt_text and prompt_text.strip() else chunk_text
            contents = [types.Content(role="user", parts=[types.Part.from_text(text=final_text)])]
            config = types.GenerateContentConfig(temperature=temp, response_modalities=["audio"],
                speech_config=types.SpeechConfig(voice_config=types.VoiceConfig(
                    prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice))))
            
            response = client.models.generate_content(model=FIXED_MODEL_NAME_STANDARD, contents=contents, config=config)
            if response.candidates and response.candidates[0].content and response.candidates[0].content.parts and response.candidates[0].content.parts[0].inline_data:
                logging.info(f"[{session_id}] ✅ (Standard) موفقیت در تلاش {attempt+1}.")
                return response.candidates[0].content.parts[0].inline_data
        except Exception as e:
            logging.warning(f"[{session_id}] ⚠️ (Standard) خطا در تلاش {attempt+1}: {e}")
            time.sleep(0.5)
    return None

def core_generate_audio(text_input, prompt_input, selected_voice, temperature_val, session_id, use_live_model=False, retry_limit=50, fallback_to_live=False):
    logging.info(f"[{session_id}] 🚀 شروع: Live={use_live_model}, Retry={retry_limit}, Fallback={fallback_to_live}")
    temp_dir = f"temp_{session_id}"
    os.makedirs(temp_dir, exist_ok=True)
    output_base_name = f"{temp_dir}/audio_session_{session_id}"
    final_output_path = f"output_{session_id}.wav"

    try:
        # 1. اگر دستور مستقیم استفاده از لایف باشد (مثلاً کاربر رایگان)
        if use_live_model:
            pcm_data = asyncio.run(generate_audio_live_with_retry(text_input, prompt_input, selected_voice, session_id))
            if pcm_data and save_pcm_to_wav(pcm_data, final_output_path):
                return final_output_path
            else:
                raise Exception("تولید صدا با مدل لایف ناموفق بود.")

        # 2. استفاده از مدل استاندارد
        else:
            # تقسیم‌بندی هوشمند حذف شده و فقط یک چانک (کل متن) برمی‌گرداند
            text_chunks = smart_text_split(text_input, DEFAULT_MAX_CHUNK_SIZE)
            generated_files = []
            standard_failed = False

            for i, chunk in enumerate(text_chunks):
                # تلاش با مدل استاندارد به تعداد retry_limit
                inline_data = generate_audio_chunk_standard_with_retry(chunk, prompt_input, selected_voice, temperature_val, session_id, retry_limit)
                
                if inline_data:
                    data_buffer = inline_data.data
                    ext = mimetypes.guess_extension(inline_data.mime_type) or ".wav"
                    if "audio/L" in inline_data.mime_type and ext == ".wav": 
                        data_buffer = convert_to_wav(data_buffer, inline_data.mime_type)
                    if not ext.startswith("."): ext = "." + ext
                    fpath = save_binary_file(f"{output_base_name}_part{i+1:03d}{ext}", data_buffer)
                    if fpath: generated_files.append(fpath)
                else:
                    standard_failed = True
                    break # شکست در تولید یکی از چانک‌ها (در اینجا کل متن)
            
            # 3. بررسی شکست و Fallback
            if standard_failed:
                if fallback_to_live:
                    logging.info(f"[{session_id}] 🔄 مدل استاندارد شکست خورد. سوییچ به مدل لایف (Fallback)...")
                    generated_files = [] 
                    # فراخوانی مدل لایف برای کل متن
                    pcm_data = asyncio.run(generate_audio_live_with_retry(text_input, prompt_input, selected_voice, session_id))
                    if pcm_data and save_pcm_to_wav(pcm_data, final_output_path):
                        return final_output_path
                    else:
                        raise Exception("هم مدل استاندارد و هم مدل لایف (Fallback) شکست خوردند.")
                else:
                    raise Exception(f"تولید صدا با مدل استاندارد پس از {retry_limit} تلاش ناموفق بود.")
            
            # اگر استاندارد موفق بود، فایل‌ها را ادغام کن (در اینجا معمولاً فقط یک فایل است)
            if not generated_files: raise Exception("هیچ فایلی تولید نشد.")
            
            if len(generated_files) > 1:
                if PYDUB_AVAILABLE and merge_audio_files_func(generated_files, final_output_path):
                    pass
                else:
                    shutil.move(generated_files[0], final_output_path)
            else:
                shutil.move(generated_files[0], final_output_path)
            
            return final_output_path

    finally:
        if os.path.exists(temp_dir):
            shutil.rmtree(temp_dir)

_init_api_keys()

app = FastAPI(title="Alpha TTS Worker API")

class TTSRequest(BaseModel):
    text: str
    prompt: str | None = ""
    speaker: str
    temperature: float
    use_live_model: bool = False
    retry_limit: int = 50       # پارامتر جدید
    fallback_to_live: bool = False # پارامتر جدید

@app.post("/generate")
def generate_audio_endpoint(request: TTSRequest):
    session_id = str(uuid.uuid4())[:8]
    try:
        final_path = core_generate_audio(
            text_input=request.text,
            prompt_input=request.prompt,
            selected_voice=request.speaker,
            temperature_val=request.temperature,
            session_id=session_id,
            use_live_model=request.use_live_model,
            retry_limit=request.retry_limit,
            fallback_to_live=request.fallback_to_live
        )
        if final_path and os.path.exists(final_path):
            from fastapi.responses import FileResponse
            return FileResponse(path=final_path, media_type='audio/wav', filename=os.path.basename(final_path), background=shutil.rmtree(os.path.dirname(final_path), ignore_errors=True))
        else:
            raise HTTPException(status_code=500, detail="خطا در تولید فایل صوتی.")
    except Exception as e:
        logging.error(f"[{session_id}] ❌ خطا: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/")
def health_check():
    return {"status": "ok", "message": "TTS Worker is running."}

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 7860))
    uvicorn.run(app, host="0.0.0.0", port=port, reload=False)