vicentemovil Claude Sonnet 4.6 commited on
Commit
107501d
·
1 Parent(s): 853d0fe

Add MMS_FA forced alignment — return word timestamps per audio slot

Browse files

Each generated audio now runs forced alignment (torchaudio MMS_FA) on the
cleaned text and returns [{text, start, end}] JSON — same structure as Fish
TTS word timestamps — so callers can build SRT using their own logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +88 -4
app.py CHANGED
@@ -273,6 +273,10 @@ def load_models():
273
  pass
274
 
275
 
 
 
 
 
276
 
277
  def compile_model(should_compile):
278
  """Compile the model for faster inference."""
@@ -322,6 +326,79 @@ def do_compile():
322
  return gr.update(value=f"✗ Compilation failed: {str(e)}", visible=True), gr.update(interactive=True)
323
 
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  def save_audio_with_format(audio_tensor: torch.Tensor, base_path: Path, filename: str, sample_rate: int, audio_format: str) -> Path:
326
  """Save audio in specified format, fallback to WAV if MP3 encoding fails."""
327
  if audio_format == "mp3":
@@ -2182,7 +2259,7 @@ def generate_unified(
2182
  texts = texts[:MAX_TEXTS]
2183
 
2184
  if not texts:
2185
- return tuple([gr.update(visible=False)] * (1 + MAX_TEXTS * 2))
2186
 
2187
  # Determine speaker audio path (URL or file)
2188
  speaker_audio_path = None
@@ -2268,6 +2345,7 @@ def generate_unified(
2268
  # Initialize outputs
2269
  result_labels = [gr.update(visible=False)] * MAX_TEXTS
2270
  result_audios = [gr.update(visible=False)] * MAX_TEXTS
 
2271
  time_display = gr.update(visible=False)
2272
 
2273
  # Generate each text
@@ -2308,8 +2386,12 @@ def generate_unified(
2308
  _cdn = upload_to_r2(audio_path)
2309
  result_audios[i] = gr.update(value=_cdn if _cdn else str(audio_path), visible=True)
2310
 
 
 
 
 
2311
  # Yield progressive results
2312
- yield tuple([time_display] + result_labels + result_audios)
2313
 
2314
  # Final time display
2315
  batch_duration = time.time() - batch_start
@@ -2318,7 +2400,7 @@ def generate_unified(
2318
  visible=True
2319
  )
2320
 
2321
- yield tuple([time_display] + result_labels + result_audios)
2322
 
2323
 
2324
  with gr.Blocks(title="Echo-TTS", css=LINK_CSS + SIMPLE_CSS, js=JS_CODE) as demo:
@@ -2426,9 +2508,11 @@ with gr.Blocks(title="Echo-TTS", css=LINK_CSS + SIMPLE_CSS, js=JS_CODE) as demo:
2426
  # Pre-allocate output slots (up to MAX_TEXTS)
2427
  output_labels = []
2428
  output_audios = []
 
2429
  for i in range(MAX_TEXTS):
2430
  output_labels.append(gr.Markdown(visible=False))
2431
  output_audios.append(gr.Textbox(visible=False, interactive=False, label=f"Audio URL {i+1}"))
 
2432
 
2433
  # Session state
2434
  session_id_state = gr.State(None)
@@ -2503,7 +2587,7 @@ with gr.Blocks(title="Echo-TTS", css=LINK_CSS + SIMPLE_CSS, js=JS_CODE) as demo:
2503
  audio_format,
2504
  session_id_state,
2505
  ],
2506
- outputs=[generation_time_display] + output_labels + output_audios
2507
  )
2508
 
2509
  # Initialize session
 
273
  pass
274
 
275
 
276
+ # Load MMS_FA alignment model at startup so ZeroGPU preloads it
277
+ _get_mms_fa()
278
+
279
+
280
 
281
  def compile_model(should_compile):
282
  """Compile the model for faster inference."""
 
326
  return gr.update(value=f"✗ Compilation failed: {str(e)}", visible=True), gr.update(interactive=True)
327
 
328
 
329
+ def _clean_text_echo(text: str) -> str:
330
+ """Strip (parenthetical cues) for SRT alignment."""
331
+ import re
332
+ return re.sub(r'\([^)]*\)', '', text).strip()
333
+
334
+
335
+ _mms_fa_bundle = None
336
+ _mms_fa_model = None
337
+ _mms_fa_dict = None
338
+ _mms_fa_sample_rate = None
339
+
340
+
341
+ def _get_mms_fa():
342
+ global _mms_fa_bundle, _mms_fa_model, _mms_fa_dict, _mms_fa_sample_rate
343
+ if _mms_fa_model is None:
344
+ _mms_fa_bundle = torchaudio.pipelines.MMS_FA
345
+ _mms_fa_model = _mms_fa_bundle.get_model(with_star=False).cuda()
346
+ _mms_fa_dict = _mms_fa_bundle.get_dict()
347
+ _mms_fa_sample_rate = _mms_fa_bundle.sample_rate
348
+ return _mms_fa_model, _mms_fa_dict, _mms_fa_sample_rate
349
+
350
+
351
+ def align_words(audio_path: str, text: str) -> list:
352
+ """
353
+ Forced-align text to audio using MMS_FA.
354
+ Returns [{text, start, end}, ...] — same structure as Fish TTS word timestamps.
355
+ """
356
+ model, dictionary, bundle_sr = _get_mms_fa()
357
+
358
+ waveform, sr = torchaudio.load(audio_path)
359
+ if waveform.shape[0] > 1:
360
+ waveform = waveform.mean(dim=0, keepdim=True)
361
+ if sr != bundle_sr:
362
+ waveform = torchaudio.functional.resample(waveform, sr, bundle_sr)
363
+ waveform = waveform.cuda()
364
+
365
+ words = text.split()
366
+ if not words:
367
+ return []
368
+
369
+ try:
370
+ with torch.inference_mode():
371
+ emission, _ = model(waveform)
372
+
373
+ tokenised = []
374
+ kept_words = []
375
+ for w in words:
376
+ tokens = [dictionary[c] for c in w.lower() if c in dictionary]
377
+ if tokens:
378
+ tokenised.append(tokens)
379
+ kept_words.append(w)
380
+
381
+ if not tokenised:
382
+ return []
383
+
384
+ token_spans = torchaudio.functional.forced_align(emission, tokenised, blank=0)
385
+
386
+ num_frames = emission.shape[1]
387
+ audio_duration = waveform.shape[-1] / bundle_sr
388
+
389
+ result = []
390
+ for word, spans in zip(kept_words, token_spans):
391
+ result.append({
392
+ "text": word,
393
+ "start": round(spans[0].start / num_frames * audio_duration, 4),
394
+ "end": round(spans[-1].end / num_frames * audio_duration, 4),
395
+ })
396
+ return result
397
+ except Exception as e:
398
+ print(f"[align] Alignment failed: {e}")
399
+ return []
400
+
401
+
402
  def save_audio_with_format(audio_tensor: torch.Tensor, base_path: Path, filename: str, sample_rate: int, audio_format: str) -> Path:
403
  """Save audio in specified format, fallback to WAV if MP3 encoding fails."""
404
  if audio_format == "mp3":
 
2259
  texts = texts[:MAX_TEXTS]
2260
 
2261
  if not texts:
2262
+ return tuple([gr.update(visible=False)] * (1 + MAX_TEXTS * 3))
2263
 
2264
  # Determine speaker audio path (URL or file)
2265
  speaker_audio_path = None
 
2345
  # Initialize outputs
2346
  result_labels = [gr.update(visible=False)] * MAX_TEXTS
2347
  result_audios = [gr.update(visible=False)] * MAX_TEXTS
2348
+ result_srts = [gr.update(visible=False)] * MAX_TEXTS
2349
  time_display = gr.update(visible=False)
2350
 
2351
  # Generate each text
 
2386
  _cdn = upload_to_r2(audio_path)
2387
  result_audios[i] = gr.update(value=_cdn if _cdn else str(audio_path), visible=True)
2388
 
2389
+ # Forced alignment → word timestamps (same structure as Fish TTS)
2390
+ words = align_words(str(audio_path), _clean_text_echo(text))
2391
+ result_srts[i] = gr.update(value=json.dumps(words, ensure_ascii=False), visible=True)
2392
+
2393
  # Yield progressive results
2394
+ yield tuple([time_display] + result_labels + result_audios + result_srts)
2395
 
2396
  # Final time display
2397
  batch_duration = time.time() - batch_start
 
2400
  visible=True
2401
  )
2402
 
2403
+ yield tuple([time_display] + result_labels + result_audios + result_srts)
2404
 
2405
 
2406
  with gr.Blocks(title="Echo-TTS", css=LINK_CSS + SIMPLE_CSS, js=JS_CODE) as demo:
 
2508
  # Pre-allocate output slots (up to MAX_TEXTS)
2509
  output_labels = []
2510
  output_audios = []
2511
+ output_srts = []
2512
  for i in range(MAX_TEXTS):
2513
  output_labels.append(gr.Markdown(visible=False))
2514
  output_audios.append(gr.Textbox(visible=False, interactive=False, label=f"Audio URL {i+1}"))
2515
+ output_srts.append(gr.Textbox(visible=False, interactive=False, label=f"SRT {i+1}", lines=4))
2516
 
2517
  # Session state
2518
  session_id_state = gr.State(None)
 
2587
  audio_format,
2588
  session_id_state,
2589
  ],
2590
+ outputs=[generation_time_display] + output_labels + output_audios + output_srts
2591
  )
2592
 
2593
  # Initialize session