| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- # -*- coding: utf-8 -*-
- """
- Tests fuer trixy_core.audio_morpher.
- Testet:
- - VoiceMorphPreset (Laden, Serialisierung, from_dict/to_dict)
- - DSP-Funktionen (pitch_shift, speed_change, vtlp, breathiness, shimmer, fade)
- - Dynamik-Effekte (loud_pitch_boost, scale_pauses, loud_tempo_scale)
- - Erweiterte Effekte (reverb, lowpass, highpass, flanger, distortion)
- - AudioMorpher (Preset-Verwaltung, morph-Kette)
- - VoiceMorphProcessor (Pipeline-Bridge)
- """
- import json
- import pytest
- import tempfile
- from pathlib import Path
- import numpy as np
- from trixy_core.audio_morpher import (
- VoiceMorphPreset,
- AudioMorpher,
- load_presets_from_directory,
- # DSP
- pitch_shift,
- speed_change,
- vtlp,
- add_breathiness,
- add_shimmer,
- apply_fade,
- # Dynamik
- loud_pitch_boost,
- scale_pauses,
- loud_tempo_scale,
- # Effekte
- apply_reverb,
- apply_lowpass,
- apply_highpass,
- apply_flanger,
- apply_distortion,
- # Convenience
- apply_voice_morph,
- # Pipeline
- VoiceMorphProcessor,
- )
- # --- Fixtures ---
- @pytest.fixture
- def sine_audio():
- """1 Sekunde 440Hz Sinus bei 16kHz."""
- sr = 16000
- t = np.linspace(0, 1.0, sr)
- return np.sin(2 * np.pi * 440 * t).astype(np.float64) * 0.5
- @pytest.fixture
- def silence_audio():
- """1 Sekunde Stille bei 16kHz."""
- return np.zeros(16000, dtype=np.float64)
- @pytest.fixture
- def preset_dir(tmp_path):
- """Temporaeres Verzeichnis mit Test-Presets."""
- presets = [
- {"preset_id": "test_pitch", "label": "Test Pitch",
- "pitch": {"semitones": 3.0}},
- {"preset_id": "test_full", "label": "Test Voll",
- "pitch": {"semitones": -2.0}, "speed": {"factor": 0.9},
- "breathiness": {"amount": 0.05}, "shimmer": {"amount": 0.01},
- "formant": {"shift": 0.02},
- "dynamics": {"pause_scale": 1.2, "loud_pitch_boost": 0.3},
- "trim": {"ltrim": 0.01, "rtrim": 0.02}},
- ]
- (tmp_path / "test.json").write_text(json.dumps(presets))
- return tmp_path
- # --- VoiceMorphPreset ---
- class TestVoiceMorphPreset:
- def test_from_dict_minimal(self):
- p = VoiceMorphPreset.from_dict({"preset_id": "x", "label": "X"})
- assert p.preset_id == "x"
- assert p.pitch_semitones == 0.0
- assert p.speed_factor == 1.0
- assert p.breathiness == 0.0
- def test_from_dict_full(self):
- data = {
- "preset_id": "test", "label": "Test",
- "pitch": {"semitones": 4.0},
- "speed": {"factor": 0.95},
- "formant": {"shift": -0.03},
- "breathiness": {"amount": 0.04},
- "shimmer": {"amount": 0.015},
- "dynamics": {"pause_scale": 1.3, "loud_pitch_boost": 0.5, "loud_tempo_scale": 0.9},
- "trim": {"ltrim": 0.01, "rtrim": 0.04},
- "reverb": {"room_size": 0.5, "damping": 0.4, "wet": 0.3},
- "lowpass": {"cutoff": 3000, "order": 3},
- "highpass": {"cutoff": 200},
- "flanger": {"rate": 0.5, "depth": 0.003, "wet": 0.2},
- "distortion": {"gain": 2.0, "threshold": 0.6},
- }
- p = VoiceMorphPreset.from_dict(data)
- assert p.pitch_semitones == 4.0
- assert p.speed_factor == 0.95
- assert p.formant_extra == -0.03
- assert p.breathiness == 0.04
- assert p.shimmer == 0.015
- assert p.pause_scale == 1.3
- assert p.loud_pitch_boost == 0.5
- assert p.loud_tempo_scale == 0.9
- assert p.ltrim == 0.01
- assert p.rtrim == 0.04
- assert p.reverb_wet == 0.3
- assert p.reverb_room_size == 0.5
- assert p.lowpass_cutoff == 3000
- assert p.lowpass_order == 3
- assert p.highpass_cutoff == 200
- assert p.flanger_wet == 0.2
- assert p.distortion_gain == 2.0
- def test_to_dict_roundtrip(self):
- data = {
- "preset_id": "rt", "label": "Roundtrip",
- "pitch": {"semitones": -2.5},
- "breathiness": {"amount": 0.03},
- "trim": {"rtrim": 0.04},
- }
- p = VoiceMorphPreset.from_dict(data)
- d = p.to_dict()
- p2 = VoiceMorphPreset.from_dict(d)
- assert p2.preset_id == p.preset_id
- assert abs(p2.pitch_semitones - p.pitch_semitones) < 1e-6
- assert abs(p2.breathiness - p.breathiness) < 1e-6
- assert abs(p2.rtrim - p.rtrim) < 1e-6
- def test_unknown_keys_in_extra(self):
- data = {"preset_id": "x", "label": "X", "future_effect": {"amount": 0.5}}
- p = VoiceMorphPreset.from_dict(data)
- assert "future_effect" in p._extra
- def test_to_dict_omits_defaults(self):
- p = VoiceMorphPreset(preset_id="x", label="X")
- d = p.to_dict()
- assert "pitch" not in d
- assert "speed" not in d
- assert "breathiness" not in d
- # --- Preset-Laden ---
- class TestLoadPresets:
- def test_load_from_directory(self, preset_dir):
- presets = load_presets_from_directory(preset_dir)
- assert len(presets) == 2
- ids = [p.preset_id for p in presets]
- assert "test_pitch" in ids
- assert "test_full" in ids
- def test_load_nonexistent_dir(self):
- presets = load_presets_from_directory("/nonexistent/path")
- assert presets == []
- def test_load_default_presets(self):
- morpher = AudioMorpher()
- assert len(morpher.presets) > 0
- ids = morpher.preset_ids
- assert "deeper" in ids
- assert "brighter" in ids
- # --- DSP-Funktionen ---
- class TestDSP:
- def test_pitch_shift_up(self, sine_audio):
- result = pitch_shift(sine_audio, 16000, 3.0)
- assert len(result) < len(sine_audio) # Hoeher = kuerzer
- assert result.dtype == np.float64
- def test_pitch_shift_down(self, sine_audio):
- result = pitch_shift(sine_audio, 16000, -3.0)
- assert len(result) > len(sine_audio) # Tiefer = laenger
- def test_pitch_shift_zero(self, sine_audio):
- result = pitch_shift(sine_audio, 16000, 0.0)
- assert len(result) == len(sine_audio)
- def test_speed_change_faster(self, sine_audio):
- result = speed_change(sine_audio, 16000, 1.5)
- assert len(result) < len(sine_audio)
- def test_speed_change_slower(self, sine_audio):
- result = speed_change(sine_audio, 16000, 0.7)
- assert len(result) > len(sine_audio)
- def test_vtlp_preserves_length(self, sine_audio):
- result = vtlp(sine_audio, 16000, 1.1)
- assert len(result) == len(sine_audio)
- def test_vtlp_identity(self, sine_audio):
- result = vtlp(sine_audio, 16000, 1.0)
- assert len(result) == len(sine_audio)
- def test_breathiness_adds_noise(self, sine_audio):
- result = add_breathiness(sine_audio, 16000, 0.1)
- assert len(result) == len(sine_audio)
- assert not np.allclose(result, sine_audio, atol=1e-3)
- def test_breathiness_zero(self, sine_audio):
- result = add_breathiness(sine_audio, 16000, 0.0)
- np.testing.assert_array_equal(result, sine_audio)
- def test_shimmer(self, sine_audio):
- result = add_shimmer(sine_audio, 16000, 0.02)
- assert len(result) == len(sine_audio)
- def test_fade(self, sine_audio):
- result = apply_fade(sine_audio, 16000, fade_ms=15.0)
- assert len(result) == len(sine_audio)
- assert abs(result[0]) < abs(sine_audio[0]) + 1e-6 # Fade-in: leiser am Anfang
- assert abs(result[-1]) < 0.01 # Fade-out: nahe Null am Ende
- # --- Dynamik ---
- class TestDynamics:
- def test_loud_pitch_boost_silence(self, silence_audio):
- result = loud_pitch_boost(silence_audio, 16000, 2.0)
- np.testing.assert_array_almost_equal(result, silence_audio)
- def test_loud_pitch_boost_preserves_length(self, sine_audio):
- result = loud_pitch_boost(sine_audio, 16000, 1.0)
- assert len(result) == len(sine_audio)
- def test_scale_pauses_identity(self, sine_audio):
- result = scale_pauses(sine_audio, 16000, 1.0)
- assert len(result) == len(sine_audio)
- def test_loud_tempo_scale_identity(self, sine_audio):
- result = loud_tempo_scale(sine_audio, 16000, 1.0)
- assert len(result) == len(sine_audio)
- # --- Erweiterte Effekte ---
- class TestEffects:
- def test_reverb_preserves_length(self, sine_audio):
- result = apply_reverb(sine_audio, 16000, room_size=0.5, damping=0.5, wet=0.3)
- assert len(result) == len(sine_audio)
- def test_reverb_zero_wet(self, sine_audio):
- result = apply_reverb(sine_audio, 16000, wet=0.0)
- np.testing.assert_array_equal(result, sine_audio)
- def test_lowpass(self, sine_audio):
- result = apply_lowpass(sine_audio, 16000, cutoff=2000)
- assert len(result) == len(sine_audio)
- def test_highpass(self, sine_audio):
- result = apply_highpass(sine_audio, 16000, cutoff=200)
- assert len(result) == len(sine_audio)
- def test_flanger(self, sine_audio):
- result = apply_flanger(sine_audio, 16000, rate=0.5, depth=0.002, wet=0.3)
- assert len(result) == len(sine_audio)
- def test_distortion(self, sine_audio):
- result = apply_distortion(sine_audio, 16000, gain=3.0, threshold=0.5)
- assert len(result) == len(sine_audio)
- assert np.max(np.abs(result)) <= 0.5 + 1e-6 # Soft-clipped
- def test_distortion_identity(self, sine_audio):
- result = apply_distortion(sine_audio, 16000, gain=1.0, threshold=1.0)
- np.testing.assert_array_equal(result, sine_audio)
- # --- AudioMorpher ---
- class TestAudioMorpher:
- def test_load_default_presets(self):
- morpher = AudioMorpher()
- assert len(morpher.presets) >= 9 # female, male, general, effects
- def test_load_custom_presets(self, preset_dir):
- morpher = AudioMorpher(preset_dir)
- assert len(morpher.presets) == 2
- def test_get_preset(self):
- morpher = AudioMorpher()
- p = morpher.get_preset("deeper")
- assert p is not None
- assert p.pitch_semitones < 0
- def test_get_preset_not_found(self):
- morpher = AudioMorpher()
- assert morpher.get_preset("nonexistent") is None
- def test_morph_by_id(self, sine_audio):
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, "deeper")
- assert result is not None
- assert len(result) > 0
- def test_morph_by_preset(self, sine_audio):
- preset = VoiceMorphPreset(preset_id="t", label="T", pitch_semitones=2.0)
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, preset)
- assert len(result) < len(sine_audio) # Hoeher = kuerzer
- def test_morph_unknown_preset_returns_original(self, sine_audio):
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, "nonexistent")
- np.testing.assert_array_equal(result, sine_audio)
- def test_morph_zero_intensity(self, sine_audio):
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, "deeper", intensity=0.0)
- np.testing.assert_array_equal(result, sine_audio)
- def test_morph_with_rtrim(self, sine_audio):
- preset = VoiceMorphPreset(preset_id="t", label="T", rtrim=0.1)
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, preset)
- expected_trim = int(16000 * 0.1)
- assert len(result) < len(sine_audio)
- assert abs(len(sine_audio) - len(result) - expected_trim) < 500 # Fade aendert auch
- def test_morph_applies_fade(self, sine_audio):
- preset = VoiceMorphPreset(preset_id="t", label="T", pitch_semitones=1.0)
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, preset)
- assert abs(result[-1]) < 0.01 # Fade-out am Ende
- def test_morph_with_effects(self, sine_audio):
- preset = VoiceMorphPreset.from_dict({
- "preset_id": "fx", "label": "FX",
- "reverb": {"wet": 0.2, "room_size": 0.3},
- "lowpass": {"cutoff": 3000},
- "distortion": {"gain": 2.0, "threshold": 0.7},
- })
- morpher = AudioMorpher()
- result = morpher.morph(sine_audio, 16000, preset)
- assert len(result) > 0
- def test_reload_presets(self, preset_dir):
- morpher = AudioMorpher(preset_dir)
- assert len(morpher.presets) == 2
- # Neues Preset hinzufuegen
- new_presets = [{"preset_id": "new", "label": "New"}]
- (preset_dir / "new.json").write_text(json.dumps(new_presets))
- morpher.reload_presets()
- assert len(morpher.presets) == 3
- # --- Convenience-Funktion ---
- class TestApplyVoiceMorph:
- def test_apply_voice_morph(self, sine_audio):
- result = apply_voice_morph(sine_audio, 16000, "deeper")
- assert len(result) > 0
- assert len(result) != len(sine_audio) # deeper veraendert Laenge
- # --- VoiceMorphProcessor ---
- class TestVoiceMorphProcessor:
- def test_creation(self):
- proc = VoiceMorphProcessor(preset="brighter", intensity=0.6)
- assert proc.id == "voice_morph"
- assert proc.preset is not None
- assert proc.preset.preset_id == "brighter"
- assert proc.intensity == 0.6
- def test_creation_empty(self):
- proc = VoiceMorphProcessor()
- assert proc.preset is None
- def test_set_preset_by_id(self):
- proc = VoiceMorphProcessor()
- proc.preset = "deeper"
- assert proc.preset is not None
- assert proc.preset.preset_id == "deeper"
- def test_get_config(self):
- proc = VoiceMorphProcessor(preset="brighter", intensity=0.5)
- config = proc.get_config()
- assert config["preset"] == "brighter"
- assert config["intensity"] == 0.5
|