Spaces:
Running on Zero
Running on Zero
revert
Browse files
app.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""DialogueSidon — two-speaker dialogue separation demo.
|
| 3 |
-
|
| 4 |
Loads exported torch.export components from sarulab-speech/DialogueSidon on
|
| 5 |
Hugging Face Hub and runs diffusion-based speaker separation.
|
| 6 |
Inputs up to 120 s are processed in one shot; longer inputs use chunked
|
|
@@ -12,7 +11,6 @@ import json
|
|
| 12 |
import os
|
| 13 |
import subprocess
|
| 14 |
import tempfile
|
| 15 |
-
import time
|
| 16 |
|
| 17 |
try:
|
| 18 |
import spaces
|
|
@@ -96,20 +94,20 @@ def extract_fbank_features(
|
|
| 96 |
mean = feat.mean(0, keepdim=True)
|
| 97 |
var = feat.var(0, keepdim=True)
|
| 98 |
feat = (feat - mean) / torch.sqrt(var + 1e-5)
|
| 99 |
-
features.append(feat.to(device
|
| 100 |
|
| 101 |
input_features, attention_mask = _pad_batch(features)
|
| 102 |
b, t, c = input_features.shape
|
| 103 |
t = (t // stride) * stride
|
| 104 |
input_features = input_features[:, :t, :]
|
| 105 |
attention_mask = attention_mask[:, :t]
|
| 106 |
-
input_features = input_features.reshape(b, t // stride, c * stride)
|
| 107 |
-
attention_mask = attention_mask[:, 1::stride]
|
| 108 |
return {"input_features": input_features, "attention_mask": attention_mask}
|
| 109 |
|
| 110 |
|
| 111 |
# ---------------------------------------------------------------------------
|
| 112 |
-
# Model loading (cached
|
| 113 |
# ---------------------------------------------------------------------------
|
| 114 |
|
| 115 |
_cache: dict = {}
|
|
@@ -126,7 +124,6 @@ def load_models(device: torch.device) -> dict:
|
|
| 126 |
with open(paths["metadata.json"]) as fp:
|
| 127 |
meta = json.load(fp)
|
| 128 |
|
| 129 |
-
print(f"Loading models onto {device} ...")
|
| 130 |
ssl_encoder = torch.export.load(paths["ssl_encoder.pt2"]).module().to(device)
|
| 131 |
diffusion_head = torch.export.load(paths["diffusion_head.pt2"]).module().to(device)
|
| 132 |
vae_decoder = torch.export.load(paths["vae_decoder.pt2"]).module().to(device)
|
|
@@ -186,10 +183,9 @@ def _separate_chunk(
|
|
| 186 |
latent_dim = models["latent_dim"]
|
| 187 |
|
| 188 |
noisy_ssl = extract_fbank_features([wav.view(-1)], device)
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
features, pred0, pred1 = models["ssl_encoder"](input_features)
|
| 193 |
|
| 194 |
predicted_latents = torch.cat([pred0, pred1], dim=-1)
|
| 195 |
conditioning = torch.cat([_normalize(predicted_latents, models), features], dim=-1)
|
|
@@ -207,7 +203,7 @@ def _separate_chunk(
|
|
| 207 |
).prev_sample
|
| 208 |
|
| 209 |
latents = _denormalize(latents, models)
|
| 210 |
-
spk1 = models["vae_decoder"](latents[:, :, :latent_dim].transpose(1, 2)).squeeze(0)
|
| 211 |
spk2 = models["vae_decoder"](latents[:, :, latent_dim:].transpose(1, 2)).squeeze(0)
|
| 212 |
return torch.cat([spk1, spk2], dim=0) # [2, T]
|
| 213 |
|
|
@@ -241,6 +237,7 @@ def separate(
|
|
| 241 |
models = load_models(device)
|
| 242 |
out_sr = models["sample_rate"]
|
| 243 |
|
|
|
|
| 244 |
if sample_rate != SAMPLE_RATE_IN:
|
| 245 |
wav_16k = torchaudio.functional.resample(wav, sample_rate, SAMPLE_RATE_IN)
|
| 246 |
else:
|
|
@@ -251,11 +248,13 @@ def separate(
|
|
| 251 |
total_samples = wav_16k.shape[-1]
|
| 252 |
|
| 253 |
if total_samples <= chunk_samples:
|
|
|
|
| 254 |
max_val = wav_16k.abs().max().clamp_min(1e-6)
|
| 255 |
wav_norm = torch.nn.functional.pad(0.9 * wav_16k / max_val, (160, 160))
|
| 256 |
separated = _separate_chunk(wav_norm, num_steps, models, device)
|
| 257 |
return separated, out_sr
|
| 258 |
|
|
|
|
| 259 |
overlap_samples_in = int(OVERLAP_SECONDS * SAMPLE_RATE_IN)
|
| 260 |
hop_samples = chunk_samples - overlap_samples_in
|
| 261 |
starts = list(range(0, total_samples, hop_samples))
|
|
@@ -263,14 +262,15 @@ def separate(
|
|
| 263 |
stitched: torch.Tensor | None = None
|
| 264 |
prev_end_in = 0
|
| 265 |
|
| 266 |
-
for start in starts:
|
| 267 |
end = min(start + chunk_samples, total_samples)
|
| 268 |
chunk = wav_16k[:, start:end]
|
| 269 |
max_val = chunk.abs().max().clamp_min(1e-6)
|
| 270 |
chunk_norm = torch.nn.functional.pad(0.9 * chunk / max_val, (160, 160))
|
| 271 |
|
| 272 |
-
pred = _separate_chunk(chunk_norm, num_steps, models, device)
|
| 273 |
|
|
|
|
| 274 |
target_out = max(1, round((end - start) * out_sr / SAMPLE_RATE_IN))
|
| 275 |
if pred.shape[-1] > target_out:
|
| 276 |
pred = pred[:, :target_out]
|
|
@@ -284,14 +284,11 @@ def separate(
|
|
| 284 |
continue
|
| 285 |
|
| 286 |
overlap_in = max(0, prev_end_in - start)
|
| 287 |
-
overlap_out = max(
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
pred.shape[-1],
|
| 293 |
-
),
|
| 294 |
-
)
|
| 295 |
|
| 296 |
if overlap_out > 0:
|
| 297 |
pred, _ = _maybe_swap(stitched[:, -overlap_out:], pred, overlap_out)
|
|
@@ -315,7 +312,8 @@ def extract_audio_from_video(video_path: str) -> tuple[torch.Tensor, int]:
|
|
| 315 |
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 316 |
tmp_path = tmp.name
|
| 317 |
subprocess.run(
|
| 318 |
-
["ffmpeg", "-y", "-i", video_path, "-ac", "1", "-ar", str(SAMPLE_RATE_IN),
|
|
|
|
| 319 |
check=True,
|
| 320 |
stdout=subprocess.DEVNULL,
|
| 321 |
stderr=subprocess.DEVNULL,
|
|
@@ -331,9 +329,13 @@ def create_stereo_video(
|
|
| 331 |
spk2: np.ndarray,
|
| 332 |
out_sr: int,
|
| 333 |
) -> str:
|
| 334 |
-
"""Mux separated speakers (L=spk1, R=spk2) back into a video file.
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
stereo_tensor = torch.from_numpy(stereo).float()
|
|
|
|
| 337 |
peak = stereo_tensor.abs().max().clamp_min(1e-6)
|
| 338 |
stereo_tensor = stereo_tensor / peak * 0.9
|
| 339 |
|
|
@@ -347,13 +349,13 @@ def create_stereo_video(
|
|
| 347 |
subprocess.run(
|
| 348 |
[
|
| 349 |
"ffmpeg", "-y",
|
| 350 |
-
"-i", video_path,
|
| 351 |
-
"-i", audio_path,
|
| 352 |
-
"-c:v", "copy",
|
| 353 |
-
"-c:a", "aac",
|
| 354 |
"-b:a", "192k",
|
| 355 |
-
"-map", "0:v:0",
|
| 356 |
-
"-map", "1:a:0",
|
| 357 |
"-shortest",
|
| 358 |
out_path,
|
| 359 |
],
|
|
@@ -365,45 +367,6 @@ def create_stereo_video(
|
|
| 365 |
return out_path
|
| 366 |
|
| 367 |
|
| 368 |
-
# ---------------------------------------------------------------------------
|
| 369 |
-
# RTF report & mel helpers
|
| 370 |
-
# ---------------------------------------------------------------------------
|
| 371 |
-
|
| 372 |
-
def _extract_mel_db(wav_np: np.ndarray, sr: int, n_mels: int = 80) -> np.ndarray:
|
| 373 |
-
wav_t = torch.from_numpy(wav_np.copy()).float()
|
| 374 |
-
if wav_t.ndim != 1:
|
| 375 |
-
wav_t = wav_t.reshape(-1)
|
| 376 |
-
peak = wav_t.abs().max().clamp_min(1e-6)
|
| 377 |
-
wav_t = (wav_t / peak).unsqueeze(0)
|
| 378 |
-
mel_tf = torchaudio.transforms.MelSpectrogram(
|
| 379 |
-
sample_rate=sr, n_fft=1024, hop_length=256, n_mels=n_mels
|
| 380 |
-
)
|
| 381 |
-
mel = mel_tf(wav_t)
|
| 382 |
-
mel_db = torchaudio.transforms.AmplitudeToDB(stype="power", top_db=80)(mel)
|
| 383 |
-
return mel_db[0].numpy()
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
def _rtf_report(duration_s: float, proc_time_s: float, mel_stats: dict, device: str = "unknown") -> str:
|
| 387 |
-
rtf = proc_time_s / max(duration_s, 1e-6)
|
| 388 |
-
lines = [
|
| 389 |
-
"=== DialogueSidon Separation Report ===",
|
| 390 |
-
"",
|
| 391 |
-
f"Device : {device}",
|
| 392 |
-
f"Input duration : {duration_s:.2f} s",
|
| 393 |
-
f"Processing time : {proc_time_s:.2f} s",
|
| 394 |
-
f"RTF : {rtf:.3f}x ({'faster' if rtf < 1.0 else 'slower'} than real-time)",
|
| 395 |
-
"",
|
| 396 |
-
"--- Mel Spectrogram Statistics (80 bins) ---",
|
| 397 |
-
f"{'Source':<12} {'Mean (dB)':>12} {'Peak (dB)':>12} {'Std (dB)':>12}",
|
| 398 |
-
"-" * 52,
|
| 399 |
-
]
|
| 400 |
-
for name, stats in mel_stats.items():
|
| 401 |
-
lines.append(
|
| 402 |
-
f"{name:<12} {stats['mean']:>12.1f} {stats['peak']:>12.1f} {stats['std']:>12.1f}"
|
| 403 |
-
)
|
| 404 |
-
return "\n".join(lines)
|
| 405 |
-
|
| 406 |
-
|
| 407 |
# ---------------------------------------------------------------------------
|
| 408 |
# Gradio interface
|
| 409 |
# ---------------------------------------------------------------------------
|
|
@@ -413,7 +376,8 @@ def get_device() -> torch.device:
|
|
| 413 |
|
| 414 |
|
| 415 |
def _wav_to_numpy_output(wav_tensor: torch.Tensor, sr: int) -> tuple[int, np.ndarray]:
|
| 416 |
-
arr = wav_tensor.
|
|
|
|
| 417 |
arr = np.clip(arr / max(np.abs(arr).max(), 1e-6) * 0.9, -1.0, 1.0)
|
| 418 |
return sr, (arr * 32767).astype(np.int16)
|
| 419 |
|
|
@@ -421,13 +385,11 @@ def _wav_to_numpy_output(wav_tensor: torch.Tensor, sr: int) -> tuple[int, np.nda
|
|
| 421 |
def run_separation_audio(
|
| 422 |
input_audio: tuple[int, np.ndarray] | None,
|
| 423 |
num_steps: int,
|
| 424 |
-
) -> tuple[tuple[int, np.ndarray], tuple[int, np.ndarray]
|
| 425 |
if input_audio is None:
|
| 426 |
raise gr.Error("Please upload an audio file.")
|
| 427 |
|
| 428 |
sr, audio_np = input_audio
|
| 429 |
-
duration_s = audio_np.shape[0] / sr
|
| 430 |
-
|
| 431 |
wav = torch.from_numpy(audio_np.copy()).float()
|
| 432 |
|
| 433 |
if wav.ndim == 1:
|
|
@@ -437,68 +399,39 @@ def run_separation_audio(
|
|
| 437 |
wav = wav.T
|
| 438 |
wav = wav.mean(dim=0, keepdim=True)
|
| 439 |
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
wav = wav / scale
|
| 443 |
|
| 444 |
device = get_device()
|
|
|
|
| 445 |
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
spk1_out = _wav_to_numpy_output(separated[0], out_sr)
|
| 451 |
-
spk2_out = _wav_to_numpy_output(separated[1], out_sr)
|
| 452 |
-
|
| 453 |
-
audio_mono = audio_np.mean(axis=-1) if audio_np.ndim == 2 else audio_np
|
| 454 |
-
mel_in = _extract_mel_db((audio_mono / scale).astype(np.float32), sr)
|
| 455 |
-
mel_s1 = _extract_mel_db(spk1_out[1].astype(np.float32) / 32767.0, out_sr)
|
| 456 |
-
mel_s2 = _extract_mel_db(spk2_out[1].astype(np.float32) / 32767.0, out_sr)
|
| 457 |
-
|
| 458 |
-
mel_stats = {
|
| 459 |
-
"Input": {"mean": float(mel_in.mean()), "peak": float(mel_in.max()), "std": float(mel_in.std())},
|
| 460 |
-
"Speaker 1": {"mean": float(mel_s1.mean()), "peak": float(mel_s1.max()), "std": float(mel_s1.std())},
|
| 461 |
-
"Speaker 2": {"mean": float(mel_s2.mean()), "peak": float(mel_s2.max()), "std": float(mel_s2.std())},
|
| 462 |
-
}
|
| 463 |
-
report = _rtf_report(duration_s, proc_time, mel_stats, str(device))
|
| 464 |
-
|
| 465 |
-
return spk1_out, spk2_out, report
|
| 466 |
|
| 467 |
|
| 468 |
def run_separation_video(
|
| 469 |
video_path: str | None,
|
| 470 |
num_steps: int,
|
| 471 |
-
) -> tuple[str | None, tuple[int, np.ndarray], tuple[int, np.ndarray]
|
| 472 |
if video_path is None:
|
| 473 |
raise gr.Error("Please upload a video file.")
|
| 474 |
|
| 475 |
wav, sr = extract_audio_from_video(video_path)
|
| 476 |
-
duration_s = wav.shape[-1] / sr
|
| 477 |
device = get_device()
|
|
|
|
| 478 |
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
proc_time = time.perf_counter() - t0
|
| 482 |
|
| 483 |
-
spk1_np = separated[0].detach().cpu().numpy()
|
| 484 |
-
spk2_np = separated[1].detach().cpu().numpy()
|
| 485 |
out_video = create_stereo_video(video_path, spk1_np, spk2_np, out_sr)
|
| 486 |
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
mel_s2 = _extract_mel_db(spk2_out[1].astype(np.float32) / 32767.0, out_sr)
|
| 493 |
-
|
| 494 |
-
mel_stats = {
|
| 495 |
-
"Input": {"mean": float(mel_in.mean()), "peak": float(mel_in.max()), "std": float(mel_in.std())},
|
| 496 |
-
"Speaker 1": {"mean": float(mel_s1.mean()), "peak": float(mel_s1.max()), "std": float(mel_s1.std())},
|
| 497 |
-
"Speaker 2": {"mean": float(mel_s2.mean()), "peak": float(mel_s2.max()), "std": float(mel_s2.std())},
|
| 498 |
-
}
|
| 499 |
-
report = _rtf_report(duration_s, proc_time, mel_stats, str(device))
|
| 500 |
-
|
| 501 |
-
return out_video, spk1_out, spk2_out, report
|
| 502 |
|
| 503 |
|
| 504 |
if HAS_SPACES:
|
|
@@ -513,10 +446,8 @@ with gr.Blocks(title="DialogueSidon — Dialogue Separation") as demo:
|
|
| 513 |
Upload a degraded or noisy audio/video recording of a two-speaker conversation.
|
| 514 |
DialogueSidon jointly **separates** the two speakers and **restores** clean, high-quality speech
|
| 515 |
from the mixture — handling background noise, reverberation, and channel degradation in one pass.
|
| 516 |
-
|
| 517 |
Inputs up to 120 s are processed in one shot. Longer inputs are processed in 120 s chunks
|
| 518 |
with 10 s overlap crossfade and automatic speaker re-alignment across chunks.
|
| 519 |
-
|
| 520 |
**Model**: [sarulab-speech/DialogueSidon](https://huggingface.co/sarulab-speech/DialogueSidon)
|
| 521 |
"""
|
| 522 |
)
|
|
@@ -527,6 +458,9 @@ with gr.Blocks(title="DialogueSidon — Dialogue Separation") as demo:
|
|
| 527 |
)
|
| 528 |
|
| 529 |
with gr.Tabs():
|
|
|
|
|
|
|
|
|
|
| 530 |
with gr.Tab("Audio"):
|
| 531 |
with gr.Row():
|
| 532 |
with gr.Column():
|
|
@@ -540,20 +474,20 @@ with gr.Blocks(title="DialogueSidon — Dialogue Separation") as demo:
|
|
| 540 |
audio_spk1 = gr.Audio(label="Speaker 1", type="numpy")
|
| 541 |
audio_spk2 = gr.Audio(label="Speaker 2", type="numpy")
|
| 542 |
|
| 543 |
-
audio_report = gr.Textbox(
|
| 544 |
-
label="RTF Report", lines=10, max_lines=15, interactive=False
|
| 545 |
-
)
|
| 546 |
audio_btn.click(
|
| 547 |
fn=run_separation_audio,
|
| 548 |
inputs=[audio_input, num_steps],
|
| 549 |
-
outputs=[audio_spk1, audio_spk2
|
| 550 |
)
|
| 551 |
gr.Examples(
|
| 552 |
-
examples=[["LDC2026S02_mono.wav",
|
| 553 |
-
inputs=[audio_input,
|
| 554 |
label="Ex1"
|
| 555 |
)
|
| 556 |
|
|
|
|
|
|
|
|
|
|
| 557 |
with gr.Tab("Video"):
|
| 558 |
with gr.Row():
|
| 559 |
with gr.Column():
|
|
@@ -569,13 +503,10 @@ with gr.Blocks(title="DialogueSidon — Dialogue Separation") as demo:
|
|
| 569 |
video_spk1 = gr.Audio(label="Speaker 1 (audio only)", type="numpy")
|
| 570 |
video_spk2 = gr.Audio(label="Speaker 2 (audio only)", type="numpy")
|
| 571 |
|
| 572 |
-
video_report = gr.Textbox(
|
| 573 |
-
label="RTF Report", lines=10, max_lines=15, interactive=False
|
| 574 |
-
)
|
| 575 |
video_btn.click(
|
| 576 |
fn=run_separation_video,
|
| 577 |
inputs=[video_input, num_steps],
|
| 578 |
-
outputs=[video_output, video_spk1, video_spk2
|
| 579 |
)
|
| 580 |
|
| 581 |
gr.Markdown("---\n**License**: CC-BY-NC 4.0 — non-commercial use only.")
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""DialogueSidon — two-speaker dialogue separation demo.
|
|
|
|
| 3 |
Loads exported torch.export components from sarulab-speech/DialogueSidon on
|
| 4 |
Hugging Face Hub and runs diffusion-based speaker separation.
|
| 5 |
Inputs up to 120 s are processed in one shot; longer inputs use chunked
|
|
|
|
| 11 |
import os
|
| 12 |
import subprocess
|
| 13 |
import tempfile
|
|
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
import spaces
|
|
|
|
| 94 |
mean = feat.mean(0, keepdim=True)
|
| 95 |
var = feat.var(0, keepdim=True)
|
| 96 |
feat = (feat - mean) / torch.sqrt(var + 1e-5)
|
| 97 |
+
features.append(feat.to(device))
|
| 98 |
|
| 99 |
input_features, attention_mask = _pad_batch(features)
|
| 100 |
b, t, c = input_features.shape
|
| 101 |
t = (t // stride) * stride
|
| 102 |
input_features = input_features[:, :t, :]
|
| 103 |
attention_mask = attention_mask[:, :t]
|
| 104 |
+
input_features = input_features.reshape(b, t // stride, c * stride)
|
| 105 |
+
attention_mask = attention_mask[:, 1::stride]
|
| 106 |
return {"input_features": input_features, "attention_mask": attention_mask}
|
| 107 |
|
| 108 |
|
| 109 |
# ---------------------------------------------------------------------------
|
| 110 |
+
# Model loading (cached)
|
| 111 |
# ---------------------------------------------------------------------------
|
| 112 |
|
| 113 |
_cache: dict = {}
|
|
|
|
| 124 |
with open(paths["metadata.json"]) as fp:
|
| 125 |
meta = json.load(fp)
|
| 126 |
|
|
|
|
| 127 |
ssl_encoder = torch.export.load(paths["ssl_encoder.pt2"]).module().to(device)
|
| 128 |
diffusion_head = torch.export.load(paths["diffusion_head.pt2"]).module().to(device)
|
| 129 |
vae_decoder = torch.export.load(paths["vae_decoder.pt2"]).module().to(device)
|
|
|
|
| 183 |
latent_dim = models["latent_dim"]
|
| 184 |
|
| 185 |
noisy_ssl = extract_fbank_features([wav.view(-1)], device)
|
| 186 |
+
features, pred0, pred1 = models["ssl_encoder"](
|
| 187 |
+
noisy_ssl["input_features"], noisy_ssl["attention_mask"]
|
| 188 |
+
)
|
|
|
|
| 189 |
|
| 190 |
predicted_latents = torch.cat([pred0, pred1], dim=-1)
|
| 191 |
conditioning = torch.cat([_normalize(predicted_latents, models), features], dim=-1)
|
|
|
|
| 203 |
).prev_sample
|
| 204 |
|
| 205 |
latents = _denormalize(latents, models)
|
| 206 |
+
spk1 = models["vae_decoder"](latents[:, :, :latent_dim].transpose(1, 2)).squeeze(0) # [1, T]
|
| 207 |
spk2 = models["vae_decoder"](latents[:, :, latent_dim:].transpose(1, 2)).squeeze(0)
|
| 208 |
return torch.cat([spk1, spk2], dim=0) # [2, T]
|
| 209 |
|
|
|
|
| 237 |
models = load_models(device)
|
| 238 |
out_sr = models["sample_rate"]
|
| 239 |
|
| 240 |
+
# resample to 16 kHz
|
| 241 |
if sample_rate != SAMPLE_RATE_IN:
|
| 242 |
wav_16k = torchaudio.functional.resample(wav, sample_rate, SAMPLE_RATE_IN)
|
| 243 |
else:
|
|
|
|
| 248 |
total_samples = wav_16k.shape[-1]
|
| 249 |
|
| 250 |
if total_samples <= chunk_samples:
|
| 251 |
+
# single-shot inference
|
| 252 |
max_val = wav_16k.abs().max().clamp_min(1e-6)
|
| 253 |
wav_norm = torch.nn.functional.pad(0.9 * wav_16k / max_val, (160, 160))
|
| 254 |
separated = _separate_chunk(wav_norm, num_steps, models, device)
|
| 255 |
return separated, out_sr
|
| 256 |
|
| 257 |
+
# chunked streaming inference
|
| 258 |
overlap_samples_in = int(OVERLAP_SECONDS * SAMPLE_RATE_IN)
|
| 259 |
hop_samples = chunk_samples - overlap_samples_in
|
| 260 |
starts = list(range(0, total_samples, hop_samples))
|
|
|
|
| 262 |
stitched: torch.Tensor | None = None
|
| 263 |
prev_end_in = 0
|
| 264 |
|
| 265 |
+
for idx, start in enumerate(starts):
|
| 266 |
end = min(start + chunk_samples, total_samples)
|
| 267 |
chunk = wav_16k[:, start:end]
|
| 268 |
max_val = chunk.abs().max().clamp_min(1e-6)
|
| 269 |
chunk_norm = torch.nn.functional.pad(0.9 * chunk / max_val, (160, 160))
|
| 270 |
|
| 271 |
+
pred = _separate_chunk(chunk_norm, num_steps, models, device) # [2, T_out]
|
| 272 |
|
| 273 |
+
# match output length to input length (resampling ratio)
|
| 274 |
target_out = max(1, round((end - start) * out_sr / SAMPLE_RATE_IN))
|
| 275 |
if pred.shape[-1] > target_out:
|
| 276 |
pred = pred[:, :target_out]
|
|
|
|
| 284 |
continue
|
| 285 |
|
| 286 |
overlap_in = max(0, prev_end_in - start)
|
| 287 |
+
overlap_out = max(0, min(
|
| 288 |
+
round(overlap_in * out_sr / SAMPLE_RATE_IN),
|
| 289 |
+
stitched.shape[-1],
|
| 290 |
+
pred.shape[-1],
|
| 291 |
+
))
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
if overlap_out > 0:
|
| 294 |
pred, _ = _maybe_swap(stitched[:, -overlap_out:], pred, overlap_out)
|
|
|
|
| 312 |
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 313 |
tmp_path = tmp.name
|
| 314 |
subprocess.run(
|
| 315 |
+
["ffmpeg", "-y", "-i", video_path, "-ac", "1", "-ar", str(SAMPLE_RATE_IN),
|
| 316 |
+
"-vn", tmp_path],
|
| 317 |
check=True,
|
| 318 |
stdout=subprocess.DEVNULL,
|
| 319 |
stderr=subprocess.DEVNULL,
|
|
|
|
| 329 |
spk2: np.ndarray,
|
| 330 |
out_sr: int,
|
| 331 |
) -> str:
|
| 332 |
+
"""Mux separated speakers (L=spk1, R=spk2) back into a video file.
|
| 333 |
+
Returns path to the output video (caller is responsible for cleanup).
|
| 334 |
+
"""
|
| 335 |
+
# Write stereo audio to a temp wav
|
| 336 |
+
stereo = np.stack([spk1, spk2], axis=0) # [2, T]
|
| 337 |
stereo_tensor = torch.from_numpy(stereo).float()
|
| 338 |
+
# Normalise to avoid clipping
|
| 339 |
peak = stereo_tensor.abs().max().clamp_min(1e-6)
|
| 340 |
stereo_tensor = stereo_tensor / peak * 0.9
|
| 341 |
|
|
|
|
| 349 |
subprocess.run(
|
| 350 |
[
|
| 351 |
"ffmpeg", "-y",
|
| 352 |
+
"-i", video_path, # original video (video stream)
|
| 353 |
+
"-i", audio_path, # new stereo audio
|
| 354 |
+
"-c:v", "copy", # copy video stream unchanged
|
| 355 |
+
"-c:a", "aac", # encode audio as AAC
|
| 356 |
"-b:a", "192k",
|
| 357 |
+
"-map", "0:v:0", # take video from first input
|
| 358 |
+
"-map", "1:a:0", # take audio from second input
|
| 359 |
"-shortest",
|
| 360 |
out_path,
|
| 361 |
],
|
|
|
|
| 367 |
return out_path
|
| 368 |
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
# ---------------------------------------------------------------------------
|
| 371 |
# Gradio interface
|
| 372 |
# ---------------------------------------------------------------------------
|
|
|
|
| 376 |
|
| 377 |
|
| 378 |
def _wav_to_numpy_output(wav_tensor: torch.Tensor, sr: int) -> tuple[int, np.ndarray]:
|
| 379 |
+
arr = wav_tensor.cpu().numpy()
|
| 380 |
+
# Convert to int16 for Gradio audio output
|
| 381 |
arr = np.clip(arr / max(np.abs(arr).max(), 1e-6) * 0.9, -1.0, 1.0)
|
| 382 |
return sr, (arr * 32767).astype(np.int16)
|
| 383 |
|
|
|
|
| 385 |
def run_separation_audio(
|
| 386 |
input_audio: tuple[int, np.ndarray] | None,
|
| 387 |
num_steps: int,
|
| 388 |
+
) -> tuple[tuple[int, np.ndarray], tuple[int, np.ndarray]]:
|
| 389 |
if input_audio is None:
|
| 390 |
raise gr.Error("Please upload an audio file.")
|
| 391 |
|
| 392 |
sr, audio_np = input_audio
|
|
|
|
|
|
|
| 393 |
wav = torch.from_numpy(audio_np.copy()).float()
|
| 394 |
|
| 395 |
if wav.ndim == 1:
|
|
|
|
| 399 |
wav = wav.T
|
| 400 |
wav = wav.mean(dim=0, keepdim=True)
|
| 401 |
|
| 402 |
+
if audio_np.dtype in (np.int16, np.int32):
|
| 403 |
+
wav = wav / float(np.iinfo(audio_np.dtype).max)
|
|
|
|
| 404 |
|
| 405 |
device = get_device()
|
| 406 |
+
separated, out_sr = separate(wav, sr, num_steps, device)
|
| 407 |
|
| 408 |
+
return (
|
| 409 |
+
_wav_to_numpy_output(separated[0], out_sr),
|
| 410 |
+
_wav_to_numpy_output(separated[1], out_sr),
|
| 411 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
|
| 414 |
def run_separation_video(
|
| 415 |
video_path: str | None,
|
| 416 |
num_steps: int,
|
| 417 |
+
) -> tuple[str | None, tuple[int, np.ndarray], tuple[int, np.ndarray]]:
|
| 418 |
if video_path is None:
|
| 419 |
raise gr.Error("Please upload a video file.")
|
| 420 |
|
| 421 |
wav, sr = extract_audio_from_video(video_path)
|
|
|
|
| 422 |
device = get_device()
|
| 423 |
+
separated, out_sr = separate(wav, sr, num_steps, device)
|
| 424 |
|
| 425 |
+
spk1_np = separated[0].cpu().numpy()
|
| 426 |
+
spk2_np = separated[1].cpu().numpy()
|
|
|
|
| 427 |
|
|
|
|
|
|
|
| 428 |
out_video = create_stereo_video(video_path, spk1_np, spk2_np, out_sr)
|
| 429 |
|
| 430 |
+
return (
|
| 431 |
+
out_video,
|
| 432 |
+
_wav_to_numpy_output(separated[0], out_sr),
|
| 433 |
+
_wav_to_numpy_output(separated[1], out_sr),
|
| 434 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
|
| 437 |
if HAS_SPACES:
|
|
|
|
| 446 |
Upload a degraded or noisy audio/video recording of a two-speaker conversation.
|
| 447 |
DialogueSidon jointly **separates** the two speakers and **restores** clean, high-quality speech
|
| 448 |
from the mixture — handling background noise, reverberation, and channel degradation in one pass.
|
|
|
|
| 449 |
Inputs up to 120 s are processed in one shot. Longer inputs are processed in 120 s chunks
|
| 450 |
with 10 s overlap crossfade and automatic speaker re-alignment across chunks.
|
|
|
|
| 451 |
**Model**: [sarulab-speech/DialogueSidon](https://huggingface.co/sarulab-speech/DialogueSidon)
|
| 452 |
"""
|
| 453 |
)
|
|
|
|
| 458 |
)
|
| 459 |
|
| 460 |
with gr.Tabs():
|
| 461 |
+
# ------------------------------------------------------------------
|
| 462 |
+
# Audio tab
|
| 463 |
+
# ------------------------------------------------------------------
|
| 464 |
with gr.Tab("Audio"):
|
| 465 |
with gr.Row():
|
| 466 |
with gr.Column():
|
|
|
|
| 474 |
audio_spk1 = gr.Audio(label="Speaker 1", type="numpy")
|
| 475 |
audio_spk2 = gr.Audio(label="Speaker 2", type="numpy")
|
| 476 |
|
|
|
|
|
|
|
|
|
|
| 477 |
audio_btn.click(
|
| 478 |
fn=run_separation_audio,
|
| 479 |
inputs=[audio_input, num_steps],
|
| 480 |
+
outputs=[audio_spk1, audio_spk2],
|
| 481 |
)
|
| 482 |
gr.Examples(
|
| 483 |
+
examples=[["LDC2026S02_mono.wav",100]],
|
| 484 |
+
inputs=[audio_input,num_steps],
|
| 485 |
label="Ex1"
|
| 486 |
)
|
| 487 |
|
| 488 |
+
# ------------------------------------------------------------------
|
| 489 |
+
# Video tab
|
| 490 |
+
# ------------------------------------------------------------------
|
| 491 |
with gr.Tab("Video"):
|
| 492 |
with gr.Row():
|
| 493 |
with gr.Column():
|
|
|
|
| 503 |
video_spk1 = gr.Audio(label="Speaker 1 (audio only)", type="numpy")
|
| 504 |
video_spk2 = gr.Audio(label="Speaker 2 (audio only)", type="numpy")
|
| 505 |
|
|
|
|
|
|
|
|
|
|
| 506 |
video_btn.click(
|
| 507 |
fn=run_separation_video,
|
| 508 |
inputs=[video_input, num_steps],
|
| 509 |
+
outputs=[video_output, video_spk1, video_spk2],
|
| 510 |
)
|
| 511 |
|
| 512 |
gr.Markdown("---\n**License**: CC-BY-NC 4.0 — non-commercial use only.")
|