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

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