Files
qiming/qiming-mcp-proxy/voice-cli/tts_service.py
2026-06-01 13:03:20 +08:00

429 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
TTS服务模块 - 使用index-tts库进行语音合成
"""
import os
import sys
import tempfile
import asyncio
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
import indextts
INDEX_TTS_AVAILABLE = True
logger.info("IndexTTS library imported successfully")
except ImportError as e:
INDEX_TTS_AVAILABLE = False
logger.warning(f"IndexTTS library not available: {e}")
try:
import torch
import torchaudio
import numpy as np
import soundfile as sf
AUDIO_LIBS_AVAILABLE = True
logger.info("Audio processing libraries imported successfully")
except ImportError as e:
AUDIO_LIBS_AVAILABLE = False
logger.warning(f"Audio processing libraries not available: {e}")
class TTSService:
"""TTS服务类 - 使用IndexTTS库进行语音合成"""
def __init__(self, model_path: Optional[str] = None):
"""
初始化TTS服务
Args:
model_path: TTS模型路径如果为None则使用默认模型
"""
self.model_path = model_path
self.model = None
self.device = "cuda" if torch.cuda.is_available() else "cpu"
if not INDEX_TTS_AVAILABLE:
logger.warning("IndexTTS not available, using mock implementation")
if not AUDIO_LIBS_AVAILABLE:
logger.warning("Audio processing libraries not available, using mock implementation")
logger.info(f"TTS service initialized (device: {self.device})")
def _setup_environment(self):
"""设置Python环境"""
logger.info("TTS environment setup complete")
def load_model(self, model_name: str = "default"):
"""
加载TTS模型
Args:
model_name: 模型名称
"""
try:
if INDEX_TTS_AVAILABLE and AUDIO_LIBS_AVAILABLE:
# 使用真实的IndexTTS库
# IndexTTS 需要语音提示文件,我们使用一个默认的或从模型路径加载
from indextts.infer import IndexTTS
model_dir = self.model_path or "checkpoints"
config_path = f"{model_dir}/config.yaml"
self.model = IndexTTS(
model_dir=model_dir,
cfg_path=config_path
)
logger.info(f"IndexTTS model config loaded successfully: {model_name}")
else:
# Mock实现
self.model = f"mock_model_{model_name}"
logger.info(f"Mock IndexTTS model loaded: {model_name}")
except Exception as e:
logger.error(f"Failed to load TTS model: {e}")
raise
def synthesize_sync(
self,
text: str,
output_path: str,
model: Optional[str] = None,
speed: float = 1.0,
pitch: int = 0,
volume: float = 1.0,
format: str = "mp3"
) -> Dict[str, Any]:
"""
同步语音合成
Args:
text: 要合成的文本
output_path: 输出文件路径
model: 模型名称
speed: 语速 (0.5-2.0)
pitch: 音调 (-20到20)
volume: 音量 (0.5-2.0)
format: 输出格式
Returns:
包含合成结果的字典
"""
try:
# 确保模型已加载
if self.model is None:
self.load_model(model or "default")
# 验证参数
if not text.strip():
raise ValueError("Text cannot be empty")
if not (0.5 <= speed <= 2.0):
raise ValueError("Speed must be between 0.5 and 2.0")
if not (-20 <= pitch <= 20):
raise ValueError("Pitch must be between -20 and 20")
if not (0.5 <= volume <= 2.0):
raise ValueError("Volume must be between 0.5 and 2.0")
# 确保输出目录存在
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
import time
start_time = time.time()
if INDEX_TTS_AVAILABLE and AUDIO_LIBS_AVAILABLE:
# 使用真实的TTS库进行合成
try:
# 合成音频
logger.info(f"Starting TTS synthesis for text: {text[:50]}...")
# 使用TTS进行合成
self.model.infer(
audio_prompt="reference_voice.wav",
text=text,
output_path=output_path
)
logger.info(f"TTS synthesis completed successfully")
logger.info(f"TTS synthesis completed in {time.time() - start_time:.2f}s")
except Exception as e:
logger.error(f"TTS synthesis failed: {e}")
# 回退到Mock实现
return self._mock_synthesize(text, output_path, speed, pitch, volume, format)
else:
# 使用Mock实现
return self._mock_synthesize(text, output_path, speed, pitch, volume, format)
# 检查输出文件是否存在
if not Path(output_path).exists():
raise FileNotFoundError(f"Output file not created: {output_path}")
file_size = Path(output_path).stat().st_size
duration = len(text) * 0.1 # 估算时长
return {
"success": True,
"output_path": output_path,
"file_size": file_size,
"duration": duration,
"text_length": len(text),
"parameters": {
"speed": speed,
"pitch": pitch,
"volume": volume,
"format": format
}
}
except Exception as e:
logger.error(f"TTS synthesis failed: {e}")
return {
"success": False,
"error": str(e),
"output_path": None,
"file_size": 0
}
def _mock_synthesize(
self,
text: str,
output_path: str,
speed: float = 1.0,
pitch: int = 0,
volume: float = 1.0,
format: str = "mp3"
) -> Dict[str, Any]:
"""Mock TTS合成实现 - 使用真实音频库生成音频"""
try:
import time
start_time = time.time()
# 使用真实音频库生成音频
if AUDIO_LIBS_AVAILABLE:
try:
# 生成真实音频数据
sample_rate = 22050
base_duration = max(1.0, len(text) * 0.05) # 基础时长 + 每字符0.05秒
duration = base_duration / speed # 根据语速调整
# 根据文本生成不同频率的正弦波
base_freq = 220.0 + pitch * 5 # 基础频率 + 音调调整
text_hash = hash(text)
freq_variation = (text_hash % 100) + 50
frequency = base_freq + freq_variation
# 生成时间轴
t = np.linspace(0, duration, int(sample_rate * duration), False)
# 生成正弦波
sine_wave = np.sin(2 * np.pi * frequency * t)
# 添加包络使其更像语音
envelope = np.exp(-t * 1.5)
audio_data = sine_wave * envelope
# 添加少量噪声
noise = np.random.normal(0, 0.005, audio_data.shape)
audio_data = audio_data + noise
# 应用音量调整
audio_data = audio_data * volume
# 归一化
audio_data = audio_data / np.max(np.abs(audio_data)) * 0.8
# 转换为torch张量
audio_tensor = torch.from_numpy(audio_data).float()
if audio_tensor.dim() == 1:
audio_tensor = audio_tensor.unsqueeze(0)
# 保存音频文件
if format.lower() == "wav":
torchaudio.save(output_path, audio_tensor, sample_rate)
elif format.lower() == "mp3":
# 先保存为WAV
temp_wav = output_path.replace('.mp3', '.wav')
torchaudio.save(temp_wav, audio_tensor, sample_rate)
# 尝试转换为MP3
try:
import subprocess
subprocess.run([
'ffmpeg', '-y', '-i', temp_wav,
'-codec:a', 'libmp3lame', '-qscale:a', '2',
output_path
], check=True, capture_output=True)
Path(temp_wav).unlink(missing_ok=True)
except (subprocess.CalledProcessError, FileNotFoundError):
logger.warning("ffmpeg not available, using WAV format instead")
Path(temp_wav).rename(output_path)
else:
torchaudio.save(output_path, audio_tensor, sample_rate)
actual_duration = duration
logger.info(f"Real audio synthesis completed in {time.time() - start_time:.2f}s")
except Exception as e:
logger.error(f"Real audio synthesis failed: {e}")
# 回退到简单mock
return self._simple_mock_synthesize(text, output_path, speed, pitch, volume, format)
else:
# 没有音频库使用简单mock
return self._simple_mock_synthesize(text, output_path, speed, pitch, volume, format)
# 验证文件
if not Path(output_path).exists():
raise FileNotFoundError(f"Output file not created: {output_path}")
file_size = Path(output_path).stat().st_size
return {
"success": True,
"output_path": output_path,
"file_size": file_size,
"duration": actual_duration,
"text_length": len(text),
"parameters": {
"speed": speed,
"pitch": pitch,
"volume": volume,
"format": format
}
}
except Exception as e:
logger.error(f"Mock TTS synthesis failed: {e}")
raise Exception(f"Mock TTS synthesis failed: {e}")
def _simple_mock_synthesize(
self,
text: str,
output_path: str,
speed: float = 1.0,
pitch: int = 0,
volume: float = 1.0,
format: str = "mp3"
) -> Dict[str, Any]:
"""简单Mock TTS合成实现"""
try:
# 创建模拟音频文件
with open(output_path, 'wb') as f:
# 根据文本长度生成模拟数据
mock_data_size = max(1024, len(text) * 16) # 基础1KB + 每字符16字节
f.write(b'\x00' * mock_data_size)
# 模拟处理时间
import time
time.sleep(0.1)
duration = max(1.0, len(text) * 0.05) # 基础1秒 + 每字符0.05秒
return {
"success": True,
"output_path": output_path,
"file_size": Path(output_path).stat().st_size,
"duration": duration,
"text_length": len(text),
"parameters": {
"speed": speed,
"pitch": pitch,
"volume": volume,
"format": format
}
}
except Exception as e:
raise Exception(f"Simple mock TTS synthesis failed: {e}")
async def synthesize_async(
self,
text: str,
output_path: str,
model: Optional[str] = None,
speed: float = 1.0,
pitch: int = 0,
volume: float = 1.0,
format: str = "mp3"
) -> Dict[str, Any]:
"""
异步语音合成
Args:
text: 要合成的文本
output_path: 输出文件路径
model: 模型名称
speed: 语速
pitch: 音调
volume: 音量
format: 输出格式
Returns:
包含合成结果的字典
"""
# 在线程池中执行同步合成
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
self.synthesize_sync,
text, output_path, model, speed, pitch, volume, format
)
return result
def main():
"""命令行接口"""
import argparse
parser = argparse.ArgumentParser(description="TTS Service CLI")
parser.add_argument("text", help="Text to synthesize")
parser.add_argument("--output", "-o", help="Output file path")
parser.add_argument("--model", "-m", help="Model name")
parser.add_argument("--speed", "-s", type=float, default=1.0, help="Speech speed (0.5-2.0)")
parser.add_argument("--pitch", "-p", type=int, default=0, help="Pitch (-20 to 20)")
parser.add_argument("--volume", "-v", type=float, default=1.0, help="Volume (0.5-2.0)")
parser.add_argument("--format", "-f", default="mp3", help="Output format")
args = parser.parse_args()
# 如果没有指定输出路径,使用临时文件
if not args.output:
with tempfile.NamedTemporaryFile(suffix=f".{args.format}", delete=False) as f:
args.output = f.name
try:
# 初始化TTS服务
tts_service = TTSService()
# 执行合成
result = tts_service.synthesize_sync(
text=args.text,
output_path=args.output,
model=args.model,
speed=args.speed,
pitch=args.pitch,
volume=args.volume,
format=args.format
)
if result["success"]:
print(f"Synthesis completed successfully!")
print(f"Output file: {result['output_path']}")
print(f"File size: {result['file_size']} bytes")
print(f"Duration: {result['duration']} seconds")
else:
print(f"Synthesis failed: {result['error']}")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()