"""
Text segmentation for low-latency streaming synthesis.
Module summary
--------------
In streaming mode the time to first audio (TTFA) is dominated by how much text
the engine must synthesise before it can emit anything. Splitting a paragraph
into sentence-sized units lets speaker-helper synthesise and emit the first
unit quickly while later units are still being produced. This module holds the
deterministic, dependency-free splitter used for that.
Usage example
-------------
>>> from speaker_helper.text import split_sentences
>>> split_sentences("Bonjour. Comment ça va ? Bien !")
['Bonjour.', 'Comment ça va ?', 'Bien !']
Author
------
Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui
"""
from __future__ import annotations
import re
# Sentence terminators for the languages speaker-helper targets. We keep the
# terminator attached to the sentence so the engine still hears the prosodic
# cue. French guillemets and typographic punctuation are intentionally NOT
# treated as boundaries — only end-of-sentence marks are.
_SENTENCE_RE = re.compile(r"[^.!?…。!?]+[.!?…。!?]*", re.UNICODE)
[docs]
def split_sentences(text: str) -> list[str]:
"""Split text into sentence-sized, non-empty, stripped units.
Parameters
----------
text : str
Arbitrary text, possibly multi-sentence and multi-line.
Returns
-------
list[str]
Sentences in order, each stripped of surrounding whitespace, with
their terminating punctuation preserved. Text without any terminator
returns a single-element list (the whole stripped input); empty or
whitespace-only input returns an empty list.
Examples
--------
>>> split_sentences("Un. Deux. Trois.")
['Un.', 'Deux.', 'Trois.']
>>> split_sentences("no terminator here")
['no terminator here']
>>> split_sentences(" ")
[]
"""
# Whitespace-only input has no sentences to speak.
if not text or not text.strip():
return []
# Each regex match is a run of text plus its trailing terminator(s); strip
# surrounding whitespace so chunks glue back together cleanly.
pieces = [m.group(0).strip() for m in _SENTENCE_RE.finditer(text)]
# Drop empties left by back-to-back terminators (e.g. "?!").
sentences = [p for p in pieces if p]
# A body with no terminator at all yields nothing above; fall back to the
# whole stripped text so the caller always gets something to synthesise.
return sentences or [text.strip()]
[docs]
def chunk_for_streaming(text: str, first_chunk_sentences: int = 1) -> list[str]:
"""Group sentences into synthesis chunks, keeping the first one small.
Parameters
----------
text : str
The text to synthesise.
first_chunk_sentences : int, optional
How many sentences to place in the first chunk. Keeping this at ``1``
(the default) minimises TTFA; larger values reduce per-chunk overhead
at the cost of a slower first emission.
Returns
-------
list[str]
Chunks of text to synthesise in order. The first chunk holds
``first_chunk_sentences`` sentences; every remaining sentence is its
own chunk so downstream audio keeps flowing steadily.
Examples
--------
>>> chunk_for_streaming("A. B. C.", first_chunk_sentences=1)
['A.', 'B.', 'C.']
>>> chunk_for_streaming("A. B. C.", first_chunk_sentences=2)
['A. B.', 'C.']
"""
sentences = split_sentences(text)
if not sentences:
return []
# Never emit a zero-sentence first chunk, whatever the caller passed.
n = max(1, first_chunk_sentences)
# The first chunk bundles ``n`` sentences (its size sets TTFA); every later
# sentence is its own chunk so audio keeps flowing at a steady cadence.
first = " ".join(sentences[:n])
return [first, *sentences[n:]]