Files
UmbraAudio/tests/test_generation/test_audio_effects.py
2026-10-18 19:01:04 -07:00

344 lines
12 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""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 (
EFFECT_FN,
EMOTION_EFFECTS,
TRAIT_EFFECTS,
_bandpass,
_bitcrusher,
_hard_clip,
_lowpass,
_noise_inject,
_pitch_jitter,
_random_gating,
_ring_modulate,
_tremolo,
apply_effects,
apply_effects_to_file,
apply_emotion_effects,
apply_emotion_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) == {}
def _rms(audio: np.ndarray) -> float:
return float(np.sqrt(np.mean(np.square(audio))))
def _speechlike() -> np.ndarray:
"""A broadband harmonic signal with a syllable-rate amplitude envelope."""
t = np.linspace(0, DURATION, SAMPLES, dtype=np.float32)
am = 0.5 + 0.5 * np.sin(2 * np.pi * 3.5 * t)
sig = am * (
np.sin(2 * np.pi * 140 * t)
+ 0.5 * np.sin(2 * np.pi * 280 * t)
+ 0.25 * np.sin(2 * np.pi * 420 * t)
)
return (0.6 * sig).astype(np.float32)
class TestEmotionEffects:
def test_neutral_is_untouched_baseline(self) -> None:
out = apply_emotion_effects(_sine(), SR, "neutral")
np.testing.assert_allclose(out, _sine(), atol=1e-6)
def test_unknown_emotion_is_untouched(self) -> None:
out = apply_emotion_effects(_sine(), SR, "confused")
np.testing.assert_allclose(out, _sine(), atol=1e-6)
def test_angry_is_louder_and_faster(self) -> None:
base = _speechlike()
out = apply_emotion_effects(base, SR, "angry")
assert _rms(out) > _rms(base)
assert len(out) < len(base)
def test_whisper_is_softer(self) -> None:
base = _speechlike()
out = apply_emotion_effects(base, SR, "whisper")
assert _rms(out) < _rms(base) * 0.5
def test_sad_is_quieter_and_slower(self) -> None:
base = _speechlike()
out = apply_emotion_effects(base, SR, "sad")
assert _rms(out) < _rms(base)
assert len(out) > len(base)
def test_angry_and_sad_differ_in_delivery(self) -> None:
base = _speechlike()
angry = apply_emotion_effects(base, SR, "angry")
sad = apply_emotion_effects(base, SR, "sad")
assert len(angry) < len(sad)
def test_all_chain_effects_exist(self) -> None:
for emotion, chain in EMOTION_EFFECTS.items():
for effect_name, _ in chain:
assert effect_name in EFFECT_FN, f"{emotion}: {effect_name}"
def test_all_chain_effects_finite(self) -> None:
base = _speechlike()
for emotion in EMOTION_EFFECTS:
out = apply_emotion_effects(base, SR, emotion)
assert np.all(np.isfinite(out))
assert np.max(np.abs(out)) <= 1.0001
def test_apply_emotion_effects_to_file(self, tmp_path: Path) -> None:
wav = tmp_path / "e.wav"
sf.write(str(wav), _speechlike(), SR)
applied = apply_emotion_effects_to_file(wav, "angry")
assert applied is True
result, _ = sf.read(str(wav), dtype="float32")
assert len(result) != SAMPLES or not np.allclose(result, _speechlike(), atol=0.01)
def test_apply_emotion_effects_to_file_neutral_is_false(self, tmp_path: Path) -> None:
wav = tmp_path / "n.wav"
audio = _sine()
sf.write(str(wav), audio, SR)
assert apply_emotion_effects_to_file(wav, "neutral") is False
result, _ = sf.read(str(wav), dtype="float32")
np.testing.assert_allclose(result, audio, atol=1e-4)