feat: initial Flask REST API implementation for TTS + Voice Conversion
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user