344 lines
12 KiB
Python
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)
|