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.
260 lines
8.8 KiB
Python
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) == {}
|