|
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 |
|
|
|
|
|
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 خود اضافه کنید.") |
|
|
|
|
|
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 |
|
|
|
return key_to_use, key_display_index |
|
|
|
|
|
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) |
|
|
|
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] |
|
|
|
return final_chunks |
|
|
|
def merge_audio_files_func(file_paths, output_path): |
|
if not PYDUB_AVAILABLE: _log("⚠️ 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: _log(f"⚠️ فایل برای ادغام پیدا نشد: {fp}") |
|
combined.export(output_path, format="wav") |
|
|
|
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): |
|
|
|
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 |
|
|
|
|
|
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 = """ |
|
<div class='app-header-alpha'> |
|
<h1>Alpha TTS</h1> |
|
<p>جادوی تبدیل متن به صدا در دستان شما</p> |
|
</div> |
|
""" |
|
|
|
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("<p class='temp_description_class_alpha_v3'>مقادیر بالاتر = تنوع بیشتر، مقادیر پایینتر = یکنواختی بیشتر.</p>") |
|
|
|
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("<h3 class='section-title-main-alpha' style='margin-top:2.5rem; text-align:center; border-bottom:none;'>نمونههای کاربردی</h3>", 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("<p class='app-footer-final'>Alpha Language Learning © 2024</p>") |
|
|
|
if __name__ == "__main__": |
|
if NUM_API_KEYS > 0 : |
|
demo.launch() |
|
else: |
|
_log("🔴 برنامه به دلیل عدم وجود کلید API جیمینای اجرا نشد. لطفاً Secrets را بررسی کنید.") |