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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user