feat: Add Piper TTS integration with pitch control support
- Created PiperTTSService with pitch shifting (-12 to +12 semitones) - Created UnifiedTTSService to manage multiple TTS engines - Added engine selection endpoints (GET/POST /api/tts/engines) - Added voices listing endpoint (GET /api/tts/voices) - Added pitch_adjustments parameter to TTS generation - Created install_piper.sh for easy Piper installation - Updated backend README with multi-engine documentation - Created comprehensive PIPER_TTS_GUIDE.md - Added TTS_ENGINE environment variable for engine selection - Updated requirements.txt with piper-tts and pydub This allows users to choose between Kokoro (high quality) and Piper (fast with pitch control) TTS engines at runtime.
This commit is contained in:
113
backend/main.py
113
backend/main.py
@@ -11,7 +11,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
# Import services
|
||||
from services.role_detector import RoleDetector
|
||||
from services.tts_service import TTSService
|
||||
from services.unified_tts_service import UnifiedTTSService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -54,6 +54,11 @@ class RoleVoiceMapping(BaseModel):
|
||||
id: str = Field(..., description="Role identifier")
|
||||
voice: str = Field(..., description="Kokoro voice ID")
|
||||
|
||||
class RolePitchMapping(BaseModel):
|
||||
"""Model for role-to-pitch mapping."""
|
||||
id: str = Field(..., description="Role identifier")
|
||||
pitch: int = Field(0, description="Pitch shift in semitones (-12 to +12)")
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
"""Request model for TTS generation endpoint."""
|
||||
segments: Optional[List[ScriptSegment]] = Field(None, description="Optional edited script segments")
|
||||
@@ -61,6 +66,7 @@ class TTSRequest(BaseModel):
|
||||
lang: str = Field(..., description="Language code (a, b, f, j)")
|
||||
roles: List[RoleVoiceMapping] = Field(..., description="Role to voice mappings")
|
||||
speed: Optional[float] = Field(1.0, description="Playback speed (0.5-1.5)")
|
||||
pitch_adjustments: Optional[List[RolePitchMapping]] = Field(None, description="Optional pitch adjustments per role (Piper only)")
|
||||
|
||||
class VoicePreviewRequest(BaseModel):
|
||||
"""Request model for voice preview endpoint."""
|
||||
@@ -75,6 +81,24 @@ class ErrorResponse(BaseModel):
|
||||
message: str = Field(..., description="Human-readable error message")
|
||||
details: Optional[dict] = Field(None, description="Additional error details")
|
||||
|
||||
class EngineInfo(BaseModel):
|
||||
"""Model for TTS engine information."""
|
||||
name: str = Field(..., description="Engine identifier")
|
||||
active: bool = Field(..., description="Whether this engine is currently active")
|
||||
|
||||
class EnginesResponse(BaseModel):
|
||||
"""Response model for available engines."""
|
||||
engines: List[EngineInfo] = Field(..., description="List of available TTS engines")
|
||||
current: str = Field(..., description="Currently active engine")
|
||||
|
||||
class SetEngineRequest(BaseModel):
|
||||
"""Request model for setting TTS engine."""
|
||||
engine: str = Field(..., description="Engine to activate (kokoro or piper)")
|
||||
|
||||
class VoicesResponse(BaseModel):
|
||||
"""Response model for available voices."""
|
||||
voices: dict = Field(..., description="Dict mapping engine names to voice lists")
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Text to Audiobook API",
|
||||
@@ -120,7 +144,7 @@ async def general_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
# Initialize services
|
||||
role_detector = RoleDetector()
|
||||
tts_service = TTSService()
|
||||
tts_service = UnifiedTTSService()
|
||||
|
||||
# API Endpoints
|
||||
@app.get("/api/health")
|
||||
@@ -129,6 +153,80 @@ async def health_check():
|
||||
logger.info("Health check requested")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/tts/engines", response_model=EnginesResponse)
|
||||
async def get_engines():
|
||||
"""
|
||||
Get list of available TTS engines and current selection.
|
||||
|
||||
Returns:
|
||||
EnginesResponse with list of engines and current active engine
|
||||
"""
|
||||
logger.info("Engines list requested")
|
||||
|
||||
try:
|
||||
available = tts_service.get_available_engines()
|
||||
current = tts_service.engine_name
|
||||
|
||||
engines = [
|
||||
EngineInfo(name=engine, active=(engine == current))
|
||||
for engine in available
|
||||
]
|
||||
|
||||
return EnginesResponse(engines=engines, current=current)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting engines: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
@app.post("/api/tts/engines")
|
||||
async def set_engine(request: SetEngineRequest):
|
||||
"""
|
||||
Set the active TTS engine.
|
||||
|
||||
Args:
|
||||
request: SetEngineRequest with engine name
|
||||
|
||||
Returns:
|
||||
Success message with new engine
|
||||
|
||||
Raises:
|
||||
400: If engine name is invalid
|
||||
"""
|
||||
logger.info(f"Engine change requested: {request.engine}")
|
||||
|
||||
try:
|
||||
tts_service.set_engine(request.engine)
|
||||
return {"status": "ok", "engine": request.engine}
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid engine: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={
|
||||
"error": "ValidationError",
|
||||
"message": str(e),
|
||||
"details": {"field": "engine"}
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting engine: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
@app.get("/api/tts/voices", response_model=VoicesResponse)
|
||||
async def get_voices():
|
||||
"""
|
||||
Get available voices for all TTS engines.
|
||||
|
||||
Returns:
|
||||
VoicesResponse with dict mapping engine names to voice lists
|
||||
"""
|
||||
logger.info("Voices list requested")
|
||||
|
||||
try:
|
||||
voices = tts_service.get_available_voices()
|
||||
return VoicesResponse(voices=voices)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting voices: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
@app.post("/api/tts/roles", response_model=RolesResponse)
|
||||
async def detect_roles(request: RoleDetectionRequest):
|
||||
"""
|
||||
@@ -420,6 +518,11 @@ async def generate_audiobook(request: TTSRequest):
|
||||
# Convert roles list to dictionary for TTS service
|
||||
role_voice_map = {role.id: role.voice for role in request.roles}
|
||||
|
||||
# Convert pitch adjustments to dictionary if provided
|
||||
pitch_map = None
|
||||
if request.pitch_adjustments:
|
||||
pitch_map = {p.id: p.pitch for p in request.pitch_adjustments}
|
||||
|
||||
try:
|
||||
# Generate audiobook using segments if provided, otherwise use text
|
||||
if request.segments:
|
||||
@@ -428,7 +531,8 @@ async def generate_audiobook(request: TTSRequest):
|
||||
segments=[(seg.speaker, seg.text) for seg in request.segments],
|
||||
lang=request.lang,
|
||||
role_voice_map=role_voice_map,
|
||||
speed=request.speed
|
||||
speed=request.speed,
|
||||
pitch_adjustments=pitch_map
|
||||
)
|
||||
else:
|
||||
logger.info("Generating audiobook from text with automatic segmentation")
|
||||
@@ -436,7 +540,8 @@ async def generate_audiobook(request: TTSRequest):
|
||||
text=request.text,
|
||||
lang=request.lang,
|
||||
role_voice_map=role_voice_map,
|
||||
speed=request.speed
|
||||
speed=request.speed,
|
||||
pitch_adjustments=pitch_map
|
||||
)
|
||||
|
||||
# Create streaming response with proper headers
|
||||
|
||||
Reference in New Issue
Block a user