Spaces:
Build error
Build error
| """ | |
| Répétiteur Vocal — POC (Bac tchadien, Maths série D, français) | |
| Interface : réplique WhatsApp (le produit final vivra sur WhatsApp). | |
| Pipeline : Vocale élève → ASR (faster-whisper) → LLM (Qwen2.5-1.5B local, GGUF) | |
| → TTS (MMS-TTS français) → Vocale réponse | |
| 100% modèles open-source, 100% local : AUCUN token ni secret requis. | |
| Conçu pour un Space HF gratuit (CPU 2 vCPU, 16 Go RAM). | |
| """ | |
| import os | |
| import re | |
| import tempfile | |
| from datetime import datetime | |
| import gradio as gr | |
| import numpy as np | |
| import scipy.io.wavfile as wavfile | |
| import torch | |
| from faster_whisper import WhisperModel | |
| from huggingface_hub import hf_hub_download | |
| from llama_cpp import Llama | |
| from transformers import AutoTokenizer, VitsModel | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| LLM_REPO = "Qwen/Qwen2.5-1.5B-Instruct-GGUF" | |
| LLM_FILE = "qwen2.5-1.5b-instruct-q4_k_m.gguf" | |
| ASR_MODEL_SIZE = "small" | |
| TTS_MODEL = "facebook/mms-tts-fra" | |
| SYSTEM_PROMPT = """Tu es un répétiteur de mathématiques pour des élèves de \ | |
| Terminale D au Tchad qui préparent le baccalauréat. Tu expliques comme un \ | |
| grand frère patient, en français simple et clair. | |
| Règles impératives : | |
| 1. Ta réponse sera LUE À VOIX HAUTE : écris tout en toutes lettres. \ | |
| Jamais de symboles mathématiques. Écris "x au carré" et non "x²", \ | |
| "racine de deux" et non "√2", "un demi" et non "1/2", "moins trois" et non "-3". | |
| 2. Pas de listes, pas de titres, pas de formatage : uniquement des phrases \ | |
| courtes qui s'enchaînent naturellement à l'oral. | |
| 3. Maximum 150 mots. Va à l'essentiel : l'idée clé, puis un petit exemple. | |
| 4. Utilise des exemples concrets de la vie au Tchad quand c'est possible \ | |
| (marché, francs CFA, distances entre villes). | |
| 5. Termine par une question courte pour vérifier que l'élève a compris. | |
| 6. Si la question ne concerne pas les études, ramène gentiment l'élève \ | |
| vers ses révisions.""" | |
| # --------------------------------------------------------------------------- | |
| # Chargement des modèles locaux (une seule fois au démarrage du Space) | |
| # --------------------------------------------------------------------------- | |
| print("Chargement ASR (faster-whisper)...") | |
| asr_model = WhisperModel(ASR_MODEL_SIZE, device="cpu", compute_type="int8") | |
| print("Chargement TTS (MMS-TTS français)...") | |
| tts_model = VitsModel.from_pretrained(TTS_MODEL) | |
| tts_tokenizer = AutoTokenizer.from_pretrained(TTS_MODEL) | |
| tts_model.eval() | |
| print("Chargement LLM local (Qwen2.5-1.5B GGUF, Q4)...") | |
| llm_path = hf_hub_download(repo_id=LLM_REPO, filename=LLM_FILE) | |
| llm = Llama(model_path=llm_path, n_ctx=2048, n_threads=2, verbose=False) | |
| print("Modèles prêts.") | |
| # --------------------------------------------------------------------------- | |
| # Briques du pipeline | |
| # --------------------------------------------------------------------------- | |
| def transcrire(audio_path: str) -> str: | |
| segments, _ = asr_model.transcribe(audio_path, language="fr", beam_size=5) | |
| return " ".join(seg.text.strip() for seg in segments).strip() | |
| def repondre(question: str, memoire: list) -> str: | |
| messages = [{"role": "system", "content": SYSTEM_PROMPT}] | |
| for q, r in memoire[-3:]: | |
| messages.append({"role": "user", "content": q}) | |
| messages.append({"role": "assistant", "content": r}) | |
| messages.append({"role": "user", "content": question}) | |
| reponse = llm.create_chat_completion( | |
| messages=messages, max_tokens=250, temperature=0.4 | |
| ) | |
| return reponse["choices"][0]["message"]["content"].strip() | |
| def nettoyer_pour_tts(texte: str) -> str: | |
| texte = re.sub(r"[*_#`>\[\]()]", " ", texte) | |
| texte = re.sub(r"\s+", " ", texte) | |
| return texte.strip() | |
| def synthetiser(texte: str) -> str: | |
| texte = nettoyer_pour_tts(texte) | |
| inputs = tts_tokenizer(texte, return_tensors="pt") | |
| with torch.no_grad(): | |
| waveform = tts_model(**inputs).waveform | |
| audio = waveform.squeeze().cpu().numpy() | |
| audio = (audio / np.max(np.abs(audio)) * 32767).astype(np.int16) | |
| out_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name | |
| wavfile.write(out_path, tts_model.config.sampling_rate, audio) | |
| return out_path | |
| def _heure() -> str: | |
| return datetime.now().strftime("%H:%M") | |
| # --------------------------------------------------------------------------- | |
| # Logique de l'interface (format "messages" du Chatbot Gradio) | |
| # --------------------------------------------------------------------------- | |
| def traiter(audio_path, question_texte, chat, memoire): | |
| chat = chat or [] | |
| memoire = memoire or [] | |
| if audio_path: | |
| question = transcrire(audio_path) | |
| etiquette = f"🎤 {question}" if question else "" | |
| elif question_texte and question_texte.strip(): | |
| question = question_texte.strip() | |
| etiquette = question | |
| else: | |
| return chat, memoire, None, "" | |
| if not question: | |
| chat.append({"role": "assistant", | |
| "content": "Je n'ai pas bien entendu 🙏 Réessaie en parlant plus fort."}) | |
| return chat, memoire, None, "" | |
| chat.append({"role": "user", "content": etiquette}) | |
| try: | |
| explication = repondre(question, memoire) | |
| except Exception as e: | |
| chat.append({"role": "assistant", | |
| "content": f"⚠️ Erreur LLM : {e}"}) | |
| return chat, memoire, None, "" | |
| # Note vocale du répétiteur, puis transcription texte (comme sur WhatsApp) | |
| try: | |
| audio_reponse = synthetiser(explication) | |
| chat.append({"role": "assistant", | |
| "content": {"path": audio_reponse, "mime_type": "audio/wav"}}) | |
| except Exception: | |
| pass | |
| chat.append({"role": "assistant", "content": explication}) | |
| memoire.append((question, explication)) | |
| return chat, memoire, None, "" | |
| # --------------------------------------------------------------------------- | |
| # Interface — réplique WhatsApp | |
| # --------------------------------------------------------------------------- | |
| CSS = """ | |
| /* ---- cadre téléphone ---- */ | |
| .gradio-container { | |
| max-width: 460px !important; | |
| margin: 0 auto !important; | |
| background: #0b141a !important; | |
| padding: 0 !important; | |
| } | |
| footer {display: none !important;} | |
| /* ---- en-tête WhatsApp ---- */ | |
| #wa-header { | |
| background: #075E54; | |
| color: #fff; | |
| padding: 10px 14px; | |
| border-radius: 0; | |
| margin: 0; | |
| } | |
| #wa-header * {color: #fff !important;} | |
| /* ---- zone de chat : papier peint WhatsApp ---- */ | |
| #wa-chat { | |
| background-color: #ECE5DD !important; | |
| background-image: radial-gradient(#d9d2c9 0.75px, transparent 0.75px); | |
| background-size: 18px 18px; | |
| border: none !important; | |
| border-radius: 0 !important; | |
| } | |
| #wa-chat .placeholder, #wa-chat .panel {background: transparent !important;} | |
| /* ---- bulles ---- */ | |
| #wa-chat .message { | |
| border: none !important; | |
| border-radius: 8px !important; | |
| box-shadow: 0 1px 0.5px rgba(0,0,0,0.13) !important; | |
| font-size: 14.5px !important; | |
| max-width: 82% !important; | |
| } | |
| #wa-chat .message.user, #wa-chat .user { | |
| background: #DCF8C6 !important; | |
| color: #111b21 !important; | |
| } | |
| #wa-chat .message.bot, #wa-chat .bot { | |
| background: #ffffff !important; | |
| color: #111b21 !important; | |
| } | |
| #wa-chat .message audio {width: 230px; height: 40px;} | |
| #wa-chat .avatar-container {display: none !important;} | |
| /* ---- barre de saisie ---- */ | |
| #wa-input { | |
| background: #F0F2F5 !important; | |
| padding: 6px 8px !important; | |
| margin: 0 !important; | |
| border-radius: 0 !important; | |
| } | |
| #wa-text textarea { | |
| border-radius: 20px !important; | |
| background: #fff !important; | |
| border: none !important; | |
| } | |
| #wa-send { | |
| background: #00A884 !important; | |
| color: #fff !important; | |
| border-radius: 50% !important; | |
| min-width: 46px !important; | |
| max-width: 46px !important; | |
| height: 46px !important; | |
| font-size: 20px !important; | |
| } | |
| #wa-mic {border: none !important; background: transparent !important;} | |
| #wa-mic .wrap {border: none !important;} | |
| """ | |
| EN_TETE = """ | |
| <div style="display:flex;align-items:center;gap:12px;"> | |
| <div style="width:40px;height:40px;border-radius:50%;background:#128C7E; | |
| display:flex;align-items:center;justify-content:center;font-size:20px;">🎓</div> | |
| <div style="flex:1;"> | |
| <div style="font-weight:600;font-size:16px;">Répétiteur Maths D</div> | |
| <div style="font-size:12px;opacity:0.85;">en ligne</div> | |
| </div> | |
| <div style="font-size:18px;opacity:0.9;">📹 📞 ⋮</div> | |
| </div> | |
| """ | |
| with gr.Blocks(css=CSS, title="Répétiteur Vocal", theme=gr.themes.Base()) as demo: | |
| gr.HTML(EN_TETE, elem_id="wa-header") | |
| chatbot = gr.Chatbot( | |
| value=[{"role": "assistant", | |
| "content": "Salut ! 👋 Je suis ton répétiteur de maths Terminale D. " | |
| "Envoie-moi ta question en note vocale 🎤 ou par écrit."}], | |
| type="messages", | |
| elem_id="wa-chat", | |
| height=520, | |
| show_label=False, | |
| ) | |
| memoire = gr.State([]) | |
| with gr.Row(elem_id="wa-input"): | |
| micro = gr.Audio( | |
| sources=["microphone"], type="filepath", show_label=False, | |
| elem_id="wa-mic", scale=2, waveform_options={"show_recording_waveform": False}, | |
| ) | |
| texte = gr.Textbox( | |
| show_label=False, placeholder="Message", elem_id="wa-text", scale=3, | |
| ) | |
| envoyer = gr.Button("➤", elem_id="wa-send", scale=0) | |
| envoyer.click( | |
| traiter, | |
| inputs=[micro, texte, chatbot, memoire], | |
| outputs=[chatbot, memoire, micro, texte], | |
| ) | |
| texte.submit( | |
| traiter, | |
| inputs=[micro, texte, chatbot, memoire], | |
| outputs=[chatbot, memoire, micro, texte], | |
| ) | |
| # Envoi automatique dès la fin de l'enregistrement vocal (comme WhatsApp) | |
| micro.stop_recording( | |
| traiter, | |
| inputs=[micro, texte, chatbot, memoire], | |
| outputs=[chatbot, memoire, micro, texte], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |