Spaces:
Sleeping
Sleeping
| import re | |
| from typing import Optional | |
| import edge_tts | |
| from langdetect import detect | |
| from src.utils.logger import setup_logger | |
| logger = setup_logger(__name__) | |
| class TextToSpeechService: | |
| """ | |
| Service responsible for converting generated summaries | |
| or notes into speech using Microsoft Edge TTS. | |
| Features: | |
| 1. Text cleaning | |
| 2. Automatic language detection | |
| 3. Voice selection based on language | |
| 4. Audio generation | |
| """ | |
| VOICE_MAP = { | |
| "ar": "ar-EG-SalmaNeural", | |
| "en": "en-US-AriaNeural", | |
| "fr": "fr-FR-DeniseNeural", | |
| "ja": "ja-JP-NanamiNeural", | |
| "de": "de-DE-KatjaNeural", | |
| "es": "es-ES-ElviraNeural", | |
| "it": "it-IT-ElsaNeural", | |
| "pt": "pt-BR-FranciscaNeural", | |
| "ru": "ru-RU-SvetlanaNeural", | |
| "zh-cn": "zh-CN-XiaoxiaoNeural", | |
| "zh-tw": "zh-TW-HsiaoChenNeural", | |
| "ko": "ko-KR-SunHiNeural", | |
| "tr": "tr-TR-EmelNeural", | |
| "pl": "pl-PL-ZofiaNeural", | |
| "sv": "sv-SE-SofieNeural", | |
| "hi": "hi-IN-SwaraNeural", | |
| "id": "id-ID-GadisNeural", | |
| } | |
| DEFAULT_VOICE = "en-US-AriaNeural" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Text Cleaning | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _clean_text(self, text: str) -> str: | |
| """ | |
| Remove URLs, markdown symbols, YouTube metadata, | |
| and unnecessary characters before TTS generation. | |
| """ | |
| # 1. Remove YouTube header block first (before anything else) | |
| text = re.sub( | |
| r'#\s*YouTube Video.*?---', | |
| '', | |
| text, | |
| flags=re.DOTALL | |
| ) | |
| text = re.sub( | |
| r'^.*?(Ψ§ΩΩ Ψ΅Ψ―Ψ±|Ψ§ΩΩ Ψ―Ψ©|Source|Duration|YouTube|youtu\.be|youtube\.com).*$', | |
| '', | |
| text, | |
| flags=re.MULTILINE | re.IGNORECASE | |
| ) | |
| # 2. Remove URLs | |
| text = re.sub( | |
| r'https?://\S+|www\.\S+', | |
| ' ', | |
| text | |
| ) | |
| # 3. Remove standalone numbers on their own line | |
| text = re.sub( | |
| r'^\s*\d+\s*$', | |
| '', | |
| text, | |
| flags=re.MULTILINE | |
| ) | |
| # 4. Remove Markdown headers | |
| text = re.sub(r'#+', ' ', text) | |
| # 5. Remove Markdown symbols | |
| text = re.sub(r'[*_~`>|]', ' ', text) | |
| # 6. Remove dashes | |
| text = re.sub(r'-+', ' ', text) | |
| # 7. Keep only useful characters | |
| text = re.sub( | |
| r'[^\w\s\u0600-\u06FF.,!?ΨΨ:\n]', | |
| ' ', | |
| text | |
| ) | |
| # 8. Collapse extra spaces | |
| text = re.sub(r'\s+', ' ', text) | |
| return text.strip() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Language Detection | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _detect_voice(self, text: str) -> str: | |
| """ | |
| Detect language and return matching voice. | |
| """ | |
| try: | |
| language = detect(text) | |
| logger.info( | |
| f"Detected language: {language}" | |
| ) | |
| return self.VOICE_MAP.get( | |
| language, | |
| self.DEFAULT_VOICE | |
| ) | |
| except Exception as e: | |
| logger.warning( | |
| f"Language detection failed: {e}" | |
| ) | |
| return self.DEFAULT_VOICE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Generate Speech | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def generate_audio( | |
| self, | |
| text: str, | |
| output_file: str = "summary.mp3", | |
| rate: str = "-10%" | |
| ) -> str: | |
| """ | |
| Convert text into speech and save it as MP3. | |
| Returns generated audio path. | |
| """ | |
| cleaned_text = self._clean_text(text) | |
| if not cleaned_text: | |
| raise ValueError( | |
| "No valid text available for speech generation." | |
| ) | |
| voice = self._detect_voice(cleaned_text) | |
| logger.info( | |
| f"Generating speech using voice: {voice}" | |
| ) | |
| communicate = edge_tts.Communicate( | |
| text=cleaned_text, | |
| voice=voice, | |
| rate=rate | |
| ) | |
| await communicate.save(output_file) | |
| logger.info( | |
| f"Audio saved successfully: {output_file}" | |
| ) | |
| return output_file |