返回 Pixelle-Video
tts_util.py
根目录 / pixelle_video / utils / tts_util.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 Edge TTS Utility - Temporarily not used
15
16 This is the original edge-tts implementation, kept here for potential future use.
17 Currently, TTS service uses ComfyUI workflows only.
18 """
19
20 import asyncio
21 import ssl
22 import random
23 import certifi
24 import edge_tts as edge_tts_sdk
25 from edge_tts.exceptions import NoAudioReceived
26 from loguru import logger
27 from aiohttp import WSServerHandshakeError, ClientResponseError
28
29
30 # Use certifi bundle for SSL verification instead of disabling it
31 _USE_CERTIFI_SSL = True
32
33 # Retry configuration for Edge TTS (to handle 401 errors and NoAudioReceived)
34 _RETRY_COUNT = 5 # Default retry count
35 _RETRY_BASE_DELAY = 1.0 # Base retry delay in seconds (for exponential backoff)
36 _MAX_RETRY_DELAY = 10.0 # Maximum retry delay in seconds
37
38 # Rate limiting configuration
39 _REQUEST_DELAY = 0.5 # Minimum delay before each request (seconds)
40 _MAX_CONCURRENT_REQUESTS = 3 # Maximum concurrent requests
41
42 # Global semaphore for rate limiting (created per event loop)
43 _request_semaphore = None
44 _semaphore_loop = None
45
46
47 def _get_request_semaphore():
48 """Get or create request semaphore for current event loop"""
49 global _request_semaphore, _semaphore_loop
50
51 try:
52 current_loop = asyncio.get_running_loop()
53 except RuntimeError:
54 # No running loop
55 return asyncio.Semaphore(_MAX_CONCURRENT_REQUESTS)
56
57 # If semaphore doesn't exist or belongs to different loop, create new one
58 if _request_semaphore is None or _semaphore_loop != current_loop:
59 _request_semaphore = asyncio.Semaphore(_MAX_CONCURRENT_REQUESTS)
60 _semaphore_loop = current_loop
61
62 return _request_semaphore
63
64
65 async def edge_tts(
66 text: str,
67 voice: str = "[Chinese] zh-CN Yunjian",
68 rate: str = "+0%",
69 volume: str = "+0%",
70 pitch: str = "+0Hz",
71 output_path: str = None,
72 retry_count: int = _RETRY_COUNT,
73 retry_base_delay: float = _RETRY_BASE_DELAY,
74 ) -> bytes:
75 """
76 Convert text to speech using Microsoft Edge TTS
77
78 This service is free and requires no API key.
79 Supports 400+ voices across 100+ languages.
80
81 Returns audio data as bytes (MP3 format).
82
83 Includes automatic retry mechanism with exponential backoff and jitter
84 to handle 401 authentication errors and temporary network issues.
85 Also includes concurrent request limiting and rate limiting.
86
87 Args:
88 text: Text to convert to speech
89 voice: Voice ID (e.g., [Chinese] zh-CN Yunjian, [English] en-US Jenny)
90 rate: Speech rate (e.g., +0%, +50%, -20%)
91 volume: Speech volume (e.g., +0%, +50%, -20%)
92 pitch: Speech pitch (e.g., +0Hz, +10Hz, -5Hz)
93 output_path: Optional output file path to save audio
94 retry_count: Number of retries on failure (default: 5)
95 retry_base_delay: Base delay for exponential backoff (default: 1.0s)
96
97 Returns:
98 Audio data as bytes (MP3 format)
99
100 Popular Chinese voices:
101 - [Chinese] zh-CN Yunjian (male, default)
102 - [Chinese] zh-CN Xiaoxiao (female)
103 - [Chinese] zh-CN Yunxi (male)
104 - [Chinese] zh-CN Xiaoyi (female)
105
106 Popular English voices:
107 - [English] en-US Jenny (female)
108 - [English] en-US Guy (male)
109 - [English] en-GB Sonia (female, British)
110
111 Example:
112 audio_bytes = await edge_tts(
113 text="你好,世界!",
114 voice="[Chinese] zh-CN Yunjian",
115 rate="+20%"
116 )
117 """
118 logger.debug(f"Calling Edge TTS with voice: {voice}, rate: {rate}, retry_count: {retry_count}")
119
120 # Use semaphore to limit concurrent requests
121 request_semaphore = _get_request_semaphore()
122 async with request_semaphore:
123 # Add a small random delay before each request to avoid rate limiting
124 pre_delay = _REQUEST_DELAY + random.uniform(0, 0.3)
125 logger.debug(f"Waiting {pre_delay:.2f}s before request (rate limiting)")
126 await asyncio.sleep(pre_delay)
127
128 last_error = None
129
130 # Retry loop
131 for attempt in range(retry_count + 1): # +1 because first attempt is not a retry
132 if attempt > 0:
133 # Exponential backoff with jitter
134 # delay = base * (2 ^ attempt) + random jitter
135 exponential_delay = retry_base_delay * (2 ** (attempt - 1))
136 jitter = random.uniform(0, retry_base_delay)
137 retry_delay = min(exponential_delay + jitter, _MAX_RETRY_DELAY)
138
139 logger.info(f"🔄 Retrying Edge TTS (attempt {attempt + 1}/{retry_count + 1}) after {retry_delay:.2f}s delay...")
140 await asyncio.sleep(retry_delay)
141
142 try:
143 # Create communicate instance with certifi SSL context
144 if _USE_CERTIFI_SSL:
145 if attempt == 0: # Only log info once
146 logger.debug("Using certifi SSL certificates for secure Edge TTS connection")
147 # Create SSL context with certifi bundle
148 import certifi
149 ssl_context = ssl.create_default_context(cafile=certifi.where())
150 else:
151 ssl_context = None
152
153 # Create communicate instance
154 communicate = edge_tts_sdk.Communicate(
155 text=text,
156 voice=voice,
157 rate=rate,
158 volume=volume,
159 pitch=pitch,
160 )
161
162 # Collect audio chunks
163 audio_chunks = []
164 async for chunk in communicate.stream():
165 if chunk["type"] == "audio":
166 audio_chunks.append(chunk["data"])
167
168 audio_data = b"".join(audio_chunks)
169
170 if attempt > 0:
171 logger.success(f"✅ Retry succeeded on attempt {attempt + 1}")
172
173 logger.info(f"Generated {len(audio_data)} bytes of audio data")
174
175 # Save to file if output_path is provided
176 if output_path:
177 with open(output_path, "wb") as f:
178 f.write(audio_data)
179 logger.info(f"Audio saved to: {output_path}")
180
181 return audio_data
182
183 except (WSServerHandshakeError, ClientResponseError) as e:
184 # Network/authentication errors - retry
185 last_error = e
186 error_code = getattr(e, 'status', 'unknown')
187 error_msg = str(e)
188
189 # Log more detailed information for 401 errors
190 if error_code == 401 or '401' in error_msg:
191 logger.warning(f"⚠️ Edge TTS 401 Authentication Error (attempt {attempt + 1}/{retry_count + 1})")
192 logger.debug(f"Error details: {error_msg}")
193 logger.debug(f"This is usually caused by rate limiting. Will retry with exponential backoff...")
194 else:
195 logger.warning(f"⚠️ Edge TTS error (attempt {attempt + 1}/{retry_count + 1}): {error_code} - {e}")
196
197 if attempt >= retry_count:
198 # Last attempt failed
199 logger.error(f"❌ All {retry_count + 1} attempts failed. Last error: {error_code}")
200 raise
201 # Otherwise, continue to next retry
202
203 except NoAudioReceived as e:
204 # NoAudioReceived is often a temporary issue - retry with longer delay
205 last_error = e
206 logger.warning(f"⚠️ Edge TTS NoAudioReceived (attempt {attempt + 1}/{retry_count + 1})")
207 logger.debug(f"This is usually a temporary Microsoft service issue. Will retry with longer delay...")
208
209 if attempt >= retry_count:
210 logger.error(f"❌ All {retry_count + 1} attempts failed due to NoAudioReceived")
211 raise
212 # Add extra delay for NoAudioReceived errors
213 await asyncio.sleep(2.0)
214
215 except Exception as e:
216 # Other errors - don't retry, raise immediately
217 logger.error(f"Edge TTS error (non-retryable): {type(e).__name__} - {e}")
218 raise
219
220 # Should not reach here, but just in case
221 if last_error:
222 raise last_error
223 else:
224 raise RuntimeError("Edge TTS failed without error (unexpected)")
225
226
227 def get_audio_duration(audio_path: str) -> float:
228 """
229 Get audio file duration in seconds
230
231 Args:
232 audio_path: Path to audio file
233
234 Returns:
235 Duration in seconds
236 """
237 try:
238 # Try using ffmpeg-python
239 import ffmpeg
240 probe = ffmpeg.probe(audio_path)
241 duration = float(probe['format']['duration'])
242 return duration
243 except Exception as e:
244 logger.warning(f"Failed to get audio duration: {e}, using estimate")
245 # Fallback: estimate based on file size (very rough)
246 import os
247 file_size = os.path.getsize(audio_path)
248 # Assume ~16kbps for MP3, so 2KB per second
249 estimated_duration = file_size / 2000
250 return max(1.0, estimated_duration) # At least 1 second
251
252
253 async def list_voices(locale: str = None, retry_count: int = _RETRY_COUNT, retry_base_delay: float = _RETRY_BASE_DELAY) -> list[str]:
254 """
255 List all available voices for Edge TTS
256
257 Returns a list of voice IDs (ShortName).
258 Optionally filter by locale.
259
260 Includes automatic retry mechanism with exponential backoff and jitter
261 to handle network errors and rate limiting.
262
263 Args:
264 locale: Filter by locale (e.g., zh-CN, en-US, ja-JP)
265 retry_count: Number of retries on failure (default: 5)
266 retry_base_delay: Base delay for exponential backoff (default: 1.0s)
267
268 Returns:
269 List of voice IDs
270
271 Example:
272 # List all voices
273 voices = await list_voices()
274 # Returns: ['[Chinese] zh-CN Yunjian', '[Chinese] zh-CN Xiaoxiao', ...]
275
276 # List Chinese voices only
277 voices = await list_voices(locale="zh-CN")
278 # Returns: ['[Chinese] zh-CN Yunjian', '[Chinese] zh-CN Xiaoxiao', ...]
279 """
280 logger.debug(f"Fetching Edge TTS voices, locale filter: {locale}, retry_count: {retry_count}")
281
282 # Use semaphore to limit concurrent requests
283 request_semaphore = _get_request_semaphore()
284 async with request_semaphore:
285 # Add a small random delay before each request to avoid rate limiting
286 pre_delay = _REQUEST_DELAY + random.uniform(0, 0.3)
287 logger.debug(f"Waiting {pre_delay:.2f}s before request (rate limiting)")
288 await asyncio.sleep(pre_delay)
289
290 last_error = None
291
292 # Retry loop
293 for attempt in range(retry_count + 1):
294 if attempt > 0:
295 # Exponential backoff with jitter
296 exponential_delay = retry_base_delay * (2 ** (attempt - 1))
297 jitter = random.uniform(0, retry_base_delay)
298 retry_delay = min(exponential_delay + jitter, _MAX_RETRY_DELAY)
299
300 logger.info(f"🔄 Retrying list voices (attempt {attempt + 1}/{retry_count + 1}) after {retry_delay:.2f}s delay...")
301 await asyncio.sleep(retry_delay)
302
303 try:
304 # Get all voices (edge-tts handles SSL internally)
305 voices = await edge_tts_sdk.list_voices()
306
307 # Filter by locale if specified
308 if locale:
309 voices = [v for v in voices if v["Locale"].startswith(locale)]
310
311 # Extract voice IDs (ShortName)
312 voice_ids = [voice["ShortName"] for voice in voices]
313
314 if attempt > 0:
315 logger.success(f"✅ Retry succeeded on attempt {attempt + 1}")
316
317 logger.info(f"Found {len(voice_ids)} voices" + (f" for locale '{locale}'" if locale else ""))
318 return voice_ids
319
320 except (WSServerHandshakeError, ClientResponseError) as e:
321 # Network/authentication errors - retry
322 last_error = e
323 error_code = getattr(e, 'status', 'unknown')
324 error_msg = str(e)
325
326 # Log more detailed information for 401 errors
327 if error_code == 401 or '401' in error_msg:
328 logger.warning(f"⚠️ Edge TTS 401 Authentication Error (list_voices attempt {attempt + 1}/{retry_count + 1})")
329 logger.debug(f"Error details: {error_msg}")
330 logger.debug(f"This is usually caused by rate limiting. Will retry with exponential backoff...")
331 else:
332 logger.warning(f"⚠️ List voices error (attempt {attempt + 1}/{retry_count + 1}): {error_code} - {e}")
333
334 if attempt >= retry_count:
335 logger.error(f"❌ All {retry_count + 1} attempts failed. Last error: {error_code}")
336 raise
337
338 except Exception as e:
339 # Other errors - don't retry, raise immediately
340 logger.error(f"List voices error (non-retryable): {type(e).__name__} - {e}")
341 raise
342
343 # Should not reach here, but just in case
344 if last_error:
345 raise last_error
346 else:
347 raise RuntimeError("List voices failed without error (unexpected)")
348
349
349 lines PYTHON