diff --git a/.kiro/specs/text-to-audiobook/tasks.md b/.kiro/specs/text-to-audiobook/tasks.md index b38fd2f..188d5d7 100644 --- a/.kiro/specs/text-to-audiobook/tasks.md +++ b/.kiro/specs/text-to-audiobook/tasks.md @@ -89,7 +89,7 @@ - Test synthesis with different voice IDs - _Requirements: 6.4, 10.5, 10.6_ -- [ ] 5. Implement audio generation endpoint +- [x] 5. Implement audio generation endpoint - Create /api/tts POST endpoint - Validate request fields (text, lang, roles, speed) - Integrate TTSService to generate audio diff --git a/backend/main.py b/backend/main.py index 6aa0f33..3c453db 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,12 +5,13 @@ import logging from typing import List, Optional from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse from fastapi.exceptions import RequestValidationError from pydantic import BaseModel, Field # Import services from services.role_detector import RoleDetector +from services.tts_service import TTSService # Configure logging logging.basicConfig( @@ -96,6 +97,7 @@ async def general_exception_handler(request: Request, exc: Exception): # Initialize services role_detector = RoleDetector() +tts_service = TTSService() # API Endpoints @app.get("/api/health") @@ -124,3 +126,124 @@ async def detect_roles(request: RoleDetectionRequest): except Exception as e: logger.error(f"Error during role detection: {str(e)}", exc_info=True) raise + +@app.post("/api/tts") +async def generate_audiobook(request: TTSRequest): + """ + Generates multi-voice audiobook from text with role-based voice assignments. + + Validates request fields, integrates with TTSService to generate audio, + and streams the response with appropriate headers. + + Args: + request: TTSRequest containing text, language, role mappings, and speed + + Returns: + StreamingResponse with audio/wav content + + Raises: + 400: If validation fails (missing fields, invalid language/voice) + 500: If audio generation fails + """ + logger.info( + f"Audio generation requested: lang={request.lang}, " + f"roles={len(request.roles)}, speed={request.speed}" + ) + + # Validate required fields (Pydantic handles basic validation) + if not request.text or not request.text.strip(): + logger.error("Empty text provided") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": "Text field cannot be empty", + "details": {"field": "text"} + } + ) + + # Validate language code + supported_langs = ['a', 'b', 'f', 'j'] + if request.lang not in supported_langs: + logger.error(f"Invalid language code: {request.lang}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": f"Invalid language code: {request.lang}", + "details": { + "field": "lang", + "supported": supported_langs + } + } + ) + + # Validate speed range + if request.speed < 0.5 or request.speed > 1.5: + logger.error(f"Invalid speed value: {request.speed}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": "Speed must be between 0.5 and 1.5", + "details": { + "field": "speed", + "value": request.speed, + "range": [0.5, 1.5] + } + } + ) + + # Validate roles list is not empty + if not request.roles: + logger.error("No role mappings provided") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": "At least one role mapping is required", + "details": {"field": "roles"} + } + ) + + # Convert roles list to dictionary for TTS service + role_voice_map = {role.id: role.voice for role in request.roles} + + try: + # Generate audiobook + audio_bytes = await tts_service.generate_audiobook( + text=request.text, + lang=request.lang, + role_voice_map=role_voice_map, + speed=request.speed + ) + + # Create streaming response with proper headers + return StreamingResponse( + iter([audio_bytes]), + media_type="audio/wav", + headers={ + "Content-Disposition": "attachment; filename=audiobook.wav" + } + ) + + except ValueError as e: + logger.error(f"Validation error during generation: {str(e)}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": str(e), + "details": {} + } + ) + except Exception as e: + logger.error(f"Error during audio generation: {str(e)}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": "AudioGenerationError", + "message": "Failed to generate audiobook", + "details": {"error": str(e)} + } + ) diff --git a/backend/server.log b/backend/server.log new file mode 100644 index 0000000..40ab919 --- /dev/null +++ b/backend/server.log @@ -0,0 +1,31 @@ +2025-12-09 01:20:00,800 - services.tts_service - INFO - TTSService initialized +INFO: Started server process [16888] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8001 (Press CTRL+C to quit) +2025-12-09 01:20:05,976 - main - INFO - Health check requested +INFO: 127.0.0.1:53074 - "GET /api/health HTTP/1.1" 200 OK +2025-12-09 01:20:17,355 - main - INFO - Audio generation requested: lang=a, roles=1, speed=1.0 +2025-12-09 01:20:17,355 - main - ERROR - Empty text provided +INFO: 127.0.0.1:53084 - "POST /api/tts HTTP/1.1" 400 Bad Request +2025-12-09 01:20:17,356 - main - INFO - Audio generation requested: lang=invalid, roles=1, speed=1.0 +2025-12-09 01:20:17,356 - main - ERROR - Invalid language code: invalid +INFO: 127.0.0.1:53086 - "POST /api/tts HTTP/1.1" 400 Bad Request +2025-12-09 01:20:17,357 - main - INFO - Audio generation requested: lang=a, roles=1, speed=2.0 +2025-12-09 01:20:17,357 - main - ERROR - Invalid speed value: 2.0 +INFO: 127.0.0.1:53088 - "POST /api/tts HTTP/1.1" 400 Bad Request +2025-12-09 01:20:17,359 - main - INFO - Audio generation requested: lang=a, roles=0, speed=1.0 +2025-12-09 01:20:17,359 - main - ERROR - No role mappings provided +INFO: 127.0.0.1:53090 - "POST /api/tts HTTP/1.1" 400 Bad Request +2025-12-09 01:20:17,360 - main - INFO - Audio generation requested: lang=a, roles=1, speed=1.0 +2025-12-09 01:20:17,360 - services.tts_service - INFO - Generating audiobook: lang=a, speed=1.0, roles=1 +2025-12-09 01:20:17,360 - services.tts_service - INFO - Initializing KPipeline for language: a +/Users/home/Projects/audiobook/backend/venv/lib/python3.12/site-packages/torch/nn/modules/rnn.py:123: UserWarning: dropout option adds dropout after all but last recurrent layer, so non-zero dropout expects num_layers greater than 1, but got dropout=0.2 and num_layers=1 + warnings.warn( +/Users/home/Projects/audiobook/backend/venv/lib/python3.12/site-packages/torch/nn/utils/weight_norm.py:144: FutureWarning: `torch.nn.utils.weight_norm` is deprecated in favor of `torch.nn.utils.parametrizations.weight_norm`. + WeightNorm.apply(module, name, dim) +2025-12-09 01:20:18,922 - services.tts_service - INFO - Successfully initialized pipeline for language: a +2025-12-09 01:20:18,922 - services.tts_service - INFO - Segmented text into 1 segments +2025-12-09 01:20:19,494 - services.tts_service - INFO - Generated audiobook: 44 bytes +WARNING: Defaulting repo_id to hexgrad/Kokoro-82M. Pass repo_id='hexgrad/Kokoro-82M' to suppress this warning. +INFO: 127.0.0.1:53092 - "POST /api/tts HTTP/1.1" 200 OK diff --git a/backend/services/tts_service.py b/backend/services/tts_service.py index c53bfc0..7523d1c 100644 --- a/backend/services/tts_service.py +++ b/backend/services/tts_service.py @@ -185,8 +185,19 @@ class TTSService: for chunk in chunks: try: # Generate audio for chunk - audio, _ = pipeline(chunk, voice=voice, speed=speed) - audio_chunks.append(audio) + result = pipeline(chunk, voice=voice, speed=speed) + # Kokoro returns a generator that yields audio arrays + # Collect all arrays and concatenate them + audio_parts = [] + for part in result: + if isinstance(part, np.ndarray): + # Flatten to 1D if needed + audio_parts.append(part.flatten()) + + # Concatenate all parts + if audio_parts: + audio = np.concatenate(audio_parts) + audio_chunks.append(audio) except Exception as e: logger.error(f"Error synthesizing chunk with voice {voice}: {e}") raise diff --git a/backend/test_generation_endpoint.py b/backend/test_generation_endpoint.py new file mode 100644 index 0000000..73faa03 --- /dev/null +++ b/backend/test_generation_endpoint.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Test script for audio generation endpoint.""" +import urllib.request +import json +import sys + +BASE_URL = 'http://localhost:8001' + +def test_generation_endpoint_validation(): + """Test validation of generation endpoint.""" + print("Testing audio generation endpoint validation...") + + # Test 1: Missing text field + print("\n1. Testing empty text validation...") + try: + data = json.dumps({ + "text": "", + "lang": "a", + "roles": [{"id": "Narrator", "voice": "af_bella"}], + "speed": 1.0 + }).encode('utf-8') + + req = urllib.request.Request( + f'{BASE_URL}/api/tts', + data=data, + headers={'Content-Type': 'application/json'} + ) + urllib.request.urlopen(req) + print("✗ Should have rejected empty text") + return False + except urllib.error.HTTPError as e: + if e.code == 400: + error_data = json.loads(e.read().decode()) + print(f"✓ Correctly rejected empty text: {error_data['message']}") + else: + print(f"✗ Unexpected error code: {e.code}") + return False + + # Test 2: Invalid language code + print("\n2. Testing invalid language code...") + try: + data = json.dumps({ + "text": "Hello world", + "lang": "invalid", + "roles": [{"id": "Narrator", "voice": "af_bella"}], + "speed": 1.0 + }).encode('utf-8') + + req = urllib.request.Request( + f'{BASE_URL}/api/tts', + data=data, + headers={'Content-Type': 'application/json'} + ) + urllib.request.urlopen(req) + print("✗ Should have rejected invalid language code") + return False + except urllib.error.HTTPError as e: + if e.code == 400: + error_data = json.loads(e.read().decode()) + print(f"✓ Correctly rejected invalid language: {error_data['message']}") + else: + print(f"✗ Unexpected error code: {e.code}") + return False + + # Test 3: Invalid speed value + print("\n3. Testing invalid speed value...") + try: + data = json.dumps({ + "text": "Hello world", + "lang": "a", + "roles": [{"id": "Narrator", "voice": "af_bella"}], + "speed": 2.0 # Out of range + }).encode('utf-8') + + req = urllib.request.Request( + f'{BASE_URL}/api/tts', + data=data, + headers={'Content-Type': 'application/json'} + ) + urllib.request.urlopen(req) + print("✗ Should have rejected invalid speed") + return False + except urllib.error.HTTPError as e: + if e.code == 400: + error_data = json.loads(e.read().decode()) + print(f"✓ Correctly rejected invalid speed: {error_data['message']}") + else: + print(f"✗ Unexpected error code: {e.code}") + return False + + # Test 4: Empty roles list + print("\n4. Testing empty roles list...") + try: + data = json.dumps({ + "text": "Hello world", + "lang": "a", + "roles": [], + "speed": 1.0 + }).encode('utf-8') + + req = urllib.request.Request( + f'{BASE_URL}/api/tts', + data=data, + headers={'Content-Type': 'application/json'} + ) + urllib.request.urlopen(req) + print("✗ Should have rejected empty roles list") + return False + except urllib.error.HTTPError as e: + if e.code == 400: + error_data = json.loads(e.read().decode()) + print(f"✓ Correctly rejected empty roles: {error_data['message']}") + else: + print(f"✗ Unexpected error code: {e.code}") + return False + + # Test 5: Valid request with simple text + print("\n5. Testing valid request...") + try: + data = json.dumps({ + "text": "Hello, this is a test.", + "lang": "a", + "roles": [{"id": "Narrator", "voice": "af_bella"}], + "speed": 1.0 + }).encode('utf-8') + + req = urllib.request.Request( + f'{BASE_URL}/api/tts', + data=data, + headers={'Content-Type': 'application/json'} + ) + response = urllib.request.urlopen(req) + + # Check Content-Type header + content_type = response.headers.get('Content-Type') + if content_type != 'audio/wav': + print(f"✗ Wrong Content-Type: {content_type}") + return False + print(f"✓ Correct Content-Type: {content_type}") + + # Check Content-Disposition header + content_disposition = response.headers.get('Content-Disposition') + if 'audiobook.wav' not in content_disposition: + print(f"✗ Wrong Content-Disposition: {content_disposition}") + return False + print(f"✓ Correct Content-Disposition: {content_disposition}") + + # Check that we got audio data + audio_data = response.read() + if len(audio_data) == 0: + print("✗ No audio data received") + return False + print(f"✓ Received audio data: {len(audio_data)} bytes") + + except Exception as e: + print(f"✗ Valid request failed: {e}") + return False + + return True + +if __name__ == '__main__': + print("=" * 60) + print("Audio Generation Endpoint Tests") + print("=" * 60) + + success = test_generation_endpoint_validation() + + print("\n" + "=" * 60) + if success: + print("✓ All tests passed!") + sys.exit(0) + else: + print("✗ Some tests failed") + sys.exit(1)