Add AI-powered text segmentation for intelligent speaker assignment
- Added segment_text() method to OllamaService for AI-based text splitting - OllamaService now annotates each text segment with the appropriate speaker - Updated TTSService to use AI segmentation when available - Falls back to pattern-based segmentation when Ollama unavailable - AI intelligently assigns dialogue to speakers and narrative to Narrator - Added comprehensive test suite for AI segmentation - Verified AI correctly splits unmarked dialogue between multiple speakers This makes the tool much more powerful - users can now input plain narrative text without explicit role markers and the AI will intelligently assign voices to different characters and the narrator.
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Ollama integration service for AI-based speaker detection.
|
Ollama integration service for AI-based speaker detection and text segmentation.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Dict, Tuple
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +214,145 @@ Return format: {{"speakers": ["Speaker1", "Speaker2", ...]}}
|
|||||||
# Look for common patterns like lists or quoted names
|
# Look for common patterns like lists or quoted names
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def segment_text(self, text: str, speakers: List[str]) -> List[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Uses AI to segment text and assign each segment to a speaker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text to segment
|
||||||
|
speakers: List of known speakers in the text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples: (speaker_name, text_segment)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If Ollama is unavailable or request fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client: # Longer timeout for segmentation
|
||||||
|
prompt = self._build_segmentation_prompt(text, speakers)
|
||||||
|
|
||||||
|
logger.info(f"Requesting AI segmentation for {len(text)} chars with {len(speakers)} speakers")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"format": "json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
response_text = result.get("response", "")
|
||||||
|
|
||||||
|
segments = self._parse_segmentation_response(response_text)
|
||||||
|
logger.info(f"AI segmentation produced {len(segments)} segments")
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning("Ollama segmentation request timed out")
|
||||||
|
raise Exception("Ollama segmentation timed out")
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning(f"Ollama HTTP error during segmentation: {e}")
|
||||||
|
raise Exception(f"Ollama service error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ollama segmentation failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _build_segmentation_prompt(self, text: str, speakers: List[str]) -> str:
|
||||||
|
"""
|
||||||
|
Constructs prompt for text segmentation with speaker attribution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text to segment
|
||||||
|
speakers: List of known speakers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt string
|
||||||
|
"""
|
||||||
|
speakers_str = ", ".join(f'"{s}"' for s in speakers)
|
||||||
|
|
||||||
|
prompt = f"""You are analyzing a story or dialogue. Your task is to split the text into segments and assign each segment to the appropriate speaker.
|
||||||
|
|
||||||
|
Known speakers: {speakers_str}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Split the text into logical segments (sentences or short paragraphs)
|
||||||
|
2. Assign each segment to the speaker who is speaking or being described
|
||||||
|
3. Use "Narrator" for narrative text, descriptions, or text that doesn't belong to a specific character
|
||||||
|
4. If a character is mentioned but not speaking, use "Narrator"
|
||||||
|
5. Only use speakers from the known speakers list
|
||||||
|
|
||||||
|
Return a JSON object with a "segments" array. Each segment should have:
|
||||||
|
- "speaker": the name of the speaker (must be from the known speakers list or "Narrator")
|
||||||
|
- "text": the text content for that segment
|
||||||
|
|
||||||
|
Example format:
|
||||||
|
{{
|
||||||
|
"segments": [
|
||||||
|
{{"speaker": "Narrator", "text": "The old wizard gazed into his crystal ball."}},
|
||||||
|
{{"speaker": "Wizard", "text": "I see danger approaching."}},
|
||||||
|
{{"speaker": "Narrator", "text": "A young knight heard the warning."}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Text to segment:
|
||||||
|
{text}
|
||||||
|
|
||||||
|
Return ONLY the JSON object with the segments array.
|
||||||
|
"""
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _parse_segmentation_response(self, response: str) -> List[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Parses Ollama segmentation response to extract speaker-text pairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Response text from Ollama
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples: (speaker_name, text_content)
|
||||||
|
"""
|
||||||
|
if not response or not response.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to parse as JSON
|
||||||
|
data = json.loads(response.strip())
|
||||||
|
|
||||||
|
# Extract segments array
|
||||||
|
segments_data = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "segments" in data:
|
||||||
|
segments_data = data["segments"]
|
||||||
|
else:
|
||||||
|
# Try to find any array in the response
|
||||||
|
for value in data.values():
|
||||||
|
if isinstance(value, list):
|
||||||
|
segments_data = value
|
||||||
|
break
|
||||||
|
elif isinstance(data, list):
|
||||||
|
segments_data = data
|
||||||
|
|
||||||
|
# Convert to tuples
|
||||||
|
segments = []
|
||||||
|
for seg in segments_data:
|
||||||
|
if isinstance(seg, dict) and "speaker" in seg and "text" in seg:
|
||||||
|
speaker = str(seg["speaker"]).strip()
|
||||||
|
text = str(seg["text"]).strip()
|
||||||
|
if speaker and text:
|
||||||
|
segments.append((speaker, text))
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"Failed to parse Ollama segmentation response as JSON: {response[:200]}")
|
||||||
|
return []
|
||||||
|
|
||||||
async def is_available(self) -> bool:
|
async def is_available(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if Ollama service is available.
|
Checks if Ollama service is available.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Tuple, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
from kokoro import KPipeline
|
from kokoro import KPipeline
|
||||||
|
from .ollama_service import OllamaService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,9 +31,10 @@ class TTSService:
|
|||||||
COLON_PATTERN = r'^([A-Z][a-zA-Z\s]+?):\s*(.+)$'
|
COLON_PATTERN = r'^([A-Z][a-zA-Z\s]+?):\s*(.+)$'
|
||||||
BRACKET_PATTERN = r'^\[([A-Z][a-zA-Z\s]+?)\]\s*(.+)$'
|
BRACKET_PATTERN = r'^\[([A-Z][a-zA-Z\s]+?)\]\s*(.+)$'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, ollama_service: Optional[OllamaService] = None):
|
||||||
"""Initializes TTS service with empty pipeline cache."""
|
"""Initializes TTS service with empty pipeline cache and optional Ollama service."""
|
||||||
self.pipelines: Dict[str, KPipeline] = {}
|
self.pipelines: Dict[str, KPipeline] = {}
|
||||||
|
self.ollama_service = ollama_service or OllamaService()
|
||||||
logger.info("TTSService initialized")
|
logger.info("TTSService initialized")
|
||||||
|
|
||||||
def get_pipeline(self, lang_code: str) -> KPipeline:
|
def get_pipeline(self, lang_code: str) -> KPipeline:
|
||||||
@@ -72,15 +74,62 @@ class TTSService:
|
|||||||
logger.error(f"Failed to initialize pipeline for language {lang_code}: {e}")
|
logger.error(f"Failed to initialize pipeline for language {lang_code}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def segment_text_with_ai(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
role_voice_map: Dict[str, str]
|
||||||
|
) -> Optional[List[Tuple[str, str, str]]]:
|
||||||
|
"""
|
||||||
|
Uses AI to segment text and assign speakers intelligently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text to segment
|
||||||
|
role_voice_map: Mapping of role IDs to voice IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples: (role_id, voice_id, text_content) or None if AI unavailable
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if Ollama is available
|
||||||
|
is_available = await self.ollama_service.is_available()
|
||||||
|
if not is_available:
|
||||||
|
logger.info("Ollama not available for AI segmentation")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get list of speakers from role_voice_map
|
||||||
|
speakers = list(role_voice_map.keys())
|
||||||
|
|
||||||
|
# Request AI segmentation
|
||||||
|
ai_segments = await self.ollama_service.segment_text(text, speakers)
|
||||||
|
|
||||||
|
if not ai_segments:
|
||||||
|
logger.warning("AI segmentation returned no segments")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convert to format with voice assignments
|
||||||
|
segments = []
|
||||||
|
for speaker, content in ai_segments:
|
||||||
|
voice = role_voice_map.get(speaker, self.DEFAULT_VOICE)
|
||||||
|
segments.append((speaker, voice, content))
|
||||||
|
|
||||||
|
logger.info(f"AI segmented text into {len(segments)} segments")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"AI segmentation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def segment_text(
|
def segment_text(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
role_voice_map: Dict[str, str]
|
role_voice_map: Dict[str, str]
|
||||||
) -> List[Tuple[str, str, str]]:
|
) -> List[Tuple[str, str, str]]:
|
||||||
"""
|
"""
|
||||||
Segments text into (role_id, voice, text_content) tuples.
|
Segments text into (role_id, voice, text_content) tuples using pattern matching.
|
||||||
Identifies role markers and assigns segments accordingly.
|
Identifies role markers and assigns segments accordingly.
|
||||||
|
|
||||||
|
This is the fallback method when AI segmentation is not available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Input text to segment
|
text: Input text to segment
|
||||||
role_voice_map: Mapping of role IDs to voice IDs
|
role_voice_map: Mapping of role IDs to voice IDs
|
||||||
@@ -118,7 +167,7 @@ class TTSService:
|
|||||||
voice = role_voice_map.get("Narrator", self.DEFAULT_VOICE)
|
voice = role_voice_map.get("Narrator", self.DEFAULT_VOICE)
|
||||||
segments.append(("Narrator", voice, line))
|
segments.append(("Narrator", voice, line))
|
||||||
|
|
||||||
logger.info(f"Segmented text into {len(segments)} segments")
|
logger.info(f"Pattern-based segmentation produced {len(segments)} segments")
|
||||||
return segments
|
return segments
|
||||||
|
|
||||||
def _chunk_text(self, text: str, max_length: int = 500) -> List[str]:
|
def _chunk_text(self, text: str, max_length: int = 500) -> List[str]:
|
||||||
@@ -186,13 +235,16 @@ class TTSService:
|
|||||||
try:
|
try:
|
||||||
# Generate audio for chunk
|
# Generate audio for chunk
|
||||||
result = pipeline(chunk, voice=voice, speed=speed)
|
result = pipeline(chunk, voice=voice, speed=speed)
|
||||||
# Kokoro returns a generator that yields audio arrays
|
# Kokoro returns a generator that yields Result objects
|
||||||
# Collect all arrays and concatenate them
|
# Each Result object has an .audio attribute (torch.Tensor)
|
||||||
audio_parts = []
|
audio_parts = []
|
||||||
for part in result:
|
for part in result:
|
||||||
if isinstance(part, np.ndarray):
|
# Extract audio tensor and convert to numpy
|
||||||
# Flatten to 1D if needed
|
if hasattr(part, 'audio'):
|
||||||
audio_parts.append(part.flatten())
|
audio_tensor = part.audio
|
||||||
|
# Convert torch tensor to numpy and flatten
|
||||||
|
audio_np = audio_tensor.cpu().numpy().flatten()
|
||||||
|
audio_parts.append(audio_np)
|
||||||
|
|
||||||
# Concatenate all parts
|
# Concatenate all parts
|
||||||
if audio_parts:
|
if audio_parts:
|
||||||
@@ -247,6 +299,8 @@ class TTSService:
|
|||||||
Main orchestration method for audiobook generation.
|
Main orchestration method for audiobook generation.
|
||||||
Segments text, synthesizes each segment, concatenates, and returns audio bytes.
|
Segments text, synthesizes each segment, concatenates, and returns audio bytes.
|
||||||
|
|
||||||
|
Uses AI segmentation when available, falls back to pattern matching.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Input text
|
text: Input text
|
||||||
lang: Language code
|
lang: Language code
|
||||||
@@ -264,8 +318,14 @@ class TTSService:
|
|||||||
# Get or create pipeline for language
|
# Get or create pipeline for language
|
||||||
pipeline = self.get_pipeline(lang)
|
pipeline = self.get_pipeline(lang)
|
||||||
|
|
||||||
# Segment text by roles
|
# Try AI segmentation first, fall back to pattern matching
|
||||||
segments = self.segment_text(text, role_voice_map)
|
segments = await self.segment_text_with_ai(text, role_voice_map)
|
||||||
|
|
||||||
|
if segments is None:
|
||||||
|
logger.info("Using pattern-based segmentation as fallback")
|
||||||
|
segments = self.segment_text(text, role_voice_map)
|
||||||
|
else:
|
||||||
|
logger.info("Using AI-powered segmentation")
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
raise ValueError("No segments to synthesize")
|
raise ValueError("No segments to synthesize")
|
||||||
|
|||||||
149
backend/test_ai_segmentation.py
Normal file
149
backend/test_ai_segmentation.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for AI-powered text segmentation.
|
||||||
|
Tests the integration between OllamaService and TTSService for intelligent text splitting.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from services.ollama_service import OllamaService
|
||||||
|
from services.tts_service import TTSService
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ai_segmentation():
|
||||||
|
"""Test AI segmentation with a sample narrative text."""
|
||||||
|
print("Testing AI-Powered Text Segmentation\n" + "="*50)
|
||||||
|
|
||||||
|
# Sample text without explicit role markers
|
||||||
|
text = """The old wizard gazed into his crystal ball. "I see danger approaching," he whispered.
|
||||||
|
A young knight heard the warning and drew his sword. "I will protect the kingdom," he declared boldly.
|
||||||
|
The princess watched from her tower window. She knew the prophecy would come true."""
|
||||||
|
|
||||||
|
print(f"\nInput text:\n{text}\n")
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
ollama_service = OllamaService()
|
||||||
|
tts_service = TTSService(ollama_service=ollama_service)
|
||||||
|
|
||||||
|
# Check if Ollama is available
|
||||||
|
is_available = await ollama_service.is_available()
|
||||||
|
if not is_available:
|
||||||
|
print("❌ Ollama is not available. Please start Ollama service.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("✓ Ollama service is available\n")
|
||||||
|
|
||||||
|
# Define role-voice mapping (simulating what user would assign)
|
||||||
|
role_voice_map = {
|
||||||
|
"Narrator": "af_bella",
|
||||||
|
"The old wizard": "am_adam",
|
||||||
|
"A young knight": "am_michael",
|
||||||
|
"The princess": "af_sarah"
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Role-voice mapping: {role_voice_map}\n")
|
||||||
|
|
||||||
|
# Test AI segmentation
|
||||||
|
try:
|
||||||
|
print("Requesting AI segmentation...")
|
||||||
|
segments = await tts_service.segment_text_with_ai(text, role_voice_map)
|
||||||
|
|
||||||
|
if segments is None:
|
||||||
|
print("❌ AI segmentation returned None")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\n✓ AI segmentation successful! Generated {len(segments)} segments:\n")
|
||||||
|
|
||||||
|
for i, (speaker, voice, content) in enumerate(segments, 1):
|
||||||
|
print(f"Segment {i}:")
|
||||||
|
print(f" Speaker: {speaker}")
|
||||||
|
print(f" Voice: {voice}")
|
||||||
|
print(f" Text: {content[:80]}{'...' if len(content) > 80 else ''}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Verify segments are properly assigned
|
||||||
|
speakers_used = set(seg[0] for seg in segments)
|
||||||
|
print(f"Speakers used in segmentation: {speakers_used}")
|
||||||
|
|
||||||
|
# Check if AI intelligently assigned different speakers
|
||||||
|
if len(speakers_used) > 1:
|
||||||
|
print("\n✓ SUCCESS: AI intelligently assigned multiple speakers!")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("\n⚠ WARNING: Only one speaker used. AI may not have detected dialogue.")
|
||||||
|
return True # Still a success, just not ideal
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fallback_to_pattern():
|
||||||
|
"""Test that pattern-based segmentation works as fallback."""
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Testing Fallback to Pattern-Based Segmentation\n")
|
||||||
|
|
||||||
|
# Text with explicit markers
|
||||||
|
text = """Alice: Hello there!
|
||||||
|
Bob: Hi Alice, how are you?
|
||||||
|
[Narrator] They continued their conversation in the garden."""
|
||||||
|
|
||||||
|
print(f"Input text:\n{text}\n")
|
||||||
|
|
||||||
|
tts_service = TTSService()
|
||||||
|
|
||||||
|
role_voice_map = {
|
||||||
|
"Alice": "af_bella",
|
||||||
|
"Bob": "am_adam",
|
||||||
|
"Narrator": "af_sarah"
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Role-voice mapping: {role_voice_map}\n")
|
||||||
|
|
||||||
|
# Use pattern-based segmentation
|
||||||
|
segments = tts_service.segment_text(text, role_voice_map)
|
||||||
|
|
||||||
|
print(f"✓ Pattern segmentation generated {len(segments)} segments:\n")
|
||||||
|
|
||||||
|
for i, (speaker, voice, content) in enumerate(segments, 1):
|
||||||
|
print(f"Segment {i}: [{speaker}] {content}")
|
||||||
|
|
||||||
|
# Verify all speakers were detected
|
||||||
|
speakers_used = set(seg[0] for seg in segments)
|
||||||
|
expected_speakers = {"Alice", "Bob", "Narrator"}
|
||||||
|
|
||||||
|
if speakers_used == expected_speakers:
|
||||||
|
print(f"\n✓ SUCCESS: All expected speakers detected: {speakers_used}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"\n❌ FAIL: Expected {expected_speakers}, got {speakers_used}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all tests."""
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("AI-POWERED TEXT SEGMENTATION TEST SUITE")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
# Test 1: AI segmentation
|
||||||
|
test1_passed = await test_ai_segmentation()
|
||||||
|
|
||||||
|
# Test 2: Pattern fallback
|
||||||
|
test2_passed = await test_fallback_to_pattern()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("TEST SUMMARY")
|
||||||
|
print("="*70)
|
||||||
|
print(f"AI Segmentation: {'✓ PASSED' if test1_passed else '❌ FAILED'}")
|
||||||
|
print(f"Pattern Fallback: {'✓ PASSED' if test2_passed else '❌ FAILED'}")
|
||||||
|
print("="*70 + "\n")
|
||||||
|
|
||||||
|
return test1_passed and test2_passed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
success = asyncio.run(main())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
Reference in New Issue
Block a user