feat: initial Flask REST API implementation for TTS + Voice Conversion

This commit is contained in:
root
2026-04-08 01:35:34 +02:00
commit 879f770927
17 changed files with 837 additions and 0 deletions

55
app/__init__.py Normal file
View File

@@ -0,0 +1,55 @@
"""Flask application factory."""
import os
import logging
from flask import Flask
from flask_restx import Api
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def create_app():
"""Create and configure Flask application."""
app = Flask(__name__)
# Configuration
app.config['VOICES_DIR'] = os.getenv('VOICES_DIR', './voices')
app.config['OUTPUT_DIR'] = os.getenv('OUTPUT_DIR', './output')
app.config['API_TOKENS'] = os.getenv('API_TOKENS', '').split(',')
app.config['SEED_VC_MODEL'] = os.getenv('SEED_VC_MODEL', 'seed-uvit-whisper-small-wavenet')
app.config['SEED_VC_REPO_PATH'] = os.getenv('SEED_VC_REPO_PATH', './Seed-VC')
app.config['EDGE_TTS_VOICE'] = os.getenv('EDGE_TTS_VOICE', 'ru-RU-SvetlanaNeural')
# Ensure directories exist
os.makedirs(app.config['VOICES_DIR'], exist_ok=True)
os.makedirs(app.config['OUTPUT_DIR'], exist_ok=True)
# Create and initialize API
api = Api(
version='1.0',
title='TTS-VC API',
description='Text-to-Speech with Voice Conversion API',
doc='/swagger-ui',
validate=True
)
api.init_app(app)
# Register namespaces
from app.api import tts as tts_module
from app.api import voices as voices_module
from app.api import health as health_module
api.add_namespace(tts_module.api, path='/tts')
api.add_namespace(voices_module.api, path='/voices')
api.add_namespace(health_module.api, path='/health')
logger.info("Flask app created successfully")
return app

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

22
app/api/health.py Normal file
View File

@@ -0,0 +1,22 @@
"""Health check endpoint."""
import logging
from flask_restx import Namespace, Resource
logger = logging.getLogger(__name__)
api = Namespace('health', description='Health check operations')
@api.route('')
class Health(Resource):
"""Health check endpoint."""
@api.doc('health_check')
def get(self):
"""Check API health status."""
logger.info("Health check requested")
return {
'status': 'healthy',
'service': 'tts-vc-api',
'version': '1.0.0'
}, 200

152
app/api/tts.py Normal file
View File

@@ -0,0 +1,152 @@
"""TTS API endpoints."""
import os
import uuid
import logging
import tempfile
from flask import current_app, request, send_file
from flask_restx import Namespace, Resource, fields
from app.models.voice import Voice
from app.services.edge_tts_service import EdgeTTSService
from app.services.seed_vc_service import SeedVCService
logger = logging.getLogger(__name__)
api = Namespace('tts', description='Text-to-Speech operations')
# Models
tts_request_model = api.model('TTSRequest', {
'text': fields.String(required=True, description='Text to convert to speech'),
'voice_id': fields.String(required=True, description='Reference voice ID'),
'speed': fields.Float(description='Speech speed (0.5-2.0)', default=1.0)
})
tts_response_model = api.model('TTSResponse', {
'message': fields.String(),
'voice_id': fields.String()
})
error_model = api.model('Error', {
'error': fields.String(required=True),
'message': fields.String()
})
def check_auth():
"""Check if request is authenticated."""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
token = auth_header[7:] # Remove 'Bearer '
valid_tokens = current_app.config.get('API_TOKENS', [])
return token in valid_tokens
@api.route('')
class TTS(Resource):
"""Text-to-Speech conversion endpoint."""
@api.doc('text_to_speech', security='bearer')
@api.expect(tts_request_model)
@api.response(200, 'Success')
@api.response(400, 'Bad Request', error_model)
@api.response(401, 'Unauthorized', error_model)
@api.response(404, 'Voice Not Found', error_model)
@api.response(500, 'Server Error', error_model)
@api.produces(['audio/wav'])
def post(self):
"""
Convert text to speech with voice cloning.
Takes text input, generates speech using Edge-TTS with Russian female voice,
then applies voice conversion using the specified reference voice.
"""
logger.info("TTS request received")
# Check authentication
if not check_auth():
logger.warning("Unauthorized TTS request")
return {'error': 'Unauthorized', 'message': 'Invalid or missing token'}, 401
# Parse request
data = request.get_json()
if not data:
logger.error("No JSON data in request")
return {'error': 'Bad Request', 'message': 'JSON body required'}, 400
# Validate required fields
text = data.get('text', '').strip()
voice_id = data.get('voice_id', '').strip()
speed = float(data.get('speed', 1.0))
if not text:
logger.error("Empty text provided")
return {'error': 'Bad Request', 'message': 'Text is required'}, 400
if not voice_id:
logger.error("Empty voice_id provided")
return {'error': 'Bad Request', 'message': 'voice_id is required'}, 400
# Validate speed
if not 0.5 <= speed <= 2.0:
logger.error(f"Invalid speed: {speed}")
return {'error': 'Bad Request', 'message': 'Speed must be between 0.5 and 2.0'}, 400
# Check if voice exists
voices_dir = current_app.config['VOICES_DIR']
voice = Voice.from_file(voice_id, voices_dir)
if not voice or not voice.exists(voices_dir):
logger.error(f"Voice not found: {voice_id}")
return {'error': 'Not Found', 'message': f'Voice "{voice_id}" not found'}, 404
reference_path = voice.get_audio_path(voices_dir)
logger.info(f"Using reference voice: {reference_path}")
# Generate unique output filename
output_id = str(uuid.uuid4())[:8]
output_dir = current_app.config['OUTPUT_DIR']
tts_output = os.path.join(output_dir, f"{output_id}_tts.mp3")
final_output = os.path.join(output_dir, f"{output_id}_converted.wav")
try:
# Step 1: Generate TTS with Edge-TTS
logger.info("Step 1: Generating TTS with Edge-TTS")
edge_tts_voice = current_app.config['EDGE_TTS_VOICE']
tts_service = EdgeTTSService(voice=edge_tts_voice)
tts_service.text_to_speech(text, tts_output, speed=speed)
# Step 2: Convert voice with Seed-VC
logger.info("Step 2: Converting voice with Seed-VC")
seed_vc_model = current_app.config['SEED_VC_MODEL']
seed_vc_repo = current_app.config['SEED_VC_REPO_PATH']
vc_service = SeedVCService(model_name=seed_vc_model, repo_path=seed_vc_repo)
vc_service.convert_voice(tts_output, reference_path, final_output)
logger.info(f"TTS+VC complete: {final_output}")
# Return audio file
return send_file(
final_output,
mimetype='audio/wav',
as_attachment=True,
download_name=f"tts_{voice_id}.wav"
)
except Exception as e:
logger.error(f"TTS processing failed: {e}", exc_info=True)
# Cleanup on failure
for f in [tts_output, final_output]:
if os.path.exists(f):
try:
os.remove(f)
except:
pass
return {'error': 'Server Error', 'message': str(e)}, 500
finally:
# Cleanup temporary files
try:
if os.path.exists(tts_output):
os.remove(tts_output)
except:
pass

166
app/api/voices.py Normal file
View File

@@ -0,0 +1,166 @@
"""Voices API endpoints."""
import os
import uuid
import logging
from flask import current_app, request
from flask_restx import Namespace, Resource, fields
from werkzeug.utils import secure_filename
from app.models.voice import Voice
logger = logging.getLogger(__name__)
api = Namespace('voices', description='Voice management operations')
# Models
voice_model = api.model('Voice', {
'id': fields.String(required=True, description='Voice ID'),
'name': fields.String(required=True, description='Voice name'),
'filename': fields.String(required=True, description='Audio filename'),
'created_at': fields.String(description='Creation timestamp')
})
voices_list_model = api.model('VoicesList', {
'voices': fields.List(fields.Nested(voice_model))
})
upload_response_model = api.model('UploadResponse', {
'id': fields.String(required=True, description='Voice ID'),
'status': fields.String(required=True, description='Upload status'),
'name': fields.String(description='Voice name')
})
error_model = api.model('Error', {
'error': fields.String(required=True),
'message': fields.String()
})
def check_auth():
"""Check if request is authenticated."""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
token = auth_header[7:] # Remove 'Bearer '
valid_tokens = current_app.config.get('API_TOKENS', [])
return token in valid_tokens
@api.route('')
class VoicesList(Resource):
"""List all available voices."""
@api.doc('list_voices', security='bearer')
@api.response(200, 'Success', voices_list_model)
@api.response(401, 'Unauthorized', error_model)
def get(self):
"""List all available reference voices."""
logger.info("Listing voices")
if not check_auth():
logger.warning("Unauthorized request to list voices")
return {'error': 'Unauthorized', 'message': 'Invalid or missing token'}, 401
voices_dir = current_app.config['VOICES_DIR']
voices = Voice.list_all(voices_dir)
logger.info(f"Found {len(voices)} voices")
return {'voices': [v.to_dict() for v in voices]}, 200
@api.route('/upload')
class VoiceUpload(Resource):
"""Upload new reference voice."""
@api.doc('upload_voice', security='bearer')
@api.param('file', 'Audio file (WAV, MP3, OGG)', required=True, type='file')
@api.param('name', 'Display name for the voice', required=False, type='string')
@api.response(201, 'Created', upload_response_model)
@api.response(400, 'Bad Request', error_model)
@api.response(401, 'Unauthorized', error_model)
def post(self):
"""Upload a new reference voice."""
logger.info("Voice upload requested")
if not check_auth():
logger.warning("Unauthorized voice upload attempt")
return {'error': 'Unauthorized', 'message': 'Invalid or missing token'}, 401
# Check if file is present
if 'file' not in request.files:
logger.error("No file in request")
return {'error': 'Bad Request', 'message': 'No file provided'}, 400
file = request.files['file']
if file.filename == '':
logger.error("Empty filename")
return {'error': 'Bad Request', 'message': 'Empty filename'}, 400
# Validate file extension
allowed_extensions = {'.wav', '.mp3', '.ogg', '.m4a', '.flac'}
filename = secure_filename(file.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in allowed_extensions:
logger.error(f"Invalid file extension: {ext}")
return {
'error': 'Bad Request',
'message': f'Invalid file type. Allowed: {", ".join(allowed_extensions)}'
}, 400
# Generate voice ID
voice_id = str(uuid.uuid4())[:8]
# Get display name
name = request.form.get('name', '').strip()
if not name:
name = os.path.splitext(filename)[0]
# Prepare filenames
audio_filename = f"{voice_id}{ext}"
voices_dir = current_app.config['VOICES_DIR']
audio_path = os.path.join(voices_dir, audio_filename)
try:
# Save file
file.save(audio_path)
logger.info(f"File saved: {audio_path}")
# Convert to WAV if needed (for consistency)
if ext != '.wav':
try:
import soundfile as sf
import librosa
audio, sr = librosa.load(audio_path, sr=22050)
wav_filename = f"{voice_id}.wav"
wav_path = os.path.join(voices_dir, wav_filename)
sf.write(wav_path, audio, 22050)
# Remove original and update filename
os.remove(audio_path)
audio_filename = wav_filename
logger.info(f"Converted to WAV: {wav_path}")
except Exception as e:
logger.warning(f"Could not convert to WAV: {e}")
# Create voice metadata
voice = Voice(
voice_id=voice_id,
name=name,
filename=audio_filename
)
voice.save(voices_dir)
logger.info(f"Voice uploaded successfully: {voice_id}")
return {
'id': voice_id,
'status': 'uploaded',
'name': name
}, 201
except Exception as e:
logger.error(f"Failed to save voice: {e}")
# Cleanup on failure
if os.path.exists(audio_path):
os.remove(audio_path)
return {'error': 'Server Error', 'message': str(e)}, 500

26
app/config.py Normal file
View File

@@ -0,0 +1,26 @@
"""Configuration module."""
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""Application configuration."""
# API Authentication
API_TOKENS = os.getenv('API_TOKENS', '').split(',')
# Directories
VOICES_DIR = os.getenv('VOICES_DIR', './voices')
OUTPUT_DIR = os.getenv('OUTPUT_DIR', './output')
# Seed-VC
SEED_VC_MODEL = os.getenv('SEED_VC_MODEL', 'seed-uvit-whisper-small-wavenet')
SEED_VC_REPO_PATH = os.getenv('SEED_VC_REPO_PATH', './Seed-VC')
# Edge-TTS
EDGE_TTS_VOICE = os.getenv('EDGE_TTS_VOICE', 'ru-RU-SvetlanaNeural')
# Flask
FLASK_PORT = int(os.getenv('FLASK_PORT', '5000'))
FLASK_DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'

4
app/models/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Models package."""
from app.models.voice import Voice
__all__ = ['Voice']

73
app/models/voice.py Normal file
View File

@@ -0,0 +1,73 @@
"""Voice model for reference voice metadata."""
import os
import json
from datetime import datetime
from typing import List, Optional, Dict, Any
class Voice:
"""Represents a reference voice."""
def __init__(self, voice_id: str, name: str, filename: str, created_at: str = None):
self.id = voice_id
self.name = name
self.filename = filename
self.created_at = created_at or datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
'id': self.id,
'name': self.name,
'filename': self.filename,
'created_at': self.created_at
}
@classmethod
def from_file(cls, voice_id: str, voices_dir: str) -> Optional['Voice']:
"""Load voice metadata from JSON file."""
meta_path = os.path.join(voices_dir, f"{voice_id}.json")
if not os.path.exists(meta_path):
return None
with open(meta_path, 'r') as f:
data = json.load(f)
return cls(
voice_id=data['id'],
name=data['name'],
filename=data['filename'],
created_at=data.get('created_at')
)
def save(self, voices_dir: str):
"""Save voice metadata to JSON file."""
meta_path = os.path.join(voices_dir, f"{self.id}.json")
with open(meta_path, 'w') as f:
json.dump(self.to_dict(), f, indent=2)
def get_audio_path(self, voices_dir: str) -> str:
"""Get full path to audio file."""
return os.path.join(voices_dir, self.filename)
def exists(self, voices_dir: str) -> bool:
"""Check if voice files exist."""
audio_path = self.get_audio_path(voices_dir)
meta_path = os.path.join(voices_dir, f"{self.id}.json")
return os.path.exists(audio_path) and os.path.exists(meta_path)
@classmethod
def list_all(cls, voices_dir: str) -> List['Voice']:
"""List all voices in directory."""
voices = []
if not os.path.exists(voices_dir):
return voices
for filename in os.listdir(voices_dir):
if filename.endswith('.json'):
voice_id = filename[:-5] # Remove .json
voice = cls.from_file(voice_id, voices_dir)
if voice and voice.exists(voices_dir):
voices.append(voice)
return sorted(voices, key=lambda v: v.created_at, reverse=True)

5
app/services/__init__.py Normal file
View 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']

View 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

View 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