import os import re import json import uuid import gc import asyncio import threading import signal import numpy as np from scipy.io import wavfile from pydub import AudioSegment import gradio as gr import edge_tts from huggingface_hub import hf_hub_download, HfApi # ----------------- حل مشكلة أمان PyTorch والـ Checkpoint (طريقة Monkey-patch) ----------------- import torch _orig_torch_load = torch.load def _patched_torch_load(*args, **kwargs): kwargs.setdefault("weights_only", False) return _orig_torch_load(*args, **kwargs) torch.load = _patched_torch_load # الموافقة التلقائية على شروط استخدام Coqui لتفادي توقف السيرفر os.environ['COQUI_TOS_AGREED'] = '1' from TTS.api import TTS # ════════════════════════════════════════════════════════════════════ # إعدادات البيئة والخزنة # ════════════════════════════════════════════════════════════════════ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() # 🔐 مفتاح سري مشترك لحماية نقطة API الخارجية فقط (الواجهة العامة تبقى # عامة بالكامل لتسجيل دخول VIP الطبيعي). يُضبط في: # Settings → Variables and secrets → INTERNAL_API_KEY # ويجب ضبط نفس القيمة بالضبط في مساحة "الأستاذ عبود" (teacher_app) أيضاً. INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "").strip() VOICES_DIR = "./platform_voices" AUDIO_FOLDER = "./audio" # المجلد الخاص بالأصوات العربية المرجعية الثابتة (للاستنساخ) DATASET_REPO = "Asem75/aiocr_asistant" USER_REGISTRY_PATH = "system/user_registry.json" def ensure_dir(path: str): """تنشئ المجلد، وإذا كان موجوداً كملف عادي (وليس مجلداً) تحذفه أولاً ثم تنشئ المجلد بأمان.""" if os.path.exists(path) and not os.path.isdir(path): os.remove(path) os.makedirs(path, exist_ok=True) ensure_dir(VOICES_DIR) ensure_dir(AUDIO_FOLDER) # ════════════════════════════════════════════════════════════════════ # 1) بوابة الحماية الصارمة (VIP Only Gate) # # الشكل الحقيقي المؤكَّد لملف "system/user_registry.json" (من الخزنة): # { # "Divid": { # "email": "eduassistant75@gmail.com", # "device_token": "SECURE-FPR-23726480", # "rank": "free", # "registered_at": "2026-06-20 13:51:58" # }, # "اسم_مستخدم_آخر": { ... } # } # المفتاح الخارجي لكل عنصر هو اسم المستخدم نفسه (user_name) — وليس حقلاً # داخلياً. القيمة الداخلية لا تحتوي على user_name إطلاقاً. # ════════════════════════════════════════════════════════════════════ def load_user_registry(): """يقرأ سجل المستخدمين من الخزنة كقاموس {user_name: {email, device_token, rank, registered_at}}. عند أي فشل يُرجع قاموساً فارغاً → فشل آمن (Fail-Closed): لا أحد يدخل بدل أن يدخل الجميع بالخطأ.""" try: path = hf_hub_download( repo_id=DATASET_REPO, filename=USER_REGISTRY_PATH, repo_type="dataset", token=HF_TOKEN or None, force_download=True, # لضمان سحب آخر نسخة من السجل مع كل محاولة دخول ) with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return data return {} except Exception as e: print(f"⚠️ فشل تحميل سجل المستخدمين (رفض آمن لكل الدخول): {e}") return {} def authenticate_user(email: str, device_token: str): """يطابق البريد الإلكتروني وبصمة الجهاز عبر كل عناصر السجل. يُرجع قاموساً يحتوي user_name (المفتاح الخارجي) + باقي بيانات المستخدم، فقط إذا كانت الرتبة 'vip' بالضبط. أي حالة أخرى (غير مسجل، رتبة 'free'، تطابق جزئي) تُرجع None → حظر كامل.""" email_n = (email or "").strip().lower() token_n = (device_token or "").strip() if not email_n or not token_n: return None registry = load_user_registry() for user_name, info in registry.items(): if (info.get("email", "").strip().lower() == email_n and info.get("device_token", "").strip() == token_n): if info.get("rank") == "vip": return {"user_name": user_name, **info} return None # موجود لكن رتبته ليست vip → حظر return None # غير مسجل أصلاً → حظر # ════════════════════════════════════════════════════════════════════ # 2) الملفات النصية المخصصة لكل عميل + حفظ الأصوات باسمه # ════════════════════════════════════════════════════════════════════ def list_user_text_files(user_name: str): """يسرد فقط الملفات الموجودة داخل Final_Arabic_Files/{user_name}/ في الخزنة، بحيث لا يرى أي عميل إلا ملفاته الخاصة.""" if not user_name: return [] try: api = HfApi() all_files = api.list_repo_files(repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN or None) prefix = f"Final_Arabic_Files/{user_name}/" return [f for f in all_files if f.startswith(prefix) and not f.endswith("/")] except Exception as e: print(f"⚠️ فشل سرد ملفات المستخدم {user_name}: {e}") return [] def load_user_text_file(file_path: str) -> str: if not file_path: return "" try: local_path = hf_hub_download(repo_id=DATASET_REPO, filename=file_path, repo_type="dataset", token=HF_TOKEN or None) with open(local_path, "r", encoding="utf-8") as f: return f.read() except Exception as e: return f"⚠️ تعذر تحميل الملف: {e}" def upload_audio_to_vault(local_path: str, user_name: str = None): """يرفع الملف الصوتي للخزنة. إذا تم تمرير user_name يُحفظ تحت voices/{user_name}/{filename}، وإلا يُحفظ في voices/{filename} (للحالات العامة/الاختبارية بدون مستخدم محدد).""" if not HF_TOKEN: return " (⚠️ لا يوجد رمز HF_TOKEN)" try: api = HfApi() filename = os.path.basename(local_path) path_in_repo = f"voices/{user_name}/{filename}" if user_name else f"voices/{filename}" api.upload_file( path_or_fileobj=local_path, path_in_repo=path_in_repo, repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN, ) return f" (💾 تم الحفظ في: {path_in_repo})" except Exception as e: return f" (⚠️ فشل المزامنة: {e})" # جلب قائمة الملفات الصوتية المرجعية الثابتة (لاستنساخ XTTS) من مجلد audio المحلي def get_local_speaker_waves(): if not os.path.exists(AUDIO_FOLDER): return [] files = [f for f in os.listdir(AUDIO_FOLDER) if f.endswith(('.wav', '.mp3'))] return [(f.split('.')[0].replace('-', ' '), os.path.join(AUDIO_FOLDER, f)) for f in files] # ════════════════════════════════════════════════════════════════════ # حلقة Edge-TTS الدائمة (خيط واحد طويل العمر، بدون إنشاء/إغلاق متكرر) # ════════════════════════════════════════════════════════════════════ _edge_loop = None _edge_thread = None def _start_edge_loop(): global _edge_loop, _edge_thread if _edge_loop is not None: return _edge_loop = asyncio.new_event_loop() def run_loop(): asyncio.set_event_loop(_edge_loop) _edge_loop.run_forever() _edge_thread = threading.Thread(target=run_loop, daemon=True) _edge_thread.start() def _stop_edge_loop(): global _edge_loop, _edge_thread if _edge_loop is not None: _edge_loop.call_soon_threadsafe(_edge_loop.stop) _edge_thread.join(timeout=5) _edge_loop.close() _edge_loop = None _edge_thread = None def _shutdown_handler(signum, frame): _stop_edge_loop() exit(0) signal.signal(signal.SIGINT, _shutdown_handler) signal.signal(signal.SIGTERM, _shutdown_handler) def get_available_voices(): _start_edge_loop() async def _fetch(): voices = await edge_tts.list_voices() arabic, english = [], [] for v in voices: short_name = v["ShortName"] if "Neural" not in short_name: continue label = f"{v['Locale']} - {short_name} ({v['Gender']})" if v["Locale"].startswith("ar"): arabic.append((label, short_name)) elif v["Locale"].startswith("en"): english.append((label, short_name)) arabic.sort(key=lambda x: x[0]) english.sort(key=lambda x: x[0]) return arabic, english future = asyncio.run_coroutine_threadsafe(_fetch(), _edge_loop) return future.result(timeout=30) print("⏳ جاري تحميل قائمة أصوات Edge‑TTS...") try: ARABIC_EDGE_VOICES, ENGLISH_EDGE_VOICES = get_available_voices() except Exception: ARABIC_EDGE_VOICES = [("العربية - صوت افتراضي", "ar-JO-TaimNeural")] ENGLISH_EDGE_VOICES = [("English - Default", "en-US-JennyNeural")] # ════════════════════════════════════════════════════════════════════ # محركات التوليد الصوتي (كل دالة تأخذ جملة واحدة قصيرة وتُرجع مسار ملف) # ════════════════════════════════════════════════════════════════════ def generate_edge_audio(text: str, voice: str) -> str: filepath = os.path.join(VOICES_DIR, f"edge_{uuid.uuid4().hex}.mp3") async def _task(): communicate = edge_tts.Communicate(text.strip(), voice) await communicate.save(filepath) _start_edge_loop() future = asyncio.run_coroutine_threadsafe(_task(), _edge_loop) future.result(timeout=120) return filepath _piper_voice = None _piper_lock = threading.Lock() def load_piper_lazy(): global _piper_voice if _piper_voice is not None: return _piper_voice with _piper_lock: if _piper_voice is not None: return _piper_voice from piper import PiperVoice model_path = hf_hub_download(repo_id=DATASET_REPO, filename="models/ar_JO-kareem-low.onnx", repo_type="dataset", token=HF_TOKEN or None) config_path = hf_hub_download(repo_id=DATASET_REPO, filename="models/ar_JO-kareem-low.onnx.json", repo_type="dataset", token=HF_TOKEN or None) _piper_voice = PiperVoice.load(model_path, config_path) return _piper_voice def generate_piper_audio(text: str) -> str: voice = load_piper_lazy() filepath = os.path.join(VOICES_DIR, f"piper_{uuid.uuid4().hex}.wav") try: gc.collect() pcm_chunks = [chunk.audio_int16_bytes for chunk in voice.synthesize(text.strip())] wavfile.write(filepath, 22050, np.frombuffer(b"".join(pcm_chunks), dtype=np.int16)) return filepath except Exception as e: if os.path.exists(filepath): os.remove(filepath) raise RuntimeError(f"Piper Error: {e}") _xtts_model = None _xtts_lock = threading.Lock() def load_xtts_lazy(): global _xtts_model if _xtts_model is not None: return _xtts_model with _xtts_lock: if _xtts_model is not None: return _xtts_model print("⏳ جاري تحميل نموذج الاستنساخ XTTS-v2...") device = "cuda" if torch.cuda.is_available() else "cpu" _xtts_model = TTS("tts_models/multilingual/multi-dataset/xtts_v2", gpu=torch.cuda.is_available()).to(device) print("✅ تم تحميل محرك XTTS-v2 بنجاح.") return _xtts_model def generate_xtts_audio(text: str, speaker_audio_path: str) -> str: if not speaker_audio_path or not os.path.exists(speaker_audio_path): raise ValueError("يجب تحديد أو رفع ملف صوتي مرجعي للاستنساخ.") tts = load_xtts_lazy() filepath = os.path.join(VOICES_DIR, f"xtts_{uuid.uuid4().hex}.wav") try: gc.collect() tts.tts_to_file(text=text.strip(), speaker_wav=speaker_audio_path, language="ar", file_path=filepath) return filepath except Exception as e: if os.path.exists(filepath): os.remove(filepath) raise RuntimeError(f"XTTS Error: {e}") # ════════════════════════════════════════════════════════════════════ # 4) تقطيع النصوص الطويلة ودمج المقاطع الصوتية # # التقطيع الأساسي على علامات إنهاء الجملة (. ! ؟). إذا بقيت جملة واحدة # طويلة جداً بعد ذلك (أطول من MAX_CHUNK_CHARS)، نقسّمها إضافياً على # الفاصلة (، ,) فقط كحماية إضافية — لتجنّب تفتيت كل جملة قصيرة عادية إلى # أجزاء صغيرة جداً بلا داعٍ. # ════════════════════════════════════════════════════════════════════ MAX_CHUNK_CHARS = 300 _SENTENCE_END_RE = re.compile(r'(?<=[.!؟])\s+') _SOFT_SPLIT_RE = re.compile(r'(?<=[،,])\s+') def split_text_into_chunks(text: str): text = (text or "").strip() if not text: return [] sentences = [s.strip() for s in _SENTENCE_END_RE.split(text) if s.strip()] if not sentences: sentences = [text] chunks = [] for s in sentences: if len(s) <= MAX_CHUNK_CHARS: chunks.append(s) continue # جملة طويلة جداً: تقطيع إضافي على الفواصل لحماية المحرك الصوتي buf = "" for part in [p.strip() for p in _SOFT_SPLIT_RE.split(s) if p.strip()]: if len(buf) + len(part) + 1 <= MAX_CHUNK_CHARS: buf = f"{buf} {part}".strip() else: if buf: chunks.append(buf) buf = part if buf: chunks.append(buf) return chunks def synthesize_long_text(text: str, synth_fn, *synth_args) -> str: """يقطّع النص، يولّد كل جزء عبر synth_fn، ثم يدمج كل المقاطع في ملف WAV واحد نهائي عبر pydub. إذا كان النص قصيراً (جملة واحدة) يعمل مباشرة بدون أي تقطيع لتوفير الوقت.""" chunks = split_text_into_chunks(text) if not chunks: raise ValueError("النص فارغ بعد المعالجة.") if len(chunks) == 1: return synth_fn(chunks[0], *synth_args) combined = AudioSegment.empty() temp_files = [] try: for chunk in chunks: part_path = synth_fn(chunk, *synth_args) temp_files.append(part_path) combined += AudioSegment.from_file(part_path) combined += AudioSegment.silent(duration=180) # فاصل طبيعي قصير بين الجمل final_path = os.path.join(VOICES_DIR, f"merged_{uuid.uuid4().hex}.wav") combined.export(final_path, format="wav") return final_path finally: for p in temp_files: if os.path.exists(p): try: os.remove(p) except Exception: pass # ════════════════════════════════════════════════════════════════════ # دالة التوليد الرئيسية (واجهة المستخدم الداخلية، بعد تسجيل الدخول) # ════════════════════════════════════════════════════════════════════ def synth_arabic(text, engine, edge_voice, xtts_mode, local_speaker, uploaded_speaker, user_name): if not text or not text.strip(): return None, "⚠️ الرجاء كتابة نص أولاً" try: if engine == "محرك Coqui XTTS-v2 (استنساخ الأصوات العربية)": chosen_speaker = local_speaker if xtts_mode == "اختر من الأصوات العربية المرفوعة في المستودع" else uploaded_speaker path = synthesize_long_text(text, generate_xtts_audio, chosen_speaker) elif engine == "محرك Piper المحلي (بصوت كريم)": path = synthesize_long_text(text, generate_piper_audio) else: path = synthesize_long_text(text, generate_edge_audio, edge_voice) msg = upload_audio_to_vault(path, user_name) return path, f"✅ تم التوليد بنجاح!{msg}" except Exception as e: return None, f"❌ {e}" # ════════════════════════════════════════════════════════════════════ # 3) نقطة API الخارجية (Webhook) لمساحة المحادثة الأخرى # # 🔐 محمية الآن بمفتاح سري (api_key) يُقارَن بمتغيّر البيئة # INTERNAL_API_KEY. الواجهة الرسومية (تسجيل دخول VIP) تبقى عامة بالكامل # ولا تتأثر بهذا — الحماية هنا خاصة بنقطة الـ API فقط. # فشل آمن (Fail-Closed): إذا لم يُضبط INTERNAL_API_KEY في إعدادات هذه # المساحة، تُرفض كل الاستدعاءات تلقائياً بدل أن تبقى مفتوحة بالخطأ. # ════════════════════════════════════════════════════════════════════ def synth_arabic_api(text: str, speaker_name: str, api_key: str): if not INTERNAL_API_KEY or (api_key or "").strip() != INTERNAL_API_KEY: raise gr.Error("🚫 وصول مرفوض: مفتاح API غير صحيح أو غير مُفعَّل في هذه المساحة.") if not text or not text.strip(): raise gr.Error("النص المُرسل فارغ.") speaker_name = (speaker_name or "").strip() if speaker_name.startswith("ar-"): return synthesize_long_text(text, generate_edge_audio, speaker_name) speaker_path = os.path.join(AUDIO_FOLDER, speaker_name) if not os.path.exists(speaker_path): raise gr.Error(f"لم يتم العثور على الصوت المرجعي: {speaker_name}") return synthesize_long_text(text, generate_xtts_audio, speaker_path) # ════════════════════════════════════════════════════════════════════ # واجهة Gradio # ════════════════════════════════════════════════════════════════════ LOCAL_SPEAKERS = get_local_speaker_waves() with gr.Blocks(title="منصة الأستاذ عبود") as demo: gr.Markdown("# 🎙️ منصة الأستاذ عبود - نظام التوليد الصوتي الاحترافي") user_name_state = gr.State("") # ---------------- شاشة الحراسة (VIP Login Gate) ---------------- with gr.Column(visible=True) as login_screen: gr.Markdown("### 🔒 الدخول مقصور على أعضاء VIP فقط") email_input = gr.Textbox(label="البريد الإلكتروني") device_input = gr.Textbox(label="بصمة الجهاز (Device Token)") login_btn = gr.Button("🚪 دخول", variant="primary") # ---------------- الواجهة الرئيسية (مخفية حتى تسجيل الدخول) ---------------- with gr.Column(visible=False) as main_app: welcome_md = gr.Markdown("") with gr.Tabs(): with gr.Tab("العربية"): with gr.Row(): with gr.Column(scale=3): text_ar = gr.Textbox(label="✍️ النص العربي لتوليده صوتاً", lines=6, rtl=True) with gr.Column(scale=1): engine_ar = gr.Radio( choices=["محرك مايكروسوفت السحابي (Edge-TTS)", "محرك Piper المحلي (بصوت كريم)", "محرك Coqui XTTS-v2 (استنساخ الأصوات العربية)"], label="⚙️ اختر المحرك الصوتي", value="محرك مايكروسوفت السحابي (Edge-TTS)" ) voice_ar = gr.Dropdown(choices=ARABIC_EDGE_VOICES, label="🎙️ الصوت العربي (Edge)", value=ARABIC_EDGE_VOICES[0][1] if ARABIC_EDGE_VOICES else None) xtts_mode_ar = gr.Radio(choices=["اختر من الأصوات العربية المرفوعة في المستودع", "رفع ملف صوتي جديد (مايكروفون)"], label="🎛️ مصدر صوت الاستنساخ", value="رفع ملف صوتي جديد (مايكروفون)", visible=False) local_speaker_ar = gr.Dropdown(choices=LOCAL_SPEAKERS, label="👤 الأصوات العربية المتاحة في السيرفر", visible=False) uploaded_speaker_ar = gr.Audio(label="🎙️ ارفع ملف صوتي مرجعي (6 ثوانٍ)", type="filepath", visible=False) btn_ar = gr.Button("🚀 توليد الصوت العربي", variant="primary") audio_ar = gr.Audio(label="الملف الصوتي الناتج", type="filepath") status_ar = gr.Textbox(label="حالة التوليد والرفع سحابياً", interactive=False) def update_engine_fields(engine): is_edge = engine == "محرك مايكروسوفت السحابي (Edge-TTS)" is_xtts = engine == "محرك Coqui XTTS-v2 (استنساخ الأصوات العربية)" return gr.update(visible=is_edge), gr.update(visible=is_xtts), gr.update(visible=False), gr.update(visible=False) def update_xtts_source(mode): is_local = mode == "اختر من الأصوات العربية المرفوعة في المستودع" return gr.update(visible=is_local), gr.update(visible=not is_local) engine_ar.change(fn=update_engine_fields, inputs=engine_ar, outputs=[voice_ar, xtts_mode_ar, local_speaker_ar, uploaded_speaker_ar], api_name=False) xtts_mode_ar.change(fn=update_xtts_source, inputs=xtts_mode_ar, outputs=[local_speaker_ar, uploaded_speaker_ar], api_name=False) btn_ar.click( fn=synth_arabic, inputs=[text_ar, engine_ar, voice_ar, xtts_mode_ar, local_speaker_ar, uploaded_speaker_ar, user_name_state], outputs=[audio_ar, status_ar], api_name=False, # ⚠️ هام: بدون هذا، Gradio يُصدّرها تلقائياً باسم دالتها "/synth_arabic" ويتعارض مع نقطة الـ API المقصودة أدناه ) with gr.Tab("English"): with gr.Row(): with gr.Column(scale=3): text_en = gr.Textbox(label="✍️ English Text", lines=6) with gr.Column(scale=1): voice_en = gr.Dropdown(choices=ENGLISH_EDGE_VOICES, label="🎙️ English Voice", value=ENGLISH_EDGE_VOICES[0][1] if ENGLISH_EDGE_VOICES else None) btn_en = gr.Button("🚀 Generate English Audio", variant="primary") audio_en = gr.Audio(label="Output Audio", type="filepath") status_en = gr.Textbox(label="Status", interactive=False) btn_en.click( fn=lambda t, v: (synthesize_long_text(t, generate_edge_audio, v), "✅ Done"), inputs=[text_en, voice_en], outputs=[audio_en, status_en], api_name=False, ) with gr.Tab("📁 ملفاتي"): gr.Markdown("الملفات النصية المخصصة لك فقط من الخزنة (Final_Arabic_Files/اسمك/).") refresh_files_btn = gr.Button("🔄 تحديث قائمة ملفاتي") my_files_dropdown = gr.Dropdown(label="اختر ملفاً نصياً", choices=[]) load_file_btn = gr.Button("📥 تحميل محتوى الملف المختار") my_file_text = gr.Textbox(label="محتوى الملف", lines=8, rtl=True) use_in_generate_btn = gr.Button("➡️ استخدم هذا النص في تبويب العربية") def refresh_my_files(user_name): return gr.update(choices=list_user_text_files(user_name)) refresh_files_btn.click(fn=refresh_my_files, inputs=[user_name_state], outputs=[my_files_dropdown], api_name=False) load_file_btn.click(fn=load_user_text_file, inputs=[my_files_dropdown], outputs=[my_file_text], api_name=False) use_in_generate_btn.click(fn=lambda txt: txt, inputs=[my_file_text], outputs=[text_ar], api_name=False) # ---------------- ربط زر تسجيل الدخول ---------------- def handle_login(email, device_token): user = authenticate_user(email, device_token) if user is None: raise gr.Error("🚫 الوصول مرفوض: البريد الإلكتروني أو بصمة الجهاز غير صحيحة، أو الحساب ليس VIP.") resolved_name = user.get("user_name") or user.get("email", "guest") files = list_user_text_files(resolved_name) return ( gr.update(visible=False), # login_screen gr.update(visible=True), # main_app resolved_name, # user_name_state f"### مرحباً بك، {resolved_name} 👋", # welcome_md gr.update(choices=files), # my_files_dropdown ) login_btn.click( fn=handle_login, inputs=[email_input, device_input], outputs=[login_screen, main_app, user_name_state, welcome_md, my_files_dropdown], api_name=False, ) # ---------------- نقطة API خفية لمساحة المحادثة الخارجية ---------------- # مكوّنات غير ظاهرة في الواجهة الرسومية، تُستخدَم فقط عبر استدعاء API. with gr.Row(visible=False): api_text_in = gr.Textbox() api_speaker_in = gr.Textbox() api_key_in = gr.Textbox() api_audio_out = gr.Audio(type="filepath") api_trigger_btn = gr.Button() api_trigger_btn.click( fn=synth_arabic_api, inputs=[api_text_in, api_speaker_in, api_key_in], outputs=api_audio_out, api_name="synth_arabic", ) if __name__ == "__main__": _start_edge_loop() demo.queue(max_size=1).launch(server_name="0.0.0.0")