Update app.py
Browse files
app.py
CHANGED
@@ -6,7 +6,7 @@ import re
|
|
6 |
import struct
|
7 |
import time
|
8 |
import zipfile
|
9 |
-
from google import genai
|
10 |
from google.genai import types
|
11 |
|
12 |
# تلاش برای ایمپورت pydub و تنظیم فلگ در دسترس بودن
|
@@ -15,6 +15,8 @@ try:
|
|
15 |
PYDUB_AVAILABLE = True
|
16 |
except ImportError:
|
17 |
PYDUB_AVAILABLE = False
|
|
|
|
|
18 |
|
19 |
# --- ثابتها ---
|
20 |
SPEAKER_VOICES = [
|
@@ -24,18 +26,26 @@ SPEAKER_VOICES = [
|
|
24 |
"Rasalthgeti", "Orus", "Aoede", "Callirrhoe", "Autonoe", "Enceladus",
|
25 |
"Iapetus", "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda"
|
26 |
]
|
27 |
-
|
|
|
28 |
|
29 |
-
# --- توابع کمکی (
|
|
|
30 |
|
31 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
try:
|
33 |
with open(file_name, "wb") as f:
|
34 |
f.write(data)
|
35 |
-
|
36 |
return file_name
|
37 |
except Exception as e:
|
38 |
-
|
39 |
return None
|
40 |
|
41 |
def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
|
@@ -48,7 +58,6 @@ def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
|
|
48 |
block_align = num_channels * bytes_per_sample
|
49 |
byte_rate = sample_rate * block_align
|
50 |
chunk_size = 36 + data_size
|
51 |
-
|
52 |
header = struct.pack(
|
53 |
"<4sI4s4sIHHIIHH4sI",
|
54 |
b"RIFF", chunk_size, b"WAVE", b"fmt ", 16, 1, num_channels,
|
@@ -58,7 +67,7 @@ def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
|
|
58 |
|
59 |
def parse_audio_mime_type(mime_type: str) -> dict[str, int | None]:
|
60 |
bits_per_sample = 16
|
61 |
-
rate = 24000
|
62 |
parts = mime_type.split(";")
|
63 |
for param in parts:
|
64 |
param = param.strip()
|
@@ -66,137 +75,124 @@ def parse_audio_mime_type(mime_type: str) -> dict[str, int | None]:
|
|
66 |
try:
|
67 |
rate_str = param.split("=", 1)[1]
|
68 |
rate = int(rate_str)
|
69 |
-
except (ValueError, IndexError):
|
70 |
-
|
71 |
-
elif param.startswith("audio/L"): # e.g., audio/L16
|
72 |
try:
|
73 |
bits_per_sample = int(param.split("L", 1)[1])
|
74 |
-
except (ValueError, IndexError):
|
75 |
-
pass
|
76 |
return {"bits_per_sample": bits_per_sample, "rate": rate}
|
77 |
|
78 |
-
def smart_text_split(text, max_size=3800):
|
79 |
if len(text) <= max_size:
|
80 |
return [text]
|
81 |
chunks = []
|
82 |
current_chunk = ""
|
83 |
-
# Split by sentences, keeping delimiters. Prioritize common Persian sentence enders.
|
84 |
sentences = re.split(r'(?<=[.!?؟])\s+', text)
|
85 |
-
|
86 |
for sentence in sentences:
|
87 |
-
sentence_with_space = sentence + " "
|
88 |
if len(current_chunk) + len(sentence_with_space) > max_size:
|
89 |
-
if current_chunk:
|
90 |
chunks.append(current_chunk.strip())
|
91 |
-
# Now, current_chunk becomes the new sentence.
|
92 |
-
# If this new sentence itself is too long, it needs to be split further.
|
93 |
current_chunk = sentence
|
94 |
while len(current_chunk) > max_size:
|
95 |
-
# Find a good split point (e.g., comma, space) near max_size
|
96 |
-
# Fallback to hard split if no good point found
|
97 |
split_idx = -1
|
98 |
-
# Try splitting at Persian/English punctuation within the oversized chunk
|
99 |
possible_split_chars = ['،', ',', ';', ':', ' ']
|
100 |
-
for char_idx in range(max_size - 1, max_size // 2, -1):
|
101 |
if current_chunk[char_idx] in possible_split_chars:
|
102 |
split_idx = char_idx + 1
|
103 |
break
|
104 |
-
|
105 |
if split_idx != -1:
|
106 |
chunks.append(current_chunk[:split_idx].strip())
|
107 |
current_chunk = current_chunk[split_idx:].strip()
|
108 |
-
else:
|
109 |
chunks.append(current_chunk[:max_size].strip())
|
110 |
current_chunk = current_chunk[max_size:].strip()
|
111 |
else:
|
112 |
current_chunk += (" " if current_chunk else "") + sentence
|
113 |
-
|
114 |
-
if current_chunk: # Add any remaining part
|
115 |
chunks.append(current_chunk.strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
|
117 |
-
return [c for c in chunks if c] # Ensure no empty chunks
|
118 |
|
119 |
-
def merge_audio_files_func(file_paths, output_path,
|
120 |
if not PYDUB_AVAILABLE:
|
121 |
-
|
122 |
return False
|
123 |
try:
|
124 |
-
|
125 |
combined = AudioSegment.empty()
|
126 |
for i, file_path in enumerate(file_paths):
|
127 |
if os.path.exists(file_path):
|
128 |
-
|
129 |
-
audio = AudioSegment.from_file(file_path)
|
130 |
combined += audio
|
131 |
-
if i < len(file_paths) - 1:
|
132 |
-
combined += AudioSegment.silent(duration=200)
|
133 |
else:
|
134 |
-
|
135 |
combined.export(output_path, format="wav")
|
136 |
-
|
137 |
return True
|
138 |
except Exception as e:
|
139 |
-
|
140 |
return False
|
141 |
|
142 |
-
def create_zip_file(file_paths, zip_name,
|
143 |
try:
|
144 |
with zipfile.ZipFile(zip_name, 'w') as zipf:
|
145 |
for file_path in file_paths:
|
146 |
if os.path.exists(file_path):
|
147 |
zipf.write(file_path, os.path.basename(file_path))
|
148 |
-
|
149 |
return True
|
150 |
except Exception as e:
|
151 |
-
|
152 |
return False
|
153 |
|
154 |
-
# --- تابع اصلی تولید (
|
155 |
def core_generate_audio(
|
156 |
text_input, prompt_input, selected_voice, output_base_name,
|
157 |
-
|
158 |
-
|
159 |
-
log_messages_list # Pass the list to append logs
|
160 |
):
|
161 |
-
|
162 |
|
163 |
-
#
|
164 |
-
api_key = os.environ.get("GEMINI_API_KEY")
|
165 |
if not api_key:
|
166 |
-
|
167 |
-
|
168 |
-
|
|
|
|
|
169 |
|
170 |
-
# مقداردهی اولیه کلاینت GenAI
|
171 |
try:
|
172 |
-
|
173 |
client = genai.Client(api_key=api_key)
|
174 |
-
|
175 |
except Exception as e:
|
176 |
-
|
177 |
-
|
178 |
-
return None, None
|
179 |
|
180 |
if not text_input or text_input.strip() == "":
|
181 |
-
|
182 |
-
return None, None
|
183 |
-
|
184 |
-
text_chunks = smart_text_split(text_input, max_chunk)
|
185 |
-
log_messages_list.append(f"📊 متن به {len(text_chunks)} قطعه تقسیم شد.")
|
186 |
-
for i, chunk in enumerate(text_chunks):
|
187 |
-
log_messages_list.append(f"📝 قطعه {i+1}: {len(chunk)} کاراکتر")
|
188 |
-
text_chunks = [c for c in text_chunks if c] # فیلتر کردن قطعات خالی احتمالی
|
189 |
|
|
|
190 |
if not text_chunks:
|
191 |
-
|
192 |
-
return None, None
|
193 |
|
194 |
generated_files = []
|
195 |
-
|
196 |
-
# فایلها در ریشه فضای کاری Space ذخیره میشوند
|
197 |
|
198 |
for i, chunk in enumerate(text_chunks):
|
199 |
-
|
200 |
final_text = f'"{prompt_input}"\n{chunk}' if prompt_input and prompt_input.strip() else chunk
|
201 |
|
202 |
contents = [types.Content(role="user", parts=[types.Part.from_text(text=final_text)])]
|
@@ -213,8 +209,8 @@ def core_generate_audio(
|
|
213 |
current_chunk_filename_base = f"{output_base_name}_part{i+1:03d}"
|
214 |
|
215 |
try:
|
216 |
-
response = client.models.generate_content(
|
217 |
-
model=
|
218 |
contents=contents,
|
219 |
config=generate_content_config,
|
220 |
)
|
@@ -225,330 +221,307 @@ def core_generate_audio(
|
|
225 |
|
226 |
inline_data = response.candidates[0].content.parts[0].inline_data
|
227 |
data_buffer = inline_data.data
|
228 |
-
# حدس پسوند فایل بر اساس MIME type
|
229 |
file_extension = mimetypes.guess_extension(inline_data.mime_type)
|
230 |
|
231 |
-
# اگر پسوند قابل تشخیص نبود یا باینری عمومی بود، WAV را در نظر میگیریم
|
232 |
-
# و در صورت نیاز (مثلاً برای audio/L16) هدر WAV اضافه میکنیم
|
233 |
if file_extension is None or "binary" in inline_data.mime_type or file_extension == ".bin":
|
234 |
file_extension = ".wav"
|
235 |
-
if "audio/L" in inline_data.mime_type:
|
236 |
data_buffer = convert_to_wav(data_buffer, inline_data.mime_type)
|
237 |
-
elif inline_data.mime_type == "audio/mpeg":
|
238 |
-
|
239 |
-
elif inline_data.mime_type == "audio/wav":
|
240 |
-
file_extension = ".wav" # اگر API مستقیما WAV داد
|
241 |
|
242 |
-
generated_file_path = save_binary_file(f"{current_chunk_filename_base}{file_extension}", data_buffer,
|
243 |
if generated_file_path:
|
244 |
generated_files.append(generated_file_path)
|
245 |
-
|
246 |
|
247 |
elif response.text:
|
248 |
-
|
249 |
if "rate limit" in response.text.lower() or "quota" in response.text.lower():
|
250 |
-
|
|
|
251 |
|
252 |
else:
|
253 |
-
|
254 |
-
|
255 |
|
256 |
except types.generation_types.BlockedPromptException as bpe:
|
257 |
-
|
258 |
-
|
259 |
-
log_messages_list.append("لطفاً متن ورودی یا پرامپت سبک گفتار را بررسی و اصلاح کنید.")
|
260 |
-
continue
|
261 |
-
except types.generation_types.StopCandidateException as sce:
|
262 |
-
log_messages_list.append(f"❌ تولید محتوا برای قطعه {i+1} به دلیل نامشخصی متوقف شد: {sce}")
|
263 |
-
continue
|
264 |
except Exception as e:
|
265 |
-
|
266 |
-
if "API key not valid" in str(e):
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
|
|
271 |
|
272 |
if i < len(text_chunks) - 1 and len(text_chunks) > 1 :
|
273 |
-
|
274 |
time.sleep(sleep_time)
|
275 |
|
276 |
if not generated_files:
|
277 |
-
|
278 |
-
return None, None
|
279 |
|
280 |
-
|
281 |
|
282 |
playback_file = None
|
283 |
download_file = None
|
|
|
284 |
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
if
|
290 |
-
download_file = zip_filename
|
291 |
-
if generated_files: playback_file = generated_files[0]
|
292 |
-
else:
|
293 |
-
merged_filename = f"{output_base_name}_merged.wav"
|
294 |
-
if merge_audio_files_func(generated_files, merged_filename, log_messages_list):
|
295 |
playback_file = merged_filename
|
296 |
download_file = merged_filename
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
log_messages_list.append(f"⚠️ خطا در حذف فایل جزئی {os.path.basename(file_path)}: {e}")
|
307 |
else:
|
308 |
-
|
|
|
309 |
zip_filename = f"{output_base_name}_all_parts.zip"
|
310 |
-
if create_zip_file(generated_files, zip_filename,
|
311 |
download_file = zip_filename
|
312 |
-
if generated_files: playback_file = generated_files[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
313 |
|
314 |
elif len(generated_files) == 1:
|
315 |
playback_file = generated_files[0]
|
316 |
download_file = generated_files[0]
|
317 |
-
|
318 |
-
else: # Multiple files, no merge requested (or PYDUB_AVAILABLE is False and merge_files was True)
|
319 |
-
zip_filename = f"{output_base_name}_all_parts.zip"
|
320 |
-
if create_zip_file(generated_files, zip_filename, log_messages_list):
|
321 |
-
download_file = zip_filename
|
322 |
-
if generated_files: playback_file = generated_files[0]
|
323 |
|
324 |
if playback_file and not os.path.exists(playback_file):
|
325 |
-
|
326 |
playback_file = None
|
327 |
if download_file and not os.path.exists(download_file):
|
328 |
-
|
329 |
download_file = None
|
|
|
|
|
|
|
330 |
|
331 |
-
return playback_file, download_file
|
332 |
|
333 |
-
# --- تابع رابط کاربری Gradio ---
|
334 |
def gradio_tts_interface(
|
335 |
use_file_input, uploaded_file, text_to_speak,
|
336 |
speech_prompt, speaker_voice, output_filename_base_in,
|
337 |
-
|
338 |
-
|
339 |
-
merge_audio_files_flag, delete_partial_files_flag,
|
340 |
-
progress=gr.Progress(track_tqdm=True) # track_tqdm for visual progress if using loops with tqdm
|
341 |
):
|
342 |
-
|
343 |
|
344 |
actual_text_input = ""
|
345 |
if use_file_input:
|
346 |
if uploaded_file is not None:
|
347 |
try:
|
348 |
-
# Gradio file objects have a .name attribute which is the temp path
|
349 |
with open(uploaded_file.name, 'r', encoding='utf-8') as f:
|
350 |
actual_text_input = f.read().strip()
|
351 |
-
|
352 |
-
log_messages.append(f"📝 نمونه متن فایل: '{actual_text_input[:100]}{'...' if len(actual_text_input) > 100 else ''}'")
|
353 |
if not actual_text_input:
|
354 |
-
|
355 |
-
return None, None, "\n".join(log_messages)
|
356 |
except Exception as e:
|
357 |
-
|
358 |
-
return None, None, "
|
359 |
else:
|
360 |
-
|
361 |
-
return None, None, "\n".join(log_messages)
|
362 |
else:
|
363 |
actual_text_input = text_to_speak
|
364 |
if not actual_text_input or not actual_text_input.strip():
|
365 |
-
|
366 |
-
return None, None, "\n".join(log_messages)
|
367 |
-
log_messages.append(f"📖 متن ورودی دستی: {len(actual_text_input)} کاراکتر")
|
368 |
-
log_messages.append(f"📝 نمونه متن ورودی: '{actual_text_input[:100]}{'...' if len(actual_text_input) > 100 else ''}'")
|
369 |
-
|
370 |
-
# Sanitize output_filename_base to prevent path traversal or invalid characters
|
371 |
-
output_filename_base = re.sub(r'[^\w\-_]', '', output_filename_base_in if output_filename_base_in else "gemini_tts_output")
|
372 |
-
if not output_filename_base: # If sanitization results in empty string
|
373 |
-
output_filename_base = "gemini_tts_output"
|
374 |
-
log_messages.append(f"🏷️ نام پایه فایل خروجی: {output_filename_base}")
|
375 |
-
|
376 |
-
|
377 |
-
if not PYDUB_AVAILABLE:
|
378 |
-
log_messages.append("⚠️ کتابخانه pydub در دسترس نیست. امکان ادغام فایلهای صوتی وجود نخواهد داشت و فایلهای صوتی به صورت جداگانه (در صورت وجود چند بخش) در یک فایل ZIP ارائه میشوند.")
|
379 |
-
current_merge_audio_files = False # Force disable merge if pydub is not available
|
380 |
-
else:
|
381 |
-
current_merge_audio_files = merge_audio_files_flag
|
382 |
|
|
|
|
|
383 |
|
384 |
-
playback_path, download_path = core_generate_audio(
|
385 |
actual_text_input, speech_prompt, speaker_voice, output_filename_base,
|
386 |
-
|
387 |
-
current_merge_audio_files, delete_partial_files_flag, log_messages
|
388 |
)
|
389 |
-
|
390 |
-
log_output_str = "\n".join(log_messages)
|
391 |
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
pass # Avoid error message if it was just an empty input from the start
|
397 |
-
elif not valid_playback_path and not valid_download_path and actual_text_input.strip():
|
398 |
-
# Add this only if there was text input but no output files
|
399 |
-
log_output_str += "\n🛑 هیچ فایل صوتی برای پخش یا دانلود در دسترس نیست."
|
400 |
|
401 |
-
return
|
402 |
|
403 |
# --- تعریف رابط کاربری Gradio ---
|
404 |
css = """
|
405 |
-
body { font-family: 'Tahoma', 'Arial', sans-serif; direction: rtl; }
|
406 |
-
.gradio-container { max-width: 95% !important; margin: auto !important; padding:
|
407 |
-
@media (min-width: 768px) { .gradio-container { max-width:
|
408 |
footer { display: none !important; }
|
409 |
-
.gr-button { background-color: #
|
410 |
-
.gr-button:hover { background-color: #
|
411 |
-
.gr-input, .gr-dropdown, .gr-slider, .gr-checkbox, .gr-textbox, .gr-file { border-radius: 6px !important; }
|
412 |
-
.gr-panel { padding: 15px !important; border-radius: 8px !important;
|
413 |
-
h2, h3 { color: #
|
414 |
-
|
|
|
|
|
415 |
#output_audio .gallery, #download_file_output .gallery { display: none !important; }
|
416 |
-
|
417 |
-
|
418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
419 |
"""
|
420 |
|
421 |
with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue=gr.themes.colors.blue, secondary_hue=gr.themes.colors.sky)) as demo:
|
422 |
-
gr.
|
423 |
-
|
424 |
-
gr.
|
425 |
-
with gr.Accordion("⚠️ راهنمای مهم: تنظیم کلید API جمینای", open=False):
|
426 |
gr.Markdown(
|
427 |
-
"**برای استفاده از این ابزار،
|
428 |
-
"1. به صفحه اصلی این Space
|
429 |
-
"2.
|
430 |
-
"3. در منوی سمت
|
431 |
-
"4. روی
|
432 |
-
"5.
|
433 |
-
"6.
|
434 |
-
"7.
|
435 |
-
"**توجه:** پس از افزودن یا تغییر Secret، ممکن است لازم باشد Space را یکبار Restart کنید. برای این کار، از منوی سهنقطه (⋮) در کنار دکمه 'Settings' در صفحه اصلی Space، گزینه 'Restart this Space' را انتخاب کنید."
|
436 |
)
|
437 |
-
|
438 |
-
|
439 |
-
with gr.Row():
|
440 |
with gr.Column(scale=3, min_width=300):
|
441 |
-
gr.Markdown("###
|
442 |
-
use_file_input_cb = gr.Checkbox(label="📄 استفاده از فایل متنی ورودی (.txt)", value=False
|
443 |
-
|
444 |
-
# فایل ورودی و متن ورودی با توجه به چکباکس نمایش داده میشوند (منطق در تابع اصلی)
|
445 |
-
uploaded_file_input = gr.File(
|
446 |
-
label="📂 آپلود فایل متنی (فقط شامل متن اصلی، UTF-8)",
|
447 |
-
file_types=['.txt'],
|
448 |
-
visible=False # Initially hidden, controlled by checkbox interaction
|
449 |
-
)
|
450 |
text_to_speak_tb = gr.Textbox(
|
451 |
-
label="⌨️ متن
|
452 |
-
placeholder="
|
453 |
lines=8,
|
454 |
-
value="
|
455 |
-
|
456 |
-
visible=True # Initially visible
|
457 |
)
|
458 |
-
|
459 |
-
# JavaScript to toggle visibility
|
460 |
use_file_input_cb.change(
|
461 |
fn=lambda x: (gr.update(visible=x), gr.update(visible=not x)),
|
462 |
inputs=use_file_input_cb,
|
463 |
outputs=[uploaded_file_input, text_to_speak_tb]
|
464 |
)
|
465 |
-
|
466 |
speech_prompt_tb = gr.Textbox(
|
467 |
-
label="🗣️
|
468 |
-
placeholder="مثال:
|
469 |
-
value="
|
470 |
lines=2
|
471 |
)
|
472 |
|
473 |
with gr.Column(scale=2, min_width=250):
|
474 |
-
gr.Markdown("###
|
475 |
-
model_name_dd = gr.Dropdown(
|
476 |
-
MODEL_NAMES, label="🤖 انتخاب مدل Gemini TTS", value="gemini-2.5-flash-preview-tts"
|
477 |
-
)
|
478 |
speaker_voice_dd = gr.Dropdown(
|
479 |
-
SPEAKER_VOICES, label="🎤 انتخاب
|
480 |
)
|
481 |
temperature_slider = gr.Slider(
|
482 |
-
minimum=0.
|
483 |
-
)
|
484 |
output_filename_base_tb = gr.Textbox(
|
485 |
-
label="📛 نام پایه فایل خروجی (
|
486 |
)
|
487 |
|
488 |
-
gr.Markdown("#### تنظیمات پیشرفته")
|
489 |
max_chunk_size_slider = gr.Slider(
|
490 |
-
minimum=1500, maximum=4000, step=100, value=3800, label="📏 حداکثر کاراکتر
|
491 |
-
)
|
492 |
sleep_between_requests_slider = gr.Slider(
|
493 |
-
minimum=3, maximum=
|
494 |
-
)
|
495 |
-
merge_audio_files_cb = gr.Checkbox(label="🔗 ادغام فایلهای صوتی در یک فایل WAV (نیازمند pydub)", value=True)
|
496 |
-
delete_partial_files_cb = gr.Checkbox(label="🗑️ حذف فایلهای جزئی پس از ادغام (اگر ادغام فعال باشد)", value=False)
|
497 |
-
|
498 |
-
|
499 |
-
generate_button = gr.Button("🎙️ تولید صدا", variant="primary", elem_id="generate_button_main")
|
500 |
-
gr.HTML("<hr>")
|
501 |
|
502 |
-
gr.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
503 |
with gr.Row():
|
504 |
with gr.Column(scale=1):
|
505 |
-
output_audio = gr.Audio(label="🔊 فایل صوتی تولید
|
506 |
with gr.Column(scale=1):
|
507 |
-
download_file_output = gr.File(label="💾 دانلود فایل نهایی (WAV یا ZIP)", elem_id="download_file_link")
|
508 |
-
|
509 |
-
gr.Markdown("### 📜 لاگها و پیامهای فرآیند")
|
510 |
-
logs_output_tb = gr.Textbox(label=" ", lines=10, interactive=False, autoscroll=True, elem_id="logs_textbox")
|
511 |
|
512 |
generate_button.click(
|
513 |
fn=gradio_tts_interface,
|
514 |
inputs=[
|
515 |
use_file_input_cb, uploaded_file_input, text_to_speak_tb,
|
516 |
speech_prompt_tb, speaker_voice_dd, output_filename_base_tb,
|
517 |
-
|
518 |
-
max_chunk_size_slider, sleep_between_requests_slider,
|
519 |
-
merge_audio_files_cb, delete_partial_files_cb
|
520 |
],
|
521 |
-
outputs=[output_audio, download_file_output,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
522 |
)
|
523 |
|
|
|
524 |
gr.Examples(
|
|
|
525 |
examples=[
|
526 |
-
[False, None, "
|
527 |
-
[False, None, "
|
528 |
-
[False, None, "
|
|
|
|
|
|
|
|
|
|
|
529 |
],
|
530 |
inputs=[
|
531 |
use_file_input_cb, uploaded_file_input, text_to_speak_tb,
|
532 |
speech_prompt_tb, speaker_voice_dd, output_filename_base_tb,
|
533 |
-
|
534 |
-
max_chunk_size_slider, sleep_between_requests_slider,
|
535 |
-
merge_audio_files_cb, delete_partial_files_cb
|
536 |
],
|
537 |
-
outputs=[output_audio, download_file_output,
|
538 |
fn=gradio_tts_interface,
|
539 |
-
cache_examples=False
|
540 |
)
|
541 |
|
542 |
gr.Markdown(
|
543 |
-
"<div style='text-align: center; margin-top: 30px; font-size: 0.9em; color:
|
544 |
-
"
|
545 |
-
"لطفاً
|
546 |
-
"برای بهترین نتیجه، از مرورگرهای بهروز استفاده کنید."
|
547 |
"</div>"
|
548 |
)
|
549 |
|
550 |
if __name__ == "__main__":
|
551 |
-
# برای اجرای محلی با قابلیت hot-reload و debug
|
552 |
-
# demo.launch(debug=True, share=False)
|
553 |
-
# برای اجرای عادی (مثلا در محیط Hugging Face Spaces، این خط معمولا لازم نیست چون Gradio خودش هندل میکنه)
|
554 |
demo.launch()
|
|
|
6 |
import struct
|
7 |
import time
|
8 |
import zipfile
|
9 |
+
from google import genai # همچنان برای ارتباط با API لازم است
|
10 |
from google.genai import types
|
11 |
|
12 |
# تلاش برای ایمپورت pydub و تنظیم فلگ در دسترس بودن
|
|
|
15 |
PYDUB_AVAILABLE = True
|
16 |
except ImportError:
|
17 |
PYDUB_AVAILABLE = False
|
18 |
+
# اگر pydub نباشد، ادغام خودکار ممکن نیست و باید به کاربر اطلاع داده شود یا فقط اولین قطعه ارائه شود.
|
19 |
+
# برای این نسخه، فرض میکنیم اگر pydub نباشد، فقط اولین قطعه برگردانده میشود یا ZIP ارائه میشود.
|
20 |
|
21 |
# --- ثابتها ---
|
22 |
SPEAKER_VOICES = [
|
|
|
26 |
"Rasalthgeti", "Orus", "Aoede", "Callirrhoe", "Autonoe", "Enceladus",
|
27 |
"Iapetus", "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda"
|
28 |
]
|
29 |
+
# مدل ثابت است و دیگر نیازی به لیست مدلها نیست
|
30 |
+
FIXED_MODEL_NAME = "gemini-2.5-flash-preview-tts"
|
31 |
|
32 |
+
# --- توابع کمکی (بدون تغییر زیاد، فقط لاگها دیگر به کاربر نمایش داده نمیشوند) ---
|
33 |
+
# لاگها همچنان در لیست جمعآوری میشوند اما در خروجی نهایی Gradio نمایش داده نمیشوند.
|
34 |
|
35 |
+
def _log(message, log_list):
|
36 |
+
"""تابع داخلی برای افزودن پیام به لیست لاگها."""
|
37 |
+
# print(message) # برای دیباگ در کنسول Hugging Face Spaces مفید است
|
38 |
+
log_list.append(message)
|
39 |
+
|
40 |
+
|
41 |
+
def save_binary_file(file_name, data, log_list):
|
42 |
try:
|
43 |
with open(file_name, "wb") as f:
|
44 |
f.write(data)
|
45 |
+
_log(f"✅ فایل در مسیر زیر ذخیره شد: {file_name}", log_list)
|
46 |
return file_name
|
47 |
except Exception as e:
|
48 |
+
_log(f"❌ خطا در ذخیره فایل {file_name}: {e}", log_list)
|
49 |
return None
|
50 |
|
51 |
def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
|
|
|
58 |
block_align = num_channels * bytes_per_sample
|
59 |
byte_rate = sample_rate * block_align
|
60 |
chunk_size = 36 + data_size
|
|
|
61 |
header = struct.pack(
|
62 |
"<4sI4s4sIHHIIHH4sI",
|
63 |
b"RIFF", chunk_size, b"WAVE", b"fmt ", 16, 1, num_channels,
|
|
|
67 |
|
68 |
def parse_audio_mime_type(mime_type: str) -> dict[str, int | None]:
|
69 |
bits_per_sample = 16
|
70 |
+
rate = 24000
|
71 |
parts = mime_type.split(";")
|
72 |
for param in parts:
|
73 |
param = param.strip()
|
|
|
75 |
try:
|
76 |
rate_str = param.split("=", 1)[1]
|
77 |
rate = int(rate_str)
|
78 |
+
except (ValueError, IndexError): pass
|
79 |
+
elif param.startswith("audio/L"):
|
|
|
80 |
try:
|
81 |
bits_per_sample = int(param.split("L", 1)[1])
|
82 |
+
except (ValueError, IndexError): pass
|
|
|
83 |
return {"bits_per_sample": bits_per_sample, "rate": rate}
|
84 |
|
85 |
+
def smart_text_split(text, max_size=3800, log_list=None):
|
86 |
if len(text) <= max_size:
|
87 |
return [text]
|
88 |
chunks = []
|
89 |
current_chunk = ""
|
|
|
90 |
sentences = re.split(r'(?<=[.!?؟])\s+', text)
|
|
|
91 |
for sentence in sentences:
|
92 |
+
sentence_with_space = sentence + " "
|
93 |
if len(current_chunk) + len(sentence_with_space) > max_size:
|
94 |
+
if current_chunk:
|
95 |
chunks.append(current_chunk.strip())
|
|
|
|
|
96 |
current_chunk = sentence
|
97 |
while len(current_chunk) > max_size:
|
|
|
|
|
98 |
split_idx = -1
|
|
|
99 |
possible_split_chars = ['،', ',', ';', ':', ' ']
|
100 |
+
for char_idx in range(max_size - 1, max_size // 2, -1):
|
101 |
if current_chunk[char_idx] in possible_split_chars:
|
102 |
split_idx = char_idx + 1
|
103 |
break
|
|
|
104 |
if split_idx != -1:
|
105 |
chunks.append(current_chunk[:split_idx].strip())
|
106 |
current_chunk = current_chunk[split_idx:].strip()
|
107 |
+
else:
|
108 |
chunks.append(current_chunk[:max_size].strip())
|
109 |
current_chunk = current_chunk[max_size:].strip()
|
110 |
else:
|
111 |
current_chunk += (" " if current_chunk else "") + sentence
|
112 |
+
if current_chunk:
|
|
|
113 |
chunks.append(current_chunk.strip())
|
114 |
+
|
115 |
+
final_chunks = [c for c in chunks if c]
|
116 |
+
if log_list: # Optional logging
|
117 |
+
_log(f"📊 متن به {len(final_chunks)} قطعه تقسیم شد.", log_list)
|
118 |
+
for i, chunk_text in enumerate(final_chunks):
|
119 |
+
_log(f"📝 قطعه {i+1}: {len(chunk_text)} کاراکتر", log_list)
|
120 |
+
return final_chunks
|
121 |
|
|
|
122 |
|
123 |
+
def merge_audio_files_func(file_paths, output_path, log_list):
|
124 |
if not PYDUB_AVAILABLE:
|
125 |
+
_log("❌ pydub در دسترس نیست. نمیتوان فایلها را ادغام کرد.", log_list)
|
126 |
return False
|
127 |
try:
|
128 |
+
_log(f"🔗 در حال ادغام {len(file_paths)} فایل صوتی...", log_list)
|
129 |
combined = AudioSegment.empty()
|
130 |
for i, file_path in enumerate(file_paths):
|
131 |
if os.path.exists(file_path):
|
132 |
+
_log(f"📎 اضافه کردن فایل {i+1}: {os.path.basename(file_path)}", log_list)
|
133 |
+
audio = AudioSegment.from_file(file_path)
|
134 |
combined += audio
|
135 |
+
if i < len(file_paths) - 1:
|
136 |
+
combined += AudioSegment.silent(duration=200)
|
137 |
else:
|
138 |
+
_log(f"⚠️ فایل پیدا نشد: {file_path}", log_list)
|
139 |
combined.export(output_path, format="wav")
|
140 |
+
_log(f"✅ فایل ادغام شده ذخیره شد: {output_path}", log_list)
|
141 |
return True
|
142 |
except Exception as e:
|
143 |
+
_log(f"❌ خطا در ادغام فایلها: {e}", log_list)
|
144 |
return False
|
145 |
|
146 |
+
def create_zip_file(file_paths, zip_name, log_list):
|
147 |
try:
|
148 |
with zipfile.ZipFile(zip_name, 'w') as zipf:
|
149 |
for file_path in file_paths:
|
150 |
if os.path.exists(file_path):
|
151 |
zipf.write(file_path, os.path.basename(file_path))
|
152 |
+
_log(f"📦 فایل ZIP ایجاد شد: {zip_name}", log_list)
|
153 |
return True
|
154 |
except Exception as e:
|
155 |
+
_log(f"❌ خطا در ایجاد فایل ZIP: {e}", log_list)
|
156 |
return False
|
157 |
|
158 |
+
# --- تابع اصلی تولید (با تغییرات درخواستی) ---
|
159 |
def core_generate_audio(
|
160 |
text_input, prompt_input, selected_voice, output_base_name,
|
161 |
+
temperature_val, max_chunk, sleep_time,
|
162 |
+
log_list # Pass the list to append internal logs
|
|
|
163 |
):
|
164 |
+
_log("🚀 شروع فرآیند تبدیل متن به گفتار با هوش مصنوعی آلفا...", log_list)
|
165 |
|
166 |
+
api_key = os.environ.get("GEMINI_API_KEY") # نام Secret در HF Spaces
|
|
|
167 |
if not api_key:
|
168 |
+
_log("❌ خطا: کلید API برای هوش مصنوعی آلفا (GEMINI_API_KEY) در Secrets این Space تنظیم نشده است.", log_list)
|
169 |
+
# چون لاگ نمایش داده نمیشود، کاربر فقط خروجی خالی دریافت میکند.
|
170 |
+
# بهتر است یک پیام خطا به کاربر نمایش داده شود اگر کلید نیست.
|
171 |
+
# اما طبق درخواست، لاگ حذف شده، پس فعلا اینطور میماند.
|
172 |
+
return None, None, "خطا: کلید API تنظیم نشده است. لطفاً با مدیر تماس بگیرید." # پیام خطا برای کاربر
|
173 |
|
|
|
174 |
try:
|
175 |
+
_log("🛠️ در حال ایجاد کلاینت هوش مصنوعی آلفا...", log_list)
|
176 |
client = genai.Client(api_key=api_key)
|
177 |
+
_log("✅ کلاینت با موفقیت ایجاد شد.", log_list)
|
178 |
except Exception as e:
|
179 |
+
_log(f"❌ خطا در ایجاد کلاینت: {e}", log_list)
|
180 |
+
return None, None, "خطا در اتصال به سرویس هوش مصنوعی. لطفاً بعداً تلاش کنید."
|
|
|
181 |
|
182 |
if not text_input or text_input.strip() == "":
|
183 |
+
_log("❌ خطا: متن ورودی خالی است.", log_list)
|
184 |
+
return None, None, "خطا: لطفاً متنی را برای تبدیل وارد کنید."
|
|
|
|
|
|
|
|
|
|
|
|
|
185 |
|
186 |
+
text_chunks = smart_text_split(text_input, max_chunk, log_list)
|
187 |
if not text_chunks:
|
188 |
+
_log("❌ خطا: هیچ قطعه متنی برای پردازش وجود ندارد.", log_list)
|
189 |
+
return None, None, "خطا: مشکلی در پردازش متن ورودی پیش آمد."
|
190 |
|
191 |
generated_files = []
|
192 |
+
model_to_use = FIXED_MODEL_NAME # استفاده از مدل ثابت
|
|
|
193 |
|
194 |
for i, chunk in enumerate(text_chunks):
|
195 |
+
_log(f"\n🔊 تولید صدا برای قطعه {i+1}/{len(text_chunks)} با مدل {model_to_use}...", log_list)
|
196 |
final_text = f'"{prompt_input}"\n{chunk}' if prompt_input and prompt_input.strip() else chunk
|
197 |
|
198 |
contents = [types.Content(role="user", parts=[types.Part.from_text(text=final_text)])]
|
|
|
209 |
current_chunk_filename_base = f"{output_base_name}_part{i+1:03d}"
|
210 |
|
211 |
try:
|
212 |
+
response = client.models.generate_content(
|
213 |
+
model=model_to_use,
|
214 |
contents=contents,
|
215 |
config=generate_content_config,
|
216 |
)
|
|
|
221 |
|
222 |
inline_data = response.candidates[0].content.parts[0].inline_data
|
223 |
data_buffer = inline_data.data
|
|
|
224 |
file_extension = mimetypes.guess_extension(inline_data.mime_type)
|
225 |
|
|
|
|
|
226 |
if file_extension is None or "binary" in inline_data.mime_type or file_extension == ".bin":
|
227 |
file_extension = ".wav"
|
228 |
+
if "audio/L" in inline_data.mime_type:
|
229 |
data_buffer = convert_to_wav(data_buffer, inline_data.mime_type)
|
230 |
+
elif inline_data.mime_type == "audio/mpeg": file_extension = ".mp3"
|
231 |
+
elif inline_data.mime_type == "audio/wav": file_extension = ".wav"
|
|
|
|
|
232 |
|
233 |
+
generated_file_path = save_binary_file(f"{current_chunk_filename_base}{file_extension}", data_buffer, log_list)
|
234 |
if generated_file_path:
|
235 |
generated_files.append(generated_file_path)
|
236 |
+
_log(f"✅ قطعه {i+1} تولید شد: {os.path.basename(generated_file_path)}", log_list)
|
237 |
|
238 |
elif response.text:
|
239 |
+
_log(f"ℹ️ پیام متنی از API برای قطعه {i+1}: {response.text}", log_list)
|
240 |
if "rate limit" in response.text.lower() or "quota" in response.text.lower():
|
241 |
+
_log(f"⏳ محدودیت تعداد درخواست API. افزایش فاصله زمانی.", log_list)
|
242 |
+
# میتوان sleep_time را به صورت پویا افزایش داد
|
243 |
|
244 |
else:
|
245 |
+
_log(f"⚠️ پاسخ API برای قطعه {i+1} حاوی داده صوتی یا پیام متنی نبود. جزئیات: {response.prompt_feedback if response else 'No response'}", log_list)
|
|
|
246 |
|
247 |
except types.generation_types.BlockedPromptException as bpe:
|
248 |
+
_log(f"❌ محتوای پرامپت برای قطعه {i+1} مسدود شد: {bpe}", log_list)
|
249 |
+
return None, None, "خطا: محتوای ورودی شما توسط سیستم ایمنی مسدود شد. لطفاً متن را تغییر دهید."
|
|
|
|
|
|
|
|
|
|
|
250 |
except Exception as e:
|
251 |
+
_log(f"❌ خطا در تولید قطعه {i+1}: {e}", log_list)
|
252 |
+
if "API key not valid" in str(e): return None, None, "خطا: کلید API نامعتبر است."
|
253 |
+
elif "quota" in str(e).lower(): return None, None, "خطا: محدودیت استفاده از سرویس به پایان رسیده است."
|
254 |
+
# برای سایر خطاها، یک پیام عمومی
|
255 |
+
# return None, None, "خطا در تولید صدا. لطفاً دوباره ��لاش کنید." # این باعث توقف کل فرآیند میشود.
|
256 |
+
# بهتر است ادامه دهد و اگر هیچ فایلی تولید نشد، آنگاه پیام خطا بدهد.
|
257 |
+
continue # ادامه به قطعه بعدی
|
258 |
|
259 |
if i < len(text_chunks) - 1 and len(text_chunks) > 1 :
|
260 |
+
_log(f"⏱️ انتظار {sleep_time} ثانیه...", log_list)
|
261 |
time.sleep(sleep_time)
|
262 |
|
263 |
if not generated_files:
|
264 |
+
_log("❌ هیچ فایل صوتی تولید نشد!", log_list)
|
265 |
+
return None, None, "متاسفانه هیچ فایل صوتی تولید نشد. لطفاً ورودی خود را بررسی کرده و مجدداً تلاش کنید."
|
266 |
|
267 |
+
_log(f"\n🎉 {len(generated_files)} فایل صوتی با موفقیت تولید شد!", log_list)
|
268 |
|
269 |
playback_file = None
|
270 |
download_file = None
|
271 |
+
user_message = "صدا با موفقیت تولید شد." # پیام پیشفرض
|
272 |
|
273 |
+
# ادغام خودکار و حذف فایلهای جزئی
|
274 |
+
if len(generated_files) > 1:
|
275 |
+
if PYDUB_AVAILABLE:
|
276 |
+
merged_filename = f"{output_base_name}_final_audio.wav"
|
277 |
+
if merge_audio_files_func(generated_files, merged_filename, log_list):
|
|
|
|
|
|
|
|
|
|
|
278 |
playback_file = merged_filename
|
279 |
download_file = merged_filename
|
280 |
+
_log(f"🎵 فایل نهایی ادغام شده: {os.path.basename(merged_filename)}", log_list)
|
281 |
+
# حذف خودکار فایلهای جزئی
|
282 |
+
for file_path in generated_files:
|
283 |
+
try:
|
284 |
+
if os.path.abspath(file_path) != os.path.abspath(merged_filename):
|
285 |
+
os.remove(file_path)
|
286 |
+
_log(f"🗑️ فایل جزئی حذف شد: {os.path.basename(file_path)}", log_list)
|
287 |
+
except Exception as e:
|
288 |
+
_log(f"⚠️ خطا در حذف فایل جزئی {os.path.basename(file_path)}: {e}", log_list)
|
|
|
289 |
else:
|
290 |
+
_log("⚠️ ادغام ممکن نبود. فایل ZIP از قطعات ارائه میشود.", log_list)
|
291 |
+
user_message = "ادغام فایلها ممکن نبود. فایل ZIP از قطعات صوتی برای دانلود آماده شد."
|
292 |
zip_filename = f"{output_base_name}_all_parts.zip"
|
293 |
+
if create_zip_file(generated_files, zip_filename, log_list):
|
294 |
download_file = zip_filename
|
295 |
+
if generated_files: playback_file = generated_files[0] # پخش اولین قطعه
|
296 |
+
else: # pydub در دسترس نیست
|
297 |
+
_log("⚠️ pydub برای ادغام در دسترس نیست. فایل ZIP از قطعات ارائه میشود.", log_list)
|
298 |
+
user_message = "فایلهای صوتی به صورت جداگانه در یک فایل ZIP آماده شدند (امکان ادغام وجود نداشت)."
|
299 |
+
zip_filename = f"{output_base_name}_all_parts.zip"
|
300 |
+
if create_zip_file(generated_files, zip_filename, log_list):
|
301 |
+
download_file = zip_filename
|
302 |
+
if generated_files: playback_file = generated_files[0]
|
303 |
|
304 |
elif len(generated_files) == 1:
|
305 |
playback_file = generated_files[0]
|
306 |
download_file = generated_files[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
307 |
|
308 |
if playback_file and not os.path.exists(playback_file):
|
309 |
+
_log(f"⚠️ فایل پخش {os.path.basename(playback_file)} وجود ندارد!", log_list)
|
310 |
playback_file = None
|
311 |
if download_file and not os.path.exists(download_file):
|
312 |
+
_log(f"⚠️ فایل دانلود {os.path.basename(download_file)} وجود ندارد!", log_list)
|
313 |
download_file = None
|
314 |
+
|
315 |
+
if not playback_file and not download_file:
|
316 |
+
user_message = "خطا در تولید یا آمادهسازی فایل نهایی."
|
317 |
|
318 |
+
return playback_file, download_file, user_message
|
319 |
|
320 |
+
# --- تابع رابط کاربری Gradio (با تغییرات درخواستی) ---
|
321 |
def gradio_tts_interface(
|
322 |
use_file_input, uploaded_file, text_to_speak,
|
323 |
speech_prompt, speaker_voice, output_filename_base_in,
|
324 |
+
temperature, max_chunk_size, sleep_between_requests,
|
325 |
+
progress=gr.Progress(track_tqdm=True)
|
|
|
|
|
326 |
):
|
327 |
+
internal_logs = [] # لیست برای لاگهای داخلی، به کاربر نمایش داده نمیشود
|
328 |
|
329 |
actual_text_input = ""
|
330 |
if use_file_input:
|
331 |
if uploaded_file is not None:
|
332 |
try:
|
|
|
333 |
with open(uploaded_file.name, 'r', encoding='utf-8') as f:
|
334 |
actual_text_input = f.read().strip()
|
335 |
+
_log(f"✅ متن از فایل '{os.path.basename(uploaded_file.name)}' بارگذاری شد.", internal_logs)
|
|
|
336 |
if not actual_text_input:
|
337 |
+
return None, None, "خطا: فایل آپلود شده خالی است."
|
|
|
338 |
except Exception as e:
|
339 |
+
_log(f"❌ خطا در خواندن فایل آپلود شده: {e}", internal_logs)
|
340 |
+
return None, None, f"خطا در خواندن فایل: {e}"
|
341 |
else:
|
342 |
+
return None, None, "خطا: گزینه فایل انتخاب شده اما فایلی آپلود نشده."
|
|
|
343 |
else:
|
344 |
actual_text_input = text_to_speak
|
345 |
if not actual_text_input or not actual_text_input.strip():
|
346 |
+
return None, None, "خطا: لطفاً متنی را وارد کنید."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
347 |
|
348 |
+
output_filename_base = re.sub(r'[^\w\-_]', '', output_filename_base_in if output_filename_base_in else "alpha_tts_output")
|
349 |
+
if not output_filename_base: output_filename_base = "alpha_tts_output"
|
350 |
|
351 |
+
playback_path, download_path, user_message_from_core = core_generate_audio(
|
352 |
actual_text_input, speech_prompt, speaker_voice, output_filename_base,
|
353 |
+
temperature, max_chunk_size, sleep_between_requests, internal_logs
|
|
|
354 |
)
|
|
|
|
|
355 |
|
356 |
+
# پیام نهایی برای کاربر
|
357 |
+
final_user_message = user_message_from_core
|
358 |
+
if not PYDUB_AVAILABLE and len(smart_text_split(actual_text_input, max_chunk_size)) > 1 and download_path and download_path.endswith(".zip"):
|
359 |
+
final_user_message = "صدا با موفقیت تولید شد. چون قطعات متعدد بودند و امکان ادغام خودکار فراهم نبود، فایل ZIP برای دانلود آماده شد."
|
|
|
|
|
|
|
|
|
360 |
|
361 |
+
return playback_path, download_path, final_user_message
|
362 |
|
363 |
# --- تعریف رابط کاربری Gradio ---
|
364 |
css = """
|
365 |
+
body { font-family: 'Tahoma', 'Arial', sans-serif; direction: rtl; background-color: #f0f2f5; }
|
366 |
+
.gradio-container { max-width: 95% !important; margin: 20px auto !important; padding: 15px !important; background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
367 |
+
@media (min-width: 768px) { .gradio-container { max-width: 700px !important; } }
|
368 |
footer { display: none !important; }
|
369 |
+
.gr-button { font-weight: bold; background-color: #007bff !important; color: white !important; border-radius: 8px !important; padding: 10px 20px !important; transition: background-color 0.3s ease; }
|
370 |
+
.gr-button:hover { background-color: #0056b3 !important; }
|
371 |
+
.gr-input, .gr-dropdown, .gr-slider, .gr-checkbox, .gr-textbox, .gr-file { border-radius: 6px !important; border: 1px solid #ced4da; }
|
372 |
+
.gr-panel { padding: 15px !important; border-radius: 8px !important; background-color: #f8f9fa; border: 1px solid #e9ecef; margin-bottom:15px; }
|
373 |
+
h1, h2, h3 { color: #343a40; text-align: center; }
|
374 |
+
h1 { font-size: 1.8em; margin-bottom: 5px;}
|
375 |
+
h2 { font-size: 1.2em; margin-bottom: 15px; color: #495057;}
|
376 |
+
label { font-weight: 500; color: #495057; margin-bottom: 5px; display: block; }
|
377 |
#output_audio .gallery, #download_file_output .gallery { display: none !important; }
|
378 |
+
textarea, input[type="text"] { direction: rtl; text-align: right; padding: 10px; font-size: 1em; }
|
379 |
+
.gr-form { gap: 20px !important; }
|
380 |
+
.user_message_output { padding: 10px; margin-top: 15px; border-radius: 6px; text-align: center; font-weight: 500; }
|
381 |
+
.user_message_output.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
382 |
+
.user_message_output.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
383 |
+
"""
|
384 |
+
|
385 |
+
alpha_intro = """
|
386 |
+
<div style='text-align:center; padding:10px;'>
|
387 |
+
<img src='https://img.icons8.com/fluency/96/artificial-intelligence.png' alt='AI Icon' style='width:60px; height:60px; margin-bottom:5px;'/>
|
388 |
+
<h1>تبدیل متن به صدا با هوش مصنوعی آلفا</h1>
|
389 |
+
<p style='font-size:1.1em; color:#555;'>به سادگی متن خود را وارد کنید و صدای طبیعی و رسا تحویل بگیرید!</p>
|
390 |
+
</div>
|
391 |
"""
|
392 |
|
393 |
with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue=gr.themes.colors.blue, secondary_hue=gr.themes.colors.sky)) as demo:
|
394 |
+
gr.HTML(alpha_intro)
|
395 |
+
|
396 |
+
with gr.Accordion("⚠️ راهنمای مهم: تنظیم کلید API", open=False, elem_id="api_key_accordion"):
|
|
|
397 |
gr.Markdown(
|
398 |
+
"**برای استفاده از این ابزار، نیاز به تنظیم یک کلید API در تنظیمات این Space دارید:**\n"
|
399 |
+
"1. به صفحه اصلی این Space بروید.\n"
|
400 |
+
"2. روی نام Space و سپس 'Settings' (⚙️) کلیک کنید.\n"
|
401 |
+
"3. در منوی سمت چپ، به 'Secrets' بروید.\n"
|
402 |
+
"4. روی '+ New secret' کلیک کنید.\n"
|
403 |
+
"5. نام Secret را `GEMINI_API_KEY` (با حروف بزرگ) وارد کنید.\n"
|
404 |
+
"6. کلید API خود را در فیلد 'Value' وارد کنید.\n"
|
405 |
+
"7. 'Save secret' را بزنید و در صورت نیاز Space را Restart کنید."
|
|
|
406 |
)
|
407 |
+
|
408 |
+
with gr.Row(elem_classes="gr-form"):
|
|
|
409 |
with gr.Column(scale=3, min_width=300):
|
410 |
+
gr.Markdown("### ۱. متن و سبک گفتار خود را وارد کنید")
|
411 |
+
use_file_input_cb = gr.Checkbox(label="📄 استفاده از فایل متنی ورودی (.txt)", value=False)
|
412 |
+
uploaded_file_input = gr.File(label="📂 آپلود فایل متنی (UTF-8)", file_types=['.txt'], visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
413 |
text_to_speak_tb = gr.Textbox(
|
414 |
+
label="⌨️ متن برای تبدیل به گفتار:",
|
415 |
+
placeholder="اینجا بنویسید...",
|
416 |
lines=8,
|
417 |
+
value="سلام! من هوش مصنوعی آلفا هستم و میتوانم متن شما را به صدا تبدیل کنم.",
|
418 |
+
visible=True
|
|
|
419 |
)
|
|
|
|
|
420 |
use_file_input_cb.change(
|
421 |
fn=lambda x: (gr.update(visible=x), gr.update(visible=not x)),
|
422 |
inputs=use_file_input_cb,
|
423 |
outputs=[uploaded_file_input, text_to_speak_tb]
|
424 |
)
|
|
|
425 |
speech_prompt_tb = gr.Textbox(
|
426 |
+
label="🗣️ سبک گفتار (اختیاری):",
|
427 |
+
placeholder="مثال: یک گوینده خبر حرفهای، یک دوست صمیمی، پرانرژی و شاد",
|
428 |
+
value="با لحنی دوستانه و واضح صحبت کن.",
|
429 |
lines=2
|
430 |
)
|
431 |
|
432 |
with gr.Column(scale=2, min_width=250):
|
433 |
+
gr.Markdown("### ۲. تنظیمات صدا")
|
|
|
|
|
|
|
434 |
speaker_voice_dd = gr.Dropdown(
|
435 |
+
SPEAKER_VOICES, label="🎤 انتخاب گوینده:", value="Charon"
|
436 |
)
|
437 |
temperature_slider = gr.Slider(
|
438 |
+
minimum=0.1, maximum=1.5, step=0.05, value=0.9, label="🌡️ خلاقیت صدا (دما):"
|
439 |
+
)
|
440 |
output_filename_base_tb = gr.Textbox(
|
441 |
+
label="📛 نام پایه فایل خروجی (اختیاری):", value="alpha_audio_output"
|
442 |
)
|
443 |
|
444 |
+
gr.Markdown("#### تنظیمات فنی (پیشرفته)")
|
445 |
max_chunk_size_slider = gr.Slider(
|
446 |
+
minimum=1500, maximum=4000, step=100, value=3800, label="📏 حداکثر کاراکتر هر بخش:"
|
447 |
+
)
|
448 |
sleep_between_requests_slider = gr.Slider(
|
449 |
+
minimum=3, maximum=20, step=0.5, value=10, label="⏱️ تاخیر بین بخشها (ثانیه):"
|
450 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
451 |
|
452 |
+
generate_button = gr.Button("🎧 تولید صدا با آلفا", variant="primary", elem_id="generate_button_main")
|
453 |
+
|
454 |
+
user_message_display = gr.Textbox(
|
455 |
+
label=" ",
|
456 |
+
interactive=False,
|
457 |
+
elem_classes="user_message_output",
|
458 |
+
placeholder="پیام وضعیت اینجا نمایش داده میشود..."
|
459 |
+
)
|
460 |
+
|
461 |
+
gr.HTML("<hr style='margin: 20px 0;'>")
|
462 |
+
gr.Markdown("<h3 style='text-align:center; margin-bottom:10px;'>📢 نتیجه و دانلود 📢</h3>")
|
463 |
with gr.Row():
|
464 |
with gr.Column(scale=1):
|
465 |
+
output_audio = gr.Audio(label="🔊 فایل صوتی تولید شده:", type="filepath", elem_id="output_audio_player")
|
466 |
with gr.Column(scale=1):
|
467 |
+
download_file_output = gr.File(label="💾 دانلود فایل نهایی (WAV یا ZIP):", elem_id="download_file_link")
|
|
|
|
|
|
|
468 |
|
469 |
generate_button.click(
|
470 |
fn=gradio_tts_interface,
|
471 |
inputs=[
|
472 |
use_file_input_cb, uploaded_file_input, text_to_speak_tb,
|
473 |
speech_prompt_tb, speaker_voice_dd, output_filename_base_tb,
|
474 |
+
temperature_slider, max_chunk_size_slider, sleep_between_requests_slider
|
|
|
|
|
475 |
],
|
476 |
+
outputs=[output_audio, download_file_output, user_message_display]
|
477 |
+
).then(
|
478 |
+
# JavaScript to update user_message_display class based on content
|
479 |
+
None, None, None,
|
480 |
+
_js="""
|
481 |
+
(audio, file, msg_text) => {
|
482 |
+
const msg_box = document.querySelector('.user_message_output textarea'); // Gradio Textbox is a textarea
|
483 |
+
if (msg_box) {
|
484 |
+
msg_box.classList.remove('success', 'error'); // Clear previous classes
|
485 |
+
if (msg_text.toLowerCase().includes('خطا') || msg_text.toLowerCase().includes('متاسفانه')) {
|
486 |
+
msg_box.classList.add('error');
|
487 |
+
} else if (msg_text) {
|
488 |
+
msg_box.classList.add('success');
|
489 |
+
}
|
490 |
+
}
|
491 |
+
return [audio, file, msg_text]; // Must return all outputs
|
492 |
+
}
|
493 |
+
"""
|
494 |
)
|
495 |
|
496 |
+
|
497 |
gr.Examples(
|
498 |
+
label="✨ نمونههای آماده برای امتحان کردن ✨",
|
499 |
examples=[
|
500 |
+
[False, None, "سلام به همه دوستان! امروز میخواهیم درباره آخرین دستاوردهای هوش مصنوعی صحبت کنیم.", "با لحنی پر انرژی و هیجانزده، مانند یک مجری برنامه علمی.", "Zephyr", "alpha_demo_1", 0.95, 3800, 8],
|
501 |
+
[False, None, "داستان از آنجا شروع شد که در یک شب تاریک و طوفانی، قهرمان ما به کلبهای مرموز رسید.", "با صدایی آرام و داستانی، مناسب برای قصهگویی شبانه.", "Achird", "alpha_story_1", 0.8, 3500, 12],
|
502 |
+
[False, None, "آخرین اخبار ورزشی: تیم ملی فوتبال کشورمان با یک بازی درخشان به پیروزی رسید!", "مانند یک گزارشگر ورزشی هیجانزده و سریع.", "Orus", "alpha_news_1", 1.0, 3000, 7],
|
503 |
+
[False, None, "آموزش پخت کیک شکلاتی: ابتدا فر را با دمای ۱۸۰ درجه سانتیگراد گرم کنید. سپس آرد، شکر و کاکائو را با هم مخلوط نمایید.", "با صدایی واضح، آموزشی و کمی آهستهتر از حد معمول.", "Vindemiatrix", "alpha_recipe_1", 0.75, 3800, 10],
|
504 |
+
[False, None, "به پادکست هفتگی ما خوش آمدید. این هفته به بررسی عمیق تاثیرات فناوری بر زندگی روزمره خواهیم پرداخت.", "مانند یک میزبان پادکست، صمیمی و متفکر.", "Laomedeia", "alpha_podcast_1", 0.85, 3600, 11],
|
505 |
+
[False, None, "اعلامیه مهم: پرواز شماره ۳۷۲ به مقصد تهران با یک ساعت تاخیر انجام خواهد شد. از صبر و شکیبایی شما سپاسگزاریم.", "با صدایی رسمی و واضح، مانند اعلانات فرودگاه.", "Schedar", "alpha_announce_1", 0.7, 3200, 9],
|
506 |
+
[False, None, "یک شعر زیبا از حافظ: «یوسف گمگشته بازآید به کنعان غم مخور / کلبه احزان شود روزی گلستان غم مخور».", "با لحنی ادیبانه و احساسی، مناسب برای دکلمه شعر.", "Alnilam", "alpha_poem_1", 0.9, 3700, 13],
|
507 |
+
[False, None, "تماس با پشتیبانی: برای ارتباط با واحد فروش عدد ۱، واحد پشتیبانی فنی عدد ۲ و برای سایر موارد عدد ۳ را شمارهگیری فرمایید.", "با صدایی ماشینی و راهنما، مانند سیستم پاسخگویی تلفنی.", "Puck", "alpha_ivr_1", 0.6, 3000, 6],
|
508 |
],
|
509 |
inputs=[
|
510 |
use_file_input_cb, uploaded_file_input, text_to_speak_tb,
|
511 |
speech_prompt_tb, speaker_voice_dd, output_filename_base_tb,
|
512 |
+
temperature_slider, max_chunk_size_slider, sleep_between_requests_slider
|
|
|
|
|
513 |
],
|
514 |
+
outputs=[output_audio, download_file_output, user_message_display],
|
515 |
fn=gradio_tts_interface,
|
516 |
+
cache_examples=False
|
517 |
)
|
518 |
|
519 |
gr.Markdown(
|
520 |
+
"<div style='text-align: center; margin-top: 30px; padding-top:15px; border-top: 1px solid #eee; font-size: 0.9em; color: #6c757d;'>"
|
521 |
+
"قدرت گرفته از فناوری پیشرفته هوش مصنوعی آلفا.<br>"
|
522 |
+
"لطفاً از این ابزار به صورت مسئولانه استفاده کنید."
|
|
|
523 |
"</div>"
|
524 |
)
|
525 |
|
526 |
if __name__ == "__main__":
|
|
|
|
|
|
|
527 |
demo.launch()
|