Spaces:
Running on Zero
Running on Zero
Commit ·
107501d
1
Parent(s): 853d0fe
Add MMS_FA forced alignment — return word timestamps per audio slot
Browse filesEach 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>
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 *
|
| 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
|