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))
|