Files
wildminder-ComfyUI-Chatterbox/tests/test_dsp.py
T
WildAi 6e81c7e604 refactor(vendor): librosa/scipy to dsp replicas
The training-baked featurization (three Slaney mel filterbanks, the
voice encoder's STFT, the periodic Hann behind the vocoder's ISTFT
convergence) is reimplemented operation-for-operation in a new vendored
module, src/chatterbox/models/dsp.py (ISC attribution retained), so
librosa and scipy leave the vendored tree's import list. Bit-exactness
against librosa 1.0.0 on these exact parameters was proven once by the
probe; tests/test_dsp.py pins what must not drift afterwards.
2026-10-09 22:02:49 +03:00

328 lines
13 KiB
Python

"""Unit tests for the vendored ``models/dsp.py`` DSP replicas.
``conftest.py`` registers a ``MagicMock`` for every dotted name under
``src/chatterbox`` -- ``chatterbox.models.dsp`` included -- so the module under
test is loaded straight from disk under a private name, the same mechanism the
one-off librosa-vs-replica probe uses. No test in the shipped path imports
librosa or scipy: bit-exactness against librosa 1.0 was proven once by that
probe, and what these tests pin is what must not silently drift afterwards --
shapes, dtypes, and the padding/trim branch structure -- using only numpy,
torch and soundfile.
The parameters are the vendored call sites' own: the three baked mel
filterbanks (s3tokenizer, s3gen mel, voice encoder), the voice encoder's STFT
(n_fft=400, hop=160, win=400, center=True, reflect) and trim (top_db=20), and
the periodic Hann behind the scipy ``get_window`` mirror. The signals come
from the probe's seed and draw order, so the inputs pinned here are the inputs
that were proven bit-exact there.
"""
from __future__ import annotations
import importlib.util
import os
import warnings
import numpy as np
import pytest
import torch
from conftest import ROOT_DIR
DSP_PATH = os.path.join(ROOT_DIR, "src", "chatterbox", "models", "dsp.py")
#: The voice encoder's STFT geometry (``VoiceEncConfig`` in
#: ``models/voice_encoder/config.py``); shared by every stft test below.
N_FFT = 400
HOP_LENGTH = 160
#: The one-off probe's seed. Drawing in its order keeps every signal these
#: tests use byte-identical to the signal the librosa comparison used.
_PROBE_SEED = 20261009
def _load_dsp_module():
"""Execute ``models/dsp.py`` under a private top-level name.
The conftest mock covers ``chatterbox.models.dsp``, so a package import
would hand back a MagicMock; loading the file directly is the only way to
exercise the real thing without waking the model stack.
"""
spec = importlib.util.spec_from_file_location("dsp_under_test", DSP_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
dsp = _load_dsp_module()
#: The three filterbanks the pretrained featurization bakes in, as
#: ``(label, sr, n_fft, n_mels, fmin, fmax)`` -- one per vendored call site.
FILTERBANKS = (
("s3tokenizer", 16000, 400, 128, 0.0, None),
("s3gen", 24000, 1920, 80, 0.0, 8000),
("voice_encoder", 16000, 400, 40, 0.0, 8000),
)
#: Input lengths that hit every centred-padding branch: shorter than
#: ``n_fft`` (warns), shorter than ``n_fft + 2*hop`` (head/tail overlap ->
#: copy-pad), and longer (pre/post frame split).
STFT_LENGTHS = (300, 400, 450, 521, 1000, 1600, 2423, 24000, 24001, 100000)
_probe_rng = np.random.default_rng(_PROBE_SEED)
_STFT_INPUTS = {
length: _probe_rng.standard_normal(length).astype(np.float32)
for length in STFT_LENGTHS
}
_NOISE = _probe_rng.standard_normal(24000).astype(np.float32)
def _speech_like(length: int, sr: int) -> np.ndarray:
"""Three bursts of a 220 Hz tone at 0.3 amplitude, silence between.
The probe's speech generator: RMS-rich bursts separated by long silences,
which is what puts the trim edges to work.
"""
t = np.arange(length) / sr
y = np.zeros(length, dtype=np.float64)
for lo, hi in [(0.05, 0.3), (0.4, 0.7), (0.8, 0.95)]:
mask = (t >= lo) & (t < hi)
y[mask] = 0.3 * np.sin(2 * np.pi * 220 * t[mask])
return y.astype(np.float32)
def _naive_centered_stft(y: np.ndarray) -> np.ndarray:
"""librosa's centred STFT as plain pad-frame-window-rfft.
``dsp.stft`` splits long signals into pre/post frame groups and copy-pads
short ones; both are optimizations that must reproduce this direct
definition. The window comes from the module under test because it is
pinned separately (:class:`TestHannWindow`) -- what this reference checks
is the padding and frame layout, bit for bit.
"""
pad = N_FFT // 2
padded = np.pad(y, (pad, pad), mode="reflect")
n_frames = 1 + (len(padded) - N_FFT) // HOP_LENGTH
frames = np.stack(
[padded[i * HOP_LENGTH : i * HOP_LENGTH + N_FFT] for i in range(n_frames)],
axis=-1,
)
window = dsp.hann_window(N_FFT)[:, np.newaxis]
return np.fft.rfft(window * frames, axis=-2).astype(np.complex64)
class TestMelFilterbank:
"""The Slaney triangles the three featurizers bake in at import time."""
@pytest.mark.parametrize(
("label", "sr", "n_fft", "n_mels", "fmin", "fmax"),
FILTERBANKS,
ids=[case[0] for case in FILTERBANKS],
)
def test_shape_dtype_and_triangle_structure(
self,
label: str,
sr: int,
n_fft: int,
n_mels: int,
fmin: float,
fmax: float | None,
) -> None:
weights = dsp.mel_filterbank(sr, n_fft, n_mels, fmin=fmin, fmax=fmax)
# librosa's default output: float32, one row per mel band.
assert weights.shape == (n_mels, 1 + n_fft // 2)
assert weights.dtype == np.float32
assert (weights >= 0).all(), f"{label}: negative weight"
assert (weights > 0).any(axis=1).all(), f"{label}: an all-zero mel band"
for row in weights:
support = np.flatnonzero(row)
# A Slaney triangle is one contiguous run of bins, never scatter.
assert support.size > 0, f"{label}: an all-zero mel band"
assert support[-1] - support[0] + 1 == support.size, (
f"{label}: non-contiguous band support"
)
def test_repeat_call_is_identical(self) -> None:
first = dsp.mel_filterbank(16000, 400, 128)
second = dsp.mel_filterbank(16000, 400, 128)
assert np.array_equal(first, second)
class TestHannWindow:
"""The periodic Hann: ``scipy.signal.get_window('hann', M, fftbins=True)``."""
def test_matches_the_periodic_formula(self) -> None:
window = dsp.hann_window(400)
reference = 0.5 - 0.5 * np.cos(2 * np.pi * np.arange(400) / 400)
assert window.shape == (400,)
assert window.dtype == np.float64
assert window[0] == 0.0 # the dropped M+1th sample would be 0 too
assert np.allclose(window, reference, atol=1e-12)
def test_matches_torchs_periodic_hann(self) -> None:
# ``torch.hann_window`` is periodic by default -- the very window the
# s3tokenizer and s3gen paths build themselves -- so this is the
# in-stack cross-check that needs no scipy.
reference = torch.hann_window(400, periodic=True, dtype=torch.float64)
assert np.allclose(dsp.hann_window(400), reference.numpy(), atol=1e-12)
class TestStft:
"""The voice encoder's STFT geometry across every padding branch."""
@pytest.mark.parametrize("length", STFT_LENGTHS)
def test_shape_dtype_and_warning_branch(self, length: int) -> None:
y = _STFT_INPUTS[length]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
spectrum = dsp.stft(
y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=N_FFT
)
# librosa's centred frame count, for every padding branch.
assert spectrum.shape == (1 + N_FFT // 2, 1 + length // HOP_LENGTH)
assert spectrum.dtype == np.complex64 # float32 in -> complex64 out
# n_fft longer than the signal is the only warning librosa raises here.
assert bool(caught) == (length < N_FFT)
@pytest.mark.parametrize("length", STFT_LENGTHS)
def test_matches_the_direct_definition(self, length: int) -> None:
y = _STFT_INPUTS[length]
assert np.array_equal(
dsp.stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=N_FFT),
_naive_centered_stft(y),
)
def test_uncentered_shape(self) -> None:
y = _STFT_INPUTS[24000]
spectrum = dsp.stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, center=False)
assert spectrum.shape == (
1 + N_FFT // 2,
1 + (24000 - N_FFT) // HOP_LENGTH,
)
assert spectrum.dtype == np.complex64
def test_uncentered_rejects_signal_shorter_than_n_fft(self) -> None:
with pytest.raises(ValueError):
dsp.stft(
_STFT_INPUTS[300], n_fft=N_FFT, hop_length=HOP_LENGTH, center=False
)
def test_float64_input_promotes_to_complex128(self) -> None:
y = _STFT_INPUTS[1000].astype(np.float64)
spectrum = dsp.stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH)
assert spectrum.dtype == np.complex128
class TestTrimSilence:
"""Silence trimming at the voice encoder's ``top_db=20``."""
TOP_DB = 20.0
def test_noise_survives_unchanged(self) -> None:
trimmed = dsp.trim_silence(_NOISE, top_db=self.TOP_DB)
assert np.array_equal(trimmed, _NOISE)
def test_short_clip_survives_unchanged(self) -> None:
# L=2423: the first and last frames already reach the tone, so no
# frame qualifies as silent and the clip passes through whole.
y = _speech_like(2423, 16000)
assert np.array_equal(dsp.trim_silence(y, top_db=self.TOP_DB), y)
def test_edge_silence_shrinks_the_clip(self) -> None:
y = _speech_like(24000, 16000)
trimmed = dsp.trim_silence(y, top_db=self.TOP_DB)
assert trimmed.size < y.size
assert not np.array_equal(trimmed, y[y.size - trimmed.size :])
def test_leading_silence_is_trimmed_at_the_head(self) -> None:
y = np.concatenate(
[np.zeros(5000, np.float32), _speech_like(19000, 16000)]
)
trimmed = dsp.trim_silence(y, top_db=self.TOP_DB)
assert trimmed.size < y.size
assert not np.array_equal(trimmed, y[: trimmed.size])
def test_trailing_silence_is_trimmed_at_the_tail(self) -> None:
y = np.concatenate(
[_speech_like(19000, 16000), np.zeros(5000, np.float32)]
)
trimmed = dsp.trim_silence(y, top_db=self.TOP_DB)
assert trimmed.size < y.size
assert np.array_equal(trimmed, y[: trimmed.size])
assert not np.array_equal(trimmed, y[y.size - trimmed.size :])
def test_all_silent_clip_is_returned_untrimmed(self) -> None:
# librosa.effects.trim behaves the same way: with no non-silent frame
# there is no interval to return, so the input passes through.
y = np.zeros(24000, np.float32)
trimmed = dsp.trim_silence(y, top_db=self.TOP_DB)
assert np.array_equal(trimmed, y)
def test_dtype_is_preserved(self) -> None:
y = _speech_like(24000, 16000)
assert dsp.trim_silence(y, top_db=self.TOP_DB).dtype == y.dtype
class TestResample:
"""The same-rate no-op; the sinc path is measured by the probe, not here."""
def test_same_rate_is_a_no_op(self) -> None:
y = _NOISE[:1024]
assert np.array_equal(dsp.resample(y, 16000.0, 16000.0), y)
class TestLoadAudioAt:
"""Reference-clip loading: path, in-memory waveform, cuda tensor."""
def test_path_reads_float32_mono(self, tmp_path) -> None:
import soundfile as sf
y = _NOISE[:1600]
path = os.path.join(str(tmp_path), "ref.wav")
sf.write(path, y, 16000, subtype="FLOAT")
loaded = dsp.load_audio_at(path, 16000)
assert loaded.dtype == np.float32
assert loaded.shape == (1600,)
assert np.array_equal(loaded, y)
def test_path_downmixes_stereo_by_channel_mean(self, tmp_path) -> None:
import soundfile as sf
rng = np.random.default_rng(11)
stereo = rng.standard_normal((800, 2)).astype(np.float32)
path = os.path.join(str(tmp_path), "stereo.wav")
sf.write(path, stereo, 16000, subtype="FLOAT")
loaded = dsp.load_audio_at(path, 16000)
assert loaded.shape == (800,)
assert np.array_equal(loaded, stereo.mean(axis=1))
def test_ndarray_becomes_float32_and_keeps_its_values(self) -> None:
y = _NOISE[:500].astype(np.float64)
loaded = dsp.load_audio_at(y, 24000)
assert isinstance(loaded, np.ndarray)
assert loaded.dtype == np.float32
assert np.array_equal(loaded, y.astype(np.float32))
def test_target_sr_is_ignored_for_in_memory_audio(self) -> None:
# The node hands over audio already resampled to target_sr; only the
# on-disk path resamples.
y = _NOISE[:500]
assert np.array_equal(dsp.load_audio_at(y, 24000), y)
def test_cpu_tensor_becomes_float32_ndarray(self) -> None:
tensor = torch.from_numpy(_NOISE[:100])
loaded = dsp.load_audio_at(tensor, 16000)
assert isinstance(loaded, np.ndarray)
assert not torch.is_tensor(loaded)
assert loaded.dtype == np.float32
assert np.array_equal(loaded, tensor.numpy())
@pytest.mark.skipif(not torch.cuda.is_available(), reason="no cuda device")
def test_cuda_tensor_is_moved_to_cpu(self) -> None:
tensor = torch.from_numpy(_NOISE[:100]).cuda()
loaded = dsp.load_audio_at(tensor, 16000)
assert isinstance(loaded, np.ndarray)
assert not torch.is_tensor(loaded)
assert loaded.dtype == np.float32
assert np.array_equal(loaded, tensor.cpu().numpy())