File size: 15,416 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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# Copyright (c) 2026 The Anuvani-Diarization authors
# SPDX-License-Identifier: MIT OR Apache-2.0
"""diar-audio against soundfile 0.14.0 (libsndfile 1.2.2) and the release's `load_audio`: decoded float32 samples,
sample rates, channel means and error messages, compared bit for bit.

The engine's input path is `decode_mono` (Rust), then torchaudio's resample when the rate is not 16 kHz (the same
call the release makes, run by the network backend), then `validate` (Rust). `engine_load_audio` below composes
exactly that, so the end-to-end comparison with the release's `load_audio` covers every branch.
"""
import json
import os
import sys
import unicodedata
from multiprocessing import get_context
from pathlib import Path, PurePosixPath

import numpy as np
import pytest
import soundfile as sf
from hypothesis import given, strategies as st
from hypothesis.extra import numpy as hnp

from conftest import PROJ

TEST_AUDIO = PROJ / "artifacts/test_audio"


@pytest.fixture(scope="session")
def load_audio_ref():
    sys.path.insert(0, str(PROJ / "tests"))
    import refpkg
    refpkg.load()
    import torch
    # what load_bundle sets before any load_audio
    torch.set_num_threads(2)
    from anuvani_diar_ref.frontend import load_audio
    return load_audio


class EngineError(Exception):
    """An error the engine reports as the string "ExceptionName: message"."""


def outcome(fn, *a):
    try:
        return fn(*a), None
    except EngineError as e:
        return None, str(e)
    # the release's failure is part of the contract
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"


def engine_load_audio(core, path):
    """decode_mono (Rust) -> torchaudio resample if needed (the release's own call) -> validate (Rust)."""
    import torch
    import torchaudio.functional as F
    name = str(path)
    audio, sr, err = core.decode_mono(name)
    if audio is None:
        raise EngineError(err)
    if sr != 16000:
        audio = F.resample(torch.from_numpy(audio).unsqueeze(0), sr, 16000).squeeze(0).numpy().astype(np.float32)
        sr = 16000
        err = core.validate_audio(name, audio)
    if err:
        raise EngineError(err)
    return audio, sr


def same_load(core, load_audio_ref, path):
    ref, ref_err = outcome(load_audio_ref, Path(path))
    got, got_err = outcome(engine_load_audio, core, Path(path))
    if ref_err or got_err:
        return ref_err is not None and got_err == ref_err, (ref_err, got_err)
    (ra, rsr), (ga, gsr) = ref, got
    return (rsr == gsr and ra.dtype == ga.dtype and ra.shape == ga.shape and ra.tobytes() == ga.tobytes()), None


# ---------------------------------------------------------------- synthetic files: every format and subtype

def _signal(rng, n, ch, kind):
    x = (rng.standard_normal((n, ch)) * 0.3).astype(np.float64)
    if kind == "clip":
        # full scale
        x[: n // 3] = np.sign(x[: n // 3])
    if kind == "edge":
        x = np.clip(x, -1, 1)
        if n > 8:
            x[0] = 1.0; x[1] = -1.0; x[2] = 0.0; x[3] = -0.0; x[4] = 1e-40; x[5] = 3.0; x[6] = -3.0
    return np.clip(x, -1, 1) if kind != "edge" else x


# (format, subtype, extension, rates)
SPECS = [
    ("WAV", "PCM_U8", "wav", (16000, 8000)), ("WAV", "PCM_16", "wav", (16000, 44100)), ("WAV", "PCM_24", "wav", (16000, 48000)),
    ("WAV", "PCM_32", "wav", (16000,)), ("WAV", "FLOAT", "wav", (16000, 22050)), ("WAV", "DOUBLE", "wav", (16000,)),
    ("WAV", "ULAW", "wav", (16000, 8000)), ("WAV", "ALAW", "wav", (8000,)), ("WAV", "IMA_ADPCM", "wav", (16000,)),
    ("WAV", "MS_ADPCM", "wav", (16000,)), ("WAV", "GSM610", "wav", (8000,)),
    ("FLAC", "PCM_S8", "flac", (16000,)), ("FLAC", "PCM_16", "flac", (16000, 44100)), ("FLAC", "PCM_24", "flac", (16000, 48000)),
    ("OGG", "VORBIS", "ogg", (16000, 44100)), ("OGG", "OPUS", "opus", (16000, 48000)),
    ("MP3", "MPEG_LAYER_III", "mp3", (16000, 44100)),
    ("AIFF", "PCM_16", "aiff", (16000,)), ("AIFF", "FLOAT", "aiff", (16000,)), ("AU", "PCM_16", "au", (16000,)),
    ("W64", "PCM_16", "w64", (16000,)), ("CAF", "PCM_16", "caf", (16000,)), ("RF64", "FLOAT", "rf64", (16000,)),
]
CHANNELS = (1, 2, 3, 6, 8)
LENGTHS = (1, 7, 4801, 3 * 16000 + 5)


@pytest.fixture(scope="session")
def synthetic(tmp_path_factory):
    d = tmp_path_factory.mktemp("audio")
    rng = np.random.default_rng(20261007)
    files = []
    for fmt, sub, ext, rates in SPECS:
        for ch in CHANNELS:
            if sub == "GSM610" and ch > 1:
                continue
            for n in LENGTHS:
                for sr in rates:
                    kind = ("plain", "clip", "edge")[(len(files)) % 3]
                    x = _signal(rng, n, ch, kind if sub in ("FLOAT", "DOUBLE") else ("clip" if kind == "clip" else "plain"))
                    p = d / f"{fmt}_{sub}_{ch}ch_{n}_{sr}.{ext}"
                    try:
                        sf.write(p, x if ch > 1 else x[:, 0], sr, subtype=sub, format=fmt)
                    except Exception:
                        # a combination libsndfile cannot write
                        continue
                    files.append(p)
    # float files carrying NaN / inf (the release rejects them after the mean)
    for name, val in (("nan", np.nan), ("posinf", np.inf), ("neginf", -np.inf)):
        x = _signal(rng, 32000, 2, "plain"); x[777, 1] = val
        p = d / f"WAV_FLOAT_special_{name}.wav"; sf.write(p, x, 16000, subtype="FLOAT"); files.append(p)
    # >= 8 channels: numpy's 8-accumulator pairwise sum
    for ch in (9, 12, 16, 33):
        x = _signal(rng, 9000, ch, "plain")
        p = d / f"WAV_FLOAT_{ch}ch.wav"; sf.write(p, x, 16000, subtype="FLOAT"); files.append(p)
    # empty files at both kinds of rate
    for sr in (16000, 8000):
        p = d / f"empty_{sr}.wav"; sf.write(p, np.zeros(0, np.float32), sr, subtype="PCM_16"); files.append(p)
    # not audio / unreadable
    (d / "text.wav").write_text("not a wav file\n"); files.append(d / "text.wav")
    trunc = d / "truncated.flac"; good = files[[str(f) for f in files].index(str(d / "FLAC_PCM_16_2ch_48005_16000.flac"))]
    trunc.write_bytes(good.read_bytes()[: len(good.read_bytes()) // 2]); files.append(trunc)
    (d / "zero_bytes.wav").write_bytes(b""); files.append(d / "zero_bytes.wav")
    files.append(d / "does_not_exist.wav")
    # a directory
    files.append(d)
    return files


def test_synthetic_matrix_is_broad(synthetic):
    assert len(synthetic) > 250, len(synthetic)


def test_sf_read_matches_soundfile(core, synthetic):
    core.sndfile_init()
    bad = []
    for p in synthetic:
        ref, ref_err = outcome(sf.read, str(p), -1, 0, None, "float32", False)
        got, got_err = core.sf_read_f32(str(p))
        if ref_err or got is None:
            if ref_err != got_err:
                bad.append((p.name, ref_err, got_err))
            continue
        ra, rsr = ref
        if not (rsr == got_err and ra.dtype == got.dtype and ra.shape == got.shape and ra.tobytes() == got.tobytes()):
            bad.append((p.name, "data", ra.shape, getattr(got, "shape", None)))
    assert not bad, bad[:10]


def test_load_audio_matches_release(core, load_audio_ref, synthetic):
    bad = [(p.name, info) for p in synthetic for ok, info in [same_load(core, load_audio_ref, p)] if not ok]
    assert not bad, bad[:10]


def test_robustness_files_match_release(core, load_audio_ref):
    items = json.loads((TEST_AUDIO / "MANIFEST.json").read_text())
    bad = [(i["sid"], info) for i in items for ok, info in [same_load(core, load_audio_ref, i["path"])] if not ok]
    assert len(items) == 28 and not bad, bad


# ---------------------------------------------------------------- channel mean

special = st.sampled_from([0.0, -0.0, 1.0, -1.0, 1e-45, -1e-45, 1e-38, 3.4e38, -3.4e38, np.inf, -np.inf, np.nan, 0.1, 1 / 3])


@given(hnp.arrays(np.float32, st.tuples(st.integers(0, 40), st.integers(1, 40)),
                  elements=st.one_of(st.floats(-1, 1, width=32), special.map(np.float32))))
def test_channel_mean_matches_numpy(core, x):
    got = core.channel_mean(np.ascontiguousarray(x))
    ref = x.mean(axis=1).astype(np.float32) if x.shape[0] else np.zeros(0, np.float32)
    na, nb = np.isnan(ref), np.isnan(got)
    assert np.array_equal(na, nb) and ref[~na].tobytes() == got[~nb].tobytes()


def test_channel_mean_long_rows(core):
    rng = np.random.default_rng(3)
    for ch in (127, 128, 129, 255, 1000, 9000):
        x = rng.standard_normal((5, ch)).astype(np.float32)
        assert core.channel_mean(x).tobytes() == x.mean(axis=1).astype(np.float32).tobytes(), ch


# ---------------------------------------------------------------- names and messages

def test_repr_every_code_point(core):
    bad = []
    for cp in range(0x110000):
        if 0xD800 <= cp <= 0xDFFF:
            continue
        s = chr(cp)
        if core.py_repr_str(s) != repr(s):
            bad.append(hex(cp))
    assert not bad, (len(bad), bad[:20])


@given(st.text(alphabet=st.sampled_from(list("ab'\"\\/ .\t\n\r\x00\x7f\x80\xa0कि​\U0001F600")), max_size=12))
def test_repr_strings(core, s):
    if "\x00" in s:
        return
    assert core.py_repr_str(s) == repr(s)


@pytest.mark.parametrize("p", ["a//b/./c/", "./a", "", ".", "//a", "///a", "a/../b", "/", "x/.wav", "/x/a.b.wav",
                               "x.", "a/b/", "..", "./..", "a/./././b", "/x/noext", "/x/.hidden.flac", "c d/e f.mp3"])
def test_pathlib(core, p):
    assert core.pathlib_str(p) == str(PurePosixPath(p))
    assert core.pathlib_stem(p) == PurePosixPath(p).stem


# ---------------------------------------------------------------- every replay input (slow)

def _check_one(item):
    from anuvani_diar import _core as core
    import refpkg
    refpkg.load()
    import torch
    torch.set_num_threads(2)
    from anuvani_diar_ref.frontend import load_audio
    ok, info = same_load(core, load_audio, item["path"])
    return item["set"] + "/" + item["sid"], ok, info


@pytest.mark.slow
def test_every_replay_input_decodes_identically():
    items = json.loads((PROJ / "artifacts/oracle_audio_full_inputs.json").read_text())
    with get_context("spawn").Pool(16, initializer=os.nice, initargs=(5,)) as pool:
        res = pool.map(_check_one, items, chunksize=4)
    bad = [r for r in res if not r[1]]
    assert len(res) == 1973 and not bad, bad[:10]


# ---------------------------------------------------------------- damaged files (seeded mutations)

MUTANT_BASES = [("WAV", "PCM_16", "wav", 2, 16000), ("WAV", "FLOAT", "wav", 1, 16000), ("FLAC", "PCM_16", "flac", 1, 16000),
                ("FLAC", "PCM_24", "flac", 2, 44100), ("OGG", "VORBIS", "ogg", 2, 16000), ("MP3", "MPEG_LAYER_III", "mp3", 1, 16000),
                ("AIFF", "PCM_16", "aiff", 1, 8000), ("W64", "PCM_16", "w64", 1, 16000), ("CAF", "PCM_16", "caf", 2, 16000),
                ("AU", "PCM_16", "au", 1, 16000), ("RF64", "FLOAT", "rf64", 1, 16000)]
EDGE_WORDS = (0, 1, 2, 0x7F, 0xFF, 0x7FFF, 0xFFFF, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFF)
PLAIN_RATES = (8000, 16000, 22050, 44100, 48000)


def mutate(data, rng):
    """One seeded damage: bits flipped in the header or anywhere, a cut, a header word set to an edge value,
    bytes inserted or removed, or junk appended."""
    b = bytearray(data)
    kind = int(rng.integers(6))
    if kind == 0:
        for _ in range(int(rng.integers(1, 4))):
            i = int(rng.integers(min(len(b), 64)))
            b[i] ^= 1 << int(rng.integers(8))
    elif kind == 1:
        for _ in range(int(rng.integers(1, 8))):
            i = int(rng.integers(len(b)))
            b[i] ^= 1 << int(rng.integers(8))
    elif kind == 2:
        b = b[: int(rng.integers(len(b)))]
    elif kind == 3:
        i = 4 * int(rng.integers(min(len(b), 128) // 4))
        b[i:i + 4] = int(EDGE_WORDS[int(rng.integers(len(EDGE_WORDS)))]).to_bytes(4, "little" if rng.integers(2) else "big")
    elif kind == 4:
        i = int(rng.integers(len(b)))
        if rng.integers(2):
            b[i:i] = rng.integers(0, 256, int(rng.integers(1, 16)), dtype=np.uint8).tobytes()
        else:
            del b[i:i + int(rng.integers(1, 16))]
    else:
        b += rng.integers(0, 256, int(rng.integers(1, 4096)), dtype=np.uint8).tobytes()
    return bytes(b)


@pytest.fixture(scope="session")
def mutants(tmp_path_factory):
    d = tmp_path_factory.mktemp("mutants")
    rng = np.random.default_rng(20261008)
    files = []
    for fmt, sub, ext, ch, sr in MUTANT_BASES:
        x = _signal(rng, 8000, ch, "plain")
        base = d / f"base_{fmt}_{sub}.{ext}"
        sf.write(base, x if ch > 1 else x[:, 0], sr, subtype=sub, format=fmt)
        data = base.read_bytes()
        for k in range(250):
            p = d / f"m_{fmt}_{sub}_{k:03d}.{ext}"
            p.write_bytes(mutate(data, rng))
            files.append(p)
    return files


def test_damaged_files_decode_as_soundfile(core, mutants):
    """2,750 damaged files: the same samples and rate as soundfile, or the same error text."""
    core.sndfile_init()
    bad, kinds = [], set()
    for p in mutants:
        ref, ref_err = outcome(sf.read, str(p), -1, 0, None, "float32", False)
        got, got_err = core.sf_read_f32(str(p))
        if ref_err or got is None:
            kinds.add((ref_err or "").split(":")[0])
            if ref_err != got_err:
                bad.append((p.name, ref_err, got_err))
            continue
        ra, rsr = ref
        if not (rsr == got_err and ra.dtype == got.dtype and ra.shape == got.shape and ra.tobytes() == got.tobytes()):
            bad.append((p.name, "data", ra.shape, getattr(got, "shape", None)))
    assert not bad, bad[:10]
    # the suite does reach the error paths
    assert "LibsndfileError" in kinds


def test_damaged_files_load_as_the_release(core, load_audio_ref, mutants):
    """The whole input path (decode, channel mean, resample at the usual rates, validation) on the damaged files;
    files whose damage gives an unusual rate are compared at decoding only (above), since resampling an arbitrary
    rate is the release's own torchaudio call either way."""
    bad, n = [], 0
    for p in mutants:
        got, sr = core.sf_read_f32(str(p))
        if got is not None and sr not in PLAIN_RATES:
            continue
        n += 1
        ok, info = same_load(core, load_audio_ref, p)
        if not ok:
            bad.append((p.name, info))
    assert n > 1500 and not bad, (n, bad[:10])


def test_memory_error_text_matches_numpy(core):
    from numpy.core._exceptions import _ArrayMemoryError
    sizes = set()
    for k in range(0, 62):
        for d in (-3, -1, 0, 1, 3):
            sizes.add((1 << k) + d)
    for unit in range(1, 7):
        for v in (999.4, 999.5, 999.6, 1023.4, 1023.5, 1023.6, 1.0, 1.005, 9.995, 99.95):
            sizes.add(int(v * (1 << (10 * unit))))
    rng = np.random.default_rng(5)
    sizes.update(int(x) for x in rng.integers(1, 1 << 62, 3000, dtype=np.int64))
    bad = []
    # numpy refuses larger sizes before allocating
    for nb in sorted(s for s in sizes if 0 < s < 1 << 63):
        for ch in (1, 2):
            frames = nb // (4 * ch)
            if frames <= 0:
                continue
            shape = (frames,) if ch == 1 else (frames, ch)
            want = str(_ArrayMemoryError(shape, np.dtype("float32")))
            got = core.numpy_memory_error(frames, ch)
            if got != want:
                bad.append((frames, ch, want, got))
    assert not bad, bad[:5]