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 logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Tuple
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -214,6 +214,145 @@ Return format: {{"speakers": ["Speaker1", "Speaker2", ...]}}
|
||||
# Look for common patterns like lists or quoted names
|
||||
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:
|
||||
"""
|
||||
Checks if Ollama service is available.
|
||||
|
||||
@@ -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