test_voice_morph.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer das VoiceMorph Plugin.
  4. """
  5. import asyncio
  6. import numpy as np
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  11. from trixy_core.audio_morpher.presets import VoiceMorphPreset
  12. from plugins.voice_morph.main import VoiceMorphPlugin
  13. # ============================================================================
  14. # Fixtures
  15. # ============================================================================
  16. def _make_plugin(tmp_path, config_overrides=None) -> tuple[VoiceMorphPlugin, MagicMock]:
  17. """Erstellt ein VoiceMorphPlugin mit Mock-Application."""
  18. mock_app = MagicMock()
  19. mock_app.events = MagicMock()
  20. mock_app.events.emit = AsyncMock()
  21. mock_app.events.trigger = AsyncMock()
  22. mock_app.events.register = MagicMock()
  23. mock_app.events.unregister = MagicMock()
  24. mock_app.debug = True
  25. mock_app.services = MagicMock()
  26. mock_app.services.get_service = MagicMock(return_value=None)
  27. config = {
  28. "enabled": True,
  29. "active_preset": "test_preset",
  30. "intensity": 1.0,
  31. "presets": {
  32. "test_preset": {
  33. "label": "Test Preset",
  34. "pitch": {"semitones": 4.0},
  35. "speed": {"factor": 0.95},
  36. },
  37. "robot": {
  38. "label": "Robot Stimme",
  39. "pitch": {"semitones": -2.0},
  40. "distortion": {"gain": 1.5},
  41. },
  42. },
  43. }
  44. if config_overrides:
  45. config.update(config_overrides)
  46. plugin_path = tmp_path / "voice_morph"
  47. plugin_path.mkdir(exist_ok=True)
  48. plugin = VoiceMorphPlugin(
  49. application=mock_app,
  50. plugin_path=plugin_path,
  51. config=config,
  52. )
  53. return plugin, mock_app
  54. def _make_data(**kwargs) -> IntentReceivedData:
  55. """Erstellt IntentReceivedData mit Defaults."""
  56. defaults = {
  57. "intent": "voice_morph_status",
  58. "satellite_id": "sat-1",
  59. "room_id": "wohnzimmer",
  60. "is_authenticated": True,
  61. }
  62. defaults.update(kwargs)
  63. return IntentReceivedData(**defaults)
  64. def _make_sine_audio(freq: float = 440.0, duration: float = 0.1,
  65. sample_rate: int = 22050) -> bytes:
  66. """Generiert Sinus-Audio als int16 hex-encoded bytes."""
  67. t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
  68. audio = (np.sin(2 * np.pi * freq * t) * 16000).astype(np.int16)
  69. return audio.tobytes().hex()
  70. # ============================================================================
  71. # Tests: Plugin Lifecycle
  72. # ============================================================================
  73. class TestLifecycle:
  74. """Tests fuer on_load und on_unload."""
  75. @pytest.mark.asyncio
  76. async def test_on_load_registers_handler(self, tmp_path):
  77. """on_load registriert tts_completed Event-Handler."""
  78. plugin, mock_app = _make_plugin(tmp_path)
  79. await plugin.on_load()
  80. mock_app.events.register.assert_called_once()
  81. call_args = mock_app.events.register.call_args
  82. assert call_args[0][0] == "tts_completed"
  83. @pytest.mark.asyncio
  84. async def test_on_load_sets_active_preset(self, tmp_path):
  85. """on_load setzt das konfigurierte Preset."""
  86. plugin, _ = _make_plugin(tmp_path)
  87. await plugin.on_load()
  88. assert plugin._active_preset is not None
  89. assert plugin._active_preset.preset_id == "test_preset"
  90. assert plugin._active_preset.label == "Test Preset"
  91. @pytest.mark.asyncio
  92. async def test_on_load_unknown_preset(self, tmp_path):
  93. """on_load mit unbekanntem Preset bleibt deaktiviert."""
  94. plugin, _ = _make_plugin(tmp_path, {"active_preset": "nonexistent"})
  95. await plugin.on_load()
  96. assert plugin._active_preset is None
  97. @pytest.mark.asyncio
  98. async def test_on_load_no_preset(self, tmp_path):
  99. """on_load ohne active_preset bleibt deaktiviert."""
  100. plugin, _ = _make_plugin(tmp_path, {"active_preset": ""})
  101. await plugin.on_load()
  102. assert plugin._active_preset is None
  103. @pytest.mark.asyncio
  104. async def test_on_load_custom_intensity(self, tmp_path):
  105. """on_load laedt Intensitaet aus Config."""
  106. plugin, _ = _make_plugin(tmp_path, {"intensity": 0.75})
  107. await plugin.on_load()
  108. assert plugin._intensity == 0.75
  109. @pytest.mark.asyncio
  110. async def test_on_unload(self, tmp_path):
  111. """on_unload entfernt Event-Handler."""
  112. plugin, mock_app = _make_plugin(tmp_path)
  113. await plugin.on_load()
  114. await plugin.on_unload()
  115. mock_app.events.unregister.assert_called_once()
  116. @pytest.mark.asyncio
  117. async def test_loads_custom_presets(self, tmp_path):
  118. """on_load laedt Presets aus config.json."""
  119. plugin, _ = _make_plugin(tmp_path)
  120. await plugin.on_load()
  121. assert plugin._morpher.get_preset("test_preset") is not None
  122. assert plugin._morpher.get_preset("robot") is not None
  123. # ============================================================================
  124. # Tests: Audio Morphing
  125. # ============================================================================
  126. class TestAudioMorphing:
  127. """Tests fuer den _on_tts_completed Handler."""
  128. @pytest.mark.asyncio
  129. async def test_morph_modifies_audio(self, tmp_path):
  130. """Morphing veraendert die Audio-Daten."""
  131. plugin, _ = _make_plugin(tmp_path)
  132. await plugin.on_load()
  133. original_hex = _make_sine_audio(440.0, 0.5, 22050)
  134. event_data = {
  135. "audio_data": original_hex,
  136. "sample_rate": 22050,
  137. "satellite_id": "sat-1",
  138. }
  139. await plugin._on_tts_completed("tts_completed", event_data)
  140. # Audio wurde veraendert
  141. assert event_data["audio_data"] != original_hex
  142. # Ist immer noch gueltiges Hex
  143. bytes.fromhex(event_data["audio_data"])
  144. @pytest.mark.asyncio
  145. async def test_morph_skipped_when_disabled(self, tmp_path):
  146. """Kein Morphing wenn kein Preset aktiv."""
  147. plugin, _ = _make_plugin(tmp_path, {"active_preset": ""})
  148. await plugin.on_load()
  149. original_hex = _make_sine_audio()
  150. event_data = {"audio_data": original_hex, "sample_rate": 22050}
  151. await plugin._on_tts_completed("tts_completed", event_data)
  152. assert event_data["audio_data"] == original_hex
  153. @pytest.mark.asyncio
  154. async def test_morph_empty_audio(self, tmp_path):
  155. """Leeres Audio wird ignoriert."""
  156. plugin, _ = _make_plugin(tmp_path)
  157. await plugin.on_load()
  158. event_data = {"audio_data": "", "sample_rate": 22050}
  159. await plugin._on_tts_completed("tts_completed", event_data)
  160. assert event_data["audio_data"] == ""
  161. @pytest.mark.asyncio
  162. async def test_morph_preserves_other_fields(self, tmp_path):
  163. """Morphing veraendert nur audio_data, nicht andere Felder."""
  164. plugin, _ = _make_plugin(tmp_path)
  165. await plugin.on_load()
  166. event_data = {
  167. "audio_data": _make_sine_audio(),
  168. "sample_rate": 22050,
  169. "satellite_id": "sat-1",
  170. "provider": "piper",
  171. "text": "Hallo Welt",
  172. }
  173. await plugin._on_tts_completed("tts_completed", event_data)
  174. assert event_data["satellite_id"] == "sat-1"
  175. assert event_data["provider"] == "piper"
  176. assert event_data["text"] == "Hallo Welt"
  177. assert event_data["sample_rate"] == 22050
  178. # ============================================================================
  179. # Tests: Intent-Handler
  180. # ============================================================================
  181. class TestIntents:
  182. """Tests fuer die 6 Voice Morph Intent-Handler."""
  183. @pytest.mark.asyncio
  184. async def test_enable(self, tmp_path):
  185. """voice_morph_enable aktiviert das Preset."""
  186. plugin, _ = _make_plugin(tmp_path)
  187. await plugin.on_load()
  188. plugin._active_preset = None # Erst deaktivieren
  189. result = await plugin.handle_enable(_make_data())
  190. assert result.success is True
  191. assert plugin._active_preset is not None
  192. assert "Test Preset" in result.response_text
  193. @pytest.mark.asyncio
  194. async def test_enable_no_preset_configured(self, tmp_path):
  195. """voice_morph_enable ohne konfiguriertes Preset."""
  196. plugin, _ = _make_plugin(tmp_path, {"active_preset": ""})
  197. await plugin.on_load()
  198. result = await plugin.handle_enable(_make_data())
  199. assert result.success is False
  200. @pytest.mark.asyncio
  201. async def test_disable(self, tmp_path):
  202. """voice_morph_disable deaktiviert das Morphing."""
  203. plugin, _ = _make_plugin(tmp_path)
  204. await plugin.on_load()
  205. result = await plugin.handle_disable(_make_data())
  206. assert result.success is True
  207. assert plugin._active_preset is None
  208. assert "deaktiviert" in result.response_text.lower()
  209. @pytest.mark.asyncio
  210. async def test_status_active(self, tmp_path):
  211. """voice_morph_status zeigt aktives Preset."""
  212. plugin, _ = _make_plugin(tmp_path)
  213. await plugin.on_load()
  214. result = await plugin.handle_status(_make_data())
  215. assert result.success is True
  216. assert "Test Preset" in result.response_text
  217. assert "100" in result.response_text # 100 Prozent
  218. @pytest.mark.asyncio
  219. async def test_status_inactive(self, tmp_path):
  220. """voice_morph_status zeigt deaktiviert."""
  221. plugin, _ = _make_plugin(tmp_path)
  222. await plugin.on_load()
  223. plugin._active_preset = None
  224. result = await plugin.handle_status(_make_data())
  225. assert result.success is True
  226. assert "deaktiviert" in result.response_text.lower()
  227. @pytest.mark.asyncio
  228. async def test_set_preset_by_id(self, tmp_path):
  229. """voice_morph_set_preset wechselt Preset per ID."""
  230. plugin, _ = _make_plugin(tmp_path)
  231. await plugin.on_load()
  232. plugin.save_config = MagicMock()
  233. data = _make_data()
  234. result = await plugin.handle_set_preset(data, preset_name="robot")
  235. assert result.success is True
  236. assert plugin._active_preset.preset_id == "robot"
  237. @pytest.mark.asyncio
  238. async def test_set_preset_by_label(self, tmp_path):
  239. """voice_morph_set_preset wechselt Preset per Label."""
  240. plugin, _ = _make_plugin(tmp_path)
  241. await plugin.on_load()
  242. plugin.save_config = MagicMock()
  243. result = await plugin.handle_set_preset(_make_data(), preset_name="Robot Stimme")
  244. assert result.success is True
  245. assert plugin._active_preset.preset_id == "robot"
  246. @pytest.mark.asyncio
  247. async def test_set_preset_not_found(self, tmp_path):
  248. """voice_morph_set_preset mit unbekanntem Preset."""
  249. plugin, _ = _make_plugin(tmp_path)
  250. await plugin.on_load()
  251. result = await plugin.handle_set_preset(_make_data(), preset_name="nonexistent")
  252. assert result.success is False
  253. @pytest.mark.asyncio
  254. async def test_set_preset_no_name(self, tmp_path):
  255. """voice_morph_set_preset ohne Preset-Name."""
  256. plugin, _ = _make_plugin(tmp_path)
  257. await plugin.on_load()
  258. result = await plugin.handle_set_preset(_make_data(), preset_name="")
  259. assert result.success is False
  260. @pytest.mark.asyncio
  261. async def test_list_presets(self, tmp_path):
  262. """voice_morph_list_presets listet alle Presets."""
  263. plugin, _ = _make_plugin(tmp_path)
  264. await plugin.on_load()
  265. result = await plugin.handle_list_presets(_make_data())
  266. assert result.success is True
  267. assert "Test Preset" in result.response_text
  268. assert "Robot Stimme" in result.response_text
  269. @pytest.mark.asyncio
  270. async def test_set_intensity_percent(self, tmp_path):
  271. """voice_morph_set_intensity mit Prozentwert."""
  272. plugin, _ = _make_plugin(tmp_path)
  273. await plugin.on_load()
  274. plugin.save_config = MagicMock()
  275. result = await plugin.handle_set_intensity(_make_data(), value="80 Prozent")
  276. assert result.success is True
  277. assert abs(plugin._intensity - 0.8) < 0.01
  278. assert "80" in result.response_text
  279. @pytest.mark.asyncio
  280. async def test_set_intensity_decimal(self, tmp_path):
  281. """voice_morph_set_intensity mit Dezimalwert."""
  282. plugin, _ = _make_plugin(tmp_path)
  283. await plugin.on_load()
  284. plugin.save_config = MagicMock()
  285. result = await plugin.handle_set_intensity(_make_data(), value="0.5")
  286. assert result.success is True
  287. assert abs(plugin._intensity - 0.5) < 0.01
  288. @pytest.mark.asyncio
  289. async def test_set_intensity_clamps(self, tmp_path):
  290. """voice_morph_set_intensity clampt auf 0-100%."""
  291. plugin, _ = _make_plugin(tmp_path)
  292. await plugin.on_load()
  293. plugin.save_config = MagicMock()
  294. result = await plugin.handle_set_intensity(_make_data(), value="150")
  295. assert result.success is True
  296. assert plugin._intensity == 1.0
  297. @pytest.mark.asyncio
  298. async def test_set_intensity_invalid(self, tmp_path):
  299. """voice_morph_set_intensity mit ungueltigem Wert."""
  300. plugin, _ = _make_plugin(tmp_path)
  301. await plugin.on_load()
  302. result = await plugin.handle_set_intensity(_make_data(), value="abc")
  303. assert result.success is False
  304. @pytest.mark.asyncio
  305. async def test_set_intensity_empty(self, tmp_path):
  306. """voice_morph_set_intensity ohne Wert."""
  307. plugin, _ = _make_plugin(tmp_path)
  308. await plugin.on_load()
  309. result = await plugin.handle_set_intensity(_make_data(), value="")
  310. assert result.success is False