tts_util.py 14 KB

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