# -*- coding: utf-8 -*- """ Tests für Crossfade-Plugin. Diese Tests: 1. Testen den CrossfadeProcessor 2. Testen die crossfade_audio() Funktion 3. Erstellen echte Crossfade-Mixes aus Musikdateien 4. Speichern Testdateien zum Anhören """ import struct import pytest from pathlib import Path from unittest.mock import MagicMock, AsyncMock # Plugin-Imports from plugins.crossfade import ( CrossfadePlugin, CrossfadeProcessor, CrossfadeConfig, crossfade_audio, save_wav, load_wav, ) from trixy_core.audio.processing import ( AudioProcessingContext, ProcessorPriority, ) # ============================================================================= # Konstanten # ============================================================================= MUSIC_DIR = Path("./assets/default/music") OUTPUT_DIR = Path("./assets/default/music/crossfade_tests") # Audio-Parameter SAMPLE_RATE = 48000 CHANNELS = 2 BIT_DEPTH = 16 BYTES_PER_MS = (SAMPLE_RATE * CHANNELS * 2) // 1000 # ============================================================================= # Hilfsfunktionen # ============================================================================= def has_pydub() -> bool: """Prüft ob pydub und ffmpeg verfügbar sind.""" import subprocess try: from pydub import AudioSegment # Prüfe auch ob ffprobe verfügbar ist result = subprocess.run( ["ffprobe", "-version"], capture_output=True, timeout=5, ) return result.returncode == 0 except (ImportError, FileNotFoundError, subprocess.TimeoutExpired): return False def has_music_files() -> bool: """Prüft ob Musikdateien verfügbar sind.""" return MUSIC_DIR.exists() and any(MUSIC_DIR.glob("*.mp3")) def get_music_files() -> list[Path]: """Liefert alle Musikdateien.""" if not MUSIC_DIR.exists(): return [] files = list(MUSIC_DIR.glob("*.mp3")) files.extend(MUSIC_DIR.glob("*.wav")) files.extend(MUSIC_DIR.glob("*.ogg")) files.extend(MUSIC_DIR.glob("*.flac")) return sorted(files) def decode_mp3_to_pcm(path: Path, duration_ms: int | None = None) -> tuple[bytes, int, int]: """ Dekodiert MP3 zu PCM mit pydub. Args: path: Pfad zur MP3-Datei duration_ms: Maximale Dauer in ms (None = komplett) Returns: Tuple (pcm_data, sample_rate, channels) """ from pydub import AudioSegment audio = AudioSegment.from_file(path) # Auf Stereo und 48kHz konvertieren audio = audio.set_channels(2) audio = audio.set_frame_rate(SAMPLE_RATE) audio = audio.set_sample_width(2) # 16-bit # Kürzen wenn gewünscht if duration_ms: audio = audio[:duration_ms] return audio.raw_data, audio.frame_rate, audio.channels def generate_sine_wave( duration_ms: int, frequency: int = 440, sample_rate: int = 48000, channels: int = 2, amplitude: float = 0.5, ) -> bytes: """ Generiert einen Sinus-Ton als PCM-Daten. Args: duration_ms: Dauer in Millisekunden frequency: Frequenz in Hz sample_rate: Sample-Rate channels: Anzahl Kanäle amplitude: Amplitude (0.0 - 1.0) Returns: PCM-Daten (16-bit signed, little-endian) """ import math sample_count = int(sample_rate * duration_ms / 1000) samples = [] for i in range(sample_count): t = i / sample_rate value = int(amplitude * 32767 * math.sin(2 * math.pi * frequency * t)) # Stereo: Gleiches Sample für beide Kanäle for _ in range(channels): samples.append(value) return struct.pack(f"<{len(samples)}h", *samples) def generate_test_audio(duration_ms: int, pattern: str = "sine") -> bytes: """ Generiert Test-Audio. Args: duration_ms: Dauer pattern: "sine", "silence", "noise" Returns: PCM-Daten """ if pattern == "silence": sample_count = int(SAMPLE_RATE * duration_ms / 1000) * CHANNELS return struct.pack(f"<{sample_count}h", *([0] * sample_count)) elif pattern == "noise": import random sample_count = int(SAMPLE_RATE * duration_ms / 1000) * CHANNELS samples = [random.randint(-16384, 16384) for _ in range(sample_count)] return struct.pack(f"<{sample_count}h", *samples) else: return generate_sine_wave(duration_ms) # ============================================================================= # Tests für CrossfadeConfig # ============================================================================= class TestCrossfadeConfig: """Tests für CrossfadeConfig.""" def test_default_values(self): """Testet Standard-Werte.""" config = CrossfadeConfig() assert config.duration_ms == 5000 assert config.fade_curve == "linear" assert config.min_track_duration_ms == 30000 assert config.prebuffer_duration_ms == 6000 assert config.enabled is True def test_custom_values(self): """Testet benutzerdefinierte Werte.""" config = CrossfadeConfig( duration_ms=3000, fade_curve="cosine", min_track_duration_ms=20000, enabled=False, ) assert config.duration_ms == 3000 assert config.fade_curve == "cosine" assert config.min_track_duration_ms == 20000 assert config.enabled is False # ============================================================================= # Tests für CrossfadeProcessor # ============================================================================= class TestCrossfadeProcessor: """Tests für CrossfadeProcessor.""" @pytest.fixture def processor(self): """Erstellt einen CrossfadeProcessor.""" return CrossfadeProcessor( config=CrossfadeConfig( duration_ms=2000, min_track_duration_ms=5000, ) ) @pytest.fixture def mock_track(self): """Erstellt einen Mock-Track.""" track = MagicMock() track.id = "track_1" track.path = Path("/fake/path.mp3") track.duration_ms = 180000 # 3 Minuten track.metadata.format = "mp3" return track @pytest.fixture def mock_next_track(self): """Erstellt einen Mock für den nächsten Track.""" track = MagicMock() track.id = "track_2" track.path = Path("/fake/path2.mp3") track.duration_ms = 200000 track.metadata.format = "mp3" return track def test_processor_creation(self, processor): """Testet Prozessor-Erstellung.""" assert processor.id == "crossfade" assert processor.name == "Crossfade" assert processor.priority == ProcessorPriority.CROSSFADE assert processor.enabled is True def test_processor_priority(self, processor): """Testet korrekte Priorität.""" assert processor.priority == 400 # ProcessorPriority.CROSSFADE def test_process_passthrough_when_disabled(self, processor): """Testet Passthrough wenn deaktiviert.""" processor.enabled = False chunk = b'\x00' * 1000 context = AudioProcessingContext() result = processor.process(chunk, context) assert result == chunk def test_process_passthrough_no_next_track(self, processor, mock_track): """Testet Passthrough ohne nächsten Track.""" chunk = b'\x00' * 1000 context = AudioProcessingContext( track=mock_track, next_track=None, position_ms=178000, # Nahe Ende duration_ms=180000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) result = processor.process(chunk, context) # Kein Crossfade ohne nächsten Track, aber fade-out möglich assert len(result) == len(chunk) def test_process_passthrough_not_near_end(self, processor, mock_track, mock_next_track): """Testet Passthrough wenn nicht nahe am Ende.""" chunk = generate_test_audio(100) # 100ms context = AudioProcessingContext( track=mock_track, next_track=mock_next_track, position_ms=60000, # Mitte des Tracks duration_ms=180000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) result = processor.process(chunk, context) # Nicht in Crossfade-Region - unveränderter Chunk assert result == chunk def test_process_fades_out_near_end_with_next(self, processor, mock_track, mock_next_track): """Testet Fade-Out nahe am Ende mit nächstem Track aber ohne Puffer.""" chunk = generate_test_audio(100) context = AudioProcessingContext( track=mock_track, next_track=mock_next_track, # Nächster Track vorhanden position_ms=179000, # 1 Sekunde vor Ende duration_ms=180000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) # Kein Puffer geladen - nur Fade-Out anwenden result = processor.process(chunk, context) # Chunk sollte leiser sein (fade-out ohne gemischten nächsten Track) assert len(result) == len(chunk) # Vergleiche RMS-Lautstärke original_rms = calculate_rms(chunk) result_rms = calculate_rms(result) # Bei 1 Sekunde verbleibend und 2 Sekunden Crossfade: 50% Lautstärke assert result_rms < original_rms * 0.6 # Sollte deutlich leiser sein def test_preload_from_pcm(self, processor): """Testet direktes Laden von PCM-Daten.""" pcm_data = generate_test_audio(1000) processor.preload_from_pcm(pcm_data) assert processor._next_track_loaded is True assert len(processor._next_track_buffer) > 0 def test_crossfade_mixing(self, processor, mock_track, mock_next_track): """Testet Mischen während Crossfade.""" # Nächsten Track vorladen next_audio = generate_sine_wave(3000, frequency=880) # Andere Frequenz processor.preload_from_pcm(next_audio) # Kontext nahe am Ende context = AudioProcessingContext( track=mock_track, next_track=mock_next_track, position_ms=179000, # 1 Sekunde vor Ende duration_ms=180000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) chunk = generate_sine_wave(100, frequency=440) # 100ms bei 440Hz result = processor.process(chunk, context) # Ergebnis sollte gemischtes Audio sein assert len(result) == len(chunk) # Sollte sich vom Original unterscheiden (weil gemischt) assert result != chunk def test_on_track_start(self, processor, mock_track, mock_next_track): """Testet on_track_start.""" context = AudioProcessingContext( track=mock_track, next_track=mock_next_track, duration_ms=180000, ) processor.on_track_start(context) assert processor._current_track == mock_track assert processor._next_track == mock_next_track assert processor._is_crossfading is False def test_on_track_end(self, processor, mock_track): """Testet on_track_end.""" processor._is_crossfading = True context = AudioProcessingContext(track=mock_track) processor.on_track_end(context) assert processor._is_crossfading is False assert processor._crossfades_performed == 1 def test_reset(self, processor): """Testet Reset.""" processor._is_crossfading = True processor.preload_from_pcm(b'\x00' * 1000) processor.reset() assert processor._is_crossfading is False assert processor._next_track_loaded is False assert len(processor._next_track_buffer) == 0 def test_get_config(self, processor): """Testet get_config.""" config = processor.get_config() assert config["id"] == "crossfade" assert config["crossfade_duration_ms"] == 2000 assert config["fade_curve"] == "linear" assert "crossfades_performed" in config assert "is_crossfading" in config def test_track_too_short(self, processor, mock_track, mock_next_track): """Testet, dass zu kurze Tracks nicht crossfaden.""" mock_track.duration_ms = 3000 # Kürzer als min_track_duration_ms context = AudioProcessingContext( track=mock_track, next_track=mock_next_track, position_ms=2000, duration_ms=3000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) chunk = generate_test_audio(100) result = processor.process(chunk, context) # Sollte unverändert sein (Track zu kurz) assert result == chunk def calculate_rms(pcm_data: bytes) -> float: """Berechnet RMS-Lautstärke.""" samples = struct.unpack(f"<{len(pcm_data)//2}h", pcm_data) if not samples: return 0.0 rms = (sum(s**2 for s in samples) / len(samples)) ** 0.5 return rms # ============================================================================= # Tests für crossfade_audio() # ============================================================================= class TestCrossfadeAudio: """Tests für die crossfade_audio() Funktion.""" def test_crossfade_sine_waves(self): """Testet Crossfade zwischen zwei Sinuswellen.""" # Zwei verschiedene Frequenzen audio1 = generate_sine_wave(5000, frequency=440) # A4 audio2 = generate_sine_wave(5000, frequency=880) # A5 result = crossfade_audio( audio1, audio2, crossfade_ms=2000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) # Ergebnis sollte kürzer sein als beide zusammen expected_length = len(audio1) + len(audio2) - (2000 * BYTES_PER_MS) assert len(result) == expected_length def test_crossfade_linear_curve(self): """Testet lineare Fade-Kurve.""" audio1 = generate_test_audio(3000) audio2 = generate_test_audio(3000) result = crossfade_audio( audio1, audio2, crossfade_ms=1000, fade_curve="linear", ) assert len(result) > 0 def test_crossfade_cosine_curve(self): """Testet Cosinus-Fade-Kurve.""" audio1 = generate_test_audio(3000) audio2 = generate_test_audio(3000) result = crossfade_audio( audio1, audio2, crossfade_ms=1000, fade_curve="cosine", ) assert len(result) > 0 def test_crossfade_exponential_curve(self): """Testet exponentielle Fade-Kurve.""" audio1 = generate_test_audio(3000) audio2 = generate_test_audio(3000) result = crossfade_audio( audio1, audio2, crossfade_ms=1000, fade_curve="exponential", ) assert len(result) > 0 def test_crossfade_too_short_audio(self): """Testet Verhalten bei zu kurzen Audio-Daten.""" audio1 = generate_test_audio(500) # 500ms audio2 = generate_test_audio(500) # Crossfade länger als Audio - sollte einfach verketten result = crossfade_audio( audio1, audio2, crossfade_ms=2000, ) # Sollte beide komplett enthalten assert len(result) == len(audio1) + len(audio2) # ============================================================================= # Tests für WAV I/O # ============================================================================= class TestWavIO: """Tests für WAV Ein-/Ausgabe.""" def test_save_and_load_wav(self, tmp_path): """Testet Speichern und Laden.""" pcm_data = generate_test_audio(1000) wav_path = tmp_path / "test.wav" save_wav(pcm_data, wav_path, SAMPLE_RATE, CHANNELS) assert wav_path.exists() loaded, sample_rate, channels = load_wav(wav_path) assert sample_rate == SAMPLE_RATE assert channels == CHANNELS assert len(loaded) == len(pcm_data) def test_save_creates_directories(self, tmp_path): """Testet, dass Verzeichnisse erstellt werden.""" wav_path = tmp_path / "nested" / "dir" / "test.wav" pcm_data = generate_test_audio(100) save_wav(pcm_data, wav_path) assert wav_path.exists() # ============================================================================= # Integrationstests mit echten Musikdateien # ============================================================================= @pytest.mark.skipif(not has_pydub(), reason="pydub nicht installiert") @pytest.mark.skipif(not has_music_files(), reason="Keine Musikdateien vorhanden") class TestRealMusicCrossfade: """ Integrationstests mit echten Musikdateien. Diese Tests erstellen echte Crossfade-Mixes und speichern sie in ./assets/default/music/crossfade_tests/ """ @pytest.fixture(autouse=True) def setup_output_dir(self): """Erstellt Output-Verzeichnis.""" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) def test_crossfade_two_tracks(self): """Testet Crossfade zwischen zwei echten Tracks.""" files = get_music_files() if len(files) < 2: pytest.skip("Nicht genug Musikdateien") file1, file2 = files[0], files[1] # Lade nur die ersten 15 Sekunden von jedem Track audio1, sr1, ch1 = decode_mp3_to_pcm(file1, duration_ms=15000) audio2, sr2, ch2 = decode_mp3_to_pcm(file2, duration_ms=15000) # Crossfade erstellen (5 Sekunden) crossfaded = crossfade_audio( audio1, audio2, crossfade_ms=5000, sample_rate=SAMPLE_RATE, channels=CHANNELS, fade_curve="linear", ) # Speichern output_path = OUTPUT_DIR / f"crossfade_linear_{file1.stem}_to_{file2.stem}.wav" save_wav(crossfaded, output_path) assert output_path.exists() print(f"\nGespeichert: {output_path}") def test_crossfade_different_curves(self): """Testet verschiedene Fade-Kurven.""" files = get_music_files() if len(files) < 2: pytest.skip("Nicht genug Musikdateien") file1, file2 = files[0], files[1] # Lade Audio audio1, _, _ = decode_mp3_to_pcm(file1, duration_ms=15000) audio2, _, _ = decode_mp3_to_pcm(file2, duration_ms=15000) for curve in ["linear", "cosine", "exponential"]: crossfaded = crossfade_audio( audio1, audio2, crossfade_ms=5000, sample_rate=SAMPLE_RATE, channels=CHANNELS, fade_curve=curve, ) output_path = OUTPUT_DIR / f"crossfade_{curve}_{file1.stem}_to_{file2.stem}.wav" save_wav(crossfaded, output_path) assert output_path.exists() print(f"Gespeichert ({curve}): {output_path}") def test_crossfade_different_durations(self): """Testet verschiedene Crossfade-Dauern.""" files = get_music_files() if len(files) < 2: pytest.skip("Nicht genug Musikdateien") file1, file2 = files[0], files[1] audio1, _, _ = decode_mp3_to_pcm(file1, duration_ms=20000) audio2, _, _ = decode_mp3_to_pcm(file2, duration_ms=20000) for duration_ms in [1000, 3000, 5000, 8000]: crossfaded = crossfade_audio( audio1, audio2, crossfade_ms=duration_ms, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) output_path = OUTPUT_DIR / f"crossfade_{duration_ms}ms_{file1.stem}_to_{file2.stem}.wav" save_wav(crossfaded, output_path) assert output_path.exists() print(f"Gespeichert ({duration_ms}ms): {output_path}") def test_crossfade_chain_three_tracks(self): """Testet Verkettung von drei Tracks mit Crossfade.""" files = get_music_files() if len(files) < 3: pytest.skip("Nicht genug Musikdateien") file1, file2, file3 = files[0], files[1], files[2] # Lade Audio audio1, _, _ = decode_mp3_to_pcm(file1, duration_ms=15000) audio2, _, _ = decode_mp3_to_pcm(file2, duration_ms=15000) audio3, _, _ = decode_mp3_to_pcm(file3, duration_ms=15000) # Erste Crossfade mixed1 = crossfade_audio( audio1, audio2, crossfade_ms=4000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) # Zweite Crossfade mixed2 = crossfade_audio( mixed1, audio3, crossfade_ms=4000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) output_path = OUTPUT_DIR / f"crossfade_chain_{file1.stem}_{file2.stem}_{file3.stem}.wav" save_wav(mixed2, output_path) assert output_path.exists() print(f"\nGespeichert (3-Track-Chain): {output_path}") def test_create_demo_mix(self): """Erstellt einen Demo-Mix aus allen verfügbaren Tracks.""" files = get_music_files() if len(files) < 2: pytest.skip("Nicht genug Musikdateien") # Maximal 4 Tracks für Demo files = files[:4] crossfade_ms = 5000 segment_length = 20000 # 20 Sekunden pro Track print(f"\nErstelle Demo-Mix aus {len(files)} Tracks...") # Ersten Track laden result, _, _ = decode_mp3_to_pcm(files[0], duration_ms=segment_length) print(f" Track 1: {files[0].name}") # Weitere Tracks hinzufügen for i, file in enumerate(files[1:], 2): audio, _, _ = decode_mp3_to_pcm(file, duration_ms=segment_length) print(f" Track {i}: {file.name}") result = crossfade_audio( result, audio, crossfade_ms=crossfade_ms, sample_rate=SAMPLE_RATE, channels=CHANNELS, fade_curve="cosine", ) # Speichern output_path = OUTPUT_DIR / "demo_mix.wav" save_wav(result, output_path) assert output_path.exists() # Dauer berechnen duration_seconds = len(result) / (SAMPLE_RATE * CHANNELS * 2) print(f"\nDemo-Mix gespeichert: {output_path}") print(f"Dauer: {duration_seconds:.1f} Sekunden") print(f"Größe: {len(result) / 1024 / 1024:.1f} MB") # ============================================================================= # Tests für CrossfadePlugin # ============================================================================= class TestCrossfadePlugin: """Tests für CrossfadePlugin.""" @pytest.fixture def plugin(self, tmp_path): """Erstellt ein CrossfadePlugin.""" config = { "enabled": True, "crossfade_duration_ms": 5000, "fade_curve": "linear", } return CrossfadePlugin( application=None, plugin_path=tmp_path, config=config, ) @pytest.mark.asyncio async def test_plugin_load(self, plugin): """Testet Plugin-Laden.""" await plugin.on_load() assert plugin.processor is not None assert plugin.processor.config.duration_ms == 5000 @pytest.mark.asyncio async def test_plugin_enable_disable(self, plugin): """Testet Aktivieren/Deaktivieren.""" await plugin.on_load() await plugin.on_enable() assert plugin.processor.enabled is True await plugin.on_disable() assert plugin.processor.enabled is False @pytest.mark.asyncio async def test_plugin_config_change(self, plugin): """Testet Konfigurationsänderung.""" await plugin.on_load() old_config = plugin.config.copy() plugin.config["crossfade_duration_ms"] = 3000 await plugin.on_config_change(old_config) assert plugin.processor.config.duration_ms == 3000 @pytest.mark.asyncio async def test_plugin_unload(self, plugin): """Testet Plugin-Entladen.""" await plugin.on_load() await plugin.on_unload() assert plugin.processor is None @pytest.mark.asyncio async def test_plugin_stats(self, plugin): """Testet Statistiken.""" await plugin.on_load() stats = plugin.get_stats() assert "crossfade_duration_ms" in stats assert "crossfades_performed" in stats # ============================================================================= # Performance-Tests # ============================================================================= class TestCrossfadePerformance: """Performance-Tests für Crossfade.""" def test_crossfade_processing_speed(self): """Testet Verarbeitungsgeschwindigkeit.""" import time processor = CrossfadeProcessor( config=CrossfadeConfig(duration_ms=2000) ) # Nächsten Track vorladen next_audio = generate_test_audio(5000) processor.preload_from_pcm(next_audio) # Mock-Track für Kontext track = MagicMock() track.duration_ms = 180000 # 100 Chunks verarbeiten (je 100ms) chunks = [generate_test_audio(100) for _ in range(100)] start = time.perf_counter() for i, chunk in enumerate(chunks): # Position im Crossfade-Bereich position_ms = 178000 + (i * 10) # 178-179 Sekunden context = AudioProcessingContext( track=track, next_track=track, # Nächster Track vorhanden position_ms=position_ms, duration_ms=180000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) processor.process(chunk, context) elapsed = time.perf_counter() - start print(f"\n100 Chunks verarbeitet in {elapsed*1000:.2f}ms") print(f"Durchschnitt: {elapsed*10:.2f}ms pro Chunk") # Sollte schnell genug für Echtzeit sein (< 100ms pro Chunk) assert elapsed < 1.0 # 100 Chunks in unter 1 Sekunde def test_crossfade_audio_speed(self): """Testet Geschwindigkeit der crossfade_audio() Funktion.""" import time # Zwei 30-Sekunden Tracks audio1 = generate_test_audio(30000) audio2 = generate_test_audio(30000) start = time.perf_counter() result = crossfade_audio( audio1, audio2, crossfade_ms=5000, sample_rate=SAMPLE_RATE, channels=CHANNELS, ) elapsed = time.perf_counter() - start print(f"\n30s + 30s Tracks mit 5s Crossfade: {elapsed*1000:.2f}ms") # Sollte schnell sein (< 1 Sekunde) assert elapsed < 1.0 assert len(result) > 0