Source code for ssmd.sentence

"""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)