feat: initial Flask REST API implementation for TTS + Voice Conversion
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# API Authentication Tokens (comma-separated)
|
||||||
|
API_TOKENS=dev-token-123,prod-token-456
|
||||||
|
|
||||||
|
# Directories
|
||||||
|
VOICES_DIR=./voices
|
||||||
|
OUTPUT_DIR=./output
|
||||||
|
|
||||||
|
# Seed-VC Configuration
|
||||||
|
SEED_VC_MODEL=seed-uvit-whisper-small-wavenet
|
||||||
|
SEED_VC_REPO_PATH=./Seed-VC
|
||||||
|
|
||||||
|
# Edge-TTS Configuration
|
||||||
|
EDGE_TTS_VOICE=ru-RU-SvetlanaNeural
|
||||||
|
|
||||||
|
# Flask Configuration
|
||||||
|
FLASK_PORT=5000
|
||||||
|
FLASK_DEBUG=false
|
||||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Application data
|
||||||
|
voices/*
|
||||||
|
!voices/.gitkeep
|
||||||
|
output/*
|
||||||
|
!output/.gitkeep
|
||||||
|
Seed-VC/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
55
app/__init__.py
Normal file
55
app/__init__.py
Normal 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
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
22
app/api/health.py
Normal file
22
app/api/health.py
Normal 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
152
app/api/tts.py
Normal 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
166
app/api/voices.py
Normal 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
26
app/config.py
Normal 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
4
app/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Models package."""
|
||||||
|
from app.models.voice import Voice
|
||||||
|
|
||||||
|
__all__ = ['Voice']
|
||||||
73
app/models/voice.py
Normal file
73
app/models/voice.py
Normal 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
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
|
||||||
0
output/.gitkeep
Normal file
0
output/.gitkeep
Normal file
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
flask>=2.3.0
|
||||||
|
flask-restx>=1.3.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
edge-tts>=6.1.0
|
||||||
|
torch>=2.0.0
|
||||||
|
torchaudio>=2.0.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
pydub>=0.25.0
|
||||||
|
librosa>=0.10.0
|
||||||
|
soundfile>=0.12.0
|
||||||
|
Werkzeug>=2.3.0
|
||||||
42
run.py
Normal file
42
run.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Entry point for TTS-VC API."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging first
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the Flask application."""
|
||||||
|
# Add current directory to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# Import app factory
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
# Create app
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
port = int(os.getenv('FLASK_PORT', '5000'))
|
||||||
|
debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
logger.info(f"Starting TTS-VC API on port {port}")
|
||||||
|
logger.info(f"Debug mode: {debug}")
|
||||||
|
logger.info(f"Swagger UI: http://localhost:{port}/swagger-ui")
|
||||||
|
|
||||||
|
# Run app
|
||||||
|
app.run(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=port,
|
||||||
|
debug=debug
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
0
voices/.gitkeep
Normal file
0
voices/.gitkeep
Normal file
Reference in New Issue
Block a user