Randriamiandry commited on
Commit
aacfb92
·
1 Parent(s): bc155b3

Ajout du clonage vocal et interface complète

Browse files
Files changed (5) hide show
  1. --output +0 -0
  2. -H +0 -0
  3. -d +0 -0
  4. app.py +122 -25
  5. static/index.html +381 -0
--output ADDED
File without changes
-H ADDED
File without changes
-d ADDED
File without changes
app.py CHANGED
@@ -1,54 +1,151 @@
1
  import io
2
- from fastapi import FastAPI, HTTPException
3
- from fastapi.responses import Response
 
 
 
4
  from pydantic import BaseModel
5
  from pocket_tts import TTSModel
6
  import scipy.io.wavfile
7
  import numpy as np
 
8
 
9
- # --- Chargement du modèle (une seule fois au démarrage) ---
 
 
 
 
 
10
  print("Chargement du modèle Pocket TTS...")
11
  tts_model = TTSModel.load_model()
12
-
13
- # Sélection d'une voix pré-définie. La liste est disponible ici :
14
- # https://huggingface.co/kyutai/tts-voices
15
- # Tu peux aussi utiliser un fichier audio local (.wav)
16
- VOICE_NAME = "alba" # Choisis parmi 'alba', 'gentle', 'friendly', etc.
17
- voice_state = tts_model.get_state_for_audio_prompt(VOICE_NAME)
18
  print("Modèle chargé et prêt !")
19
- # --- Fin du chargement ---
20
 
21
- app = FastAPI(title="API Pocket TTS",
22
- description="Une API simple pour générer de la parole à partir de texte avec Pocket TTS.",
23
- version="1.0")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- # Modèle de données pour la requête POST
 
 
 
 
 
 
 
 
 
 
26
  class TTSRequest(BaseModel):
27
  text: str
 
28
 
29
- @app.get("/")
30
- def root():
31
- return {"message": "Bienvenue sur l'API Pocket TTS. Utilisez l'endpoint /tts pour générer de l'audio."}
32
 
33
  @app.post("/tts")
34
  async def generate_speech(request: TTSRequest):
35
  """
36
- Reçoit du texte et retourne un fichier audio WAV.
37
  """
38
  text = request.text
39
  if not text:
40
  raise HTTPException(status_code=400, detail="Le texte ne peut pas être vide.")
41
 
42
- print(f"Génération de l'audio pour : {text[:50]}...")
 
 
 
43
 
44
- # Générer l'audio : le résultat est un tenseur PyTorch
45
- audio_tensor = tts_model.generate_audio(voice_state, text)
46
 
47
- # Convertir le tenseur en tableau NumPy et le sauvegarder dans un buffer en mémoire
 
 
 
 
 
 
 
 
48
  audio_numpy = audio_tensor.numpy().astype(np.int16)
49
  buffer = io.BytesIO()
50
  scipy.io.wavfile.write(buffer, tts_model.sample_rate, audio_numpy)
51
- buffer.seek(0) # Retourner au début du buffer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- # Retourner le fichier audio WAV
54
- return Response(content=buffer.read(), media_type="audio/wav")
 
 
 
1
  import io
2
+ import os
3
+ import tempfile
4
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form
5
+ from fastapi.responses import Response, FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
  from pydantic import BaseModel
8
  from pocket_tts import TTSModel
9
  import scipy.io.wavfile
10
  import numpy as np
11
+ import time
12
 
13
+ # Créer l'application FastAPI
14
+ app = FastAPI(title="API Pocket TTS",
15
+ description="Une API simple pour générer de la parole à partir de texte avec Pocket TTS, avec clonage de voix.",
16
+ version="2.0")
17
+
18
+ # Charger le modèle (une seule fois au démarrage)
19
  print("Chargement du modèle Pocket TTS...")
20
  tts_model = TTSModel.load_model()
21
+ print(f"Taux d'échantillonnage : {tts_model.sample_rate} Hz")
 
 
 
 
 
22
  print("Modèle chargé et prêt !")
 
23
 
24
+ # Dictionnaire pour stocker les états de voix (intégrées + clonées)
25
+ voices = {}
26
+
27
+ # Charger les voix intégrées
28
+ builtin_voices = ["alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma"]
29
+ for voice_name in builtin_voices:
30
+ try:
31
+ voices[voice_name] = tts_model.get_state_for_audio_prompt(voice_name)
32
+ print(f"Voix intégrée chargée : {voice_name}")
33
+ except Exception as e:
34
+ print(f"Erreur lors du chargement de la voix {voice_name} : {e}")
35
+
36
+ print(f"{len(voices)} voix intégrées chargées. Prêtes pour la génération.")
37
+
38
+
39
+ # Servir les fichiers statiques (HTML, CSS, JS)
40
+ app.mount("/static", StaticFiles(directory="static"), name="static")
41
+
42
 
43
+ @app.get("/")
44
+ async def root():
45
+ return {"message": "Bienvenue sur l'API Pocket TTS. Utilisez l'interface /demo ou l'endpoint /tts pour générer de l'audio."}
46
+
47
+
48
+ @app.get("/demo")
49
+ async def demo():
50
+ return FileResponse("static/index.html")
51
+
52
+
53
+ # Modèle pour les requêtes JSON
54
  class TTSRequest(BaseModel):
55
  text: str
56
+ voice_name: str = "alba"
57
 
 
 
 
58
 
59
  @app.post("/tts")
60
  async def generate_speech(request: TTSRequest):
61
  """
62
+ Reçoit du texte et un nom de voix, retourne un fichier audio WAV.
63
  """
64
  text = request.text
65
  if not text:
66
  raise HTTPException(status_code=400, detail="Le texte ne peut pas être vide.")
67
 
68
+ voice_name = request.voice_name
69
+ if voice_name not in voices:
70
+ available = ", ".join(voices.keys())
71
+ raise HTTPException(status_code=400, detail=f"Voix '{voice_name}' inconnue. Voix disponibles : {available}")
72
 
73
+ print(f"Génération avec la voix '{voice_name}' pour : {text[:50]}...")
 
74
 
75
+ # Générer l'audio
76
+ try:
77
+ # Pour une voix clonée, voice_state est déjà le résultat de get_state_for_audio_prompt
78
+ voice_state = voices[voice_name]
79
+ audio_tensor = tts_model.generate_audio(voice_state, text)
80
+ except Exception as e:
81
+ raise HTTPException(status_code=500, detail=f"Erreur de génération audio : {str(e)}")
82
+
83
+ # Convertir en numpy et sauvegarder dans un buffer
84
  audio_numpy = audio_tensor.numpy().astype(np.int16)
85
  buffer = io.BytesIO()
86
  scipy.io.wavfile.write(buffer, tts_model.sample_rate, audio_numpy)
87
+ buffer.seek(0)
88
+
89
+ # Retourner le fichier audio
90
+ return Response(content=buffer.read(), media_type="audio/wav", headers={"Content-Disposition": "attachment; filename=speech.wav"})
91
+
92
+
93
+ @app.post("/clone-voice")
94
+ async def clone_voice(audio_file: UploadFile = File(...), voice_name: str = Form(...)):
95
+ """
96
+ Endpoint pour cloner une voix à partir d'un fichier audio.
97
+ """
98
+ # Vérifier que le nom de la voix n'existe pas déjà
99
+ if voice_name in voices:
100
+ raise HTTPException(status_code=400, detail=f"Une voix nommée '{voice_name}' existe déjà. Veuillez choisir un autre nom.")
101
+
102
+ # Vérifier le type de fichier
103
+ if not audio_file.filename.endswith(('.wav', '.mp3', '.m4a')):
104
+ raise HTTPException(status_code=400, detail="Format de fichier non supporté. Utilisez WAV, MP3 ou M4A.")
105
+
106
+ # Sauvegarder le fichier temporairement
107
+ try:
108
+ # Créer un fichier temporaire
109
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
110
+ content = await audio_file.read()
111
+ tmp_file.write(content)
112
+ tmp_path = tmp_file.name
113
+
114
+ # Si ce n'est pas déjà un WAV, convertir avec pydub (optionnel)
115
+ if not audio_file.filename.endswith('.wav'):
116
+ # Ici, tu pourrais ajouter une conversion avec pydub si nécessaire
117
+ # Pour simplifier, on va supposer que le modèle peut gérer les MP3
118
+ pass
119
+
120
+ # Cloner la voix
121
+ voice_state = tts_model.get_state_for_audio_prompt(tmp_path)
122
+ voices[voice_name] = voice_state
123
+
124
+ # Nettoyer
125
+ os.unlink(tmp_path)
126
+
127
+ print(f"Voix clonée '{voice_name}' ajoutée avec succès. Nombre total de voix : {len(voices)}")
128
+ return {"message": f"Voix '{voice_name}' clonée avec succès.", "voice_name": voice_name, "total_voices": len(voices)}
129
+
130
+ except Exception as e:
131
+ # Nettoyer en cas d'erreur
132
+ if 'tmp_path' in locals():
133
+ try:
134
+ os.unlink(tmp_path)
135
+ except:
136
+ pass
137
+ raise HTTPException(status_code=500, detail=f"Erreur lors du clonage : {str(e)}")
138
+
139
+
140
+ @app.get("/voices")
141
+ async def list_voices():
142
+ """
143
+ Retourne la liste des voix disponibles.
144
+ """
145
+ return {"voices": list(voices.keys())}
146
+
147
 
148
+ # Démarrer le serveur si exécuté directement
149
+ if __name__ == "__main__":
150
+ import uvicorn
151
+ uvicorn.run(app, host="0.0.0.0", port=7860)
static/index.html ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Pocket TTS - Synthèse vocale et clonage de voix</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ min-height: 100vh;
18
+ padding: 20px;
19
+ }
20
+
21
+ .container {
22
+ max-width: 800px;
23
+ margin: 0 auto;
24
+ }
25
+
26
+ .card {
27
+ background: white;
28
+ border-radius: 20px;
29
+ padding: 30px;
30
+ margin-bottom: 20px;
31
+ box-shadow: 0 10px 40px rgba(0,0,0,0.1);
32
+ }
33
+
34
+ h1 {
35
+ color: #667eea;
36
+ margin-bottom: 10px;
37
+ }
38
+
39
+ h2 {
40
+ color: #764ba2;
41
+ margin: 20px 0 15px 0;
42
+ font-size: 1.3em;
43
+ }
44
+
45
+ textarea, select, input {
46
+ width: 100%;
47
+ padding: 12px;
48
+ margin: 10px 0;
49
+ border: 2px solid #e0e0e0;
50
+ border-radius: 10px;
51
+ font-size: 16px;
52
+ transition: all 0.3s;
53
+ }
54
+
55
+ textarea:focus, select:focus, input:focus {
56
+ outline: none;
57
+ border-color: #667eea;
58
+ }
59
+
60
+ button {
61
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
62
+ color: white;
63
+ border: none;
64
+ padding: 12px 30px;
65
+ border-radius: 25px;
66
+ font-size: 16px;
67
+ cursor: pointer;
68
+ transition: transform 0.2s;
69
+ margin-top: 10px;
70
+ }
71
+
72
+ button:hover {
73
+ transform: translateY(-2px);
74
+ }
75
+
76
+ button:active {
77
+ transform: translateY(0);
78
+ }
79
+
80
+ audio {
81
+ width: 100%;
82
+ margin-top: 20px;
83
+ }
84
+
85
+ .status {
86
+ margin-top: 15px;
87
+ padding: 10px;
88
+ border-radius: 10px;
89
+ font-size: 14px;
90
+ }
91
+
92
+ .status.success {
93
+ background-color: #d4edda;
94
+ color: #155724;
95
+ }
96
+
97
+ .status.error {
98
+ background-color: #f8d7da;
99
+ color: #721c24;
100
+ }
101
+
102
+ .status.info {
103
+ background-color: #d1ecf1;
104
+ color: #0c5460;
105
+ }
106
+
107
+ .voice-list {
108
+ display: flex;
109
+ flex-wrap: wrap;
110
+ gap: 10px;
111
+ margin: 15px 0;
112
+ }
113
+
114
+ .voice-tag {
115
+ background: #f0f0f0;
116
+ padding: 5px 12px;
117
+ border-radius: 20px;
118
+ font-size: 14px;
119
+ cursor: pointer;
120
+ transition: all 0.2s;
121
+ }
122
+
123
+ .voice-tag:hover {
124
+ background: #667eea;
125
+ color: white;
126
+ }
127
+
128
+ .file-upload {
129
+ border: 2px dashed #667eea;
130
+ background: #f8f9ff;
131
+ text-align: center;
132
+ padding: 20px;
133
+ border-radius: 10px;
134
+ cursor: pointer;
135
+ }
136
+
137
+ .file-upload input {
138
+ display: none;
139
+ }
140
+
141
+ hr {
142
+ margin: 20px 0;
143
+ border: none;
144
+ border-top: 2px solid #e0e0e0;
145
+ }
146
+ </style>
147
+ </head>
148
+ <body>
149
+ <div class="container">
150
+ <div class="card">
151
+ <h1>🎤 Pocket TTS Studio</h1>
152
+ <p>Synthèse vocale et clonage de voix - Interface complète</p>
153
+ </div>
154
+
155
+ <!-- Section Synthèse vocale -->
156
+ <div class="card">
157
+ <h2>📝 1. Synthèse vocale</h2>
158
+ <textarea id="text" rows="4" placeholder="Saisissez le texte à synthétiser...">Bonjour, ceci est un test de mon API vocale avec clonage de voix.</textarea>
159
+
160
+ <label for="voice">Choisir une voix :</label>
161
+ <select id="voice">
162
+ <option value="">Chargement des voix...</option>
163
+ </select>
164
+
165
+ <button id="generateBtn">🎵 Générer l'audio</button>
166
+ <audio id="audioPlayer" controls style="display: none;"></audio>
167
+ <div id="ttsStatus" class="status"></div>
168
+ </div>
169
+
170
+ <!-- Section Clonage de voix -->
171
+ <div class="card">
172
+ <h2>🎙️ 2. Cloner une nouvelle voix</h2>
173
+ <div class="file-upload" id="uploadArea">
174
+ <input type="file" id="audioFile" accept=".wav,.mp3,.m4a">
175
+ <p>📁 Cliquez ou glissez un fichier audio (WAV, MP3, M4A)</p>
176
+ <p style="font-size: 12px; color: #666;">5-15 secondes pour un meilleur résultat</p>
177
+ </div>
178
+
179
+ <input type="text" id="newVoiceName" placeholder="Nom de la nouvelle voix (ex: ma_voix)" style="margin-top: 15px;">
180
+ <button id="cloneBtn">✨ Cloner cette voix</button>
181
+ <div id="cloneStatus" class="status"></div>
182
+ </div>
183
+
184
+ <!-- Section Liste des voix -->
185
+ <div class="card">
186
+ <h2>🎧 3. Voix disponibles</h2>
187
+ <div id="voiceList" class="voice-list">
188
+ <p>Chargement des voix...</p>
189
+ </div>
190
+ <button id="refreshVoicesBtn" style="background: #6c757d;">🔄 Actualiser la liste</button>
191
+ </div>
192
+ </div>
193
+
194
+ <script>
195
+ let currentAudioBlob = null;
196
+
197
+ // Fonction pour charger la liste des voix
198
+ async function loadVoices() {
199
+ try {
200
+ const response = await fetch('/voices');
201
+ if (response.ok) {
202
+ const data = await response.json();
203
+ const voiceSelect = document.getElementById('voice');
204
+ const voiceListDiv = document.getElementById('voiceList');
205
+
206
+ // Mettre à jour le select
207
+ voiceSelect.innerHTML = '';
208
+ data.voices.forEach(voice => {
209
+ const option = document.createElement('option');
210
+ option.value = voice;
211
+ option.textContent = voice;
212
+ voiceSelect.appendChild(option);
213
+ });
214
+
215
+ // Mettre à jour l'affichage des tags
216
+ voiceListDiv.innerHTML = '';
217
+ data.voices.forEach(voice => {
218
+ const tag = document.createElement('div');
219
+ tag.className = 'voice-tag';
220
+ tag.textContent = voice;
221
+ tag.onclick = () => {
222
+ document.getElementById('voice').value = voice;
223
+ };
224
+ voiceListDiv.appendChild(tag);
225
+ });
226
+
227
+ if (data.voices.length === 0) {
228
+ voiceListDiv.innerHTML = '<p>Aucune voix disponible.</p>';
229
+ }
230
+ }
231
+ } catch (error) {
232
+ console.error('Erreur chargement voix:', error);
233
+ }
234
+ }
235
+
236
+ // Génération audio
237
+ document.getElementById('generateBtn').onclick = async () => {
238
+ const text = document.getElementById('text').value.trim();
239
+ const voiceName = document.getElementById('voice').value;
240
+ const statusDiv = document.getElementById('ttsStatus');
241
+ const audioPlayer = document.getElementById('audioPlayer');
242
+
243
+ if (!text) {
244
+ statusDiv.className = 'status error';
245
+ statusDiv.textContent = 'Veuillez saisir du texte.';
246
+ return;
247
+ }
248
+
249
+ if (!voiceName) {
250
+ statusDiv.className = 'status error';
251
+ statusDiv.textContent = 'Veuillez sélectionner une voix.';
252
+ return;
253
+ }
254
+
255
+ statusDiv.className = 'status info';
256
+ statusDiv.textContent = 'Génération en cours...';
257
+ audioPlayer.style.display = 'none';
258
+
259
+ try {
260
+ const response = await fetch('/tts', {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ text: text, voice_name: voiceName })
264
+ });
265
+
266
+ if (response.ok) {
267
+ currentAudioBlob = await response.blob();
268
+ const audioUrl = URL.createObjectURL(currentAudioBlob);
269
+ audioPlayer.src = audioUrl;
270
+ audioPlayer.style.display = 'block';
271
+ audioPlayer.play();
272
+ statusDiv.className = 'status success';
273
+ statusDiv.textContent = '✅ Audio généré avec succès !';
274
+ } else {
275
+ const errorText = await response.text();
276
+ statusDiv.className = 'status error';
277
+ statusDiv.textContent = `Erreur : ${errorText}`;
278
+ }
279
+ } catch (error) {
280
+ statusDiv.className = 'status error';
281
+ statusDiv.textContent = `Erreur réseau : ${error.message}`;
282
+ }
283
+ };
284
+
285
+ // Clonage de voix
286
+ document.getElementById('cloneBtn').onclick = async () => {
287
+ const fileInput = document.getElementById('audioFile');
288
+ const voiceName = document.getElementById('newVoiceName').value.trim();
289
+ const statusDiv = document.getElementById('cloneStatus');
290
+
291
+ if (!fileInput.files || fileInput.files.length === 0) {
292
+ statusDiv.className = 'status error';
293
+ statusDiv.textContent = 'Veuillez sélectionner un fichier audio.';
294
+ return;
295
+ }
296
+
297
+ if (!voiceName) {
298
+ statusDiv.className = 'status error';
299
+ statusDiv.textContent = 'Veuillez donner un nom à cette voix.';
300
+ return;
301
+ }
302
+
303
+ // Vérification simple du nom (pas d'espaces, caractères spéciaux)
304
+ if (!/^[a-zA-Z0-9_-]+$/.test(voiceName)) {
305
+ statusDiv.className = 'status error';
306
+ statusDiv.textContent = 'Le nom ne doit contenir que des lettres, chiffres, tirets ou underscores.';
307
+ return;
308
+ }
309
+
310
+ const formData = new FormData();
311
+ formData.append('audio_file', fileInput.files[0]);
312
+ formData.append('voice_name', voiceName);
313
+
314
+ statusDiv.className = 'status info';
315
+ statusDiv.textContent = 'Clonage en cours (cela peut prendre quelques secondes)...';
316
+
317
+ try {
318
+ const response = await fetch('/clone-voice', {
319
+ method: 'POST',
320
+ body: formData
321
+ });
322
+
323
+ if (response.ok) {
324
+ const result = await response.json();
325
+ statusDiv.className = 'status success';
326
+ statusDiv.textContent = `✅ Voix "${voiceName}" clonée avec succès ! (Total: ${result.total_voices} voix)`;
327
+ // Réinitialiser les champs
328
+ fileInput.value = '';
329
+ document.getElementById('newVoiceName').value = '';
330
+ // Recharger la liste des voix
331
+ await loadVoices();
332
+ } else {
333
+ const errorText = await response.text();
334
+ statusDiv.className = 'status error';
335
+ statusDiv.textContent = `Erreur : ${errorText}`;
336
+ }
337
+ } catch (error) {
338
+ statusDiv.className = 'status error';
339
+ statusDiv.textContent = `Erreur réseau : ${error.message}`;
340
+ }
341
+ };
342
+
343
+ // Rafraîchir la liste des voix
344
+ document.getElementById('refreshVoicesBtn').onclick = loadVoices;
345
+
346
+ // Upload zone - drag & drop
347
+ const uploadArea = document.getElementById('uploadArea');
348
+ const fileInput = document.getElementById('audioFile');
349
+
350
+ uploadArea.onclick = () => fileInput.click();
351
+
352
+ uploadArea.ondragover = (e) => {
353
+ e.preventDefault();
354
+ uploadArea.style.borderColor = '#764ba2';
355
+ uploadArea.style.background = '#f0e6ff';
356
+ };
357
+
358
+ uploadArea.ondragleave = (e) => {
359
+ e.preventDefault();
360
+ uploadArea.style.borderColor = '#667eea';
361
+ uploadArea.style.background = '#f8f9ff';
362
+ };
363
+
364
+ uploadArea.ondrop = (e) => {
365
+ e.preventDefault();
366
+ uploadArea.style.borderColor = '#667eea';
367
+ uploadArea.style.background = '#f8f9ff';
368
+ const files = e.dataTransfer.files;
369
+ if (files.length > 0 && (files[0].type.includes('audio'))) {
370
+ fileInput.files = files;
371
+ statusDiv.textContent = `Fichier sélectionné : ${files[0].name}`;
372
+ } else {
373
+ alert('Veuillez déposer un fichier audio valide.');
374
+ }
375
+ };
376
+
377
+ // Initialiser
378
+ loadVoices();
379
+ </script>
380
+ </body>
381
+ </html>