main.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. """
  2. Text-to-Speech (TTS) Plugin for Trixy Application
  3. This plugin demonstrates TTS integration with the Trixy plugin system:
  4. - Audio synthesis and voice generation
  5. - Integration with various TTS engines (OpenAI TTS, Google TTS, Azure, etc.)
  6. - Voice selection and customization
  7. - Speech rate, pitch, and volume control
  8. - SSML (Speech Synthesis Markup Language) support
  9. - Audio output format control
  10. - Batch processing and streaming synthesis
  11. - Voice caching and optimization
  12. - Multi-language support
  13. - Custom voice models and training
  14. This serves as a reference for implementing TTS functionality in Trixy.
  15. Features:
  16. - Multiple TTS engine support
  17. - Voice customization and selection
  18. - SSML markup support
  19. - Audio format control
  20. - Streaming and batch synthesis
  21. - Voice caching
  22. - Performance optimization
  23. - Multi-language support
  24. """
  25. import os
  26. import time
  27. import threading
  28. import queue
  29. import tempfile
  30. import hashlib
  31. from typing import Any, Dict, Optional, List, Tuple, Union
  32. from pathlib import Path
  33. from dataclasses import dataclass
  34. # Import plugin base class and event decorator
  35. from trixy_core.plugins import TrixyPlugin
  36. from trixy_core.events import TrixyEvent
  37. def pprint(message: str) -> None:
  38. """TTS plugin logging function."""
  39. print(f"[TTS_PLUGIN] {message}")
  40. @dataclass
  41. class Voice:
  42. """Voice configuration."""
  43. name: str
  44. gender: str = "neutral"
  45. language: str = "en"
  46. region: str = "US"
  47. age: str = "adult"
  48. style: str = "neutral"
  49. engine: str = "default"
  50. @dataclass
  51. class TTSRequest:
  52. """Text-to-speech synthesis request."""
  53. text: str
  54. voice: Voice
  55. conversation_id: str = ""
  56. speed: float = 1.0
  57. pitch: float = 1.0
  58. volume: float = 1.0
  59. output_format: str = "wav"
  60. use_ssml: bool = False
  61. timestamp: float = 0.0
  62. def __post_init__(self):
  63. if self.timestamp == 0.0:
  64. self.timestamp = time.time()
  65. class TTSResult:
  66. """Text-to-speech synthesis result."""
  67. def __init__(
  68. self,
  69. audio_data: bytes,
  70. text: str,
  71. voice: Voice,
  72. processing_time: float = 0.0,
  73. output_format: str = "wav",
  74. sample_rate: int = 16000,
  75. success: bool = True,
  76. error_message: str = ""
  77. ):
  78. self.audio_data = audio_data
  79. self.text = text
  80. self.voice = voice
  81. self.processing_time = processing_time
  82. self.output_format = output_format
  83. self.sample_rate = sample_rate
  84. self.success = success
  85. self.error_message = error_message
  86. self.timestamp = time.time()
  87. class TTSPlugin(TrixyPlugin):
  88. """
  89. Text-to-Speech plugin for Trixy application.
  90. This plugin provides TTS functionality with support for:
  91. - Multiple TTS engines (OpenAI TTS, Google TTS, Azure Speech, etc.)
  92. - Voice selection and customization
  93. - SSML markup support
  94. - Audio format control and optimization
  95. - Streaming and batch synthesis
  96. - Voice caching and performance optimization
  97. - Multi-language support
  98. """
  99. def initialize(self) -> None:
  100. """Initialize the TTS plugin."""
  101. pprint("Initializing TTS Plugin...")
  102. # Plugin state
  103. self._synthesis_queue = queue.Queue()
  104. self._synthesis_thread: Optional[threading.Thread] = None
  105. self._synthesis_shutdown = threading.Event()
  106. self._active_requests: Dict[str, TTSRequest] = {}
  107. # Voice cache
  108. self._voice_cache: Dict[str, bytes] = {}
  109. self._cache_lock = threading.RLock()
  110. # Statistics
  111. self._stats = {
  112. 'total_requests': 0,
  113. 'successful_syntheses': 0,
  114. 'failed_syntheses': 0,
  115. 'total_processing_time': 0.0,
  116. 'average_processing_time': 0.0,
  117. 'cache_hits': 0,
  118. 'total_audio_generated': 0, # in bytes
  119. 'voices_used': set(),
  120. 'languages_used': set()
  121. }
  122. # Setup configuration
  123. self._setup_configuration()
  124. # Initialize available voices
  125. self._initialize_voices()
  126. # Initialize TTS engines
  127. self._initialize_engines()
  128. # Start synthesis thread
  129. self._start_synthesis_thread()
  130. pprint(f"TTS Plugin initialized with {len(self._voices)} voices and {len(self._engines)} engines")
  131. def _setup_configuration(self) -> None:
  132. """Setup plugin configuration."""
  133. # Engine configuration
  134. self.primary_engine = self.get_config_value("primary_engine", "openai", str)
  135. self.fallback_engine = self.get_config_value("fallback_engine", "google", str)
  136. self.engines_enabled = self.get_config_value("engines_enabled", ["openai", "google", "azure"], list)
  137. # Voice configuration
  138. self.default_voice_name = self.get_config_value("default_voice", "alloy", str)
  139. self.default_language = self.get_config_value("default_language", "en", str)
  140. self.default_gender = self.get_config_value("default_gender", "neutral", str)
  141. # Audio configuration
  142. self.output_format = self.get_config_value("output_format", "wav", str)
  143. self.sample_rate = self.get_config_value("sample_rate", 16000, int)
  144. self.bit_depth = self.get_config_value("bit_depth", 16, int)
  145. self.channels = self.get_config_value("channels", 1, int)
  146. # Synthesis parameters
  147. self.default_speed = self.get_config_value("default_speed", 1.0, float)
  148. self.default_pitch = self.get_config_value("default_pitch", 1.0, float)
  149. self.default_volume = self.get_config_value("default_volume", 1.0, float)
  150. # Processing configuration
  151. self.max_text_length = self.get_config_value("max_text_length", 4000, int)
  152. self.enable_caching = self.get_config_value("enable_caching", True, bool)
  153. self.enable_ssml = self.get_config_value("enable_ssml", True, bool)
  154. self.processing_timeout = self.get_config_value("processing_timeout", 30.0, float)
  155. # Performance configuration
  156. self.max_concurrent_requests = self.get_config_value("max_concurrent_requests", 2, int)
  157. self.cache_size_mb = self.get_config_value("cache_size_mb", 50, int)
  158. self.enable_streaming = self.get_config_value("enable_streaming", False, bool)
  159. # Validation
  160. if self.default_speed <= 0 or self.default_speed > 3.0:
  161. pprint("Warning: default_speed should be between 0.1 and 3.0")
  162. self.default_speed = max(0.1, min(3.0, self.default_speed))
  163. if self.max_text_length <= 0:
  164. pprint("Warning: max_text_length must be positive")
  165. self.max_text_length = 4000
  166. def _initialize_voices(self) -> None:
  167. """Initialize available voices."""
  168. self._voices: Dict[str, Voice] = {}
  169. # OpenAI voices
  170. openai_voices = [
  171. Voice("alloy", "neutral", "en", "US", "adult", "neutral", "openai"),
  172. Voice("echo", "male", "en", "US", "adult", "neutral", "openai"),
  173. Voice("fable", "female", "en", "UK", "adult", "calm", "openai"),
  174. Voice("onyx", "male", "en", "US", "adult", "deep", "openai"),
  175. Voice("nova", "female", "en", "US", "young", "bright", "openai"),
  176. Voice("shimmer", "female", "en", "US", "adult", "warm", "openai")
  177. ]
  178. # Google voices
  179. google_voices = [
  180. Voice("en-US-Standard-A", "female", "en", "US", "adult", "standard", "google"),
  181. Voice("en-US-Standard-B", "male", "en", "US", "adult", "standard", "google"),
  182. Voice("en-US-Standard-C", "female", "en", "US", "adult", "standard", "google"),
  183. Voice("en-US-Standard-D", "male", "en", "US", "adult", "standard", "google"),
  184. Voice("en-US-Wavenet-A", "female", "en", "US", "adult", "wavenet", "google"),
  185. Voice("en-US-Wavenet-B", "male", "en", "US", "adult", "wavenet", "google")
  186. ]
  187. # Azure voices
  188. azure_voices = [
  189. Voice("en-US-AriaNeural", "female", "en", "US", "adult", "neutral", "azure"),
  190. Voice("en-US-DavisNeural", "male", "en", "US", "adult", "neutral", "azure"),
  191. Voice("en-US-JennyNeural", "female", "en", "US", "adult", "assistant", "azure"),
  192. Voice("en-US-GuyNeural", "male", "en", "US", "adult", "neutral", "azure")
  193. ]
  194. # Add all voices to the registry
  195. for voice_list in [openai_voices, google_voices, azure_voices]:
  196. for voice in voice_list:
  197. self._voices[voice.name] = voice
  198. # Load custom voices from config
  199. custom_voices = self.get_config_value("custom_voices", [], list)
  200. for voice_config in custom_voices:
  201. if isinstance(voice_config, dict) and "name" in voice_config:
  202. voice = Voice(
  203. name=voice_config["name"],
  204. gender=voice_config.get("gender", "neutral"),
  205. language=voice_config.get("language", "en"),
  206. region=voice_config.get("region", "US"),
  207. age=voice_config.get("age", "adult"),
  208. style=voice_config.get("style", "neutral"),
  209. engine=voice_config.get("engine", "custom")
  210. )
  211. self._voices[voice.name] = voice
  212. pprint(f"Initialized {len(self._voices)} voices")
  213. def _initialize_engines(self) -> None:
  214. """Initialize available TTS engines."""
  215. self._engines = {}
  216. # Initialize OpenAI TTS engine
  217. if "openai" in self.engines_enabled:
  218. self._engines["openai"] = self._init_openai_engine()
  219. # Initialize Google TTS engine
  220. if "google" in self.engines_enabled:
  221. self._engines["google"] = self._init_google_engine()
  222. # Initialize Azure Speech engine
  223. if "azure" in self.engines_enabled:
  224. self._engines["azure"] = self._init_azure_engine()
  225. # Initialize custom engines
  226. if "custom" in self.engines_enabled:
  227. self._engines["custom"] = self._init_custom_engine()
  228. pprint(f"Initialized {len(self._engines)} TTS engines: {list(self._engines.keys())}")
  229. def _init_openai_engine(self) -> Dict[str, Any]:
  230. """Initialize OpenAI TTS engine."""
  231. try:
  232. # Try to import openai (placeholder - would import real OpenAI client)
  233. # import openai
  234. openai_config = {
  235. 'api_key': self.get_config_value("openai_api_key", "", str),
  236. 'model': self.get_config_value("openai_model", "tts-1", str),
  237. 'response_format': self.get_config_value("openai_response_format", "mp3", str),
  238. 'speed': self.get_config_value("openai_speed", 1.0, float)
  239. }
  240. pprint(f"OpenAI TTS engine configured with model: {openai_config['model']}")
  241. return {
  242. 'name': 'openai',
  243. 'available': True,
  244. 'config': openai_config,
  245. 'client': None, # Placeholder - would initialize actual client
  246. 'supported_formats': ['mp3', 'opus', 'aac', 'flac'],
  247. 'max_text_length': 4096
  248. }
  249. except ImportError:
  250. pprint("OpenAI TTS not available - install openai package")
  251. return {'name': 'openai', 'available': False}
  252. def _init_google_engine(self) -> Dict[str, Any]:
  253. """Initialize Google Text-to-Speech engine."""
  254. try:
  255. # Try to import google cloud tts (placeholder)
  256. # from google.cloud import texttospeech
  257. google_config = {
  258. 'credentials_path': self.get_config_value("google_credentials_path", "", str),
  259. 'audio_encoding': self.get_config_value("google_audio_encoding", "LINEAR16", str),
  260. 'sample_rate_hertz': self.get_config_value("google_sample_rate", 16000, int),
  261. 'effects_profile_id': self.get_config_value("google_effects_profile", [], list)
  262. }
  263. pprint("Google TTS engine configured")
  264. return {
  265. 'name': 'google',
  266. 'available': True,
  267. 'config': google_config,
  268. 'client': None, # Placeholder
  269. 'supported_formats': ['wav', 'mp3', 'ogg'],
  270. 'max_text_length': 5000
  271. }
  272. except ImportError:
  273. pprint("Google TTS not available - install google-cloud-texttospeech package")
  274. return {'name': 'google', 'available': False}
  275. def _init_azure_engine(self) -> Dict[str, Any]:
  276. """Initialize Azure Speech Services TTS engine."""
  277. try:
  278. # Try to import azure speech (placeholder)
  279. # import azure.cognitiveservices.speech as speechsdk
  280. azure_config = {
  281. 'subscription_key': self.get_config_value("azure_subscription_key", "", str),
  282. 'region': self.get_config_value("azure_region", "eastus", str),
  283. 'output_format': self.get_config_value("azure_output_format", "Wav16Khz16BitMonoPcm", str)
  284. }
  285. pprint("Azure Speech TTS engine configured")
  286. return {
  287. 'name': 'azure',
  288. 'available': True,
  289. 'config': azure_config,
  290. 'synthesizer': None, # Placeholder
  291. 'supported_formats': ['wav', 'mp3'],
  292. 'max_text_length': 10000
  293. }
  294. except ImportError:
  295. pprint("Azure Speech TTS not available - install azure-cognitiveservices-speech package")
  296. return {'name': 'azure', 'available': False}
  297. def _init_custom_engine(self) -> Dict[str, Any]:
  298. """Initialize custom TTS engine."""
  299. custom_config = {
  300. 'model_path': self.get_config_value("custom_model_path", "", str),
  301. 'vocoder_path': self.get_config_value("custom_vocoder_path", "", str),
  302. 'use_gpu': self.get_config_value("custom_use_gpu", False, bool)
  303. }
  304. return {
  305. 'name': 'custom',
  306. 'available': bool(custom_config['model_path']),
  307. 'config': custom_config,
  308. 'model': None,
  309. 'supported_formats': ['wav'],
  310. 'max_text_length': 1000
  311. }
  312. def _start_synthesis_thread(self) -> None:
  313. """Start the TTS synthesis thread."""
  314. if self._synthesis_thread is None or not self._synthesis_thread.is_alive():
  315. self._synthesis_thread = threading.Thread(
  316. target=self._synthesis_loop,
  317. name="TTSSynthesisThread",
  318. daemon=True
  319. )
  320. self._synthesis_thread.start()
  321. pprint("TTS synthesis thread started")
  322. def _synthesis_loop(self) -> None:
  323. """Main synthesis loop for TTS requests."""
  324. while not self._synthesis_shutdown.wait(0.1):
  325. try:
  326. # Get request from queue with timeout
  327. try:
  328. request = self._synthesis_queue.get(timeout=1.0)
  329. except queue.Empty:
  330. continue
  331. # Process the synthesis request
  332. self._process_synthesis_request(request)
  333. self._synthesis_queue.task_done()
  334. except Exception as e:
  335. pprint(f"Error in synthesis loop: {e}")
  336. def _process_synthesis_request(self, request: TTSRequest) -> None:
  337. """Process a single TTS synthesis request."""
  338. try:
  339. pprint(f"Processing TTS request: '{request.text[:50]}...' with voice {request.voice.name}")
  340. # Check cache if enabled
  341. if self.enable_caching:
  342. cached_result = self._check_cache(request)
  343. if cached_result:
  344. self._stats['cache_hits'] += 1
  345. self._send_tts_result(cached_result, request)
  346. return
  347. # Validate text length
  348. if len(request.text) > self.max_text_length:
  349. pprint(f"Text too long ({len(request.text)} chars), truncating to {self.max_text_length}")
  350. request.text = request.text[:self.max_text_length]
  351. # Perform synthesis
  352. result = self._synthesize_speech(request)
  353. # Cache result if successful and caching enabled
  354. if result and result.success and self.enable_caching:
  355. self._cache_result(request, result)
  356. # Send result
  357. if result:
  358. self._send_tts_result(result, request)
  359. # Update statistics
  360. self._update_stats(result, request)
  361. except Exception as e:
  362. pprint(f"Error processing synthesis request: {e}")
  363. self._stats['failed_syntheses'] += 1
  364. def _synthesize_speech(self, request: TTSRequest) -> Optional[TTSResult]:
  365. """Synthesize speech from text."""
  366. start_time = time.time()
  367. try:
  368. # Determine engine to use
  369. engine_name = request.voice.engine
  370. if engine_name == "default":
  371. engine_name = self.primary_engine
  372. # Try primary engine
  373. if engine_name in self._engines and self._engines[engine_name]['available']:
  374. result = self._synthesize_with_engine(request, engine_name)
  375. if result and result.success:
  376. return result
  377. # Fallback to secondary engine
  378. if (self.fallback_engine in self._engines and
  379. self._engines[self.fallback_engine]['available'] and
  380. self.fallback_engine != engine_name):
  381. pprint(f"Primary engine failed, trying fallback: {self.fallback_engine}")
  382. # Update request voice for fallback engine
  383. fallback_voice = self._get_compatible_voice(request.voice, self.fallback_engine)
  384. fallback_request = TTSRequest(
  385. text=request.text,
  386. voice=fallback_voice,
  387. conversation_id=request.conversation_id,
  388. speed=request.speed,
  389. pitch=request.pitch,
  390. volume=request.volume,
  391. output_format=request.output_format,
  392. use_ssml=request.use_ssml
  393. )
  394. result = self._synthesize_with_engine(fallback_request, self.fallback_engine)
  395. if result and result.success:
  396. return result
  397. return None
  398. except Exception as e:
  399. pprint(f"Speech synthesis failed: {e}")
  400. return TTSResult(
  401. audio_data=b'',
  402. text=request.text,
  403. voice=request.voice,
  404. processing_time=time.time() - start_time,
  405. success=False,
  406. error_message=str(e)
  407. )
  408. def _synthesize_with_engine(self, request: TTSRequest, engine_name: str) -> Optional[TTSResult]:
  409. """Synthesize speech using a specific engine."""
  410. if engine_name == "openai":
  411. return self._synthesize_openai(request)
  412. elif engine_name == "google":
  413. return self._synthesize_google(request)
  414. elif engine_name == "azure":
  415. return self._synthesize_azure(request)
  416. elif engine_name == "custom":
  417. return self._synthesize_custom(request)
  418. else:
  419. pprint(f"Unknown engine: {engine_name}")
  420. return None
  421. def _synthesize_openai(self, request: TTSRequest) -> Optional[TTSResult]:
  422. """Synthesize speech using OpenAI TTS."""
  423. try:
  424. start_time = time.time()
  425. pprint("Synthesizing with OpenAI TTS...")
  426. # Simulate processing delay
  427. time.sleep(0.5)
  428. # Simulate audio generation (actual implementation would call OpenAI API)
  429. audio_data = b'\x00' * (self.sample_rate * 2) # 1 second of silence
  430. return TTSResult(
  431. audio_data=audio_data,
  432. text=request.text,
  433. voice=request.voice,
  434. processing_time=time.time() - start_time,
  435. output_format="mp3",
  436. sample_rate=self.sample_rate,
  437. success=True
  438. )
  439. except Exception as e:
  440. pprint(f"OpenAI TTS synthesis failed: {e}")
  441. return TTSResult(
  442. audio_data=b'',
  443. text=request.text,
  444. voice=request.voice,
  445. success=False,
  446. error_message=str(e)
  447. )
  448. def _synthesize_google(self, request: TTSRequest) -> Optional[TTSResult]:
  449. """Synthesize speech using Google TTS."""
  450. try:
  451. start_time = time.time()
  452. pprint("Synthesizing with Google TTS...")
  453. # Simulate processing
  454. time.sleep(0.3)
  455. # Simulate audio generation
  456. audio_data = b'\x00' * (self.sample_rate * 2) # 1 second of silence
  457. return TTSResult(
  458. audio_data=audio_data,
  459. text=request.text,
  460. voice=request.voice,
  461. processing_time=time.time() - start_time,
  462. output_format="wav",
  463. sample_rate=self.sample_rate,
  464. success=True
  465. )
  466. except Exception as e:
  467. pprint(f"Google TTS synthesis failed: {e}")
  468. return None
  469. def _synthesize_azure(self, request: TTSRequest) -> Optional[TTSResult]:
  470. """Synthesize speech using Azure Speech Services."""
  471. try:
  472. start_time = time.time()
  473. pprint("Synthesizing with Azure Speech...")
  474. # Simulate processing
  475. time.sleep(0.4)
  476. # Simulate audio generation
  477. audio_data = b'\x00' * (self.sample_rate * 2) # 1 second of silence
  478. return TTSResult(
  479. audio_data=audio_data,
  480. text=request.text,
  481. voice=request.voice,
  482. processing_time=time.time() - start_time,
  483. output_format="wav",
  484. sample_rate=self.sample_rate,
  485. success=True
  486. )
  487. except Exception as e:
  488. pprint(f"Azure TTS synthesis failed: {e}")
  489. return None
  490. def _synthesize_custom(self, request: TTSRequest) -> Optional[TTSResult]:
  491. """Synthesize speech using custom engine."""
  492. try:
  493. start_time = time.time()
  494. pprint("Synthesizing with custom engine...")
  495. # Simulate processing
  496. time.sleep(0.8)
  497. # Simulate audio generation
  498. audio_data = b'\x00' * (self.sample_rate * 2) # 1 second of silence
  499. return TTSResult(
  500. audio_data=audio_data,
  501. text=request.text,
  502. voice=request.voice,
  503. processing_time=time.time() - start_time,
  504. output_format="wav",
  505. sample_rate=self.sample_rate,
  506. success=True
  507. )
  508. except Exception as e:
  509. pprint(f"Custom TTS synthesis failed: {e}")
  510. return None
  511. def _get_compatible_voice(self, voice: Voice, engine_name: str) -> Voice:
  512. """Get a compatible voice for the specified engine."""
  513. # Find a voice with same language and gender for the target engine
  514. for voice_name, available_voice in self._voices.items():
  515. if (available_voice.engine == engine_name and
  516. available_voice.language == voice.language and
  517. available_voice.gender == voice.gender):
  518. return available_voice
  519. # Fallback to any voice from the target engine
  520. for voice_name, available_voice in self._voices.items():
  521. if available_voice.engine == engine_name:
  522. return available_voice
  523. # Last resort: return original voice
  524. return voice
  525. def _check_cache(self, request: TTSRequest) -> Optional[TTSResult]:
  526. """Check if synthesis result is cached."""
  527. # Generate cache key
  528. cache_key = self._generate_cache_key(request)
  529. with self._cache_lock:
  530. if cache_key in self._voice_cache:
  531. pprint("Cache hit for TTS request")
  532. return TTSResult(
  533. audio_data=self._voice_cache[cache_key],
  534. text=request.text,
  535. voice=request.voice,
  536. processing_time=0.0,
  537. output_format=self.output_format,
  538. sample_rate=self.sample_rate,
  539. success=True
  540. )
  541. return None
  542. def _cache_result(self, request: TTSRequest, result: TTSResult) -> None:
  543. """Cache TTS synthesis result."""
  544. cache_key = self._generate_cache_key(request)
  545. with self._cache_lock:
  546. # Check cache size limit
  547. current_size = sum(len(data) for data in self._voice_cache.values())
  548. max_size = self.cache_size_mb * 1024 * 1024
  549. if current_size + len(result.audio_data) > max_size:
  550. # Remove oldest entries
  551. self._cleanup_cache()
  552. self._voice_cache[cache_key] = result.audio_data
  553. pprint(f"Cached TTS result for key: {cache_key[:16]}...")
  554. def _generate_cache_key(self, request: TTSRequest) -> str:
  555. """Generate a cache key for the request."""
  556. key_data = f"{request.text}_{request.voice.name}_{request.speed}_{request.pitch}_{request.volume}_{request.output_format}"
  557. return hashlib.md5(key_data.encode()).hexdigest()
  558. def _cleanup_cache(self) -> None:
  559. """Clean up cache by removing oldest entries."""
  560. if len(self._voice_cache) > 10: # Keep at most 10 entries
  561. # Remove first 5 entries (oldest)
  562. keys_to_remove = list(self._voice_cache.keys())[:5]
  563. for key in keys_to_remove:
  564. del self._voice_cache[key]
  565. pprint("Cleaned up voice cache")
  566. def _send_tts_result(self, result: TTSResult, request: TTSRequest) -> None:
  567. """Send TTS result via event system."""
  568. try:
  569. event_handler = self.application.get_event_handler()
  570. if event_handler:
  571. event_data = {
  572. 'conversation_id': request.conversation_id,
  573. 'audio_data': result.audio_data,
  574. 'text': result.text,
  575. 'voice_settings': {
  576. 'voice_name': result.voice.name,
  577. 'language': result.voice.language,
  578. 'gender': result.voice.gender,
  579. 'engine': result.voice.engine
  580. },
  581. 'audio_format': result.output_format,
  582. 'sample_rate': result.sample_rate,
  583. 'processing_time': result.processing_time,
  584. 'timestamp': result.timestamp,
  585. 'success': result.success,
  586. 'error_message': result.error_message
  587. }
  588. event_handler.trigger_event("tts_received", event_data)
  589. pprint(f"TTS result sent: '{result.text[:30]}...' ({len(result.audio_data)} bytes)")
  590. except Exception as e:
  591. pprint(f"Error sending TTS result: {e}")
  592. def _update_stats(self, result: Optional[TTSResult], request: TTSRequest) -> None:
  593. """Update processing statistics."""
  594. self._stats['total_requests'] += 1
  595. if result and result.success:
  596. self._stats['successful_syntheses'] += 1
  597. self._stats['total_processing_time'] += result.processing_time
  598. self._stats['average_processing_time'] = (
  599. self._stats['total_processing_time'] / self._stats['successful_syntheses']
  600. )
  601. self._stats['total_audio_generated'] += len(result.audio_data)
  602. self._stats['voices_used'].add(result.voice.name)
  603. self._stats['languages_used'].add(result.voice.language)
  604. else:
  605. self._stats['failed_syntheses'] += 1
  606. # Event Handlers
  607. @TrixyEvent("intent_received")
  608. def on_intent_received(self, event_name: str, event_data: Any) -> None:
  609. """Handle intent events that need TTS response."""
  610. if not self.is_enabled():
  611. return
  612. # This would typically be handled by an intent/response plugin
  613. # But we can provide basic TTS functionality here
  614. try:
  615. conversation_id = event_data.get('conversation_id', 'unknown')
  616. intent = event_data.get('intent', '')
  617. entities = event_data.get('entities', {})
  618. # Example: Handle basic TTS requests
  619. if intent == "tts_request":
  620. text = entities.get('text', 'Hello, this is a test message')
  621. voice_name = entities.get('voice', self.default_voice_name)
  622. self.synthesize_text(text, voice_name, conversation_id)
  623. except Exception as e:
  624. pprint(f"Error handling intent: {e}")
  625. @TrixyEvent("system_startup")
  626. def on_system_startup(self, event_name: str, event_data: Any) -> None:
  627. """Handle system startup."""
  628. if not self.is_enabled():
  629. return
  630. pprint("TTS Plugin system startup")
  631. # Verify engines
  632. available_engines = [name for name, engine in self._engines.items() if engine['available']]
  633. if not available_engines:
  634. pprint("WARNING: No TTS engines available!")
  635. else:
  636. pprint(f"Available TTS engines: {available_engines}")
  637. # Public Methods
  638. def synthesize_text(
  639. self,
  640. text: str,
  641. voice_name: Optional[str] = None,
  642. conversation_id: str = "",
  643. speed: float = None,
  644. pitch: float = None,
  645. volume: float = None,
  646. use_ssml: bool = False
  647. ) -> bool:
  648. """
  649. Synthesize text to speech.
  650. Args:
  651. text: Text to synthesize
  652. voice_name: Name of voice to use
  653. conversation_id: Conversation ID for response routing
  654. speed: Speech speed multiplier
  655. pitch: Pitch adjustment
  656. volume: Volume adjustment
  657. use_ssml: Whether text contains SSML markup
  658. Returns:
  659. bool: True if request was queued successfully
  660. """
  661. try:
  662. # Get voice
  663. if voice_name and voice_name in self._voices:
  664. voice = self._voices[voice_name]
  665. else:
  666. voice = self._voices.get(self.default_voice_name)
  667. if not voice:
  668. voice = next(iter(self._voices.values()))
  669. # Create request
  670. request = TTSRequest(
  671. text=text,
  672. voice=voice,
  673. conversation_id=conversation_id,
  674. speed=speed or self.default_speed,
  675. pitch=pitch or self.default_pitch,
  676. volume=volume or self.default_volume,
  677. output_format=self.output_format,
  678. use_ssml=use_ssml and self.enable_ssml
  679. )
  680. # Queue for processing
  681. self._synthesis_queue.put(request)
  682. pprint(f"TTS request queued: '{text[:30]}...' with voice {voice.name}")
  683. return True
  684. except Exception as e:
  685. pprint(f"Error queueing TTS request: {e}")
  686. return False
  687. def get_available_voices(self, language: Optional[str] = None, gender: Optional[str] = None) -> List[Dict[str, str]]:
  688. """Get list of available voices with optional filtering."""
  689. voices = []
  690. for voice_name, voice in self._voices.items():
  691. if language and voice.language != language:
  692. continue
  693. if gender and voice.gender != gender:
  694. continue
  695. voices.append({
  696. 'name': voice.name,
  697. 'language': voice.language,
  698. 'gender': voice.gender,
  699. 'region': voice.region,
  700. 'age': voice.age,
  701. 'style': voice.style,
  702. 'engine': voice.engine
  703. })
  704. return voices
  705. def test_voice(self, voice_name: str, test_text: str = "This is a test message") -> Dict[str, Any]:
  706. """Test a specific voice."""
  707. if voice_name not in self._voices:
  708. return {'success': False, 'error': f'Voice {voice_name} not found'}
  709. try:
  710. voice = self._voices[voice_name]
  711. request = TTSRequest(
  712. text=test_text,
  713. voice=voice,
  714. conversation_id="test",
  715. speed=self.default_speed,
  716. pitch=self.default_pitch,
  717. volume=self.default_volume,
  718. output_format=self.output_format
  719. )
  720. result = self._synthesize_speech(request)
  721. return {
  722. 'success': result.success if result else False,
  723. 'voice': voice_name,
  724. 'text': test_text,
  725. 'processing_time': result.processing_time if result else 0,
  726. 'audio_size': len(result.audio_data) if result and result.success else 0,
  727. 'error': result.error_message if result and not result.success else None
  728. }
  729. except Exception as e:
  730. return {'success': False, 'error': str(e)}
  731. # Lifecycle Hooks
  732. def on_enable(self) -> None:
  733. """Called when plugin is enabled."""
  734. pprint("TTS Plugin enabled")
  735. self._start_synthesis_thread()
  736. def on_disable(self) -> None:
  737. """Called when plugin is disabled."""
  738. pprint("TTS Plugin disabled")
  739. # Clear synthesis queue
  740. while not self._synthesis_queue.empty():
  741. try:
  742. self._synthesis_queue.get_nowait()
  743. except queue.Empty:
  744. break
  745. def cleanup(self) -> None:
  746. """Clean up TTS plugin resources."""
  747. pprint("TTS Plugin cleanup")
  748. # Stop synthesis thread
  749. self._synthesis_shutdown.set()
  750. if self._synthesis_thread and self._synthesis_thread.is_alive():
  751. self._synthesis_thread.join(timeout=5.0)
  752. # Clear queues and cache
  753. while not self._synthesis_queue.empty():
  754. try:
  755. self._synthesis_queue.get_nowait()
  756. except queue.Empty:
  757. break
  758. with self._cache_lock:
  759. self._voice_cache.clear()
  760. # Save final statistics
  761. stats = self._stats.copy()
  762. stats['voices_used'] = list(stats['voices_used'])
  763. stats['languages_used'] = list(stats['languages_used'])
  764. self.set_config_value("final_stats", stats)
  765. def get_tts_stats(self) -> Dict[str, Any]:
  766. """Get TTS processing statistics."""
  767. stats = self._stats.copy()
  768. # Convert sets to lists for JSON serialization
  769. stats['voices_used'] = list(stats['voices_used'])
  770. stats['languages_used'] = list(stats['languages_used'])
  771. # Add current status
  772. stats.update({
  773. 'queue_size': self._synthesis_queue.qsize(),
  774. 'cache_size': len(self._voice_cache),
  775. 'cache_memory_mb': sum(len(data) for data in self._voice_cache.values()) / (1024 * 1024),
  776. 'available_voices': len(self._voices),
  777. 'available_engines': [name for name, engine in self._engines.items() if engine['available']],
  778. 'primary_engine': self.primary_engine,
  779. 'fallback_engine': self.fallback_engine
  780. })
  781. return stats