Task 19: Update TTS generation endpoint to accept edited scripts
- Modified TTSRequest to accept optional segments array - Updated generate_audiobook endpoint to use segments if provided - Added generate_audiobook_from_segments method to TTSService - Ensured backward compatibility with text-based generation - Added comprehensive tests for segment-based generation - All tests passing (7/7) Requirements: 15.7
This commit is contained in:
@@ -372,7 +372,7 @@
|
|||||||
- Test error handling
|
- Test error handling
|
||||||
- _Requirements: 15.2_
|
- _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
|
- Modify TTSRequest to accept optional segments array
|
||||||
- Update generate_audiobook logic to use segments if provided
|
- Update generate_audiobook logic to use segments if provided
|
||||||
- Fall back to text segmentation if no segments provided
|
- Fall back to text segmentation if no segments provided
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class RoleVoiceMapping(BaseModel):
|
|||||||
|
|
||||||
class TTSRequest(BaseModel):
|
class TTSRequest(BaseModel):
|
||||||
"""Request model for TTS generation endpoint."""
|
"""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)")
|
lang: str = Field(..., description="Language code (a, b, f, j)")
|
||||||
roles: List[RoleVoiceMapping] = Field(..., description="Role to voice mappings")
|
roles: List[RoleVoiceMapping] = Field(..., description="Role to voice mappings")
|
||||||
speed: Optional[float] = Field(1.0, description="Playback speed (0.5-1.5)")
|
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(
|
logger.info(
|
||||||
f"Audio generation requested: lang={request.lang}, "
|
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)
|
# Validate that either segments or text is provided
|
||||||
if not request.text or not request.text.strip():
|
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")
|
logger.error("Empty text provided")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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}
|
role_voice_map = {role.id: role.voice for role in request.roles}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate audiobook
|
# Generate audiobook using segments if provided, otherwise use text
|
||||||
audio_bytes = await tts_service.generate_audiobook(
|
if request.segments:
|
||||||
text=request.text,
|
logger.info(f"Generating audiobook from {len(request.segments)} edited segments")
|
||||||
lang=request.lang,
|
audio_bytes = await tts_service.generate_audiobook_from_segments(
|
||||||
role_voice_map=role_voice_map,
|
segments=[(seg.speaker, seg.text) for seg in request.segments],
|
||||||
speed=request.speed
|
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
|
# Create streaming response with proper headers
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -404,3 +404,66 @@ class TTSService:
|
|||||||
|
|
||||||
logger.info(f"Generated audiobook: {len(audio_bytes)} bytes")
|
logger.info(f"Generated audiobook: {len(audio_bytes)} bytes")
|
||||||
return audio_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
|
||||||
|
|||||||
164
backend/test_segment_based_generation.py
Normal file
164
backend/test_segment_based_generation.py
Normal file
@@ -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"])
|
||||||
Reference in New Issue
Block a user