| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- # Copyright (C) 2025 AIDC-AI
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- # http://www.apache.org/licenses/LICENSE-2.0
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
- """
- Edge TTS Utility - Temporarily not used
- This is the original edge-tts implementation, kept here for potential future use.
- Currently, TTS service uses ComfyUI workflows only.
- """
- import asyncio
- import ssl
- import random
- import certifi
- import edge_tts as edge_tts_sdk
- from edge_tts.exceptions import NoAudioReceived
- from loguru import logger
- from aiohttp import WSServerHandshakeError, ClientResponseError
- # Use certifi bundle for SSL verification instead of disabling it
- _USE_CERTIFI_SSL = True
- # Retry configuration for Edge TTS (to handle 401 errors and NoAudioReceived)
- _RETRY_COUNT = 5 # Default retry count
- _RETRY_BASE_DELAY = 1.0 # Base retry delay in seconds (for exponential backoff)
- _MAX_RETRY_DELAY = 10.0 # Maximum retry delay in seconds
- # Rate limiting configuration
- _REQUEST_DELAY = 0.5 # Minimum delay before each request (seconds)
- _MAX_CONCURRENT_REQUESTS = 3 # Maximum concurrent requests
- # Global semaphore for rate limiting (created per event loop)
- _request_semaphore = None
- _semaphore_loop = None
- def _get_request_semaphore():
- """Get or create request semaphore for current event loop"""
- global _request_semaphore, _semaphore_loop
-
- try:
- current_loop = asyncio.get_running_loop()
- except RuntimeError:
- # No running loop
- return asyncio.Semaphore(_MAX_CONCURRENT_REQUESTS)
-
- # If semaphore doesn't exist or belongs to different loop, create new one
- if _request_semaphore is None or _semaphore_loop != current_loop:
- _request_semaphore = asyncio.Semaphore(_MAX_CONCURRENT_REQUESTS)
- _semaphore_loop = current_loop
-
- return _request_semaphore
- async def edge_tts(
- text: str,
- voice: str = "[Chinese] zh-CN Yunjian",
- rate: str = "+0%",
- volume: str = "+0%",
- pitch: str = "+0Hz",
- output_path: str = None,
- retry_count: int = _RETRY_COUNT,
- retry_base_delay: float = _RETRY_BASE_DELAY,
- ) -> bytes:
- """
- Convert text to speech using Microsoft Edge TTS
-
- This service is free and requires no API key.
- Supports 400+ voices across 100+ languages.
-
- Returns audio data as bytes (MP3 format).
-
- Includes automatic retry mechanism with exponential backoff and jitter
- to handle 401 authentication errors and temporary network issues.
- Also includes concurrent request limiting and rate limiting.
-
- Args:
- text: Text to convert to speech
- voice: Voice ID (e.g., [Chinese] zh-CN Yunjian, [English] en-US Jenny)
- rate: Speech rate (e.g., +0%, +50%, -20%)
- volume: Speech volume (e.g., +0%, +50%, -20%)
- pitch: Speech pitch (e.g., +0Hz, +10Hz, -5Hz)
- output_path: Optional output file path to save audio
- retry_count: Number of retries on failure (default: 5)
- retry_base_delay: Base delay for exponential backoff (default: 1.0s)
-
- Returns:
- Audio data as bytes (MP3 format)
-
- Popular Chinese voices:
- - [Chinese] zh-CN Yunjian (male, default)
- - [Chinese] zh-CN Xiaoxiao (female)
- - [Chinese] zh-CN Yunxi (male)
- - [Chinese] zh-CN Xiaoyi (female)
-
- Popular English voices:
- - [English] en-US Jenny (female)
- - [English] en-US Guy (male)
- - [English] en-GB Sonia (female, British)
-
- Example:
- audio_bytes = await edge_tts(
- text="你好,世界!",
- voice="[Chinese] zh-CN Yunjian",
- rate="+20%"
- )
- """
- logger.debug(f"Calling Edge TTS with voice: {voice}, rate: {rate}, retry_count: {retry_count}")
-
- # Use semaphore to limit concurrent requests
- request_semaphore = _get_request_semaphore()
- async with request_semaphore:
- # Add a small random delay before each request to avoid rate limiting
- pre_delay = _REQUEST_DELAY + random.uniform(0, 0.3)
- logger.debug(f"Waiting {pre_delay:.2f}s before request (rate limiting)")
- await asyncio.sleep(pre_delay)
-
- last_error = None
-
- # Retry loop
- for attempt in range(retry_count + 1): # +1 because first attempt is not a retry
- if attempt > 0:
- # Exponential backoff with jitter
- # delay = base * (2 ^ attempt) + random jitter
- exponential_delay = retry_base_delay * (2 ** (attempt - 1))
- jitter = random.uniform(0, retry_base_delay)
- retry_delay = min(exponential_delay + jitter, _MAX_RETRY_DELAY)
-
- logger.info(f"🔄 Retrying Edge TTS (attempt {attempt + 1}/{retry_count + 1}) after {retry_delay:.2f}s delay...")
- await asyncio.sleep(retry_delay)
-
- try:
- # Create communicate instance with certifi SSL context
- if _USE_CERTIFI_SSL:
- if attempt == 0: # Only log info once
- logger.debug("Using certifi SSL certificates for secure Edge TTS connection")
- # Create SSL context with certifi bundle
- import certifi
- ssl_context = ssl.create_default_context(cafile=certifi.where())
- else:
- ssl_context = None
-
- # Create communicate instance
- communicate = edge_tts_sdk.Communicate(
- text=text,
- voice=voice,
- rate=rate,
- volume=volume,
- pitch=pitch,
- )
-
- # Collect audio chunks
- audio_chunks = []
- async for chunk in communicate.stream():
- if chunk["type"] == "audio":
- audio_chunks.append(chunk["data"])
-
- audio_data = b"".join(audio_chunks)
-
- if attempt > 0:
- logger.success(f"✅ Retry succeeded on attempt {attempt + 1}")
-
- logger.info(f"Generated {len(audio_data)} bytes of audio data")
-
- # Save to file if output_path is provided
- if output_path:
- with open(output_path, "wb") as f:
- f.write(audio_data)
- logger.info(f"Audio saved to: {output_path}")
-
- return audio_data
-
- except (WSServerHandshakeError, ClientResponseError) as e:
- # Network/authentication errors - retry
- last_error = e
- error_code = getattr(e, 'status', 'unknown')
- error_msg = str(e)
-
- # Log more detailed information for 401 errors
- if error_code == 401 or '401' in error_msg:
- logger.warning(f"⚠️ Edge TTS 401 Authentication Error (attempt {attempt + 1}/{retry_count + 1})")
- logger.debug(f"Error details: {error_msg}")
- logger.debug(f"This is usually caused by rate limiting. Will retry with exponential backoff...")
- else:
- logger.warning(f"⚠️ Edge TTS error (attempt {attempt + 1}/{retry_count + 1}): {error_code} - {e}")
-
- if attempt >= retry_count:
- # Last attempt failed
- logger.error(f"❌ All {retry_count + 1} attempts failed. Last error: {error_code}")
- raise
- # Otherwise, continue to next retry
-
- except NoAudioReceived as e:
- # NoAudioReceived is often a temporary issue - retry with longer delay
- last_error = e
- logger.warning(f"⚠️ Edge TTS NoAudioReceived (attempt {attempt + 1}/{retry_count + 1})")
- logger.debug(f"This is usually a temporary Microsoft service issue. Will retry with longer delay...")
-
- if attempt >= retry_count:
- logger.error(f"❌ All {retry_count + 1} attempts failed due to NoAudioReceived")
- raise
- # Add extra delay for NoAudioReceived errors
- await asyncio.sleep(2.0)
-
- except Exception as e:
- # Other errors - don't retry, raise immediately
- logger.error(f"Edge TTS error (non-retryable): {type(e).__name__} - {e}")
- raise
-
- # Should not reach here, but just in case
- if last_error:
- raise last_error
- else:
- raise RuntimeError("Edge TTS failed without error (unexpected)")
- def get_audio_duration(audio_path: str) -> float:
- """
- Get audio file duration in seconds
-
- Args:
- audio_path: Path to audio file
-
- Returns:
- Duration in seconds
- """
- try:
- # Try using ffmpeg-python
- import ffmpeg
- probe = ffmpeg.probe(audio_path)
- duration = float(probe['format']['duration'])
- return duration
- except Exception as e:
- logger.warning(f"Failed to get audio duration: {e}, using estimate")
- # Fallback: estimate based on file size (very rough)
- import os
- file_size = os.path.getsize(audio_path)
- # Assume ~16kbps for MP3, so 2KB per second
- estimated_duration = file_size / 2000
- return max(1.0, estimated_duration) # At least 1 second
- async def list_voices(locale: str = None, retry_count: int = _RETRY_COUNT, retry_base_delay: float = _RETRY_BASE_DELAY) -> list[str]:
- """
- List all available voices for Edge TTS
-
- Returns a list of voice IDs (ShortName).
- Optionally filter by locale.
-
- Includes automatic retry mechanism with exponential backoff and jitter
- to handle network errors and rate limiting.
-
- Args:
- locale: Filter by locale (e.g., zh-CN, en-US, ja-JP)
- retry_count: Number of retries on failure (default: 5)
- retry_base_delay: Base delay for exponential backoff (default: 1.0s)
-
- Returns:
- List of voice IDs
-
- Example:
- # List all voices
- voices = await list_voices()
- # Returns: ['[Chinese] zh-CN Yunjian', '[Chinese] zh-CN Xiaoxiao', ...]
-
- # List Chinese voices only
- voices = await list_voices(locale="zh-CN")
- # Returns: ['[Chinese] zh-CN Yunjian', '[Chinese] zh-CN Xiaoxiao', ...]
- """
- logger.debug(f"Fetching Edge TTS voices, locale filter: {locale}, retry_count: {retry_count}")
-
- # Use semaphore to limit concurrent requests
- request_semaphore = _get_request_semaphore()
- async with request_semaphore:
- # Add a small random delay before each request to avoid rate limiting
- pre_delay = _REQUEST_DELAY + random.uniform(0, 0.3)
- logger.debug(f"Waiting {pre_delay:.2f}s before request (rate limiting)")
- await asyncio.sleep(pre_delay)
-
- last_error = None
-
- # Retry loop
- for attempt in range(retry_count + 1):
- if attempt > 0:
- # Exponential backoff with jitter
- exponential_delay = retry_base_delay * (2 ** (attempt - 1))
- jitter = random.uniform(0, retry_base_delay)
- retry_delay = min(exponential_delay + jitter, _MAX_RETRY_DELAY)
-
- logger.info(f"🔄 Retrying list voices (attempt {attempt + 1}/{retry_count + 1}) after {retry_delay:.2f}s delay...")
- await asyncio.sleep(retry_delay)
-
- try:
- # Get all voices (edge-tts handles SSL internally)
- voices = await edge_tts_sdk.list_voices()
-
- # Filter by locale if specified
- if locale:
- voices = [v for v in voices if v["Locale"].startswith(locale)]
-
- # Extract voice IDs (ShortName)
- voice_ids = [voice["ShortName"] for voice in voices]
-
- if attempt > 0:
- logger.success(f"✅ Retry succeeded on attempt {attempt + 1}")
-
- logger.info(f"Found {len(voice_ids)} voices" + (f" for locale '{locale}'" if locale else ""))
- return voice_ids
-
- except (WSServerHandshakeError, ClientResponseError) as e:
- # Network/authentication errors - retry
- last_error = e
- error_code = getattr(e, 'status', 'unknown')
- error_msg = str(e)
-
- # Log more detailed information for 401 errors
- if error_code == 401 or '401' in error_msg:
- logger.warning(f"⚠️ Edge TTS 401 Authentication Error (list_voices attempt {attempt + 1}/{retry_count + 1})")
- logger.debug(f"Error details: {error_msg}")
- logger.debug(f"This is usually caused by rate limiting. Will retry with exponential backoff...")
- else:
- logger.warning(f"⚠️ List voices error (attempt {attempt + 1}/{retry_count + 1}): {error_code} - {e}")
-
- if attempt >= retry_count:
- logger.error(f"❌ All {retry_count + 1} attempts failed. Last error: {error_code}")
- raise
-
- except Exception as e:
- # Other errors - don't retry, raise immediately
- logger.error(f"List voices error (non-retryable): {type(e).__name__} - {e}")
- raise
-
- # Should not reach here, but just in case
- if last_error:
- raise last_error
- else:
- raise RuntimeError("List voices failed without error (unexpected)")
|