返回 Pixelle-Video
tts_service.py
根目录 / pixelle_video / services / tts_service.py
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
319 lines PYTHON