Download vanta/data/ami.py from rjattt/echofocus: direct link, hf CLI and curl.
- Browser
- Download file 8.45 kB
-
https://huggingface.co/spaces/rjattt/echofocus/resolve/main/vanta/data/ami.py
- Command line
-
hf download hf://spaces/rjattt/echofocus/vanta/data/ami.py
-
curl -L -o ami.py https://huggingface.co/spaces/rjattt/echofocus/resolve/main/vanta/data/ami.py
8.45 kB
| """AMI real-recording dataset — genuine mixtures with genuine ground truth. | |
| Each AMI meeting is recorded twice, simultaneously: | |
| Headset-N : close-talking mic on speaker N -> near-clean target voice | |
| Array1-01 : mic in the room -> the real mixture of everyone | |
| So (Array1-01, Headset-N) is a real (mixture, target) pair: same room, same | |
| moment, same acoustics, conversational speech, real mic chain. That is exactly | |
| the physics our synthetic pipeline can't fake. | |
| Three problems real data brings that synthesis never did, and how we handle them: | |
| 1. LEVEL/CHANNEL MISMATCH. The headset signal is not "the target as the room mic | |
| heard it" — different mic, distance, and gain. Training SI-SDR against the raw | |
| headset would ask the model to invent a channel transform. Fix: project the | |
| headset signal onto the room-mic signal (least-squares scalar) so the target | |
| is expressed *in the mixture's own channel*. SI-SDR is scale-invariant, but | |
| this keeps target and mixture in the same acoustic space. | |
| 2. SILENCE. Meetings are mostly one person talking; a random 3 s window often has | |
| the target silent, which is a degenerate SI-SDR target. Fix: index only | |
| windows where the target is genuinely active (energy threshold), while still | |
| allowing partial activity so the model keeps learning "go quiet when I stop". | |
| 3. TARGET LEAKAGE INTO OTHER HEADSETS. Not our problem here — we only use the | |
| target's own headset — but it's why we don't build interference from the | |
| other headsets. | |
| Enrollment comes from a *different* window of the same speaker's headset, so it | |
| matches deployment: a clean-ish reference clip of the person you want. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| from torch.utils.data import Dataset, get_worker_info | |
| from vanta.utils.audio import EPS, peak_normalize | |
| ARRAY_MIC = "Array1-01" | |
| def _rms(x: np.ndarray) -> float: | |
| return float(np.sqrt(np.mean(x**2) + EPS)) | |
| def build_index( | |
| ami_root: Path, | |
| clip_seconds: float = 3.0, | |
| hop_seconds: float = 2.0, | |
| sr: int = 16000, | |
| min_active_rms: float = 0.005, | |
| min_active_frac: float = 0.4, | |
| ) -> list[dict]: | |
| """Scan meetings and index windows where a speaker is genuinely talking. | |
| Returns entries: {meeting, speaker, start_sample, n_samples}. Reads the audio | |
| once per file with soundfile blocks, so memory stays flat. | |
| """ | |
| entries: list[dict] = [] | |
| n = int(clip_seconds * sr) | |
| hop = int(hop_seconds * sr) | |
| for mdir in sorted(p for p in ami_root.iterdir() if p.is_dir()): | |
| meeting = mdir.name | |
| array_path = mdir / f"{meeting}.{ARRAY_MIC}.wav" | |
| if not array_path.exists(): | |
| continue | |
| for spk in range(4): | |
| hs_path = mdir / f"{meeting}.Headset-{spk}.wav" | |
| if not hs_path.exists(): | |
| continue | |
| try: | |
| info = sf.info(str(hs_path)) | |
| hs, file_sr = sf.read(str(hs_path), dtype="float32", always_2d=False) | |
| except Exception: | |
| continue | |
| if hs.ndim > 1: | |
| hs = hs.mean(axis=1) | |
| if file_sr != sr: | |
| import soxr | |
| hs = soxr.resample(hs, file_sr, sr, quality="QQ").astype(np.float32) | |
| # Frame-level activity, then keep windows that are mostly active. | |
| frame = int(0.05 * sr) | |
| n_frames = len(hs) // frame | |
| if n_frames == 0: | |
| continue | |
| fr = hs[: n_frames * frame].reshape(n_frames, frame) | |
| active = (np.sqrt((fr**2).mean(axis=1) + EPS) > min_active_rms) | |
| frames_per_win = max(int(clip_seconds * sr) // frame, 1) | |
| for start_f in range(0, n_frames - frames_per_win, max(hop // frame, 1)): | |
| win = active[start_f : start_f + frames_per_win] | |
| if win.mean() >= min_active_frac: | |
| entries.append( | |
| { | |
| "meeting": meeting, | |
| "speaker": spk, | |
| "start": int(start_f * frame), | |
| "n": n, | |
| } | |
| ) | |
| return entries | |
| class AmiDataset(Dataset): | |
| """Real (mixture, target, enrollment) triples from AMI meetings.""" | |
| def __init__( | |
| self, | |
| ami_root: str | Path, | |
| index_path: str | Path, | |
| clip_seconds: float = 3.0, | |
| enroll_seconds: float = 5.0, | |
| sr: int = 16000, | |
| seed: int = 0, | |
| ): | |
| self.root = Path(ami_root) | |
| self.sr = sr | |
| self.clip_n = int(clip_seconds * sr) | |
| self.enroll_n = int(enroll_seconds * sr) | |
| self.seed = seed | |
| with open(index_path) as f: | |
| self.entries = [json.loads(line) for line in f] | |
| # Windows grouped per (meeting, speaker) so enrollment can come from a | |
| # different moment of the same person. | |
| self.by_spk: dict[tuple[str, int], list[int]] = {} | |
| for i, e in enumerate(self.entries): | |
| self.by_spk.setdefault((e["meeting"], e["speaker"]), []).append(i) | |
| self._rng: np.random.Generator | None = None | |
| def __len__(self) -> int: | |
| return len(self.entries) | |
| def _get_rng(self) -> np.random.Generator: | |
| if self._rng is None: | |
| info = get_worker_info() | |
| seed = self.seed if info is None else (torch.initial_seed() % (2**31)) | |
| self._rng = np.random.default_rng(seed) | |
| return self._rng | |
| def _read(self, meeting: str, mic: str, start: int, n: int) -> np.ndarray: | |
| path = self.root / meeting / f"{meeting}.{mic}.wav" | |
| with sf.SoundFile(str(path)) as f: | |
| file_sr = f.samplerate | |
| if file_sr != self.sr: | |
| ratio = file_sr / self.sr | |
| f.seek(int(start * ratio)) | |
| raw = f.read(int(n * ratio), dtype="float32", always_2d=False) | |
| if raw.ndim > 1: | |
| raw = raw.mean(axis=1) | |
| import soxr | |
| w = soxr.resample(raw, file_sr, self.sr, quality="HQ").astype(np.float32) | |
| else: | |
| f.seek(start) | |
| w = f.read(n, dtype="float32", always_2d=False) | |
| if w.ndim > 1: | |
| w = w.mean(axis=1) | |
| if len(w) < n: | |
| w = np.pad(w, (0, n - len(w))) | |
| return w[:n].astype(np.float32) | |
| def __getitem__(self, idx: int) -> dict: | |
| rng = self._get_rng() | |
| e = self.entries[idx] | |
| meeting, spk = e["meeting"], e["speaker"] | |
| mixture = self._read(meeting, ARRAY_MIC, e["start"], self.clip_n) | |
| headset = self._read(meeting, f"Headset-{spk}", e["start"], self.clip_n) | |
| # Express the target in the mixture's channel: least-squares scalar | |
| # projection of the headset signal onto the room-mic signal. Without this | |
| # the model is asked to invent a mic/distance transform it cannot know. | |
| denom = float(np.dot(headset, headset)) + EPS | |
| alpha = float(np.dot(mixture, headset)) / denom | |
| target = (alpha * headset).astype(np.float32) | |
| # Enrollment: a different active window of the same speaker's headset. | |
| pool = self.by_spk[(meeting, spk)] | |
| j = int(rng.integers(0, len(pool))) | |
| if len(pool) > 1 and pool[j] == idx: | |
| j = (j + 1) % len(pool) | |
| enr_entry = self.entries[pool[j]] | |
| enrollment = self._read( | |
| meeting, f"Headset-{spk}", enr_entry["start"], self.enroll_n | |
| ) | |
| enrollment = peak_normalize(enrollment, peak=0.95) | |
| # Keep the mixture in a sane range; scale the target identically so the | |
| # relationship the loss sees is untouched. | |
| peak = float(np.max(np.abs(mixture))) + EPS | |
| if peak > 0.95: | |
| s = 0.95 / peak | |
| mixture = mixture * s | |
| target = target * s | |
| return { | |
| "mixture": torch.from_numpy(mixture.astype(np.float32)), | |
| "target": torch.from_numpy(target.astype(np.float32)), | |
| "enrollment": torch.from_numpy(enrollment.astype(np.float32)), | |
| "target_speaker": f"{meeting}-{spk}", | |
| } | |