"""Sentence - A collection of segments with voice context.
A Sentence represents a logical unit of speech that should be spoken together.
Sentences contain segments and have an optional voice context.
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from ssmd.segment import Segment
from ssmd.ssml_conversions import SSMD_BREAK_STRENGTH_MAP
from ssmd.types import BreakAttrs, ProsodyAttrs, VoiceAttrs
if TYPE_CHECKING:
from ssmd.capabilities import TTSCapabilities
[docs]
@dataclass
class Sentence:
"""A sentence containing segments with directive context.
Represents a logical sentence unit that should be spoken together.
Sentences are split on:
- Directive changes (<div ...> blocks)
- Sentence boundaries (.!?) when sentence_detection=True
- Paragraph breaks (\n\n)
Attributes:
segments: List of segments in the sentence
voice: Voice context for entire sentence (from <div voice=...> directives)
language: Language directive for the sentence
prosody: Prosody directive for the sentence
is_paragraph_end: True if sentence ends with paragraph break
paragraph_index: Zero-based paragraph index for this sentence
sentence_index: Zero-based sentence index within the document
breaks_after: Pauses after the sentence
"""
segments: list[Segment] = field(default_factory=list)
voice: VoiceAttrs | None = None
language: str | None = None
prosody: ProsodyAttrs | None = None
is_paragraph_end: bool = False
paragraph_index: int = 0
sentence_index: int = 0
breaks_after: list[BreakAttrs] = field(default_factory=list)
[docs]
def to_ssml(
self,
capabilities: "TTSCapabilities | None" = None,
extensions: dict | None = None,
wrap_sentence: bool = False,
warnings: list[str] | None = None,
) -> str:
"""Convert sentence to SSML.
Args:
capabilities: TTS engine capabilities for filtering
extensions: Custom extension handlers
wrap_sentence: If True, wrap content in <s> tag
warnings: Optional list to collect warnings
Returns:
SSML string
"""
# Build segment content
content_parts = []
for segment in self.segments:
content_parts.append(
segment.to_ssml(
capabilities,
extensions,
warnings=warnings,
)
)
# Join segments with spaces, but handle punctuation intelligently
content = self._join_segments(content_parts)
# Wrap in <s> tag if requested
if wrap_sentence:
content = f"<s>{content}</s>"
# Apply directive wrappers (voice/language/prosody)
content = self._wrap_directives(content, capabilities, warnings)
# Add breaks after sentence
if not capabilities or capabilities.break_tags:
for brk in self.breaks_after:
content += self._break_to_ssml(brk)
return content
def _join_segments(self, parts: list[str]) -> str:
"""Join SSML segment parts with appropriate spacing.
Adds spaces between segments but not before punctuation.
Args:
parts: List of SSML strings for each segment
Returns:
Joined SSML string
"""
import re
if not parts:
return ""
result = parts[0]
for i in range(1, len(parts)):
part = parts[i]
# Don't add space before punctuation or if part starts with <break
if part and (
re.match(r'^[.!?,;:\'")\]}>]', part)
or part.startswith("<break")
or part.startswith("<mark")
):
result += part
# Don't add space if previous part ends with opening bracket/quote
elif result and result[-1] in "([{<\"'":
result += part
else:
result += " " + part
return result
def _wrap_directives(
self,
content: str,
capabilities: "TTSCapabilities | None",
warnings: list[str] | None,
) -> str:
"""Apply voice, language, and prosody directives."""
from ssmd.segment import _escape_xml_attr
if self.voice:
attrs = []
if self.voice.name:
name = _escape_xml_attr(self.voice.name)
attrs.append(f'name="{name}"')
else:
if self.voice.language:
lang = _escape_xml_attr(self.voice.language)
attrs.append(f'language="{lang}"')
if self.voice.gender:
gender = _escape_xml_attr(self.voice.gender)
attrs.append(f'gender="{gender}"')
if self.voice.variant:
variant = _escape_xml_attr(str(self.voice.variant))
attrs.append(f'variant="{variant}"')
if attrs:
content = f"<voice {' '.join(attrs)}>{content}</voice>"
if self.language and (not capabilities or capabilities.language):
if not capabilities or capabilities.language_scopes.get("sentence", True):
lang_escaped = _escape_xml_attr(self.language)
content = f'<lang xml:lang="{lang_escaped}">{content}</lang>'
elif warnings is not None:
warnings.append(
f"Language scope 'sentence' not supported, "
f"dropping lang={self.language}"
)
if self.prosody and (not capabilities or capabilities.prosody):
prosody_attrs = []
if self.prosody.volume and (not capabilities or capabilities.volume):
vol = _escape_xml_attr(self.prosody.volume)
prosody_attrs.append(f'volume="{vol}"')
if self.prosody.rate and (not capabilities or capabilities.rate):
rate = _escape_xml_attr(self.prosody.rate)
prosody_attrs.append(f'rate="{rate}"')
if self.prosody.pitch and (not capabilities or capabilities.pitch):
pitch = _escape_xml_attr(self.prosody.pitch)
prosody_attrs.append(f'pitch="{pitch}"')
if prosody_attrs:
content = f"<prosody {' '.join(prosody_attrs)}>{content}</prosody>"
return content
def _break_to_ssml(self, brk: BreakAttrs) -> str:
"""Convert break to SSML."""
from ssmd.segment import _escape_xml_attr
if brk.time:
time = _escape_xml_attr(brk.time)
return f'<break time="{time}"/>'
elif brk.strength:
strength = _escape_xml_attr(brk.strength)
return f'<break strength="{strength}"/>'
return "<break/>"
[docs]
def to_ssmd(self) -> str:
"""Convert sentence to SSMD markdown.
Returns:
SSMD string
"""
result = ""
# Build segment content
content_parts = [segment.to_ssmd() for segment in self.segments]
result += self._join_text_parts(content_parts)
# Add breaks after sentence
for brk in self.breaks_after:
result += " " + self._break_to_ssmd(brk)
return result
def _break_to_ssmd(self, brk: BreakAttrs) -> str:
"""Convert break to SSMD format."""
if brk.time:
return f"...{brk.time}"
elif brk.strength:
return SSMD_BREAK_STRENGTH_MAP.get(brk.strength, "...s")
return "...s"
[docs]
def to_text(self) -> str:
"""Convert sentence to plain text.
Returns:
Plain text with all markup removed
"""
text_parts = [segment.to_text() for segment in self.segments]
return self._join_text_parts(text_parts)
def _join_text_parts(self, parts: list[str]) -> str:
"""Join text parts with appropriate spacing.
Adds spaces between parts but not before punctuation.
Args:
parts: List of text strings for each segment
Returns:
Joined text string
"""
import re
if not parts:
return ""
# Filter out empty parts
parts = [p for p in parts if p]
if not parts:
return ""
result = parts[0]
for i in range(1, len(parts)):
part = parts[i]
# Don't add space before punctuation
if part and re.match(r'^[.!?,;:\'")\]}>]', part):
result += part
# Don't add space if previous part ends with opening bracket/quote
elif result and result[-1] in "([{<\"'":
result += part
else:
result += " " + part
return result
@property
def text(self) -> str:
"""Get plain text content of the sentence.
Returns:
Plain text string
"""
return self.to_text()
[docs]
def __str__(self) -> str:
"""String representation returns plain text."""
return self.to_text()
[docs]
def __len__(self) -> int:
"""Return number of segments."""
return len(self.segments)
[docs]
def __iter__(self):
"""Iterate over segments."""
return iter(self.segments)