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