Transfer Bot commited on
Commit
b92a61a
·
0 Parent(s):

Moved to Hugging Face automatically

Browse files
Files changed (7) hide show
  1. .gitattributes +53 -0
  2. Dockerfile +29 -0
  3. README.md +49 -0
  4. __pycache__/app.cpython-312.pyc +0 -0
  5. app.py +592 -0
  6. index.html +785 -0
  7. requirements.txt +6 -0
.gitattributes ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.whl filter=lfs diff=lfs merge=lfs -text
37
+ *.png filter=lfs diff=lfs merge=lfs -text
38
+ *.jpg filter=lfs diff=lfs merge=lfs -text
39
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
40
+ *.gif filter=lfs diff=lfs merge=lfs -text
41
+ *.webp filter=lfs diff=lfs merge=lfs -text
42
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
43
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
44
+ *.wav filter=lfs diff=lfs merge=lfs -text
45
+ *.tar.gz filter=lfs diff=lfs merge=lfs -text
46
+ *.pdf filter=lfs diff=lfs merge=lfs -text
47
+ *.woff filter=lfs diff=lfs merge=lfs -text
48
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
49
+ *.ttf filter=lfs diff=lfs merge=lfs -text
50
+ *.eot filter=lfs diff=lfs merge=lfs -text
51
+ *.ico filter=lfs diff=lfs merge=lfs -text
52
+ *.sqlite3 filter=lfs diff=lfs merge=lfs -text
53
+ *.db filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE 1
4
+ ENV PYTHONUNBUFFERED 1
5
+
6
+ WORKDIR /app
7
+
8
+ COPY requirements.txt .
9
+
10
+ RUN apt-get update && \
11
+ apt-get install -y --no-install-recommends ffmpeg && \
12
+ apt-get clean && \
13
+ rm -rf /var/lib/apt/lists/*
14
+
15
+ RUN pip install --no-cache-dir --upgrade pip && \
16
+ pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY . .
19
+
20
+ # ساخت پوشه برای فایل‌های صوتی
21
+ RUN mkdir -p /app/audio_cache
22
+
23
+ EXPOSE 7860
24
+
25
+ # healthcheck داخلی داکر: اگر سرور پاسخ ندهد، پلتفرم متوجه می‌شود
26
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
27
+ CMD python -c "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://127.0.0.1:7860/healthz', timeout=5).status==200 else sys.exit(1)"
28
+
29
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--timeout-keep-alive", "30"]
README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: tts translate Alpha
3
+ emoji: ✈️🗣️
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # ✈️ آلفا مترجم — مترجم و گوینده هوشمند چندزبانه
12
+
13
+ این ابزار متن شما را بین ده‌ها زبان ترجمه کرده و با صدای طبیعی (Edge-TTS مایکروسافت) می‌خواند.
14
+
15
+ ## نکته مهم درباره‌ی پایداری (Uptime)
16
+
17
+ این پروژه از دو سرویس **رایگان و بدون کلید API** استفاده می‌کند:
18
+ - ترجمه: `deep-translator` (از طریق Google Translate)
19
+ - تبدیل متن به صدا: `edge-tts` (سرویس رایگان مایکروسافت)
20
+
21
+ هیچ Secret/API Key ای برای اجرا لازم نیست.
22
+
23
+ ### چرا ممکن است اسپیس گاهی ری‌استارت شود؟
24
+ 1. **پلن رایگان Hugging Face Spaces** منابع محدودی (CPU/RAM مشترک) دارد و پلتفرم ممکن است container را برای مدیریت منابع، به‌روزرسانی زیرساخت، یا در صورت رسیدن به سقف مصرف حافظه، ری‌استارت کند. این رفتار طبیعی پلن رایگان است و مستقل از کیفیت کد شماست.
25
+ 2. سرویس رایگان Edge-TTS مایکروسافت گاهی زیر بار زیاد (درخواست‌های هم‌زمان بالا) خطای موقت `No audio was received` می‌دهد. کد فعلی این حالت را با **retry خودکار** و **محدودسازی تعداد درخواست هم‌زمان** (`TTS_CONCURRENCY_LIMIT`) مدیریت می‌کند تا این خطاها به بحران حافظه/کرش تبدیل نشوند.
26
+
27
+ ### برای حداکثر پایداری توصیه می‌شود:
28
+ - در تنظیمات Space (Settings → Sleep time)، اگر پلن رایگان استفاده می‌کنید توجه کنید که Spaceهای CPU Basic رایگان ممکن است در نبود ترافیک بعد از مدتی خواب بروند؛ برای جلوگیری از خواب کامل باید سخت‌افزار **Persistent/Paid** انتخاب شود («Upgrade Space» در تنظیمات).
29
+ - متغیرهای محیطی زیر را می‌توانید در Settings → Variables تنظیم کنید تا بار سرویس را کنترل کنید:
30
+ - `TTS_CONCURRENCY_LIMIT` (پیش‌فرض: 6) — تعداد درخواست هم‌زمان مجاز به Edge-TTS
31
+ - `AUDIO_CACHE_MAX_MB` (پیش‌فرض: 300) — حداکثر حجم مجاز پوشه‌ی کش صوتی قبل از پاکسازی اضطراری
32
+ - `TRANSLATE_CONCURRENCY` (پیش‌فرض: 4) — تعداد درخواست ترجمه‌ی هم‌زمان
33
+ - `AZURE_TRANSLATOR_KEY` و `AZURE_TRANSLATOR_REGION` (اختیاری، در Secrets) — مسیر ترجمه‌ی رسمی و پایدار
34
+ - `TRANSLATE_PROXY` (اختیاری، در Secrets) — پروکسی خروجی برای درخواست‌های ترجمه
35
+
36
+ ### مانیتورینگ
37
+ مسیر `/healthz` برای بررسی سلامت سرویس در دسترس است و در `Dockerfile` هم healthcheck داخلی تعریف شده است.
38
+
39
+ ## نحوه استفاده
40
+ 1. متن خود را وارد کنید.
41
+ 2. زبان مبدا و مقصد و صدای دلخواه را انتخاب کنید.
42
+ 3. (اختیاری) سرعت، حجم و گام صدا را در تنظیمات پیشرفته تغییر دهید.
43
+ 4. روی دکمه‌ی ترجمه و تلفظ بزنید.
44
+
45
+ ## وابستگی‌ها
46
+ همه‌ی نیازمندی‌ها در `requirements.txt` مشخص و توسط `Dockerfile` نصب می‌شوند.
47
+
48
+ ---
49
+ ساخته شده با ❤️ — FastAPI · Edge-TTS · Google Translate
__pycache__/app.cpython-312.pyc ADDED
Binary file (46.3 kB). View file
 
app.py ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ from typing import List, Optional, Any
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from pydantic import BaseModel
11
+ import edge_tts
12
+ from deep_translator import GoogleTranslator
13
+ import uuid
14
+
15
+ # --- پیکربندی اولیه ---
16
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
17
+ AUDIO_DIR = "audio_cache"
18
+ os.makedirs(AUDIO_DIR, exist_ok=True)
19
+
20
+ # حداکثر تعداد درخواست هم‌زمان TTS - جلوگیری از overload شدن سرویس مایکروسافت
21
+ # و جلوگیری از مصرف بی‌رویه RAM/Thread که باعث ریستارت اسپیس می‌شود
22
+ TTS_CONCURRENCY_LIMIT = int(os.environ.get("TTS_CONCURRENCY_LIMIT", 6))
23
+ TTS_SEMAPHORE = asyncio.Semaphore(TTS_CONCURRENCY_LIMIT)
24
+ TTS_TIMEOUT = 20 # ثانیه - جلوگیری از هنگ کردن کانکشن‌ها
25
+ TTS_MAX_RETRIES = 3
26
+
27
+ # حداکثر حجم مجاز پوشه audio_cache (به مگابایت) قبل از پاکسازی اضطراری
28
+ AUDIO_CACHE_MAX_MB = int(os.environ.get("AUDIO_CACHE_MAX_MB", 300))
29
+
30
+ # پاکسازی فایل‌های صوتی قدیمی برای جلوگیری از پر شدن هاست
31
+ def cleanup_old_audio():
32
+ while True:
33
+ try:
34
+ now = time.time()
35
+ entries = []
36
+ total_size = 0
37
+ for filename in os.listdir(AUDIO_DIR):
38
+ filepath = os.path.join(AUDIO_DIR, filename)
39
+ try:
40
+ st = os.stat(filepath)
41
+ except FileNotFoundError:
42
+ continue
43
+ total_size += st.st_size
44
+ entries.append((filepath, st.st_mtime, st.st_size))
45
+ if st.st_mtime < now - 3600: # حذف فایل‌های قدیمی‌تر از ۱ ساعت
46
+ try:
47
+ os.remove(filepath)
48
+ total_size -= st.st_size
49
+ except Exception as e:
50
+ logging.warning(f"Cleanup: could not remove {filepath}: {e}")
51
+
52
+ # اگر حجم کش با وجود پاکسازی زمانی هنوز خیلی بزرگ بود، قدیمی‌ترین‌ها را زودتر حذف کن
53
+ limit_bytes = AUDIO_CACHE_MAX_MB * 1024 * 1024
54
+ if total_size > limit_bytes:
55
+ entries.sort(key=lambda x: x[1])
56
+ for filepath, mtime, size in entries:
57
+ if total_size <= limit_bytes:
58
+ break
59
+ try:
60
+ os.remove(filepath)
61
+ total_size -= size
62
+ except Exception:
63
+ pass
64
+ logging.info("Cleanup: emergency trim of audio_cache due to size limit")
65
+ except Exception as e:
66
+ logging.error(f"Cleanup error: {e}")
67
+ time.sleep(600) # هر ۱۰ دقیقه به جای ۳۰ دقیقه
68
+
69
+ threading.Thread(target=cleanup_old_audio, daemon=True, name="audio-cleanup").start()
70
+
71
+ # --- دیتابیس جامع زبان‌ها و صداها ---
72
+ # زبان‌های پرکاربرد در ابتدا قرار گرفته‌اند و سپس سایر زبان‌ها
73
+ LANGUAGES_MAP = {
74
+ # --- زبان‌های پرکاربرد ---
75
+ "انگلیسی": {"code": "en", "voices": {'زن - جنی (آمریکا)': 'en-US-JennyNeural', 'مرد - گای (آمریکا)': 'en-US-GuyNeural', 'زن - آریا (آمریکا)': 'en-US-AriaNeural', 'مرد - کریستوفر (آمریکا)': 'en-US-ChristopherNeural', 'زن - لیبی (بریتانیا)': 'en-GB-LibbyNeural', 'مرد - رایان (بریتانیا)': 'en-GB-RyanNeural', 'زن - ناتاشا (استرالیا)': 'en-AU-NatashaNeural', 'مرد - ویلیام (استرالیا)': 'en-AU-WilliamNeural', 'مرد - لیام (کانادا)': 'en-CA-LiamNeural', 'زن - کلارا (کانادا)': 'en-CA-ClaraNeural', 'زن - نیرجا (هند)': 'en-IN-NeerjaNeural', 'مرد - پرابات (هند)': 'en-IN-PrabhatNeural', 'زن - آسیمی (نیجریه)': 'en-NG-EzinneNeural', 'مرد - آبه (نیجریه)': 'en-NG-AbeoNeural'}},
76
+ "فارسی": {"code": "fa", "voices": {'زن - دلارا': 'fa-IR-DilaraNeural', 'مرد - فرید': 'fa-IR-FaridNeural'}},
77
+ "عربی": {"code": "ar", "voices": {'زن - فاطمه (امارات)': 'ar-AE-FatimaNeural', 'مرد - حمدان (امارات)': 'ar-AE-HamdanNeural', 'زن - سلما (مصر)': 'ar-EG-SalmaNeural', 'مرد - شاکر (مصر)': 'ar-EG-ShakirNeural', 'مرد - حامد (عربستان)': 'ar-SA-HamedNeural', 'زن - زاریة (عربستان)': 'ar-SA-ZariyahNeural'}},
78
+ "چینی": {"code": "zh-CN", "voices": {'مرد - یونشی': 'zh-CN-YunxiNeural', 'زن - شیائوشیا': 'zh-CN-XiaoxiaoNeural', 'زن - شیائویی': 'zh-CN-XiaoyiNeural', 'مرد - یونجیان': 'zh-CN-YunjianNeural'}},
79
+ "چی��ی (سنتی)": {"code": "zh-TW", "voices": {'زن - هسیاوچن': 'zh-TW-HsiaoChenNeural', 'مرد - یونژه': 'zh-TW-YunJheNeural', 'زن - هسیاویو': 'zh-TW-HsiaoYuNeural'}},
80
+ "ترکی": {"code": "tr", "voices": {'زن - امل': 'tr-TR-EmelNeural', 'مرد - احمد': 'tr-TR-AhmetNeural'}},
81
+ "فرانسوی": {"code": "fr", "voices": {'زن - الویز (فرانسه)': 'fr-FR-EloiseNeural', 'مرد - هنری (فرانسه)': 'fr-FR-HenriNeural', 'زن - دنیس (فرانسه)': 'fr-FR-DeniseNeural', 'زن - سیلویا (کانادا)': 'fr-CA-SylvieNeural', 'مرد - آنتوان (کانادا)': 'fr-CA-AntoineNeural', 'زن - آریان (سوئیس)': 'fr-CH-ArianeNeural', 'مرد - فابریس (سوئیس)': 'fr-CH-FabriceNeural', 'زن - شارلین (بلژیک)': 'fr-BE-CharlineNeural', 'مرد - جرارد (بلژیک)': 'fr-BE-GerardNeural'}},
82
+ "آلمانی": {"code": "de", "voices": {'زن - کاترین (آلمان)': 'de-DE-KatjaNeural', 'مرد - کنراد (آلمان)': 'de-DE-ConradNeural', 'زن - آملی (آلمان)': 'de-DE-AmelieNeural', 'زن - اینگرید (اتریش)': 'de-AT-IngridNeural', 'مرد - یوناس (اتریش)': 'de-AT-JonasNeural', 'زن - لنی (سوئیس)': 'de-CH-LeniNeural', 'مرد - یان (سوئیس)': 'de-CH-JanNeural'}},
83
+ "اسپانیایی": {"code": "es", "voices": {'زن - الویرا (اسپانیا)': 'es-ES-ElviraNeural', 'مرد - آلوارو (اسپانیا)': 'es-ES-AlvaroNeural', 'زن - دالیا (مکزیک)': 'es-MX-DaliaNeural', 'مرد - خورخه (مکزیک)': 'es-MX-JorgeNeural', 'زن - النا (آرژانتین)': 'es-AR-ElenaNeural', 'مرد - توماس (آرژانتین)': 'es-AR-TomasNeural', 'زن - سالومه (کلمبیا)': 'es-CO-SalomeNeural', 'مرد - گونزالو (کلمبیا)': 'es-CO-GonzaloNeural'}},
84
+ "روسی": {"code": "ru", "voices": {'زن - سوتلانا': 'ru-RU-SvetlanaNeural', 'مرد - دیمیتری': 'ru-RU-DmitryNeural', 'زن - داریا': 'ru-RU-DariyaNeural'}},
85
+ "ژاپنی": {"code": "ja", "voices": {'زن - نانامی': 'ja-JP-NanamiNeural', 'مرد - کیتا': 'ja-JP-KeitaNeural', 'زن - آئوی': 'ja-JP-AoiNeural'}},
86
+ "ایتالیایی": {"code": "it", "voices": {'زن - السا': 'it-IT-ElsaNeural', 'مرد - دیگو': 'it-IT-DiegoNeural', 'زن - ایزابلا': 'it-IT-IsabellaNeural'}},
87
+ "کره‌ای": {"code": "ko", "voices": {'زن - سونوهی': 'ko-KR-SunHiNeural', 'مرد - اینجون': 'ko-KR-InJoonNeural', 'زن - جیمین': 'ko-KR-JiMinNeural'}},
88
+ "هندی": {"code": "hi", "voices": {'زن - مادور': 'hi-IN-MadhurNeural', 'مرد - سوآرا': 'hi-IN-SwaraNeural'}},
89
+ "هلندی": {"code": "nl", "voices": {'زن - کوئلت': 'nl-NL-ColetteNeural', 'مرد - مارتین': 'nl-NL-MaartenNeural', 'زن - دنا (بلژیک)': 'nl-BE-DenaNeural', 'مرد - آرنو (بلژیک)': 'nl-BE-ArnaudNeural'}},
90
+ "سوئدی": {"code": "sv", "voices": {'زن - سوفی': 'sv-SE-SofieNeural', 'مرد - ماتیاس': 'sv-SE-MattiasNeural'}},
91
+ "لهستانی": {"code": "pl", "voices": {'زن - زوفیا': 'pl-PL-ZofiaNeural', 'مرد - مارک': 'pl-PL-MarekNeural'}},
92
+ "پرتغالی": {"code": "pt", "voices": {'زن - فرانسیسکا (برزیل)': 'pt-BR-FranciscaNeural', 'مرد - آنتونیو (برزیل)': 'pt-BR-AntonioNeural', 'زن - راکل (پرتغال)': 'pt-PT-RaquelNeural', 'مرد - دوارته (پرتغال)': 'pt-PT-DuarteNeural'}},
93
+
94
+ # --- سایر زبان‌ها ---
95
+ "آفریقانس": {"code": "af", "voices": {'زن - آدریا': 'af-ZA-AdriNeural', 'مرد - ویلم': 'af-ZA-WillemNeural'}},
96
+ "آلبانیایی": {"code": "sq", "voices": {'زن - آنیرا': 'sq-AL-AnilaNeural', 'مرد - ایلیر': 'sq-AL-IlirNeural'}},
97
+ "آمهاری": {"code": "am", "voices": {'زن - مکدز': 'am-ET-MekdesNeural', 'مرد - آمها': 'am-ET-AmehaNeural'}},
98
+ "ارمنی": {"code": "hy", "voices": {}},
99
+ "آذربایجانی": {"code": "az", "voices": {'زن - بانو': 'az-AZ-BanuNeural', 'مرد - بابک': 'az-AZ-BabekNeural'}},
100
+ "باسک": {"code": "eu", "voices": {'زن - آینهوا': 'eu-ES-AinhoaNeural', 'مرد - آندر': 'eu-ES-AnderNeural'}},
101
+ "بلاروسی": {"code": "be", "voices": {}},
102
+ "بنگالی": {"code": "bn", "voices": {'زن - نابانیا (بنگلادش)': 'bn-BD-NabanitaNeural', 'مرد - پردیپ (بنگلادش)': 'bn-BD-PradeepNeural', 'زن - تانیا (هند)': 'bn-IN-TanyaNeural', 'مرد - بشکر (هند)': 'bn-IN-BashkarNeural'}},
103
+ "بوسنیایی": {"code": "bs", "voices": {'زن - وسنا': 'bs-BA-VesnaNeural', 'مرد - گوران': 'bs-BA-GoranNeural'}},
104
+ "بلغاری": {"code": "bg", "voices": {'زن - کالینا': 'bg-BG-KalinaNeural', 'مرد - بوریس': 'bg-BG-BorislavNeural'}},
105
+ "کاتالان": {"code": "ca", "voices": {'زن - جوآنا': 'ca-ES-JoanaNeural', 'مرد - انریک': 'ca-ES-EnricNeural'}},
106
+ "سبوانو": {"code": "ceb", "voices": {}},
107
+ "کورسی": {"code": "co", "voices": {}},
108
+ "کروات": {"code": "hr", "voices": {'زن - گابریلا': 'hr-HR-GabrijelaNeural', 'مرد - سرکو': 'hr-HR-SreckoNeural'}},
109
+ "چک": {"code": "cs", "voices": {'زن - ولاستا': 'cs-CZ-VlastaNeural', 'مرد - آنتونین': 'cs-CZ-AntoninNeural'}},
110
+ "دانمارکی": {"code": "da", "voices": {'زن - کریستل': 'da-DK-ChristelNeural', 'مرد - جپ': 'da-DK-JeppeNeural'}},
111
+ "اسپرانتو": {"code": "eo", "voices": {}},
112
+ "استونیایی": {"code": "et", "voices": {'زن - آنو': 'et-EE-AnuNeural', 'مرد - کرت': 'et-EE-KertNeural'}},
113
+ "فنلاندی": {"code": "fi", "voices": {'زن - سلما': 'fi-FI-SelmaNeural', 'مرد - هری': 'fi-FI-HarriNeural'}},
114
+ "فریسی": {"code": "fy", "voices": {}},
115
+ "گالیسیایی": {"code": "gl", "voices": {'زن - سابیا': 'gl-ES-SabelaNeural', 'مرد - روی': 'gl-ES-RoiNeural'}},
116
+ "گرجی": {"code": "ka", "voices": {'زن - اکا': 'ka-GE-EkaNeural', 'مرد - گیورگی': 'ka-GE-GiorgiNeural'}},
117
+ "یونانی": {"code": "el", "voices": {'زن - آتینا': 'el-GR-AthinaNeural', 'مرد - نستوراس': 'el-GR-NestorasNeural'}},
118
+ "گجراتی": {"code": "gu", "voices": {'زن - دوانی': 'gu-IN-DhwaniNeural', 'مرد - نیرانجان': 'gu-IN-NiranjanNeural'}},
119
+ "هائیتی": {"code": "ht", "voices": {}},
120
+ "هاوسا": {"code": "ha", "voices": {}},
121
+ "هاوایی": {"code": "haw", "voices": {}},
122
+ "عبری": {"code": "iw", "voices": {'زن - هیلا': 'he-IL-HilaNeural', 'مرد - آوری': 'he-IL-AvriNeural'}}, # کد ترجمه به iw تغییر کرد
123
+ "همونگ": {"code": "hmn", "voices": {}},
124
+ "مجاری": {"code": "hu", "voices": {'زن - نوئمی': 'hu-HU-NoemiNeural', 'مرد - تاماس': 'hu-HU-TamasNeural'}},
125
+ "ایسلندی": {"code": "is", "voices": {'زن - گودرون': 'is-IS-GudrunNeural', 'مرد - گونار': 'is-IS-GunnarNeural'}},
126
+ "ایگبو": {"code": "ig", "voices": {}},
127
+ "اندونزیایی": {"code": "id", "voices": {'زن - گادیس': 'id-ID-GadisNeural', 'مرد - آردی': 'id-ID-ArdiNeural'}},
128
+ "ایرلندی": {"code": "ga", "voices": {'زن - اورلا': 'ga-IE-OrlaNeural', 'مرد - کولم': 'ga-IE-ColmNeural'}},
129
+ "جاوه‌ای": {"code": "jw", "voices": {'زن - سیتی': 'jv-ID-SitiNeural', 'مرد - بیما': 'jv-ID-BimasenaNeural'}}, # کد ترجمه به jw تغییر کرد
130
+ "کانادا": {"code": "kn", "voices": {'زن - ساپانا': 'kn-IN-SapnaNeural', 'مرد - گاگان': 'kn-IN-GaganNeural'}},
131
+ "قزاقی": {"code": "kk", "voices": {'زن - آیگول': 'kk-KZ-AigulNeural', 'مرد - دولت': 'kk-KZ-DauletNeural'}},
132
+ "خمر": {"code": "km", "voices": {'زن - سریموچ': 'km-KH-SreymomNeural', 'مرد - پیسث': 'km-KH-PisethNeural'}},
133
+ "کردی": {"code": "ku", "voices": {}},
134
+ "قرقیزی": {"code": "ky", "voices": {}},
135
+ "لائو": {"code": "lo", "voices": {'زن - چانتاوونگ': 'lo-LA-ChanthavongNeural', 'مرد - کئو': 'lo-LA-KeoNeural'}},
136
+ "لاتین": {"code": "la", "voices": {}},
137
+ "لتونیایی": {"code": "lv", "voices": {'زن - اورلیا': 'lv-LV-EveritaNeural', 'مرد - نیلس': 'lv-LV-NilsNeural'}},
138
+ "لیتوانیایی": {"code": "lt", "voices": {'زن - اونا': 'lt-LT-OnaNeural', 'مرد - لئوناس': 'lt-LT-LeonasNeural'}},
139
+ "لوکزامبورگی": {"code": "lb", "voices": {}},
140
+ "مقدونی": {"code": "mk", "voices": {'زن - ماریا': 'mk-MK-MarijaNeural', 'مرد - الکساندر': 'mk-MK-AleksandarNeural'}},
141
+ "مالاگاسی": {"code": "mg", "voices": {}},
142
+ "مالایی": {"code": "ms", "voices": {'زن - یاسمین': 'ms-MY-YasminNeural', 'مرد - عثمان': 'ms-MY-OsmanNeural'}},
143
+ "مالایالم": {"code": "ml", "voices": {'زن - سوبهانجا': 'ml-IN-SobhanaNeural', 'مرد - میدون': 'ml-IN-MidhunNeural'}},
144
+ "مالتی": {"code": "mt", "voices": {'زن - گریس': 'mt-MT-GraceNeural', 'مرد - جوزف': 'mt-MT-JosephNeural'}},
145
+ "مائوری": {"code": "mi", "voices": {}},
146
+ "مراتی": {"code": "mr", "voices": {'زن - آروهی': 'mr-IN-AarohiNeural', 'مرد - مانوهر': 'mr-IN-ManoharNeural'}},
147
+ "مغولی": {"code": "mn", "voices": {'زن - یسوی': 'mn-MN-YesuiNeural', 'مرد - باتا': 'mn-MN-BataaNeural'}},
148
+ "میانماری (برمه‌ای)": {"code": "my", "voices": {'زن - نیلار': 'my-MM-NilarNeural', 'مرد - تیها': 'my-MM-ThihaNeural'}},
149
+ "نپالی": {"code": "ne", "voices": {'زن - هما': 'ne-NP-HemkalaNeural', 'مرد - ساگار': 'ne-NP-SagarNeural'}},
150
+ "نروژی": {"code": "no", "voices": {'زن - پرنیله': 'nb-NO-PernilleNeural', 'مرد - فین': 'nb-NO-FinnNeural'}},
151
+ "پشتو": {"code": "ps", "voices": {'زن - لطیفه': 'ps-AF-LatifaNeural', 'مرد - گل‌نواز': 'ps-AF-GulNawazNeural'}},
152
+ "پنجابی": {"code": "pa", "voices": {}},
153
+ "رومانیایی": {"code": "ro", "voices": {'زن - آلینا': 'ro-RO-AlinaNeural', 'مرد - امیل': 'ro-RO-EmilNeural'}},
154
+ "صربی": {"code": "sr", "voices": {'زن - سوفیا': 'sr-RS-SophieNeural', 'مرد - نیکولا': 'sr-RS-NicholasNeural'}},
155
+ "سسوتو": {"code": "st", "voices": {}},
156
+ "سینهالی": {"code": "si", "voices": {'زن - تیمی': 'si-LK-ThiliniNeural', 'مرد - سامیرا': 'si-LK-SameeraNeural'}},
157
+ "اسلواکی": {"code": "sk", "voices": {'زن - ویکتوریا': 'sk-SK-ViktoriaNeural', 'مرد - لوکاس': 'sk-SK-LukasNeural'}},
158
+ "اسلوونیایی": {"code": "sl", "voices": {'زن - پترا': 'sl-SI-PetraNeural', 'مرد - روک': 'sl-SI-RokNeural'}},
159
+ "سومالیایی": {"code": "so", "voices": {'زن - اوباخ': 'so-SO-UbaxNeural', 'مرد - موزه': 'so-SO-MuuseNeural'}},
160
+ "سوندانی": {"code": "su", "voices": {'زن - توتی': 'su-ID-TutiNeural', 'مرد - جاجانگ': 'su-ID-JajangNeural'}},
161
+ "سواحیلی": {"code": "sw", "voices": {'زن - زوری (کنیا)': 'sw-KE-ZuriNeural', 'مرد - رفیق (کنیا)': 'sw-KE-RafikiNeural', 'زن - ریما (تانزانیا)': 'sw-TZ-RehemaNeural', 'مرد - دادی (تانزانیا)': 'sw-TZ-DaudiNeural'}},
162
+ "تاجیکی": {"code": "tg", "voices": {}},
163
+ "تامیلی": {"code": "ta", "voices": {'زن - پالاوی (هند)': 'ta-IN-PallaviNeural', 'مرد - والان (هند)': 'ta-IN-ValluvarNeural', 'زن - کانی (سریلانکا)': 'ta-LK-KaniNeural', 'مرد - کومار (سریلانکا)': 'ta-LK-KumarNeural'}},
164
+ "تلوگو": {"code": "te", "voices": {'زن - شروتی': 'te-IN-ShrutiNeural', 'مرد - موهان': 'te-IN-MohanNeural'}},
165
+ "تایلندی": {"code": "th", "voices": {'زن - پرم‌وادی': 'th-TH-PremwadeeNeural', 'مرد - نیوات': 'th-TH-NiwatNeural'}},
166
+ "اوکراینی": {"code": "uk", "voices": {'زن - پولینا': 'uk-UA-PolinaNeural', 'مرد - اوستاپ': 'uk-UA-OstapNeural'}},
167
+ "اردو": {"code": "ur", "voices": {'زن - گل (پاکستان)': 'ur-PK-UzmaNeural', 'مرد - اسد (پاکستان)': 'ur-PK-AsadNeural', 'زن - گلرخ (هند)': 'ur-IN-GulNeural', 'مرد - سلمان (هند)': 'ur-IN-SalmanNeural'}},
168
+ "ازبکی": {"code": "uz", "voices": {'زن - مدینه': 'uz-UZ-MadinaNeural', 'مرد - سردار': 'uz-UZ-SardorNeural'}},
169
+ "ویتنامی": {"code": "vi", "voices": {'زن - هوآی‌می': 'vi-VN-HoaiMyNeural', 'مرد - نام‌مین': 'vi-VN-NamMinhNeural'}},
170
+ "ولزی": {"code": "cy", "voices": {'زن - نیا': 'cy-GB-NiaNeural', 'مرد - آلد': 'cy-GB-AledNeural'}},
171
+ "خوسا": {"code": "xh", "voices": {}},
172
+ "یدی": {"code": "yi", "voices": {}},
173
+ "یوروبا": {"code": "yo", "voices": {}},
174
+ "زولو": {"code": "zu", "voices": {'زن - تاندکا': 'zu-ZA-ThandoNeural', 'مرد - تبوگو': 'zu-ZA-ThembaNeural'}}
175
+ }
176
+
177
+ # --- دیتابیس قدیمی برای پشتیبانی API دوستان شما ---
178
+ LEGACY_VOICES = {
179
+ 'انگلیسی (آمریکا) - جنی (زن)': 'en-US-JennyNeural', 'انگلیسی (آمریکا) - گای (مرد)': 'en-US-GuyNeural',
180
+ 'انگلیسی (آمریکا) - آنا (زن، صدای کودک)': 'en-US-AnaNeural', 'انگلیسی (آمریکا) - آریا (زن)': 'en-US-AriaNeural',
181
+ 'انگلیسی (بریتانیا) - لیبی (زن)': 'en-GB-LibbyNeural', 'انگلیسی (بریتانیا) - رایان (مرد)': 'en-GB-RyanNeural',
182
+ }
183
+
184
+ # --- راه‌اندازی سرور ---
185
+ app = FastAPI(title="Alpha Translator API")
186
+ app.mount("/audio_cache", StaticFiles(directory=AUDIO_DIR), name="audio_cache")
187
+
188
+ # --- توابع اصلی پردازش ---
189
+ # =====================================================================
190
+ # موتور ترجمه‌ی مقاوم (چند مسیر پشتیبان + کش + کنترل نرخ)
191
+ # ---------------------------------------------------------------------
192
+ # علت خطای «too many requests»: همه‌ی کاربران از یک IP مشترک سرور به
193
+ # یک آدرس واحد درخواست می‌فرستادند و آن آدرس IP را موقتاً محدود می‌کرد.
194
+ # راه‌حل:
195
+ # ۱) چند مسیر ترجمه‌ی مستقل؛ اگر یکی محدود شد، بی‌صدا مسیر بعدی.
196
+ # ۲) مسیری که محدود شد برای مدتی کنار گذاشته می‌شود (cooldown).
197
+ # ۳) کش ترجمه‌ها؛ متن تکراری اصلاً درخواست جدید نمی‌سازد.
198
+ # ۴) صف و محدودسازی هم‌زمانی تا به سقف نرخ نخوریم.
199
+ # ۵) متن‌های طولانی تکه‌تکه ترجمه می‌شوند.
200
+ # ۶) هیچ جزئیاتی از سرویس بالادستی به کاربر نشان داده نمی‌شود.
201
+ # متغیرهای محیطی اختیاری (Settings → Secrets):
202
+ # AZURE_TRANSLATOR_KEY / AZURE_TRANSLATOR_REGION → مسیر رسمی و پایدار
203
+ # TRANSLATE_PROXY → پروکسی خروجی (مثل��ً http://user:pass@host:port)
204
+ # =====================================================================
205
+ import random
206
+ import urllib.parse
207
+ from collections import OrderedDict
208
+ import httpx
209
+
210
+ TRANSLATE_CONCURRENCY = int(os.environ.get("TRANSLATE_CONCURRENCY", 4))
211
+ TRANSLATE_SEMAPHORE = asyncio.Semaphore(TRANSLATE_CONCURRENCY)
212
+ TRANSLATE_CHUNK_CHARS = 1500
213
+ TRANSLATE_CACHE_MAX = int(os.environ.get("TRANSLATE_CACHE_MAX", 3000))
214
+ TRANSLATE_CACHE_TTL = 24 * 3600
215
+ TRANSLATE_PROXY = os.environ.get("TRANSLATE_PROXY", "").strip() or None
216
+ AZURE_KEY = os.environ.get("AZURE_TRANSLATOR_KEY", "").strip()
217
+ AZURE_REGION = os.environ.get("AZURE_TRANSLATOR_REGION", "").strip()
218
+
219
+ TRANSLATE_ERROR_MSG = "خطا در ترجمه: سرویس ترجمه لحظاتی شلوغ است، لطفاً چند ثانیه بعد دوباره تلاش کنید."
220
+
221
+ _USER_AGENTS = [
222
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36",
223
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
224
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36",
225
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36",
226
+ ]
227
+
228
+ _http_client: Optional[httpx.AsyncClient] = None
229
+
230
+ def _client() -> httpx.AsyncClient:
231
+ global _http_client
232
+ if _http_client is None or _http_client.is_closed:
233
+ kwargs = dict(timeout=httpx.Timeout(12.0, connect=6.0), follow_redirects=True,
234
+ limits=httpx.Limits(max_connections=20, max_keepalive_connections=10))
235
+ if TRANSLATE_PROXY:
236
+ kwargs["proxy"] = TRANSLATE_PROXY
237
+ _http_client = httpx.AsyncClient(**kwargs)
238
+ return _http_client
239
+
240
+ def _headers() -> dict:
241
+ return {"User-Agent": random.choice(_USER_AGENTS), "Accept": "*/*"}
242
+
243
+ class RateLimited(Exception):
244
+ pass
245
+
246
+ # --- تبدیل کد زبان برای سرویس‌هایی که کد متفاوت دارند ---
247
+ _STD_CODE = {"iw": "he", "jw": "jv"}
248
+ _AZURE_CODE = {"iw": "he", "jw": "jv", "zh-CN": "zh-Hans", "zh-TW": "zh-Hant", "no": "nb", "sr": "sr-Cyrl", "mn": "mn-Cyrl", "ku": "ku"}
249
+
250
+ def _raise_for(resp: httpx.Response):
251
+ if resp.status_code in (429, 503) or resp.status_code == 403:
252
+ raise RateLimited(f"status {resp.status_code}")
253
+ resp.raise_for_status()
254
+
255
+ # --- مسیرهای ترجمه (هر کدام یک تابع async: (text, sl, tl) -> str) ---
256
+ async def _p_gtx(text, sl, tl):
257
+ r = await _client().post(
258
+ "https://translate.googleapis.com/translate_a/single",
259
+ params={"client": "gtx", "sl": sl, "tl": tl, "dt": "t", "ie": "UTF-8", "oe": "UTF-8"},
260
+ data={"q": text}, headers=_headers())
261
+ _raise_for(r)
262
+ data = r.json()
263
+ out = "".join(seg[0] for seg in (data[0] or []) if seg and seg[0])
264
+ if not out.strip():
265
+ raise ValueError("empty")
266
+ return out
267
+
268
+ async def _p_dict_ext(text, sl, tl):
269
+ r = await _client().post(
270
+ "https://clients5.google.com/translate_a/t",
271
+ params={"client": "dict-chrome-ex", "sl": sl, "tl": tl},
272
+ data={"q": text}, headers=_headers())
273
+ _raise_for(r)
274
+ data = r.json()
275
+ item = data[0] if isinstance(data, list) and data else data
276
+ if isinstance(item, list):
277
+ item = item[0]
278
+ if not isinstance(item, str) or not item.strip():
279
+ raise ValueError("empty")
280
+ return item
281
+
282
+ async def _p_gmobile(text, sl, tl):
283
+ # مسیر قدیمی (همان کتابخانه‌ی قبلی) به عنوان پشتیبان
284
+ def _do():
285
+ return GoogleTranslator(source=sl, target=tl).translate(text)
286
+ try:
287
+ out = await asyncio.wait_for(asyncio.to_thread(_do), timeout=15)
288
+ except Exception as e:
289
+ if "too many" in str(e).lower() or "429" in str(e):
290
+ raise RateLimited(str(e))
291
+ raise
292
+ if not out or not str(out).strip():
293
+ raise ValueError("empty")
294
+ return out
295
+
296
+ _LINGVA_HOSTS = ["https://lingva.ml", "https://lingva.lunar.icu", "https://translate.plausibility.cloud"]
297
+
298
+ async def _p_lingva(text, sl, tl):
299
+ last = None
300
+ for host in random.sample(_LINGVA_HOSTS, len(_LINGVA_HOSTS)):
301
+ try:
302
+ q = urllib.parse.quote(text, safe="")
303
+ r = await _client().get(f"{host}/api/v1/{sl}/{tl}/{q}", headers=_headers())
304
+ _raise_for(r)
305
+ out = r.json().get("translation", "")
306
+ if out.strip():
307
+ return out
308
+ last = ValueError("empty")
309
+ except Exception as e:
310
+ last = e
311
+ raise last or ValueError("lingva failed")
312
+
313
+ async def _p_mymemory(text, sl, tl):
314
+ if len(text.encode("utf-8")) > 480: # سقف این سرویس
315
+ raise ValueError("too long")
316
+ src = "Autodetect" if sl == "auto" else _STD_CODE.get(sl, sl)
317
+ r = await _client().get("https://api.mymemory.translated.net/get",
318
+ params={"q": text, "langpair": f"{src}|{_STD_CODE.get(tl, tl)}"},
319
+ headers=_headers())
320
+ _raise_for(r)
321
+ data = r.json()
322
+ if int(data.get("responseStatus", 200)) == 429:
323
+ raise RateLimited("quota")
324
+ out = (data.get("responseData") or {}).get("translatedText", "")
325
+ if not out.strip() or "MYMEMORY WARNING" in out.upper() or "QUERY LENGTH" in out.upper():
326
+ raise ValueError("bad response")
327
+ return out
328
+
329
+ async def _p_azure(text, sl, tl):
330
+ params = {"api-version": "3.0", "to": _AZURE_CODE.get(tl, tl)}
331
+ if sl != "auto":
332
+ params["from"] = _AZURE_CODE.get(sl, sl)
333
+ headers = {"Ocp-Apim-Subscription-Key": AZURE_KEY, "Content-Type": "application/json"}
334
+ if AZURE_REGION:
335
+ headers["Ocp-Apim-Subscription-Region"] = AZURE_REGION
336
+ r = await _client().post("https://api.cognitive.microsofttranslator.com/translate",
337
+ params=params, headers=headers, json=[{"Text": text}])
338
+ _raise_for(r)
339
+ out = r.json()[0]["translations"][0]["text"]
340
+ if not out.strip():
341
+ raise ValueError("empty")
342
+ return out
343
+
344
+ # ترتیب مسیرها؛ اگر کلید رسمی تنظیم شده باشد، اول از همه همان استفاده می‌شود
345
+ _PROVIDERS = []
346
+ if AZURE_KEY:
347
+ _PROVIDERS.append(("p0", _p_azure))
348
+ _PROVIDERS += [("p1", _p_gtx), ("p2", _p_dict_ext), ("p3", _p_gmobile), ("p4", _p_lingva), ("p5", _p_mymemory)]
349
+
350
+ # وضعیت محدودیت هر مسیر: نام → (زمان پایان cooldown، تعداد دفعات پیاپی محدود شدن)
351
+ _cooldown: dict = {}
352
+
353
+ def _available(name: str) -> bool:
354
+ until, _ = _cooldown.get(name, (0, 0))
355
+ return time.time() >= until
356
+
357
+ def _mark_limited(name: str):
358
+ _, strikes = _cooldown.get(name, (0, 0))
359
+ strikes += 1
360
+ wait = min(30 * (2 ** (strikes - 1)), 900) # ۳۰ث، ۶۰ث، ... حداکثر ۱۵ دقیقه
361
+ _cooldown[name] = (time.time() + wait, strikes)
362
+ logging.warning(f"Translate provider {name} rate-limited; cooling down {wait}s")
363
+
364
+ def _mark_ok(name: str):
365
+ if name in _cooldown:
366
+ _cooldown.pop(name, None)
367
+
368
+ # --- کش ---
369
+ _tcache: "OrderedDict[tuple, tuple]" = OrderedDict()
370
+
371
+ def _cache_get(key):
372
+ item = _tcache.get(key)
373
+ if not item:
374
+ return None
375
+ value, ts = item
376
+ if time.time() - ts > TRANSLATE_CACHE_TTL:
377
+ _tcache.pop(key, None)
378
+ return None
379
+ _tcache.move_to_end(key)
380
+ return value
381
+
382
+ def _cache_set(key, value):
383
+ _tcache[key] = (value, time.time())
384
+ _tcache.move_to_end(key)
385
+ while len(_tcache) > TRANSLATE_CACHE_MAX:
386
+ _tcache.popitem(last=False)
387
+
388
+ def _split_text(text: str, limit: int = TRANSLATE_CHUNK_CHARS) -> List[str]:
389
+ if len(text) <= limit:
390
+ return [text]
391
+ chunks, buf = [], ""
392
+ import re
393
+ parts = re.split(r"(?<=[\.\!\?؟。\n])", text)
394
+ for part in parts:
395
+ while len(part) > limit: # جمله‌ی خیلی بلند
396
+ cut = part.rfind(" ", 0, limit)
397
+ cut = cut if cut > limit // 2 else limit
398
+ if buf:
399
+ chunks.append(buf); buf = ""
400
+ chunks.append(part[:cut]); part = part[cut:]
401
+ if len(buf) + len(part) > limit:
402
+ chunks.append(buf); buf = part
403
+ else:
404
+ buf += part
405
+ if buf:
406
+ chunks.append(buf)
407
+ return [c for c in chunks if c]
408
+
409
+ async def _translate_chunk(text: str, sl: str, tl: str) -> Optional[str]:
410
+ if not text.strip():
411
+ return text
412
+ key = (sl, tl, text)
413
+ cached = _cache_get(key)
414
+ if cached is not None:
415
+ return cached
416
+
417
+ # دو دور تلاش: دور اول فقط مسیرهای آزاد؛ دور دوم (بعد از مکث کوتاه) همه‌ی مسیرها
418
+ for round_no in range(2):
419
+ tried_any = False
420
+ for name, fn in _PROVIDERS:
421
+ if round_no == 0 and not _available(name):
422
+ continue
423
+ tried_any = True
424
+ try:
425
+ async with TRANSLATE_SEMAPHORE:
426
+ out = await asyncio.wait_for(fn(text, sl, tl), timeout=15)
427
+ _mark_ok(name)
428
+ _cache_set(key, out)
429
+ return out
430
+ except RateLimited:
431
+ _mark_limited(name)
432
+ except Exception as e:
433
+ logging.warning(f"Translate provider {name} failed: {type(e).__name__}: {e}")
434
+ if round_no == 0:
435
+ await asyncio.sleep(1.0 if tried_any else 0.3)
436
+ return None
437
+
438
+ async def translate_text(text: str, source: str, target: str) -> str:
439
+ try:
440
+ text = text.strip()
441
+ if source != "auto" and source == target:
442
+ return text
443
+ pieces = []
444
+ for chunk in _split_text(text):
445
+ out = await _translate_chunk(chunk, source, target)
446
+ if out is None:
447
+ logging.error("Translation Error: all providers failed")
448
+ return TRANSLATE_ERROR_MSG
449
+ pieces.append(out)
450
+ sep = "" if all(p.endswith(("\n", " ")) for p in pieces[:-1]) else " "
451
+ return sep.join(pieces).strip()
452
+ except Exception as e:
453
+ logging.error(f"Translation Error: {type(e).__name__}: {e}")
454
+ return TRANSLATE_ERROR_MSG
455
+
456
+ async def generate_audio(text: str, voice_id: str, rate: int, volume: int, pitch: int) -> str:
457
+ filename = f"{uuid.uuid4().hex}.mp3"
458
+ filepath = os.path.join(AUDIO_DIR, filename)
459
+ rate_str, volume_str, pitch_str = f"{int(rate):+g}%", f"{int(volume):+g}%", f"{int(pitch):+g}Hz"
460
+
461
+ async with TTS_SEMAPHORE:
462
+ last_error = None
463
+ for attempt in range(1, TTS_MAX_RETRIES + 1):
464
+ try:
465
+ communicate = edge_tts.Communicate(
466
+ text, voice_id, rate=rate_str, volume=volume_str, pitch=pitch_str
467
+ )
468
+ await asyncio.wait_for(communicate.save(filepath), timeout=TTS_TIMEOUT)
469
+ if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
470
+ return f"/audio_cache/{filename}"
471
+ last_error = "generated empty audio file"
472
+ except asyncio.TimeoutError:
473
+ last_error = "timeout"
474
+ except Exception as e:
475
+ last_error = str(e)
476
+
477
+ logging.warning(
478
+ f"TTS attempt {attempt}/{TTS_MAX_RETRIES} failed for voice={voice_id}: {last_error}"
479
+ )
480
+ try:
481
+ if os.path.exists(filepath):
482
+ os.remove(filepath)
483
+ except Exception:
484
+ pass
485
+ if attempt < TTS_MAX_RETRIES:
486
+ await asyncio.sleep(1.2 * attempt)
487
+
488
+ logging.error(f"TTS Error: all retries failed - {last_error}")
489
+ return ""
490
+
491
+ # --- مدل‌های داده (برای دریافت اطلاعات از فرانت‌اند) ---
492
+ class TranslateRequest(BaseModel):
493
+ text: str
494
+ source_lang: str
495
+ target_lang: str
496
+ voice_key: str
497
+ rate: int = 0
498
+ volume: int = 0
499
+ pitch: int = 0
500
+
501
+ class GradioLegacyRequest(BaseModel):
502
+ data: List[Any] # [text, voice, rate, volume, pitch]
503
+
504
+ # --- Endpoints API وب‌سایت ---
505
+ @app.get("/")
506
+ async def serve_index():
507
+ return FileResponse("index.html")
508
+
509
+ @app.get("/healthz")
510
+ async def healthz():
511
+ """برای مانیتورینگ سلامت اسپیس؛ همیشه سبک و سریع پاسخ می‌دهد."""
512
+ return {"status": "ok", "time": time.time()}
513
+
514
+ @app.get("/api/config")
515
+ async def get_config():
516
+ return {"languages": LANGUAGES_MAP}
517
+
518
+ @app.post("/api/translate_and_speak")
519
+ async def api_translate_speak(req: TranslateRequest):
520
+ try:
521
+ if not req.text or not req.text.strip():
522
+ return {"success": False, "error": "متن ورودی خالی است."}
523
+
524
+ src_code = "auto" if req.source_lang == "شناسایی خودکار" else LANGUAGES_MAP.get(req.source_lang, {}).get("code", "auto")
525
+ target_lang_data = LANGUAGES_MAP.get(req.target_lang, {})
526
+ tgt_code = target_lang_data.get("code", "en")
527
+
528
+ # پیدا کردن آیدی دقیق صدا
529
+ voice_id = target_lang_data.get("voices", {}).get(req.voice_key)
530
+ if not voice_id:
531
+ voices = list(target_lang_data.get("voices", {}).values())
532
+ voice_id = voices[0] if voices else "en-US-JennyNeural"
533
+
534
+ # 1. ترجمه
535
+ translated_text = await translate_text(req.text, src_code, tgt_code)
536
+ if translated_text == TRANSLATE_ERROR_MSG:
537
+ return {"success": False, "error": translated_text}
538
+
539
+ # 2. تولید صدا (اگر زبانی صدا نداشته باشد، این بخش نادیده گرفته می‌شود)
540
+ audio_url = ""
541
+ if target_lang_data.get("voices"):
542
+ audio_url = await generate_audio(translated_text, voice_id, req.rate, req.volume, req.pitch)
543
+ if not audio_url:
544
+ # ترجمه موفق بود ولی صدا تولید نشد - همچنان متن را برگردان
545
+ return {
546
+ "success": True,
547
+ "translated_text": translated_text,
548
+ "audio_url": "",
549
+ "warning": "متن ترجمه شد اما در ساخت فایل صوتی مشکلی پیش آمد. لطفاً دوباره تلاش کنید."
550
+ }
551
+
552
+ return {
553
+ "success": True,
554
+ "translated_text": translated_text,
555
+ "audio_url": audio_url
556
+ }
557
+ except Exception as e:
558
+ # هرگز نباید یک درخواست خراب کل سرور را از کار بیندازد
559
+ logging.error(f"Unhandled error in translate_and_speak: {e}")
560
+ return JSONResponse(status_code=500, content={"success": False, "error": "خطای داخلی سرور. لطفاً دوباره تلاش کنید."})
561
+
562
+ # --- شبیه‌ساز API گاردین برای ربات‌ها/اسکریپت‌های دوستان شما ---
563
+ @app.post("/run/translate_and_speak")
564
+ @app.post("/api/translate_and_speak_legacy")
565
+ async def legacy_api(req: GradioLegacyRequest, request: Request):
566
+ """
567
+ این بخش دقیقا ساختار Gradio ��ا شبیه سازی می‌کند تا هیچ اسکریپتی از کار نیفتد.
568
+ """
569
+ try:
570
+ text, voice_key, rate, volume, pitch = req.data[0], req.data[1], req.data[2], req.data[3], req.data[4]
571
+
572
+ # ترجمه به انگلیسی (رفتار قبلی سیستم)
573
+ translated_text = await translate_text(text, "fa", "en")
574
+
575
+ # پیدا کردن صدای انگلیسی قدیمی
576
+ voice_id = LEGACY_VOICES.get(voice_key, "en-US-JennyNeural")
577
+
578
+ audio_url = await generate_audio(translated_text, voice_id, rate, volume, pitch)
579
+
580
+ # ساختن آدرس کامل فایل صوتی برای کلاینت‌ها
581
+ base_url = str(request.base_url).rstrip("/")
582
+ full_audio_url = f"{base_url}{audio_url}" if audio_url else None
583
+
584
+ # خروجی دقیقاً با ساختار پاسخ Gradio
585
+ return JSONResponse(content={
586
+ "data": [
587
+ translated_text,
588
+ {"name": full_audio_url, "data": full_audio_url, "is_file": True} if full_audio_url else None
589
+ ]
590
+ })
591
+ except Exception as e:
592
+ return JSONResponse(content={"error": str(e)}, status_code=500)
index.html ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fa" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>آلفا مترجم | Alpha Translator</title>
7
+ <link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.0.0/Vazirmatn-font-face.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+
10
+ <style>
11
+ :root {
12
+ --sky: #38bdf8;
13
+ --sky-deep: #0ea5e9;
14
+ --sky-pale: #e0f2fe;
15
+ --ocean: #0369a1;
16
+ --mint: #34d399;
17
+ --lavender: #818cf8;
18
+ --peach: #fb923c;
19
+ --bg: #f0f9ff;
20
+ --card: #ffffff;
21
+ --text: #0f172a;
22
+ --muted: #64748b;
23
+ --border: #e2e8f0;
24
+ --shadow: 0 20px 60px -10px rgba(14,165,233,0.15);
25
+ }
26
+
27
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
28
+
29
+ body {
30
+ font-family: 'Vazirmatn', sans-serif;
31
+ background: var(--bg);
32
+ min-height: 100vh;
33
+ overflow-x: hidden;
34
+ color: var(--text);
35
+ }
36
+
37
+ /* ===== BACKGROUND ===== */
38
+ .bg-scene {
39
+ position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
40
+ }
41
+ .bg-scene::before {
42
+ content: ''; position: absolute; top: -20%; right: -10%;
43
+ width: 70vw; height: 70vw; border-radius: 50%;
44
+ background: radial-gradient(circle, rgba(56,189,248,0.18) 0%, transparent 70%);
45
+ animation: floatBlob 12s ease-in-out infinite;
46
+ }
47
+ .bg-scene::after {
48
+ content: ''; position: absolute; bottom: -10%; left: -5%;
49
+ width: 55vw; height: 55vw; border-radius: 50%;
50
+ background: radial-gradient(circle, rgba(129,140,248,0.14) 0%, transparent 70%);
51
+ animation: floatBlob 15s ease-in-out infinite reverse;
52
+ }
53
+ @keyframes floatBlob {
54
+ 0%,100% { transform: translate(0,0) scale(1); }
55
+ 33% { transform: translate(3%,4%) scale(1.04); }
56
+ 66% { transform: translate(-2%,2%) scale(0.97); }
57
+ }
58
+
59
+ /* ===== LAYOUT ===== */
60
+ .app-wrapper { position: relative; z-index: 1; max-width: 720px; margin: 0 auto; padding: 0 16px 40px; }
61
+
62
+ /* ===== HEADER ===== */
63
+ header {
64
+ padding: 32px 0 20px; text-align: center;
65
+ animation: headerDrop 0.8s cubic-bezier(0.34,1.56,0.64,1) both;
66
+ }
67
+ @keyframes headerDrop {
68
+ 0% { opacity:0; transform: translateY(-40px) scale(0.9); }
69
+ 100% { opacity:1; transform: translateY(0) scale(1); }
70
+ }
71
+ .logo-wrap { display: inline-flex; align-items: center; justify-content: center; gap: 14px; }
72
+
73
+ /* ===== LOGO ICON ===== */
74
+ .logo-icon-wrap { position: relative; width: 64px; height: 64px; }
75
+ .logo-icon-wrap svg { width: 64px; height: 64px; filter: drop-shadow(0 4px 18px rgba(14,165,233,0.4)); }
76
+ .logo-icon-pulse {
77
+ position: absolute; inset: -6px; border-radius: 50%;
78
+ border: 2px solid rgba(56,189,248,0.5);
79
+ animation: pulse-ring 2s ease-out infinite;
80
+ }
81
+ .logo-icon-pulse2 {
82
+ position: absolute; inset: -14px; border-radius: 50%;
83
+ border: 2px solid rgba(56,189,248,0.25);
84
+ animation: pulse-ring 2s ease-out 0.6s infinite;
85
+ }
86
+ @keyframes pulse-ring {
87
+ 0% { transform: scale(0.9); opacity: 1; }
88
+ 100% { transform: scale(1.5); opacity: 0; }
89
+ }
90
+
91
+ .logo-text { display: flex; flex-direction: column; align-items: flex-start; line-height: 1; gap: 4px; }
92
+ .logo-title {
93
+ font-size: 2rem; font-weight: 900;
94
+ background: linear-gradient(135deg, var(--sky-deep) 0%, var(--lavender) 100%);
95
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
96
+ }
97
+ .logo-sub { font-size: 0.72rem; color: var(--muted); font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }
98
+
99
+ /* ===== CARD ===== */
100
+ .card {
101
+ background: rgba(255,255,255,0.82);
102
+ backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
103
+ border: 1px solid rgba(255,255,255,0.9);
104
+ border-radius: 28px; padding: 28px 24px;
105
+ box-shadow: var(--shadow), 0 1px 0 rgba(255,255,255,0.9) inset;
106
+ animation: cardRise 0.7s cubic-bezier(0.34,1.2,0.64,1) 0.2s both;
107
+ }
108
+ @keyframes cardRise {
109
+ 0% { opacity:0; transform: translateY(30px) scale(0.97); }
110
+ 100% { opacity:1; transform: translateY(0) scale(1); }
111
+ }
112
+
113
+ /* ===== LANGUAGE ROW ===== */
114
+ .lang-row { display: flex; align-items: flex-end; gap: 10px; margin-bottom: 20px; }
115
+ .lang-group { flex: 1; }
116
+ .lang-label {
117
+ display: flex; align-items: center; gap: 6px;
118
+ font-size: 0.7rem; font-weight: 700; color: var(--muted);
119
+ text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;
120
+ }
121
+ .lang-label .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--sky-deep); }
122
+
123
+ .select-wrap { position: relative; }
124
+ .select-wrap select {
125
+ width: 100%; appearance: none; -webkit-appearance: none;
126
+ background: rgba(248,250,252,0.9);
127
+ border: 1.5px solid var(--border); border-radius: 14px;
128
+ padding: 13px 14px 13px 40px;
129
+ font-family: 'Vazirmatn', sans-serif; font-size: 0.9rem; font-weight: 600;
130
+ color: var(--text); cursor: pointer; transition: all 0.25s ease; outline: none;
131
+ }
132
+ .select-wrap select:focus {
133
+ border-color: var(--sky); background: #fff;
134
+ box-shadow: 0 0 0 4px rgba(56,189,248,0.12);
135
+ }
136
+ .select-arrow {
137
+ position: absolute; left: 14px; top: 50%;
138
+ transform: translateY(-50%); color: var(--muted);
139
+ pointer-events: none; font-size: 0.7rem;
140
+ }
141
+
142
+ .swap-btn {
143
+ flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%;
144
+ background: linear-gradient(135deg, var(--sky-pale), #ede9fe);
145
+ border: 1.5px solid rgba(56,189,248,0.3);
146
+ display: flex; align-items: center; justify-content: center;
147
+ cursor: pointer; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1);
148
+ color: var(--sky-deep); font-size: 0.85rem; margin-bottom: 2px;
149
+ }
150
+ .swap-btn:hover {
151
+ transform: rotate(180deg) scale(1.12);
152
+ background: linear-gradient(135deg, #bae6fd, #c7d2fe);
153
+ box-shadow: 0 4px 16px rgba(56,189,248,0.3);
154
+ }
155
+
156
+ /* ===== TEXTAREA ===== */
157
+ .textarea-wrap { position: relative; margin-bottom: 28px; }
158
+ .textarea-wrap textarea {
159
+ width: 100%; min-height: 140px; resize: none;
160
+ border: 1.5px solid var(--border); border-radius: 18px;
161
+ padding: 18px 18px 50px;
162
+ font-family: 'Vazirmatn', sans-serif; font-size: 1.05rem; line-height: 1.8;
163
+ color: var(--text); background: rgba(248,250,252,0.8);
164
+ outline: none; transition: all 0.3s ease;
165
+ }
166
+ .textarea-wrap textarea:focus {
167
+ border-color: var(--sky); background: #fff;
168
+ box-shadow: 0 0 0 4px rgba(56,189,248,0.1);
169
+ }
170
+ .textarea-wrap textarea::placeholder { color: #94a3b8; }
171
+ .textarea-wrap textarea.shake {
172
+ animation: shakeField 0.5s cubic-bezier(0.36,0.07,0.19,0.97) both;
173
+ border-color: #fb923c !important;
174
+ box-shadow: 0 0 0 4px rgba(251,146,60,0.15) !important;
175
+ }
176
+ @keyframes shakeField {
177
+ 10%,90% { transform: translateX(-4px); }
178
+ 20%,80% { transform: translateX(6px); }
179
+ 30%,50%,70% { transform: translateX(-6px); }
180
+ 40%,60% { transform: translateX(6px); }
181
+ }
182
+ .textarea-hint {
183
+ position: absolute; bottom: -26px; right: 0;
184
+ font-size: 0.78rem; color: #fb923c; font-weight: 700;
185
+ opacity: 0; transform: translateY(-4px); transition: all 0.3s ease;
186
+ display: flex; align-items: center; gap: 5px;
187
+ }
188
+ .textarea-hint.show { opacity: 1; transform: translateY(0); }
189
+ .clear-btn {
190
+ position: absolute; bottom: 14px; left: 14px;
191
+ width: 32px; height: 32px; border-radius: 50%;
192
+ background: white; border: 1px solid var(--border);
193
+ display: flex; align-items: center; justify-content: center;
194
+ cursor: pointer; color: var(--muted); font-size: 0.8rem;
195
+ transition: all 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.06);
196
+ }
197
+ .clear-btn:hover { color: #ef4444; transform: scale(1.1); }
198
+
199
+ /* ===== TRANSLATE BUTTON ===== */
200
+ .translate-btn-wrap { margin-top: 4px; }
201
+ .translate-btn {
202
+ width: 100%; padding: 17px 24px; border-radius: 18px; border: none;
203
+ background: linear-gradient(135deg, var(--sky-deep) 0%, #6366f1 100%);
204
+ color: white; font-family: 'Vazirmatn', sans-serif;
205
+ font-size: 1.1rem; font-weight: 800; letter-spacing: 0.02em;
206
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
207
+ gap: 10px; position: relative; overflow: hidden;
208
+ transition: all 0.3s cubic-bezier(0.34,1.2,0.64,1);
209
+ box-shadow: 0 8px 32px rgba(3,105,161,0.35), 0 1px 0 rgba(255,255,255,0.15) inset;
210
+ }
211
+ .translate-btn::before {
212
+ content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%;
213
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.18), transparent);
214
+ transition: left 0.5s ease;
215
+ }
216
+ .translate-btn:hover::before { left: 100%; }
217
+ .translate-btn:hover { transform: translateY(-2px); box-shadow: 0 14px 40px rgba(3,105,161,0.4); }
218
+ .translate-btn:active { transform: scale(0.98); }
219
+ .translate-btn:disabled { opacity: 0.85; cursor: not-allowed; transform: none; }
220
+ .btn-icon-wrap {
221
+ width: 34px; height: 34px; background: rgba(255,255,255,0.2);
222
+ border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem;
223
+ }
224
+
225
+ /* ===== SPINNER ===== */
226
+ .spinner {
227
+ width: 22px; height: 22px;
228
+ border: 2.5px solid rgba(255,255,255,0.3); border-top-color: white;
229
+ border-radius: 50%; animation: spin 0.7s linear infinite;
230
+ }
231
+ @keyframes spin { to { transform: rotate(360deg); } }
232
+
233
+ /* ===== SETTINGS ===== */
234
+ .settings-panel {
235
+ margin-top: 18px; border-radius: 18px;
236
+ background: rgba(248,250,252,0.7); border: 1.5px solid var(--border); overflow: hidden;
237
+ }
238
+ .settings-toggle {
239
+ display: flex; justify-content: space-between; align-items: center;
240
+ padding: 14px 18px; cursor: pointer; user-select: none; list-style: none;
241
+ }
242
+ .settings-toggle::-webkit-details-marker { display: none; }
243
+ .settings-label { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); }
244
+ .settings-label .badge {
245
+ background: linear-gradient(135deg, var(--sky-pale), #ede9fe);
246
+ color: var(--ocean); font-size: 0.65rem; padding: 2px 8px;
247
+ border-radius: 20px; font-weight: 800;
248
+ }
249
+ .settings-chevron { color: var(--muted); font-size: 0.75rem; transition: transform 0.3s ease; }
250
+ details[open] .settings-chevron { transform: rotate(180deg); }
251
+ .settings-body {
252
+ padding: 0 18px 18px; border-top: 1px solid var(--border);
253
+ display: flex; flex-direction: column; gap: 16px;
254
+ animation: fadeDown 0.3s ease both;
255
+ }
256
+ @keyframes fadeDown {
257
+ from { opacity: 0; transform: translateY(-8px); }
258
+ to { opacity: 1; transform: translateY(0); }
259
+ }
260
+ .voice-select-wrap { margin-top: 12px; }
261
+ .settings-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
262
+ @media (max-width: 500px) { .settings-row { grid-template-columns: 1fr; } }
263
+ .slider-group label {
264
+ display: flex; justify-content: space-between; align-items: center;
265
+ font-size: 0.73rem; font-weight: 700; color: var(--muted); margin-bottom: 6px;
266
+ }
267
+ .slider-group label span {
268
+ background: linear-gradient(135deg, var(--sky-deep), var(--lavender));
269
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-weight: 800;
270
+ }
271
+ input[type="range"] {
272
+ -webkit-appearance: none; width: 100%; height: 4px;
273
+ border-radius: 4px; background: linear-gradient(to left, var(--sky-deep) 50%, #e2e8f0 50%);
274
+ outline: none; cursor: pointer;
275
+ }
276
+ input[type="range"]::-webkit-slider-thumb {
277
+ -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
278
+ background: white; border: 2.5px solid var(--sky-deep);
279
+ box-shadow: 0 2px 8px rgba(3,105,161,0.25); cursor: pointer; transition: transform 0.15s;
280
+ }
281
+ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); }
282
+
283
+ #voiceSelect {
284
+ width: 100%; appearance: none; -webkit-appearance: none;
285
+ background: white; border: 1.5px solid var(--border); border-radius: 12px;
286
+ padding: 11px 14px 11px 38px;
287
+ font-family: 'Vazirmatn', sans-serif; font-size: 0.85rem; font-weight: 600;
288
+ color: var(--text); outline: none; transition: all 0.2s; cursor: pointer;
289
+ }
290
+ #voiceSelect:focus { border-color: var(--sky); box-shadow: 0 0 0 4px rgba(56,189,248,0.1); }
291
+
292
+ /* ===== OUTPUT ===== */
293
+ .output-section {
294
+ margin-top: 20px; display: none; flex-direction: column; gap: 14px;
295
+ opacity: 0; transform: translateY(16px);
296
+ transition: opacity 0.45s ease, transform 0.45s cubic-bezier(0.34,1.2,0.64,1);
297
+ }
298
+ .output-section.visible { display: flex; opacity: 1; transform: translateY(0); }
299
+
300
+ .output-box {
301
+ position: relative;
302
+ background: linear-gradient(135deg, rgba(224,242,254,0.6), rgba(237,233,254,0.4));
303
+ border: 1.5px solid rgba(56,189,248,0.25); border-radius: 18px;
304
+ padding: 22px 18px 52px; min-height: 90px;
305
+ }
306
+ .output-chip {
307
+ position: absolute; top: -12px; right: 18px;
308
+ background: linear-gradient(135deg, var(--sky-deep), var(--lavender));
309
+ color: white; font-size: 0.7rem; font-weight: 800;
310
+ padding: 4px 14px; border-radius: 20px; letter-spacing: 0.05em;
311
+ box-shadow: 0 4px 12px rgba(3,105,161,0.3);
312
+ }
313
+ .output-text {
314
+ font-size: 1.05rem; line-height: 1.9; color: var(--text);
315
+ white-space: pre-wrap; margin-top: 8px;
316
+ }
317
+ .copy-btn {
318
+ position: absolute; bottom: 14px; left: 14px;
319
+ width: 34px; height: 34px; border-radius: 50%;
320
+ background: white; border: 1.5px solid rgba(56,189,248,0.3);
321
+ display: flex; align-items: center; justify-content: center;
322
+ cursor: pointer; color: var(--sky-deep); font-size: 0.8rem;
323
+ transition: all 0.2s; box-shadow: 0 2px 10px rgba(3,105,161,0.1);
324
+ }
325
+ .copy-btn:hover { background: var(--sky-pale); transform: scale(1.1); }
326
+
327
+ /* ===== AUDIO PLAYER ===== */
328
+ .audio-player-wrap {
329
+ background: white; border: 1.5px solid var(--border); border-radius: 16px;
330
+ padding: 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.04);
331
+ display: flex; flex-direction: column; gap: 14px;
332
+ }
333
+ .audio-player-row {
334
+ display: flex; align-items: center; gap: 8px; width: 100%;
335
+ }
336
+ audio { flex: 1; height: 38px; border-radius: 8px; outline: none; min-width: 0; }
337
+
338
+ /* ===== DOWNLOAD BUTTON ===== */
339
+ .download-btn {
340
+ align-self: center;
341
+ display: flex; align-items: center; justify-content: center; gap: 8px;
342
+ padding: 12px 24px; border-radius: 14px; border: none;
343
+ background: linear-gradient(135deg, #0ea5e9, #6366f1);
344
+ color: white; font-family: 'Vazirmatn', sans-serif;
345
+ font-size: 0.85rem; font-weight: 800; cursor: pointer;
346
+ transition: all 0.25s cubic-bezier(0.34,1.3,0.64,1);
347
+ box-shadow: 0 4px 14px rgba(14,165,233,0.35);
348
+ white-space: nowrap; position: relative; overflow: hidden;
349
+ min-width: 200px;
350
+ }
351
+ .download-btn::before {
352
+ content: ''; position: absolute; inset: 0;
353
+ background: linear-gradient(135deg, rgba(255,255,255,0.15), transparent);
354
+ border-radius: 14px;
355
+ }
356
+ .download-btn:hover {
357
+ transform: translateY(-2px) scale(1.04);
358
+ box-shadow: 0 8px 22px rgba(14,165,233,0.45);
359
+ }
360
+ .download-btn:active { transform: scale(0.97); }
361
+ .download-btn .dl-icon {
362
+ width: 22px; height: 22px; border-radius: 50%;
363
+ background: rgba(255,255,255,0.25);
364
+ display: flex; align-items: center; justify-content: center;
365
+ font-size: 0.72rem; flex-shrink: 0;
366
+ }
367
+
368
+ /* ===== SECTION DIVIDER ===== */
369
+ .section-divider { display: flex; align-items: center; gap: 10px; margin: 4px 0; }
370
+ .section-divider::before, .section-divider::after {
371
+ content: ''; flex: 1; height: 1px;
372
+ background: linear-gradient(to right, transparent, var(--border), transparent);
373
+ }
374
+ .section-divider span { font-size: 0.65rem; font-weight: 800; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; }
375
+
376
+ /* ===== TOAST ===== */
377
+ .toast-container {
378
+ position: fixed; top: 20px; right: 50%; transform: translateX(50%);
379
+ z-index: 9999; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
380
+ }
381
+ .toast {
382
+ background: white; border-radius: 14px; padding: 12px 20px;
383
+ font-size: 0.88rem; font-weight: 700;
384
+ display: flex; align-items: center; gap: 10px;
385
+ box-shadow: 0 8px 32px rgba(0,0,0,0.12); border: 1.5px solid var(--border);
386
+ pointer-events: auto; animation: toastIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
387
+ min-width: 240px; max-width: 90vw;
388
+ }
389
+ .toast.leaving { animation: toastOut 0.3s ease both; }
390
+ @keyframes toastIn { from { opacity:0; transform: translateY(-20px) scale(0.9); } to { opacity:1; transform: translateY(0) scale(1); } }
391
+ @keyframes toastOut { from { opacity:1; transform: translateY(0) scale(1); } to { opacity:0; transform: translateY(-12px) scale(0.92); } }
392
+ .toast-icon { width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.85rem; flex-shrink: 0; }
393
+ .toast.success .toast-icon { background: #dcfce7; color: #16a34a; }
394
+ .toast.error .toast-icon { background: #fee2e2; color: #dc2626; }
395
+ .toast.info .toast-icon { background: var(--sky-pale); color: var(--sky-deep); }
396
+
397
+ /* ===== FOOTER ===== */
398
+ footer { padding: 16px 0 28px; }
399
+
400
+ /* ===== SCROLLBAR ===== */
401
+ ::-webkit-scrollbar { width: 5px; }
402
+ ::-webkit-scrollbar-track { background: transparent; }
403
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
404
+ </style>
405
+ </head>
406
+ <body>
407
+
408
+ <div class="bg-scene"></div>
409
+ <div class="toast-container" id="toastContainer"></div>
410
+
411
+ <div class="app-wrapper">
412
+
413
+ <!-- HEADER -->
414
+ <header>
415
+ <div class="logo-wrap">
416
+ <div class="logo-icon-wrap">
417
+ <div class="logo-icon-pulse"></div>
418
+ <div class="logo-icon-pulse2"></div>
419
+ <!-- Google Translate style SVG icon -->
420
+ <svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
421
+ <defs>
422
+ <linearGradient id="lg2" x1="22" y1="24" x2="56" y2="58" gradientUnits="userSpaceOnUse">
423
+ <stop offset="0%" stop-color="#0ea5e9"/>
424
+ <stop offset="100%" stop-color="#6366f1"/>
425
+ </linearGradient>
426
+ </defs>
427
+ <rect x="8" y="12" width="34" height="34" rx="8" fill="#e0f2fe" stroke="#0ea5e9" stroke-width="2.5"/>
428
+ <text x="25" y="36" fill="#0ea5e9" font-size="22" font-family="Arial, sans-serif" font-weight="900" text-anchor="middle">A</text>
429
+ <rect x="22" y="24" width="34" height="34" rx="8" fill="url(#lg2)" stroke="#ffffff" stroke-width="3"/>
430
+ <text x="39" y="48" fill="#ffffff" font-size="22" font-family="Arial, sans-serif" font-weight="bold" text-anchor="middle">文</text>
431
+ </svg>
432
+ </div>
433
+ <div class="logo-text">
434
+ <span class="logo-title">آلفا مترجم</span>
435
+ <span class="logo-sub">Alpha Translator</span>
436
+ </div>
437
+ </div>
438
+ </header>
439
+
440
+ <!-- MAIN CARD -->
441
+ <div class="card">
442
+
443
+ <!-- Language Row -->
444
+ <div class="lang-row">
445
+ <div class="lang-group">
446
+ <div class="lang-label"><span class="dot"></span> زبان مبدأ</div>
447
+ <div class="select-wrap">
448
+ <select id="sourceLang">
449
+ <option value="شناسایی خودکار">🌍 شناسایی خودکار</option>
450
+ </select>
451
+ <i class="fa-solid fa-chevron-down select-arrow"></i>
452
+ </div>
453
+ </div>
454
+
455
+ <button class="swap-btn" id="swapBtn" title="تعویض زبان‌ها">
456
+ <i class="fa-solid fa-arrows-left-right"></i>
457
+ </button>
458
+
459
+ <div class="lang-group">
460
+ <div class="lang-label"><span class="dot" style="background:var(--lavender)"></span> زبان مقصد</div>
461
+ <div class="select-wrap">
462
+ <select id="targetLang"></select>
463
+ <i class="fa-solid fa-chevron-down select-arrow" style="color:var(--lavender)"></i>
464
+ </div>
465
+ </div>
466
+ </div>
467
+
468
+ <!-- Textarea -->
469
+ <div class="textarea-wrap" id="textareaWrap">
470
+ <textarea id="inputText" rows="5" placeholder="متن خود را برای ترجمه اینجا وارد کنید..."></textarea>
471
+ <button class="clear-btn" id="clearBtn" title="پاک کردن">
472
+ <i class="fa-solid fa-eraser"></i>
473
+ </button>
474
+ <div class="textarea-hint" id="textHint">
475
+ <i class="fa-solid fa-triangle-exclamation"></i> لطفاً متن را وارد کنید
476
+ </div>
477
+ </div>
478
+
479
+ <!-- Translate Button -->
480
+ <div class="translate-btn-wrap">
481
+ <button class="translate-btn" id="translateBtn">
482
+ <div class="btn-icon-wrap">
483
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
484
+ </div>
485
+ <span id="btnText">ترجمه و پخش صدا</span>
486
+ </button>
487
+ </div>
488
+
489
+ <!-- Settings -->
490
+ <details class="settings-panel" id="settingsDetails">
491
+ <summary class="settings-toggle">
492
+ <div class="settings-label">
493
+ <i class="fa-solid fa-sliders" style="color:var(--sky-deep)"></i>
494
+ تنظیمات گوینده و صدا
495
+ <span class="badge">پیشرفته</span>
496
+ </div>
497
+ <i class="fa-solid fa-chevron-down settings-chevron"></i>
498
+ </summary>
499
+ <div class="settings-body">
500
+ <div class="voice-select-wrap">
501
+ <div class="lang-label" style="margin-bottom:8px;">
502
+ <i class="fa-solid fa-microphone" style="color:var(--sky-deep)"></i> انتخاب گوینده
503
+ </div>
504
+ <div class="select-wrap">
505
+ <select id="voiceSelect"></select>
506
+ <i class="fa-solid fa-chevron-down select-arrow"></i>
507
+ </div>
508
+ </div>
509
+ <div class="section-divider"><span>تنظیمات صدا</span></div>
510
+ <div class="settings-row">
511
+ <div class="slider-group">
512
+ <label>سرعت <span id="rateVal">0</span></label>
513
+ <input type="range" id="rateSlider" min="-50" max="50" value="0">
514
+ </div>
515
+ <div class="slider-group">
516
+ <label>گام <span id="pitchVal">0</span></label>
517
+ <input type="range" id="pitchSlider" min="-50" max="50" value="0">
518
+ </div>
519
+ <div class="slider-group">
520
+ <label>حجم <span id="volVal">0</span></label>
521
+ <input type="range" id="volSlider" min="-50" max="50" value="0">
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </details>
526
+
527
+ <!-- Output -->
528
+ <div class="output-section" id="outputSection">
529
+ <div class="output-box">
530
+ <div class="output-chip">✦ ترجمه</div>
531
+ <p class="output-text" id="outputText"></p>
532
+ <button class="copy-btn" id="copyBtn" title="کپی متن">
533
+ <i class="fa-regular fa-copy"></i>
534
+ </button>
535
+ </div>
536
+ <div class="audio-player-wrap">
537
+ <div class="audio-player-row">
538
+ <audio id="audioPlayer" controls controlsList="nodownload">
539
+ مرورگر شما از پخش صدا پشتیبانی نمی‌کند.
540
+ </audio>
541
+ </div>
542
+ <button class="download-btn" id="downloadBtn" title="دانلود صدا">
543
+ <div class="dl-icon"><i class="fa-solid fa-arrow-down"></i></div>
544
+ دانلود صدا
545
+ </button>
546
+ </div>
547
+ </div>
548
+
549
+ </div><!-- end .card -->
550
+
551
+ <footer></footer>
552
+ </div>
553
+
554
+ <script>
555
+ // ===== RTL LANGUAGES MAP =====
556
+ const RTL_LANGS = new Set(['فارسی','عربی','هبری','اردو','پشتو','کردی','سندی','دیوهی']);
557
+
558
+ function isRTL(langName) {
559
+ return RTL_LANGS.has(langName);
560
+ }
561
+
562
+ // ===== TOAST =====
563
+ function showToast(msg, type = 'info', duration = 3500) {
564
+ const icons = { success: 'fa-check', error: 'fa-xmark', info: 'fa-info' };
565
+ const container = document.getElementById('toastContainer');
566
+ const toast = document.createElement('div');
567
+ toast.className = `toast ${type}`;
568
+ toast.innerHTML = `<div class="toast-icon"><i class="fa-solid ${icons[type]}"></i></div><span>${msg}</span>`;
569
+ container.appendChild(toast);
570
+ setTimeout(() => {
571
+ toast.classList.add('leaving');
572
+ setTimeout(() => toast.remove(), 300);
573
+ }, duration);
574
+ }
575
+
576
+ // ===== SLIDERS =====
577
+ function updateSliderTrack(slider) {
578
+ const pct = ((slider.value - slider.min) / (slider.max - slider.min)) * 100;
579
+ slider.style.background = `linear-gradient(to left, var(--sky-deep) ${pct}%, #e2e8f0 ${pct}%)`;
580
+ }
581
+ ['rate','pitch','vol'].forEach(id => {
582
+ const slider = document.getElementById(id+'Slider');
583
+ const valEl = document.getElementById(id+'Val');
584
+ updateSliderTrack(slider);
585
+ slider.addEventListener('input', () => { valEl.textContent = slider.value; updateSliderTrack(slider); });
586
+ });
587
+
588
+ // ===== ELEMENTS =====
589
+ let languagesData = {};
590
+ const sourceLang = document.getElementById('sourceLang');
591
+ const targetLang = document.getElementById('targetLang');
592
+ const voiceSelect = document.getElementById('voiceSelect');
593
+ const inputText = document.getElementById('inputText');
594
+ const translateBtn = document.getElementById('translateBtn');
595
+ const btnText = document.getElementById('btnText');
596
+ const outputSection = document.getElementById('outputSection');
597
+ const outputText = document.getElementById('outputText');
598
+ const audioPlayer = document.getElementById('audioPlayer');
599
+ const clearBtn = document.getElementById('clearBtn');
600
+ const copyBtn = document.getElementById('copyBtn');
601
+ const downloadBtn = document.getElementById('downloadBtn');
602
+ const textHint = document.getElementById('textHint');
603
+
604
+ // ===== TEXT DIRECTION HELPERS =====
605
+ function applyInputDir(langName) {
606
+ const rtl = isRTL(langName) || langName === 'شناسایی خودکار';
607
+ inputText.dir = rtl ? 'rtl' : 'ltr';
608
+ inputText.style.textAlign = rtl ? 'right' : 'left';
609
+ if (langName === 'شناسایی خودکار') {
610
+ checkDynamicDirection();
611
+ }
612
+ }
613
+
614
+ function checkDynamicDirection() {
615
+ if (sourceLang.value !== 'شناسایی خودکار') return;
616
+ const val = inputText.value;
617
+ const firstCharMatch = val.match(/\S/);
618
+ if (firstCharMatch) {
619
+ const rtlRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
620
+ const isRtl = rtlRegex.test(firstCharMatch[0]);
621
+ inputText.dir = isRtl ? 'rtl' : 'ltr';
622
+ inputText.style.textAlign = isRtl ? 'right' : 'left';
623
+ } else {
624
+ inputText.dir = 'rtl';
625
+ inputText.style.textAlign = 'right';
626
+ }
627
+ }
628
+
629
+ inputText.addEventListener('input', checkDynamicDirection);
630
+
631
+ function applyOutputDir(langName) {
632
+ const rtl = isRTL(langName);
633
+ outputText.dir = rtl ? 'rtl' : 'ltr';
634
+ outputText.style.textAlign = rtl ? 'right' : 'left';
635
+ }
636
+
637
+ // ===== LOAD CONFIG =====
638
+ async function loadConfig() {
639
+ try {
640
+ const res = await fetch('/api/config');
641
+ const data = await res.json();
642
+ languagesData = data.languages;
643
+ Object.keys(languagesData).forEach(lang => {
644
+ sourceLang.add(new Option(lang, lang));
645
+ const isSelected = lang === 'انگلیسی';
646
+ targetLang.add(new Option(lang, lang, isSelected, isSelected));
647
+ });
648
+ updateVoices('انگلیسی');
649
+ applyInputDir('شناسایی خودکار');
650
+ applyOutputDir('انگلیسی');
651
+ } catch {
652
+ showToast('خطا در بارگذاری زبان‌ها', 'error');
653
+ }
654
+ }
655
+
656
+ function updateVoices(langKey) {
657
+ voiceSelect.innerHTML = '';
658
+ const voices = languagesData[langKey]?.voices || {};
659
+ if (!Object.keys(voices).length) { voiceSelect.add(new Option('بدون پشتیبانی صوتی', '')); return; }
660
+ Object.keys(voices).forEach(vName => voiceSelect.add(new Option(vName, vName)));
661
+ }
662
+
663
+ sourceLang.addEventListener('change', e => applyInputDir(e.target.value));
664
+ targetLang.addEventListener('change', e => { updateVoices(e.target.value); applyOutputDir(e.target.value); });
665
+
666
+ // ===== SWAP =====
667
+ document.getElementById('swapBtn').addEventListener('click', () => {
668
+ const sv = sourceLang.value, tv = targetLang.value;
669
+ if (sv === 'شناسایی خودکار') { showToast('ابتدا یک زبان مبدأ انتخاب کنید', 'info'); return; }
670
+ for (let i = 0; i < sourceLang.options.length; i++) if (sourceLang.options[i].value === tv) { sourceLang.selectedIndex = i; break; }
671
+ for (let i = 0; i < targetLang.options.length; i++) if (targetLang.options[i].value === sv) { targetLang.selectedIndex = i; break; }
672
+ updateVoices(targetLang.value);
673
+ applyInputDir(sourceLang.value);
674
+ applyOutputDir(targetLang.value);
675
+ const translated = outputText.textContent;
676
+ if (translated) { inputText.value = translated; outputSection.classList.remove('visible'); setTimeout(() => { outputSection.style.display = 'none'; }, 450); }
677
+ });
678
+
679
+ // ===== CLEAR =====
680
+ clearBtn.addEventListener('click', () => {
681
+ inputText.value = '';
682
+ inputText.focus();
683
+ checkDynamicDirection();
684
+ outputSection.classList.remove('visible');
685
+ setTimeout(() => { outputSection.style.display = 'none'; }, 450);
686
+ });
687
+
688
+ // ===== COPY =====
689
+ copyBtn.addEventListener('click', () => {
690
+ navigator.clipboard.writeText(outputText.innerText).then(() => {
691
+ copyBtn.innerHTML = '<i class="fa-solid fa-check" style="color:#16a34a"></i>';
692
+ showToast('متن کپی شد!', 'success', 2000);
693
+ setTimeout(() => copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i>', 2000);
694
+ });
695
+ });
696
+
697
+ // ===== DOWNLOAD =====
698
+ downloadBtn.addEventListener('click', () => {
699
+ const src = audioPlayer.src;
700
+ if (!src) return;
701
+
702
+ // بررسی اینکه آیا صفحه درون آی‌فریم باز شده است یا خیر
703
+ if (window.self !== window.top) {
704
+ // ارسال آدرس صدا به کامپوننت والد جهت آپلود تکه‌ای و دانلود با نام سایت شما
705
+ window.parent.postMessage({
706
+ type: 'INITIATE_DOWNLOAD_FROM_URL',
707
+ payload: {
708
+ audioUrl: src
709
+ }
710
+ }, '*');
711
+ showToast('در حال آماده‌سازی فایل صوتی در برنامه...', 'success', 2000);
712
+ } else {
713
+ // حالت پیش‌فرض (دانلود مستقیم مرورگر) در صورتی که خارج از آی‌فریم باز شده باشد
714
+ const a = document.createElement('a');
715
+ a.href = src;
716
+ a.download = 'alpha-translator-audio.mp3';
717
+ a.click();
718
+ showToast('در حال دانلود مستقیم...', 'success', 2000);
719
+ }
720
+ });
721
+
722
+ // ===== TRANSLATE =====
723
+ translateBtn.addEventListener('click', async () => {
724
+ const text = inputText.value.trim();
725
+ if (!text) {
726
+ inputText.classList.add('shake');
727
+ textHint.classList.add('show');
728
+ setTimeout(() => inputText.classList.remove('shake'), 500);
729
+ setTimeout(() => textHint.classList.remove('show'), 3000);
730
+ return;
731
+ }
732
+
733
+ textHint.classList.remove('show');
734
+ translateBtn.disabled = true;
735
+ btnText.innerHTML = '<span style="display:inline-flex; align-items:center; gap:8px;"><span class="spinner"></span> در حال پردازش...</span>';
736
+ outputSection.classList.remove('visible');
737
+ setTimeout(() => { outputSection.style.display = 'none'; }, 450);
738
+
739
+ try {
740
+ const response = await fetch('/api/translate_and_speak', {
741
+ method: 'POST',
742
+ headers: { 'Content-Type': 'application/json' },
743
+ body: JSON.stringify({
744
+ text,
745
+ source_lang: sourceLang.value,
746
+ target_lang: targetLang.value,
747
+ voice_key: voiceSelect.value || '',
748
+ rate: parseInt(document.getElementById('rateSlider').value),
749
+ pitch: parseInt(document.getElementById('pitchSlider').value),
750
+ volume: parseInt(document.getElementById('volSlider').value)
751
+ })
752
+ });
753
+
754
+ const result = await response.json();
755
+
756
+ if (result.success) {
757
+ outputText.innerText = result.translated_text;
758
+ applyOutputDir(targetLang.value);
759
+
760
+ if (result.audio_url) {
761
+ audioPlayer.src = result.audio_url + '?t=' + Date.now();
762
+ audioPlayer.parentElement.parentElement.style.display = 'flex';
763
+ audioPlayer.play().catch(() => {});
764
+ } else {
765
+ audioPlayer.parentElement.parentElement.style.display = 'none';
766
+ }
767
+
768
+ outputSection.style.display = 'flex';
769
+ requestAnimationFrame(() => requestAnimationFrame(() => outputSection.classList.add('visible')));
770
+ showToast('ترجمه با موفقیت انجام شد!', 'success');
771
+ } else {
772
+ showToast(result.error || 'خطایی رخ داد', 'error');
773
+ }
774
+ } catch {
775
+ showToast('خطا در ارتباط با سرور', 'error');
776
+ } finally {
777
+ translateBtn.disabled = false;
778
+ btnText.innerHTML = 'ترجمه و پخش صدا';
779
+ }
780
+ });
781
+
782
+ loadConfig();
783
+ </script>
784
+ </body>
785
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.103.0
2
+ uvicorn>=0.23.2
3
+ edge-tts>=6.1.9
4
+ deep-translator>=1.11.4
5
+ pydantic>=2.4.2
6
+ httpx>=0.27.0