test_crossfade.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für Crossfade-Plugin.
  4. Diese Tests:
  5. 1. Testen den CrossfadeProcessor
  6. 2. Testen die crossfade_audio() Funktion
  7. 3. Erstellen echte Crossfade-Mixes aus Musikdateien
  8. 4. Speichern Testdateien zum Anhören
  9. """
  10. import struct
  11. import pytest
  12. from pathlib import Path
  13. from unittest.mock import MagicMock, AsyncMock
  14. # Plugin-Imports
  15. from plugins.crossfade import (
  16. CrossfadePlugin,
  17. CrossfadeProcessor,
  18. CrossfadeConfig,
  19. crossfade_audio,
  20. save_wav,
  21. load_wav,
  22. )
  23. from trixy_core.audio.processing import (
  24. AudioProcessingContext,
  25. ProcessorPriority,
  26. )
  27. # =============================================================================
  28. # Konstanten
  29. # =============================================================================
  30. MUSIC_DIR = Path("./assets/default/music")
  31. OUTPUT_DIR = Path("./assets/default/music/crossfade_tests")
  32. # Audio-Parameter
  33. SAMPLE_RATE = 48000
  34. CHANNELS = 2
  35. BIT_DEPTH = 16
  36. BYTES_PER_MS = (SAMPLE_RATE * CHANNELS * 2) // 1000
  37. # =============================================================================
  38. # Hilfsfunktionen
  39. # =============================================================================
  40. def has_pydub() -> bool:
  41. """Prüft ob pydub und ffmpeg verfügbar sind."""
  42. import subprocess
  43. try:
  44. from pydub import AudioSegment
  45. # Prüfe auch ob ffprobe verfügbar ist
  46. result = subprocess.run(
  47. ["ffprobe", "-version"],
  48. capture_output=True,
  49. timeout=5,
  50. )
  51. return result.returncode == 0
  52. except (ImportError, FileNotFoundError, subprocess.TimeoutExpired):
  53. return False
  54. def has_music_files() -> bool:
  55. """Prüft ob Musikdateien verfügbar sind."""
  56. return MUSIC_DIR.exists() and any(MUSIC_DIR.glob("*.mp3"))
  57. def get_music_files() -> list[Path]:
  58. """Liefert alle Musikdateien."""
  59. if not MUSIC_DIR.exists():
  60. return []
  61. files = list(MUSIC_DIR.glob("*.mp3"))
  62. files.extend(MUSIC_DIR.glob("*.wav"))
  63. files.extend(MUSIC_DIR.glob("*.ogg"))
  64. files.extend(MUSIC_DIR.glob("*.flac"))
  65. return sorted(files)
  66. def decode_mp3_to_pcm(path: Path, duration_ms: int | None = None) -> tuple[bytes, int, int]:
  67. """
  68. Dekodiert MP3 zu PCM mit pydub.
  69. Args:
  70. path: Pfad zur MP3-Datei
  71. duration_ms: Maximale Dauer in ms (None = komplett)
  72. Returns:
  73. Tuple (pcm_data, sample_rate, channels)
  74. """
  75. from pydub import AudioSegment
  76. audio = AudioSegment.from_file(path)
  77. # Auf Stereo und 48kHz konvertieren
  78. audio = audio.set_channels(2)
  79. audio = audio.set_frame_rate(SAMPLE_RATE)
  80. audio = audio.set_sample_width(2) # 16-bit
  81. # Kürzen wenn gewünscht
  82. if duration_ms:
  83. audio = audio[:duration_ms]
  84. return audio.raw_data, audio.frame_rate, audio.channels
  85. def generate_sine_wave(
  86. duration_ms: int,
  87. frequency: int = 440,
  88. sample_rate: int = 48000,
  89. channels: int = 2,
  90. amplitude: float = 0.5,
  91. ) -> bytes:
  92. """
  93. Generiert einen Sinus-Ton als PCM-Daten.
  94. Args:
  95. duration_ms: Dauer in Millisekunden
  96. frequency: Frequenz in Hz
  97. sample_rate: Sample-Rate
  98. channels: Anzahl Kanäle
  99. amplitude: Amplitude (0.0 - 1.0)
  100. Returns:
  101. PCM-Daten (16-bit signed, little-endian)
  102. """
  103. import math
  104. sample_count = int(sample_rate * duration_ms / 1000)
  105. samples = []
  106. for i in range(sample_count):
  107. t = i / sample_rate
  108. value = int(amplitude * 32767 * math.sin(2 * math.pi * frequency * t))
  109. # Stereo: Gleiches Sample für beide Kanäle
  110. for _ in range(channels):
  111. samples.append(value)
  112. return struct.pack(f"<{len(samples)}h", *samples)
  113. def generate_test_audio(duration_ms: int, pattern: str = "sine") -> bytes:
  114. """
  115. Generiert Test-Audio.
  116. Args:
  117. duration_ms: Dauer
  118. pattern: "sine", "silence", "noise"
  119. Returns:
  120. PCM-Daten
  121. """
  122. if pattern == "silence":
  123. sample_count = int(SAMPLE_RATE * duration_ms / 1000) * CHANNELS
  124. return struct.pack(f"<{sample_count}h", *([0] * sample_count))
  125. elif pattern == "noise":
  126. import random
  127. sample_count = int(SAMPLE_RATE * duration_ms / 1000) * CHANNELS
  128. samples = [random.randint(-16384, 16384) for _ in range(sample_count)]
  129. return struct.pack(f"<{sample_count}h", *samples)
  130. else:
  131. return generate_sine_wave(duration_ms)
  132. # =============================================================================
  133. # Tests für CrossfadeConfig
  134. # =============================================================================
  135. class TestCrossfadeConfig:
  136. """Tests für CrossfadeConfig."""
  137. def test_default_values(self):
  138. """Testet Standard-Werte."""
  139. config = CrossfadeConfig()
  140. assert config.duration_ms == 5000
  141. assert config.fade_curve == "linear"
  142. assert config.min_track_duration_ms == 30000
  143. assert config.prebuffer_duration_ms == 6000
  144. assert config.enabled is True
  145. def test_custom_values(self):
  146. """Testet benutzerdefinierte Werte."""
  147. config = CrossfadeConfig(
  148. duration_ms=3000,
  149. fade_curve="cosine",
  150. min_track_duration_ms=20000,
  151. enabled=False,
  152. )
  153. assert config.duration_ms == 3000
  154. assert config.fade_curve == "cosine"
  155. assert config.min_track_duration_ms == 20000
  156. assert config.enabled is False
  157. # =============================================================================
  158. # Tests für CrossfadeProcessor
  159. # =============================================================================
  160. class TestCrossfadeProcessor:
  161. """Tests für CrossfadeProcessor."""
  162. @pytest.fixture
  163. def processor(self):
  164. """Erstellt einen CrossfadeProcessor."""
  165. return CrossfadeProcessor(
  166. config=CrossfadeConfig(
  167. duration_ms=2000,
  168. min_track_duration_ms=5000,
  169. )
  170. )
  171. @pytest.fixture
  172. def mock_track(self):
  173. """Erstellt einen Mock-Track."""
  174. track = MagicMock()
  175. track.id = "track_1"
  176. track.path = Path("/fake/path.mp3")
  177. track.duration_ms = 180000 # 3 Minuten
  178. track.metadata.format = "mp3"
  179. return track
  180. @pytest.fixture
  181. def mock_next_track(self):
  182. """Erstellt einen Mock für den nächsten Track."""
  183. track = MagicMock()
  184. track.id = "track_2"
  185. track.path = Path("/fake/path2.mp3")
  186. track.duration_ms = 200000
  187. track.metadata.format = "mp3"
  188. return track
  189. def test_processor_creation(self, processor):
  190. """Testet Prozessor-Erstellung."""
  191. assert processor.id == "crossfade"
  192. assert processor.name == "Crossfade"
  193. assert processor.priority == ProcessorPriority.CROSSFADE
  194. assert processor.enabled is True
  195. def test_processor_priority(self, processor):
  196. """Testet korrekte Priorität."""
  197. assert processor.priority == 400 # ProcessorPriority.CROSSFADE
  198. def test_process_passthrough_when_disabled(self, processor):
  199. """Testet Passthrough wenn deaktiviert."""
  200. processor.enabled = False
  201. chunk = b'\x00' * 1000
  202. context = AudioProcessingContext()
  203. result = processor.process(chunk, context)
  204. assert result == chunk
  205. def test_process_passthrough_no_next_track(self, processor, mock_track):
  206. """Testet Passthrough ohne nächsten Track."""
  207. chunk = b'\x00' * 1000
  208. context = AudioProcessingContext(
  209. track=mock_track,
  210. next_track=None,
  211. position_ms=178000, # Nahe Ende
  212. duration_ms=180000,
  213. sample_rate=SAMPLE_RATE,
  214. channels=CHANNELS,
  215. )
  216. result = processor.process(chunk, context)
  217. # Kein Crossfade ohne nächsten Track, aber fade-out möglich
  218. assert len(result) == len(chunk)
  219. def test_process_passthrough_not_near_end(self, processor, mock_track, mock_next_track):
  220. """Testet Passthrough wenn nicht nahe am Ende."""
  221. chunk = generate_test_audio(100) # 100ms
  222. context = AudioProcessingContext(
  223. track=mock_track,
  224. next_track=mock_next_track,
  225. position_ms=60000, # Mitte des Tracks
  226. duration_ms=180000,
  227. sample_rate=SAMPLE_RATE,
  228. channels=CHANNELS,
  229. )
  230. result = processor.process(chunk, context)
  231. # Nicht in Crossfade-Region - unveränderter Chunk
  232. assert result == chunk
  233. def test_process_fades_out_near_end_with_next(self, processor, mock_track, mock_next_track):
  234. """Testet Fade-Out nahe am Ende mit nächstem Track aber ohne Puffer."""
  235. chunk = generate_test_audio(100)
  236. context = AudioProcessingContext(
  237. track=mock_track,
  238. next_track=mock_next_track, # Nächster Track vorhanden
  239. position_ms=179000, # 1 Sekunde vor Ende
  240. duration_ms=180000,
  241. sample_rate=SAMPLE_RATE,
  242. channels=CHANNELS,
  243. )
  244. # Kein Puffer geladen - nur Fade-Out anwenden
  245. result = processor.process(chunk, context)
  246. # Chunk sollte leiser sein (fade-out ohne gemischten nächsten Track)
  247. assert len(result) == len(chunk)
  248. # Vergleiche RMS-Lautstärke
  249. original_rms = calculate_rms(chunk)
  250. result_rms = calculate_rms(result)
  251. # Bei 1 Sekunde verbleibend und 2 Sekunden Crossfade: 50% Lautstärke
  252. assert result_rms < original_rms * 0.6 # Sollte deutlich leiser sein
  253. def test_preload_from_pcm(self, processor):
  254. """Testet direktes Laden von PCM-Daten."""
  255. pcm_data = generate_test_audio(1000)
  256. processor.preload_from_pcm(pcm_data)
  257. assert processor._next_track_loaded is True
  258. assert len(processor._next_track_buffer) > 0
  259. def test_crossfade_mixing(self, processor, mock_track, mock_next_track):
  260. """Testet Mischen während Crossfade."""
  261. # Nächsten Track vorladen
  262. next_audio = generate_sine_wave(3000, frequency=880) # Andere Frequenz
  263. processor.preload_from_pcm(next_audio)
  264. # Kontext nahe am Ende
  265. context = AudioProcessingContext(
  266. track=mock_track,
  267. next_track=mock_next_track,
  268. position_ms=179000, # 1 Sekunde vor Ende
  269. duration_ms=180000,
  270. sample_rate=SAMPLE_RATE,
  271. channels=CHANNELS,
  272. )
  273. chunk = generate_sine_wave(100, frequency=440) # 100ms bei 440Hz
  274. result = processor.process(chunk, context)
  275. # Ergebnis sollte gemischtes Audio sein
  276. assert len(result) == len(chunk)
  277. # Sollte sich vom Original unterscheiden (weil gemischt)
  278. assert result != chunk
  279. def test_on_track_start(self, processor, mock_track, mock_next_track):
  280. """Testet on_track_start."""
  281. context = AudioProcessingContext(
  282. track=mock_track,
  283. next_track=mock_next_track,
  284. duration_ms=180000,
  285. )
  286. processor.on_track_start(context)
  287. assert processor._current_track == mock_track
  288. assert processor._next_track == mock_next_track
  289. assert processor._is_crossfading is False
  290. def test_on_track_end(self, processor, mock_track):
  291. """Testet on_track_end."""
  292. processor._is_crossfading = True
  293. context = AudioProcessingContext(track=mock_track)
  294. processor.on_track_end(context)
  295. assert processor._is_crossfading is False
  296. assert processor._crossfades_performed == 1
  297. def test_reset(self, processor):
  298. """Testet Reset."""
  299. processor._is_crossfading = True
  300. processor.preload_from_pcm(b'\x00' * 1000)
  301. processor.reset()
  302. assert processor._is_crossfading is False
  303. assert processor._next_track_loaded is False
  304. assert len(processor._next_track_buffer) == 0
  305. def test_get_config(self, processor):
  306. """Testet get_config."""
  307. config = processor.get_config()
  308. assert config["id"] == "crossfade"
  309. assert config["crossfade_duration_ms"] == 2000
  310. assert config["fade_curve"] == "linear"
  311. assert "crossfades_performed" in config
  312. assert "is_crossfading" in config
  313. def test_track_too_short(self, processor, mock_track, mock_next_track):
  314. """Testet, dass zu kurze Tracks nicht crossfaden."""
  315. mock_track.duration_ms = 3000 # Kürzer als min_track_duration_ms
  316. context = AudioProcessingContext(
  317. track=mock_track,
  318. next_track=mock_next_track,
  319. position_ms=2000,
  320. duration_ms=3000,
  321. sample_rate=SAMPLE_RATE,
  322. channels=CHANNELS,
  323. )
  324. chunk = generate_test_audio(100)
  325. result = processor.process(chunk, context)
  326. # Sollte unverändert sein (Track zu kurz)
  327. assert result == chunk
  328. def calculate_rms(pcm_data: bytes) -> float:
  329. """Berechnet RMS-Lautstärke."""
  330. samples = struct.unpack(f"<{len(pcm_data)//2}h", pcm_data)
  331. if not samples:
  332. return 0.0
  333. rms = (sum(s**2 for s in samples) / len(samples)) ** 0.5
  334. return rms
  335. # =============================================================================
  336. # Tests für crossfade_audio()
  337. # =============================================================================
  338. class TestCrossfadeAudio:
  339. """Tests für die crossfade_audio() Funktion."""
  340. def test_crossfade_sine_waves(self):
  341. """Testet Crossfade zwischen zwei Sinuswellen."""
  342. # Zwei verschiedene Frequenzen
  343. audio1 = generate_sine_wave(5000, frequency=440) # A4
  344. audio2 = generate_sine_wave(5000, frequency=880) # A5
  345. result = crossfade_audio(
  346. audio1, audio2,
  347. crossfade_ms=2000,
  348. sample_rate=SAMPLE_RATE,
  349. channels=CHANNELS,
  350. )
  351. # Ergebnis sollte kürzer sein als beide zusammen
  352. expected_length = len(audio1) + len(audio2) - (2000 * BYTES_PER_MS)
  353. assert len(result) == expected_length
  354. def test_crossfade_linear_curve(self):
  355. """Testet lineare Fade-Kurve."""
  356. audio1 = generate_test_audio(3000)
  357. audio2 = generate_test_audio(3000)
  358. result = crossfade_audio(
  359. audio1, audio2,
  360. crossfade_ms=1000,
  361. fade_curve="linear",
  362. )
  363. assert len(result) > 0
  364. def test_crossfade_cosine_curve(self):
  365. """Testet Cosinus-Fade-Kurve."""
  366. audio1 = generate_test_audio(3000)
  367. audio2 = generate_test_audio(3000)
  368. result = crossfade_audio(
  369. audio1, audio2,
  370. crossfade_ms=1000,
  371. fade_curve="cosine",
  372. )
  373. assert len(result) > 0
  374. def test_crossfade_exponential_curve(self):
  375. """Testet exponentielle Fade-Kurve."""
  376. audio1 = generate_test_audio(3000)
  377. audio2 = generate_test_audio(3000)
  378. result = crossfade_audio(
  379. audio1, audio2,
  380. crossfade_ms=1000,
  381. fade_curve="exponential",
  382. )
  383. assert len(result) > 0
  384. def test_crossfade_too_short_audio(self):
  385. """Testet Verhalten bei zu kurzen Audio-Daten."""
  386. audio1 = generate_test_audio(500) # 500ms
  387. audio2 = generate_test_audio(500)
  388. # Crossfade länger als Audio - sollte einfach verketten
  389. result = crossfade_audio(
  390. audio1, audio2,
  391. crossfade_ms=2000,
  392. )
  393. # Sollte beide komplett enthalten
  394. assert len(result) == len(audio1) + len(audio2)
  395. # =============================================================================
  396. # Tests für WAV I/O
  397. # =============================================================================
  398. class TestWavIO:
  399. """Tests für WAV Ein-/Ausgabe."""
  400. def test_save_and_load_wav(self, tmp_path):
  401. """Testet Speichern und Laden."""
  402. pcm_data = generate_test_audio(1000)
  403. wav_path = tmp_path / "test.wav"
  404. save_wav(pcm_data, wav_path, SAMPLE_RATE, CHANNELS)
  405. assert wav_path.exists()
  406. loaded, sample_rate, channels = load_wav(wav_path)
  407. assert sample_rate == SAMPLE_RATE
  408. assert channels == CHANNELS
  409. assert len(loaded) == len(pcm_data)
  410. def test_save_creates_directories(self, tmp_path):
  411. """Testet, dass Verzeichnisse erstellt werden."""
  412. wav_path = tmp_path / "nested" / "dir" / "test.wav"
  413. pcm_data = generate_test_audio(100)
  414. save_wav(pcm_data, wav_path)
  415. assert wav_path.exists()
  416. # =============================================================================
  417. # Integrationstests mit echten Musikdateien
  418. # =============================================================================
  419. @pytest.mark.skipif(not has_pydub(), reason="pydub nicht installiert")
  420. @pytest.mark.skipif(not has_music_files(), reason="Keine Musikdateien vorhanden")
  421. class TestRealMusicCrossfade:
  422. """
  423. Integrationstests mit echten Musikdateien.
  424. Diese Tests erstellen echte Crossfade-Mixes und speichern sie
  425. in ./assets/default/music/crossfade_tests/
  426. """
  427. @pytest.fixture(autouse=True)
  428. def setup_output_dir(self):
  429. """Erstellt Output-Verzeichnis."""
  430. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  431. def test_crossfade_two_tracks(self):
  432. """Testet Crossfade zwischen zwei echten Tracks."""
  433. files = get_music_files()
  434. if len(files) < 2:
  435. pytest.skip("Nicht genug Musikdateien")
  436. file1, file2 = files[0], files[1]
  437. # Lade nur die ersten 15 Sekunden von jedem Track
  438. audio1, sr1, ch1 = decode_mp3_to_pcm(file1, duration_ms=15000)
  439. audio2, sr2, ch2 = decode_mp3_to_pcm(file2, duration_ms=15000)
  440. # Crossfade erstellen (5 Sekunden)
  441. crossfaded = crossfade_audio(
  442. audio1, audio2,
  443. crossfade_ms=5000,
  444. sample_rate=SAMPLE_RATE,
  445. channels=CHANNELS,
  446. fade_curve="linear",
  447. )
  448. # Speichern
  449. output_path = OUTPUT_DIR / f"crossfade_linear_{file1.stem}_to_{file2.stem}.wav"
  450. save_wav(crossfaded, output_path)
  451. assert output_path.exists()
  452. print(f"\nGespeichert: {output_path}")
  453. def test_crossfade_different_curves(self):
  454. """Testet verschiedene Fade-Kurven."""
  455. files = get_music_files()
  456. if len(files) < 2:
  457. pytest.skip("Nicht genug Musikdateien")
  458. file1, file2 = files[0], files[1]
  459. # Lade Audio
  460. audio1, _, _ = decode_mp3_to_pcm(file1, duration_ms=15000)
  461. audio2, _, _ = decode_mp3_to_pcm(file2, duration_ms=15000)
  462. for curve in ["linear", "cosine", "exponential"]:
  463. crossfaded = crossfade_audio(
  464. audio1, audio2,
  465. crossfade_ms=5000,
  466. sample_rate=SAMPLE_RATE,
  467. channels=CHANNELS,
  468. fade_curve=curve,
  469. )
  470. output_path = OUTPUT_DIR / f"crossfade_{curve}_{file1.stem}_to_{file2.stem}.wav"
  471. save_wav(crossfaded, output_path)
  472. assert output_path.exists()
  473. print(f"Gespeichert ({curve}): {output_path}")
  474. def test_crossfade_different_durations(self):
  475. """Testet verschiedene Crossfade-Dauern."""
  476. files = get_music_files()
  477. if len(files) < 2:
  478. pytest.skip("Nicht genug Musikdateien")
  479. file1, file2 = files[0], files[1]
  480. audio1, _, _ = decode_mp3_to_pcm(file1, duration_ms=20000)
  481. audio2, _, _ = decode_mp3_to_pcm(file2, duration_ms=20000)
  482. for duration_ms in [1000, 3000, 5000, 8000]:
  483. crossfaded = crossfade_audio(
  484. audio1, audio2,
  485. crossfade_ms=duration_ms,
  486. sample_rate=SAMPLE_RATE,
  487. channels=CHANNELS,
  488. )
  489. output_path = OUTPUT_DIR / f"crossfade_{duration_ms}ms_{file1.stem}_to_{file2.stem}.wav"
  490. save_wav(crossfaded, output_path)
  491. assert output_path.exists()
  492. print(f"Gespeichert ({duration_ms}ms): {output_path}")
  493. def test_crossfade_chain_three_tracks(self):
  494. """Testet Verkettung von drei Tracks mit Crossfade."""
  495. files = get_music_files()
  496. if len(files) < 3:
  497. pytest.skip("Nicht genug Musikdateien")
  498. file1, file2, file3 = files[0], files[1], files[2]
  499. # Lade Audio
  500. audio1, _, _ = decode_mp3_to_pcm(file1, duration_ms=15000)
  501. audio2, _, _ = decode_mp3_to_pcm(file2, duration_ms=15000)
  502. audio3, _, _ = decode_mp3_to_pcm(file3, duration_ms=15000)
  503. # Erste Crossfade
  504. mixed1 = crossfade_audio(
  505. audio1, audio2,
  506. crossfade_ms=4000,
  507. sample_rate=SAMPLE_RATE,
  508. channels=CHANNELS,
  509. )
  510. # Zweite Crossfade
  511. mixed2 = crossfade_audio(
  512. mixed1, audio3,
  513. crossfade_ms=4000,
  514. sample_rate=SAMPLE_RATE,
  515. channels=CHANNELS,
  516. )
  517. output_path = OUTPUT_DIR / f"crossfade_chain_{file1.stem}_{file2.stem}_{file3.stem}.wav"
  518. save_wav(mixed2, output_path)
  519. assert output_path.exists()
  520. print(f"\nGespeichert (3-Track-Chain): {output_path}")
  521. def test_create_demo_mix(self):
  522. """Erstellt einen Demo-Mix aus allen verfügbaren Tracks."""
  523. files = get_music_files()
  524. if len(files) < 2:
  525. pytest.skip("Nicht genug Musikdateien")
  526. # Maximal 4 Tracks für Demo
  527. files = files[:4]
  528. crossfade_ms = 5000
  529. segment_length = 20000 # 20 Sekunden pro Track
  530. print(f"\nErstelle Demo-Mix aus {len(files)} Tracks...")
  531. # Ersten Track laden
  532. result, _, _ = decode_mp3_to_pcm(files[0], duration_ms=segment_length)
  533. print(f" Track 1: {files[0].name}")
  534. # Weitere Tracks hinzufügen
  535. for i, file in enumerate(files[1:], 2):
  536. audio, _, _ = decode_mp3_to_pcm(file, duration_ms=segment_length)
  537. print(f" Track {i}: {file.name}")
  538. result = crossfade_audio(
  539. result, audio,
  540. crossfade_ms=crossfade_ms,
  541. sample_rate=SAMPLE_RATE,
  542. channels=CHANNELS,
  543. fade_curve="cosine",
  544. )
  545. # Speichern
  546. output_path = OUTPUT_DIR / "demo_mix.wav"
  547. save_wav(result, output_path)
  548. assert output_path.exists()
  549. # Dauer berechnen
  550. duration_seconds = len(result) / (SAMPLE_RATE * CHANNELS * 2)
  551. print(f"\nDemo-Mix gespeichert: {output_path}")
  552. print(f"Dauer: {duration_seconds:.1f} Sekunden")
  553. print(f"Größe: {len(result) / 1024 / 1024:.1f} MB")
  554. # =============================================================================
  555. # Tests für CrossfadePlugin
  556. # =============================================================================
  557. class TestCrossfadePlugin:
  558. """Tests für CrossfadePlugin."""
  559. @pytest.fixture
  560. def plugin(self, tmp_path):
  561. """Erstellt ein CrossfadePlugin."""
  562. config = {
  563. "enabled": True,
  564. "crossfade_duration_ms": 5000,
  565. "fade_curve": "linear",
  566. }
  567. return CrossfadePlugin(
  568. application=None,
  569. plugin_path=tmp_path,
  570. config=config,
  571. )
  572. @pytest.mark.asyncio
  573. async def test_plugin_load(self, plugin):
  574. """Testet Plugin-Laden."""
  575. await plugin.on_load()
  576. assert plugin.processor is not None
  577. assert plugin.processor.config.duration_ms == 5000
  578. @pytest.mark.asyncio
  579. async def test_plugin_enable_disable(self, plugin):
  580. """Testet Aktivieren/Deaktivieren."""
  581. await plugin.on_load()
  582. await plugin.on_enable()
  583. assert plugin.processor.enabled is True
  584. await plugin.on_disable()
  585. assert plugin.processor.enabled is False
  586. @pytest.mark.asyncio
  587. async def test_plugin_config_change(self, plugin):
  588. """Testet Konfigurationsänderung."""
  589. await plugin.on_load()
  590. old_config = plugin.config.copy()
  591. plugin.config["crossfade_duration_ms"] = 3000
  592. await plugin.on_config_change(old_config)
  593. assert plugin.processor.config.duration_ms == 3000
  594. @pytest.mark.asyncio
  595. async def test_plugin_unload(self, plugin):
  596. """Testet Plugin-Entladen."""
  597. await plugin.on_load()
  598. await plugin.on_unload()
  599. assert plugin.processor is None
  600. @pytest.mark.asyncio
  601. async def test_plugin_stats(self, plugin):
  602. """Testet Statistiken."""
  603. await plugin.on_load()
  604. stats = plugin.get_stats()
  605. assert "crossfade_duration_ms" in stats
  606. assert "crossfades_performed" in stats
  607. # =============================================================================
  608. # Performance-Tests
  609. # =============================================================================
  610. class TestCrossfadePerformance:
  611. """Performance-Tests für Crossfade."""
  612. def test_crossfade_processing_speed(self):
  613. """Testet Verarbeitungsgeschwindigkeit."""
  614. import time
  615. processor = CrossfadeProcessor(
  616. config=CrossfadeConfig(duration_ms=2000)
  617. )
  618. # Nächsten Track vorladen
  619. next_audio = generate_test_audio(5000)
  620. processor.preload_from_pcm(next_audio)
  621. # Mock-Track für Kontext
  622. track = MagicMock()
  623. track.duration_ms = 180000
  624. # 100 Chunks verarbeiten (je 100ms)
  625. chunks = [generate_test_audio(100) for _ in range(100)]
  626. start = time.perf_counter()
  627. for i, chunk in enumerate(chunks):
  628. # Position im Crossfade-Bereich
  629. position_ms = 178000 + (i * 10) # 178-179 Sekunden
  630. context = AudioProcessingContext(
  631. track=track,
  632. next_track=track, # Nächster Track vorhanden
  633. position_ms=position_ms,
  634. duration_ms=180000,
  635. sample_rate=SAMPLE_RATE,
  636. channels=CHANNELS,
  637. )
  638. processor.process(chunk, context)
  639. elapsed = time.perf_counter() - start
  640. print(f"\n100 Chunks verarbeitet in {elapsed*1000:.2f}ms")
  641. print(f"Durchschnitt: {elapsed*10:.2f}ms pro Chunk")
  642. # Sollte schnell genug für Echtzeit sein (< 100ms pro Chunk)
  643. assert elapsed < 1.0 # 100 Chunks in unter 1 Sekunde
  644. def test_crossfade_audio_speed(self):
  645. """Testet Geschwindigkeit der crossfade_audio() Funktion."""
  646. import time
  647. # Zwei 30-Sekunden Tracks
  648. audio1 = generate_test_audio(30000)
  649. audio2 = generate_test_audio(30000)
  650. start = time.perf_counter()
  651. result = crossfade_audio(
  652. audio1, audio2,
  653. crossfade_ms=5000,
  654. sample_rate=SAMPLE_RATE,
  655. channels=CHANNELS,
  656. )
  657. elapsed = time.perf_counter() - start
  658. print(f"\n30s + 30s Tracks mit 5s Crossfade: {elapsed*1000:.2f}ms")
  659. # Sollte schnell sein (< 1 Sekunde)
  660. assert elapsed < 1.0
  661. assert len(result) > 0