tts_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. # Copyright (C) 2025 AIDC-AI
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. # See the License for the specific language governing permissions and
  11. # limitations under the License.
  12. """
  13. TTS (Text-to-Speech) Service - Supports both local and ComfyUI inference
  14. """
  15. import os
  16. import uuid
  17. from pathlib import Path
  18. from typing import Optional
  19. from comfykit import ComfyKit
  20. from loguru import logger
  21. from pixelle_video.services.comfy_base_service import ComfyBaseService
  22. from pixelle_video.utils.tts_util import edge_tts
  23. from pixelle_video.tts_voices import speed_to_rate
  24. class TTSService(ComfyBaseService):
  25. """
  26. TTS (Text-to-Speech) service - Workflow-based
  27. Uses ComfyKit to execute TTS workflows.
  28. Usage:
  29. # Use default workflow
  30. audio_path = await pixelle_video.tts(text="Hello, world!")
  31. # Use specific workflow
  32. audio_path = await pixelle_video.tts(
  33. text="你好,世界!",
  34. workflow="tts_edge.json"
  35. )
  36. # List available workflows
  37. workflows = pixelle_video.tts.list_workflows()
  38. """
  39. WORKFLOW_PREFIX = "tts_"
  40. DEFAULT_WORKFLOW = None # No hardcoded default, must be configured
  41. WORKFLOWS_DIR = "workflows"
  42. def __init__(self, config: dict, core=None):
  43. """
  44. Initialize TTS service
  45. Args:
  46. config: Full application config dict
  47. core: PixelleVideoCore instance (for accessing shared ComfyKit)
  48. """
  49. super().__init__(config, service_name="tts", core=core)
  50. async def __call__(
  51. self,
  52. text: str,
  53. workflow: Optional[str] = None,
  54. # ComfyUI connection (optional overrides)
  55. comfyui_url: Optional[str] = None,
  56. runninghub_api_key: Optional[str] = None,
  57. # TTS parameters
  58. voice: Optional[str] = None,
  59. speed: Optional[float] = None,
  60. # Inference mode override
  61. inference_mode: Optional[str] = None,
  62. # Output path
  63. output_path: Optional[str] = None,
  64. **params
  65. ) -> str:
  66. """
  67. Generate speech using local Edge TTS or ComfyUI workflow
  68. Args:
  69. text: Text to convert to speech
  70. workflow: Workflow filename (for ComfyUI mode, default: from config)
  71. comfyui_url: ComfyUI URL (optional, overrides config)
  72. runninghub_api_key: RunningHub API key (optional, overrides config)
  73. voice: Voice ID (for local mode: Edge TTS voice ID; for ComfyUI: workflow-specific)
  74. speed: Speech speed multiplier (1.0 = normal, >1.0 = faster, <1.0 = slower)
  75. inference_mode: Override inference mode ("local" or "comfyui", default: from config)
  76. output_path: Custom output path (auto-generated if None)
  77. **params: Additional workflow parameters
  78. Returns:
  79. Generated audio file path
  80. Examples:
  81. # Local inference (Edge TTS)
  82. audio_path = await pixelle_video.tts(
  83. text="Hello, world!",
  84. inference_mode="local",
  85. voice="zh-CN-YunjianNeural",
  86. speed=1.2
  87. )
  88. # ComfyUI inference
  89. audio_path = await pixelle_video.tts(
  90. text="你好,世界!",
  91. inference_mode="comfyui",
  92. workflow="runninghub/tts_edge.json"
  93. )
  94. """
  95. # Determine inference mode (param > config)
  96. mode = inference_mode or self.config.get("inference_mode", "local")
  97. # Route to appropriate implementation
  98. if mode == "local":
  99. return await self._call_local_tts(
  100. text=text,
  101. voice=voice,
  102. speed=speed,
  103. output_path=output_path
  104. )
  105. else: # comfyui
  106. # 1. Resolve workflow (returns structured info)
  107. workflow_info = self._resolve_workflow(workflow=workflow)
  108. # 2. Execute ComfyUI workflow
  109. return await self._call_comfyui_workflow(
  110. workflow_info=workflow_info,
  111. text=text,
  112. comfyui_url=comfyui_url,
  113. runninghub_api_key=runninghub_api_key,
  114. voice=voice,
  115. speed=speed,
  116. output_path=output_path,
  117. **params
  118. )
  119. async def _call_local_tts(
  120. self,
  121. text: str,
  122. voice: Optional[str] = None,
  123. speed: Optional[float] = None,
  124. output_path: Optional[str] = None,
  125. ) -> str:
  126. """
  127. Generate speech using local Edge TTS
  128. Args:
  129. text: Text to convert to speech
  130. voice: Edge TTS voice ID (default: from config)
  131. speed: Speech speed multiplier (default: from config)
  132. output_path: Custom output path (auto-generated if None)
  133. Returns:
  134. Generated audio file path
  135. """
  136. # Get config defaults
  137. local_config = self.config.get("local", {})
  138. # Determine voice and speed (param > config)
  139. final_voice = voice or local_config.get("voice", "zh-CN-YunjianNeural")
  140. final_speed = speed if speed is not None else local_config.get("speed", 1.2)
  141. # Convert speed to rate parameter
  142. rate = speed_to_rate(final_speed)
  143. logger.info(f"🎙️ Using local Edge TTS: voice={final_voice}, speed={final_speed}x (rate={rate})")
  144. # Generate output path if not provided
  145. if not output_path:
  146. # Generate unique filename
  147. unique_id = uuid.uuid4().hex
  148. output_path = f"output/{unique_id}.mp3"
  149. # Ensure output directory exists
  150. Path("output").mkdir(parents=True, exist_ok=True)
  151. # Call Edge TTS
  152. try:
  153. audio_bytes = await edge_tts(
  154. text=text,
  155. voice=final_voice,
  156. rate=rate,
  157. output_path=output_path
  158. )
  159. logger.info(f"✅ Generated audio (local Edge TTS): {output_path}")
  160. return output_path
  161. except Exception as e:
  162. logger.error(f"Local TTS generation error: {e}")
  163. raise
  164. async def _call_comfyui_workflow(
  165. self,
  166. workflow_info: dict,
  167. text: str,
  168. comfyui_url: Optional[str] = None,
  169. runninghub_api_key: Optional[str] = None,
  170. voice: Optional[str] = None,
  171. speed: float = 1.0,
  172. output_path: Optional[str] = None,
  173. **params
  174. ) -> str:
  175. """
  176. Generate speech using ComfyUI workflow
  177. Args:
  178. workflow_info: Workflow info dict from _resolve_workflow()
  179. text: Text to convert to speech
  180. comfyui_url: ComfyUI URL
  181. runninghub_api_key: RunningHub API key
  182. voice: Voice ID (workflow-specific)
  183. speed: Speech speed multiplier (workflow-specific)
  184. output_path: Custom output path (downloads if URL returned)
  185. **params: Additional workflow parameters
  186. Returns:
  187. Generated audio file path (local if output_path provided, otherwise URL)
  188. """
  189. logger.info(f"🎙️ Using workflow: {workflow_info['key']}")
  190. # 1. Build workflow parameters (ComfyKit config is now managed by core)
  191. workflow_params = {"text": text}
  192. # Add optional TTS parameters (only if explicitly provided and not None)
  193. if voice is not None:
  194. workflow_params["voice"] = voice
  195. if speed is not None and speed != 1.0:
  196. workflow_params["speed"] = speed
  197. # Add any additional parameters
  198. workflow_params.update(params)
  199. logger.debug(f"Workflow parameters: {workflow_params}")
  200. # 3. Execute workflow using shared ComfyKit instance from core
  201. try:
  202. # Get shared ComfyKit instance (lazy initialization + config hot-reload)
  203. kit = await self.core._get_or_create_comfykit()
  204. # Determine what to pass to ComfyKit based on source
  205. if workflow_info["source"] == "runninghub" and "workflow_id" in workflow_info:
  206. # RunningHub: pass workflow_id
  207. workflow_input = workflow_info["workflow_id"]
  208. logger.info(f"Executing RunningHub TTS workflow: {workflow_input}")
  209. else:
  210. # Selfhost: pass file path
  211. workflow_input = workflow_info["path"]
  212. logger.info(f"Executing selfhost TTS workflow: {workflow_input}")
  213. result = await kit.execute(workflow_input, workflow_params)
  214. # 4. Handle result
  215. if result.status != "completed":
  216. error_msg = result.msg or "Unknown error"
  217. logger.error(f"TTS generation failed: {error_msg}")
  218. raise Exception(f"TTS generation failed: {error_msg}")
  219. # ComfyKit result can have audio files in different output types
  220. # Try to get audio file path from result
  221. audio_path = None
  222. # Check for audio files in result.audios (if available)
  223. if hasattr(result, 'audios') and result.audios:
  224. audio_path = result.audios[0]
  225. logger.debug(f"✅ Found audio in result.audios: {audio_path}")
  226. # Check for files in result.files
  227. elif hasattr(result, 'files') and result.files:
  228. audio_path = result.files[0]
  229. logger.debug(f"✅ Found audio in result.files: {audio_path}")
  230. # Check in outputs dictionary
  231. elif hasattr(result, 'outputs') and result.outputs:
  232. logger.debug(f"Searching for audio file in result.outputs: {result.outputs}")
  233. # Try to find audio file in outputs
  234. for key, value in result.outputs.items():
  235. if isinstance(value, str) and any(value.endswith(ext) for ext in ['.mp3', '.wav', '.flac']):
  236. audio_path = value
  237. logger.debug(f"✅ Found audio in result.outputs[{key}]: {audio_path}")
  238. break
  239. if not audio_path:
  240. logger.error("No audio file generated")
  241. logger.error(f"❌ Result analysis:")
  242. logger.error(f" - result.audios: {getattr(result, 'audios', 'NOT_FOUND')}")
  243. logger.error(f" - result.files: {getattr(result, 'files', 'NOT_FOUND')}")
  244. logger.error(f" - result.outputs: {getattr(result, 'outputs', 'NOT_FOUND')}")
  245. logger.error(f" - Full __dict__: {result.__dict__}")
  246. raise Exception("No audio file generated by workflow")
  247. # If output_path provided and audio_path is URL, download to local
  248. if output_path and audio_path.startswith(('http://', 'https://')):
  249. import httpx
  250. import os
  251. # Ensure parent directory exists
  252. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  253. logger.info(f"Downloading audio from {audio_path} to {output_path}")
  254. async with httpx.AsyncClient() as client:
  255. response = await client.get(audio_path)
  256. response.raise_for_status()
  257. with open(output_path, 'wb') as f:
  258. f.write(response.content)
  259. logger.info(f"✅ Generated audio (ComfyUI): {output_path}")
  260. return output_path
  261. logger.info(f"✅ Generated audio (ComfyUI): {audio_path}")
  262. return audio_path
  263. except Exception as e:
  264. logger.error(f"TTS generation error: {e}")
  265. raise