Files
UmbraAudio/tests/test_generation/test_audio_effects.py
B.D. Murphy c86e323d7a Initial commit: UmbraAudio audiobook generation program
Quality-first multi-pass audiobook generation from English manuscripts.

Includes:
- umbraaudio/ core package (pipeline, config, voices, web review UI)
- scripts/ CLI utilities + model download script
- tts_server/ remote TTS server
- specs/ design docs, tests/, pyproject.toml + uv.lock
- Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch
  saved at scripts/qwen3_tts_demo_umbra_patch.patch
- .env with runtime config/secrets

Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh),
output/, logs/, .venv, .umbra runtime data, and book projects
(DealHunterShip_*), which are committed separately.
2026-08-30 07:58:24 -07:00

260 lines
8.8 KiB
Python

"""Tests for umbraaudio.generation.audio_effects."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import soundfile as sf
from umbraaudio.generation.audio_effects import (
TRAIT_EFFECTS,
_bandpass,
_bitcrusher,
_hard_clip,
_lowpass,
_noise_inject,
_pitch_jitter,
_random_gating,
_ring_modulate,
_tremolo,
apply_effects,
apply_effects_to_file,
get_effects_for_traits,
load_speaker_traits,
)
SR = 24000
DURATION = 0.5
SAMPLES = int(SR * DURATION)
def _sine(freq: float = 440.0) -> np.ndarray:
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
return (0.8 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def _silence() -> np.ndarray:
return np.zeros(SAMPLES, dtype=np.float32)
class TestGetEffectsForTraits:
def test_empty_traits_returns_empty(self) -> None:
assert get_effects_for_traits([]) == []
def test_unknown_trait_returns_empty(self) -> None:
assert get_effects_for_traits(["fluffy", "bouncy"]) == []
def test_known_trait_returns_effects(self) -> None:
chain = get_effects_for_traits(["broken"])
assert len(chain) > 0
assert chain[0][0] == "bitcrusher"
def test_distorted_maps_to_bitcrusher_and_clip(self) -> None:
chain = get_effects_for_traits(["distorted"])
names = [n for n, _ in chain]
assert "bitcrusher" in names
assert "hard_clip" in names
def test_choppy_maps_to_tremolo(self) -> None:
chain = get_effects_for_traits(["choppy"])
names = [n for n, _ in chain]
assert "tremolo" in names
def test_raspy_maps_to_noise_and_jitter(self) -> None:
chain = get_effects_for_traits(["raspy"])
names = [n for n, _ in chain]
assert "noise_inject" in names
assert "pitch_jitter" in names
def test_hard_to_understand_maps_to_bandpass_and_noise(self) -> None:
chain = get_effects_for_traits(["hard to understand"])
names = [n for n, _ in chain]
assert "bandpass" in names
assert "noise_inject" in names
def test_deduplicates_identical_effects(self) -> None:
chain = get_effects_for_traits(["broken", "distorted"])
names = [n for n, _ in chain]
assert names.count("bitcrusher") == 1
def test_multiple_traits_chain_in_order(self) -> None:
chain = get_effects_for_traits(["distorted", "choppy"])
names = [n for n, _ in chain]
assert names.index("bitcrusher") < names.index("tremolo")
def test_all_mapped_traits_have_effect_fn(self) -> None:
all_effects: set[str] = set()
for effects in TRAIT_EFFECTS.values():
for name, _ in effects:
all_effects.add(name)
from umbraaudio.generation.audio_effects import EFFECT_FN
for name in all_effects:
assert name in EFFECT_FN, f"Missing EFFECT_FN for {name}"
class TestApplyEffects:
def test_no_traits_returns_original(self) -> None:
audio = _sine()
result = apply_effects(audio, SR, [])
np.testing.assert_array_equal(result, audio)
def test_output_same_length(self) -> None:
audio = _sine()
result = apply_effects(audio, SR, ["choppy"])
assert len(result) == len(audio)
def test_output_finite(self) -> None:
audio = _sine()
for trait in ["broken", "distorted", "raspy", "choppy", "hard to understand"]:
result = apply_effects(audio, SR, [trait])
assert np.all(np.isfinite(result)), f"Non-finite output for trait: {trait}"
def test_output_bounded(self) -> None:
audio = _sine()
for trait in ["broken", "distorted", "raspy", "choppy"]:
result = apply_effects(audio, SR, [trait])
assert float(np.max(np.abs(result))) <= 1.0 + 1e-5, f"Out of bounds for trait: {trait}"
def test_does_not_mutate_input(self) -> None:
audio = _sine()
original = audio.copy()
apply_effects(audio, SR, ["broken"])
np.testing.assert_array_equal(audio, original)
class TestIndividualEffects:
def test_bitcrusher_changes_signal(self) -> None:
audio = _sine()
result = _bitcrusher(audio, SR, bits=4)
assert not np.allclose(audio, result, atol=0.01)
def test_hard_clip_reduces_dynamic_range(self) -> None:
audio = _sine()
result = _hard_clip(audio, SR, threshold=0.2)
assert np.max(np.abs(result)) <= 1.0 + 1e-5
def test_tremolo_modulates_amplitude(self) -> None:
audio = np.ones(SAMPLES, dtype=np.float32)
result = _tremolo(audio, SR, rate=10.0, depth=0.9)
assert np.max(result) <= 1.0
assert np.min(result) >= 0.0
assert np.max(result) - np.min(result) > 0.1
def test_random_gating_creates_zeros(self) -> None:
audio = np.ones(SAMPLES, dtype=np.float32)
result = _random_gating(audio, SR, gap_prob=0.5)
assert np.any(result == 0.0)
def test_bandpass_reduces_energy_outside_band(self) -> None:
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
low_freq_signal = np.sin(2 * np.pi * 100 * t).astype(np.float32)
result = _bandpass(low_freq_signal, SR, low_hz=500, high_hz=3000)
assert np.max(np.abs(result)) < 0.1
def test_lowpass_reduces_high_frequency(self) -> None:
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
high_freq = np.sin(2 * np.pi * 8000 * t).astype(np.float32)
result = _lowpass(high_freq, SR, cutoff_hz=1000)
assert np.max(np.abs(result)) < 0.1
def test_noise_inject_adds_variation(self) -> None:
audio = np.zeros(SAMPLES, dtype=np.float32)
result = _noise_inject(audio, SR, amount=0.1)
assert np.max(np.abs(result)) > 0.0
def test_pitch_jitter_changes_signal(self) -> None:
audio = _sine()
result = _pitch_jitter(audio, SR, jitter_hz=8.0, depth=0.05)
assert not np.allclose(audio, result, atol=0.01)
def test_ring_modulate_creates_sidebands(self) -> None:
audio = _sine(200.0)
result = _ring_modulate(audio, SR, freq=50.0)
assert not np.allclose(audio, result, atol=0.01)
class TestApplyEffectsToFile:
def test_applies_effects_to_wav(self, tmp_path: Path) -> None:
wav = tmp_path / "test.wav"
audio = _sine()
sf.write(str(wav), audio, SR)
apply_effects_to_file(wav, ["choppy"])
result, sr = sf.read(str(wav), dtype="float32")
assert sr == SR
assert len(result) == SAMPLES
assert not np.allclose(result, audio, atol=0.01)
def test_no_traits_returns_false(self, tmp_path: Path) -> None:
wav = tmp_path / "test.wav"
audio = _sine()
sf.write(str(wav), audio, SR)
applied = apply_effects_to_file(wav, [])
assert applied is False
result, _ = sf.read(str(wav), dtype="float32")
np.testing.assert_allclose(result, audio, atol=1e-4)
def test_custom_output_path(self, tmp_path: Path) -> None:
src = tmp_path / "src.wav"
dst = tmp_path / "dst.wav"
audio = _sine()
sf.write(str(src), audio, SR)
apply_effects_to_file(src, ["distorted"], output_path=dst)
assert dst.exists()
result, _ = sf.read(str(dst), dtype="float32")
assert not np.allclose(result, audio, atol=0.01)
def test_stereo_to_mono(self, tmp_path: Path) -> None:
wav = tmp_path / "stereo.wav"
audio = np.column_stack([_sine(), _sine(880.0)])
sf.write(str(wav), audio, SR)
apply_effects_to_file(wav, ["broken"])
result, _ = sf.read(str(wav), dtype="float32")
assert result.ndim == 1
class TestLoadSpeakerTraits:
def test_empty_file(self, tmp_path: Path) -> None:
path = tmp_path / "speakers.json"
path.write_text('{"speakers": []}')
assert load_speaker_traits(path) == {}
def test_missing_file(self, tmp_path: Path) -> None:
assert load_speaker_traits(tmp_path / "nope.json") == {}
def test_extracts_traits(self, tmp_path: Path) -> None:
path = tmp_path / "speakers.json"
data = {
"speakers": [
{
"primary_name": "Distorted Voice",
"vocal_traits": ["broken", "distorted"],
},
{
"primary_name": "Normal",
"vocal_traits": [],
},
]
}
path.write_text(json.dumps(data))
result = load_speaker_traits(path)
assert result == {"Distorted Voice": ["broken", "distorted"]}
def test_skips_speakers_without_traits(self, tmp_path: Path) -> None:
path = tmp_path / "speakers.json"
data = {
"speakers": [
{"primary_name": "Narrator", "demographics": {}},
]
}
path.write_text(json.dumps(data))
assert load_speaker_traits(path) == {}