feat: initial Flask REST API implementation for TTS + Voice Conversion
This commit is contained in:
5
app/services/__init__.py
Normal file
5
app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Services package."""
|
||||
from app.services.edge_tts_service import EdgeTTSService
|
||||
from app.services.seed_vc_service import SeedVCService
|
||||
|
||||
__all__ = ['EdgeTTSService', 'SeedVCService']
|
||||
67
app/services/edge_tts_service.py
Normal file
67
app/services/edge_tts_service.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Edge-TTS service for text-to-speech conversion."""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
import edge_tts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EdgeTTSService:
|
||||
"""Service for converting text to speech using Edge-TTS."""
|
||||
|
||||
def __init__(self, voice: str = "ru-RU-SvetlanaNeural"):
|
||||
self.voice = voice
|
||||
logger.info(f"EdgeTTS Service initialized with voice: {voice}")
|
||||
|
||||
async def _generate_speech_async(self, text: str, output_path: str, speed: float = 1.0) -> str:
|
||||
"""Generate speech asynchronously."""
|
||||
# Adjust rate based on speed
|
||||
if speed != 1.0:
|
||||
rate_percent = int((speed - 1.0) * 100)
|
||||
rate = f"{rate_percent:+d}%"
|
||||
else:
|
||||
rate = "+0%"
|
||||
|
||||
communicate = edge_tts.Communicate(text, self.voice, rate=rate)
|
||||
await communicate.save(output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
def text_to_speech(self, text: str, output_path: str, speed: float = 1.0) -> str:
|
||||
"""
|
||||
Convert text to speech and save to file.
|
||||
|
||||
Args:
|
||||
text: Text to convert
|
||||
output_path: Path to save audio file
|
||||
speed: Speech speed multiplier (0.5 - 2.0)
|
||||
|
||||
Returns:
|
||||
Path to generated audio file
|
||||
"""
|
||||
logger.info(f"Generating TTS for text: {text[:50]}...")
|
||||
|
||||
# Run async function in sync context
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(
|
||||
self._generate_speech_async(text, output_path, speed)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
logger.info(f"TTS generated: {result}")
|
||||
return result
|
||||
|
||||
def get_supported_voices(self) -> list:
|
||||
"""Get list of supported voices."""
|
||||
voices = [
|
||||
"ru-RU-SvetlanaNeural",
|
||||
"ru-RU-DmitryNeural",
|
||||
"en-US-AriaNeural",
|
||||
"en-US-GuyNeural",
|
||||
]
|
||||
return voices
|
||||
143
app/services/seed_vc_service.py
Normal file
143
app/services/seed_vc_service.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Seed-VC service for voice conversion."""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
import soundfile as sf
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SeedVCService:
|
||||
"""Service for voice conversion using Seed-VC."""
|
||||
|
||||
def __init__(self, model_name: str = "seed-uvit-whisper-small-wavenet", repo_path: str = "./Seed-VC"):
|
||||
self.model_name = model_name
|
||||
self.repo_path = repo_path
|
||||
self.model = None
|
||||
self.device = None
|
||||
self.initialized = False
|
||||
|
||||
logger.info(f"SeedVC Service initialized with model: {model_name}")
|
||||
|
||||
def _ensure_repo_exists(self):
|
||||
"""Ensure Seed-VC repository is cloned."""
|
||||
if not os.path.exists(self.repo_path):
|
||||
logger.info("Cloning Seed-VC repository...")
|
||||
import subprocess
|
||||
subprocess.run([
|
||||
"git", "clone", "https://github.com/Plachtaa/Seed-VC.git", self.repo_path
|
||||
], check=True)
|
||||
logger.info("Seed-VC repository cloned")
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the model (lazy loading)."""
|
||||
if self.initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
self._ensure_repo_exists()
|
||||
|
||||
# Add Seed-VC to path
|
||||
if self.repo_path not in sys.path:
|
||||
sys.path.insert(0, self.repo_path)
|
||||
|
||||
import torch
|
||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
logger.info(f"Using device: {self.device}")
|
||||
|
||||
# Import Seed-VC modules
|
||||
try:
|
||||
from inference import load_model, inference
|
||||
self.inference_fn = inference
|
||||
self.model = load_model(self.model_name, self.device)
|
||||
logger.info(f"Model {self.model_name} loaded successfully")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not import Seed-VC modules: {e}")
|
||||
logger.warning("Voice conversion will use passthrough mode")
|
||||
self.model = None
|
||||
|
||||
self.initialized = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Seed-VC: {e}")
|
||||
self.initialized = True # Mark as initialized to avoid retry
|
||||
self.model = None
|
||||
|
||||
def convert_voice(
|
||||
self,
|
||||
source_audio_path: str,
|
||||
reference_audio_path: str,
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""
|
||||
Convert voice from source to match reference voice.
|
||||
|
||||
Args:
|
||||
source_audio_path: Path to source audio (TTS output)
|
||||
reference_audio_path: Path to reference voice audio
|
||||
output_path: Path to save converted audio
|
||||
|
||||
Returns:
|
||||
Path to converted audio file
|
||||
"""
|
||||
self.initialize()
|
||||
|
||||
logger.info(f"Converting voice: {source_audio_path} -> {output_path}")
|
||||
logger.info(f"Using reference: {reference_audio_path}")
|
||||
|
||||
if self.model is None:
|
||||
# Fallback: copy source audio
|
||||
logger.warning("Seed-VC not available, using passthrough mode")
|
||||
import shutil
|
||||
shutil.copy(source_audio_path, output_path)
|
||||
return output_path
|
||||
|
||||
try:
|
||||
# Load audio files
|
||||
source_audio, sr = librosa.load(source_audio_path, sr=22050)
|
||||
reference_audio, _ = librosa.load(reference_audio_path, sr=22050)
|
||||
|
||||
# Perform voice conversion
|
||||
converted_audio = self.inference_fn(
|
||||
self.model,
|
||||
source_audio,
|
||||
reference_audio,
|
||||
sr=22050
|
||||
)
|
||||
|
||||
# Save output
|
||||
sf.write(output_path, converted_audio, 22050)
|
||||
logger.info(f"Voice conversion complete: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Voice conversion failed: {e}")
|
||||
# Fallback: copy source audio
|
||||
import shutil
|
||||
shutil.copy(source_audio_path, output_path)
|
||||
return output_path
|
||||
|
||||
def preprocess_audio(self, input_path: str, output_path: str, target_sr: int = 22050) -> str:
|
||||
"""
|
||||
Preprocess audio to match Seed-VC requirements.
|
||||
|
||||
Args:
|
||||
input_path: Input audio path
|
||||
output_path: Output audio path
|
||||
target_sr: Target sample rate
|
||||
|
||||
Returns:
|
||||
Path to preprocessed audio
|
||||
"""
|
||||
try:
|
||||
audio, sr = librosa.load(input_path, sr=target_sr)
|
||||
sf.write(output_path, audio, target_sr)
|
||||
return output_path
|
||||
except Exception as e:
|
||||
logger.error(f"Audio preprocessing failed: {e}")
|
||||
raise
|
||||
Reference in New Issue
Block a user