Hinnou commited on
Commit
b2efd43
·
verified ·
1 Parent(s): d0500e9

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +14 -7
  2. app.py +284 -0
  3. packages.txt +1 -0
  4. requirements.txt +7 -0
README.md CHANGED
@@ -1,13 +1,20 @@
1
  ---
2
- title: Noukiko Api
3
- emoji: 🏆
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.31.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Noukiko Dubbing API
3
+ emoji: 🎬
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Noukiko – API de doublage automatique
13
+
14
+ Vidéo (anglais/français) → transcription (Whisper) → traduction (Meta NLLB-200) → voix (Meta MMS-TTS) → vidéo doublée en Fon, Yoruba, Ewe, Haoussa.
15
+
16
+ - `POST /dub` `{"video_url":"https://.../video.mp4","source_lang":"auto","target_lang":"fon"}`
17
+ - `GET /jobs/{job_id}` → statut, étape, `result_url`
18
+ - Page de test : `/ui`
19
+
20
+ Variable du Space : `PUBLIC_URL` = adresse du Space.
app.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Noukiko - moteur de doublage automatique
3
+ Vidéo (anglais/français) -> transcription -> traduction (Fon, Yoruba...) -> voix -> vidéo doublée.
4
+
5
+ API :
6
+ POST /dub {"video_url": "...", "source_lang": "en", "target_lang": "fon"} -> {"job_id": "..."}
7
+ GET /jobs/{id} -> {"status": "en_attente|en_cours|termine|erreur", "progress": 0-100,
8
+ "result_url": "...", "error": "..."}
9
+ GET /files/{id}.mp4 -> la vidéo doublée
10
+ GET /languages -> langues disponibles
11
+ """
12
+ import os
13
+ import re
14
+ import uuid
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ import threading
19
+ from concurrent.futures import ThreadPoolExecutor
20
+
21
+ import requests
22
+ from fastapi import FastAPI, HTTPException
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import FileResponse
25
+ from pydantic import BaseModel
26
+
27
+ # ---------------------------------------------------------------- config
28
+ OUT_DIR = os.environ.get("NOUKIKO_OUT", "/tmp/noukiko_out")
29
+ PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://hinnou-noukiko-api.hf.space").rstrip("/") # ex: https://xxx.hf.space
30
+ MAX_SECONDS = int(os.environ.get("MAX_SECONDS", "600")) # durée max d'une vidéo
31
+ ORIGINAL_VOLUME = float(os.environ.get("ORIGINAL_VOLUME", "0.12")) # son d'origine en fond
32
+ WHISPER_SIZE = os.environ.get("WHISPER_SIZE", "small")
33
+ NLLB_MODEL = os.environ.get("NLLB_MODEL", "facebook/nllb-200-distilled-600M")
34
+ os.makedirs(OUT_DIR, exist_ok=True)
35
+
36
+ # Langues sources (Whisper) -> code NLLB
37
+ SOURCE_LANGS = {"en": "eng_Latn", "fr": "fra_Latn"}
38
+
39
+ # Langues cibles : code NLLB + modèle de voix Meta MMS-TTS
40
+ TARGET_LANGS = {
41
+ "fon": {"name": "Fon", "nllb": "fon_Latn", "tts": "facebook/mms-tts-fon"},
42
+ "yor": {"name": "Yoruba", "nllb": "yor_Latn", "tts": "facebook/mms-tts-yor"},
43
+ "ewe": {"name": "Ewe", "nllb": "ewe_Latn", "tts": "facebook/mms-tts-ewe"},
44
+ "hau": {"name": "Haoussa", "nllb": "hau_Latn", "tts": "facebook/mms-tts-hau"},
45
+ "fra": {"name": "Français", "nllb": "fra_Latn", "tts": "facebook/mms-tts-fra"},
46
+ "eng": {"name": "Anglais", "nllb": "eng_Latn", "tts": "facebook/mms-tts-eng"},
47
+ }
48
+
49
+ # ---------------------------------------------------------------- modèles (chargés à la demande)
50
+ _lock = threading.Lock()
51
+ _models = {}
52
+
53
+
54
+ def get_whisper():
55
+ with _lock:
56
+ if "whisper" not in _models:
57
+ from faster_whisper import WhisperModel
58
+ _models["whisper"] = WhisperModel(WHISPER_SIZE, device="auto", compute_type="int8")
59
+ return _models["whisper"]
60
+
61
+
62
+ def get_translator():
63
+ with _lock:
64
+ if "nllb" not in _models:
65
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
66
+ tok = AutoTokenizer.from_pretrained(NLLB_MODEL)
67
+ mdl = AutoModelForSeq2SeqLM.from_pretrained(NLLB_MODEL)
68
+ _models["nllb"] = (tok, mdl)
69
+ return _models["nllb"]
70
+
71
+
72
+ def get_tts(model_id):
73
+ with _lock:
74
+ key = "tts:" + model_id
75
+ if key not in _models:
76
+ from transformers import VitsModel, AutoTokenizer
77
+ _models[key] = (AutoTokenizer.from_pretrained(model_id), VitsModel.from_pretrained(model_id))
78
+ return _models[key]
79
+
80
+
81
+ # ---------------------------------------------------------------- étapes
82
+ def run(cmd):
83
+ p = subprocess.run(cmd, capture_output=True, text=True)
84
+ if p.returncode != 0:
85
+ raise RuntimeError(f"ffmpeg: {p.stderr[-400:]}")
86
+ return p.stdout
87
+
88
+
89
+ def duration(path):
90
+ out = run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
91
+ "-of", "default=nw=1:nk=1", path])
92
+ return float(out.strip())
93
+
94
+
95
+ def download(url, dest):
96
+ with requests.get(url, stream=True, timeout=60) as r:
97
+ r.raise_for_status()
98
+ with open(dest, "wb") as f:
99
+ for chunk in r.iter_content(1 << 20):
100
+ f.write(chunk)
101
+
102
+
103
+ def transcribe(audio_path, source_lang):
104
+ """Retourne ([(debut, fin, texte), ...], langue_detectee)"""
105
+ lang = None if source_lang == "auto" else source_lang
106
+ segments, info = get_whisper().transcribe(audio_path, language=lang, vad_filter=True)
107
+ segs = [(s.start, s.end, s.text.strip()) for s in segments if s.text.strip()]
108
+ return segs, info.language
109
+
110
+
111
+ def translate(texts, src_code, tgt_code):
112
+ tok, mdl = get_translator()
113
+ tok.src_lang = src_code
114
+ out = []
115
+ for i in range(0, len(texts), 8):
116
+ batch = texts[i:i + 8]
117
+ enc = tok(batch, return_tensors="pt", padding=True, truncation=True, max_length=256)
118
+ gen = mdl.generate(**enc, forced_bos_token_id=tok.convert_tokens_to_ids(tgt_code), max_length=256)
119
+ out += tok.batch_decode(gen, skip_special_tokens=True)
120
+ return out
121
+
122
+
123
+ def synthesize(text, model_id, wav_path):
124
+ import torch
125
+ import scipy.io.wavfile
126
+ tok, mdl = get_tts(model_id)
127
+ inputs = tok(text, return_tensors="pt")
128
+ with torch.no_grad():
129
+ wav = mdl(**inputs).waveform[0].numpy()
130
+ scipy.io.wavfile.write(wav_path, mdl.config.sampling_rate, wav)
131
+
132
+
133
+ def fit_to_slot(wav_in, wav_out, slot):
134
+ """Accélère la phrase doublée si elle dépasse le créneau de la phrase d'origine."""
135
+ d = duration(wav_in)
136
+ speed = max(1.0, min(d / max(slot, 0.3), 1.8)) # jamais plus de 1.8x
137
+ run(["ffmpeg", "-y", "-i", wav_in, "-filter:a", f"atempo={speed:.3f}",
138
+ "-ar", "24000", "-ac", "1", wav_out])
139
+
140
+
141
+ def mix(video_in, clips, video_out, total):
142
+ """clips = [(debut_en_s, chemin_wav)]. Garde le son d'origine très bas en fond."""
143
+ cmd = ["ffmpeg", "-y", "-i", video_in]
144
+ for _, p in clips:
145
+ cmd += ["-i", p]
146
+ parts = [f"[0:a]volume={ORIGINAL_VOLUME}[bg]"]
147
+ labels = ["[bg]"]
148
+ for i, (start, _) in enumerate(clips, start=1):
149
+ ms = int(start * 1000)
150
+ parts.append(f"[{i}:a]adelay={ms}|{ms}[c{i}]")
151
+ labels.append(f"[c{i}]")
152
+ parts.append("".join(labels) + f"amix=inputs={len(labels)}:normalize=0:duration=first[aout]")
153
+ cmd += ["-filter_complex", ";".join(parts), "-map", "0:v", "-map", "[aout]",
154
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-t", f"{total:.2f}", video_out]
155
+ run(cmd)
156
+
157
+
158
+ # ---------------------------------------------------------------- jobs
159
+ jobs = {}
160
+ executor = ThreadPoolExecutor(max_workers=1)
161
+
162
+
163
+ def process(job_id, video_url, source_lang, target_lang):
164
+ job = jobs[job_id]
165
+ work = tempfile.mkdtemp(prefix="noukiko_")
166
+ try:
167
+ job.update(status="en_cours", progress=5, step="Téléchargement")
168
+ src = os.path.join(work, "in.mp4")
169
+ download(video_url, src)
170
+ total = duration(src)
171
+ if total > MAX_SECONDS:
172
+ raise RuntimeError(f"Vidéo trop longue ({int(total)} s, max {MAX_SECONDS} s)")
173
+
174
+ job.update(progress=15, step="Transcription")
175
+ audio = os.path.join(work, "audio.wav")
176
+ run(["ffmpeg", "-y", "-i", src, "-vn", "-ac", "1", "-ar", "16000", audio])
177
+ segs, detected = transcribe(audio, source_lang)
178
+ if not segs:
179
+ raise RuntimeError("Aucune parole détectée dans la vidéo")
180
+ if detected not in SOURCE_LANGS:
181
+ raise RuntimeError(f"Langue de la vidéo non prise en charge : {detected} (anglais ou français)")
182
+ job["detected_lang"] = detected
183
+
184
+ job.update(progress=40, step="Traduction")
185
+ tgt = TARGET_LANGS[target_lang]
186
+ texts = translate([s[2] for s in segs], SOURCE_LANGS[detected], tgt["nllb"])
187
+ job["transcript"] = [{"start": round(s[0], 2), "end": round(s[1], 2),
188
+ "original": s[2], "traduction": t} for s, t in zip(segs, texts)]
189
+
190
+ job.update(progress=60, step="Voix")
191
+ clips = []
192
+ for i, ((start, end, _), text) in enumerate(zip(segs, texts)):
193
+ raw = os.path.join(work, f"raw{i}.wav")
194
+ fit = os.path.join(work, f"seg{i}.wav")
195
+ synthesize(text, tgt["tts"], raw)
196
+ fit_to_slot(raw, fit, end - start)
197
+ clips.append((start, fit))
198
+ job["progress"] = 60 + int(30 * (i + 1) / len(segs))
199
+
200
+ job.update(progress=92, step="Montage")
201
+ out = os.path.join(OUT_DIR, f"{job_id}.mp4")
202
+ mix(src, clips, out, total)
203
+ job.update(status="termine", progress=100,
204
+ step="Terminé ! Ta vidéo doublée est prête, appuie sur lecture.",
205
+ result_url=f"{PUBLIC_URL}/files/{job_id}.mp4")
206
+ except Exception as e: # noqa
207
+ job.update(status="erreur", error=str(e), step=f"Erreur : {e}")
208
+ finally:
209
+ shutil.rmtree(work, ignore_errors=True)
210
+
211
+
212
+ # ---------------------------------------------------------------- API
213
+ app = FastAPI(title="Noukiko Dubbing API")
214
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
215
+
216
+
217
+ class DubRequest(BaseModel):
218
+ video_url: str
219
+ source_lang: str = "auto"
220
+ target_lang: str = "fon"
221
+
222
+
223
+ @app.get("/")
224
+ def root():
225
+ return {"service": "Noukiko", "ok": True}
226
+
227
+
228
+ @app.get("/languages")
229
+ def languages():
230
+ return {"sources": list(SOURCE_LANGS),
231
+ "cibles": [{"code": k, "nom": v["name"]} for k, v in TARGET_LANGS.items()]}
232
+
233
+
234
+ @app.post("/dub")
235
+ def dub(req: DubRequest):
236
+ if req.source_lang != "auto" and req.source_lang not in SOURCE_LANGS:
237
+ raise HTTPException(400, f"Langue source inconnue : {req.source_lang}")
238
+ if req.target_lang not in TARGET_LANGS:
239
+ raise HTTPException(400, f"Langue cible inconnue : {req.target_lang}")
240
+ if not re.match(r"^https?://", req.video_url):
241
+ raise HTTPException(400, "Le lien doit commencer par http:// ou https://")
242
+ job_id = uuid.uuid4().hex[:12]
243
+ jobs[job_id] = {"status": "en_attente", "progress": 0, "step": "En attente",
244
+ "result_url": "", "error": ""}
245
+ executor.submit(process, job_id, req.video_url, req.source_lang, req.target_lang)
246
+ return {"job_id": job_id}
247
+
248
+
249
+ @app.get("/jobs/{job_id}")
250
+ def job_status(job_id: str):
251
+ if job_id not in jobs:
252
+ raise HTTPException(404, "Tâche introuvable")
253
+ return {"job_id": job_id, **jobs[job_id]}
254
+
255
+
256
+ @app.get("/files/{name}")
257
+ def files(name: str):
258
+ path = os.path.join(OUT_DIR, os.path.basename(name))
259
+ if not os.path.exists(path):
260
+ raise HTTPException(404, "Fichier introuvable")
261
+ return FileResponse(path, media_type="video/mp4")
262
+
263
+
264
+ # ---------------------------------------------------------------- page de test (Space Gradio gratuit)
265
+ import gradio as gr
266
+
267
+
268
+ def _ui_dub(url, lang):
269
+ r = dub(DubRequest(video_url=url, target_lang=lang))
270
+ return f"Tâche lancée : {r['job_id']} — suivez /jobs/{r['job_id']}"
271
+
272
+
273
+ with gr.Blocks(title="Noukiko") as demo:
274
+ gr.Markdown("# Noukiko – doublage automatique\nAPI : `POST /dub`, `GET /jobs/{id}`")
275
+ u = gr.Textbox(label="Lien direct de la vidéo (.mp4)")
276
+ l = gr.Dropdown(list(TARGET_LANGS), value="fon", label="Langue cible")
277
+ o = gr.Textbox(label="Résultat")
278
+ gr.Button("Doubler").click(_ui_dub, [u, l], o)
279
+
280
+ app = gr.mount_gradio_app(app, demo, path="/ui")
281
+
282
+ if __name__ == "__main__":
283
+ import uvicorn
284
+ uvicorn.run(app, host="0.0.0.0", port=7860)
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ requests
2
+ huggingface_hub==0.25.2
3
+ faster-whisper==1.0.3
4
+ transformers==4.44.2
5
+ sentencepiece==0.2.0
6
+ torch==2.4.1
7
+ scipy==1.14.1