Explorar o código

feat: 添加语音播客生成功能

- 新增 TTS 音频生成服务,支持将脚本转换为语音文件
- 新增播客合成服务,使用 pydub 合并音频片段为完整播客
- 更新环境配置示例,增加 TTS API、FFmpeg 路径等设置
- 添加 pydub 依赖以支持音频处理
- 集成语音生成到主代理流程,研究完成后自动生成播客
- 添加验证脚本用于测试 ECNU LLM/TTS API 及 FFmpeg 配置
JJSun hai 7 meses
pai
achega
ff96b63a14

+ 27 - 7
Co-creation-projects/JJason-DeepCastAgent/backend/env.example

@@ -1,9 +1,29 @@
-# LLM API密钥
-OPENAI_API_KEY=your_openai_api_key_here
-# ANTHROPIC_API_KEY=your_anthropic_api_key_here
-# DASHSCOPE_API_KEY=your_dashscope_api_key_here
+# 核心配置
+LOG_LEVEL=INFO
+SEARCH_API=duckduckgo
+MAX_WEB_RESEARCH_LOOPS=3
+FETCH_FULL_PAGE=True
 
-# 其他API密钥(根据项目需要添加)
-# AMAP_API_KEY=your_amap_api_key_here
-# UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
+# TEXT模型配置 (LLM)
+LLM_PROVIDER=custom
+LLM_MODEL_ID=ecnu-max
+LLM_API_KEY=your_ecnu_api_key_here
+LLM_BASE_URL=your_openai_api_key_here
+LLM_TIMEOUT=60
 
+# TTS 配置 (复用 ECNU API Key)
+TTS_API_KEY=your_ecnu_api_key_here
+TTS_BASE_URL=your_openai_api_key_here
+TTS_MODEL=ecnu-tts
+AUDIO_OUTPUT_DIR=./output/audio
+
+# FFmpeg 配置 (Windows 环境通常需要指定路径)
+FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
+
+# 网络代理例外配置 (如有内网或特定 API 不需要走代理)
+NO_PROXY=chat.ecnu.edu.cn,api.longcat.chat,open.bigmodel.cn
+
+# 服务器配置
+HOST=0.0.0.0
+PORT=8000
+CORS_ORIGINS=http://localhost:5173,http://localhost:3000

+ 1 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/pyproject.toml

@@ -19,6 +19,7 @@ dependencies = [
     "ddgs>=9.6.1",
     "loguru>=0.7.3",
     "huggingface-hub>=1.3.3",
+    "pydub>=0.25.1",
 ]
 
 [project.optional-dependencies]

+ 33 - 1
Co-creation-projects/JJason-DeepCastAgent/backend/src/agent.py

@@ -24,6 +24,8 @@ from models import SummaryState, SummaryStateOutput, TodoItem
 from services.planner import PlanningService
 from services.reporter import ReportingService
 from services.script_generator import ScriptGenerationService
+from services.audio_generator import AudioGenerationService
+from services.audio_synthesizer import PodcastSynthesisService
 from services.search import dispatch_search, prepare_research_context
 from services.summarizer import SummarizationService
 from services.tool_events import ToolCallTracker
@@ -73,8 +75,10 @@ class DeepResearchAgent:
         self.planner = PlanningService(self.todo_agent, self.config)
         self.summarizer = SummarizationService(self._summarizer_factory, self.config)
         self.reporting = ReportingService(self.report_agent, self.config)
-        self._last_search_notices: list[str] = []
         self.script_generator = ScriptGenerationService(self.llm, self.config)
+        self.audio_generator = AudioGenerationService(self.config)
+        self.podcast_synthesizer = PodcastSynthesisService(self.config)
+        self._last_search_notices: list[str] = []
 
     # ------------------------------------------------------------------
     # Public API
@@ -144,10 +148,22 @@ class DeepResearchAgent:
         state.running_summary = report
         self._persist_final_report(state, report)
 
+        script = self.script_generator.generate_script(state)
+        self._drain_tool_events(state)
+        state.podcast_script = script
+
+        # Generate audio for the script
+        task_id = f"task_{state.report_note_id}" if state.report_note_id else "task_default"
+        audio_files = self.audio_generator.generate_audio(script, task_id)
+
+        # Synthesize podcast
+        podcast_file = self.podcast_synthesizer.synthesize_podcast(audio_files, task_id)
+        
         return SummaryStateOutput(
             running_summary=report,
             report_markdown=report,
             todo_items=state.todo_items,
+            podcast_script=script,
         )
 
     def run_stream(self, topic: str) -> Iterator[dict[str, Any]]:
@@ -295,6 +311,22 @@ class DeepResearchAgent:
             "script": script,
         }
 
+        yield {"type": "status", "message": "正在生成语音文件..."}
+        task_id = f"task_{state.report_note_id}" if state.report_note_id else "task_default"
+        audio_files = self.audio_generator.generate_audio(script, task_id)
+        yield {
+            "type": "audio_generated",
+            "files": audio_files,
+        }
+
+        yield {"type": "status", "message": "正在合成完整播客..."}
+        podcast_file = self.podcast_synthesizer.synthesize_podcast(audio_files, task_id)
+        if podcast_file:
+             yield {
+                "type": "podcast_ready",
+                "file": podcast_file,
+            }
+
         yield {"type": "done"}
 
     # ------------------------------------------------------------------

+ 37 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/src/config.py

@@ -86,6 +86,31 @@ class Configuration(BaseModel):
         title="LLM Model ID",
         description="Optional model identifier for custom OpenAI-compatible services",
     )
+    tts_api_key: Optional[str] = Field(
+        default=None,
+        title="TTS API Key",
+        description="API key for TTS service",
+    )
+    tts_base_url: str = Field(
+        default="https://chat.ecnu.edu.cn/open/api/v1/audio/speech",
+        title="TTS Base URL",
+        description="Base URL for TTS API",
+    )
+    tts_model: str = Field(
+        default="ecnu-tts",
+        title="TTS Model",
+        description="Model identifier for TTS service",
+    )
+    audio_output_dir: str = Field(
+        default="./output/audio",
+        title="Audio Output Directory",
+        description="Directory to save generated audio files",
+    )
+    ffmpeg_path: Optional[str] = Field(
+        default=None,
+        title="FFmpeg Path",
+        description="Path to ffmpeg executable",
+    )
 
     @classmethod
     def from_env(cls, overrides: Optional[dict[str, Any]] = None) -> "Configuration":
@@ -115,8 +140,20 @@ class Configuration(BaseModel):
             "search_api": os.getenv("SEARCH_API"),
             "enable_notes": os.getenv("ENABLE_NOTES"),
             "notes_workspace": os.getenv("NOTES_WORKSPACE"),
+            "tts_api_key": os.getenv("TTS_API_KEY"),
+            "tts_base_url": os.getenv("TTS_BASE_URL"),
+            "tts_model": os.getenv("TTS_MODEL"),
+            "audio_output_dir": os.getenv("AUDIO_OUTPUT_DIR"),
+            "ffmpeg_path": os.getenv("FFMPEG_PATH"),
         }
 
+        # Handle NO_PROXY
+        no_proxy = os.getenv("NO_PROXY")
+        if no_proxy:
+            os.environ["NO_PROXY"] = no_proxy
+            # Also set lowercase for compatibility
+            os.environ["no_proxy"] = no_proxy
+
         for key, value in env_aliases.items():
             if value is not None:
                 raw_values.setdefault(key, value)

+ 123 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/audio_generator.py

@@ -0,0 +1,123 @@
+"""Service for generating audio from text using TTS API."""
+
+from __future__ import annotations
+
+import logging
+import os
+import requests
+from pathlib import Path
+from typing import List, Optional
+
+from config import Configuration
+
+logger = logging.getLogger(__name__)
+
+
+class AudioGenerationService:
+    """Handles interaction with TTS service to generate audio files."""
+
+    def __init__(self, config: Configuration) -> None:
+        self._config = config
+        self._output_dir = Path(config.audio_output_dir)
+        self._ensure_output_dir()
+
+    def _ensure_output_dir(self) -> None:
+        """Create output directory if it doesn't exist."""
+        if not self._output_dir.exists():
+            try:
+                self._output_dir.mkdir(parents=True, exist_ok=True)
+                logger.info("Created audio output directory: %s", self._output_dir)
+            except Exception as e:
+                logger.error("Failed to create audio output directory: %s", e)
+
+    def generate_audio(self, script: List[dict[str, str]], task_id: str = "default") -> List[str]:
+        """
+        Generate audio files for a given script.
+        
+        Args:
+            script: List of dialogue turns, e.g. [{"role": "Host", "content": "..."}, ...]
+            task_id: Unique identifier for the current task/session
+            
+        Returns:
+            List of paths to generated audio files
+        """
+        if not self._config.tts_api_key:
+            logger.warning("TTS API key not configured. Skipping audio generation.")
+            return []
+
+        generated_files = []
+        
+        for index, turn in enumerate(script):
+            role = turn.get("role", "")
+            content = turn.get("content", "")
+            
+            if not role or not content:
+                continue
+                
+            voice_id = self._get_voice_for_role(role)
+            if not voice_id:
+                logger.warning("Unknown role: %s. Using default voice.", role)
+                voice_id = "xiayu" # Fallback
+            
+            file_name = f"{task_id}_{index:03d}_{role}.mp3"
+            file_path = self._output_dir / file_name
+            
+            if self._call_tts_api(content, voice_id, file_path):
+                generated_files.append(str(file_path))
+            else:
+                logger.error("Failed to generate audio for turn %d (%s)", index, role)
+                
+        logger.info("Generated %d audio files for task %s", len(generated_files), task_id)
+        return generated_files
+
+    def _get_voice_for_role(self, role: str) -> str:
+        """Map role names to voice IDs."""
+        role_lower = role.lower()
+        if "host" in role_lower or "xiayu" in role_lower:
+            return "xiayu"
+        elif "guest" in role_lower or "liwa" in role_lower:
+            return "liwa"
+        return "xiayu"
+
+    def _call_tts_api(self, text: str, voice: str, output_path: Path) -> bool:
+        """Call the TTS API and save the audio file."""
+        if output_path.exists():
+            logger.debug("Audio file already exists: %s", output_path)
+            return True
+
+        headers = {
+            "Authorization": f"Bearer {self._config.tts_api_key}",
+            "Content-Type": "application/json"
+        }
+        
+        payload = {
+            "model": self._config.tts_model,
+            "input": text,
+            "voice": voice,
+            "speed": 1.0
+        }
+        
+        try:
+            logger.debug("Calling TTS API for voice %s: %s...", voice, text[:20])
+            response = requests.post(
+                self._config.tts_base_url,
+                json=payload,
+                headers=headers,
+                timeout=30 # TTS generation might take some time
+            )
+            
+            if response.status_code == 200:
+                with open(output_path, "wb") as f:
+                    f.write(response.content)
+                return True
+            else:
+                logger.error(
+                    "TTS API failed with status %d: %s", 
+                    response.status_code, 
+                    response.text
+                )
+                return False
+                
+        except Exception as e:
+            logger.exception("Exception during TTS API call: %s", e)
+            return False

+ 83 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/audio_synthesizer.py

@@ -0,0 +1,83 @@
+"""Service for synthesizing audio segments into a single podcast file."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import List
+
+from pydub import AudioSegment
+
+from config import Configuration
+
+logger = logging.getLogger(__name__)
+
+
+class PodcastSynthesisService:
+    """Combines multiple audio segments into a final podcast file."""
+
+    def __init__(self, config: Configuration) -> None:
+        self._config = config
+        self._output_dir = Path(config.audio_output_dir)
+        
+        # Configure ffmpeg path if provided
+        if config.ffmpeg_path:
+            AudioSegment.converter = config.ffmpeg_path
+            logger.info("Configured ffmpeg path: %s", config.ffmpeg_path)
+        
+        # Ensure pydub/ffmpeg is available - assuming ffmpeg is installed on system
+        # If not, pydub might warn or fail, but we'll catch exceptions.
+
+    def synthesize_podcast(self, audio_files: List[str], task_id: str = "default") -> str | None:
+        """
+        Combine audio files into a single podcast MP3.
+
+        Args:
+            audio_files: List of paths to input audio files in order.
+            task_id: Unique identifier for the output filename.
+
+        Returns:
+            Path to the final podcast file, or None if failed.
+        """
+        if not audio_files:
+            logger.warning("No audio files provided for synthesis.")
+            return None
+
+        try:
+            combined = AudioSegment.empty()
+            
+            # Silence between segments (e.g. 500ms)
+            silence = AudioSegment.silent(duration=500)
+
+            valid_segments_count = 0
+            for file_path in audio_files:
+                path = Path(file_path)
+                if not path.exists():
+                    logger.warning("Audio file not found: %s", file_path)
+                    continue
+                
+                try:
+                    segment = AudioSegment.from_file(file_path, format="mp3")
+                    if valid_segments_count > 0:
+                        combined += silence
+                    combined += segment
+                    valid_segments_count += 1
+                except Exception as e:
+                    logger.error("Failed to load audio segment %s: %s", file_path, e)
+
+            if valid_segments_count == 0:
+                logger.error("No valid audio segments to combine.")
+                return None
+
+            output_filename = f"podcast_{task_id}.mp3"
+            output_path = self._output_dir / output_filename
+            
+            # Export
+            logger.info("Exporting podcast to %s...", output_path)
+            combined.export(output_path, format="mp3")
+            
+            return str(output_path)
+
+        except Exception as e:
+            logger.exception("Podcast synthesis failed: %s", e)
+            return None

+ 94 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/tests/test_audio_generator.py

@@ -0,0 +1,94 @@
+import unittest
+from unittest.mock import MagicMock, patch, mock_open
+import sys
+import os
+from pathlib import Path
+
+# Add src to path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
+
+from services.audio_generator import AudioGenerationService
+from config import Configuration
+
+class TestAudioGenerationService(unittest.TestCase):
+    def setUp(self):
+        self.mock_config = MagicMock()
+        self.mock_config.tts_api_key = "test_key"
+        self.mock_config.audio_output_dir = "./test_output"
+        self.mock_config.tts_base_url = "http://test.api/tts"
+        self.mock_config.tts_model = "test-tts"
+        self.mock_config.ffmpeg_path = "ffmpeg"
+
+        # Patch Path.mkdir to avoid actual filesystem creation during init
+        with patch('pathlib.Path.mkdir'):
+            self.service = AudioGenerationService(self.mock_config)
+
+    @patch('requests.post')
+    @patch('builtins.open', new_callable=mock_open)
+    @patch('pathlib.Path.exists')
+    def test_generate_audio_success(self, mock_exists, mock_file, mock_post):
+        # Setup mocks
+        mock_exists.return_value = False # File doesn't exist
+        
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.content = b"audio_data"
+        mock_post.return_value = mock_response
+        
+        script = [
+            {"role": "Host", "content": "Hello world"},
+            {"role": "Guest", "content": "Hi host"}
+        ]
+        
+        # Execute
+        files = self.service.generate_audio(script, "task_123")
+        
+        # Verify
+        self.assertEqual(len(files), 2)
+        self.assertTrue(files[0].endswith("task_123_000_Host.mp3"))
+        self.assertTrue(files[1].endswith("task_123_001_Guest.mp3"))
+        
+        # Verify API calls
+        self.assertEqual(mock_post.call_count, 2)
+        
+        # Check first call arguments
+        args, kwargs = mock_post.call_args_list[0]
+        self.assertEqual(kwargs['json']['voice'], 'xiayu')
+        self.assertEqual(kwargs['json']['input'], 'Hello world')
+        
+        # Check second call arguments
+        args, kwargs = mock_post.call_args_list[1]
+        self.assertEqual(kwargs['json']['voice'], 'liwa')
+        self.assertEqual(kwargs['json']['input'], 'Hi host')
+
+    def test_generate_audio_no_api_key(self):
+        self.mock_config.tts_api_key = None
+        script = [{"role": "Host", "content": "Hello"}]
+        
+        files = self.service.generate_audio(script)
+        self.assertEqual(files, [])
+
+    @patch('requests.post')
+    @patch('pathlib.Path.exists')
+    def test_generate_audio_api_failure(self, mock_exists, mock_post):
+        mock_exists.return_value = False
+        
+        mock_response = MagicMock()
+        mock_response.status_code = 500
+        mock_response.text = "Internal Server Error"
+        mock_post.return_value = mock_response
+        
+        script = [{"role": "Host", "content": "Hello"}]
+        
+        files = self.service.generate_audio(script)
+        self.assertEqual(files, [])
+
+    def test_get_voice_for_role(self):
+        self.assertEqual(self.service._get_voice_for_role("Host"), "xiayu")
+        self.assertEqual(self.service._get_voice_for_role("Xiayu"), "xiayu")
+        self.assertEqual(self.service._get_voice_for_role("Guest"), "liwa")
+        self.assertEqual(self.service._get_voice_for_role("Liwa"), "liwa")
+        self.assertEqual(self.service._get_voice_for_role("Unknown"), "xiayu") # Default
+
+if __name__ == '__main__':
+    unittest.main()

+ 61 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/tests/verify_ecnu_llm.py

@@ -0,0 +1,61 @@
+import os
+import sys
+import requests
+from dotenv import load_dotenv
+
+# Add src to path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
+
+# Load env
+load_dotenv(os.path.join(os.path.dirname(__file__), '../.env'))
+
+def test_llm_api():
+    api_key = os.getenv("LLM_API_KEY")
+    base_url = os.getenv("LLM_BASE_URL", "https://chat.ecnu.edu.cn/open/api/v1")
+    model = os.getenv("LLM_MODEL_ID", "ecnu-max")
+    
+    # Ensure URL ends with /chat/completions
+    if not base_url.endswith("/chat/completions"):
+        url = f"{base_url.rstrip('/')}/chat/completions"
+    else:
+        url = base_url
+
+    print(f"Testing LLM API...")
+    print(f"URL: {url}")
+    print(f"Model: {model}")
+    print(f"API Key: {api_key[:8]}..." if api_key else "API Key: None")
+
+    if not api_key:
+        print("❌ Error: API Key not found in environment variables")
+        return
+
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json"
+    }
+
+    payload = {
+        "model": model,
+        "messages": [
+            {"role": "user", "content": "你好,请回复“API 测试成功”"}
+        ],
+        "temperature": 0.7
+    }
+
+    try:
+        response = requests.post(url, json=payload, headers=headers, timeout=30)
+        
+        if response.status_code == 200:
+            result = response.json()
+            content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
+            print(f"✅ Success!")
+            print(f"Response: {content}")
+        else:
+            print(f"❌ Failed with status {response.status_code}")
+            print(f"Response: {response.text}")
+
+    except Exception as e:
+        print(f"❌ Exception: {e}")
+
+if __name__ == "__main__":
+    test_llm_api()

+ 55 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/tests/verify_ecnu_tts.py

@@ -0,0 +1,55 @@
+import os
+import sys
+import requests
+from dotenv import load_dotenv
+
+# Add src to path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
+
+# Load env
+load_dotenv(os.path.join(os.path.dirname(__file__), '../.env'))
+
+def test_tts_api():
+    api_key = os.getenv("TTS_API_KEY") or os.getenv("LLM_API_KEY")
+    base_url = os.getenv("TTS_BASE_URL", "https://chat.ecnu.edu.cn/open/api/v1/audio/speech")
+    model = os.getenv("TTS_MODEL", "ecnu-tts")
+    
+    print(f"Testing TTS API...")
+    print(f"URL: {base_url}")
+    print(f"Model: {model}")
+    print(f"API Key: {api_key[:8]}..." if api_key else "API Key: None")
+
+    if not api_key:
+        print("❌ Error: API Key not found in environment variables")
+        return
+
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json"
+    }
+
+    payload = {
+        "model": model,
+        "input": "你好,这是一个测试语音。",
+        "voice": "xiayu",
+        "speed": 1.0
+    }
+
+    try:
+        response = requests.post(base_url, json=payload, headers=headers, timeout=30)
+        
+        if response.status_code == 200:
+            output_file = "test_tts_output.mp3"
+            with open(output_file, "wb") as f:
+                f.write(response.content)
+            print(f"✅ Success! Audio saved to {output_file}")
+            print(f"Response size: {len(response.content)} bytes")
+        else:
+            print(f"❌ Failed with status {response.status_code}")
+            print(f"Response: {response.text}")
+
+    except Exception as e:
+        print(f"❌ Exception: {e}")
+
+if __name__ == "__main__":
+    test_tts_api()

+ 50 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/tests/verify_ffmpeg.py

@@ -0,0 +1,50 @@
+import os
+import sys
+from pydub import AudioSegment
+
+"""
+DeepCast 项目使用 pydub 库将多个 TTS 生成的音频片段(MP3)合成为最终的播客文件。
+pydub 底层依赖 ffmpeg 进行音频格式转换和处理(特别是 MP3 导出)。
+因此,必须确保系统已安装 ffmpeg 且 Python 环境能正确找到其路径。
+此脚本用于验证 ffmpeg 是否配置正确且能被 pydub 调用。
+"""
+
+# 设置 ffmpeg 路径
+ffmpeg_path = r"C:\ffmpeg\bin\ffmpeg.exe"
+AudioSegment.converter = ffmpeg_path
+
+def test_ffmpeg():
+    print(f"Testing ffmpeg at: {ffmpeg_path}")
+    
+    # Check if file exists
+    if not os.path.exists(ffmpeg_path):
+        print(f"❌ Warning: ffmpeg executable not found at {ffmpeg_path}")
+    else:
+        print(f"✅ ffmpeg executable found.")
+    
+    try:
+        # 创建 1 秒的静音片段
+        print("Creating silent audio segment...")
+        silence = AudioSegment.silent(duration=1000)
+        
+        output_file = "test_ffmpeg_output.mp3"
+        print(f"Exporting to {output_file}...")
+        
+        # 导出需要 ffmpeg
+        silence.export(output_file, format="mp3")
+        
+        if os.path.exists(output_file):
+            print("✅ Success! ffmpeg is working correctly.")
+            print(f"Output file size: {os.path.getsize(output_file)} bytes")
+            # 清理文件
+            os.remove(output_file)
+        else:
+            print("❌ Failed: Output file was not created.")
+            
+    except Exception as e:
+        print(f"❌ Exception: {e}")
+        # import traceback
+        # traceback.print_exc()
+
+if __name__ == "__main__":
+    test_ffmpeg()

+ 11 - 0
Co-creation-projects/JJason-DeepCastAgent/backend/uv.lock

@@ -363,6 +363,7 @@ dependencies = [
     { name = "huggingface-hub" },
     { name = "loguru" },
     { name = "openai" },
+    { name = "pydub" },
     { name = "python-dotenv" },
     { name = "requests" },
     { name = "tavily-python" },
@@ -389,6 +390,7 @@ requires-dist = [
     { name = "loguru", specifier = ">=0.7.3" },
     { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11.1" },
     { name = "openai", specifier = ">=1.12.0" },
+    { name = "pydub", specifier = ">=0.25.1" },
     { name = "python-dotenv", specifier = "==1.0.1" },
     { name = "requests", specifier = ">=2.31.0" },
     { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.1" },
@@ -1310,6 +1312,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" },
 ]
 
+[[package]]
+name = "pydub"
+version = "0.25.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" },
+]
+
 [[package]]
 name = "python-dotenv"
 version = "1.0.1"