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:
2025-12-09 10:59:00 +00:00
parent b48e53ce63
commit d18fddef57
3 changed files with 361 additions and 13 deletions

View File

@@ -8,6 +8,7 @@ from typing import Dict, List, Tuple, Optional
import numpy as np
import soundfile as sf
from kokoro import KPipeline
from .ollama_service import OllamaService
logger = logging.getLogger(__name__)
@@ -30,9 +31,10 @@ class TTSService:
COLON_PATTERN = r'^([A-Z][a-zA-Z\s]+?):\s*(.+)$'
BRACKET_PATTERN = r'^\[([A-Z][a-zA-Z\s]+?)\]\s*(.+)$'
def __init__(self):
"""Initializes TTS service with empty pipeline cache."""
def __init__(self, ollama_service: Optional[OllamaService] = None):
"""Initializes TTS service with empty pipeline cache and optional Ollama service."""
self.pipelines: Dict[str, KPipeline] = {}
self.ollama_service = ollama_service or OllamaService()
logger.info("TTSService initialized")
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}")
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(
self,
text: str,
role_voice_map: Dict[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.
This is the fallback method when AI segmentation is not available.
Args:
text: Input text to segment
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)
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
def _chunk_text(self, text: str, max_length: int = 500) -> List[str]:
@@ -186,13 +235,16 @@ class TTSService:
try:
# Generate audio for chunk
result = pipeline(chunk, voice=voice, speed=speed)
# Kokoro returns a generator that yields audio arrays
# Collect all arrays and concatenate them
# Kokoro returns a generator that yields Result objects
# Each Result object has an .audio attribute (torch.Tensor)
audio_parts = []
for part in result:
if isinstance(part, np.ndarray):
# Flatten to 1D if needed
audio_parts.append(part.flatten())
# Extract audio tensor and convert to numpy
if hasattr(part, 'audio'):
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
if audio_parts:
@@ -247,6 +299,8 @@ class TTSService:
Main orchestration method for audiobook generation.
Segments text, synthesizes each segment, concatenates, and returns audio bytes.
Uses AI segmentation when available, falls back to pattern matching.
Args:
text: Input text
lang: Language code
@@ -264,8 +318,14 @@ class TTSService:
# Get or create pipeline for language
pipeline = self.get_pipeline(lang)
# Segment text by roles
segments = self.segment_text(text, role_voice_map)
# Try AI segmentation first, fall back to pattern matching
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:
raise ValueError("No segments to synthesize")