Wataru's picture
revert
59ed665
Raw
History Blame Contribute Delete
18.6 kB
#!/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)
@torch.inference_mode()
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()