main.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. # -*- coding: utf-8 -*-
  2. """
  3. VoiceMorph Plugin.
  4. Morpht die TTS-Ausgabe in Echtzeit mit konfigurierbaren Presets.
  5. Klinkt sich als HIGH-Prioritaet Event-Handler in tts_completed ein
  6. und veraendert die audio_data bevor der IntentDispatcher (NORMAL) sie
  7. an den Satellite sendet.
  8. Presets koennen in der config.json definiert oder aus dem globalen
  9. Preset-Verzeichnis (assets/default/audio_morpher/) geladen werden.
  10. """
  11. from __future__ import annotations
  12. import numpy as np
  13. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  14. from trixy_core.events.enums import EventPriority
  15. from trixy_core.audio_morpher.morpher import AudioMorpher
  16. from trixy_core.audio_morpher.presets import VoiceMorphPreset
  17. from trixy_core.nlp.decorators import intent, admin_only
  18. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  19. from trixy_core.utils.debug import pinfo, pdebug, perror
  20. class VoiceMorphPlugin(TrixyPlugin):
  21. """TTS-Stimmveraenderung mit konfigurierbaren Presets."""
  22. NAME = "voice_morph"
  23. VERSION = "1.0.0"
  24. DESCRIPTION = "Morpht die TTS-Ausgabe (Pitch, Speed, Formanten, Effekte)"
  25. AUTHOR = "Trixy"
  26. def __init__(self, **kwargs) -> None:
  27. super().__init__(**kwargs)
  28. self._morpher = AudioMorpher()
  29. self._active_preset: VoiceMorphPreset | None = None
  30. self._intensity: float = 1.0
  31. self._handler_ref = None
  32. async def on_load(self) -> None:
  33. """Plugin laden — Presets initialisieren und Event-Handler registrieren."""
  34. # Eigene Presets aus config.json laden und in den Morpher einfuegen
  35. custom_presets = self.config.get("presets", {})
  36. for preset_id, preset_data in custom_presets.items():
  37. full_data = {"preset_id": preset_id, "label": preset_data.get("label", preset_id)}
  38. full_data.update(preset_data)
  39. preset = VoiceMorphPreset.from_dict(full_data)
  40. self._morpher._presets.append(preset)
  41. self._morpher._preset_map[preset_id] = preset
  42. # Intensitaet laden
  43. self._intensity = self.config.get("intensity", 1.0)
  44. # Aktives Preset setzen
  45. active_id = self.config.get("active_preset", "")
  46. if active_id:
  47. self._active_preset = self._morpher.get_preset(active_id)
  48. if self._active_preset:
  49. pinfo(f"[VoiceMorph] Aktives Preset: {self._active_preset.label} (ID: {active_id})")
  50. else:
  51. pdebug(f"[VoiceMorph] Preset '{active_id}' nicht gefunden — deaktiviert")
  52. # Event-Handler mit HIGH-Prioritaet registrieren
  53. # HIGH = vor IntentDispatcher (NORMAL), damit audio_data gemorph wird
  54. # bevor sie an den Satellite gesendet wird
  55. self._handler_ref = self._on_tts_completed
  56. self.application.events.register(
  57. "tts_completed",
  58. self._handler_ref,
  59. priority=EventPriority.HIGH,
  60. )
  61. preset_count = len(self._morpher.presets)
  62. status = self._active_preset.label if self._active_preset else "deaktiviert"
  63. pinfo(f"[VoiceMorph] Plugin geladen — {preset_count} Presets, Status: {status}")
  64. async def on_unload(self) -> None:
  65. """Plugin entladen — Event-Handler entfernen."""
  66. if self._handler_ref:
  67. try:
  68. self.application.events.unregister("tts_completed", self._handler_ref)
  69. except Exception:
  70. pass
  71. pdebug("[VoiceMorph] Plugin entladen")
  72. async def _on_tts_completed(self, event_name: str, event_data) -> None:
  73. """Morpht TTS-Audio in-place bevor der IntentDispatcher es sendet."""
  74. if not self._active_preset:
  75. return
  76. audio_hex = event_data.get("audio_data", "")
  77. if not audio_hex:
  78. return
  79. sample_rate = event_data.get("sample_rate", 22050)
  80. try:
  81. # Hex → Bytes → int16 numpy array
  82. audio_bytes = bytes.fromhex(audio_hex)
  83. audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float64)
  84. audio /= 32768.0 # Normalisieren auf [-1.0, 1.0]
  85. # Morph anwenden
  86. morphed = self._morpher.morph(
  87. audio, sample_rate, self._active_preset, self._intensity
  88. )
  89. # Zurueck in int16
  90. morphed = np.clip(morphed, -1.0, 1.0)
  91. morphed_int16 = (morphed * 32767).astype(np.int16)
  92. # In-place ersetzen im Event-Dict
  93. event_data["audio_data"] = morphed_int16.tobytes().hex()
  94. pdebug(f"[VoiceMorph] Audio gemorph: {len(audio)} → {len(morphed)} samples "
  95. f"(Preset: {self._active_preset.preset_id})")
  96. except Exception as e:
  97. perror(f"[VoiceMorph] Morph-Fehler: {e}")
  98. # =========================================================================
  99. # Intent-Handler
  100. # =========================================================================
  101. @intent("voice_morph_enable", description="Voice Morph aktivieren")
  102. @admin_only
  103. async def handle_enable(self, data: IntentReceivedData) -> IntentResult:
  104. """Aktiviert den Voice Morph mit dem konfigurierten Preset."""
  105. preset_id = self.config.get("active_preset", "")
  106. if not preset_id:
  107. return IntentResult.failure("Kein Preset konfiguriert.",
  108. "Es ist kein Voice Morph Preset konfiguriert.")
  109. preset = self._morpher.get_preset(preset_id)
  110. if not preset:
  111. return IntentResult.failure("Preset nicht gefunden.",
  112. f"Preset '{preset_id}' wurde nicht gefunden.")
  113. self._active_preset = preset
  114. return IntentResult.success_with_response(
  115. f"Voice Morph aktiviert mit Preset {preset.label}."
  116. )
  117. @intent("voice_morph_disable", description="Voice Morph deaktivieren")
  118. @admin_only
  119. async def handle_disable(self, data: IntentReceivedData) -> IntentResult:
  120. """Deaktiviert den Voice Morph."""
  121. self._active_preset = None
  122. return IntentResult.success_with_response("Voice Morph deaktiviert. Normale Stimme aktiv.")
  123. @intent("voice_morph_set_preset", description="Voice Morph Preset wechseln")
  124. @admin_only
  125. async def handle_set_preset(self, data: IntentReceivedData, preset_name: str = "") -> IntentResult:
  126. """Wechselt das aktive Preset."""
  127. if not preset_name:
  128. return IntentResult.failure("Kein Preset angegeben.", "Bitte einen Preset-Namen angeben.")
  129. # Suche nach ID oder Label (case-insensitive)
  130. found = None
  131. for preset in self._morpher.presets:
  132. if preset.preset_id.lower() == preset_name.lower():
  133. found = preset
  134. break
  135. if preset.label.lower() == preset_name.lower():
  136. found = preset
  137. break
  138. if not found:
  139. available = ", ".join(p.label for p in self._morpher.presets)
  140. return IntentResult.failure(
  141. "Preset nicht gefunden.",
  142. f"Preset '{preset_name}' nicht gefunden. Verfuegbar: {available}."
  143. )
  144. self._active_preset = found
  145. self.set_config_value("active_preset", found.preset_id)
  146. self.save_config()
  147. return IntentResult.success_with_response(f"Voice Morph gewechselt auf {found.label}.")
  148. @intent("voice_morph_status", description="Voice Morph Status abfragen")
  149. @admin_only
  150. async def handle_status(self, data: IntentReceivedData) -> IntentResult:
  151. """Gibt den aktuellen Voice Morph Status zurueck."""
  152. if self._active_preset:
  153. intensity_pct = int(self._intensity * 100)
  154. return IntentResult.success_with_response(
  155. f"Voice Morph ist aktiv mit Preset {self._active_preset.label}, "
  156. f"Intensitaet {intensity_pct} Prozent."
  157. )
  158. return IntentResult.success_with_response("Voice Morph ist deaktiviert.")
  159. @intent("voice_morph_list_presets", description="Verfuegbare Presets auflisten")
  160. @admin_only
  161. async def handle_list_presets(self, data: IntentReceivedData) -> IntentResult:
  162. """Listet alle verfuegbaren Presets auf."""
  163. if not self._morpher.presets:
  164. return IntentResult.success_with_response("Keine Voice Morph Presets verfuegbar.")
  165. names = [p.label for p in self._morpher.presets]
  166. return IntentResult.success_with_response(
  167. f"Verfuegbare Presets: {', '.join(names)}."
  168. )
  169. @intent("voice_morph_set_intensity", description="Voice Morph Intensitaet setzen")
  170. @admin_only
  171. async def handle_set_intensity(self, data: IntentReceivedData, value: str = "") -> IntentResult:
  172. """Setzt die Intensitaet des Voice Morph."""
  173. if not value:
  174. return IntentResult.failure("Kein Wert angegeben.", "Bitte einen Wert angeben.")
  175. try:
  176. # "80 Prozent" → 80, "0.8" → 0.8
  177. clean = value.lower().replace("prozent", "").replace("%", "").strip()
  178. num = float(clean)
  179. if num > 1.0:
  180. num /= 100.0 # 80 → 0.8
  181. num = max(0.0, min(1.0, num))
  182. except ValueError:
  183. return IntentResult.failure("Ungueltiger Wert.", f"'{value}' ist kein gueltiger Prozentwert.")
  184. self._intensity = num
  185. self.set_config_value("intensity", num)
  186. self.save_config()
  187. return IntentResult.success_with_response(
  188. f"Voice Morph Intensitaet auf {int(num * 100)} Prozent gesetzt."
  189. )