import gradio as gr import base64 import mimetypes import os import re import struct import time import zipfile from google import genai from google.genai import types try: from pydub import AudioSegment PYDUB_AVAILABLE = True except ImportError: PYDUB_AVAILABLE = False # --- START: منطق چرخش API Key --- GEMINI_API_KEYS = [] i = 1 while os.environ.get(f"GEMINI_API_KEY_{i}"): GEMINI_API_KEYS.append(os.environ.get(f"GEMINI_API_KEY_{i}")) i += 1 NUM_API_KEYS = len(GEMINI_API_KEYS) CURRENT_KEY_INDEX = 0 def _log(message): """تابع ساده شده برای لاگ کردن پیام‌ها به کنسول.""" print(f"[لاگ آلفا TTS] {message}") if NUM_API_KEYS == 0: _log("⛔️ خطای حیاتی: هیچ Secret با نام GEMINI_API_KEY_n یافت نشد!") _log(" لطفاً Secret ها را مانند GEMINI_API_KEY_1, GEMINI_API_KEY_2, ... در تنظیمات Space خود اضافه کنید.") # در یک محیط واقعی، ممکن است بخواهید اینجا برنامه را متوقف کنید # exit(1) else: _log(f"✅ تعداد {NUM_API_KEYS} کلید API جیمینای بارگذاری شد.") def get_next_api_key(): global CURRENT_KEY_INDEX if NUM_API_KEYS == 0: _log("⚠️ تلاش برای گرفتن کلید API در حالی که هیچ کلیدی بارگذاری نشده است.") return None key_to_use = GEMINI_API_KEYS[CURRENT_KEY_INDEX % NUM_API_KEYS] key_display_index = (CURRENT_KEY_INDEX % NUM_API_KEYS) + 1 CURRENT_KEY_INDEX += 1 # _log(f"🔑 استفاده از کلید API شماره {key_display_index} (اندیس: {CURRENT_KEY_INDEX-1})") # لاگ دقیق‌تر برای دیباگ return key_to_use, key_display_index # --- END: منطق چرخش API Key --- SPEAKER_VOICES = [ "Achird", "Zubenelgenubi", "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat", "Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Umbriel", "Algieba", "Despina", "Erinome", "Algenib", "Rasalthgeti", "Orus", "Aoede", "Callirrhoe", "Autonoe", "Enceladus", "Iapetus", "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda" ] FIXED_MODEL_NAME = "gemini-2.5-flash-preview-tts" DEFAULT_MAX_CHUNK_SIZE = 3800 DEFAULT_SLEEP_BETWEEN_REQUESTS = 8 DEFAULT_OUTPUT_FILENAME_BASE = "alpha_tts_audio" def save_binary_file(file_name, data): try: with open(file_name, "wb") as f: f.write(data) # _log(f"💾 فایل ذخیره شد: {file_name}") # لاگ کمتر return file_name except Exception as e: _log(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): if len(text) <= max_size: return [text] chunks, current_chunk = [], "" sentences = re.split(r'(?<=[.!?؟])\s+', text) for sentence in sentences: if len(current_chunk) + len(sentence) + 1 > max_size: if current_chunk: chunks.append(current_chunk.strip()) current_chunk = sentence while len(current_chunk) > max_size: split_idx = next((i for i in range(max_size - 1, max_size // 2, -1) if current_chunk[i] in ['،', ',', ';', ':', ' ']), -1) part, current_chunk = (current_chunk[:split_idx+1], current_chunk[split_idx+1:]) if split_idx != -1 else (current_chunk[:max_size], current_chunk[max_size:]) chunks.append(part.strip()) else: current_chunk += (" " if current_chunk else "") + sentence if current_chunk: chunks.append(current_chunk.strip()) final_chunks = [c for c in chunks if c] # _log(f"📊 متن به {len(final_chunks)} قطعه تقسیم شد.") # لاگ کمتر return final_chunks def merge_audio_files_func(file_paths, output_path): if not PYDUB_AVAILABLE: _log("⚠️ pydub برای ادغام در دسترس نیست."); return False try: # _log(f"🔗 ادغام {len(file_paths)} فایل صوتی...") # لاگ کمتر 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: _log(f"⚠️ فایل برای ادغام پیدا نشد: {fp}") combined.export(output_path, format="wav") # _log(f"✅ فایل ادغام شده: {output_path}") # لاگ کمتر return True except Exception as e: _log(f"❌ خطا در ادغام فایل‌های صوتی: {e}"); return False def core_generate_audio(text_input, prompt_input, selected_voice, temperature_val): _log("🚀 شروع فرآیند تولید صدا...") output_base_name = DEFAULT_OUTPUT_FILENAME_BASE max_chunk, sleep_time = DEFAULT_MAX_CHUNK_SIZE, DEFAULT_SLEEP_BETWEEN_REQUESTS selected_api_key, key_idx_display = get_next_api_key() if not selected_api_key: _log("❌ کلید API برای این درخواست در دسترس نیست. لطفاً از تنظیمات Secrets مطمئن شوید.") return None _log(f"⚙️ استفاده از کلید API شماره {key_idx_display} (پایان یافته با: ...{selected_api_key[-4:]})") try: client = genai.Client(api_key=selected_api_key) except Exception as e: _log(f"❌ خطا در مقداردهی اولیه کلاینت Gemini با کلید شماره {key_idx_display}: {e}") return None if not text_input or not text_input.strip(): _log("❌ متن ورودی خالی است.") return None text_chunks = smart_text_split(text_input, max_chunk) if not text_chunks: _log("❌ متن قابل پردازش به قطعات کوچکتر نیست.") return None generated_files = [] for i, chunk in enumerate(text_chunks): # _log(f"🔊 پردازش قطعه {i+1}/{len(text_chunks)}...") # لاگ کمتر final_text = f'"{prompt_input}"\n{chunk}' if prompt_input and prompt_input.strip() else chunk contents = [types.Content(role="user", parts=[types.Part.from_text(text=final_text)])] config = types.GenerateContentConfig(temperature=temperature_val, response_modalities=["audio"], speech_config=types.SpeechConfig(voice_config=types.VoiceConfig( prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=selected_voice)))) fname_base = f"{output_base_name}_part{i+1:03d}" try: response = client.models.generate_content(model=FIXED_MODEL_NAME, 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: inline_data = response.candidates[0].content.parts[0].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"{fname_base}{ext}", data_buffer) if fpath: generated_files.append(fpath) else: _log(f"⚠️ پاسخ API برای قطعه {i+1} بدون داده صوتی بود (با کلید شماره {key_idx_display}).") except Exception as e: _log(f"❌ خطا در تولید قطعه {i+1} با کلید شماره {key_idx_display}: {e}") continue if i < len(text_chunks) - 1 and len(text_chunks) > 1: time.sleep(sleep_time) if not generated_files: _log(f"❌ هیچ فایل صوتی با کلید شماره {key_idx_display} تولید نشد.") return None final_audio_file = None final_output_path_base = f"{output_base_name}_final" if len(generated_files) > 1: if PYDUB_AVAILABLE: merged_fn = f"{final_output_path_base}.wav" if os.path.exists(merged_fn): os.remove(merged_fn) if merge_audio_files_func(generated_files, merged_fn): final_audio_file = merged_fn else: # اگر ادغام ناموفق بود if generated_files: try: target_ext = os.path.splitext(generated_files[0])[1] renamed_first_chunk = f"{final_output_path_base}{target_ext}" if os.path.exists(renamed_first_chunk): os.remove(renamed_first_chunk) os.rename(generated_files[0], renamed_first_chunk) final_audio_file = renamed_first_chunk except Exception as e_rename: _log(f"خطا در تغییر نام فایل اولین قطعه (پس از ادغام ناموفق): {e_rename}") final_audio_file = generated_files[0] # پاک کردن فایل‌های جزئی چه ادغام موفق بوده چه ناموفق for fp_cleanup in generated_files: if final_audio_file and os.path.abspath(fp_cleanup) == os.path.abspath(final_audio_file): continue # فایل نهایی را پاک نکن try: os.remove(fp_cleanup) except: pass else: _log("⚠️ pydub در دسترس نیست. اولین قطعه صوتی ارائه می‌شود.") if generated_files: try: target_ext = os.path.splitext(generated_files[0])[1] renamed_first_chunk = f"{final_output_path_base}{target_ext}" if os.path.exists(renamed_first_chunk): os.remove(renamed_first_chunk) os.rename(generated_files[0], renamed_first_chunk) final_audio_file = renamed_first_chunk for i_gf in range(1, len(generated_files)): # پاک کردن بقیه فایل‌های جزئی try: os.remove(generated_files[i_gf]) except: pass except Exception as e_rename_single: _log(f"خطا در تغییر نام فایل اولین قطعه (بدون pydub): {e_rename_single}") final_audio_file = generated_files[0] elif len(generated_files) == 1: try: target_ext = os.path.splitext(generated_files[0])[1] final_single_fn = f"{final_output_path_base}{target_ext}" if os.path.exists(final_single_fn): os.remove(final_single_fn) os.rename(generated_files[0], final_single_fn) final_audio_file = final_single_fn except Exception as e_rename_single_final: _log(f"خطا در تغییر نام فایل تکی نهایی: {e_rename_single_final}") final_audio_file = generated_files[0] if final_audio_file and os.path.exists(final_audio_file): _log(f"✅ فایل صوتی نهایی با موفقیت با کلید شماره {key_idx_display} تولید شد: {os.path.basename(final_audio_file)}") elif final_audio_file: _log(f"⚠️ فایل نهایی '{final_audio_file}' پس از پردازش وجود ندارد! (با کلید شماره {key_idx_display})") return None else: _log(f"❓ وضعیت نامشخص برای فایل نهایی. (با کلید شماره {key_idx_display})") return None return final_audio_file def gradio_tts_interface(use_file_input, uploaded_file, text_to_speak, speech_prompt, speaker_voice, temperature, progress=gr.Progress(track_tqdm=True)): actual_text = "" if use_file_input: if uploaded_file: try: with open(uploaded_file.name, 'r', encoding='utf-8') as f: actual_text = f.read().strip() if not actual_text: _log("❌ فایل آپلود شده خالی است یا خوانده نشد."); return None except Exception as e: _log(f"❌ خطا در خواندن فایل آپلود شده: {e}"); return None else: _log("❌ گزینه استفاده از فایل انتخاب شده اما فایلی آپلود نشده."); return None else: actual_text = text_to_speak if not actual_text or not actual_text.strip(): _log("❌ متن ورودی برای تبدیل خالی است."); return None final_path = core_generate_audio(actual_text, speech_prompt, speaker_voice, temperature) return final_path # --- CSS (بدون تغییر نسبت به کد شما) --- custom_css_inspired_by_image = f""" @import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;700;800&display=swap'); :root {{ --app-font: 'Vazirmatn', sans-serif; --app-header-grad-start: #2980b9; /* آبی */ --app-header-grad-end: #2ecc71; /* سبز */ --app-panel-bg: #FFFFFF; --app-input-bg: #F7F7F7; --app-button-bg: #2979FF; --app-main-bg: linear-gradient(170deg, #E0F2FE 0%, #F3E8FF 100%); --app-text-primary: #333; --app-text-secondary: #555; --app-border-color: #E0E0E0; --radius-card: 20px; --radius-input: 8px; --shadow-card: 0 10px 30px -5px rgba(0,0,0,0.1); --shadow-button: 0 4px 10px -2px rgba(41,121,255,0.5); }} body, .gradio-container {{ font-family: var(--app-font); direction: rtl; background: var(--app-main-bg); color: var(--app-text-primary); font-size: 16px; line-height: 1.65; }} .gradio-container {{ max-width:100% !important; min-height:100vh; margin:0 !important; padding:0 !important; display:flex; flex-direction:column; }} .app-header-alpha {{ padding: 3rem 1.5rem 4rem 1.5rem; text-align: center; background-image: linear-gradient(135deg, var(--app-header-grad-start) 0%, var(--app-header-grad-end) 100%); color: white; border-bottom-left-radius: var(--radius-card); border-bottom-right-radius: var(--radius-card); box-shadow: 0 6px 20px -5px rgba(0,0,0,0.2); }} .app-header-alpha h1 {{ font-size: 2.4em; font-weight: 800; margin:0 0 0.5rem 0; text-shadow: 0 2px 4px rgba(0,0,0,0.15); }} .app-header-alpha p {{ font-size: 1.1em; color: rgba(255,255,255,0.9); margin-top:0; opacity: 0.9; }} .main-content-panel-alpha {{ padding: 1.8rem 1.5rem; max-width: 680px; margin: -2.5rem auto 2rem auto; width: 90%; background-color: var(--app-panel-bg); border-radius: var(--radius-card); box-shadow: var(--shadow-card); position:relative; z-index:10; }} @media (max-width: 768px) {{ .main-content-panel-alpha {{ width: 95%; padding: 1.5rem 1rem; margin-top: -2rem; }} .app-header-alpha h1 {{font-size:2em;}} .app-header-alpha p {{font-size:1em;}} }} footer {{display:none !important;}} .gr-button.generate-button-final {{ background: var(--app-button-bg) !important; color: white !important; border:none !important; border-radius: var(--radius-input) !important; padding: 0.8rem 1.5rem !important; font-weight: 700 !important; font-size:1.05em !important; transition: all 0.3s ease; box-shadow: var(--shadow-button); width:100%; margin-top:1.5rem !important; }} .gr-button.generate-button-final:hover {{ filter: brightness(1.1); transform: translateY(-2px); box-shadow: 0 6px 12px -3px rgba(41,121,255,0.6);}} .gr-input > label + div > textarea, .gr-dropdown > label + div > div > input, .gr-dropdown > label + div > div > select, .gr-textbox > label + div > textarea, .gr-file > label + div {{ border-radius: var(--radius-input) !important; border: 1px solid var(--app-border-color) !important; background-color: var(--app-input-bg) !important; box-shadow: inset 0 1px 2px rgba(0,0,0,0.05); padding: 0.75rem !important; }} .gr-file > label + div {{ text-align:center; border-style: dashed !important; }} .gr-input > label + div > textarea:focus, .gr-dropdown > label + div > div > input:focus, .gr-textbox > label + div > textarea:focus {{ border-color: var(--app-button-bg) !important; box-shadow: 0 0 0 3px rgba(41,121,255,0.2) !important; }} label > .label-text {{ font-weight: 700 !important; color: var(--app-text-primary) !important; font-size: 0.95em !important; margin-bottom: 0.5rem !important; }} .section-title-main-alpha {{ font-size: 1.1em; color: var(--app-text-secondary); margin-bottom:1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--app-border-color); font-weight:500; text-align:right; }} label > .label-text::before {{ margin-left: 8px; vertical-align: middle; opacity: 0.7; }} label[for*="text_input_main_alpha_v3"] > .label-text::before {{ content: '📝'; }} label[for*="speech_prompt_alpha_v3"] > .label-text::before {{ content: '🗣️'; }} label[for*="speaker_voice_alpha_v3"] > .label-text::before {{ content: '🎤'; }} label[for*="temperature_slider_alpha_v3"] > .label-text::before {{ content: '🌡️'; }} #output_audio_player_alpha_v3 audio {{ width: 100%; border-radius: var(--radius-input); margin-top:0.8rem; }} .temp_description_class_alpha_v3 {{ font-size: 0.85em; color: #777; margin-top: -0.4rem; margin-bottom: 1rem; }} .app-footer-final {{text-align:center;font-size:0.9em;color: var(--app-text-secondary);opacity:0.8; margin-top:3rem;padding:1.5rem 0; border-top:1px solid var(--app-border-color);}} """ alpha_header_html_v3 = """

Alpha TTS

جادوی تبدیل متن به صدا در دستان شما

""" with gr.Blocks(theme=gr.themes.Base(font=[gr.themes.GoogleFont("Vazirmatn")]), css=custom_css_inspired_by_image, title="آلفا TTS") as demo: gr.HTML(alpha_header_html_v3) with gr.Column(elem_classes=["main-content-panel-alpha"]): use_file_input_cb = gr.Checkbox(label="📄 استفاده از فایل متنی (.txt)", value=False, elem_id="use_file_cb_alpha_v3") uploaded_file_input = gr.File( label=" ", file_types=['.txt'], visible=False, elem_id="file_uploader_alpha_main_v3" ) text_to_speak_tb = gr.Textbox( label="متن فارسی برای تبدیل", placeholder="مثال: سلام، فردا هوا چطور است؟", lines=5, value="", visible=True, elem_id="text_input_main_alpha_v3" ) use_file_input_cb.change( fn=lambda x: (gr.update(visible=x, label=" " if x else "متن فارسی برای تبدیل"), gr.update(visible=not x)), inputs=use_file_input_cb, outputs=[uploaded_file_input, text_to_speak_tb] ) speech_prompt_tb = gr.Textbox( label="سبک گفتار (اختیاری)", placeholder="مثال: با لحنی شاد و پرانرژی", value="با لحنی دوستانه و رسا صحبت کن.", lines=2, elem_id="speech_prompt_alpha_v3" ) speaker_voice_dd = gr.Dropdown( SPEAKER_VOICES, label="انتخاب گوینده و لهجه", value="Charon", elem_id="speaker_voice_alpha_v3" ) temperature_slider = gr.Slider( minimum=0.1, maximum=1.5, step=0.05, value=0.9, label="میزان خلاقیت صدا", elem_id="temperature_slider_alpha_v3" ) gr.Markdown("

مقادیر بالاتر = تنوع بیشتر، مقادیر پایین‌تر = یکنواختی بیشتر.

") generate_button = gr.Button("🚀 تولید و پخش صدا", elem_classes=["generate-button-final"], elem_id="generate_button_alpha_v3") output_audio = gr.Audio(label=" ", type="filepath", elem_id="output_audio_player_alpha_v3") generate_button.click( fn=gradio_tts_interface, inputs=[ use_file_input_cb, uploaded_file_input, text_to_speak_tb, speech_prompt_tb, speaker_voice_dd, temperature_slider ], outputs=[output_audio] ) gr.Markdown("

نمونه‌های کاربردی

", elem_id="examples_section_title_v3") gr.Examples( examples=[ [False, None, "سلام بر شما، امیدوارم روز خوبی داشته باشید.", "با لحنی گرم و صمیمی.", "Zephyr", 0.85], [False, None, "این یک آزمایش برای بررسی کیفیت صدای تولید شده توسط هوش مصنوعی آلفا است.", "با صدایی طبیعی و روان.", "Charon", 0.9], ], inputs=[ use_file_input_cb, uploaded_file_input, text_to_speak_tb, speech_prompt_tb, speaker_voice_dd, temperature_slider ], outputs=[output_audio], fn=gradio_tts_interface, cache_examples=False ) gr.Markdown("") if __name__ == "__main__": if NUM_API_KEYS > 0 : # فقط در صورتی که کلید API موجود باشد، برنامه را اجرا کن demo.launch() else: _log("🔴 برنامه به دلیل عدم وجود کلید API جیمینای اجرا نشد. لطفاً Secrets را بررسی کنید.")