| 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 |