Randriamiandry commited on
Commit
bb48ecb
·
1 Parent(s): a4adc98

Migration vers Kokoro-82M (rapide)

Browse files
Files changed (4) hide show
  1. Dockerfile +1 -1
  2. app.py +63 -73
  3. requirements.txt +3 -4
  4. static/index.html +66 -398
Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
  FROM python:3.11-slim
2
- RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
3
  WORKDIR /app
 
4
  COPY requirements.txt .
5
  RUN pip install --no-cache-dir -r requirements.txt
6
  COPY . .
 
1
  FROM python:3.11-slim
 
2
  WORKDIR /app
3
+ RUN apt-get update && apt-get install -y ffmpeg
4
  COPY requirements.txt .
5
  RUN pip install --no-cache-dir -r requirements.txt
6
  COPY . .
app.py CHANGED
@@ -1,55 +1,47 @@
1
  import io
2
  import os
3
  import tempfile
4
- import time
5
  import numpy as np
6
  from fastapi import FastAPI, HTTPException, UploadFile, File, Form
7
- from fastapi.responses import StreamingResponse, FileResponse
8
  from fastapi.staticfiles import StaticFiles
9
  from pydantic import BaseModel
10
- import scipy.io.wavfile
11
- from pydub import AudioSegment
12
 
13
- # ---------- Configuration ----------
14
- HF_TOKEN = os.environ.get("HF_TOKEN")
15
- print("🚀 Démarrage API Pocket TTS (streaming + clonage)")
16
 
17
- if HF_TOKEN:
18
- from huggingface_hub import login
19
- login(token=HF_TOKEN)
20
- print("✅ Authentifié HF")
21
 
22
- # ---------- Chargement du modèle (API correcte) ----------
23
- from pocket_tts import TTSModel
24
 
25
- print("📥 Chargement du modèle Pocket TTS...")
26
- model = TTSModel.load_model()
27
- print(f"✅ Modèle chargé | Sample rate: {model.sample_rate} Hz")
28
 
29
- # ---------- Dictionnaire des voix ----------
30
- voices = {}
31
 
32
- # Liste des voix intégrées (disponibles dans le modèle)
33
- builtin_voices = ["alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma"]
34
- for v in builtin_voices:
35
- try:
36
- # Pour les voix intégrées, on passe simplement le nom
37
- voices[v] = model.get_state_for_audio_prompt(v)
38
- print(f"✓ Voix intégrée: {v}")
39
- except Exception as e:
40
- print(f"✗ Erreur chargement {v}: {e}")
41
 
42
- print(f"{len(voices)} voix disponibles (intégrées).")
43
-
44
- # ---------- Application FastAPI ----------
45
- app = FastAPI(title="Pocket TTS API", version="3.1")
46
-
47
- # Dossier statique pour l'interface
48
  app.mount("/static", StaticFiles(directory="static"), name="static")
49
 
50
  @app.get("/")
51
  async def root():
52
- return {"status": "ok", "model": "Pocket TTS", "endpoints": ["/tts", "/clone-voice", "/voices"]}
 
 
 
 
 
53
 
54
  @app.get("/demo")
55
  async def demo():
@@ -57,12 +49,9 @@ async def demo():
57
 
58
  class TTSRequest(BaseModel):
59
  text: str
60
- voice_name: str = "alba"
61
  speed: float = 1.0
62
-
63
- def generate_wav_chunks(wav_bytes: bytes, chunk_size=8192):
64
- for i in range(0, len(wav_bytes), chunk_size):
65
- yield wav_bytes[i:i+chunk_size]
66
 
67
  @app.post("/tts")
68
  async def generate_speech(request: TTSRequest):
@@ -70,39 +59,41 @@ async def generate_speech(request: TTSRequest):
70
  text = request.text.strip()
71
  if not text:
72
  raise HTTPException(400, "Texte vide")
73
- if request.voice_name not in voices:
74
- available = ", ".join(voices.keys())
75
- raise HTTPException(400, f"Voix inconnue. Disponibles: {available}")
76
 
77
- try:
78
- voice_state = voices[request.voice_name]
79
- # generate_audio attend (state, text) sans paramètre speed dans cette version
80
- # On ignore speed si non supporté
81
- audio_tensor = model.generate_audio(voice_state, text)
82
- audio_np = audio_tensor.numpy()
83
- # Conversion float -> int16
84
- if audio_np.dtype in (np.float32, np.float64):
85
- audio_np = np.clip(audio_np, -1.0, 1.0)
86
- audio_np = (audio_np * 32767).astype(np.int16)
87
- else:
88
- audio_np = audio_np.astype(np.int16)
89
 
90
- buffer = io.BytesIO()
91
- scipy.io.wavfile.write(buffer, model.sample_rate, audio_np)
92
- wav_bytes = buffer.getvalue()
93
- print(f"✅ Généré en {time.time()-start:.2f}s | Voix: {request.voice_name}")
94
- return StreamingResponse(
95
- generate_wav_chunks(wav_bytes),
96
- media_type="audio/wav",
97
- headers={"Content-Disposition": f"attachment; filename=speech_{request.voice_name}.wav"}
98
  )
 
 
 
 
 
 
 
99
  except Exception as e:
100
  raise HTTPException(500, f"Erreur TTS: {str(e)}")
101
 
102
  @app.post("/clone-voice")
103
  async def clone_voice(audio_file: UploadFile = File(...), voice_name: str = Form(...)):
104
- if voice_name in voices:
105
- raise HTTPException(400, f"La voix '{voice_name}' existe déjà.")
 
106
  # Sauvegarde temporaire
107
  with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
108
  content = await audio_file.read()
@@ -110,14 +101,10 @@ async def clone_voice(audio_file: UploadFile = File(...), voice_name: str = Form
110
  tmp_path = tmp.name
111
 
112
  try:
113
- # Conversion en WAV standard (mono 24kHz) via pydub
114
- audio = AudioSegment.from_file(tmp_path)
115
- audio = audio.set_channels(1).set_frame_rate(24000)
116
- audio.export(tmp_path, format="wav")
117
- # Clonage
118
- voice_state = model.get_state_for_audio_prompt(tmp_path)
119
- voices[voice_name] = voice_state
120
- return {"message": f"Voix '{voice_name}' clonée", "voice_name": voice_name, "total_voices": len(voices)}
121
  except Exception as e:
122
  raise HTTPException(500, f"Erreur clonage: {str(e)}")
123
  finally:
@@ -126,7 +113,10 @@ async def clone_voice(audio_file: UploadFile = File(...), voice_name: str = Form
126
 
127
  @app.get("/voices")
128
  async def list_voices():
129
- return {"voices": list(voices.keys())}
 
 
 
130
 
131
  if __name__ == "__main__":
132
  import uvicorn
 
1
  import io
2
  import os
3
  import tempfile
 
4
  import numpy as np
5
  from fastapi import FastAPI, HTTPException, UploadFile, File, Form
6
+ from fastapi.responses import Response, FileResponse
7
  from fastapi.staticfiles import StaticFiles
8
  from pydantic import BaseModel
9
+ import soundfile as sf
10
+ import time
11
 
12
+ print("🚀 Démarrage API Kokoro-82M (ONNX rapide)")
 
 
13
 
14
+ # -------------------------------------------------------------
15
+ # Chargement du modèle Kokoro (version ONNX quantifiée)
16
+ # -------------------------------------------------------------
17
+ from kokoro_onnx import KokoroONNX
18
 
19
+ # Modèle recommandé (quantifié automatiquement)
20
+ MODEL_NAME = "onnx-community/Kokoro-82M-v1.0-ONNX"
21
 
22
+ # Téléchargement et chargement
23
+ tts = KokoroONNX.from_pretrained(MODEL_NAME, device="cpu")
24
+ print(f"✅ Kokoro-82M chargé | Voices disponibles: {list(tts.voices.keys())}")
25
 
26
+ # Dictionnaire pour les voix clonées (zero-shot)
27
+ cloned_voices = {}
28
 
29
+ # -------------------------------------------------------------
30
+ # Application FastAPI
31
+ # -------------------------------------------------------------
32
+ app = FastAPI(title="Kokoro TTS API", version="2.0")
 
 
 
 
 
33
 
34
+ # Servir l'interface HTML (optionnel)
 
 
 
 
 
35
  app.mount("/static", StaticFiles(directory="static"), name="static")
36
 
37
  @app.get("/")
38
  async def root():
39
+ return {
40
+ "status": "ok",
41
+ "model": "Kokoro-82M (ONNX quantifié)",
42
+ "available_voices": list(tts.voices.keys()),
43
+ "endpoints": ["/tts", "/clone-voice", "/voices"]
44
+ }
45
 
46
  @app.get("/demo")
47
  async def demo():
 
49
 
50
  class TTSRequest(BaseModel):
51
  text: str
52
+ voice: str = "af_heart" # voix par défaut (anglais)
53
  speed: float = 1.0
54
+ lang: str = "fr" # "fr" ou "en"
 
 
 
55
 
56
  @app.post("/tts")
57
  async def generate_speech(request: TTSRequest):
 
59
  text = request.text.strip()
60
  if not text:
61
  raise HTTPException(400, "Texte vide")
 
 
 
62
 
63
+ # Choix de la voix : clonée ou intégrée
64
+ if request.voice in cloned_voices:
65
+ voice = cloned_voices[request.voice]
66
+ elif request.voice in tts.voices:
67
+ voice = request.voice
68
+ else:
69
+ # Fallback sur une voix par défaut selon la langue
70
+ voice = "af_heart" if request.lang == "en" else "fr_001"
71
+ if voice not in tts.voices:
72
+ voice = list(tts.voices.keys())[0]
 
 
73
 
74
+ try:
75
+ # Génération audio
76
+ audio, sample_rate = tts.tts(
77
+ text=text,
78
+ voice=voice,
79
+ speed=request.speed,
80
+ lang=request.lang
 
81
  )
82
+ # audio est un numpy array (float32)
83
+ buffer = io.BytesIO()
84
+ sf.write(buffer, audio, sample_rate, format="WAV")
85
+ buffer.seek(0)
86
+
87
+ print(f"✅ Généré en {time.time()-start:.2f}s | voix={voice} | langue={request.lang}")
88
+ return Response(content=buffer.read(), media_type="audio/wav")
89
  except Exception as e:
90
  raise HTTPException(500, f"Erreur TTS: {str(e)}")
91
 
92
  @app.post("/clone-voice")
93
  async def clone_voice(audio_file: UploadFile = File(...), voice_name: str = Form(...)):
94
+ if voice_name in cloned_voices or voice_name in tts.voices:
95
+ raise HTTPException(400, f"La voix '{voice_name}' existe déjà (intégrée ou clonée).")
96
+
97
  # Sauvegarde temporaire
98
  with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
99
  content = await audio_file.read()
 
101
  tmp_path = tmp.name
102
 
103
  try:
104
+ # Clonage zero-shot (Kokoro-ONNX le permet)
105
+ cloned_voice = tts.clone_voice(tmp_path)
106
+ cloned_voices[voice_name] = cloned_voice
107
+ return {"message": f"Voix '{voice_name}' clonée", "voice_name": voice_name, "total_cloned": len(cloned_voices)}
 
 
 
 
108
  except Exception as e:
109
  raise HTTPException(500, f"Erreur clonage: {str(e)}")
110
  finally:
 
113
 
114
  @app.get("/voices")
115
  async def list_voices():
116
+ return {
117
+ "builtin": list(tts.voices.keys()),
118
+ "cloned": list(cloned_voices.keys())
119
+ }
120
 
121
  if __name__ == "__main__":
122
  import uvicorn
requirements.txt CHANGED
@@ -1,7 +1,6 @@
1
  fastapi
2
  uvicorn[standard]
3
- pocket-tts
4
- scipy
5
- pydub
6
- huggingface-hub
7
  numpy
 
 
 
1
  fastapi
2
  uvicorn[standard]
3
+ soundfile
 
 
 
4
  numpy
5
+ kokoro-onnx>=0.3.0
6
+ huggingface-hub
static/index.html CHANGED
@@ -1,409 +1,77 @@
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 Studio – Streaming</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, #1a1a2e 0%, #16213e 100%);
17
- min-height: 100vh;
18
- padding: 20px;
19
- color: #eee;
20
- }
21
-
22
- .container {
23
- max-width: 900px;
24
- margin: 0 auto;
25
- }
26
-
27
- .card {
28
- background: rgba(255,255,255,0.1);
29
- backdrop-filter: blur(10px);
30
- border-radius: 20px;
31
- padding: 25px;
32
- margin-bottom: 20px;
33
- box-shadow: 0 8px 32px rgba(0,0,0,0.2);
34
- border: 1px solid rgba(255,255,255,0.2);
35
- }
36
-
37
- h1, h2 {
38
- margin-bottom: 15px;
39
- color: #fff;
40
- }
41
-
42
- textarea, select, input {
43
- width: 100%;
44
- padding: 12px;
45
- margin: 10px 0;
46
- border: none;
47
- border-radius: 10px;
48
- font-size: 16px;
49
- background: rgba(255,255,255,0.9);
50
- transition: all 0.3s;
51
- }
52
-
53
- textarea:focus, select:focus, input:focus {
54
- outline: none;
55
- background: white;
56
- box-shadow: 0 0 0 2px #00a8ff;
57
- }
58
-
59
- button {
60
- background: linear-gradient(135deg, #00a8ff 0%, #0077ff 100%);
61
- color: white;
62
- border: none;
63
- padding: 12px 24px;
64
- border-radius: 30px;
65
- font-size: 16px;
66
- cursor: pointer;
67
- transition: transform 0.2s, box-shadow 0.2s;
68
- margin-top: 10px;
69
- margin-right: 10px;
70
- }
71
-
72
- button:hover {
73
- transform: translateY(-2px);
74
- box-shadow: 0 10px 20px rgba(0,0,0,0.2);
75
- }
76
-
77
- button:active {
78
- transform: translateY(0);
79
- }
80
-
81
- .voice-list {
82
- display: flex;
83
- flex-wrap: wrap;
84
- gap: 10px;
85
- margin: 15px 0;
86
- }
87
-
88
- .voice-tag {
89
- background: rgba(255,255,255,0.2);
90
- padding: 6px 14px;
91
- border-radius: 30px;
92
- font-size: 14px;
93
- cursor: pointer;
94
- transition: all 0.2s;
95
- }
96
-
97
- .voice-tag:hover, .voice-tag.active {
98
- background: #00a8ff;
99
- color: white;
100
- }
101
-
102
- audio {
103
- width: 100%;
104
- margin-top: 20px;
105
- border-radius: 30px;
106
- }
107
-
108
- .status {
109
- margin-top: 15px;
110
- padding: 10px;
111
- border-radius: 10px;
112
- font-size: 14px;
113
- }
114
-
115
- .status.success {
116
- background: rgba(0,255,0,0.2);
117
- color: #aaffaa;
118
- }
119
-
120
- .status.error {
121
- background: rgba(255,0,0,0.2);
122
- color: #ffaaaa;
123
- }
124
-
125
- .status.info {
126
- background: rgba(0,168,255,0.2);
127
- color: #aae0ff;
128
- }
129
-
130
- .file-upload {
131
- border: 2px dashed #00a8ff;
132
- background: rgba(0,168,255,0.1);
133
- text-align: center;
134
- padding: 20px;
135
- border-radius: 15px;
136
- cursor: pointer;
137
- margin: 15px 0;
138
- }
139
-
140
- .file-upload input {
141
- display: none;
142
- }
143
-
144
- .row {
145
- display: flex;
146
- gap: 15px;
147
- align-items: center;
148
- }
149
-
150
- .speed-control {
151
- flex: 1;
152
- }
153
-
154
- hr {
155
- margin: 20px 0;
156
- border-color: rgba(255,255,255,0.2);
157
- }
158
  </style>
159
  </head>
160
  <body>
161
- <div class="container">
162
- <div class="card">
163
- <h1>🎤 Pocket TTS Studio – Streaming Audio</h1>
164
- <p>Synthèse vocale en temps réel (flux chunked) + clonage de voix</p>
165
- </div>
166
-
167
- <!-- Section Synthèse -->
168
- <div class="card">
169
- <h2>📝 1. Synthèse vocale</h2>
170
- <textarea id="text" rows="4" placeholder="Saisissez le texte à synthétiser...">Bonjour, ceci est un test en streaming avec Pocket TTS. La voix est claire et le son se déclenche rapidement.</textarea>
171
-
172
- <div class="row">
173
- <select id="voice" style="flex:2"></select>
174
- <div class="speed-control">
175
- <label>🎚️ Vitesse : <span id="speedValue">1.0</span></label>
176
- <input type="range" id="speed" min="0.5" max="1.5" step="0.05" value="1.0">
177
- </div>
178
- </div>
179
-
180
- <button id="generateBtn">🔊 Générer & lire (streaming)</button>
181
- <audio id="audioPlayer" controls style="display: none;"></audio>
182
- <div id="ttsStatus" class="status"></div>
183
- </div>
184
-
185
- <!-- Section Clonage -->
186
- <div class="card">
187
- <h2>🎙️ 2. Cloner une nouvelle voix</h2>
188
- <div class="file-upload" id="uploadArea">
189
- <input type="file" id="audioFile" accept=".wav,.mp3,.m4a,.ogg">
190
- <p>📁 Cliquez ou glissez un fichier audio (WAV, MP3, M4A, OGG)</p>
191
- <p style="font-size: 12px;">5-15 secondes, voix claire, peu de bruit</p>
192
- </div>
193
- <input type="text" id="newVoiceName" placeholder="Nom de la nouvelle voix (ex: ma_voix)">
194
- <button id="cloneBtn">✨ Cloner cette voix</button>
195
- <div id="cloneStatus" class="status"></div>
196
- </div>
197
-
198
- <!-- Section Liste des voix -->
199
- <div class="card">
200
- <h2>🎧 3. Voix disponibles</h2>
201
- <div id="voiceList" class="voice-list">
202
- <p>Chargement...</p>
203
- </div>
204
- <button id="refreshVoicesBtn" style="background: #6c757d;">🔄 Actualiser</button>
205
- </div>
206
- </div>
207
-
208
- <script>
209
- // Éléments
210
- const textarea = document.getElementById('text');
211
- const voiceSelect = document.getElementById('voice');
212
- const speedSlider = document.getElementById('speed');
213
- const speedValue = document.getElementById('speedValue');
214
- const generateBtn = document.getElementById('generateBtn');
215
- const audioPlayer = document.getElementById('audioPlayer');
216
- const ttsStatus = document.getElementById('ttsStatus');
217
- const cloneBtn = document.getElementById('cloneBtn');
218
- const cloneStatus = document.getElementById('cloneStatus');
219
- const refreshBtn = document.getElementById('refreshVoicesBtn');
220
- const voiceListDiv = document.getElementById('voiceList');
221
- const fileInput = document.getElementById('audioFile');
222
- const uploadArea = document.getElementById('uploadArea');
223
- const newVoiceNameInput = document.getElementById('newVoiceName');
224
-
225
- let mediaSource = null;
226
- let sourceBuffer = null;
227
- let audioQueue = [];
228
- let isUpdating = false;
229
-
230
- // Met à jour l'affichage de la vitesse
231
- speedSlider.addEventListener('input', () => {
232
- speedValue.textContent = speedSlider.value;
233
- });
234
-
235
- // Charger les voix
236
- async function loadVoices() {
237
- try {
238
- const response = await fetch('/voices');
239
- if (response.ok) {
240
- const data = await response.json();
241
- const voices = data.voices;
242
- // Mettre à jour le select
243
- voiceSelect.innerHTML = '';
244
- voices.forEach(v => {
245
- const option = document.createElement('option');
246
- option.value = v;
247
- option.textContent = v;
248
- voiceSelect.appendChild(option);
249
- });
250
- // Mettre à jour les tags
251
- voiceListDiv.innerHTML = '';
252
- voices.forEach(v => {
253
- const tag = document.createElement('div');
254
- tag.className = 'voice-tag';
255
- tag.textContent = v;
256
- tag.onclick = () => {
257
- voiceSelect.value = v;
258
- };
259
- voiceListDiv.appendChild(tag);
260
  });
261
- if (voices.length === 0) voiceListDiv.innerHTML = '<p>Aucune voix.</p>';
262
- } else {
263
- throw new Error('Erreur chargement');
264
- }
265
- } catch (error) {
266
- console.error(error);
267
- voiceListDiv.innerHTML = '<p>Erreur chargement voix</p>';
268
- }
269
- }
270
-
271
- // Génération avec streaming réel (MediaSource)
272
- async function generateAndPlayStreaming() {
273
- const text = textarea.value.trim();
274
- const voiceName = voiceSelect.value;
275
- const speed = parseFloat(speedSlider.value);
276
-
277
- if (!text) {
278
- ttsStatus.className = 'status error';
279
- ttsStatus.textContent = 'Veuillez saisir du texte.';
280
- return;
281
- }
282
- if (!voiceName) {
283
- ttsStatus.className = 'status error';
284
- ttsStatus.textContent = 'Veuillez sélectionner une voix.';
285
- return;
286
- }
287
-
288
- ttsStatus.className = 'status info';
289
- ttsStatus.textContent = '⏳ Génération en cours (streaming)...';
290
- audioPlayer.style.display = 'none';
291
-
292
- try {
293
- const response = await fetch('/tts', {
294
- method: 'POST',
295
- headers: { 'Content-Type': 'application/json' },
296
- body: JSON.stringify({ text, voice_name: voiceName, speed })
297
- });
298
-
299
- if (!response.ok) {
300
- const errorText = await response.text();
301
- throw new Error(errorText);
302
- }
303
-
304
- // Récupérer le blob complet (le streaming côté client se fait via MediaSource, mais on peut utiliser blob pour simplifier)
305
- // Pour un vrai streaming avec lecture progressive, on utilise MediaSource.
306
- // Ici, on va simplement récupérer le blob et le lire (la réponse est déjà en chunked, mais le navigateur attend la fin).
307
- // Pour démonstration, on garde cette méthode simple.
308
- const blob = await response.blob();
309
- const url = URL.createObjectURL(blob);
310
- audioPlayer.src = url;
311
- audioPlayer.style.display = 'block';
312
- audioPlayer.play();
313
- ttsStatus.className = 'status success';
314
- ttsStatus.textContent = '✅ Audio généré et en cours de lecture.';
315
- } catch (error) {
316
- ttsStatus.className = 'status error';
317
- ttsStatus.textContent = `❌ Erreur : ${error.message}`;
318
- }
319
- }
320
-
321
- // Version alternative avec MediaSource (streaming plus fin)
322
- // On garde la version simple blob car elle fonctionne très bien avec le streaming HTTP chunked
323
- // le navigateur commence à jouer dès que les premiers paquets sont reçus si le format le permet (non pour WAV ?)
324
- // En réalité WAV nécessite l'en-tête complet. Donc blob est suffisant.
325
-
326
- // Clonage de voix
327
- cloneBtn.onclick = async () => {
328
- if (!fileInput.files || fileInput.files.length === 0) {
329
- cloneStatus.className = 'status error';
330
- cloneStatus.textContent = 'Veuillez sélectionner un fichier audio.';
331
- return;
332
- }
333
- const voiceName = newVoiceNameInput.value.trim();
334
- if (!voiceName) {
335
- cloneStatus.className = 'status error';
336
- cloneStatus.textContent = 'Veuillez donner un nom à la voix.';
337
- return;
338
- }
339
- if (!/^[a-zA-Z0-9_-]+$/.test(voiceName)) {
340
- cloneStatus.className = 'status error';
341
- cloneStatus.textContent = 'Nom invalide (lettres, chiffres, tirets, underscores).';
342
- return;
343
- }
344
-
345
- const formData = new FormData();
346
- formData.append('audio_file', fileInput.files[0]);
347
- formData.append('voice_name', voiceName);
348
-
349
- cloneStatus.className = 'status info';
350
- cloneStatus.textContent = '⏳ Clonage en cours (cela peut prendre quelques secondes)...';
351
-
352
- try {
353
- const response = await fetch('/clone-voice', {
354
- method: 'POST',
355
- body: formData
356
- });
357
- if (response.ok) {
358
- const result = await response.json();
359
- cloneStatus.className = 'status success';
360
- cloneStatus.textContent = `✅ Voix "${voiceName}" clonée avec succès ! (Total: ${result.total_voices} voix)`;
361
- fileInput.value = '';
362
- newVoiceNameInput.value = '';
363
- await loadVoices();
364
- } else {
365
- const error = await response.text();
366
- throw new Error(error);
367
  }
368
- } catch (error) {
369
- cloneStatus.className = 'status error';
370
- cloneStatus.textContent = `❌ Erreur : ${error.message}`;
371
- }
372
- };
373
-
374
- // Rafraîchir les voix
375
- refreshBtn.onclick = loadVoices;
376
-
377
- // Drag & drop pour upload
378
- uploadArea.addEventListener('click', () => fileInput.click());
379
- uploadArea.addEventListener('dragover', (e) => {
380
- e.preventDefault();
381
- uploadArea.style.borderColor = '#fff';
382
- uploadArea.style.background = 'rgba(0,168,255,0.2)';
383
- });
384
- uploadArea.addEventListener('dragleave', () => {
385
- uploadArea.style.borderColor = '#00a8ff';
386
- uploadArea.style.background = 'rgba(0,168,255,0.1)';
387
- });
388
- uploadArea.addEventListener('drop', (e) => {
389
- e.preventDefault();
390
- uploadArea.style.borderColor = '#00a8ff';
391
- uploadArea.style.background = 'rgba(0,168,255,0.1)';
392
- const files = e.dataTransfer.files;
393
- if (files.length > 0 && files[0].type.includes('audio')) {
394
- fileInput.files = files;
395
- cloneStatus.className = 'status info';
396
- cloneStatus.textContent = `Fichier sélectionné : ${files[0].name}`;
397
- } else {
398
- alert('Déposez un fichier audio valide.');
399
- }
400
- });
401
-
402
- // Génération au clic
403
- generateBtn.onclick = generateAndPlayStreaming;
404
 
405
- // Initialisation
406
- loadVoices();
407
- </script>
408
  </body>
409
  </html>
 
1
  <!DOCTYPE html>
2
+ <html>
3
  <head>
4
+ <title>Kokoro TTS - rapide FR/EN</title>
 
 
5
  <style>
6
+ body { font-family: sans-serif; max-width: 600px; margin: auto; padding: 2rem; }
7
+ textarea, select, input, button { width: 100%; margin: 0.5rem 0; padding: 0.5rem; }
8
+ audio { width: 100%; margin-top: 1rem; }
9
+ .status { margin-top: 1rem; color: gray; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  </style>
11
  </head>
12
  <body>
13
+ <h1>🗣️ Kokoro TTS (ultra rapide)</h1>
14
+ <textarea id="text" rows="4">Bonjour, ceci est un test de Kokoro. La synthèse est très rapide et naturelle.</textarea>
15
+ <select id="voice"></select>
16
+ <select id="lang">
17
+ <option value="fr">Français</option>
18
+ <option value="en">Anglais</option>
19
+ </select>
20
+ <input type="range" id="speed" min="0.5" max="1.5" step="0.05" value="1.0">
21
+ <span>Vitesse: <span id="speedVal">1.0</span></span>
22
+ <button id="generate">🎤 Générer</button>
23
+ <audio id="audio" controls></audio>
24
+ <div id="status" class="status"></div>
25
+
26
+ <script>
27
+ const voiceSelect = document.getElementById('voice');
28
+ const textarea = document.getElementById('text');
29
+ const langSelect = document.getElementById('lang');
30
+ const speedSlider = document.getElementById('speed');
31
+ const speedVal = document.getElementById('speedVal');
32
+ const generateBtn = document.getElementById('generate');
33
+ const audioPlayer = document.getElementById('audio');
34
+ const statusDiv = document.getElementById('status');
35
+
36
+ speedSlider.oninput = () => speedVal.innerText = speedSlider.value;
37
+
38
+ async function loadVoices() {
39
+ const res = await fetch('/voices');
40
+ const data = await res.json();
41
+ const allVoices = [...data.builtin, ...data.cloned];
42
+ voiceSelect.innerHTML = allVoices.map(v => `<option value="${v}">${v}</option>`).join('');
43
+ }
44
+
45
+ generateBtn.onclick = async () => {
46
+ const text = textarea.value.trim();
47
+ const voice = voiceSelect.value;
48
+ const lang = langSelect.value;
49
+ const speed = parseFloat(speedSlider.value);
50
+ if (!text) return;
51
+
52
+ statusDiv.innerText = '⏳ Génération en cours...';
53
+ try {
54
+ const response = await fetch('/tts', {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ text, voice, speed, lang })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  });
59
+ if (response.ok) {
60
+ const blob = await response.blob();
61
+ const url = URL.createObjectURL(blob);
62
+ audioPlayer.src = url;
63
+ audioPlayer.play();
64
+ statusDiv.innerText = '✅ Audio prêt';
65
+ } else {
66
+ const err = await response.text();
67
+ statusDiv.innerText = `❌ Erreur: ${err}`;
68
+ }
69
+ } catch(e) {
70
+ statusDiv.innerText = `❌ ${e.message}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
+ loadVoices();
75
+ </script>
 
76
  </body>
77
  </html>