diff --git a/.kiro/specs/text-to-audiobook/tasks.md b/.kiro/specs/text-to-audiobook/tasks.md index 7c206af..14d8936 100644 --- a/.kiro/specs/text-to-audiobook/tasks.md +++ b/.kiro/specs/text-to-audiobook/tasks.md @@ -372,7 +372,7 @@ - Test error handling - _Requirements: 15.2_ -- [ ] 19. Update TTS generation endpoint to accept edited scripts +- [x] 19. Update TTS generation endpoint to accept edited scripts - Modify TTSRequest to accept optional segments array - Update generate_audiobook logic to use segments if provided - Fall back to text segmentation if no segments provided diff --git a/backend/main.py b/backend/main.py index 2f164b8..157bfdd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -56,7 +56,8 @@ class RoleVoiceMapping(BaseModel): class TTSRequest(BaseModel): """Request model for TTS generation endpoint.""" - text: str = Field(..., description="Text to convert to speech") + segments: Optional[List[ScriptSegment]] = Field(None, description="Optional edited script segments") + text: Optional[str] = Field(None, description="Text to convert to speech (used if segments not provided)") 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)") @@ -253,11 +254,24 @@ async def generate_audiobook(request: TTSRequest): """ logger.info( f"Audio generation requested: lang={request.lang}, " - f"roles={len(request.roles)}, speed={request.speed}" + f"roles={len(request.roles)}, speed={request.speed}, " + f"segments={len(request.segments) if request.segments else 0}" ) - # Validate required fields (Pydantic handles basic validation) - if not request.text or not request.text.strip(): + # Validate that either segments or text is provided + if not request.segments and not request.text: + logger.error("Neither segments nor text provided") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": "Either segments or text must be provided", + "details": {"fields": ["segments", "text"]} + } + ) + + # Validate text if provided and no segments + if not request.segments and request.text and not request.text.strip(): logger.error("Empty text provided") return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, @@ -316,13 +330,23 @@ async def generate_audiobook(request: TTSRequest): 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 - ) + # Generate audiobook using segments if provided, otherwise use text + if request.segments: + logger.info(f"Generating audiobook from {len(request.segments)} edited segments") + audio_bytes = await tts_service.generate_audiobook_from_segments( + segments=[(seg.speaker, seg.text) for seg in request.segments], + lang=request.lang, + role_voice_map=role_voice_map, + speed=request.speed + ) + else: + logger.info("Generating audiobook from text with automatic segmentation") + 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( diff --git a/backend/services/tts_service.py b/backend/services/tts_service.py index 089491f..d90c566 100644 --- a/backend/services/tts_service.py +++ b/backend/services/tts_service.py @@ -404,3 +404,66 @@ class TTSService: logger.info(f"Generated audiobook: {len(audio_bytes)} bytes") return audio_bytes + + async def generate_audiobook_from_segments( + self, + segments: List[Tuple[str, str]], + lang: str, + role_voice_map: Dict[str, str], + speed: float = 1.0 + ) -> bytes: + """ + Generates audiobook from pre-segmented script with speaker assignments. + + This method is used when the user has reviewed and edited the script, + bypassing automatic segmentation. + + Args: + segments: List of (speaker_name, text_content) tuples + lang: Language code + role_voice_map: Role to voice ID mapping + speed: Playback speed + + Returns: + Audio data as bytes (WAV format) + + Raises: + ValueError: If segments is empty or invalid + """ + logger.info( + f"Generating audiobook from {len(segments)} edited segments: " + f"lang={lang}, speed={speed}, roles={len(role_voice_map)}" + ) + + if not segments: + raise ValueError("No segments provided for synthesis") + + # Get or create pipeline for language + pipeline = self.get_pipeline(lang) + + # Convert segments to format with voice assignments + segments_with_voice = [] + for speaker, text in segments: + voice = role_voice_map.get(speaker, self.DEFAULT_VOICE) + segments_with_voice.append((speaker, voice, text)) + + logger.info(f"Processing {len(segments_with_voice)} segments with voice assignments") + + # Synthesize each segment + audio_segments = [] + for role_id, voice, content in segments_with_voice: + logger.debug(f"Synthesizing segment for role '{role_id}' with voice '{voice}'") + audio = self.synthesize_segment(pipeline, content, voice, speed) + audio_segments.append(audio) + + # Concatenate all audio + final_audio = self.concatenate_audio(audio_segments) + + # Convert to bytes (WAV format) + buffer = io.BytesIO() + sf.write(buffer, final_audio, self.SAMPLE_RATE, format='WAV') + buffer.seek(0) + audio_bytes = buffer.read() + + logger.info(f"Generated audiobook from edited segments: {len(audio_bytes)} bytes") + return audio_bytes diff --git a/backend/test_segment_based_generation.py b/backend/test_segment_based_generation.py new file mode 100644 index 0000000..cf7511d --- /dev/null +++ b/backend/test_segment_based_generation.py @@ -0,0 +1,164 @@ +""" +Test for segment-based audiobook generation (Task 19). + +Tests that the TTS endpoint can accept edited script segments +and generate audio from them, while maintaining backward compatibility +with text-based generation. +""" +import pytest +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + + +def test_generate_audiobook_with_segments(): + """Test that audiobook can be generated from edited script segments.""" + # Prepare request with segments + request_data = { + "segments": [ + {"id": "seg_0", "speaker": "Alice", "text": "Hello, how are you?"}, + {"id": "seg_1", "speaker": "Bob", "text": "I'm doing great, thanks!"}, + {"id": "seg_2", "speaker": "Narrator", "text": "They continued their conversation."} + ], + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"}, + {"id": "Bob", "voice": "am_adam"}, + {"id": "Narrator", "voice": "af_sarah"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should succeed + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/wav" + assert "audiobook.wav" in response.headers.get("content-disposition", "") + assert len(response.content) > 0 + + +def test_generate_audiobook_with_text_backward_compatibility(): + """Test that audiobook generation still works with text (backward compatibility).""" + # Prepare request with text (old format) + request_data = { + "text": "Alice: Hello there!\nBob: Hi Alice!", + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"}, + {"id": "Bob", "voice": "am_adam"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should succeed + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/wav" + assert len(response.content) > 0 + + +def test_generate_audiobook_segments_take_precedence(): + """Test that segments are used when both segments and text are provided.""" + # Prepare request with both segments and text + request_data = { + "segments": [ + {"id": "seg_0", "speaker": "Alice", "text": "From segments"} + ], + "text": "Alice: From text", + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should succeed and use segments + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/wav" + + +def test_generate_audiobook_missing_both_segments_and_text(): + """Test that request fails when neither segments nor text is provided.""" + request_data = { + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should fail with validation error + assert response.status_code == 400 + assert "ValidationError" in response.json()["error"] + + +def test_generate_audiobook_empty_segments(): + """Test that request fails when segments array is empty.""" + request_data = { + "segments": [], + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should fail - empty segments should be treated as not provided + assert response.status_code == 400 + + +def test_edited_script_preserves_speaker_assignments(): + """Test that edited speaker assignments in segments are preserved.""" + # Segments where user has changed speaker assignments + request_data = { + "segments": [ + {"id": "seg_0", "speaker": "Bob", "text": "This was originally Alice's line"}, + {"id": "seg_1", "speaker": "Alice", "text": "This was originally Bob's line"} + ], + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"}, + {"id": "Bob", "voice": "am_adam"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should succeed with edited assignments + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/wav" + assert len(response.content) > 0 + + +def test_segment_with_unmapped_speaker_uses_default(): + """Test that segments with unmapped speakers fall back to default voice.""" + request_data = { + "segments": [ + {"id": "seg_0", "speaker": "UnknownSpeaker", "text": "Hello from unknown"} + ], + "lang": "a", + "roles": [ + {"id": "Alice", "voice": "af_bella"} + ], + "speed": 1.0 + } + + response = client.post("/api/tts", json=request_data) + + # Should succeed using default voice + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/wav" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])