test_audio_morpher.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer trixy_core.audio_morpher.
  4. Testet:
  5. - VoiceMorphPreset (Laden, Serialisierung, from_dict/to_dict)
  6. - DSP-Funktionen (pitch_shift, speed_change, vtlp, breathiness, shimmer, fade)
  7. - Dynamik-Effekte (loud_pitch_boost, scale_pauses, loud_tempo_scale)
  8. - Erweiterte Effekte (reverb, lowpass, highpass, flanger, distortion)
  9. - AudioMorpher (Preset-Verwaltung, morph-Kette)
  10. - VoiceMorphProcessor (Pipeline-Bridge)
  11. """
  12. import json
  13. import pytest
  14. import tempfile
  15. from pathlib import Path
  16. import numpy as np
  17. from trixy_core.audio_morpher import (
  18. VoiceMorphPreset,
  19. AudioMorpher,
  20. load_presets_from_directory,
  21. # DSP
  22. pitch_shift,
  23. speed_change,
  24. vtlp,
  25. add_breathiness,
  26. add_shimmer,
  27. apply_fade,
  28. # Dynamik
  29. loud_pitch_boost,
  30. scale_pauses,
  31. loud_tempo_scale,
  32. # Effekte
  33. apply_reverb,
  34. apply_lowpass,
  35. apply_highpass,
  36. apply_flanger,
  37. apply_distortion,
  38. # Convenience
  39. apply_voice_morph,
  40. # Pipeline
  41. VoiceMorphProcessor,
  42. )
  43. # --- Fixtures ---
  44. @pytest.fixture
  45. def sine_audio():
  46. """1 Sekunde 440Hz Sinus bei 16kHz."""
  47. sr = 16000
  48. t = np.linspace(0, 1.0, sr)
  49. return np.sin(2 * np.pi * 440 * t).astype(np.float64) * 0.5
  50. @pytest.fixture
  51. def silence_audio():
  52. """1 Sekunde Stille bei 16kHz."""
  53. return np.zeros(16000, dtype=np.float64)
  54. @pytest.fixture
  55. def preset_dir(tmp_path):
  56. """Temporaeres Verzeichnis mit Test-Presets."""
  57. presets = [
  58. {"preset_id": "test_pitch", "label": "Test Pitch",
  59. "pitch": {"semitones": 3.0}},
  60. {"preset_id": "test_full", "label": "Test Voll",
  61. "pitch": {"semitones": -2.0}, "speed": {"factor": 0.9},
  62. "breathiness": {"amount": 0.05}, "shimmer": {"amount": 0.01},
  63. "formant": {"shift": 0.02},
  64. "dynamics": {"pause_scale": 1.2, "loud_pitch_boost": 0.3},
  65. "trim": {"ltrim": 0.01, "rtrim": 0.02}},
  66. ]
  67. (tmp_path / "test.json").write_text(json.dumps(presets))
  68. return tmp_path
  69. # --- VoiceMorphPreset ---
  70. class TestVoiceMorphPreset:
  71. def test_from_dict_minimal(self):
  72. p = VoiceMorphPreset.from_dict({"preset_id": "x", "label": "X"})
  73. assert p.preset_id == "x"
  74. assert p.pitch_semitones == 0.0
  75. assert p.speed_factor == 1.0
  76. assert p.breathiness == 0.0
  77. def test_from_dict_full(self):
  78. data = {
  79. "preset_id": "test", "label": "Test",
  80. "pitch": {"semitones": 4.0},
  81. "speed": {"factor": 0.95},
  82. "formant": {"shift": -0.03},
  83. "breathiness": {"amount": 0.04},
  84. "shimmer": {"amount": 0.015},
  85. "dynamics": {"pause_scale": 1.3, "loud_pitch_boost": 0.5, "loud_tempo_scale": 0.9},
  86. "trim": {"ltrim": 0.01, "rtrim": 0.04},
  87. "reverb": {"room_size": 0.5, "damping": 0.4, "wet": 0.3},
  88. "lowpass": {"cutoff": 3000, "order": 3},
  89. "highpass": {"cutoff": 200},
  90. "flanger": {"rate": 0.5, "depth": 0.003, "wet": 0.2},
  91. "distortion": {"gain": 2.0, "threshold": 0.6},
  92. }
  93. p = VoiceMorphPreset.from_dict(data)
  94. assert p.pitch_semitones == 4.0
  95. assert p.speed_factor == 0.95
  96. assert p.formant_extra == -0.03
  97. assert p.breathiness == 0.04
  98. assert p.shimmer == 0.015
  99. assert p.pause_scale == 1.3
  100. assert p.loud_pitch_boost == 0.5
  101. assert p.loud_tempo_scale == 0.9
  102. assert p.ltrim == 0.01
  103. assert p.rtrim == 0.04
  104. assert p.reverb_wet == 0.3
  105. assert p.reverb_room_size == 0.5
  106. assert p.lowpass_cutoff == 3000
  107. assert p.lowpass_order == 3
  108. assert p.highpass_cutoff == 200
  109. assert p.flanger_wet == 0.2
  110. assert p.distortion_gain == 2.0
  111. def test_to_dict_roundtrip(self):
  112. data = {
  113. "preset_id": "rt", "label": "Roundtrip",
  114. "pitch": {"semitones": -2.5},
  115. "breathiness": {"amount": 0.03},
  116. "trim": {"rtrim": 0.04},
  117. }
  118. p = VoiceMorphPreset.from_dict(data)
  119. d = p.to_dict()
  120. p2 = VoiceMorphPreset.from_dict(d)
  121. assert p2.preset_id == p.preset_id
  122. assert abs(p2.pitch_semitones - p.pitch_semitones) < 1e-6
  123. assert abs(p2.breathiness - p.breathiness) < 1e-6
  124. assert abs(p2.rtrim - p.rtrim) < 1e-6
  125. def test_unknown_keys_in_extra(self):
  126. data = {"preset_id": "x", "label": "X", "future_effect": {"amount": 0.5}}
  127. p = VoiceMorphPreset.from_dict(data)
  128. assert "future_effect" in p._extra
  129. def test_to_dict_omits_defaults(self):
  130. p = VoiceMorphPreset(preset_id="x", label="X")
  131. d = p.to_dict()
  132. assert "pitch" not in d
  133. assert "speed" not in d
  134. assert "breathiness" not in d
  135. # --- Preset-Laden ---
  136. class TestLoadPresets:
  137. def test_load_from_directory(self, preset_dir):
  138. presets = load_presets_from_directory(preset_dir)
  139. assert len(presets) == 2
  140. ids = [p.preset_id for p in presets]
  141. assert "test_pitch" in ids
  142. assert "test_full" in ids
  143. def test_load_nonexistent_dir(self):
  144. presets = load_presets_from_directory("/nonexistent/path")
  145. assert presets == []
  146. def test_load_default_presets(self):
  147. morpher = AudioMorpher()
  148. assert len(morpher.presets) > 0
  149. ids = morpher.preset_ids
  150. assert "deeper" in ids
  151. assert "brighter" in ids
  152. # --- DSP-Funktionen ---
  153. class TestDSP:
  154. def test_pitch_shift_up(self, sine_audio):
  155. result = pitch_shift(sine_audio, 16000, 3.0)
  156. assert len(result) < len(sine_audio) # Hoeher = kuerzer
  157. assert result.dtype == np.float64
  158. def test_pitch_shift_down(self, sine_audio):
  159. result = pitch_shift(sine_audio, 16000, -3.0)
  160. assert len(result) > len(sine_audio) # Tiefer = laenger
  161. def test_pitch_shift_zero(self, sine_audio):
  162. result = pitch_shift(sine_audio, 16000, 0.0)
  163. assert len(result) == len(sine_audio)
  164. def test_speed_change_faster(self, sine_audio):
  165. result = speed_change(sine_audio, 16000, 1.5)
  166. assert len(result) < len(sine_audio)
  167. def test_speed_change_slower(self, sine_audio):
  168. result = speed_change(sine_audio, 16000, 0.7)
  169. assert len(result) > len(sine_audio)
  170. def test_vtlp_preserves_length(self, sine_audio):
  171. result = vtlp(sine_audio, 16000, 1.1)
  172. assert len(result) == len(sine_audio)
  173. def test_vtlp_identity(self, sine_audio):
  174. result = vtlp(sine_audio, 16000, 1.0)
  175. assert len(result) == len(sine_audio)
  176. def test_breathiness_adds_noise(self, sine_audio):
  177. result = add_breathiness(sine_audio, 16000, 0.1)
  178. assert len(result) == len(sine_audio)
  179. assert not np.allclose(result, sine_audio, atol=1e-3)
  180. def test_breathiness_zero(self, sine_audio):
  181. result = add_breathiness(sine_audio, 16000, 0.0)
  182. np.testing.assert_array_equal(result, sine_audio)
  183. def test_shimmer(self, sine_audio):
  184. result = add_shimmer(sine_audio, 16000, 0.02)
  185. assert len(result) == len(sine_audio)
  186. def test_fade(self, sine_audio):
  187. result = apply_fade(sine_audio, 16000, fade_ms=15.0)
  188. assert len(result) == len(sine_audio)
  189. assert abs(result[0]) < abs(sine_audio[0]) + 1e-6 # Fade-in: leiser am Anfang
  190. assert abs(result[-1]) < 0.01 # Fade-out: nahe Null am Ende
  191. # --- Dynamik ---
  192. class TestDynamics:
  193. def test_loud_pitch_boost_silence(self, silence_audio):
  194. result = loud_pitch_boost(silence_audio, 16000, 2.0)
  195. np.testing.assert_array_almost_equal(result, silence_audio)
  196. def test_loud_pitch_boost_preserves_length(self, sine_audio):
  197. result = loud_pitch_boost(sine_audio, 16000, 1.0)
  198. assert len(result) == len(sine_audio)
  199. def test_scale_pauses_identity(self, sine_audio):
  200. result = scale_pauses(sine_audio, 16000, 1.0)
  201. assert len(result) == len(sine_audio)
  202. def test_loud_tempo_scale_identity(self, sine_audio):
  203. result = loud_tempo_scale(sine_audio, 16000, 1.0)
  204. assert len(result) == len(sine_audio)
  205. # --- Erweiterte Effekte ---
  206. class TestEffects:
  207. def test_reverb_preserves_length(self, sine_audio):
  208. result = apply_reverb(sine_audio, 16000, room_size=0.5, damping=0.5, wet=0.3)
  209. assert len(result) == len(sine_audio)
  210. def test_reverb_zero_wet(self, sine_audio):
  211. result = apply_reverb(sine_audio, 16000, wet=0.0)
  212. np.testing.assert_array_equal(result, sine_audio)
  213. def test_lowpass(self, sine_audio):
  214. result = apply_lowpass(sine_audio, 16000, cutoff=2000)
  215. assert len(result) == len(sine_audio)
  216. def test_highpass(self, sine_audio):
  217. result = apply_highpass(sine_audio, 16000, cutoff=200)
  218. assert len(result) == len(sine_audio)
  219. def test_flanger(self, sine_audio):
  220. result = apply_flanger(sine_audio, 16000, rate=0.5, depth=0.002, wet=0.3)
  221. assert len(result) == len(sine_audio)
  222. def test_distortion(self, sine_audio):
  223. result = apply_distortion(sine_audio, 16000, gain=3.0, threshold=0.5)
  224. assert len(result) == len(sine_audio)
  225. assert np.max(np.abs(result)) <= 0.5 + 1e-6 # Soft-clipped
  226. def test_distortion_identity(self, sine_audio):
  227. result = apply_distortion(sine_audio, 16000, gain=1.0, threshold=1.0)
  228. np.testing.assert_array_equal(result, sine_audio)
  229. # --- AudioMorpher ---
  230. class TestAudioMorpher:
  231. def test_load_default_presets(self):
  232. morpher = AudioMorpher()
  233. assert len(morpher.presets) >= 9 # female, male, general, effects
  234. def test_load_custom_presets(self, preset_dir):
  235. morpher = AudioMorpher(preset_dir)
  236. assert len(morpher.presets) == 2
  237. def test_get_preset(self):
  238. morpher = AudioMorpher()
  239. p = morpher.get_preset("deeper")
  240. assert p is not None
  241. assert p.pitch_semitones < 0
  242. def test_get_preset_not_found(self):
  243. morpher = AudioMorpher()
  244. assert morpher.get_preset("nonexistent") is None
  245. def test_morph_by_id(self, sine_audio):
  246. morpher = AudioMorpher()
  247. result = morpher.morph(sine_audio, 16000, "deeper")
  248. assert result is not None
  249. assert len(result) > 0
  250. def test_morph_by_preset(self, sine_audio):
  251. preset = VoiceMorphPreset(preset_id="t", label="T", pitch_semitones=2.0)
  252. morpher = AudioMorpher()
  253. result = morpher.morph(sine_audio, 16000, preset)
  254. assert len(result) < len(sine_audio) # Hoeher = kuerzer
  255. def test_morph_unknown_preset_returns_original(self, sine_audio):
  256. morpher = AudioMorpher()
  257. result = morpher.morph(sine_audio, 16000, "nonexistent")
  258. np.testing.assert_array_equal(result, sine_audio)
  259. def test_morph_zero_intensity(self, sine_audio):
  260. morpher = AudioMorpher()
  261. result = morpher.morph(sine_audio, 16000, "deeper", intensity=0.0)
  262. np.testing.assert_array_equal(result, sine_audio)
  263. def test_morph_with_rtrim(self, sine_audio):
  264. preset = VoiceMorphPreset(preset_id="t", label="T", rtrim=0.1)
  265. morpher = AudioMorpher()
  266. result = morpher.morph(sine_audio, 16000, preset)
  267. expected_trim = int(16000 * 0.1)
  268. assert len(result) < len(sine_audio)
  269. assert abs(len(sine_audio) - len(result) - expected_trim) < 500 # Fade aendert auch
  270. def test_morph_applies_fade(self, sine_audio):
  271. preset = VoiceMorphPreset(preset_id="t", label="T", pitch_semitones=1.0)
  272. morpher = AudioMorpher()
  273. result = morpher.morph(sine_audio, 16000, preset)
  274. assert abs(result[-1]) < 0.01 # Fade-out am Ende
  275. def test_morph_with_effects(self, sine_audio):
  276. preset = VoiceMorphPreset.from_dict({
  277. "preset_id": "fx", "label": "FX",
  278. "reverb": {"wet": 0.2, "room_size": 0.3},
  279. "lowpass": {"cutoff": 3000},
  280. "distortion": {"gain": 2.0, "threshold": 0.7},
  281. })
  282. morpher = AudioMorpher()
  283. result = morpher.morph(sine_audio, 16000, preset)
  284. assert len(result) > 0
  285. def test_reload_presets(self, preset_dir):
  286. morpher = AudioMorpher(preset_dir)
  287. assert len(morpher.presets) == 2
  288. # Neues Preset hinzufuegen
  289. new_presets = [{"preset_id": "new", "label": "New"}]
  290. (preset_dir / "new.json").write_text(json.dumps(new_presets))
  291. morpher.reload_presets()
  292. assert len(morpher.presets) == 3
  293. # --- Convenience-Funktion ---
  294. class TestApplyVoiceMorph:
  295. def test_apply_voice_morph(self, sine_audio):
  296. result = apply_voice_morph(sine_audio, 16000, "deeper")
  297. assert len(result) > 0
  298. assert len(result) != len(sine_audio) # deeper veraendert Laenge
  299. # --- VoiceMorphProcessor ---
  300. class TestVoiceMorphProcessor:
  301. def test_creation(self):
  302. proc = VoiceMorphProcessor(preset="brighter", intensity=0.6)
  303. assert proc.id == "voice_morph"
  304. assert proc.preset is not None
  305. assert proc.preset.preset_id == "brighter"
  306. assert proc.intensity == 0.6
  307. def test_creation_empty(self):
  308. proc = VoiceMorphProcessor()
  309. assert proc.preset is None
  310. def test_set_preset_by_id(self):
  311. proc = VoiceMorphProcessor()
  312. proc.preset = "deeper"
  313. assert proc.preset is not None
  314. assert proc.preset.preset_id == "deeper"
  315. def test_get_config(self):
  316. proc = VoiceMorphProcessor(preset="brighter", intensity=0.5)
  317. config = proc.get_config()
  318. assert config["preset"] == "brighter"
  319. assert config["intensity"] == 0.5