Toadoum commited on
Commit
284a7db
·
verified ·
1 Parent(s): 3fa4933

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +179 -0
app.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Répétiteur Vocal — POC (Bac tchadien, Maths série D, français)
3
+ Pipeline : Vocale élève → ASR (faster-whisper) → LLM (Qwen2.5 via HF Inference)
4
+ → TTS (MMS-TTS français) → Vocale réponse
5
+
6
+ 100% modèles open-source. Conçu pour un Space HF gratuit (CPU 2 vCPU).
7
+ Secret requis dans le Space : HF_TOKEN (token HuggingFace, lecture suffit).
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import tempfile
13
+
14
+ import gradio as gr
15
+ import numpy as np
16
+ import scipy.io.wavfile as wavfile
17
+ import torch
18
+ from faster_whisper import WhisperModel
19
+ from huggingface_hub import InferenceClient
20
+ from transformers import AutoTokenizer, VitsModel
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Configuration
24
+ # ---------------------------------------------------------------------------
25
+ HF_TOKEN = os.environ.get("HF_TOKEN")
26
+ LLM_MODEL = "Qwen/Qwen2.5-7B-Instruct" # open-source, servi par HF Inference
27
+ ASR_MODEL_SIZE = "small" # bon compromis qualité/vitesse CPU
28
+ TTS_MODEL = "facebook/mms-tts-fra" # VITS français, très léger
29
+
30
+ SYSTEM_PROMPT = """Tu es un répétiteur de mathématiques pour des élèves de \
31
+ Terminale D au Tchad qui préparent le baccalauréat. Tu expliques comme un \
32
+ grand frère patient, en français simple et clair.
33
+
34
+ Règles impératives :
35
+ 1. Ta réponse sera LUE À VOIX HAUTE : écris tout en toutes lettres. \
36
+ Jamais de symboles mathématiques. Écris "x au carré" et non "x²", \
37
+ "racine de deux" et non "√2", "un demi" et non "1/2", "moins trois" et non "-3".
38
+ 2. Pas de listes, pas de titres, pas de formatage : uniquement des phrases \
39
+ courtes qui s'enchaînent naturellement à l'oral.
40
+ 3. Maximum 150 mots. Va à l'essentiel : l'idée clé, puis un petit exemple.
41
+ 4. Utilise des exemples concrets de la vie au Tchad quand c'est possible \
42
+ (marché, francs CFA, distances entre villes).
43
+ 5. Termine par une question courte pour vérifier que l'élève a compris.
44
+ 6. Si la question ne concerne pas les études, ramène gentiment l'élève \
45
+ vers ses révisions."""
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Chargement des modèles locaux (une seule fois au démarrage du Space)
49
+ # ---------------------------------------------------------------------------
50
+ print("Chargement ASR (faster-whisper)...")
51
+ asr_model = WhisperModel(ASR_MODEL_SIZE, device="cpu", compute_type="int8")
52
+
53
+ print("Chargement TTS (MMS-TTS français)...")
54
+ tts_model = VitsModel.from_pretrained(TTS_MODEL)
55
+ tts_tokenizer = AutoTokenizer.from_pretrained(TTS_MODEL)
56
+ tts_model.eval()
57
+
58
+ llm_client = InferenceClient(model=LLM_MODEL, token=HF_TOKEN)
59
+ print("Modèles prêts.")
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Briques du pipeline
63
+ # ---------------------------------------------------------------------------
64
+ def transcrire(audio_path: str) -> str:
65
+ """Vocale de l'élève → texte (français)."""
66
+ segments, _ = asr_model.transcribe(audio_path, language="fr", beam_size=5)
67
+ return " ".join(seg.text.strip() for seg in segments).strip()
68
+
69
+
70
+ def repondre(question: str, historique: list) -> str:
71
+ """Question → explication du répétiteur (via LLM open-source)."""
72
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
73
+ # On garde les 3 derniers échanges pour le contexte (limite les coûts/latence)
74
+ for q, r in historique[-3:]:
75
+ messages.append({"role": "user", "content": q})
76
+ messages.append({"role": "assistant", "content": r})
77
+ messages.append({"role": "user", "content": question})
78
+
79
+ reponse = llm_client.chat_completion(
80
+ messages=messages, max_tokens=350, temperature=0.4
81
+ )
82
+ return reponse.choices[0].message.content.strip()
83
+
84
+
85
+ def nettoyer_pour_tts(texte: str) -> str:
86
+ """Filet de sécurité : retire le formatage résiduel avant synthèse."""
87
+ texte = re.sub(r"[*_#`>\[\]()]", " ", texte) # markdown résiduel
88
+ texte = re.sub(r"\s+", " ", texte) # espaces multiples
89
+ return texte.strip()
90
+
91
+
92
+ def synthetiser(texte: str) -> str:
93
+ """Texte → fichier wav (voix française MMS)."""
94
+ texte = nettoyer_pour_tts(texte)
95
+ inputs = tts_tokenizer(texte, return_tensors="pt")
96
+ with torch.no_grad():
97
+ waveform = tts_model(**inputs).waveform
98
+ audio = waveform.squeeze().cpu().numpy()
99
+ audio = (audio / np.max(np.abs(audio)) * 32767).astype(np.int16)
100
+
101
+ out_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
102
+ wavfile.write(out_path, tts_model.config.sampling_rate, audio)
103
+ return out_path
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Logique de l'interface
108
+ # ---------------------------------------------------------------------------
109
+ def traiter(audio_path, question_texte, historique):
110
+ historique = historique or []
111
+
112
+ # 1. Récupérer la question (vocale prioritaire, sinon texte)
113
+ if audio_path:
114
+ question = transcrire(audio_path)
115
+ elif question_texte and question_texte.strip():
116
+ question = question_texte.strip()
117
+ else:
118
+ return None, "", "Envoie une vocale ou écris ta question 🙂", historique
119
+
120
+ if not question:
121
+ return None, "", "Je n'ai pas bien entendu, réessaie en parlant plus fort.", historique
122
+
123
+ # 2. Réponse du répétiteur
124
+ try:
125
+ explication = repondre(question, historique)
126
+ except Exception as e:
127
+ return None, question, f"Erreur LLM (vérifie le secret HF_TOKEN) : {e}", historique
128
+
129
+ # 3. Synthèse vocale
130
+ try:
131
+ audio_reponse = synthetiser(explication)
132
+ except Exception:
133
+ audio_reponse = None # on affiche au moins le texte
134
+
135
+ historique.append((question, explication))
136
+ return audio_reponse, question, explication, historique
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Interface Gradio
141
+ # ---------------------------------------------------------------------------
142
+ with gr.Blocks(title="Répétiteur Vocal — POC") as demo:
143
+ gr.Markdown(
144
+ """
145
+ # 🎓 Répétiteur Vocal — Maths Terminale D (POC)
146
+ Pose ta question **en vocale** (ou par écrit) et reçois une explication
147
+ **parlée**, comme avec un répétiteur.
148
+ *Modèles 100% open-source : Whisper · Qwen2.5 · MMS-TTS.*
149
+ """
150
+ )
151
+
152
+ etat = gr.State([])
153
+
154
+ with gr.Row():
155
+ with gr.Column():
156
+ micro = gr.Audio(
157
+ sources=["microphone", "upload"],
158
+ type="filepath",
159
+ label="🎤 Ta question en vocale",
160
+ )
161
+ texte = gr.Textbox(
162
+ label="…ou écris ta question",
163
+ placeholder="Ex : je n'ai pas compris les suites arithmétiques",
164
+ )
165
+ bouton = gr.Button("Envoyer au répétiteur", variant="primary")
166
+
167
+ with gr.Column():
168
+ sortie_audio = gr.Audio(label="🔊 Réponse du répétiteur", autoplay=True)
169
+ transcription = gr.Textbox(label="Ta question (transcrite)")
170
+ reponse_texte = gr.Textbox(label="Explication (texte)", lines=6)
171
+
172
+ bouton.click(
173
+ traiter,
174
+ inputs=[micro, texte, etat],
175
+ outputs=[sortie_audio, transcription, reponse_texte, etat],
176
+ )
177
+
178
+ if __name__ == "__main__":
179
+ demo.launch()