File size: 9,658 Bytes
63dd946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# Copyright (c) 2026 The Anuvani-Diarization authors
# SPDX-License-Identifier: MIT OR Apache-2.0
"""The engine (crate diar-engine) against the release, byte for byte, without a GPU:

1. cached front-end records through the engine's post-processing pool vs the canonical cache oracle;
2. audio -> RTTM with the stand-in networks of fake_nn.py in both the engine and the release's own `analyse` and
   `indic_mode` (run in a subprocess): records, the RTTM and every error must be identical, with and without the
   repacked voiceprint trunk, and every network call must be one of the release's batch shapes.
"""
import json
import subprocess
import sys
from pathlib import Path

import numpy as np
import pytest

import fake_nn
import records
from conftest import PROJ, REL

MODEL = json.loads((REL / "bundle/MANIFEST.json").read_text())["modes"]["indic"]
DECODER = str(REL / "bundle" / MODEL["decoder"])
ORACLE = json.loads((PROJ / "oracle/cache_canonical.json").read_text())["files"]
CACHE_RTTM = PROJ / "artifacts/oracle_cache/canonical"
WIN, STEP = 256000, 25600


def make_engine(core, nn, **kw):
    return core.Engine(nn, clustering=MODEL["clustering"], decoder_path=DECODER, **kw)


def record_jobs(items):
    jobs = []
    for set_name, path in items:
        d = records.load(path)
        jobs.append((f"{set_name}/{path.stem}", np.ascontiguousarray(d["P"]).view(np.uint16), d["masks"], d["emb"],
                     list(map(float, d["frames_sw"])), float(d["duration_s"])))
    return jobs


def check_records_against_cache(core, items, post_threads):
    eng = make_engine(core, fake_nn.FakeNN(), post_threads=post_threads)
    got = {r["uri"]: r for r in eng.run_records(record_jobs(items))}
    assert len(got) == len(items)
    bad = []
    for set_name, path in items:
        key = f"{set_name}/{path.stem}"
        r = got[key]
        # the engine writes the uri it was given; the release writes the file id
        want = (CACHE_RTTM / set_name / "indic" / f"{path.stem}.rttm").read_bytes()
        if r["error"] is not None or r["rttm"].replace(f"SPEAKER {key} ", f"SPEAKER {path.stem} ").encode() != want:
            bad.append(key)
    return bad


def test_engine_records_sample(core):
    assert check_records_against_cache(core, records.sample(40), post_threads=8) == []


@pytest.mark.slow
def test_engine_records_all(core):
    assert check_records_against_cache(core, records.all_records(), post_threads=48) == []


# ---------------------------------------------------------------- audio -> RTTM with stand-in networks

@pytest.mark.parametrize("repack", [True, False])
def test_engine_audio_with_stand_in_networks(core, fake_reference, repack):
    items, ref = fake_reference
    log = json.loads((ref / "log.json").read_text())
    nn = fake_nn.FakeNN(devices=(0, 0, 1, 1))
    eng = make_engine(core, nn, repack=repack, keep_records=True, decode_threads=3, post_threads=6, max_ready=3)
    jobs = [(it["sid"], str(Path(it["path"])), it["path"]) for it in items]
    got = {r["uri"]: r for r in eng.run(jobs)}
    assert len(got) == len(items)
    bad = []
    for it in items:
        sid, r, want = it["sid"], got[it["sid"]], log[it["sid"]]
        if "error" in want or r["error"]:
            if r["error"] != want.get("error"):
                bad.append((sid, "error", want.get("error"), r["error"]))
            continue
        d = np.load(ref / f"{sid}.npz")
        rec = r["record"]
        same = (rec["P_bits"].tobytes() == np.ascontiguousarray(d["P"]).view(np.uint16).tobytes()
                and rec["P_bits"].shape == d["P"].shape
                and rec["masks"].tobytes() == d["masks"].tobytes() and rec["masks"].shape == d["masks"].shape
                and rec["emb"].tobytes() == d["emb"].tobytes() and rec["emb"].shape == d["emb"].shape
                and rec["chunk_sw"].tobytes() == d["chunk_sw"].tobytes()
                and rec["frames_sw"].tobytes() == d["frames_sw"].tobytes()
                and np.float64(rec["duration_s"]).tobytes() == d["duration_s"].tobytes())
        if not same:
            bad.append((sid, "record"))
        if r["rttm"] is None or r["rttm"].encode() != (ref / f"{sid}.rttm").read_bytes():
            bad.append((sid, "rttm"))
    assert not bad, bad
    # every network call is one of the release's shapes
    for call in nn.calls:
        if call[0] == "segment":
            assert call[1][1:] == (1, WIN) and 1 <= call[1][0] <= 8
        elif call[0] == "embed":
            assert call[1][1:] == (1, WIN) and call[1][0] in ((4,) if repack else (4, 8)) and call[2] == (call[1][0], 799)
        else:
            assert repack and call[1] == (8, 1, WIN) and call[2][1] == 8 and call[3][1:] == (8, 799)


# ---------------------------------------------------------------- robustness of the engine itself

class SlowFake(fake_nn.FakeNN):
    def segment(self, slot, x):
        import time
        time.sleep(0.05)
        return super().segment(slot, x)


class FailingFake(fake_nn.FakeNN):
    def segment(self, slot, x):
        # fails on any non-silent window
        if x[0, 0, :1000].sum() > 0:
            raise RuntimeError("CUDA out of memory (simulated)")
        return super().segment(slot, x)


def test_engine_stops_when_the_iterator_is_dropped(core, fake_reference):
    import time
    items, _ = fake_reference
    eng = make_engine(core, SlowFake(devices=(0,)), decode_threads=2, post_threads=2)
    it = eng.run([(i["sid"], i["path"], i["path"]) for i in items] * 3)
    first = next(it)
    t = time.perf_counter()
    # Drop: cancel and join
    del it
    assert time.perf_counter() - t < 30 and first["uri"]


def test_engine_reports_backend_errors_per_file(core, fake_reference):
    items, ref = fake_reference
    log = json.loads((ref / "log.json").read_text())
    eng = make_engine(core, FailingFake(), decode_threads=2, post_threads=2)
    got = list(eng.run([(i["sid"], i["path"], i["path"]) for i in items]))
    assert len(got) == len(items)
    for r in got:
        want = log[r["uri"]].get("error")
        assert r["error"] == want or r["error"] == "RuntimeError: CUDA out of memory (simulated)", (r["uri"], r["error"])


def test_engine_bits_do_not_depend_on_openblas_env(tmp_path):
    """The engine pins its private OpenBLAS to one thread itself: run with OPENBLAS_NUM_THREADS=64 in a fresh process."""
    import os
    import subprocess
    code = f"""
import json, sys, numpy as np
sys.path.insert(0, {str(PROJ / 'tests')!r})
import records, fake_nn
from anuvani_diar import _core
m = json.loads(open({str(REL / 'bundle/MANIFEST.json')!r}).read())["modes"]["indic"]
eng = _core.Engine(fake_nn.FakeNN(), clustering=m["clustering"], decoder_path={DECODER!r}, post_threads=8)
jobs = []
for s, p in records.sample(40):
    d = records.load(p)
    jobs.append((s + "/" + p.stem, np.ascontiguousarray(d["P"]).view(np.uint16), d["masks"], d["emb"],
                 list(map(float, d["frames_sw"])), float(d["duration_s"])))
bad = 0
for r in eng.run_records(jobs):
    s, sid = r["uri"].split("/", 1)
    want = open({str(CACHE_RTTM)!r} + f"/{{s}}/indic/{{sid}}.rttm", "rb").read()
    bad += r["rttm"].replace("SPEAKER " + r["uri"] + " ", "SPEAKER " + sid + " ").encode() != want
print("BAD", bad)
"""
    env = dict(os.environ, OPENBLAS_NUM_THREADS="64", OMP_NUM_THREADS="64", MKL_NUM_THREADS="64")
    out = subprocess.run([sys.executable, "-s", "-c", code], env=env, capture_output=True, text=True, check=True)
    assert "BAD 0" in out.stdout, out.stdout + out.stderr


def test_a_low_sample_rate_cannot_resample_past_the_limit(core, tmp_path):
    """The size limit applies to the audio after resampling too, computed from the header before anything is
    allocated: 1,000 samples declared at 1 Hz are 4 KB decoded but 64 MB at 16 kHz."""
    import soundfile as sf
    low, ok = tmp_path / "one_hertz.wav", tmp_path / "eight_khz.wav"
    sf.write(low, np.full(1000, 0.1, np.float32), 1, subtype="FLOAT")
    sf.write(ok, np.full(16000, 0.1, np.float32), 8000, subtype="FLOAT")
    nn = fake_nn.FakeNN()
    eng = make_engine(core, nn, max_audio_bytes=1 << 20, decode_threads=1, post_threads=2)
    got = {r["uri"]: r for r in eng.run([("low", str(low), str(low)), ("ok", str(ok), str(ok))])}
    assert got["low"]["error"] == ("RefusedError: audio of 64000000 bytes (resampled to 16 kHz) is larger than this "
                                   "engine's limit of 1048576 bytes")
    assert got["ok"]["error"] is None


def test_a_rate_that_does_not_reduce_cannot_build_a_huge_resampling_filter(core, tmp_path):
    """The resampler's filter grows with the declared rate, not the file's length: 100 samples declared at 16,001 Hz
    would build a 2 GB table. Refused from the header before anything is resampled (docs/SECURITY.md); 44.1 kHz runs."""
    import soundfile as sf
    odd, ok = tmp_path / "odd.wav", tmp_path / "cd.wav"
    sf.write(odd, np.full(100, 0.1, np.float32), 16001, subtype="FLOAT")
    sf.write(ok, np.full(44100, 0.1, np.float32), 44100, subtype="FLOAT")

    class Counting(fake_nn.FakeNN):
        rates = []

        def resample(self, audio, sr):
            self.rates.append(sr)
            return super().resample(audio, sr)
    nn = Counting()
    eng = make_engine(core, nn, decode_threads=1, post_threads=2)
    got = {r["uri"]: r for r in eng.run([("odd", str(odd), str(odd)), ("ok", str(ok), str(ok))])}
    assert got["odd"]["error"] == ("RefusedError: resampling 16001 Hz audio to 16 kHz needs a filter of 2049920000 bytes, "
                                   "larger than this engine's limit of 67108864 bytes")
    assert got["ok"]["error"] is None and nn.rates == [44100]