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:
2025-12-09 14:45:18 +00:00
parent b5c53604a6
commit bfdcc94b24
8 changed files with 1128 additions and 8 deletions

View File

@@ -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