kopia lustrzana https://github.com/corrscope/corrscope
293 wiersze
9.0 KiB
Python
293 wiersze
9.0 KiB
Python
import warnings
|
|
from typing import Sequence
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from delayed_assert import expect, assert_expectations
|
|
from numpy.testing import assert_allclose
|
|
|
|
from corrscope.config import CorrError
|
|
from corrscope.utils.scipy.wavfile import WavFileWarning
|
|
from corrscope.wave import Wave, Flatten, calc_flatten_matrix
|
|
|
|
parametrize = pytest.mark.parametrize
|
|
|
|
prefix = "tests/wav-formats/"
|
|
wave_paths = [
|
|
# 2000 samples, with a full-scale peak at data[1000].
|
|
"u8-impulse1000.wav",
|
|
"s16-impulse1000.wav",
|
|
"s32-impulse1000.wav",
|
|
"f32-impulse1000.wav",
|
|
"f64-impulse1000.wav",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("wave_path", wave_paths)
|
|
def test_wave(wave_path):
|
|
with warnings.catch_warnings(record=True) as w:
|
|
# Cause all warnings to always be triggered.
|
|
warnings.simplefilter("always")
|
|
|
|
wave = Wave(prefix + wave_path)
|
|
data = wave[:]
|
|
|
|
# Audacity dithers <=16-bit WAV files upon export, creating a few bits of noise.
|
|
# As a result, amin(data) <= 0.
|
|
assert -0.01 < np.amin(data) <= 0
|
|
assert 0.99 < np.amax(data) <= 1
|
|
|
|
# check for FutureWarning (raised when determining wavfile type)
|
|
warns = [o for o in w if issubclass(o.category, FutureWarning)]
|
|
assert not [str(w) for w in warns]
|
|
|
|
|
|
def test_incomplete_wav_chunk():
|
|
"""Tests that WAV files exported from foobar2000 can be loaded properly,
|
|
without a `ValueError: Incomplete wav chunk.` exception being raised."""
|
|
|
|
wave = Wave(prefix + "incomplete-wav-chunk.wav")
|
|
data = wave[:]
|
|
|
|
|
|
def test_24_bit_wav():
|
|
"""Tests that WAV files exported from foobar2000 can be loaded properly,
|
|
without a `ValueError: Incomplete wav chunk.` exception being raised."""
|
|
|
|
try:
|
|
wave = Wave(prefix + "s24-impulse1000.wav")
|
|
except ValueError as e:
|
|
assert e.args == (f"24-bit WAV is not supported.",)
|
|
except Exception as e:
|
|
pytest.fail(f"Unexpected exception {e}")
|
|
else:
|
|
pytest.fail(f"Expected exception loading 24-bit wav, none found")
|
|
|
|
|
|
# Stereo tests
|
|
|
|
|
|
def arr(*args):
|
|
return np.array(args)
|
|
|
|
|
|
def test_calc_flatten_matrix():
|
|
nchan = 3
|
|
|
|
# Test Stereo
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.Stereo, nchan), np.eye(nchan))
|
|
|
|
# Test SumAvg on various channel counts
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.SumAvg, 1), [1])
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.SumAvg, 2), [0.5, 0.5])
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.SumAvg, 4), [0.25] * 4)
|
|
|
|
# Test DiffAvg on various channel counts
|
|
# (Wave will use Mono instead of DiffAvg, on mono audio signals.
|
|
# But ensure it doesn't crash anyway.)
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.DiffAvg, 1), [0.5])
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.DiffAvg, 2), [0.5, -0.5])
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.DiffAvg, 4), [0.5, -0.5, 0, 0])
|
|
|
|
# Test Mono
|
|
np.testing.assert_equal(calc_flatten_matrix(Flatten.Mono, 1), [1])
|
|
|
|
# Test custom strings and delimiters
|
|
out = arr(1, 2, 1)
|
|
nchan = 3
|
|
np.testing.assert_equal(calc_flatten_matrix(",1,2,1,", nchan), out / sum(out))
|
|
np.testing.assert_equal(calc_flatten_matrix(" 1, 2, 1 ", nchan), out / sum(out))
|
|
np.testing.assert_equal(calc_flatten_matrix("1 2 1", nchan), out / sum(out))
|
|
|
|
# Test negative values
|
|
nchan = 2
|
|
np.testing.assert_equal(calc_flatten_matrix("1, -1", nchan), arr(1, -1) / 2)
|
|
np.testing.assert_equal(calc_flatten_matrix("-1, 1", nchan), arr(-1, 1) / 2)
|
|
np.testing.assert_equal(calc_flatten_matrix("-1, -1", nchan), arr(-1, -1) / 2)
|
|
|
|
# Test invalid inputs
|
|
with pytest.raises(CorrError):
|
|
calc_flatten_matrix("", 0)
|
|
|
|
with pytest.raises(CorrError):
|
|
calc_flatten_matrix("1 -1 uwu", 3)
|
|
|
|
with pytest.raises(CorrError):
|
|
calc_flatten_matrix("0 0", 2)
|
|
|
|
|
|
def test_stereo_merge():
|
|
"""Test indexing Wave by slices *or* ints. Flatten using default SumAvg mode."""
|
|
|
|
# Contains a full-scale sine wave in left channel, and silence in right.
|
|
# λ=100, nsamp=2000
|
|
wave = Wave(prefix + "stereo-sine-left-2000.wav")
|
|
period = 100
|
|
nsamp = 2000
|
|
|
|
# [-1, 1) from [-32768..32768)
|
|
int16_step = (1 - -1) / (2**16)
|
|
assert int16_step == 2**-15
|
|
|
|
# Check wave indexing dimensions.
|
|
assert wave[0].shape == ()
|
|
assert wave[:].shape == (nsamp,)
|
|
|
|
# Check stereo merging.
|
|
assert_allclose(wave[0], 0)
|
|
assert_allclose(wave[period], 0)
|
|
assert_allclose(wave[period // 4], 0.5, atol=int16_step)
|
|
|
|
def check_bound(obj):
|
|
amax = np.amax(obj)
|
|
assert amax.shape == ()
|
|
|
|
assert_allclose(amax, 0.5, atol=int16_step)
|
|
assert_allclose(np.amin(obj), -0.5, atol=int16_step)
|
|
|
|
check_bound(wave[:])
|
|
|
|
|
|
AllFlattens = [*Flatten.__members__.values(), "1 1", "1 0", "1 -1"]
|
|
|
|
|
|
@pytest.mark.parametrize("flatten", AllFlattens)
|
|
@pytest.mark.parametrize("return_channels", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"path,nchan,peaks",
|
|
[
|
|
("tests/sine440.wav", 1, [0.5]),
|
|
("tests/stereo in-phase.wav", 2, [1, 1]),
|
|
("tests/wav-formats/stereo-sine-left-2000.wav", 2, [1, 0]),
|
|
],
|
|
)
|
|
def test_stereo_flatten_modes(
|
|
flatten: Flatten,
|
|
return_channels: bool,
|
|
path: str,
|
|
nchan: int,
|
|
peaks: Sequence[float],
|
|
):
|
|
"""Ensures all Flatten modes are handled properly
|
|
for stereo and mono signals."""
|
|
|
|
# return_channels=False <-> triggering.
|
|
# flatten=stereo -> rendering.
|
|
# These conditions do not currently coexist.
|
|
# if not return_channels and flatten == Flatten.Stereo:
|
|
# return
|
|
|
|
assert nchan == len(peaks)
|
|
wave = Wave(path)
|
|
|
|
if flatten is Flatten.Mono:
|
|
with pytest.raises(CorrError):
|
|
wave.with_flatten(flatten, return_channels)
|
|
return
|
|
else:
|
|
wave = wave.with_flatten(flatten, return_channels)
|
|
|
|
nsamp = wave.nsamp
|
|
data = wave[:]
|
|
|
|
# wave.data == 2-D array of shape (nsamp, nchan)
|
|
if flatten == Flatten.Stereo:
|
|
assert data.shape == (nsamp, nchan)
|
|
for chan_data, peak in zip(data.T, peaks):
|
|
assert_full_scale(chan_data, peak)
|
|
else:
|
|
if return_channels:
|
|
assert data.shape == (nsamp, 1)
|
|
else:
|
|
assert data.shape == (nsamp,)
|
|
|
|
# If DiffAvg and in-phase, L-R=0.
|
|
if flatten == Flatten.DiffAvg:
|
|
if len(peaks) >= 2 and peaks[0] == peaks[1]:
|
|
np.testing.assert_equal(data, 0)
|
|
else:
|
|
pass
|
|
# If SumAvg, check average.
|
|
elif flatten == Flatten.SumAvg:
|
|
assert_full_scale(data, np.mean(peaks))
|
|
# Don't test custom string modes for now.
|
|
|
|
|
|
def assert_full_scale(data, peak):
|
|
peak = abs(peak)
|
|
assert np.amax(data) == pytest.approx(peak, rel=0.01)
|
|
assert np.amin(data) == pytest.approx(-peak, rel=0.01)
|
|
|
|
|
|
def test_stereo_mmap():
|
|
wave = Wave(prefix + "stereo-sine-left-2000.wav")
|
|
assert isinstance(wave.data, np.memmap)
|
|
|
|
|
|
# Miscellaneous tests
|
|
|
|
|
|
def test_wave_subsampling():
|
|
wave = Wave("tests/sine440.wav")
|
|
# period = 48000 / 440 = 109.(09)*
|
|
|
|
wave.get_around(1000, return_nsamp=501, stride=4)
|
|
# len([:region_len:subsampling]) == ceil(region_len / subsampling)
|
|
# If region_len % subsampling != 0, len() != region_len // subsampling.
|
|
|
|
stride = 4
|
|
region = 100 # diameter = region * stride
|
|
for i in [-1000, 50000]:
|
|
data = wave.get_around(i, region, stride)
|
|
assert (data == 0).all()
|
|
|
|
|
|
is_odd = parametrize("is_odd", [False, True])
|
|
|
|
|
|
@is_odd
|
|
def test_wave_subsampling_off_by_1(is_odd: bool):
|
|
"""When calling wave.get_around(x, N), ensure that result[N//2] == wave[x]."""
|
|
wave = Wave(prefix + "s16-impulse1000.wav")
|
|
for stride in range(1, 5 + 1):
|
|
N = 500 + is_odd
|
|
halfN = N // 2
|
|
result = wave.get_around(1000, N, stride)
|
|
|
|
assert result[halfN] > 0.5, stride
|
|
|
|
result[halfN] = 0
|
|
np.testing.assert_almost_equal(result, 0, decimal=3)
|
|
# looks like s16-impulse1000.wav isn't 0-filled after all
|
|
|
|
|
|
def test_stereo_doesnt_overflow():
|
|
"""Ensure loud stereo tracks do not overflow."""
|
|
wave = Wave("tests/stereo in-phase.wav")
|
|
|
|
samp = 100
|
|
stride = 1
|
|
data = wave.get_around(wave.nsamp // 2, samp, stride)
|
|
expect(np.amax(data) > 0.99)
|
|
expect(np.amin(data) < -0.99)
|
|
|
|
# In the absence of overflow, sine waves have no large jumps.
|
|
# In the presence of overflow, stereo sum will jump between INT_MAX and INT_MIN.
|
|
# np.mean and rescaling converts to 0.499... and -0.5, which is nearly 1.
|
|
expect(np.amax(np.abs(np.diff(data))) < 0.5)
|
|
|
|
assert_expectations()
|
|
|
|
|
|
def test_header_larger_than_filesize():
|
|
"""According to Zeinok, VortexTracker 2.5 produces slightly corrupted WAV files
|
|
whose RIFF header metadata indicates a filesize larger than the actual filesize.
|
|
|
|
Most programs read the audio chunk fine.
|
|
Scipy normally rejects such files, raises ValueError("Unexpected end of file.")
|
|
My version instead accepts such files (but warns WavFileWarning).
|
|
"""
|
|
with pytest.warns(WavFileWarning):
|
|
wave = Wave("tests/header larger than filesize.wav")
|
|
assert wave
|