from __future__ import annotations import re from dataclasses import dataclass # Sentinel, который нормализатор оставляет вместо удалённого выделительного # разделителя (пауза фиксированной длины). PAUSE_DELIM = "\uE000" DEFAULT_PAUSE_MS = 150 MAX_BREAK_MS = 5000 # Явная пользовательская пауза кодируется ОДНИМ символом из PUA: # код = EXPLICIT_BASE + длительность_в_мс (0..MAX_BREAK_MS). Одно кодо-point # переживает rutextnorm/translit (в отличие от длинных прогонов и цифр). _EXPLICIT_BASE = 0xE100 RATE_VALUES = ("x-slow", "slow", "medium", "fast", "x-fast") PITCH_VALUES = ("x-low", "low", "medium", "high", "x-high") def _escape(text: str) -> str: return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") ) def explicit_pause_char(ms: int) -> str: ms = max(0, min(int(ms), MAX_BREAK_MS)) return chr(_EXPLICIT_BASE + ms) def _is_sentinel(char: str) -> bool: return _segment_to_break(char) is not None def strip_sentinels(text: str, replacement: str = "") -> str: """Убирает sentinel-символы (для отображения нормализованного текста).""" out = [replacement if _is_sentinel(ch) else ch for ch in text] return re.sub(r" {2,}", " ", "".join(out)).strip() def _segment_to_break(char: str) -> int | None: """Длительность паузы (мс) для sentinel-символа или None, если это текст.""" code = ord(char) if char == PAUSE_DELIM: return DEFAULT_PAUSE_MS if _EXPLICIT_BASE <= code <= _EXPLICIT_BASE + MAX_BREAK_MS: return code - _EXPLICIT_BASE return None def _sentence_content(text: str) -> str: """Текст предложения с заменой sentinel-пауз на ; сам sentinel вырезается, XML-спецсимволы экранируются.""" parts: list[str] = [] buf: list[str] = [] for ch in text: ms = _segment_to_break(ch) if ms is None: buf.append(ch) continue if buf: parts.append(_escape("".join(buf))) buf = [] ms = max(0, min(ms, MAX_BREAK_MS)) parts.append(f'') if buf: parts.append(_escape("".join(buf))) return "".join(parts) def _para_block(segments) -> str: inner = "".join(f"{_sentence_content(s.text)}" for s in segments) return f"

{inner}

" def _content_length(segments) -> int: total = 0 for s in segments: total += sum(1 for ch in s.text if _segment_to_break(ch) is None) return total def build_ssml(segments, *, rate: str | None = None, pitch: str | None = None) -> str: """Собирает один -документ: предложения -> , группы предложений одного абзаца (одинаковый Segment.para) ->

. rate/pitch (опционально) оборачивают всё содержимое одним .""" if not segments: return "" groups: list[list] = [] for seg in segments: key = (seg.lang, getattr(seg, "para", 0)) if groups and ( (groups[-1][0].lang, getattr(groups[-1][0], "para", 0)) == key ): groups[-1].append(seg) else: groups.append([seg]) body = "".join(_para_block(g) for g in groups) attrs: list[str] = [] if rate and rate != "medium": attrs.append(f'rate="{rate}"') if pitch and pitch != "medium": attrs.append(f'pitch="{pitch}"') if attrs: body = f"{body}" return f"{body}" def build_ssml_chunks(segments, *, rate: str | None = None, pitch: str | None = None, max_chars: int = 5000) -> list[str]: """Режет сегменты по границам абзацев на документы <= max_chars символов контента (параграф = атомарный чанк; гигантский параграф режется по предложениям). Возвращает список -документов.""" if not segments: return [] paragraphs: list[list] = [] for seg in segments: key = (seg.lang, getattr(seg, "para", 0)) if paragraphs and ( (paragraphs[-1][0].lang, getattr(paragraphs[-1][0], "para", 0)) == key ): paragraphs[-1].append(seg) else: paragraphs.append([seg]) chunks: list[list] = [] current: list = [] current_len = 0 for para in paragraphs: para_len = _content_length(para) if para_len > max_chars: if current: chunks.append(current) current = [] current_len = 0 piece: list = [] piece_len = 0 for seg in para: seg_len = _content_length([seg]) if piece and piece_len + seg_len > max_chars: chunks.append(piece) piece = [] piece_len = 0 piece.append(seg) piece_len += seg_len if piece: current = piece current_len = piece_len continue if current and current_len + para_len > max_chars: chunks.append(current) current = [] current_len = 0 current.extend(para) current_len += para_len if current: chunks.append(current) return [build_ssml(ch, rate=rate, pitch=pitch) for ch in chunks]