VeuReu commited on
Commit
0666d07
·
verified ·
1 Parent(s): 8169748

Upload 30 files

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: veureu-tts
3
  emoji: 🔊
4
  colorFrom: indigo
5
  colorTo: blue
@@ -8,12 +8,12 @@ app_file: app.py
8
  pinned: false
9
  ---
10
 
11
- # 🗣️ veureu-tts
12
 
13
- **veureu-tts** es un servicio **Docker** basado en **FastAPI** que forma parte del ecosistema **Veureu**.
14
  Su función es generar **pistas de audio o audiodescripción (AD)** en catalán a partir de texto o de archivos **SRT**, utilizando el plugin **Matxa-Alvocat TTS**.
15
 
16
- Este Space está diseñado para ser **invocado externamente** por otros Spaces (como `veureu-app` o `veureu-engine`) o por **servicios locales**.
17
 
18
  ---
19
 
 
1
  ---
2
+ title: tts
3
  emoji: 🔊
4
  colorFrom: indigo
5
  colorTo: blue
 
8
  pinned: false
9
  ---
10
 
11
+ # 🗣️ tts
12
 
13
+ **tts** es un servicio **Docker** basado en **FastAPI** que forma parte del ecosistema **Veureu**.
14
  Su función es generar **pistas de audio o audiodescripción (AD)** en catalán a partir de texto o de archivos **SRT**, utilizando el plugin **Matxa-Alvocat TTS**.
15
 
16
+ Este Space está diseñado para ser **invocado externamente** por otros Spaces (como `app` o `engine`) o por **servicios locales**.
17
 
18
  ---
19
 
local/local_tts_ad_from_srt.py CHANGED
@@ -90,347 +90,347 @@ def tts_to_wav(
90
  out_path: str,
91
  voice: str = "central/grau",
92
  tts: Optional[MatxaCatalanTTSPlugin] = None
93
- ) -> Tuple[int, np.ndarray]:
94
- created_tts = tts is None
95
- if tts is None:
96
- tts = MatxaCatalanTTSPlugin(config={})
97
-
98
- tts.get_tts(text, out_path, voice=voice)
99
-
100
- data, sr = sf.read(out_path, dtype="float32", always_2d=False)
101
- if created_tts:
102
- del tts
103
- if data.ndim == 2:
104
- data = data.mean(axis=1)
105
- return sr, data
106
-
107
-
108
- def trim_or_pad_to_duration(data: np.ndarray, sr: int, target_sec: float) -> np.ndarray:
109
- target_len = int(round(target_sec * sr))
110
- cur_len = len(data)
111
- if cur_len > target_len:
112
- return data[:target_len]
113
- elif cur_len < target_len:
114
- pad = np.zeros(target_len - cur_len, dtype=data.dtype)
115
- return np.concatenate([data, pad])
116
- return data
117
-
118
-
119
- def _resample_np(x: np.ndarray, sr_from: int, sr_to: int) -> np.ndarray:
120
- if sr_from == sr_to:
121
- return x
122
- ratio = sr_to / sr_from
123
- new_len = int(round(len(x) * ratio))
124
- xp = np.linspace(0, 1, num=len(x), endpoint=False)
125
- fp = x
126
- xq = np.linspace(0, 1, num=new_len, endpoint=False)
127
- yq = np.interp(xq, xp, fp).astype(np.float32)
128
- return yq
129
-
130
-
131
- def mix_segments_on_timeline(
132
- segments: List[Segment],
133
- voice: str,
134
- out_final: str,
135
- target_sr: Optional[int] = None
136
- ) -> str:
137
- """
138
- Genera un master de todos los segmentos AD, colocándolos en su timestamp SRT.
139
- Si out_final termina en .mp3, escribe MP3 (vía ffmpeg/pydub); si no, WAV.
140
- """
141
- if not segments:
142
- raise ValueError("No hay segmentos (AD) con contenido en el SRT.")
143
-
144
- total_dur = max(s.end_s for s in segments)
145
- tts = MatxaCatalanTTSPlugin(config={})
146
-
147
- tmpdir = tempfile.mkdtemp(prefix="matxa_ad_")
148
- tmp_clips: List[Tuple[int, np.ndarray, float, float]] = []
149
-
150
- for seg in segments:
151
- seg_wav = os.path.join(tmpdir, f"ad_{seg.idx}.wav")
152
- sr, data = tts_to_wav(seg.text, seg_wav, voice=voice, tts=tts)
153
- seg_dur = seg.end_s - seg.start_s
154
- data = trim_or_pad_to_duration(data, sr, seg_dur)
155
- tmp_clips.append((sr, data, seg.start_s, seg.end_s))
156
-
157
- master_sr = target_sr or tmp_clips[0][0]
158
- master_len = int(round(total_dur * master_sr))
159
- master = np.zeros(master_len, dtype=np.float32)
160
-
161
- for sr, data, start_s, _ in tmp_clips:
162
- d = _resample_np(data, sr, master_sr)
163
- start_i = int(round(start_s * master_sr))
164
- end_i = start_i + len(d)
165
- if end_i > len(master):
166
- end_i = len(master)
167
- d = d[: end_i - start_i]
168
- master[start_i:end_i] += d
169
-
170
- peak = np.max(np.abs(master)) if master.size else 0.0
171
- if peak > 0.999:
172
- master = (master / peak * 0.98).astype(np.float32)
173
-
174
- base, ext = os.path.splitext(out_final)
175
- if ext.lower() == ".mp3":
176
- tmp_wav = base + ".__tmp_master__.wav"
177
- sf.write(tmp_wav, master, master_sr, subtype="PCM_16")
178
- au = AudioSegment.from_wav(tmp_wav)
179
- au.export(out_final, format="mp3")
180
- os.remove(tmp_wav)
181
- return out_final
182
- else:
183
- out_wav = base + ".wav" if ext.lower() != ".wav" else out_final
184
- sf.write(out_wav, master, master_sr, subtype="PCM_16")
185
- return out_wav
186
-
187
-
188
- # ---------- (2) extraer audio de MP4 y mezclarlo con AD (simultáneo) ----------
189
-
190
- def ffmpeg_extract_audio_mp4_to_mp3(mp4_path: str, out_mp3_path: str, bitrate: str = "192k") -> str:
191
- """
192
- Extrae el audio del MP4 y lo guarda como MP3 (requiere ffmpeg).
193
- """
194
- cmd = [
195
- "ffmpeg", "-y",
196
- "-i", mp4_path,
197
- "-vn",
198
- "-acodec", "libmp3lame", "-b:a", bitrate,
199
- out_mp3_path
200
- ]
201
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
202
- return out_mp3_path
203
-
204
-
205
- def mix_two_audios_simultaneous(mp3_a_path: str, mp3_b_path: str, out_mp3_path: str, normalise: bool = True) -> str:
206
- """
207
- Mezcla simultáneamente dos MP3 (p.ej., audio original + AD) y exporta un MP3.
208
- - Ajusta la duración al máximo de ambas (rellena silencio si hace falta).
209
- - Si normalise=True, aplica una normalización suave para evitar clipping.
210
- """
211
- a = AudioSegment.from_file(mp3_a_path)
212
- b = AudioSegment.from_file(mp3_b_path)
213
-
214
- # Igualamos duración: fondo del más largo
215
- max_len = max(len(a), len(b))
216
- if len(a) < max_len:
217
- a = a.append(AudioSegment.silent(duration=max_len - len(a)), crossfade=0)
218
- if len(b) < max_len:
219
- b = b.append(AudioSegment.silent(duration=max_len - len(b)), crossfade=0)
220
-
221
- # Mezcla: simple overlay. Puedes bajar el AD o el original si lo deseas (dB).
222
- mixed = a.overlay(b) # overlay simultáneo
223
-
224
- if normalise:
225
- peak = mixed.max_dBFS # valor negativo, cercano a 0 dBFS
226
- headroom = -1.0 # deja 1 dB de margen
227
- gain = headroom - peak
228
- mixed = mixed.apply_gain(gain)
229
-
230
- mixed.export(out_mp3_path, format="mp3")
231
- return out_mp3_path
232
-
233
-
234
- # ---------- (3) generar MP4 final: vídeo mudo + pista mezclada ----------
235
-
236
- def ffmpeg_mux_video_with_audio(video_mp4: str, audio_mp3: str, out_mp4: str) -> str:
237
- """
238
- Crea un MP4 con el vídeo mudo del original y la pista de audio proporcionada.
239
- Mantiene el vídeo sin recomprimir (-c:v copy).
240
- """
241
- cmd = [
242
- "ffmpeg", "-y",
243
- "-i", video_mp4,
244
- "-i", audio_mp3,
245
- "-map", "0:v:0", # coge el vídeo de la 1ª entrada
246
- "-map", "1:a:0", # coge el audio de la 2ª entrada
247
- "-c:v", "copy",
248
- "-shortest",
249
- out_mp4
250
- ]
251
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
252
- return out_mp4
253
-
254
-
255
- # --------------------------- pipeline convenientes ----------------------------
256
-
257
- def build_ad_track_from_srt(srt_path: str, output_path: str = "ad_master.mp3", voice: str = "central/grau") -> str:
258
- segs = parse_srt_ad_only(srt_path)
259
- if not segs:
260
- raise SystemExit("No se encontraron bloques (AD) con contenido en el SRT.")
261
- result = mix_segments_on_timeline(segs, voice=voice, out_final=output_path)
262
- return result
263
-
264
- def generate_audio_from_free_ad(video_name: str, version_name: str, voice: str = "central/grau") -> Optional[str]:
265
- """
266
- Genera un archivo de audio MP3 a partir del free_ad.txt para un vídeo y versión específicos.
267
- """
268
- base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "veureu-app", "videos"))
269
- file_path = os.path.join(base_path, video_name, version_name, "free_ad.txt")
270
- output_mp3_path = os.path.join(base_path, video_name, version_name, "free_ad.mp3")
271
-
272
- if not os.path.exists(file_path):
273
- print(f"Advertencia: No se encontró {file_path}. Saltando.")
274
- return None
275
-
276
- with open(file_path, "r", encoding="utf-8") as f:
277
- text = f.read().strip()
278
-
279
- if not text:
280
- print(f"Advertencia: El archivo {file_path} está vacío. Saltando.")
281
- return None
282
-
283
- with tempfile.TemporaryDirectory() as tmpdir:
284
- tmp_wav = os.path.join(tmpdir, "temp_audio.wav")
285
- tts_to_wav(text, tmp_wav, voice=voice)
286
- audio = AudioSegment.from_wav(tmp_wav)
287
- audio.export(output_mp3_path, format="mp3")
288
-
289
- print(f"✔ Audio generado para {video_name}/{version_name} en: {output_mp3_path}")
290
- return output_mp3_path
291
-
292
-
293
- def iterate_generate_audio_from_free_ad(voice: str = "central/grau"):
294
- """
295
- Itera sobre todos los vídeos y versiones, y genera el audio para cada free_ad.txt.
296
- """
297
- base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "veureu-app", "videos"))
298
- if not os.path.isdir(base_path):
299
- print(f"Error: El directorio de vídeos no existe: {base_path}")
300
- return
301
-
302
- for video_name in os.listdir(base_path):
303
- video_path = os.path.join(base_path, video_name)
304
- if os.path.isdir(video_path):
305
- for version_name in os.listdir(video_path):
306
- version_path = os.path.join(video_path, version_name)
307
- if os.path.isdir(version_path):
308
- generate_audio_from_free_ad(video_name, version_name, voice=voice)
309
-
310
-
311
- def generate_video_from_une_ad(video_name: str, version_name: str, voice: str = "central/grau") -> Optional[str]:
312
- """
313
- Genera un vídeo con audiodescripción a partir del une_ad.srt para un vídeo y versión específicos.
314
- """
315
- base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "veureu-app", "videos"))
316
- video_path = os.path.join(base_path, video_name, f"{video_name}.mp4")
317
- srt_path = os.path.join(base_path, video_name, version_name, "une_ad.srt")
318
- output_video_path = os.path.join(base_path, video_name, version_name, f"{video_name}_ad.mp4")
319
-
320
- if not os.path.exists(video_path) or not os.path.exists(srt_path):
321
- print(f"Advertencia: No se encontró el vídeo o el SRT para {video_name}/{version_name}. Saltando.")
322
- return None
323
-
324
- # Rutas para los archivos intermedios
325
- out_ad_mp3 = os.path.join(base_path, video_name, version_name, "ad_master.mp3")
326
- out_mix_mp3 = os.path.join(base_path, video_name, version_name, "mix_original_plus_ad.mp3")
327
-
328
- try:
329
- make_final_assets_from_video_and_srt(
330
- video_mp4=video_path,
331
- srt_path=srt_path,
332
- out_ad_mp3=out_ad_mp3,
333
- out_mix_mp3=out_mix_mp3,
334
- out_final_mp4=output_video_path,
335
- voice=voice
336
- )
337
- print(f"✔ Vídeo con AD generado para {video_name}/{version_name} en: {output_video_path}")
338
- return output_video_path
339
- except Exception as e:
340
- print(f"Error al procesar {video_name}/{version_name}: {e}")
341
- return None
342
-
343
- def iterate_generate_video_from_une_ad(voice: str = "central/grau"):
344
- """
345
- Itera sobre todos los vídeos y versiones, y genera el vídeo con AD para cada une_ad.srt.
346
- """
347
- base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "veureu-app", "videos"))
348
- if not os.path.isdir(base_path):
349
- print(f"Error: El directorio de vídeos no existe: {base_path}")
350
- return
351
-
352
- for video_name in os.listdir(base_path):
353
- video_path = os.path.join(base_path, video_name)
354
- if os.path.isdir(video_path):
355
- for version_name in os.listdir(video_path):
356
- version_path = os.path.join(video_path, version_name)
357
- if os.path.isdir(version_path):
358
- generate_video_from_une_ad(video_name, version_name, voice=voice)
359
-
360
- def make_final_assets_from_video_and_srt(
361
- video_mp4: str,
362
- srt_path: str,
363
- out_ad_mp3: str = "ad_master.mp3",
364
- out_mix_mp3: str = "mix_original_plus_ad.mp3",
365
- out_final_mp4: str = "video_con_ad.mp4",
366
- voice: str = "central/grau"
367
- ) -> Tuple[str, str, str]:
368
- """
369
- Pipeline completo:
370
- 1) genera la pista AD desde el SRT,
371
- 2) extrae audio del MP4 a MP3,
372
- 3) mezcla simultánea original+AD a MP3,
373
- 4) remuxa vídeo mudo + pista mezclada a MP4 final.
374
- Devuelve rutas: (ad_mp3, mix_mp3, final_mp4)
375
- """
376
- ad_mp3 = build_ad_track_from_srt(srt_path, output_path=out_ad_mp3, voice=voice)
377
-
378
- ori_mp3 = ffmpeg_extract_audio_mp4_to_mp3(video_mp4, out_mp3_path=os.path.splitext(out_ad_mp3)[0] + "_original.mp3")
379
- mix_mp3 = mix_two_audios_simultaneous(ori_mp3, ad_mp3, out_mix_mp3)
380
-
381
- final_mp4 = ffmpeg_mux_video_with_audio(video_mp4, mix_mp3, out_final_mp4)
382
- return ad_mp3, mix_mp3, final_mp4
383
-
384
-
385
- # -------------------------------- CLI ---------------------------------------
386
-
387
- if __name__ == "__main__":
388
- import argparse
389
- ap = argparse.ArgumentParser(description="Genera AD desde SRT y compone con video/audio usando Matxa + ffmpeg.")
390
-
391
- # Argumentos existentes
392
- ap.add_argument("--srt", help="Ruta al archivo .srt")
393
- ap.add_argument("--video", help="Ruta al archivo .mp4 (para mezclar con AD y remux final)")
394
- ap.add_argument("-o", "--output", default="ad_master.mp3", help="Salida de la pista AD (mp3 o wav).")
395
- ap.add_argument("--voice", default="central/grau", help="Voz Matxa (ej: central/grau, central/elia)")
396
- ap.add_argument("--do-pipeline", action="store_true",
397
- help="Ejecuta pipeline completo: genera AD, extrae audio del video, mezcla ambos y crea MP4 final.")
398
- ap.add_argument("--mix-output", default="mix_original_plus_ad.mp3", help="Salida de audio mezclado (original+AD)")
399
- ap.add_argument("--final-mp4", default="video_con_ad.mp4", help="Salida del MP4 final con AD")
400
-
401
- # Nuevos argumentos para las funciones de iteración
402
- ap.add_argument("--generate-free-ad-audio", action="store_true", help="Genera audio para todos los archivos free_ad.txt existentes.")
403
- ap.add_argument("--generate-une-ad-video", action="store_true", help="Genera vídeo con AD para todos los archivos une_ad.srt existentes.")
404
-
405
- args = ap.parse_args()
406
-
407
- if args.generate_free_ad_audio:
408
- print("--- Iniciando generación de audios desde free_ad.txt ---")
409
- iterate_generate_audio_from_free_ad(voice=args.voice)
410
- print("--- Proceso de generación de audios completado ---")
411
-
412
- elif args.generate_une_ad_video:
413
- print("--- Iniciando generación de vídeos con AD desde une_ad.srt ---")
414
- iterate_generate_video_from_une_ad(voice=args.voice)
415
- print("--- Proceso de generación de vídeos completado ---")
416
-
417
- elif args.do_pipeline:
418
- if not args.srt or not args.video:
419
- raise SystemExit("Para --do-pipeline necesitas --srt y --video.")
420
- ad_mp3, mix_mp3, final_mp4 = make_final_assets_from_video_and_srt(
421
- args.video, args.srt,
422
- out_ad_mp3=args.output,
423
- out_mix_mp3=args.mix_output,
424
- out_final_mp4=args.final_mp4,
425
- voice=args.voice
426
- )
427
- print(f"✔ AD: {ad_mp3}")
428
- print(f"✔ MIX: {mix_mp3}")
429
- print(f"✔ MP4: {final_mp4}")
430
- else:
431
- if not args.srt:
432
- # Si no se especifica ninguna acción, mostrar ayuda
433
- ap.print_help()
434
- raise SystemExit("Debes especificar una acción, como --generate-free-ad-audio, --generate-une-ad-video o --do-pipeline.")
435
- result = build_ad_track_from_srt(args.srt, output_path=args.output, voice=args.voice)
436
- print(f"✔ Audio AD escrito en: {result}")
 
90
  out_path: str,
91
  voice: str = "central/grau",
92
  tts: Optional[MatxaCatalanTTSPlugin] = None
93
+ ) -> Tuple[int, np.ndarray]:
94
+ created_tts = tts is None
95
+ if tts is None:
96
+ tts = MatxaCatalanTTSPlugin(config={})
97
+
98
+ tts.get_tts(text, out_path, voice=voice)
99
+
100
+ data, sr = sf.read(out_path, dtype="float32", always_2d=False)
101
+ if created_tts:
102
+ del tts
103
+ if data.ndim == 2:
104
+ data = data.mean(axis=1)
105
+ return sr, data
106
+
107
+
108
+ def trim_or_pad_to_duration(data: np.ndarray, sr: int, target_sec: float) -> np.ndarray:
109
+ target_len = int(round(target_sec * sr))
110
+ cur_len = len(data)
111
+ if cur_len > target_len:
112
+ return data[:target_len]
113
+ elif cur_len < target_len:
114
+ pad = np.zeros(target_len - cur_len, dtype=data.dtype)
115
+ return np.concatenate([data, pad])
116
+ return data
117
+
118
+
119
+ def _resample_np(x: np.ndarray, sr_from: int, sr_to: int) -> np.ndarray:
120
+ if sr_from == sr_to:
121
+ return x
122
+ ratio = sr_to / sr_from
123
+ new_len = int(round(len(x) * ratio))
124
+ xp = np.linspace(0, 1, num=len(x), endpoint=False)
125
+ fp = x
126
+ xq = np.linspace(0, 1, num=new_len, endpoint=False)
127
+ yq = np.interp(xq, xp, fp).astype(np.float32)
128
+ return yq
129
+
130
+
131
+ def mix_segments_on_timeline(
132
+ segments: List[Segment],
133
+ voice: str,
134
+ out_final: str,
135
+ target_sr: Optional[int] = None
136
+ ) -> str:
137
+ """
138
+ Genera un master de todos los segmentos AD, colocándolos en su timestamp SRT.
139
+ Si out_final termina en .mp3, escribe MP3 (vía ffmpeg/pydub); si no, WAV.
140
+ """
141
+ if not segments:
142
+ raise ValueError("No hay segmentos (AD) con contenido en el SRT.")
143
+
144
+ total_dur = max(s.end_s for s in segments)
145
+ tts = MatxaCatalanTTSPlugin(config={})
146
+
147
+ tmpdir = tempfile.mkdtemp(prefix="matxa_ad_")
148
+ tmp_clips: List[Tuple[int, np.ndarray, float, float]] = []
149
+
150
+ for seg in segments:
151
+ seg_wav = os.path.join(tmpdir, f"ad_{seg.idx}.wav")
152
+ sr, data = tts_to_wav(seg.text, seg_wav, voice=voice, tts=tts)
153
+ seg_dur = seg.end_s - seg.start_s
154
+ data = trim_or_pad_to_duration(data, sr, seg_dur)
155
+ tmp_clips.append((sr, data, seg.start_s, seg.end_s))
156
+
157
+ master_sr = target_sr or tmp_clips[0][0]
158
+ master_len = int(round(total_dur * master_sr))
159
+ master = np.zeros(master_len, dtype=np.float32)
160
+
161
+ for sr, data, start_s, _ in tmp_clips:
162
+ d = _resample_np(data, sr, master_sr)
163
+ start_i = int(round(start_s * master_sr))
164
+ end_i = start_i + len(d)
165
+ if end_i > len(master):
166
+ end_i = len(master)
167
+ d = d[: end_i - start_i]
168
+ master[start_i:end_i] += d
169
+
170
+ peak = np.max(np.abs(master)) if master.size else 0.0
171
+ if peak > 0.999:
172
+ master = (master / peak * 0.98).astype(np.float32)
173
+
174
+ base, ext = os.path.splitext(out_final)
175
+ if ext.lower() == ".mp3":
176
+ tmp_wav = base + ".__tmp_master__.wav"
177
+ sf.write(tmp_wav, master, master_sr, subtype="PCM_16")
178
+ au = AudioSegment.from_wav(tmp_wav)
179
+ au.export(out_final, format="mp3")
180
+ os.remove(tmp_wav)
181
+ return out_final
182
+ else:
183
+ out_wav = base + ".wav" if ext.lower() != ".wav" else out_final
184
+ sf.write(out_wav, master, master_sr, subtype="PCM_16")
185
+ return out_wav
186
+
187
+
188
+ # ---------- (2) extraer audio de MP4 y mezclarlo con AD (simultáneo) ----------
189
+
190
+ def ffmpeg_extract_audio_mp4_to_mp3(mp4_path: str, out_mp3_path: str, bitrate: str = "192k") -> str:
191
+ """
192
+ Extrae el audio del MP4 y lo guarda como MP3 (requiere ffmpeg).
193
+ """
194
+ cmd = [
195
+ "ffmpeg", "-y",
196
+ "-i", mp4_path,
197
+ "-vn",
198
+ "-acodec", "libmp3lame", "-b:a", bitrate,
199
+ out_mp3_path
200
+ ]
201
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
202
+ return out_mp3_path
203
+
204
+
205
+ def mix_two_audios_simultaneous(mp3_a_path: str, mp3_b_path: str, out_mp3_path: str, normalise: bool = True) -> str:
206
+ """
207
+ Mezcla simultáneamente dos MP3 (p.ej., audio original + AD) y exporta un MP3.
208
+ - Ajusta la duración al máximo de ambas (rellena silencio si hace falta).
209
+ - Si normalise=True, aplica una normalización suave para evitar clipping.
210
+ """
211
+ a = AudioSegment.from_file(mp3_a_path)
212
+ b = AudioSegment.from_file(mp3_b_path)
213
+
214
+ # Igualamos duración: fondo del más largo
215
+ max_len = max(len(a), len(b))
216
+ if len(a) < max_len:
217
+ a = a.append(AudioSegment.silent(duration=max_len - len(a)), crossfade=0)
218
+ if len(b) < max_len:
219
+ b = b.append(AudioSegment.silent(duration=max_len - len(b)), crossfade=0)
220
+
221
+ # Mezcla: simple overlay. Puedes bajar el AD o el original si lo deseas (dB).
222
+ mixed = a.overlay(b) # overlay simultáneo
223
+
224
+ if normalise:
225
+ peak = mixed.max_dBFS # valor negativo, cercano a 0 dBFS
226
+ headroom = -1.0 # deja 1 dB de margen
227
+ gain = headroom - peak
228
+ mixed = mixed.apply_gain(gain)
229
+
230
+ mixed.export(out_mp3_path, format="mp3")
231
+ return out_mp3_path
232
+
233
+
234
+ # ---------- (3) generar MP4 final: vídeo mudo + pista mezclada ----------
235
+
236
+ def ffmpeg_mux_video_with_audio(video_mp4: str, audio_mp3: str, out_mp4: str) -> str:
237
+ """
238
+ Crea un MP4 con el vídeo mudo del original y la pista de audio proporcionada.
239
+ Mantiene el vídeo sin recomprimir (-c:v copy).
240
+ """
241
+ cmd = [
242
+ "ffmpeg", "-y",
243
+ "-i", video_mp4,
244
+ "-i", audio_mp3,
245
+ "-map", "0:v:0", # coge el vídeo de la 1ª entrada
246
+ "-map", "1:a:0", # coge el audio de la 2ª entrada
247
+ "-c:v", "copy",
248
+ "-shortest",
249
+ out_mp4
250
+ ]
251
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
252
+ return out_mp4
253
+
254
+
255
+ # --------------------------- pipeline convenientes ----------------------------
256
+
257
+ def build_ad_track_from_srt(srt_path: str, output_path: str = "ad_master.mp3", voice: str = "central/grau") -> str:
258
+ segs = parse_srt_ad_only(srt_path)
259
+ if not segs:
260
+ raise SystemExit("No se encontraron bloques (AD) con contenido en el SRT.")
261
+ result = mix_segments_on_timeline(segs, voice=voice, out_final=output_path)
262
+ return result
263
+
264
+ def generate_audio_from_free_ad(video_name: str, version_name: str, voice: str = "central/grau") -> Optional[str]:
265
+ """
266
+ Genera un archivo de audio MP3 a partir del free_ad.txt para un vídeo y versión específicos.
267
+ """
268
+ base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "app", "videos"))
269
+ file_path = os.path.join(base_path, video_name, version_name, "free_ad.txt")
270
+ output_mp3_path = os.path.join(base_path, video_name, version_name, "free_ad.mp3")
271
+
272
+ if not os.path.exists(file_path):
273
+ print(f"Advertencia: No se encontró {file_path}. Saltando.")
274
+ return None
275
+
276
+ with open(file_path, "r", encoding="utf-8") as f:
277
+ text = f.read().strip()
278
+
279
+ if not text:
280
+ print(f"Advertencia: El archivo {file_path} está vacío. Saltando.")
281
+ return None
282
+
283
+ with tempfile.TemporaryDirectory() as tmpdir:
284
+ tmp_wav = os.path.join(tmpdir, "temp_audio.wav")
285
+ tts_to_wav(text, tmp_wav, voice=voice)
286
+ audio = AudioSegment.from_wav(tmp_wav)
287
+ audio.export(output_mp3_path, format="mp3")
288
+
289
+ print(f"✔ Audio generado para {video_name}/{version_name} en: {output_mp3_path}")
290
+ return output_mp3_path
291
+
292
+
293
+ def iterate_generate_audio_from_free_ad(voice: str = "central/grau"):
294
+ """
295
+ Itera sobre todos los vídeos y versiones, y genera el audio para cada free_ad.txt.
296
+ """
297
+ base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "app", "videos"))
298
+ if not os.path.isdir(base_path):
299
+ print(f"Error: El directorio de vídeos no existe: {base_path}")
300
+ return
301
+
302
+ for video_name in os.listdir(base_path):
303
+ video_path = os.path.join(base_path, video_name)
304
+ if os.path.isdir(video_path):
305
+ for version_name in os.listdir(video_path):
306
+ version_path = os.path.join(video_path, version_name)
307
+ if os.path.isdir(version_path):
308
+ generate_audio_from_free_ad(video_name, version_name, voice=voice)
309
+
310
+
311
+ def generate_video_from_une_ad(video_name: str, version_name: str, voice: str = "central/grau") -> Optional[str]:
312
+ """
313
+ Genera un vídeo con audiodescripción a partir del une_ad.srt para un vídeo y versión específicos.
314
+ """
315
+ base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "app", "videos"))
316
+ video_path = os.path.join(base_path, video_name, f"{video_name}.mp4")
317
+ srt_path = os.path.join(base_path, video_name, version_name, "une_ad.srt")
318
+ output_video_path = os.path.join(base_path, video_name, version_name, f"{video_name}_ad.mp4")
319
+
320
+ if not os.path.exists(video_path) or not os.path.exists(srt_path):
321
+ print(f"Advertencia: No se encontró el vídeo o el SRT para {video_name}/{version_name}. Saltando.")
322
+ return None
323
+
324
+ # Rutas para los archivos intermedios
325
+ out_ad_mp3 = os.path.join(base_path, video_name, version_name, "ad_master.mp3")
326
+ out_mix_mp3 = os.path.join(base_path, video_name, version_name, "mix_original_plus_ad.mp3")
327
+
328
+ try:
329
+ make_final_assets_from_video_and_srt(
330
+ video_mp4=video_path,
331
+ srt_path=srt_path,
332
+ out_ad_mp3=out_ad_mp3,
333
+ out_mix_mp3=out_mix_mp3,
334
+ out_final_mp4=output_video_path,
335
+ voice=voice
336
+ )
337
+ print(f"✔ Vídeo con AD generado para {video_name}/{version_name} en: {output_video_path}")
338
+ return output_video_path
339
+ except Exception as e:
340
+ print(f"Error al procesar {video_name}/{version_name}: {e}")
341
+ return None
342
+
343
+ def iterate_generate_video_from_une_ad(voice: str = "central/grau"):
344
+ """
345
+ Itera sobre todos los vídeos y versiones, y genera el vídeo con AD para cada une_ad.srt.
346
+ """
347
+ base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "app", "videos"))
348
+ if not os.path.isdir(base_path):
349
+ print(f"Error: El directorio de vídeos no existe: {base_path}")
350
+ return
351
+
352
+ for video_name in os.listdir(base_path):
353
+ video_path = os.path.join(base_path, video_name)
354
+ if os.path.isdir(video_path):
355
+ for version_name in os.listdir(video_path):
356
+ version_path = os.path.join(video_path, version_name)
357
+ if os.path.isdir(version_path):
358
+ generate_video_from_une_ad(video_name, version_name, voice=voice)
359
+
360
+ def make_final_assets_from_video_and_srt(
361
+ video_mp4: str,
362
+ srt_path: str,
363
+ out_ad_mp3: str = "ad_master.mp3",
364
+ out_mix_mp3: str = "mix_original_plus_ad.mp3",
365
+ out_final_mp4: str = "video_con_ad.mp4",
366
+ voice: str = "central/grau"
367
+ ) -> Tuple[str, str, str]:
368
+ """
369
+ Pipeline completo:
370
+ 1) genera la pista AD desde el SRT,
371
+ 2) extrae audio del MP4 a MP3,
372
+ 3) mezcla simultánea original+AD a MP3,
373
+ 4) remuxa vídeo mudo + pista mezclada a MP4 final.
374
+ Devuelve rutas: (ad_mp3, mix_mp3, final_mp4)
375
+ """
376
+ ad_mp3 = build_ad_track_from_srt(srt_path, output_path=out_ad_mp3, voice=voice)
377
+
378
+ ori_mp3 = ffmpeg_extract_audio_mp4_to_mp3(video_mp4, out_mp3_path=os.path.splitext(out_ad_mp3)[0] + "_original.mp3")
379
+ mix_mp3 = mix_two_audios_simultaneous(ori_mp3, ad_mp3, out_mix_mp3)
380
+
381
+ final_mp4 = ffmpeg_mux_video_with_audio(video_mp4, mix_mp3, out_final_mp4)
382
+ return ad_mp3, mix_mp3, final_mp4
383
+
384
+
385
+ # -------------------------------- CLI ---------------------------------------
386
+
387
+ if __name__ == "__main__":
388
+ import argparse
389
+ ap = argparse.ArgumentParser(description="Genera AD desde SRT y compone con video/audio usando Matxa + ffmpeg.")
390
+
391
+ # Argumentos existentes
392
+ ap.add_argument("--srt", help="Ruta al archivo .srt")
393
+ ap.add_argument("--video", help="Ruta al archivo .mp4 (para mezclar con AD y remux final)")
394
+ ap.add_argument("-o", "--output", default="ad_master.mp3", help="Salida de la pista AD (mp3 o wav).")
395
+ ap.add_argument("--voice", default="central/grau", help="Voz Matxa (ej: central/grau, central/elia)")
396
+ ap.add_argument("--do-pipeline", action="store_true",
397
+ help="Ejecuta pipeline completo: genera AD, extrae audio del video, mezcla ambos y crea MP4 final.")
398
+ ap.add_argument("--mix-output", default="mix_original_plus_ad.mp3", help="Salida de audio mezclado (original+AD)")
399
+ ap.add_argument("--final-mp4", default="video_con_ad.mp4", help="Salida del MP4 final con AD")
400
+
401
+ # Nuevos argumentos para las funciones de iteración
402
+ ap.add_argument("--generate-free-ad-audio", action="store_true", help="Genera audio para todos los archivos free_ad.txt existentes.")
403
+ ap.add_argument("--generate-une-ad-video", action="store_true", help="Genera vídeo con AD para todos los archivos une_ad.srt existentes.")
404
+
405
+ args = ap.parse_args()
406
+
407
+ if args.generate_free_ad_audio:
408
+ print("--- Iniciando generación de audios desde free_ad.txt ---")
409
+ iterate_generate_audio_from_free_ad(voice=args.voice)
410
+ print("--- Proceso de generación de audios completado ---")
411
+
412
+ elif args.generate_une_ad_video:
413
+ print("--- Iniciando generación de vídeos con AD desde une_ad.srt ---")
414
+ iterate_generate_video_from_une_ad(voice=args.voice)
415
+ print("--- Proceso de generación de vídeos completado ---")
416
+
417
+ elif args.do_pipeline:
418
+ if not args.srt or not args.video:
419
+ raise SystemExit("Para --do-pipeline necesitas --srt y --video.")
420
+ ad_mp3, mix_mp3, final_mp4 = make_final_assets_from_video_and_srt(
421
+ args.video, args.srt,
422
+ out_ad_mp3=args.output,
423
+ out_mix_mp3=args.mix_output,
424
+ out_final_mp4=args.final_mp4,
425
+ voice=args.voice
426
+ )
427
+ print(f"✔ AD: {ad_mp3}")
428
+ print(f"✔ MIX: {mix_mp3}")
429
+ print(f"✔ MP4: {final_mp4}")
430
+ else:
431
+ if not args.srt:
432
+ # Si no se especifica ninguna acción, mostrar ayuda
433
+ ap.print_help()
434
+ raise SystemExit("Debes especificar una acción, como --generate-free-ad-audio, --generate-une-ad-video o --do-pipeline.")
435
+ result = build_ad_track_from_srt(args.srt, output_path=args.output, voice=args.voice)
436
+ print(f"✔ Audio AD escrito en: {result}")
test/client_veureu_tts.py CHANGED
@@ -1,6 +1,6 @@
1
  import requests
2
 
3
- BASE = "https://jesusfigueres-veureu-tts.hf.space" # o http://127.0.0.1:7860 si local
4
 
5
  def tts_text(text, voice="central/grau", fmt="mp3", out_path="out.mp3"):
6
  url = f"{BASE}/tts/text"
 
1
  import requests
2
 
3
+ BASE = "https://veureu-tts.hf.space" # o http://127.0.0.1:7860 si local
4
 
5
  def tts_text(text, voice="central/grau", fmt="mp3", out_path="out.mp3"):
6
  url = f"{BASE}/tts/text"