File size: 7,678 Bytes
27813b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import math

import pytest
import torch
import torch.nn.functional as Fn

import kernels

rp = kernels.get_kernel("phanerozoic/resample-poly", version=1,
                        trust_remote_code=True)

# Rate pairs that matter in practice, plus a few awkward ratios.
PAIRS = [(44100, 16000), (48000, 16000), (16000, 24000), (22050, 16000),
         (8000, 16000), (44100, 22050), (48000, 44100), (16000, 16000)]


def _sig(T, seed=0, B=1):
    g = torch.Generator().manual_seed(seed)
    return torch.randn(B, T, generator=g, dtype=torch.float32)


def _ref(x, plan):
    """torchaudio's formulation: conv1d over the padded-to-common-width bank."""
    L, M, width, kern = plan.L, plan.M, plan.width, plan.kernel
    B, T = x.shape
    pad = Fn.pad(x, (width, width + M))
    r = Fn.conv1d(pad[:, None], kern[:, None, :], stride=M)
    r = r.transpose(1, 2).reshape(B, -1)
    return r[:, :plan.out_len(T)]


# --------------------------------------------------------------------------
# Agreement with the padded-bank formulation
# --------------------------------------------------------------------------

@pytest.mark.parametrize("orig,new", PAIRS)
def test_matches_padded_bank(orig, new):
    plan = rp.Resampler(orig, new)
    x = _sig(orig // 2, seed=orig + new)
    got = plan(x)
    ref = _ref(x, plan)
    assert got.shape == ref.shape, (got.shape, ref.shape)
    scale = ref.abs().max().clamp(min=1e-6)
    rel = (got - ref).abs().max() / scale
    assert rel < 2e-5, f"{orig}->{new} max rel {rel.item():.3e}"


@pytest.mark.parametrize("width", [1, 4, 6, 16])
def test_matches_across_filter_widths(width):
    plan = rp.Resampler(44100, 16000, lowpass_filter_width=width)
    x = _sig(22050, seed=width)
    scale = _ref(x, plan).abs().max().clamp(min=1e-6)
    assert ((plan(x) - _ref(x, plan)).abs().max() / scale) < 2e-5


def test_kaiser_window():
    plan = rp.Resampler(48000, 16000, method="sinc_interp_kaiser")
    x = _sig(24000, seed=3)
    scale = _ref(x, plan).abs().max().clamp(min=1e-6)
    assert ((plan(x) - _ref(x, plan)).abs().max() / scale) < 2e-5


# --------------------------------------------------------------------------
# The support really is sparse, which is the whole point
# --------------------------------------------------------------------------

def test_support_is_a_small_fraction_of_the_padded_bank():
    plan = rp.Resampler(44100, 16000)
    assert plan.L == 160 and plan.M == 441
    assert plan.density < 0.15, plan.density
    # every discarded entry must be exactly zero, not merely small
    kept = int(plan.len.sum())
    assert int((plan.kernel != 0).sum()) == kept


def test_dropped_entries_are_exactly_zero():
    """Clamping past the filter half width sets the window to cos(pi/2)^2, so
    the trimmed region is exact rather than thresholded."""
    for orig, new in [(44100, 16000), (16000, 24000)]:
        plan = rp.Resampler(orig, new)
        for i in range(plan.L):
            row = plan.kernel[i]
            s = int(plan.start[i]) + plan.width
            n = int(plan.len[i])
            outside = torch.cat([row[:s], row[s + n:]])
            assert outside.abs().max() == 0.0


# --------------------------------------------------------------------------
# Signal properties
# --------------------------------------------------------------------------

def test_identity_rate_passes_the_band_but_is_not_the_identity():
    """At equal rates the bank is a single phase, but rolloff still puts a
    low-pass at 0.99 of Nyquist. A tone in the band comes through untouched;
    white noise does not, because its top percent is removed by design."""
    sr = 16000
    plan = rp.Resampler(sr, sr)
    assert plan.L == 1 and plan.M == 1

    t = torch.arange(sr, dtype=torch.float32) / sr
    tone = torch.sin(2 * math.pi * 1000.0 * t).unsqueeze(0)
    y = plan(tone)
    assert y.shape == tone.shape
    e = 64
    assert (y[:, e:-e] - tone[:, e:-e]).abs().max() < 5e-3

    noise = _sig(8000, seed=5)
    assert (plan(noise)[:, e:-e] - noise[:, e:-e]).abs().max() > 1e-3


def test_sine_survives_downsampling():
    """A tone well below the new Nyquist must come through at its own frequency
    and amplitude."""
    sr, new_sr, f = 48000, 16000, 440.0
    n = sr
    t = torch.arange(n, dtype=torch.float32) / sr
    x = torch.sin(2 * math.pi * f * t).unsqueeze(0)
    y = rp.Resampler(sr, new_sr)(x)
    e = 512
    mid = y[0, e:-e]
    assert abs(mid.abs().max().item() - 1.0) < 0.02
    spec = torch.fft.rfft(mid * torch.hann_window(mid.numel()))
    peak = int(spec.abs().argmax())
    got_f = peak * new_sr / mid.numel()
    assert abs(got_f - f) < 5.0, got_f


def test_above_nyquist_is_attenuated():
    """A tone above the new Nyquist must be filtered out, not aliased down."""
    sr, new_sr = 48000, 16000
    n = sr
    t = torch.arange(n, dtype=torch.float32) / sr
    x = torch.sin(2 * math.pi * 11000.0 * t).unsqueeze(0)
    y = rp.Resampler(sr, new_sr)(x)
    e = 512
    assert y[0, e:-e].abs().max() < 0.05, y[0, e:-e].abs().max().item()


def test_dc_preserved():
    x = torch.full((1, 8000), 0.25)
    y = rp.Resampler(44100, 16000)(x)
    e = 64
    assert (y[0, e:-e] - 0.25).abs().max() < 5e-3


# --------------------------------------------------------------------------
# Shapes and structure
# --------------------------------------------------------------------------

@pytest.mark.parametrize("orig,new", [(44100, 16000), (16000, 48000)])
def test_output_length(orig, new):
    plan = rp.Resampler(orig, new)
    for T in (1000, 4321, 44100):
        assert plan(_sig(T, seed=T)).shape[-1] == plan.out_len(T)


def test_unbatched_drops_batch_dim():
    plan = rp.Resampler(44100, 16000)
    x = _sig(4410, seed=7)
    flat = plan(x[0])
    assert flat.dim() == 1
    assert torch.equal(flat, plan(x)[0])


def test_batch_invariance():
    plan = rp.Resampler(48000, 16000)
    x = _sig(4800, seed=8, B=5)
    batched = plan(x)
    for i in range(5):
        assert torch.equal(batched[i], plan(x[i:i + 1])[0]), f"row {i}"


def test_deterministic():
    plan = rp.Resampler(44100, 16000)
    x = _sig(4410, seed=9)
    assert torch.equal(plan(x), plan(x))


def test_thread_count_invariance():
    plan = rp.Resampler(44100, 16000)
    x = _sig(441000, seed=10)
    prev = torch.get_num_threads()
    try:
        torch.set_num_threads(1)
        a = plan(x)
        torch.set_num_threads(max(2, prev))
        b = plan(x)
    finally:
        torch.set_num_threads(prev)
    assert torch.equal(a, b)


def test_path_selection_matches_the_measured_crossover():
    """Both paths must agree; the plan picks between them on taps per output,
    since the fused reduction is fixed cost per sample and only pays above
    roughly 16 taps."""
    fused = rp.Resampler(48000, 16000)
    bank = rp.Resampler(16000, 24000)
    assert fused.fused and fused.taps_per_output >= 16.0
    assert not bank.fused and bank.taps_per_output < 16.0

    # The unselected path must still produce the same answer.
    x = _sig(16000, seed=31)
    plan = rp.Resampler(48000, 16000)
    direct = plan(_sig(48000, seed=32))
    plan.fused = False
    fallback = plan(_sig(48000, seed=32))
    plan.fused = True
    scale = fallback.abs().max().clamp(min=1e-6)
    assert ((direct - fallback).abs().max() / scale) < 2e-5


def test_rejects_bad_arguments():
    with pytest.raises(ValueError):
        rp.Resampler(0, 16000)
    with pytest.raises(ValueError):
        rp.Resampler(44100, 16000, lowpass_filter_width=0)
    with pytest.raises(ValueError):
        rp.Resampler(44100, 16000, method="nope")
    with pytest.raises(ValueError):
        rp.Resampler(44100, 16000)(torch.randn(2, 3, 4))