braidio.pacing#
Intra-beat narration pacing — how one narration beat becomes spoken turns.
A narration beat used to be exactly one TTS call: one prosodic arc, one speed, and no silence anywhere inside it. That is the mechanical read — a human varies tempo within a paragraph and pauses *proportionally to how strongly a boundary closes* (a paragraph break is not a comma).
This module is the pure, deterministic planner for that. It takes the beat’s
text and the pacing knobs off a WeaveConfig and
returns a list of NarrationTurn — what to synthesize, how fast, and how
much silence to leave after it. No audio, no API, no I/O: the renderer
(braidio.render.render_production()) executes the plan.
Two research findings drive the design
(misc/docs/research/notebooklm-and-conversational-pacing.md):
Boundary-proportional pauses. Punctuation is the engine’s pause instruction, and a pause is only human when the boundary that earns it is strong.
BOUNDARIESis that table.Final lengthening. The robotic signature is pause without lengthening — a clipped last word followed by dead air. So each boundary carries a
speed_scaleas well as agap_scale, and they stay correlated.
The unit="beat" plan is empty by construction: that is the historical
one-call-per-beat behavior, and it is the default, so nothing paces unless a
WeaveConfig asks for it.
>>> turns = plan_turns("One. Two. Three.", unit="sentence", min_turn=1, max_turn=1)
>>> [t.text for t in turns]
['One.', 'Two.', 'Three.']
>>> plan_turns("One. Two.", unit="beat")
[]
Module Attributes
How a beat's text is cut into the units a turn is built from. |
|
ElevenLabs clamps |
|
The boundary table. |
Functions
|
Name the boundary implied by |
|
Plan how to speak one narration beat. |
|
Total silence a plan inserts inside the beat (for reporting / tests). |
|
Split |
|
Cut |
Classes
|
How strongly a unit's trailing punctuation closes. |
|
One synthesizable chunk of a narration beat, with its pacing. |
- braidio.pacing.BOUNDARIES: Mapping[str, Boundary] = {'clause': Boundary(name='clause', gap_scale=0.5, speed_scale=0.99), 'ellipsis': Boundary(name='ellipsis', gap_scale=1.4, speed_scale=0.97), 'flowing': Boundary(name='flowing', gap_scale=0.35, speed_scale=1.0), 'none': Boundary(name='none', gap_scale=0.25, speed_scale=1.0), 'paragraph': Boundary(name='paragraph', gap_scale=2.2, speed_scale=0.96), 'sentence': Boundary(name='sentence', gap_scale=1.0, speed_scale=0.98)}#
The boundary table. Scales are relative to
"sentence"(== the configuredgap_turn_s), from the punctuation→break-strength table inmisc/docs/research/notebooklm-and-conversational-pacing.md.
- class braidio.pacing.Boundary(name, gap_scale, speed_scale)[source]#
Bases:
objectHow strongly a unit’s trailing punctuation closes.
gap_scalemultiplies the base gap (WeaveConfig.gap_turn_s) andspeed_scalemultiplies the turn’s speed — final lengthening, the thing that keeps a pause from sounding like a dropout. The two are deliberately correlated: a stronger boundary gets both a longer silence and a slower approach to it.
- class braidio.pacing.NarrationTurn(text, gap_after_s=0.0, speed=None, boundary='sentence')[source]#
Bases:
objectOne synthesizable chunk of a narration beat, with its pacing.
speedisNonewhen the delivery’s model has no speed control (eleven v3) — the renderer then leavesvoice_settings["speed"]alone rather than sending a parameter the model is documented not to honor.
- braidio.pacing.SEGMENTATION_UNITS = ('beat', 'paragraph', 'sentence', 'clause')#
How a beat’s text is cut into the units a turn is built from.
"beat"means “don’t cut at all” — one TTS call per beat, the historical behavior.
- braidio.pacing.SPEED_RANGE = (0.7, 1.2)#
ElevenLabs clamps
voice_settings.speedto this range in practice; a jittered speed outside it is silently useless, so the planner clamps.
- braidio.pacing.classify_boundary(text)[source]#
Name the boundary implied by
text’s trailing punctuation.- Return type:
>>> classify_boundary("So that's the charge sheet.") 'sentence' >>> classify_boundary('He said "no," and left,') 'clause' >>> classify_boundary("and then —") 'flowing' >>> classify_boundary("well…") 'ellipsis' >>> classify_boundary("no punctuation here") 'none'
- braidio.pacing.plan_turns(text, *, unit='sentence', min_turn=1, max_turn=3, seed=7, gap_s=0.0, speed_base=1.0, speed_jitter=0.0, gap_scale_cap_s=3.0)[source]#
Plan how to speak one narration beat. Pure and deterministic in
seed.Returns
[]forunit="beat"(and for empty text) — the caller’s signal to keep the historical single-call path, byte for byte. Otherwise each returnedNarrationTurnis one TTS call, with the silence to append after it and the speed to read it at.gap_sis the sentence-boundary gap; every other boundary scales off it viaBOUNDARIES, capped atgap_scale_cap_s(long silences read as a dropout, and the engines are unstable past ~3 s). The final turn of the beat never gets a trailing gap — the beat boundary belongs to the renderer’s crossfade and to the next beat’sNarration.lead_gap_s.speed_base=Noneplans no speed at all (eleven v3, which has no speed control); a float centers a per-turn jitter of±speed_jitteron it, scaled by the boundary’s final lengthening and clamped toSPEED_RANGE.- Return type:
>>> turns = plan_turns("One. Two. Three. Four.", min_turn=2, max_turn=2, gap_s=0.3) >>> [(t.text, t.gap_after_s) for t in turns] [('One. Two.', 0.3), ('Three. Four.', 0.0)] >>> plan_turns("A.\n\nB.", gap_s=0.2)[0].gap_after_s # paragraph > sentence 0.44
- braidio.pacing.planned_gap_total_s(turns)[source]#
Total silence a plan inserts inside the beat (for reporting / tests).
- Return type:
>>> planned_gap_total_s(plan_turns("A. B. C.", min_turn=1, max_turn=1, gap_s=0.3)) 0.6
- braidio.pacing.split_paragraphs(text)[source]#
Split
texton blank lines, collapsing runs of spaces within each.
- braidio.pacing.split_units(text, *, unit='sentence')[source]#
Cut
textintounit-sized pieces, grouped by paragraph.Returns a list of paragraphs, each a list of units, so a caller can keep turns from straddling a paragraph break (which would swallow the strongest pause in the beat).
unit="beat"returns one paragraph of one unit.>>> split_units("A one. A two.\n\nB one.", unit="sentence") [['A one.', 'A two.'], ['B one.']] >>> split_units("Yes, really. No.", unit="clause") [['Yes,', 'really.', 'No.']]