Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python3 | |
| """DialogueSidon — two-speaker dialogue separation demo. | |
| Loads exported torch.export components from sarulab-speech/DialogueSidon on | |
| Hugging Face Hub and runs diffusion-based speaker separation. | |
| Inputs up to 120 s are processed in one shot; longer inputs use chunked | |
| streaming inference with crossfade stitching and speaker re-alignment. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import subprocess | |
| import tempfile | |
| try: | |
| import spaces | |
| HAS_SPACES = True | |
| except ImportError: | |
| HAS_SPACES = False | |
| import numpy as np | |
| import torch | |
| import torchaudio | |
| import gradio as gr | |
| from diffusers import DPMSolverMultistepScheduler | |
| from huggingface_hub import hf_hub_download | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| # --------------------------------------------------------------------------- | |
| # Constants | |
| # --------------------------------------------------------------------------- | |
| REPO_ID = "sarulab-speech/DialogueSidon" | |
| MODEL_FILES = ["ssl_encoder.pt2", "diffusion_head.pt2", "vae_decoder.pt2", "metadata.json"] | |
| SAMPLE_RATE_IN = 16_000 | |
| CHUNK_SECONDS = 120.0 | |
| OVERLAP_SECONDS = 10.0 | |
| # --------------------------------------------------------------------------- | |
| # Feature extraction (inline, no sidon src dependency) | |
| # --------------------------------------------------------------------------- | |
| def _pad_batch( | |
| features: list[torch.Tensor], | |
| pad_to_multiple_of: int = 2, | |
| padding_value: float = 0.0, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| target_length = max(f.shape[0] for f in features) | |
| if pad_to_multiple_of: | |
| target_length = ( | |
| (target_length + pad_to_multiple_of - 1) | |
| // pad_to_multiple_of | |
| * pad_to_multiple_of | |
| ) | |
| batch_size = len(features) | |
| feature_dim = features[0].shape[1] | |
| device = features[0].device | |
| padded = torch.full( | |
| (batch_size, target_length, feature_dim), | |
| padding_value, | |
| dtype=torch.float32, | |
| device=device, | |
| ) | |
| mask = torch.zeros((batch_size, target_length), dtype=torch.int64, device=device) | |
| for i, feat in enumerate(features): | |
| padded[i, : feat.shape[0]] = feat | |
| mask[i, : feat.shape[0]] = 1 | |
| return padded, mask | |
| def extract_fbank_features( | |
| waveforms: list[torch.Tensor], | |
| device: torch.device, | |
| num_mel_bins: int = 80, | |
| stride: int = 2, | |
| ) -> dict[str, torch.Tensor]: | |
| features = [] | |
| for wav in waveforms: | |
| if wav.ndim > 1: | |
| wav = wav[0] | |
| feat = torchaudio.compliance.kaldi.fbank( | |
| wav.unsqueeze(0), | |
| sample_frequency=SAMPLE_RATE_IN, | |
| num_mel_bins=num_mel_bins, | |
| frame_length=25, | |
| frame_shift=10, | |
| dither=0.0, | |
| preemphasis_coefficient=0.97, | |
| remove_dc_offset=True, | |
| window_type="povey", | |
| use_energy=False, | |
| energy_floor=1.192092955078125e-07, | |
| ) | |
| mean = feat.mean(0, keepdim=True) | |
| var = feat.var(0, keepdim=True) | |
| feat = (feat - mean) / torch.sqrt(var + 1e-5) | |
| features.append(feat.to(device)) | |
| input_features, attention_mask = _pad_batch(features) | |
| b, t, c = input_features.shape | |
| t = (t // stride) * stride | |
| input_features = input_features[:, :t, :] | |
| attention_mask = attention_mask[:, :t] | |
| input_features = input_features.reshape(b, t // stride, c * stride) | |
| attention_mask = attention_mask[:, 1::stride] | |
| return {"input_features": input_features, "attention_mask": attention_mask} | |
| # --------------------------------------------------------------------------- | |
| # Model loading (cached) | |
| # --------------------------------------------------------------------------- | |
| _cache: dict = {} | |
| def load_models(device: torch.device) -> dict: | |
| cache_key = str(device) | |
| if cache_key in _cache: | |
| return _cache[cache_key] | |
| print(f"Downloading model files from {REPO_ID} ...") | |
| paths = {f: hf_hub_download(repo_id=REPO_ID, filename=f, token=HF_TOKEN) for f in MODEL_FILES} | |
| with open(paths["metadata.json"]) as fp: | |
| meta = json.load(fp) | |
| ssl_encoder = torch.export.load(paths["ssl_encoder.pt2"]).module().to(device) | |
| diffusion_head = torch.export.load(paths["diffusion_head.pt2"]).module().to(device) | |
| vae_decoder = torch.export.load(paths["vae_decoder.pt2"]).module().to(device) | |
| latent_norm_mean = torch.tensor( | |
| meta["latent_norm_mean"], dtype=torch.float32, device=device | |
| ).view(1, 1, -1) | |
| latent_norm_std = torch.tensor( | |
| meta["latent_norm_std"], dtype=torch.float32, device=device | |
| ).view(1, 1, -1) | |
| scheduler = DPMSolverMultistepScheduler.from_config( | |
| meta["ddpm_config"], | |
| algorithm_type="dpmsolver++", | |
| timestep_spacing="linspace", | |
| ) | |
| models = { | |
| "ssl_encoder": ssl_encoder, | |
| "diffusion_head": diffusion_head, | |
| "vae_decoder": vae_decoder, | |
| "latent_norm_mean": latent_norm_mean, | |
| "latent_norm_std": latent_norm_std, | |
| "latent_norm_initialized": meta["latent_norm_initialized"], | |
| "scheduler": scheduler, | |
| "latent_dim": meta["latent_dim"], | |
| "sample_rate": meta["sample_rate"], | |
| } | |
| _cache[cache_key] = models | |
| return models | |
| # --------------------------------------------------------------------------- | |
| # Inference helpers | |
| # --------------------------------------------------------------------------- | |
| def _normalize(latents: torch.Tensor, models: dict) -> torch.Tensor: | |
| if not models["latent_norm_initialized"]: | |
| return latents | |
| return ((latents.float() - models["latent_norm_mean"]) / models["latent_norm_std"]).to(latents.dtype) | |
| def _denormalize(latents: torch.Tensor, models: dict) -> torch.Tensor: | |
| if not models["latent_norm_initialized"]: | |
| return latents | |
| return (latents.float() * models["latent_norm_std"] + models["latent_norm_mean"]).to(latents.dtype) | |
| def _separate_chunk( | |
| wav: torch.Tensor, # [1, T] at 16 kHz, already normalized | |
| num_steps: int, | |
| models: dict, | |
| device: torch.device, | |
| ) -> torch.Tensor: | |
| """Run separation on a single chunk. Returns [2, T_audio] at model sample rate.""" | |
| latent_dim = models["latent_dim"] | |
| noisy_ssl = extract_fbank_features([wav.view(-1)], device) | |
| features, pred0, pred1 = models["ssl_encoder"]( | |
| noisy_ssl["input_features"], noisy_ssl["attention_mask"] | |
| ) | |
| predicted_latents = torch.cat([pred0, pred1], dim=-1) | |
| conditioning = torch.cat([_normalize(predicted_latents, models), features], dim=-1) | |
| seq_len = conditioning.shape[1] | |
| scheduler = models["scheduler"] | |
| scheduler.set_timesteps(num_steps, device=device) | |
| latents = torch.randn( | |
| (1, seq_len, latent_dim * 2), device=device, dtype=conditioning.dtype | |
| ) | |
| for t in scheduler.timesteps: | |
| t_batch = torch.full((1,), int(t.item()), device=device, dtype=torch.long) | |
| latents = scheduler.step( | |
| models["diffusion_head"](latents, t_batch, conditioning), t, latents | |
| ).prev_sample | |
| latents = _denormalize(latents, models) | |
| spk1 = models["vae_decoder"](latents[:, :, :latent_dim].transpose(1, 2)).squeeze(0) # [1, T] | |
| spk2 = models["vae_decoder"](latents[:, :, latent_dim:].transpose(1, 2)).squeeze(0) | |
| return torch.cat([spk1, spk2], dim=0) # [2, T] | |
| def _channel_similarity(a: torch.Tensor, b: torch.Tensor) -> float: | |
| a, b = a.reshape(-1), b.reshape(-1) | |
| a, b = a - a.mean(), b - b.mean() | |
| denom = torch.linalg.norm(a) * torch.linalg.norm(b) | |
| return float(torch.dot(a, b) / denom) if float(denom) > 1e-8 else 0.0 | |
| def _maybe_swap( | |
| prev_overlap: torch.Tensor, curr_chunk: torch.Tensor, overlap_samples: int | |
| ) -> tuple[torch.Tensor, bool]: | |
| if overlap_samples <= 0 or prev_overlap.shape[0] != 2 or curr_chunk.shape[0] != 2: | |
| return curr_chunk, False | |
| curr_ov = curr_chunk[:, :overlap_samples] | |
| direct = _channel_similarity(prev_overlap[0], curr_ov[0]) + _channel_similarity(prev_overlap[1], curr_ov[1]) | |
| swapped = _channel_similarity(prev_overlap[0], curr_ov[1]) + _channel_similarity(prev_overlap[1], curr_ov[0]) | |
| if swapped > direct: | |
| return curr_chunk[[1, 0], :], True | |
| return curr_chunk, False | |
| def separate( | |
| wav: torch.Tensor, # [1, T] at original sample_rate | |
| sample_rate: int, | |
| num_steps: int, | |
| device: torch.device, | |
| ) -> tuple[torch.Tensor, int]: | |
| models = load_models(device) | |
| out_sr = models["sample_rate"] | |
| # resample to 16 kHz | |
| if sample_rate != SAMPLE_RATE_IN: | |
| wav_16k = torchaudio.functional.resample(wav, sample_rate, SAMPLE_RATE_IN) | |
| else: | |
| wav_16k = wav | |
| wav_16k = wav_16k.to(device) | |
| chunk_samples = int(CHUNK_SECONDS * SAMPLE_RATE_IN) | |
| total_samples = wav_16k.shape[-1] | |
| if total_samples <= chunk_samples: | |
| # single-shot inference | |
| max_val = wav_16k.abs().max().clamp_min(1e-6) | |
| wav_norm = torch.nn.functional.pad(0.9 * wav_16k / max_val, (160, 160)) | |
| separated = _separate_chunk(wav_norm, num_steps, models, device) | |
| return separated, out_sr | |
| # chunked streaming inference | |
| overlap_samples_in = int(OVERLAP_SECONDS * SAMPLE_RATE_IN) | |
| hop_samples = chunk_samples - overlap_samples_in | |
| starts = list(range(0, total_samples, hop_samples)) | |
| stitched: torch.Tensor | None = None | |
| prev_end_in = 0 | |
| for idx, start in enumerate(starts): | |
| end = min(start + chunk_samples, total_samples) | |
| chunk = wav_16k[:, start:end] | |
| max_val = chunk.abs().max().clamp_min(1e-6) | |
| chunk_norm = torch.nn.functional.pad(0.9 * chunk / max_val, (160, 160)) | |
| pred = _separate_chunk(chunk_norm, num_steps, models, device) # [2, T_out] | |
| # match output length to input length (resampling ratio) | |
| target_out = max(1, round((end - start) * out_sr / SAMPLE_RATE_IN)) | |
| if pred.shape[-1] > target_out: | |
| pred = pred[:, :target_out] | |
| elif pred.shape[-1] < target_out: | |
| pad = torch.zeros(2, target_out - pred.shape[-1], device=device) | |
| pred = torch.cat([pred, pad], dim=-1) | |
| if stitched is None: | |
| stitched = pred | |
| prev_end_in = end | |
| continue | |
| overlap_in = max(0, prev_end_in - start) | |
| overlap_out = max(0, min( | |
| round(overlap_in * out_sr / SAMPLE_RATE_IN), | |
| stitched.shape[-1], | |
| pred.shape[-1], | |
| )) | |
| if overlap_out > 0: | |
| pred, _ = _maybe_swap(stitched[:, -overlap_out:], pred, overlap_out) | |
| fade = torch.linspace(0.0, 1.0, overlap_out, device=device).unsqueeze(0) | |
| blended = stitched[:, -overlap_out:] * (1 - fade) + pred[:, :overlap_out] * fade | |
| stitched = torch.cat([stitched[:, :-overlap_out], blended, pred[:, overlap_out:]], dim=-1) | |
| else: | |
| stitched = torch.cat([stitched, pred], dim=-1) | |
| prev_end_in = end | |
| return stitched, out_sr | |
| # --------------------------------------------------------------------------- | |
| # Video helpers | |
| # --------------------------------------------------------------------------- | |
| def extract_audio_from_video(video_path: str) -> tuple[torch.Tensor, int]: | |
| """Extract mono audio from a video file using ffmpeg. Returns (wav [1,T], sr).""" | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: | |
| tmp_path = tmp.name | |
| subprocess.run( | |
| ["ffmpeg", "-y", "-i", video_path, "-ac", "1", "-ar", str(SAMPLE_RATE_IN), | |
| "-vn", tmp_path], | |
| check=True, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| wav, sr = torchaudio.load(tmp_path) | |
| os.unlink(tmp_path) | |
| return wav, sr | |
| def create_stereo_video( | |
| video_path: str, | |
| spk1: np.ndarray, | |
| spk2: np.ndarray, | |
| out_sr: int, | |
| ) -> str: | |
| """Mux separated speakers (L=spk1, R=spk2) back into a video file. | |
| Returns path to the output video (caller is responsible for cleanup). | |
| """ | |
| # Write stereo audio to a temp wav | |
| stereo = np.stack([spk1, spk2], axis=0) # [2, T] | |
| stereo_tensor = torch.from_numpy(stereo).float() | |
| # Normalise to avoid clipping | |
| peak = stereo_tensor.abs().max().clamp_min(1e-6) | |
| stereo_tensor = stereo_tensor / peak * 0.9 | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_audio: | |
| audio_path = tmp_audio.name | |
| torchaudio.save(audio_path, stereo_tensor, out_sr) | |
| with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_video: | |
| out_path = tmp_video.name | |
| subprocess.run( | |
| [ | |
| "ffmpeg", "-y", | |
| "-i", video_path, # original video (video stream) | |
| "-i", audio_path, # new stereo audio | |
| "-c:v", "copy", # copy video stream unchanged | |
| "-c:a", "aac", # encode audio as AAC | |
| "-b:a", "192k", | |
| "-map", "0:v:0", # take video from first input | |
| "-map", "1:a:0", # take audio from second input | |
| "-shortest", | |
| out_path, | |
| ], | |
| check=True, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| os.unlink(audio_path) | |
| return out_path | |
| # --------------------------------------------------------------------------- | |
| # Gradio interface | |
| # --------------------------------------------------------------------------- | |
| def get_device() -> torch.device: | |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| def _wav_to_numpy_output(wav_tensor: torch.Tensor, sr: int) -> tuple[int, np.ndarray]: | |
| arr = wav_tensor.cpu().numpy() | |
| # Convert to int16 for Gradio audio output | |
| arr = np.clip(arr / max(np.abs(arr).max(), 1e-6) * 0.9, -1.0, 1.0) | |
| return sr, (arr * 32767).astype(np.int16) | |
| def run_separation_audio( | |
| input_audio: tuple[int, np.ndarray] | None, | |
| num_steps: int, | |
| ) -> tuple[tuple[int, np.ndarray], tuple[int, np.ndarray]]: | |
| if input_audio is None: | |
| raise gr.Error("Please upload an audio file.") | |
| sr, audio_np = input_audio | |
| wav = torch.from_numpy(audio_np.copy()).float() | |
| if wav.ndim == 1: | |
| wav = wav.unsqueeze(0) | |
| elif wav.ndim == 2: | |
| if wav.shape[1] <= 8: | |
| wav = wav.T | |
| wav = wav.mean(dim=0, keepdim=True) | |
| if audio_np.dtype in (np.int16, np.int32): | |
| wav = wav / float(np.iinfo(audio_np.dtype).max) | |
| device = get_device() | |
| separated, out_sr = separate(wav, sr, num_steps, device) | |
| return ( | |
| _wav_to_numpy_output(separated[0], out_sr), | |
| _wav_to_numpy_output(separated[1], out_sr), | |
| ) | |
| def run_separation_video( | |
| video_path: str | None, | |
| num_steps: int, | |
| ) -> tuple[str | None, tuple[int, np.ndarray], tuple[int, np.ndarray]]: | |
| if video_path is None: | |
| raise gr.Error("Please upload a video file.") | |
| wav, sr = extract_audio_from_video(video_path) | |
| device = get_device() | |
| separated, out_sr = separate(wav, sr, num_steps, device) | |
| spk1_np = separated[0].cpu().numpy() | |
| spk2_np = separated[1].cpu().numpy() | |
| out_video = create_stereo_video(video_path, spk1_np, spk2_np, out_sr) | |
| return ( | |
| out_video, | |
| _wav_to_numpy_output(separated[0], out_sr), | |
| _wav_to_numpy_output(separated[1], out_sr), | |
| ) | |
| if HAS_SPACES: | |
| run_separation_audio = spaces.GPU(run_separation_audio) | |
| run_separation_video = spaces.GPU(run_separation_video) | |
| with gr.Blocks(title="DialogueSidon — Dialogue Separation") as demo: | |
| gr.Markdown( | |
| """ | |
| # DialogueSidon — Two-Speaker Dialogue Separation & Restoration | |
| Upload a degraded or noisy audio/video recording of a two-speaker conversation. | |
| DialogueSidon jointly **separates** the two speakers and **restores** clean, high-quality speech | |
| from the mixture — handling background noise, reverberation, and channel degradation in one pass. | |
| Inputs up to 120 s are processed in one shot. Longer inputs are processed in 120 s chunks | |
| with 10 s overlap crossfade and automatic speaker re-alignment across chunks. | |
| **Model**: [sarulab-speech/DialogueSidon](https://huggingface.co/sarulab-speech/DialogueSidon) | |
| """ | |
| ) | |
| num_steps = gr.Slider( | |
| minimum=10, maximum=100, value=30, step=5, | |
| label="Diffusion steps (more = slower but potentially better)", | |
| ) | |
| with gr.Tabs(): | |
| # ------------------------------------------------------------------ | |
| # Audio tab | |
| # ------------------------------------------------------------------ | |
| with gr.Tab("Audio"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| audio_input = gr.Audio( | |
| label="Input mixture (mono or stereo, any sample rate)", | |
| type="numpy", | |
| ) | |
| audio_btn = gr.Button("Separate", variant="primary") | |
| with gr.Column(): | |
| audio_spk1 = gr.Audio(label="Speaker 1", type="numpy") | |
| audio_spk2 = gr.Audio(label="Speaker 2", type="numpy") | |
| audio_btn.click( | |
| fn=run_separation_audio, | |
| inputs=[audio_input, num_steps], | |
| outputs=[audio_spk1, audio_spk2], | |
| ) | |
| gr.Examples( | |
| examples=[["LDC2026S02_mono.wav",100]], | |
| inputs=[audio_input,num_steps], | |
| label="Ex1" | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Video tab | |
| # ------------------------------------------------------------------ | |
| with gr.Tab("Video"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| video_input = gr.Video( | |
| label="Input video (audio will be extracted and separated)", | |
| ) | |
| video_btn = gr.Button("Separate", variant="primary") | |
| with gr.Column(): | |
| video_output = gr.Video( | |
| label="Output video (Speaker 1 = Left, Speaker 2 = Right)", | |
| ) | |
| video_spk1 = gr.Audio(label="Speaker 1 (audio only)", type="numpy") | |
| video_spk2 = gr.Audio(label="Speaker 2 (audio only)", type="numpy") | |
| video_btn.click( | |
| fn=run_separation_video, | |
| inputs=[video_input, num_steps], | |
| outputs=[video_output, video_spk1, video_spk2], | |
| ) | |
| gr.Markdown("---\n**License**: CC-BY-NC 4.0 — non-commercial use only.") | |
| if __name__ == "__main__": | |
| demo.launch() |