VeuReu commited on
Commit
dccfe73
·
verified ·
1 Parent(s): cf4287a

Delete tts_ad_from_srt.py

Browse files
Files changed (1) hide show
  1. tts_ad_from_srt.py +0 -351
tts_ad_from_srt.py DELETED
@@ -1,351 +0,0 @@
1
- import os
2
- import re
3
- import math
4
- import tempfile
5
- import subprocess
6
- from dataclasses import dataclass
7
- from typing import List, Optional, Tuple
8
-
9
- import numpy as np
10
- import soundfile as sf
11
-
12
- # TTS plugin Matxa (ONNX/OVOS)
13
- from ovos_tts_plugin_matxa_multispeaker_cat import MatxaCatalanTTSPlugin
14
-
15
- # MP3 (vía ffmpeg) con pydub
16
- from pydub import AudioSegment
17
-
18
-
19
- @dataclass
20
- class Segment:
21
- idx: int
22
- start_s: float
23
- end_s: float
24
- text: str # ya sin "(AD): "
25
-
26
-
27
- SRT_TS = re.compile(
28
- r"(?P<h1>\d{2}):(?P<m1>\d{2}):(?P<s1>\d{2}),(?P<ms1>\d{3})\s*-->\s*"
29
- r"(?P<h2>\d{2}):(?P<m2>\d{2}):(?P<s2>\d{2}),(?P<ms2>\d{3})"
30
- )
31
-
32
- def _ts_to_seconds(h: str, m: str, s: str, ms: str) -> float:
33
- return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
34
-
35
- def _is_empty_ad_text(t: str) -> bool:
36
- """
37
- Devuelve True si el texto de AD está vacío de contenido (solo espacios/puntuación).
38
- """
39
- # quita espacios y signos; si no queda nada, es "vacío"
40
- cleaned = re.sub(r"[^\wÀ-ÿ]", "", t, flags=re.UNICODE) # conserva letras y dígitos (incluye acentos)
41
- return len(cleaned.strip()) == 0
42
-
43
- def parse_srt_ad_only(path: str) -> List[Segment]:
44
- """
45
- Devuelve sólo segmentos cuyo bloque contiene líneas que empiezan por '(AD):', '[AD]:', '(AD)' o '[AD]'.
46
- Ignora los (AD) sin información (punto 1 del encargo).
47
- """
48
- with open(path, "r", encoding="utf-8") as f:
49
- content = f.read()
50
-
51
- content = content.replace("\r\n", "\n").replace("\r", "\n")
52
- blocks = [b.strip() for b in re.split(r"\n\s*\n", content) if b.strip()]
53
-
54
- segs: List[Segment] = []
55
- for block in blocks:
56
- lines = block.split("\n")
57
- if len(lines) < 2:
58
- continue
59
- try:
60
- idx = int(lines[0].strip())
61
- ts_line = lines[1].strip()
62
- m = SRT_TS.match(ts_line)
63
- if not m:
64
- continue
65
- start_s = _ts_to_seconds(m["h1"], m["m1"], m["s1"], m["ms1"])
66
- end_s = _ts_to_seconds(m["h2"], m["m2"], m["s2"], m["ms2"])
67
-
68
- ad_texts = []
69
- for t in lines[2:]:
70
- t = t.strip()
71
- # Aceptar múltiples formatos: (AD):, [AD]:, (AD), [AD]
72
- if (t.startswith("(AD):") or t.startswith("[AD]:") or
73
- t.startswith("(AD)") and not t.startswith("(AD):") or
74
- t.startswith("[AD]") and not t.startswith("[AD]:")):
75
-
76
- # Extraer el texto después del prefijo
77
- if t.startswith("(AD):"):
78
- t = t[len("(AD):"):].lstrip()
79
- elif t.startswith("[AD]:"):
80
- t = t[len("[AD]:"):].lstrip()
81
- elif t.startswith("(AD)"):
82
- t = t[len("(AD)"):].lstrip()
83
- elif t.startswith("[AD]"):
84
- t = t[len("[AD]"):].lstrip()
85
-
86
- if t and not _is_empty_ad_text(t):
87
- ad_texts.append(t)
88
-
89
- if not ad_texts:
90
- continue # ignora bloques (AD) vacíos o sin contenido
91
-
92
- text = " ".join(ad_texts)
93
- segs.append(Segment(idx=idx, start_s=start_s, end_s=end_s, text=text))
94
- except Exception:
95
- continue
96
-
97
- segs.sort(key=lambda s: (s.start_s, s.idx))
98
- return segs
99
-
100
-
101
- def tts_to_wav(
102
- text: str,
103
- out_path: str,
104
- voice: str = "central/grau",
105
- tts: Optional[MatxaCatalanTTSPlugin] = None
106
- ) -> Tuple[int, np.ndarray]:
107
- created_tts = tts is None
108
- # No necesitamos inicializar tts aquí, ya se inicializa en app.py (get_tts)
109
- # y se pasa aquí, o se inicializa con MatxaCatalanTTSPlugin() en mix_segments_on_timeline.
110
- if tts is None:
111
- # CORRECCIÓN TEMPORAL: Inicializamos aquí si no viene para CLI (aunque en FastAPI lo hace get_tts)
112
- tts = MatxaCatalanTTSPlugin()
113
-
114
- tts.get_tts(text, out_path, voice=voice)
115
-
116
- data, sr = sf.read(out_path, dtype="float32", always_2d=False)
117
- if created_tts:
118
- # Si lo creamos aquí, lo borramos. En FastAPI se reutiliza.
119
- del tts
120
- if data.ndim == 2:
121
- data = data.mean(axis=1)
122
- return sr, data
123
-
124
-
125
- def trim_or_pad_to_duration(data: np.ndarray, sr: int, target_sec: float) -> np.ndarray:
126
- target_len = int(round(target_sec * sr))
127
- cur_len = len(data)
128
- if cur_len > target_len:
129
- return data[:target_len]
130
- elif cur_len < target_len:
131
- pad = np.zeros(target_len - cur_len, dtype=data.dtype)
132
- return np.concatenate([data, pad])
133
- return data
134
-
135
-
136
- def _resample_np(x: np.ndarray, sr_from: int, sr_to: int) -> np.ndarray:
137
- if sr_from == sr_to:
138
- return x
139
- ratio = sr_to / sr_from
140
- new_len = int(round(len(x) * ratio))
141
- xp = np.linspace(0, 1, num=len(x), endpoint=False)
142
- fp = x
143
- xq = np.linspace(0, 1, num=new_len, endpoint=False)
144
- yq = np.interp(xq, xp, fp).astype(np.float32)
145
- return yq
146
-
147
-
148
- def mix_segments_on_timeline(
149
- segments: List[Segment],
150
- voice: str,
151
- out_final: str,
152
- target_sr: Optional[int] = None
153
- ) -> str:
154
- """
155
- Genera un master de todos los segmentos AD, colocándolos en su timestamp SRT.
156
- Si out_final termina en .mp3, escribe MP3 (vía ffmpeg/pydub); si no, WAV.
157
- """
158
- if not segments:
159
- raise ValueError("No hay segmentos (AD) con contenido en el SRT.")
160
-
161
- total_dur = max(s.end_s for s in segments)
162
-
163
- # CORRECCIÓN CLAVE: Inicializar sin argumentos para evitar TypeError: 'lang'
164
- # Esta inicialización es para el modo CLI o si se usa fuera de FastAPI.
165
- tts = MatxaCatalanTTSPlugin()
166
-
167
- tmpdir = tempfile.mkdtemp(prefix="matxa_ad_")
168
- tmp_clips: List[Tuple[int, np.ndarray, float, float]] = []
169
-
170
- for seg in segments:
171
- seg_wav = os.path.join(tmpdir, f"ad_{seg.idx}.wav")
172
- # Pasamos la instancia tts creada arriba
173
- sr, data = tts_to_wav(seg.text, seg_wav, voice=voice, tts=tts)
174
- seg_dur = seg.end_s - seg.start_s
175
- data = trim_or_pad_to_duration(data, sr, seg_dur)
176
- tmp_clips.append((sr, data, seg.start_s, seg.end_s))
177
-
178
- master_sr = target_sr or tmp_clips[0][0]
179
- master_len = int(round(total_dur * master_sr))
180
- master = np.zeros(master_len, dtype=np.float32)
181
-
182
- for sr, data, start_s, _ in tmp_clips:
183
- d = _resample_np(data, sr, master_sr)
184
- start_i = int(round(start_s * master_sr))
185
- end_i = start_i + len(d)
186
- if end_i > len(master):
187
- end_i = len(master)
188
- d = d[: end_i - start_i]
189
- master[start_i:end_i] += d
190
-
191
- peak = np.max(np.abs(master)) if master.size else 0.0
192
- if peak > 0.999:
193
- master = (master / peak * 0.98).astype(np.float32)
194
-
195
- base, ext = os.path.splitext(out_final)
196
- if ext.lower() == ".mp3":
197
- tmp_wav = base + ".__tmp_master__.wav"
198
- sf.write(tmp_wav, master, master_sr, subtype="PCM_16")
199
- au = AudioSegment.from_wav(tmp_wav)
200
- au.export(out_final, format="mp3")
201
- os.remove(tmp_wav)
202
- return out_final
203
- else:
204
- out_wav = base + ".wav" if ext.lower() != ".wav" else out_final
205
- sf.write(out_wav, master, master_sr, subtype="PCM_16")
206
- return out_wav
207
-
208
-
209
- # ---------- (2) extraer audio de MP4 y mezclarlo con AD (simultáneo) ----------
210
-
211
- def ffmpeg_extract_audio_mp4_to_mp3(mp4_path: str, out_mp3_path: str, bitrate: str = "192k") -> str:
212
- """
213
- Extrae el audio del MP4 y lo guarda como MP3 (requiere ffmpeg).
214
- """
215
- cmd = [
216
- "ffmpeg", "-y",
217
- "-i", mp4_path,
218
- "-vn",
219
- "-acodec", "libmp3lame", "-b:a", bitrate,
220
- out_mp3_path
221
- ]
222
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
223
- return out_mp3_path
224
-
225
-
226
- def mix_two_audios_simultaneous(mp3_a_path: str, mp3_b_path: str, out_mp3_path: str, normalise: bool = True) -> str:
227
- """
228
- Mezcla simultáneamente dos MP3 (p.ej., audio original + AD) y exporta un MP3.
229
- - Ajusta la duración al máximo de ambas (rellena silencio si hace falta).
230
- - Si normalise=True, aplica una normalización suave para evitar clipping.
231
- """
232
- a = AudioSegment.from_file(mp3_a_path)
233
- b = AudioSegment.from_file(mp3_b_path)
234
-
235
- # Igualamos duración: fondo del más largo
236
- max_len = max(len(a), len(b))
237
- if len(a) < max_len:
238
- a = a.append(AudioSegment.silent(duration=max_len - len(a)), crossfade=0)
239
- if len(b) < max_len:
240
- b = b.append(AudioSegment.silent(duration=max_len - len(b)), crossfade=0)
241
-
242
- # Mezcla: simple overlay. Puedes bajar el AD o el original si lo deseas (dB).
243
- mixed = a.overlay(b) # overlay simultáneo
244
-
245
- if normalise:
246
- peak = mixed.max_dBFS # valor negativo, cercano a 0 dBFS
247
- headroom = -1.0 # deja 1 dB de margen
248
- gain = headroom - peak
249
- mixed = mixed.apply_gain(gain)
250
-
251
- mixed.export(out_mp3_path, format="mp3")
252
- return out_mp3_path
253
-
254
-
255
- # ---------- (3) generar MP4 final: vídeo mudo + pista mezclada ----------
256
-
257
- def ffmpeg_mux_video_with_audio(video_mp4: str, audio_mp3: str, out_mp4: str) -> str:
258
- """
259
- Crea un MP4 con el vídeo mudo del original y la pista de audio proporcionada.
260
- Mantiene el vídeo sin recomprimir (-c:v copy).
261
- """
262
- cmd = [
263
- "ffmpeg", "-y",
264
- "-i", video_mp4,
265
- "-i", audio_mp3,
266
- "-map", "0:v:0", # coge el vídeo de la 1ª entrada
267
- "-map", "1:a:0", # coge el audio de la 2ª entrada
268
- "-c:v", "copy",
269
- "-shortest",
270
- out_mp4
271
- ]
272
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
273
- return out_mp4
274
-
275
-
276
- # --------------------------- pipeline convenientes ----------------------------
277
-
278
- def build_ad_track_from_srt(srt_path: str, output_path: str = "ad_master.mp3", voice: str = "central/grau") -> str:
279
- segs = parse_srt_ad_only(srt_path)
280
- if not segs:
281
- # En lugar de fallar, crear un archivo de audio silencioso
282
- print("⚠️ No se encontraron bloques (AD) con contenido en el SRT. Creando pista silenciosa.")
283
- from pydub import AudioSegment
284
- # Crear 1 segundo de silencio
285
- silence = AudioSegment.silent(duration=1000)
286
- silence.export(output_path, format="mp3" if output_path.endswith(".mp3") else "wav")
287
- return output_path
288
-
289
- result = mix_segments_on_timeline(segs, voice=voice, out_final=output_path)
290
- return result
291
-
292
- def make_final_assets_from_video_and_srt(
293
- video_mp4: str,
294
- srt_path: str,
295
- out_ad_mp3: str = "ad_master.mp3",
296
- out_mix_mp3: str = "mix_original_plus_ad.mp3",
297
- out_final_mp4: str = "video_con_ad.mp4",
298
- voice: str = "upc_ona-medium"
299
- ) -> Tuple[str, str, str]:
300
- """
301
- Pipeline completo:
302
- 1) genera la pista AD desde el SRT,
303
- 2) extrae audio del MP4 a MP3,
304
- 3) mezcla simultánea original+AD a MP3,
305
- 4) remuxa vídeo mudo + pista mezclada a MP4 final.
306
- Devuelve rutas: (ad_mp3, mix_mp3, final_mp4)
307
- """
308
- ad_mp3 = build_ad_track_from_srt(srt_path, output_path=out_ad_mp3, voice=voice)
309
-
310
- ori_mp3 = ffmpeg_extract_audio_mp4_to_mp3(video_mp4, out_mp3_path=os.path.splitext(out_ad_mp3)[0] + "_original.mp3")
311
- mix_mp3 = mix_two_audios_simultaneous(ori_mp3, ad_mp3, out_mix_mp3)
312
-
313
- final_mp4 = ffmpeg_mux_video_with_audio(video_mp4, mix_mp3, out_final_mp4)
314
- return ad_mp3, mix_mp3, final_mp4
315
-
316
-
317
- # -------------------------------- CLI ---------------------------------------
318
-
319
- if __name__ == "__main__":
320
- import argparse
321
- ap = argparse.ArgumentParser(description="Genera AD desde SRT y compone con video/audio usando Matxa + ffmpeg.")
322
- ap.add_argument("--srt", help="Ruta al archivo .srt")
323
- ap.add_argument("--video", help="Ruta al archivo .mp4 (para mezclar con AD y remux final)")
324
- ap.add_argument("-o", "--output", default="ad_master.mp3", help="Salida de la pista AD (mp3 o wav).")
325
- ap.add_argument("--voice", default="central/grau", help="Voz Matxa (ej: central/grau, upc/pau-medium)")
326
- ap.add_argument("--do-pipeline", action="store_true",
327
- help="Ejecuta pipeline completo: genera AD, extrae audio del video, mezcla ambos y crea MP4 final.")
328
-
329
- ap.add_argument("--mix-output", default="mix_original_plus_ad.mp3", help="Salida de audio mezclado (original+AD)")
330
- ap.add_argument("--final-mp4", default="video_con_ad.mp4", help="Salida del MP4 final con AD")
331
-
332
- args = ap.parse_args()
333
-
334
- if args.do_pipeline:
335
- if not args.srt or not args.video:
336
- raise SystemExit("Para --do-pipeline necesitas --srt y --video.")
337
- ad_mp3, mix_mp3, final_mp4 = make_final_assets_from_video_and_srt(
338
- args.video, args.srt,
339
- out_ad_mp3=args.output,
340
- out_mix_mp3=args.mix_output,
341
- out_final_mp4=args.final_mp4,
342
- voice=args.voice
343
- )
344
- print(f"✔ AD: {ad_mp3}")
345
- print(f"✔ MIX: {mix_mp3}")
346
- print(f"✔ MP4: {final_mp4}")
347
- else:
348
- if not args.srt:
349
- raise SystemExit("Especifica --srt o usa --do-pipeline con --video.")
350
- result = build_ad_track_from_srt(args.srt, output_path=args.output, voice=args.voice)
351
- print(f"✔ Audio AD escrito en: {result}")