Kyudan commited on
Commit
30d0277
·
verified ·
1 Parent(s): 1c68fa2

Add non-streaming mode (streaming=False), clearer usage in README

Browse files
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") # mono
35
- dia = SlotEMeansDiarizer(device="cuda", mode="low_latency") # 1.04 s input buffer; "very_low_latency" = 0.64 s
 
 
36
  for start, end, speaker in dia.diarize(wav, sr):
37
  print(f"{start:7.2f} {end:7.2f} spk{speaker}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ```
39
 
40
- or `python examples/diarize.py meeting.wav > meeting.rttm`. The two upstream models are downloaded from their own
41
- repositories on first use (`HF_TOKEN` if they are gated for you). Install with `pip install -r requirements.txt`.
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
- """Streaming diarizer. ``diarize(wav)`` feeds a 16 kHz mono waveform in 100 ms chunks (as a live system would) and returns
43
- speaker segments after the revision horizon; ``stream()`` is the incremental interface."""
44
 
45
- def __init__(self, device="cuda", mode="low_latency", token=None, **live_kwargs):
46
- assert mode in MODES
47
- self.mode, self.device, self.live_kwargs = mode, device, live_kwargs
 
 
 
 
 
 
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 [--mode low_latency|very_low_latency] [--device cuda] -> RTTM on stdout"""
 
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, mode=a.mode).diarize(wav, sr):
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>")