speaker_helper.narration module
Narration: a document read aloud, and the timed track that follows it.
Module summary
synthesize() answers “what does this
sentence sound like”. This module answers the different question a document
asks: read these blocks, in this order, with the pauses their structure calls
for, and tell me where each one landed in the audio so a page can follow
along while it plays.
That second half is the part a plain text -> wav call cannot give you, and the part that is easy to get subtly wrong. Three facts, each learned the expensive way:
What the voice says is not always what the page shows. A formula is
notation on the page and a sentence in the ear; a footnote marker is a
signpost on the page and a stray number in the ear. So a Segment
carries both: text is the page’s words and goes into the cue, say is
what the engine is handed. The invariant is that the cue keeps the page’s
words — that is what lets a read-along highlight land on the right ones.
A segment with nothing speakable in it still takes its turn. A French
page closing a quotation on its own line gives a “sentence” that is a single
». Kokoro refuses it — nothing to synthesize, produced no phonemes —
and the obvious repair, dropping it, is worse than the crash: the page still
shows that block, so every later sentence would light up one place out. It
becomes a short silence that keeps its rank.
The timeline and the file must agree, and something has to check. The
offsets are exact and already known while the audio is concatenated; the risk
is a piece appended without being recorded. Timeline.total_seconds() is
compared against the written file, and a mismatch is an error rather than a
narration whose highlighting drifts further out with every sentence.
Usage example
>>> timeline = Timeline(1000)
>>> timeline.silence(500)
500
>>> timeline.speech(2000, index=0, text="Hello.")
>>> timeline.total_seconds
2.5
>>> print(cues_to_vtt(timeline.cues))
WEBVTT
0
00:00:00.500 --> 00:00:02.500
Hello.
- class speaker_helper.narration.NarrationResult(wav_bytes, sample_rate, duration_s, compute_s, cues=<factory>, voice_id='', language='', detected_language='', language_fallback=False, ignored_hints=<factory>)[source]
Bases:
objectA narrated document: the audio, and where every block landed in it.
- Parameters:
wav_bytes (bytes) – The complete WAV file (RIFF header + PCM).
sample_rate (int) – Sample rate in Hz.
duration_s (float) – Length of the audio.
compute_s (float) – Wall-clock seconds spent synthesising.
cues (list of SpeechCue) – Where each segment is spoken. Free to produce — the offsets were already computed to lay the audio out — and the only thing that lets a page follow a narration instead of merely playing it.
voice_id (str) – The voice that read it.
language (str) – The language it was phonemised in. Getting this wrong is not an accent: French read as
en-usis an American reading French spelling aloud, letter patterns and all.detected_language (str) – What the text turned out to be written in, when nobody named a voice. Empty when the caller chose one — this is the detector’s answer, not a restatement of the request.
language_fallback (bool) –
Truewhen the text is in a language the backend has no voice for, so it is being read by a voice that does not speak it. Said out loud, because the audio alone will not make it obvious to whoever generated it and does not speak the language either.ignored_hints (list of str) – Dials the segments asked for and this backend does not have. Empty on a plain narration; non-empty means the caller should know which engine does have them.
- speaker_helper.narration.PACE_SPEED: dict[str, float] = {'fast': 1.15, 'normal': 1.0, 'slow': 0.85}
Multipliers the engines are handed for each pace.
- speaker_helper.narration.PUNCTUATION_PAUSE_MS: int = 120
How long a segment with nothing speakable in it is held for. Short enough to read as punctuation rather than as a gap, long enough to exist.
- class speaker_helper.narration.Segment(text, say='', pause_before_ms=0.0, pause_after_ms=0.0, pace='normal', emotion='', intensity='')[source]
Bases:
objectOne block of a document, as the narrator receives it.
- Parameters:
text (str) – The page’s words. This is what the cue carries, and therefore what a read-along highlights. Never substituted.
say (str) – What the engine should actually pronounce, when that differs — a formula read as a sentence, a footnote marker dropped. Empty means “say
text”, which is the ordinary case.pause_before_ms (float) – Silence the document’s structure asks for around this block — the gap that sets a heading apart from the paragraph before it. Requested by the caller, applied here.
pause_after_ms (float) – Silence the document’s structure asks for around this block — the gap that sets a heading apart from the paragraph before it. Requested by the caller, applied here.
pace (str) –
"slow","normal"or"fast". SeePACE_SPEED.emotion (str) – Asked for by some documents, honoured by some engines. A backend that has no such dial reports the name in
NarrationResult.ignored_hintsrather than pretending.intensity (str) – Asked for by some documents, honoured by some engines. A backend that has no such dial reports the name in
NarrationResult.ignored_hintsrather than pretending.
Notes
pause_before_msis skipped on the first segment: a “pause before” with nothing before it is dead air at the head of the file.- classmethod from_mapping(raw)[source]
Build a segment from a plain dict, ignoring keys it does not know.
The wire format between a document tool and this one is JSON, and the document side is free to carry extra keys (block ids, heading levels) that are none of a narrator’s business. Unknown keys are dropped here rather than rejected, so adding one upstream is not a breaking change.
- class speaker_helper.narration.SpeechCue(index, text, start_seconds, end_seconds)[source]
Bases:
objectOne narrated block, with the slice of audio that speaks it.
- Parameters:
index (int) – Position in the original segment list, not in the audio. The two differ: a segment whose text is empty after cleaning is never synthesised, so counting emitted pieces drifts from counting segments. Carrying the original index is what lets a page map a cue back to the block it came from.
start_seconds (float) – Where this block is spoken, from the head of the file.
end_seconds (float) – Where this block is spoken, from the head of the file.
- class speaker_helper.narration.Timeline(sample_rate_hz)[source]
Bases:
objectAccumulates durations while a backend accumulates audio.
Two jobs, one object: apply the pauses the segments ask for, and record where every segment landed. Both need the same running sample count, and keeping them apart is how they drift.
Deliberately numpy-free: it counts samples and the caller holds the arrays, so this module imports with nothing but the standard library — which is what lets a caller build and inspect a narration plan without the ONNX runtime anywhere near it.
Examples
>>> timeline = Timeline(1000) >>> timeline.silence(500) 500 >>> timeline.speech(2000, index=0, text="Hello.") >>> timeline.silence(250) 250 >>> [(c.start_seconds, c.end_seconds) for c in timeline.cues] [(0.5, 2.5)] >>> timeline.total_seconds 2.75
- Parameters:
sample_rate_hz (int)
- silence(milliseconds)[source]
Advance the clock by
milliseconds; return the samples to append.Returning the count rather than an array is what keeps this module numpy-free. A caller that ignores the return value desynchronises the timeline from its own audio, which is exactly what
check_timeline_matches_audio()exists to catch.
- speaker_helper.narration.check_timeline_matches_audio(timeline, duration_s)[source]
Refuse a narration whose cues do not describe the file that was written.
The timeline counted exactly what the backend said it appended, so a disagreement means a piece reached the audio without being recorded. The symptom of letting that through is not a crash: it is a narration that plays correctly and highlights further and further behind itself, which looks like a bug in whatever renders it.
- Raises:
ValueError – When the two disagree by more than a sample’s worth of rounding.
- Parameters:
- Return type:
None
- speaker_helper.narration.coerce_segments(raw)[source]
Accept segments as dicts, as
Segment, or as bare strings.Three shapes because three callers: a document tool writes JSON, a Python caller builds dataclasses, and somebody at a REPL has a list of sentences and no interest in any of this.
- speaker_helper.narration.cues_to_vtt(cues)[source]
Render narration cues as WebVTT.
Each cue carries its segment index as the WebVTT identifier — the line before the timings — which is what lets a page highlight the right block without matching on text. The text is collapsed onto one line because a blank line ends a cue, so a hard-wrapped payload would silently become two cues and shift every index after it.
- speaker_helper.narration.format_timestamp(seconds)[source]
Format seconds as WebVTT’s
HH:MM:SS.mmm.Negative input clamps to zero: a negative timestamp is never meaningful and a renderer’s behaviour on one is undefined rather than obvious.
- speaker_helper.narration.resolve_spoken_math(segments, lang, *, narrator=None)[source]
Fill each segment’s
saywith a readable form of its notation.A convenience over
speaker_helper.speech_text.speakable(), which is where the work actually happens. Separate fromSegmenton purpose: a segment whosesaywas set deliberately — a pronunciation override, a formula a human already phrased — must not be overwritten, so this only fills the empty ones.- Parameters:
segments (list of Segment) – Left untouched; a new list is returned.
lang (str) – The language the page is written in. Not optional and not defaulted: a French page read with English operator names is worse than one that says nothing, because the listener hears fluent words in the wrong language and assumes they misheard.
narrator (callable or None) –
(latex, lang) -> str | None, asked about formulas too complex to spell out. Optional — without one every formula gets the deterministic reading, which is the baseline, not a degraded mode.
- Returns:
The same blocks, with
sayfilled where it was empty and the notation differs from the words.- Return type: