Instructions to use Kyudan/eDIAR with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Kyudan/eDIAR with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Kyudan/eDIAR", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add non-streaming mode (streaming=False), clearer usage in README
Browse files- README.md +37 -6
- emeans_diar/offline.py +34 -0
- emeans_diar/pipeline.py +19 -5
- examples/diarize.py +4 -3
README.md
CHANGED
|
@@ -25,21 +25,52 @@ speaker ids to the slots of the head.
|
|
| 25 |
|
| 26 |
Demo: https://kyudan1.github.io/eDIAR/ · Code: https://github.com/KyuDan1/eDIAR
|
| 27 |
|
| 28 |
-
## Usage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
```python
|
| 31 |
import soundfile as sf
|
| 32 |
from emeans_diar import SlotEMeansDiarizer
|
| 33 |
|
| 34 |
-
wav, sr = sf.read("meeting.wav", dtype="float32")
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
for start, end, speaker in dia.diarize(wav, sr):
|
| 37 |
print(f"{start:7.2f} {end:7.2f} spk{speaker}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
```
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
`SlotEMeansDiarizer.new_session()` returns the incremental interface used by the live demo (`feed(pcm_int16_bytes)`).
|
| 43 |
|
| 44 |
## Results (streaming, 1.04 s input buffer, DER %, collar 0.25 s, overlap scored)
|
| 45 |
|
|
|
|
| 25 |
|
| 26 |
Demo: https://kyudan1.github.io/eDIAR/ · Code: https://github.com/KyuDan1/eDIAR
|
| 27 |
|
| 28 |
+
## Usage: streaming or non-streaming
|
| 29 |
+
|
| 30 |
+
One class, one switch. Everything else (models, clustering, speaker ids) is the same.
|
| 31 |
+
|
| 32 |
+
| | `streaming=True` (default) | `streaming=False` |
|
| 33 |
+
|---|---|---|
|
| 34 |
+
| Audio | arrives in chunks, causal | whole recording at once |
|
| 35 |
+
| Latency | 1.04 s (`latency="low_latency"`) or 0.64 s (`"very_low_latency"`) | none, runs after the recording |
|
| 36 |
+
| Labels | provisional, revised for 10 s, then fixed | final |
|
| 37 |
+
| Clustering | bounded 90 s window, re-run every chunk | all embeddings of the session at once |
|
| 38 |
+
| Use it for | live meetings, long recordings, bounded cost | files, highest accuracy |
|
| 39 |
+
| Paper setting | streaming tables | non-streaming tables |
|
| 40 |
|
| 41 |
```python
|
| 42 |
import soundfile as sf
|
| 43 |
from emeans_diar import SlotEMeansDiarizer
|
| 44 |
|
| 45 |
+
wav, sr = sf.read("meeting.wav", dtype="float32") # mono
|
| 46 |
+
|
| 47 |
+
# 1) streaming (default): audio is fed in 100 ms chunks like a live system
|
| 48 |
+
dia = SlotEMeansDiarizer(device="cuda", streaming=True, latency="low_latency")
|
| 49 |
for start, end, speaker in dia.diarize(wav, sr):
|
| 50 |
print(f"{start:7.2f} {end:7.2f} spk{speaker}")
|
| 51 |
+
|
| 52 |
+
# 2) non-streaming: the whole file at once
|
| 53 |
+
dia = SlotEMeansDiarizer(device="cuda", streaming=False)
|
| 54 |
+
segments = dia.diarize(wav, sr)
|
| 55 |
+
|
| 56 |
+
# 3) real streaming loop (live audio): incremental interface
|
| 57 |
+
dia = SlotEMeansDiarizer(streaming=True)
|
| 58 |
+
session = dia.new_session()
|
| 59 |
+
for pcm in microphone_chunks(): # 16 kHz int16 bytes, any chunk size
|
| 60 |
+
msgs = session.feed(pcm) # provisional + revised labels
|
| 61 |
+
session.feed(None, flush=True)
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Command line, RTTM on stdout:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
python examples/diarize.py meeting.wav # streaming, 1.04 s
|
| 68 |
+
python examples/diarize.py meeting.wav --latency very_low_latency
|
| 69 |
+
python examples/diarize.py meeting.wav --offline # non-streaming
|
| 70 |
```
|
| 71 |
|
| 72 |
+
The two upstream models are downloaded from their own repositories on first use (`HF_TOKEN` if they are gated for you).
|
| 73 |
+
Install with `pip install -r requirements.txt`.
|
|
|
|
| 74 |
|
| 75 |
## Results (streaming, 1.04 s input buffer, DER %, collar 0.25 s, overlap scored)
|
| 76 |
|
emeans_diar/offline.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Non-streaming (whole-recording) path: the same slot head and the same slot-conditioned e-means, but the clustering sees every
|
| 2 |
+
embedding of the session at once and the encoders run over the full recording (no causal restrictions, no revision horizon)."""
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
import librosa
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _fbank(engine, wav):
|
| 10 |
+
wp = np.append(wav[0], wav[1:] - 0.97 * wav[:-1])
|
| 11 |
+
p = np.abs(librosa.stft(wp * 32768.0, n_fft=512, hop_length=160, win_length=400, window="hann", center=False)) ** 2
|
| 12 |
+
return np.log(np.maximum(engine.mel_basis_80 @ p, 1e-10)).T.astype(np.float32)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def extract(engine, wav, win_ms=1520):
|
| 16 |
+
"""Returns (spk_embs [T x 256, one per 80 ms], probs [T x 8] slot posteriors per 80 ms)."""
|
| 17 |
+
dev, r = engine.device, engine.resnet
|
| 18 |
+
mel = _fbank(engine, wav); T = mel.shape[0]; chunk, ctx = 4800, 160; fm = []
|
| 19 |
+
with torch.inference_mode():
|
| 20 |
+
for s in range(0, T, chunk):
|
| 21 |
+
e = min(T, s + chunk); sp, ep = max(0, s - ctx), min(T, e + ctx)
|
| 22 |
+
m = torch.from_numpy(mel[sp:ep]).to(dev); m = m - m.mean(0, keepdim=True)
|
| 23 |
+
o = r.layer4(r.layer3(r.layer2(r.layer1(F.relu(r.bn1(r.conv1(m.unsqueeze(0).permute(0, 2, 1).unsqueeze(1))))))))
|
| 24 |
+
o = o.squeeze(0).reshape(2560, -1); a = (s - sp) // 8; fm.append(o[:, a:a + (e - s) // 8].cpu())
|
| 25 |
+
f = torch.cat(fm, 1).to(dev)
|
| 26 |
+
w = int(round(win_ms / 80.0)); w += (w % 2 == 0); pl = w // 2
|
| 27 |
+
win = F.pad(f.unsqueeze(0), (pl, w - 1 - pl), mode="replicate").squeeze(0).unfold(1, w, 1)
|
| 28 |
+
stats = torch.cat([win.mean(-1), torch.sqrt(win.var(-1) + 1e-7)], 0).T
|
| 29 |
+
embs = F.normalize(r.seg_1(stats), p=2, dim=-1).cpu().numpy()
|
| 30 |
+
inp = engine.fe(wav, sampling_rate=16000, return_tensors="pt")
|
| 31 |
+
inp = {k: v.to(dev) if isinstance(v, torch.Tensor) else v for k, v in inp.items()}
|
| 32 |
+
p10 = engine.nemotron(**inp, output_hidden_states=False).logits[0].sigmoid().cpu().numpy()
|
| 33 |
+
n = min(len(embs), len(p10) // 8)
|
| 34 |
+
return embs[:n], p10[:n * 8].reshape(n, 8, 8).mean(1)
|
emeans_diar/pipeline.py
CHANGED
|
@@ -39,12 +39,17 @@ class _Engine:
|
|
| 39 |
|
| 40 |
|
| 41 |
class SlotEMeansDiarizer:
|
| 42 |
-
"""
|
| 43 |
-
speaker segments after the revision horizon; ``stream()`` is the incremental interface."""
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
self.engine = _Engine(device, token or os.environ.get("HF_TOKEN"))
|
| 49 |
self.proc = Nemotron3DiarizationProcessor(feature_extractor=self.engine.fe, subsampling_factor=8, streaming_mode=mode)
|
| 50 |
|
|
@@ -53,18 +58,27 @@ class SlotEMeansDiarizer:
|
|
| 53 |
return cls(*args, **kw)
|
| 54 |
|
| 55 |
def new_session(self):
|
|
|
|
| 56 |
e = self.engine
|
| 57 |
return LiveSession(LiveNemotron(e.nemotron, self.proc, self.device, self.mode), LiveResNet(e), partial(OfficialEMeansSlotDiarizer, n_init=1), self.mode, **self.live_kwargs)
|
| 58 |
|
| 59 |
def diarize(self, wav, sr=16000, chunk_s=0.1):
|
| 60 |
"""wav: float array in [-1, 1], mono. Returns [(start_s, end_s, speaker_id)], speaker ids are permanent (slot-anchored)."""
|
| 61 |
if sr != 16000: wav = librosa.resample(np.asarray(wav, np.float32), orig_sr=sr, target_sr=16000)
|
|
|
|
| 62 |
pcm = (np.clip(np.asarray(wav, np.float32), -1, 1) * 32767).astype("<i2").tobytes(); sess = self.new_session(); step = int(chunk_s * 16000) * 2
|
| 63 |
for k in range(0, len(pcm), step): sess.feed(pcm[k:k + step])
|
| 64 |
sess.feed(None, flush=True)
|
| 65 |
return frames_to_segments(sess.ld.cur)
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def frames_to_segments(frame_labels, gap_s=0.16):
|
| 69 |
"""per-80 ms-frame lists of speaker ids -> merged segments (gaps shorter than gap_s are bridged)"""
|
| 70 |
open_, out = {}, []
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
class SlotEMeansDiarizer:
|
| 42 |
+
"""Open-speaker diarizer on top of the Nemotron-3 slot head. Choose how it runs:
|
|
|
|
| 43 |
|
| 44 |
+
SlotEMeansDiarizer(streaming=True, latency="low_latency") # default: causal, audio arrives in chunks, labels may be revised for 10 s
|
| 45 |
+
SlotEMeansDiarizer(streaming=False) # whole recording at once: best accuracy, needs the full audio first
|
| 46 |
+
|
| 47 |
+
``diarize(wav)`` works in both modes and returns [(start_s, end_s, speaker_id)]. In streaming mode it feeds the waveform in 100 ms
|
| 48 |
+
chunks as a live system would; ``new_session()`` gives the incremental interface (streaming only)."""
|
| 49 |
+
|
| 50 |
+
def __init__(self, device="cuda", mode="low_latency", token=None, streaming=True, latency=None, **live_kwargs):
|
| 51 |
+
mode = latency or mode; assert mode in MODES
|
| 52 |
+
self.streaming, self.mode, self.device, self.live_kwargs = streaming, mode, device, live_kwargs
|
| 53 |
self.engine = _Engine(device, token or os.environ.get("HF_TOKEN"))
|
| 54 |
self.proc = Nemotron3DiarizationProcessor(feature_extractor=self.engine.fe, subsampling_factor=8, streaming_mode=mode)
|
| 55 |
|
|
|
|
| 58 |
return cls(*args, **kw)
|
| 59 |
|
| 60 |
def new_session(self):
|
| 61 |
+
if not self.streaming: raise RuntimeError("new_session() is the incremental interface; create the diarizer with streaming=True")
|
| 62 |
e = self.engine
|
| 63 |
return LiveSession(LiveNemotron(e.nemotron, self.proc, self.device, self.mode), LiveResNet(e), partial(OfficialEMeansSlotDiarizer, n_init=1), self.mode, **self.live_kwargs)
|
| 64 |
|
| 65 |
def diarize(self, wav, sr=16000, chunk_s=0.1):
|
| 66 |
"""wav: float array in [-1, 1], mono. Returns [(start_s, end_s, speaker_id)], speaker ids are permanent (slot-anchored)."""
|
| 67 |
if sr != 16000: wav = librosa.resample(np.asarray(wav, np.float32), orig_sr=sr, target_sr=16000)
|
| 68 |
+
if not self.streaming: return self._diarize_offline(np.asarray(wav, np.float32))
|
| 69 |
pcm = (np.clip(np.asarray(wav, np.float32), -1, 1) * 32767).astype("<i2").tobytes(); sess = self.new_session(); step = int(chunk_s * 16000) * 2
|
| 70 |
for k in range(0, len(pcm), step): sess.feed(pcm[k:k + step])
|
| 71 |
sess.feed(None, flush=True)
|
| 72 |
return frames_to_segments(sess.ld.cur)
|
| 73 |
|
| 74 |
|
| 75 |
+
def _diarize_offline(self, wav):
|
| 76 |
+
from .offline import extract
|
| 77 |
+
embs, probs = extract(self.engine, wav)
|
| 78 |
+
frames, *_ = OfficialEMeansSlotDiarizer(stride_ms=80).fit_predict(embs, probs)
|
| 79 |
+
return frames_to_segments(frames)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
def frames_to_segments(frame_labels, gap_s=0.16):
|
| 83 |
"""per-80 ms-frame lists of speaker ids -> merged segments (gaps shorter than gap_s are bridged)"""
|
| 84 |
open_, out = {}, []
|
examples/diarize.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
-
"""python examples/diarize.py meeting.wav [--
|
|
|
|
| 2 |
import argparse, sys, os
|
| 3 |
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
| 4 |
import soundfile as sf
|
| 5 |
from emeans_diar import SlotEMeansDiarizer
|
| 6 |
|
| 7 |
-
ap = argparse.ArgumentParser(); ap.add_argument("wav"); ap.add_argument("--mode", default="low_latency"); ap.add_argument("--device", default="cuda")
|
| 8 |
a = ap.parse_args()
|
| 9 |
wav, sr = sf.read(a.wav, dtype="float32")
|
| 10 |
if wav.ndim > 1: wav = wav.mean(1)
|
| 11 |
name = os.path.splitext(os.path.basename(a.wav))[0]
|
| 12 |
-
for s, e, spk in SlotEMeansDiarizer(device=a.device,
|
| 13 |
print(f"SPEAKER {name} 1 {s:.3f} {e - s:.3f} <NA> <NA> spk{spk} <NA> <NA>")
|
|
|
|
| 1 |
+
"""python examples/diarize.py meeting.wav [--offline] [--latency low_latency|very_low_latency] [--device cuda] -> RTTM on stdout
|
| 2 |
+
default: streaming (causal, 100 ms chunks). --offline: non-streaming, the whole recording at once."""
|
| 3 |
import argparse, sys, os
|
| 4 |
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
| 5 |
import soundfile as sf
|
| 6 |
from emeans_diar import SlotEMeansDiarizer
|
| 7 |
|
| 8 |
+
ap = argparse.ArgumentParser(); ap.add_argument("wav"); ap.add_argument("--latency", "--mode", dest="latency", default="low_latency", choices=["low_latency", "very_low_latency"], help="streaming input buffer: 1.04 s or 0.64 s"); ap.add_argument("--offline", action="store_true", help="non-streaming: use the whole recording at once"); ap.add_argument("--device", default="cuda")
|
| 9 |
a = ap.parse_args()
|
| 10 |
wav, sr = sf.read(a.wav, dtype="float32")
|
| 11 |
if wav.ndim > 1: wav = wav.mean(1)
|
| 12 |
name = os.path.splitext(os.path.basename(a.wav))[0]
|
| 13 |
+
for s, e, spk in SlotEMeansDiarizer(device=a.device, latency=a.latency, streaming=not a.offline).diarize(wav, sr):
|
| 14 |
print(f"SPEAKER {name} 1 {s:.3f} {e - s:.3f} <NA> <NA> spk{spk} <NA> <NA>")
|