Files
WildAi 43fb085228 feat: torchaudio-primary audio backend, librosa now optional (v2.1.0)
- New modules/audio_backend.py: capability-detected backend layer. Resample via torchaudio.functional.resample (Kaiser sinc, ComfyUI-core idiom) with scipy/librosa fallbacks; file decode via PyAV (ComfyUI pattern) with soundfile/torchaudio/librosa guarded fallbacks; encode via soundfile. Import never raises even if all optional libs are absent/broken.

- modules/audio_utils.py: resample delegates to backend; preprocess_comfy_audio resamples in tensor space (no numpy round-trip).

- Vendored processors: _load_audio_from_path/save_audio (tokenizer) and _process_single_audio (ASR) route through the backend; fixes .m4a/.ogg decode (libsndfile gap).

- Deps: librosa removed from hard deps (was never imported), scipy demoted; both moved to optional [audio-extra] extra.

- Tests: +41 test_audio_backend, +17 test_processor_io_backend, updated audio_utils/pyproject/imports tests. Full suite 529p/5f/4s (zero new failures vs 461p/5f/4s baseline); e2e smoke test passes on CPU.
2026-08-15 21:54:53 +03:00

316 lines
12 KiB
Python

"""Phase 3 tests — vendored processor file I/O routed through audio_backend.
The root ``conftest.py`` mocks the entire ``src.vibevoice`` package (to keep
heavy model imports out of the unit suite), so these tests load the REAL
vendored processor modules under a fresh, side-effect-free package alias
(``VV_Real``) built from bare ``types.ModuleType`` packages. This exercises
the actual production code paths:
- ``VibeVoiceTokenizerProcessor._load_audio_from_path`` → audio_backend.load_audio_file
- ``VibeVoiceTokenizerProcessor.save_audio`` → audio_backend.save_audio_file
- ``VibeVoiceASRProcessor._process_single_audio`` → audio_backend.load_audio_file
Scratch audio files go to the project-root ``tmp/`` folder (project rule).
"""
import importlib
import sys
import types
from pathlib import Path
import numpy as np
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TMP_DIR = PROJECT_ROOT / "tmp" / "test_processor_io"
PKG = "VV_Real"
# ====================================================================
# Real-module loader (bypasses the conftest mocks)
# ====================================================================
def _make_pkg(name: str, path: Path):
mod = types.ModuleType(name)
mod.__path__ = [str(path)]
sys.modules[name] = mod
return mod
@pytest.fixture(scope="module")
def real_vendored():
"""Load the real vendored processors under the VV_Real alias."""
already = f"{PKG}.src.vibevoice.processor.vibevoice_tokenizer_processor" in sys.modules
if not already:
_make_pkg(PKG, PROJECT_ROOT)
_make_pkg(f"{PKG}.src", PROJECT_ROOT / "src")
_make_pkg(f"{PKG}.src.vibevoice", PROJECT_ROOT / "src" / "vibevoice")
_make_pkg(
f"{PKG}.src.vibevoice.processor",
PROJECT_ROOT / "src" / "vibevoice" / "processor",
)
_make_pkg(f"{PKG}.modules", PROJECT_ROOT / "modules")
tok_mod = importlib.import_module(
f"{PKG}.src.vibevoice.processor.vibevoice_tokenizer_processor"
)
asr_mod = importlib.import_module(
f"{PKG}.src.vibevoice.processor.vibevoice_asr_processor"
)
backend = sys.modules[f"{PKG}.modules.audio_backend"]
return {"tokenizer": tok_mod, "asr": asr_mod, "backend": backend}
@pytest.fixture()
def audio_tmp_dir():
TMP_DIR.mkdir(parents=True, exist_ok=True)
return TMP_DIR
def _write_wav(path, sr=24000, seconds=0.5, freq=440.0, channels=1):
import soundfile as sf
t = np.arange(int(sr * seconds)) / sr
mono = 0.5 * np.sin(2 * np.pi * freq * t)
data = mono if channels == 1 else np.stack([mono] * channels, axis=1)
sf.write(str(path), data.astype(np.float32), sr)
return str(path)
class _StubTokenizer:
"""Minimal tokenizer stand-in satisfying VibeVoiceASRProcessor's needs."""
speech_start_id = 100
speech_end_id = 101
speech_pad_id = 102
pad_id = 0
def convert_ids_to_tokens(self, i):
return f"<tok{i}>"
def encode(self, text):
return [1, 2, 3]
def apply_chat_template(self, msgs, tokenize=False, **kwargs):
return [4, 5, 6] if tokenize else "system-prompt"
# ====================================================================
# 1. Tokenizer processor: _load_audio_from_path
# ====================================================================
class TestTokenizerLoadAudioFromPath:
def test_wav_roundtrip_at_model_sr(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = _write_wav(audio_tmp_dir / "tok_load.wav", sr=24000, seconds=0.5)
out = proc._load_audio_from_path(path)
assert isinstance(out, np.ndarray)
assert out.dtype == np.float32
assert out.ndim == 1
assert abs(len(out) - 12000) <= 64 # 0.5 s @ 24 kHz
def test_resamples_to_model_sr(self, real_vendored, audio_tmp_dir):
"""A 16 kHz file must come back at the processor's 24 kHz."""
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = _write_wav(audio_tmp_dir / "tok_16k.wav", sr=16000, seconds=1.0)
out = proc._load_audio_from_path(path)
assert abs(len(out) - 24000) <= 96 # resampled 16k → 24k
def test_stereo_becomes_mono(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = _write_wav(audio_tmp_dir / "tok_stereo.wav", sr=24000, seconds=0.3, channels=2)
out = proc._load_audio_from_path(path)
assert out.ndim == 1
def test_npy_branch_still_works(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
arr = np.random.randn(4800).astype(np.float32)
path = audio_tmp_dir / "tok.npy"
np.save(str(path), arr)
out = proc._load_audio_from_path(str(path))
assert out.dtype == np.float32
assert np.allclose(out, arr)
def test_pt_branch_still_works(self, real_vendored, audio_tmp_dir):
import torch
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
tensor = torch.randn(4800)
path = audio_tmp_dir / "tok.pt"
torch.save(tensor, str(path))
out = proc._load_audio_from_path(str(path))
assert out.dtype == np.float32
assert len(out) == 4800
def test_unsupported_extension_raises(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
bad = audio_tmp_dir / "tok.xyz"
bad.write_bytes(b"not audio")
with pytest.raises(ValueError, match="Unsupported file format"):
proc._load_audio_from_path(str(bad))
def test_load_routes_through_backend(self, real_vendored, audio_tmp_dir, monkeypatch):
"""The backend's load_audio_file must be the decode entry point."""
backend = real_vendored["backend"]
calls = []
original = backend.load_audio_file
def spy(path):
calls.append(path)
return original(path)
monkeypatch.setattr(backend, "load_audio_file", spy)
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = _write_wav(audio_tmp_dir / "tok_spy.wav", sr=24000, seconds=0.2)
proc._load_audio_from_path(path)
assert calls == [path]
# ====================================================================
# 2. Tokenizer processor: save_audio
# ====================================================================
class TestTokenizerSaveAudio:
def test_save_single_array(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = str(audio_tmp_dir / "save_single.wav")
audio = (0.4 * np.sin(2 * np.pi * 440 * np.arange(12000) / 24000)).astype(np.float32)
saved = proc.save_audio(audio, output_path=path, sampling_rate=24000)
assert saved == [path]
data, sr = real_vendored["backend"].load_audio_file(path)
assert sr == 24000
assert abs(len(data) - 12000) <= 64
def test_save_torch_tensor(self, real_vendored, audio_tmp_dir):
import torch
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = str(audio_tmp_dir / "save_tensor.wav")
audio = torch.randn(1, 1, 12000) * 0.3
saved = proc.save_audio(audio, output_path=path, sampling_rate=24000)
assert saved == [path]
assert Path(path).exists()
def test_save_list_creates_dir_of_files(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
outdir = str(audio_tmp_dir / "save_list")
audios = [np.random.randn(4800).astype(np.float32) * 0.3 for _ in range(3)]
saved = proc.save_audio(audios, output_path=outdir, sampling_rate=24000)
assert len(saved) == 3
for p in saved:
assert Path(p).exists()
def test_save_batch_3d(self, real_vendored, audio_tmp_dir):
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
outdir = str(audio_tmp_dir / "save_batch")
audio = np.random.randn(2, 1, 4800).astype(np.float32) * 0.3 # (B, C, T)
saved = proc.save_audio(audio, output_path=outdir, sampling_rate=24000)
assert len(saved) == 2
def test_save_routes_through_backend(self, real_vendored, audio_tmp_dir, monkeypatch):
backend = real_vendored["backend"]
calls = []
original = backend.save_audio_file
def spy(path, waveform, sample_rate):
calls.append((path, sample_rate))
return original(path, waveform, sample_rate)
monkeypatch.setattr(backend, "save_audio_file", spy)
proc = real_vendored["tokenizer"].VibeVoiceTokenizerProcessor(
sampling_rate=24000, normalize_audio=False
)
path = str(audio_tmp_dir / "save_spy.wav")
proc.save_audio(np.random.randn(4800).astype(np.float32) * 0.3,
output_path=path, sampling_rate=24000)
assert len(calls) == 1
assert calls[0][0] == path
assert calls[0][1] == 24000
# ====================================================================
# 3. ASR processor: _process_single_audio file path branch
# ====================================================================
class TestAsrProcessorFileLoading:
def _make_asr_processor(self, real_vendored, target_sr=24000):
return real_vendored["asr"].VibeVoiceASRProcessor(
tokenizer=_StubTokenizer(),
speech_tok_compress_ratio=3200,
target_sample_rate=target_sr,
normalize_audio=False,
)
def test_file_path_loads_via_backend(self, real_vendored, audio_tmp_dir):
proc = self._make_asr_processor(real_vendored)
path = _write_wav(audio_tmp_dir / "asr_load.wav", sr=24000, seconds=1.0)
out = proc._process_single_audio(path)
assert "speech" in out
speech = out["speech"]
assert speech.dtype == np.float32
assert abs(len(speech) - 24000) <= 96 # 1 s @ 24 kHz
def test_file_path_resamples_to_target(self, real_vendored, audio_tmp_dir):
proc = self._make_asr_processor(real_vendored, target_sr=24000)
path = _write_wav(audio_tmp_dir / "asr_16k.wav", sr=16000, seconds=1.0)
out = proc._process_single_audio(path)
assert abs(len(out["speech"]) - 24000) <= 96
def test_file_path_calls_backend_load(self, real_vendored, audio_tmp_dir, monkeypatch):
backend = real_vendored["backend"]
calls = []
original = backend.load_audio_file
def spy(path):
calls.append(path)
return original(path)
monkeypatch.setattr(backend, "load_audio_file", spy)
proc = self._make_asr_processor(real_vendored)
path = _write_wav(audio_tmp_dir / "asr_spy.wav", sr=24000, seconds=0.5)
proc._process_single_audio(path)
assert calls == [path]
def test_tensor_input_bypasses_file_loading(self, real_vendored, monkeypatch):
import torch
backend = real_vendored["backend"]
def boom(path):
raise AssertionError("load_audio_file must not be called for tensor input")
monkeypatch.setattr(backend, "load_audio_file", boom)
proc = self._make_asr_processor(real_vendored)
out = proc._process_single_audio(torch.randn(24000))
assert len(out["speech"]) == 24000
def test_output_structure(self, real_vendored, audio_tmp_dir):
proc = self._make_asr_processor(real_vendored)
path = _write_wav(audio_tmp_dir / "asr_struct.wav", sr=24000, seconds=1.0)
out = proc._process_single_audio(path)
for key in ("input_ids", "acoustic_input_mask", "speech", "vae_tok_len"):
assert key in out
assert out["vae_tok_len"] > 0