# -*- 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