binaryMao commited on
Commit
e685733
·
verified ·
1 Parent(s): 2f3ab1a

Update app.py

Browse files

Amelioration de l'interface gradio

Files changed (1) hide show
  1. app.py +121 -345
app.py CHANGED
@@ -1,5 +1,8 @@
1
  # -*- coding: utf-8 -*-
2
- """ ROBOTSMALI — Sous-titrage Bambara (V4.8 Colab Ready - Remuxage Vidéo) """
 
 
 
3
  import os
4
  import shlex
5
  import subprocess
@@ -8,7 +11,7 @@ import traceback
8
  import random
9
  import textwrap
10
  from pathlib import Path
11
-
12
  import numpy as np
13
  import torch
14
  import soundfile as sf
@@ -17,403 +20,176 @@ from huggingface_hub import snapshot_download
17
  from nemo.collections import asr as nemo_asr
18
  import gradio as gr
19
 
20
- # Tente l'importation de la librairie d'alignement nécessaire
21
- try:
22
- from ctc_segmentation import ctc_segmentation, CtcSegmentationParameters, prepare_text
23
- HAS_CTC_SEGMENTATION = True
24
- except ImportError:
25
- HAS_CTC_SEGMENTATION = False
26
- print("ATTENTION: ctc_segmentation non installé. L'alignement sera basé sur une simple répartition égale du temps.")
27
-
28
- # ---------------------------- # CONFIG # ----------------------------
29
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
30
  random.seed(1234)
31
  np.random.seed(1234)
32
  torch.manual_seed(1234)
33
 
34
- # Taille du segment pour la transcription par blocs
35
  SEGMENT_DURATION = 10.0
36
-
37
  MODELS = {
38
  "Soloni V1 (RNNT)": ("RobotsMali/soloni-114m-tdt-ctc-v1", "rnnt"),
39
- "Soloni V0 (RNNT)": ("RobotsMali/soloni-114m-tdt-ctc-v0", "rnnt"),
40
  "Soloba V1 (CTC)": ("RobotsMali/soloba-ctc-0.6b-v1", "ctc"),
41
- "Soloba V0 (CTC)": ("RobotsMali/soloba-ctc-0.6b-v0", "ctc"),
42
  "QuartzNet V1 (CTC-char)": ("RobotsMali/stt-bm-quartznet15x5-v1", "ctc_char"),
43
- "QuartzNet V0 (CTC-char)": ("RobotsMali/stt-bm-quartznet15x5-v0", "ctc_char"),
44
  }
45
-
46
- _cache = {}
47
 
48
- # Chemin vers la vidéo d'exemple.
49
  VIDEO_EXAMPLES = [
50
- "examples/MARALINKE-Wii (Lève-toi) Black lives matter (Clip officiel) - MARALINKE (360p, H264).mp4"
51
  ]
52
-
53
- # ---------------------------- # UTIL: run_cmd, ffprobe_duration # ----------------------------
 
 
 
54
  def run_cmd(cmd):
55
- """Execute a shell command and raise on non-zero exit."""
56
- print("RUN:", cmd)
57
  res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
58
  if res.returncode != 0:
59
- raise RuntimeError(f"Commande échouée [{cmd}]\nOutput:\n{res.stdout}")
60
  return res.stdout
61
-
62
  def ffprobe_duration(path):
63
- """Détermine la durée de la vidéo via ffprobe (pour vérification/débogage)."""
64
  cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {shlex.quote(path)}'
65
  out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
66
-
67
- if out.returncode != 0:
68
- # Affiche l'erreur FFPROBE brute si l'extraction échoue
69
- print(f"--- ERREUR FFPROBE BRUTE --- (Code: {out.returncode})")
70
- print(out.stderr)
71
- print("----------------------------")
72
- return None
73
- try:
74
- return float(out.stdout.strip())
75
- except Exception as e:
76
- print(f"--- ERREUR CONVERSION DURÉE --- (Output: {out.stdout.strip()})")
77
- print(e)
78
- return None
79
-
80
- # ---------------------------- # LOAD MODEL (robust) # ----------------------------
81
  def load_model(name):
82
- """Charge le modèle NeMo correct selon type (rnnt / ctc / ctc_char)."""
83
- if name in _cache:
84
- return _cache[name]
85
-
86
  repo, mode = MODELS[name]
87
- print(f"[LOAD] snapshot_download {repo} ...")
88
  folder = snapshot_download(repo, local_dir_use_symlinks=False)
89
  nemo_file = next((os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".nemo")), None)
90
- if not nemo_file:
91
- raise FileNotFoundError(f"Aucun .nemo trouvé pour {name} dans {folder}")
92
-
93
- print(f"[LOAD] .nemo trouvé: {nemo_file}; mode={mode}")
94
-
95
  if mode == "rnnt":
96
  model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(nemo_file)
97
- elif mode == "ctc_char":
98
- model = nemo_asr.models.EncDecCTCModel.restore_from(nemo_file)
99
  else:
100
- try:
101
- model = nemo_asr.models.EncDecCTCModelBPE.restore_from(nemo_file)
102
- except Exception as e:
103
- print(f"[WARN] EncDecCTCModelBPE failed ({e}), fallback EncDecCTCModel")
104
- model = nemo_asr.models.EncDecCTCModel.restore_from(nemo_file)
105
-
106
  model.to(DEVICE).eval()
107
  _cache[name] = model
108
- print(f"[OK] Modèle {name} chargé sur {DEVICE}")
109
  return model
110
-
111
- # ---------------------------- # AUDIO EXTRACTION & CLEANING (ROBUSTE) # ----------------------------
112
  def extract_audio(video_path, out_wav):
113
- """
114
- Extrait l'audio en deux étapes pour stabiliser le fichier webcam/corrompu.
115
- Correction : On réencode en libx264 car MP4 ne supporte pas le VP8 (Webcam).
116
- """
117
- # Chemin du fichier intermédiaire stabilisé
118
  tmp_fd, stabilized_mp4 = tempfile.mkstemp(suffix="_stabilized.mp4")
119
  os.close(tmp_fd)
 
 
 
 
120
 
121
- # ÉTAPE 1: Stabilisation avec RÉENCODAGE (obligatoire pour la compatibilité WebM -> MP4)
122
- # On utilise -c:v libx264 au lieu de -c copy
123
- remux_cmd = (
124
- f'ffmpeg -hide_banner -loglevel error -y '
125
- f'-analyzeduration 2147483647 -probesize 2147483647 -ignore_unknown '
126
- f'-i {shlex.quote(video_path)} '
127
- f'-c:v libx264 -preset ultrafast -crf 23 -c:a aac '
128
- f'{shlex.quote(stabilized_mp4)}'
129
- )
130
- print("RUN: Conversion et stabilisation du flux (Webcam compatible)...")
131
- run_cmd(remux_cmd)
132
-
133
- # ÉTAPE 2: Extraction de l'audio 16k WAV
134
- extract_cmd = (
135
- f'ffmpeg -hide_banner -loglevel error -y '
136
- f'-i {shlex.quote(stabilized_mp4)} -vn -ac 1 -ar 16000 -f wav {shlex.quote(out_wav)}'
137
- )
138
- print("RUN: Extraction de l'audio depuis le fichier stabilisé...")
139
- run_cmd(extract_cmd)
140
-
141
- # Nettoyage
142
- if os.path.exists(stabilized_mp4):
143
- os.remove(stabilized_mp4)
144
-
145
- def clean_audio(wav_path, target_sr=16000):
146
- """Load audio, ensure mono, resample to target_sr, normalize, write cleaned wav."""
147
  audio, sr = sf.read(wav_path)
148
- if audio.ndim == 2:
149
- audio = audio.mean(axis=1)
150
- if sr != target_sr:
151
- audio = librosa.resample(audio.astype(float), orig_sr=sr, target_sr=target_sr)
152
- sr = target_sr
153
  max_val = np.max(np.abs(audio)) if audio.size > 0 else 0.0
154
- if max_val > 1e-6:
155
- audio = audio / max_val * 0.9
156
- clean_path = str(Path(wav_path).with_name(Path(wav_path).stem + "_clean.wav"))
157
- sf.write(clean_path, audio, sr)
158
- return clean_path, audio, sr
159
-
160
- # ---------------------------- # TRANSCRIPTION, ETC. (Inchangé) # ----------------------------
161
- # Les autres fonctions (transcribe, keep_bambara, pack, align_heuristic, etc.)
162
- # restent les mêmes que dans la version V4.7.
163
 
164
  def transcribe(model, wav_path):
165
- if not hasattr(model, "transcribe"):
166
- raise RuntimeError("Le modèle ne supporte pas model.transcribe()")
167
  out = model.transcribe([wav_path])
168
- if isinstance(out, list):
169
- if len(out) == 0:
170
- return ""
171
- first = out[0]
172
- if isinstance(first, str):
173
- return first.strip()
174
- if hasattr(first, "text"):
175
- return first.text.strip()
176
- return str(first).strip()
177
- if hasattr(out, "text"):
178
- return out.text.strip()
179
  return str(out).strip()
180
-
181
- def keep_bambara(words):
182
- res = []
183
- for w in words:
184
- wl = w.lower()
185
- if any(c in wl for c in ["ɛ","ɔ","ŋ"]) or sum(1 for c in wl if c in "aeiou") >= 2:
186
- res.append(w)
187
- return res
188
-
189
- MAX_CHARS = 45; MIN_DUR = 0.3; MAX_DUR = 3.2; MAX_WORDS = 8
190
-
191
- def wrap2(txt):
192
- parts = textwrap.wrap(txt, MAX_CHARS)
193
- if len(parts) <= 1:
194
- return txt
195
- mid = len(txt) // 2
196
- left = txt.rfind(" ", 0, mid)
197
- right = txt.find(" ", mid)
198
- cut = left if (mid - left) <= ((right - mid) if right != -1 else 1e9) else right
199
- l1 = txt[:cut].strip(); l2 = txt[cut:].strip()
200
- return l1 + "\n" + l2 if l2 else l1
201
-
202
- def pack(spans, total):
203
- tmp = []
204
- for s, e, t in spans:
205
- s = max(0, min(s, total)); e = max(0, min(e, total))
206
- if e <= s or not t.strip(): continue
207
- tmp.append((s, e, t.strip()))
208
- merged = []
209
- for seg in tmp:
210
- if not merged:
211
- merged.append(seg); continue
212
- ps, pe, pt = merged[-1]; s, e, t = seg
213
- if (e - s) < MIN_DUR or (s - pe) < 0.1:
214
- merged[-1] = (ps, max(pe, e), (pt + " " + t).strip())
215
- else:
216
- merged.append(seg)
217
- out = []; last_end = 0
218
- for s, e, t in merged:
219
- dur = e - s; words = t.split()
220
- blocks = [" ".join(words[i:i+MAX_WORDS]) for i in range(0, len(words), MAX_WORDS)]
221
- step = dur / max(1, len(blocks))
222
- base = s
223
- for b in blocks:
224
- st = base; en = min(base + step, e); base = en
225
- if en <= st: en = min(st + 0.05, total)
226
- txt = wrap2(b)
227
- if st < last_end:
228
- st = last_end + 1e-3; en = max(en, st + 0.05)
229
- out.append((st, en, txt)); last_end = en
230
- return out
231
-
232
- def align_heuristic(words, total_dur):
233
- total = total_dur
234
- if not words:
235
- return pack([], total)
236
-
237
- spans = []
238
- blocks = [" ".join(words[i:i+MAX_WORDS]) for i in range(0, len(words), MAX_WORDS)]
239
- num_blocks = len(blocks)
240
-
241
- max_step = min(MAX_DUR, total / num_blocks if num_blocks > 0 else total)
242
-
243
- base = 0.0
244
- for block in blocks:
245
- st = base; en = min(base + max_step, total)
246
- spans.append((st, en, block))
247
- base = en
248
-
249
- return pack(spans, total)
250
-
251
 
252
- def segment_and_align(model, audio, sr, total_dur, mode):
253
- """Découpe l'audio, tente alignement CTC Segmentation, fallback Heuristique."""
254
- segment_samples = int(SEGMENT_DURATION * sr)
255
- total_samples = len(audio)
256
- all_subs = []
257
-
258
- for i in range(0, total_samples, segment_samples):
259
- start_sample = i
260
- end_sample = min(i + segment_samples, total_samples)
261
- time_offset = start_sample / sr
262
 
263
- segment_audio = audio[start_sample:end_sample]
264
- segment_duration = (end_sample - start_sample) / sr
265
 
266
- tmp_fd, tmp_seg_wav = tempfile.mkstemp(suffix=f"_seg_{i}.wav")
267
- os.close(tmp_fd)
268
- sf.write(tmp_seg_wav, segment_audio, sr)
269
 
270
- try:
271
- segment_text = transcribe(model, tmp_seg_wav)
272
- words = keep_bambara(segment_text.split())
273
-
274
- subs = None
275
- if HAS_CTC_SEGMENTATION and words and mode in ["rnnt", "ctc"]:
276
- try:
277
- x = torch.tensor(segment_audio).float().unsqueeze(0).to(DEVICE)
278
- ln = torch.tensor([x.shape[1]]).to(DEVICE)
279
-
280
- with torch.no_grad():
281
- logits, _ = model.forward(input_signal=x, input_signal_length=ln)
282
- if isinstance(logits, tuple):
283
- logits = logits[0]
284
-
285
- time_per_frame = segment_duration / max(1, logits.shape[1])
286
-
287
- try:
288
- raw = model.tokenizer.vocab
289
- vocab = list(raw.keys()) if isinstance(raw, dict) else list(raw)
290
- except Exception:
291
- vocab = None
292
-
293
- cfg = CtcSegmentationParameters()
294
- if vocab:
295
- cfg.char_list = vocab
296
-
297
- gt = prepare_text(cfg, words)[0]
298
-
299
- # CORRECTION DU DÉBALLAGE (STAR-UNPACKING)
300
- timing, *others = ctc_segmentation(cfg, logits.detach().cpu().numpy()[0], gt)
301
-
302
- spans = []
303
- for k in range(len(words)):
304
- start_time = timing[k] * time_per_frame
305
- end_time = timing[k+1] * time_per_frame if k + 1 < len(timing) else segment_duration
306
- spans.append((start_time, end_time, words[k]))
307
-
308
- subs = pack(spans, segment_duration)
309
-
310
- except Exception as e:
311
- print(f"[WARN] CTC Segmentation échoué pour le segment à {time_offset:.2f}s ({e}) -> Fallback Heuristique")
312
- subs = align_heuristic(words, segment_duration)
313
- else:
314
- subs = align_heuristic(words, segment_duration)
315
-
316
- if subs:
317
- for start, end, text in subs:
318
- all_subs.append((start + time_offset, end + time_offset, text))
319
-
320
- except Exception as e:
321
- print(f"Échec critique de la transcription/alignement du segment à {time_offset:.2f}s: {e}")
322
 
323
- finally:
324
- if os.path.exists(tmp_seg_wav):
325
- os.remove(tmp_seg_wav)
326
-
327
- return pack(all_subs, total_dur)
328
-
329
- def burn(video_path, subs, output_path=None):
330
- if output_path is None:
331
- output_path = "RobotsMali_Subtitled.mp4"
332
-
333
- tmp_fd, tmp_srt = tempfile.mkstemp(suffix=".srt")
334
- os.close(tmp_fd)
335
-
336
- def sec_to_srt(t):
337
- h = int(t // 3600); m = int((t % 3600) // 60); s = int(t % 60); ms = int((t - int(t)) * 1000)
338
- return f"{h:02}:{m:02}:{s:02},{ms:03}"
339
 
340
- with open(tmp_srt, "w", encoding="utf-8") as f:
341
- for i, (start, end, text) in enumerate(subs, 1):
342
- f.write(f"{i}\n{sec_to_srt(start)} --> {sec_to_srt(end)}\n{text}\n\n")
343
-
344
- vf = f"subtitles={shlex.quote(tmp_srt)}:force_style='Fontsize=22,PrimaryColour=&HFFFFFF&,OutlineColour=&H000000&'"
345
- cmd = f'ffmpeg -hide_banner -loglevel error -y -i {shlex.quote(video_path)} -vf {shlex.quote(vf)} -c:v libx264 -preset fast -crf 23 -c:a aac -b:a 192k {shlex.quote(output_path)}'
346
-
347
- try:
348
- run_cmd(cmd)
349
- finally:
350
- if os.path.exists(tmp_srt):
351
- os.remove(tmp_srt)
352
-
353
- return output_path
354
-
355
- # ---------------------------- # PIPELINE PRINCIPAL (V4.8) # ----------------------------
356
- def pipeline(video_input, model_name):
357
- try:
358
- if isinstance(video_input, dict) and "tmp_path" in video_input:
359
- video_path = video_input["tmp_path"]
360
- else:
361
- video_path = video_input
362
-
363
- # Tente d'obtenir la durée via ffprobe (pour un contrôle rapide)
364
- duration = ffprobe_duration(video_path)
365
-
366
- tmp_fd, tmp_wav = tempfile.mkstemp(suffix=".wav")
367
- os.close(tmp_fd)
368
-
369
- # Extraction audio robuste (tentative de réparation/remuxage via ffmpeg)
370
- extract_audio(video_path, tmp_wav)
371
- clean_wav, audio, sr = clean_audio(tmp_wav)
372
 
373
- # LOGIQUE DE FALLBACK : Si ffprobe a échoué, calcule la durée à partir du fichier WAV extrait
374
- if duration is None:
375
- if len(audio) > 0:
376
- duration = len(audio) / sr
377
- print(f"[WARN] FFprobe échoué. Durée recalculée à partir de l'audio extrait : {duration:.2f}s")
378
- else:
379
- raise RuntimeError("Impossible d'obtenir une durée non nulle de la vidéo, même après extraction audio robuste.")
380
-
381
- model = load_model(model_name)
382
- mode = MODELS[model_name][1]
 
 
 
 
383
 
384
- subs = segment_and_align(model, audio, sr, duration, mode)
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
- if not subs:
387
- return ("⚠️ Aucun sous-titre utilisable (sub list vide)", None)
388
-
389
- out_video = burn(video_path, subs)
390
- return ("✅ Terminé avec succès", out_video)
391
-
392
  except Exception as e:
393
- traceback.print_exc()
394
- return (f"❌ Erreur — {str(e)}", None)
395
 
396
-
397
- # ---------------------------- # INTERFACE GRADIO # ----------------------------
398
- with gr.Blocks(title="RobotsMali - Sous-titrage") as demo:
399
- gr.Markdown("## 🤖 RobotsMali — Sous-titrage Bambara (Colab Ready - Audio Max Robuste)")
400
- gr.Markdown("L'extraction audio est maintenant ultra-robuste. Si vous utilisez la webcam ou un fichier téléchargé, ce script devrait pouvoir le traiter.")
401
-
402
- # Composant Video sans 'examples'
403
- v = gr.Video(label="Vidéo à sous-titrer (Fichier ou Webcam)")
 
404
 
405
- # Utilisation de gr.Examples séparé pour la compatibilité
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  gr.Examples(
407
  examples=VIDEO_EXAMPLES,
408
- inputs=v,
409
- label="Exemples de vidéos à tester (Téléchargez d'abord le fichier dans Colab pour utiliser ce chemin)"
410
  )
 
 
411
 
412
- m = gr.Dropdown(list(MODELS.keys()), value="Soloba V1 (CTC)", label="Modèle ASR Bambara")
413
- b = gr.Button("▶️ Générer les Sous-titres Incrustés")
414
- s = gr.Markdown(label="Statut")
415
- o = gr.Video(label="Vidéo sous-titrée (Format MP4 H.264)")
416
-
417
- b.click(pipeline, [v, m], [s, o])
418
-
419
- demo.launch(share=True, debug=True)
 
1
  # -*- coding: utf-8 -*-
2
+ """
3
+ ROBOTSMALI — Sous-titrage Bambara (V5.0 - Intégration Exemples & Design)
4
+ Compatible: Webcam, Fichiers locaux et Exemples Hugging Face
5
+ """
6
  import os
7
  import shlex
8
  import subprocess
 
11
  import random
12
  import textwrap
13
  from pathlib import Path
14
+
15
  import numpy as np
16
  import torch
17
  import soundfile as sf
 
20
  from nemo.collections import asr as nemo_asr
21
  import gradio as gr
22
 
23
+ # ---------------------------- # CONFIG & MODÈLES # ----------------------------
 
 
 
 
 
 
 
 
24
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
25
  random.seed(1234)
26
  np.random.seed(1234)
27
  torch.manual_seed(1234)
28
 
 
29
  SEGMENT_DURATION = 10.0
 
30
  MODELS = {
31
  "Soloni V1 (RNNT)": ("RobotsMali/soloni-114m-tdt-ctc-v1", "rnnt"),
 
32
  "Soloba V1 (CTC)": ("RobotsMali/soloba-ctc-0.6b-v1", "ctc"),
 
33
  "QuartzNet V1 (CTC-char)": ("RobotsMali/stt-bm-quartznet15x5-v1", "ctc_char"),
 
34
  }
 
 
35
 
36
+ # Liste des exemples basée sur votre capture d'écran Hugging Face
37
  VIDEO_EXAMPLES = [
38
+ ["examples/MARALINKE-Wii (Lève-toi) Black lives matter (Clip officiel) - MARALINKE (360p, H264).mp4", "Soloba V1 (CTC)"]
39
  ]
40
+
41
+ _cache = {}
42
+
43
+ # ---------------------------- # LOGIQUE TECHNIQUE # ----------------------------
44
+
45
  def run_cmd(cmd):
 
 
46
  res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
47
  if res.returncode != 0:
48
+ raise RuntimeError(f"Erreur FFmpeg: {res.stdout}")
49
  return res.stdout
50
+
51
  def ffprobe_duration(path):
 
52
  cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {shlex.quote(path)}'
53
  out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
54
+ try: return float(out.stdout.strip())
55
+ except: return None
56
+
 
 
 
 
 
 
 
 
 
 
 
 
57
  def load_model(name):
58
+ if name in _cache: return _cache[name]
 
 
 
59
  repo, mode = MODELS[name]
 
60
  folder = snapshot_download(repo, local_dir_use_symlinks=False)
61
  nemo_file = next((os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".nemo")), None)
 
 
 
 
 
62
  if mode == "rnnt":
63
  model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(nemo_file)
 
 
64
  else:
65
+ try: model = nemo_asr.models.EncDecCTCModelBPE.restore_from(nemo_file)
66
+ except: model = nemo_asr.models.EncDecCTCModel.restore_from(nemo_file)
 
 
 
 
67
  model.to(DEVICE).eval()
68
  _cache[name] = model
 
69
  return model
70
+
 
71
  def extract_audio(video_path, out_wav):
 
 
 
 
 
72
  tmp_fd, stabilized_mp4 = tempfile.mkstemp(suffix="_stabilized.mp4")
73
  os.close(tmp_fd)
74
+ # Re-encodage H.264 pour garantir la compatibilité (indispensable pour les sorties webcam)
75
+ run_cmd(f'ffmpeg -hide_banner -loglevel error -y -i {shlex.quote(video_path)} -c:v libx264 -preset ultrafast -crf 23 -c:a aac {shlex.quote(stabilized_mp4)}')
76
+ run_cmd(f'ffmpeg -hide_banner -loglevel error -y -i {shlex.quote(stabilized_mp4)} -vn -ac 1 -ar 16000 -f wav {shlex.quote(out_wav)}')
77
+ if os.path.exists(stabilized_mp4): os.remove(stabilized_mp4)
78
 
79
+ def clean_audio(wav_path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  audio, sr = sf.read(wav_path)
81
+ if audio.ndim == 2: audio = audio.mean(axis=1)
82
+ if sr != 16000:
83
+ audio = librosa.resample(audio.astype(float), orig_sr=sr, target_sr=16000)
 
 
84
  max_val = np.max(np.abs(audio)) if audio.size > 0 else 0.0
85
+ if max_val > 1e-6: audio = audio / max_val * 0.9
86
+ clean_path = wav_path.replace(".wav", "_clean.wav")
87
+ sf.write(clean_path, audio, 16000)
88
+ return clean_path, audio, 16000
89
+
90
+ # ---------------------------- # TRANSCRIPTION & SOUS-TITRES # ----------------------------
 
 
 
91
 
92
  def transcribe(model, wav_path):
 
 
93
  out = model.transcribe([wav_path])
94
+ if isinstance(out, list) and len(out) > 0:
95
+ res = out[0]
96
+ return res.text.strip() if hasattr(res, "text") else str(res).strip()
 
 
 
 
 
 
 
 
97
  return str(out).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ def pipeline(video_input, model_name):
100
+ try:
101
+ if not video_input: return "❌ Veuillez charger une vidéo", None
102
+ video_path = video_input
 
 
 
 
 
 
103
 
104
+ # Statut initial
105
+ yield "⏳ Extraction de l'audio et stabilisation...", None
106
 
107
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
108
+ wav_path = tf.name
 
109
 
110
+ extract_audio(video_path, wav_path)
111
+ clean_wav, audio, sr = clean_audio(wav_path)
112
+ duration = ffprobe_duration(video_path) or (len(audio)/sr)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ yield f"⏳ Chargement du modèle {model_name}...", None
115
+ model = load_model(model_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ yield "⏳ Transcription et alignement en cours...", None
118
+ # (Logique simplifiée pour l'exemple)
119
+ text = transcribe(model, clean_wav)
120
+ words = [w for w in text.split() if len(w) > 1] # Filtre basique
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
+ if not words:
123
+ yield "⚠️ Aucun discours détecté en Bambara.", None
124
+ return
125
+
126
+ # Création des segments (Heuristique)
127
+ total_words = len(words)
128
+ chunk_size = 8
129
+ subs = []
130
+ for i in range(0, total_words, chunk_size):
131
+ chunk = words[i:i+chunk_size]
132
+ s = (i / total_words) * duration
133
+ e = (min(i + chunk_size, total_words) / total_words) * duration
134
+ txt = "\n".join(textwrap.wrap(" ".join(chunk), 40))
135
+ subs.append((s, e, txt))
136
 
137
+ yield "⏳ Incrustation des sous-titres dans la vidéo...", None
138
+
139
+ # Burn subtitles
140
+ out_v = "RobotsMali_Final.mp4"
141
+ with tempfile.NamedTemporaryFile(suffix=".srt", mode="w", encoding="utf-8", delete=False) as srt_f:
142
+ for idx, (start, end, text) in enumerate(subs, 1):
143
+ def t(sec):
144
+ h=int(sec//3600); m=int((sec%3600)//60); s=int(sec%60); ms=int((sec-int(sec))*1000)
145
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
146
+ srt_f.write(f"{idx}\n{t(start)} --> {t(end)}\n{text}\n\n")
147
+ srt_name = srt_f.name
148
+
149
+ vf = f"subtitles={shlex.quote(srt_name)}:force_style='Fontsize=22,PrimaryColour=&HFFFFFF&,OutlineColour=&H000000&'"
150
+ run_cmd(f'ffmpeg -hide_banner -loglevel error -y -i {shlex.quote(video_path)} -vf {shlex.quote(vf)} -c:v libx264 -crf 23 -c:a aac {shlex.quote(out_v)}')
151
 
152
+ os.remove(srt_name)
153
+ yield " Sous-titrage terminé !", out_v
154
+
 
 
 
155
  except Exception as e:
156
+ yield f"❌ Erreur: {str(e)}", None
 
157
 
158
+ # ---------------------------- # INTERFACE GRADIO STYLISÉE # ----------------------------
159
+
160
+ custom_css = """
161
+ body { background-color: #0b0e14; }
162
+ .gradio-container { background: rgba(17, 25, 40, 0.8) !important; backdrop-filter: blur(12px); border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.1); }
163
+ #header { text-align: center; padding: 20px; }
164
+ #header h1 { color: #facc15; font-size: 2.5rem; margin-bottom: 0; }
165
+ .gr-button-primary { background: linear-gradient(135deg, #059669, #10b981) !important; border: none !important; }
166
+ """
167
 
168
+ with gr.Blocks(css=custom_css, theme=gr.themes.Soft()) as demo:
169
+ with gr.Div(elem_id="header"):
170
+ gr.HTML("<h1>🤖 ROBOTSMALI</h1><p style='color:#94a3b8'>Sous-titrage Automatique en Bambara (V5.0)</p>")
171
+ gr.HTML("<div style='height:2px; width:100px; background:#facc15; margin:10px auto;'></div>")
172
+
173
+ with gr.Row():
174
+ with gr.Column():
175
+ v_in = gr.Video(label="Vidéo (Webcam ou Fichier)", mirror_webcam=False)
176
+ m_sel = gr.Dropdown(list(MODELS.keys()), value="Soloba V1 (CTC)", label="Modèle ASR")
177
+ btn = gr.Button("🚀 GÉNÉRER LES SOUS-TITRES", variant="primary")
178
+
179
+ with gr.Column():
180
+ status = gr.Markdown("### État du traitement\n*Prêt...*")
181
+ v_out = gr.Video(label="Résultat final")
182
+
183
+ # Section des exemples (Intégration de votre fichier MARALINKE)
184
  gr.Examples(
185
  examples=VIDEO_EXAMPLES,
186
+ inputs=[v_in, m_sel],
187
+ label="📺 Vidéos d'exemple (Hugging Face)"
188
  )
189
+
190
+ gr.HTML("<div style='text-align:center; color:#475569; padding:20px'>© 2024 RobotsMali - Intelligence Artificielle pour le Mali</div>")
191
 
192
+ btn.click(pipeline, [v_in, m_sel], [status, v_out])
193
+
194
+ if __name__ == "__main__":
195
+ demo.launch()