braidio

braidio — weave narration with extracted media segments into productions.

Braid two kinds of strand into one production: authored narration (TTS — single voice or a cycled pool) and extracted segments of source media (song clips, audiobook passages, news, SFX). The result renders to an audiovisual object.

This is the functional core (pure Python over files + numbers; deps: mixing, elevenlabs, and ffmpeg on PATH). An optional nw-app layer (graph bodies, transforms, provenance / partial re-render) will be added on top and imported only when nw is available — import braidio never requires it.

Extracted from the Hamilton lyrics-podcast; see that project + issues #18/#28.

class braidio.Delivery(name: str, model_id: str, voice_settings: dict = <factory>, supports_audio_tags: bool = False, note: str = '')[source]

A named narration delivery: which model + voice settings to synthesize with.

class braidio.Dialogue(turns: tuple[tuple[str, str], ...], label: str = '')[source]

A multi-speaker commentary exchange (the conversational register).

turns is an ordered tuple of (role, text) pairs (roles like "A"/"B" map to voices via a ConversationCast at render time). Rendered in ONE pass via Text-to-Dialogue so it sounds like people talking to each other. This is our own commentary → always publishable (its text is still scanned for forbidden verbatim quotes in the published cut).

class braidio.Format(id: str, name: str, summary: str, aka: tuple[str, ...]=(), cast: ConversationCast | None = None, narration_voice: str | None = None, narration_delivery: Delivery = Delivery(name='v2-presenter', model_id='eleven_multilingual_v2', voice_settings={'stability': 0.35, 'similarity_boost': 0.75, 'style': 0.35, 'use_speaker_boost': True, 'speed': 0.98}, supports_audio_tags=False, note='Host/presenter commentary lively (== v2-tuned), for the spine voice.'), weave: WeaveConfig = <factory>, roles: Mapping[str, str]=<factory>, clip_placement: str = 'before', music_bed: str = 'light', scripting: str = '')[source]

A named commentary-format preset: a bundle of high-quality defaults.

Rendered fields drive render_format()braidio.render.render_production(). Authoring fields document how to write a Script for this format (and, for clip_placement / music_bed, flag render features still on the roadmap).

render(script, *, source, out_path: str | Path | None = None, profile: Profile = Profile.PERSONAL, **overrides) Path[source]

Render script with this format’s defaults (see render_format()).

class braidio.MusicBed(asset_path: str, gain_db: float = -22.0, fade_in_s: float = 1.5, fade_out_s: float = 2.0, lead_in_s: float = 2.0, start_s: float = 0.0, loop: bool = True)[source]

An instrumental underscore spanning the production, mixed under the talk.

asset_path is an app-supplied instrumental. gain_db sets how far under the voice it sits; lead_in_s posts the bed after speech onset so its entrance feels motivated; start_s is the in-point into the asset; loop repeats the asset to cover a timeline longer than it.

class braidio.Narration(text: str, style: str | None = None, published_text: str | None = None, lead_gap_s: float = 0.0, voice: str | None = None, voice_settings: dict | None = None)[source]

A spoken narration beat (authored, synthesized by TTS).

published_text is an optional rights-safe rewrite the published profile uses when the default text quotes forbidden (e.g. copyrighted) content; leave it None when the narration is already clean.

lead_gap_s prepends a beat of silence — breathing room before a register change (e.g. a book-read entering after a conversation), so it doesn’t feel glued to the previous speaker.

voice and voice_settings are per-beat overrides so one timeline can carry contrasting roles — e.g. a lively presenter and a graver book narrator (different voice, and/or a different delivery preset’s voice_settings). Both fall back to the render’s production-level defaults when None.

class braidio.PlannedBeat(kind: str, content: str, from_index: int, note: str = '', turns: tuple | None = None)[source]

A beat resolved for a profile — what the renderer actually plays.

kind is "narration" (synthesize content) or "clip" (resolve content as a segment reference and cut audio). from_index points at the source beat; note records any substitution/drop reasoning.

class braidio.Profile(value)[source]

Which projection of the production we render.

class braidio.RenderPlan(profile: 'Profile', beats: 'list[PlannedBeat]' = <factory>, dropped: 'list[str]' = <factory>, substituted: 'list[str]' = <factory>)[source]
class braidio.ResolvedSegment(asset_path: Path, start_s: float, end_s: float, score: float = 1.0, matched_text: str = '')[source]

A cuttable span of a source asset — what a SegmentSource returns.

class braidio.RightsPolicy(forbidden_texts: ~typing.Callable[[~braidio.script.Script], ~typing.Iterable[str]] = <function RightsPolicy.<lambda>>, publishable_clip_rights: frozenset[str] = frozenset({'public-domain'}))[source]

Injected rights configuration for the published profile.

forbidden_texts yields the strings that must not appear verbatim in published narration (e.g. copyrighted lyric lines). publishable_clip_rights is the set of segment rights values allowed in the published cut.

class braidio.Script(title: str, id_slug: str, beats: list[Narration | SegmentBeat | Dialogue] = <factory>)[source]

An ordered production script.

class braidio.Segment(start_s: float, end_s: float, score: float, line_start: int, line_end: int, matched_text: str)[source]

A resolved window for a reference, with the matched line span.

class braidio.SegmentBeat(reference: str, label: str = '', rights: str = 'owned-local', published_substitute: str | None = None, placement: str = 'before')[source]

A span of source media to weave in, addressed by an opaque reference.

The renderer resolves reference[start,end) via a SegmentSource and cuts it. rights (e.g. owned-local / copyrighted / public-domain) drives the render profile; published_substitute is a transformative narration the published profile swaps in when the segment’s audio can’t be used.

placement is how the clip sits against the talk (the weaving grammar): "before" (default) and "after" play the clip clean in its own slot — the set-up→clip and clip→payoff patterns, distinguished by where you order the beat relative to the talk. "under" plays the clip concurrently beneath the following talk beat, ducked by duck_db — the “host talks over the clip” technique (place the clip immediately before the talk it should sit under).

class braidio.SegmentSource(*args, **kwargs)[source]

Resolve an opaque reference to a ResolvedSegment (or None).

class braidio.TimedLine(index: int, start_s: float, end_s: float | None, text: str)[source]

A source line with a [start_s, end_s) window (end may be None = tail).

class braidio.TimedLineSegmentSource(*, lines: list[TimedLine], asset_path: str | Path, song_end_s: float | None = None, min_score: float = 0.5)[source]

A SegmentSource over time-aligned lines + one source asset.

Binds the generic find_segment() matcher to a concrete asset so the weave engine can resolve(reference) -> ResolvedSegment.

class braidio.TimelineItem(kind: str, path: str, placement: str = 'sequential', duck_db: float = 0.0)[source]

One part on the weave timeline.

placement "sequential" (default — narration, and clean before / after clips) lays the part in its own slot. "under" overlays the part beneath the following sequential part (it does not consume its own slot), attenuated by duck_db — a ducked underlay (a clip talked over, or later a music bed).

class braidio.Voice(id: str, name: str, gender: str, accent: str = '', note: str = '')[source]

A pooled narration voice.

class braidio.WeaveConfig(voices: tuple[str, ...]=('JBFqnCBsd6RMkjVDRZzb', ), pool_label: str = 'single', voice_seed: int = 7, avoid_immediate_repeat: bool = True, model_id: str = 'eleven_multilingual_v2', voice_settings: Mapping[str, ~typing.Any]=<factory>, segmentation_unit: str = 'sentence', min_turn: int = 2, max_turn: int = 4, speed_base: float = 1.0, speed_jitter: float = 0.04, crossfade_s: float = 0.12, gap_turn_s: float = 0.0, overlap_turn_s: float = 0.0, clip_pre_roll_s: float = 0.4, clip_post_roll_s: float = 0.3, clip_fade_in_s: float = 0.5, clip_fade_out_s: float = 0.8, clip_edge_overlap_s: float = 0.5, duck_db: float = -15.0, target_lufs: float = -16.0, true_peak_dbtp: float = -1.0, sample_rate: int = 44100)[source]

All editing choices for a narration+segment weave. Frozen + serializable.

to_dict() dict[str, Any][source]

Stable serialization for a render-config provenance node (#26).

with_(**changes: Any) WeaveConfig[source]

Return a copy with fields overridden (e.g. cfg.with_(min_turn=1)).

class braidio.WeaveKind(value)[source]

A braidio production kind. COMMENTARY_WEAVE = narration woven with extracted media segments (audio now, video later).

braidio.assign_voices(n: int, pool: list[Voice], *, seed: int = 0, avoid_repeats: bool = True) list[Voice][source]

Assign a voice to each of n turns — random, seeded, no immediate repeats (so it isn’t a rigid round-robin).

braidio.bed_for_intensity(asset_path: str, intensity: str, **overrides) MusicBed | None[source]

Build a MusicBed at the gain for a Format music_bed intensity.

Returns None for "none" (or an unknown intensity), so callers can do bed = bed_for_intensity(asset, fmt.music_bed) and skip when falsy.

braidio.compose_narration(segments: list[str], config: WeaveConfig, *, out_path: str | Path, api_key: str | None = None, work_dir: str | Path = 'data/tts/compose') list[tuple[Voice, str]][source]

Render segments under configout_path.

Single-voice and multi-voice go through the same turn-based path (a single voice is just a one-voice pool). Returns the (voice, turn_text) assignment for reporting / provenance.

api_key is an optional per-request ElevenLabs key threaded to braidio.multivoice.render_multivoice() (and thence every synthesized turn); None (default) keeps the $ELEVENLABS_API_KEY fallback.

braidio.content_violations(plan: RenderPlan, forbidden: Iterable[str], *, min_words: int = 5) list[str][source]

Rights violations in a published plan (empty list = clean).

Fails if any planned beat plays non-publishable segment audio, or any narration beat contains forbidden verbatim text.

braidio.cut_quote(audio_path: str | Path, lines: list[TimedLine], quote: str, out_path: str | Path, *, pad_pre_s: float = 0.15, pad_post_s: float = 0.35, fade_s: float = 0.04, min_score: float = 0.5, song_end_s: float | None = None) Segment[source]

Resolve quote → segment and cut it from audio_path (pad + fades).

Convenience combining find_segment() + an ffmpeg cut. Returns the resolved Segment (raises LookupError if unmatched). New code should prefer a SegmentSource + braidio.weave.extract_padded().

braidio.extract_padded(asset_path: str | Path, start_s: float, end_s: float, out_path: str | Path, *, pre_roll_s: float = 0.4, post_roll_s: float = 0.3, fade_in_s: float = 0.5, fade_out_s: float = 0.8) Path[source]

Extract [start_s-pre_roll, end_s+post_roll] with in/out fades.

The target words sit in the middle; the padded, faded head/tail are the parts that overlap (tuck under) neighbouring narration in the weave.

braidio.find_segment(lines: list[TimedLine], quote: str, *, max_span: int = 12, min_score: float = 0.5, song_end_s: float | None = None) Segment | None[source]

Best contiguous run of timed lines matching quote, or None.

Scores every run lines[i..j] (up to max_span lines) by token F1 against the reference’s tokens and returns the highest-scoring run clearing min_score. Handles single-line, sub-line, and multi-line references.

braidio.find_verbatim_text(text: str, forbidden: Iterable[str], *, min_words: int = 5) list[str][source]

Forbidden lines that appear (near-)verbatim in text.

A line counts as leaked if text shares a run of min_words consecutive words with it (case-insensitive, word-level). Single words and short common phrases don’t trip it — only substantial verbatim quoting.

braidio.group_turns(segments: list[str], *, min_turn: int = 1, max_turn: int = 1, seed: int = 0) list[str][source]

Group consecutive segments into turns of min_turn..max_turn segments.

A turn is what one voice speaks before the next takes over. Bigger turns = each speaker talks longer (fewer switches). Each turn’s segments are joined into one utterance so prosody is continuous within a speaker. Turn sizes are seeded-random within the range.

braidio.layout_starts(kinds: list[str], durs: list[float], *, clip_edge_overlap_s: float, narration_crossfade_s: float) list[float][source]

Start offset (s) of each part (all sequential). Thin wrapper over layout_placed() — kept for callers that don’t use placement.

braidio.load_timing(path: str | Path) list[TimedLine][source]

Load a {lines: [{index,start_s,end_s,text}]} JSON into `TimedLine`s.

braidio.narrate(text: str, out_path: str | Path, *, api_key: str | None = None, voice_id: str | None = None, model_id: str = 'eleven_multilingual_v2', voice_settings: dict | None = None, output_format: str = 'mp3_44100_128', refresh: bool = False) Path[source]

Synthesize text to out_path (mp3). Returns the path.

Caching is handled by mixing.text_to_speech (keyed on text+voice+model); pass refresh=True to regenerate.

api_key is an optional per-request ElevenLabs key: when given it wins over the environment; when None (default) resolution falls back to $ELEVENLABS_API_KEY (unchanged behavior). This is what lets a caller thread a per-user BYO key without touching the process environment.

braidio.narration_segments(script: Script) list[str][source]

All narration (default text) of a script, as sentence-level segments.

braidio.plan_production(script: Script, profile: Profile, *, publishable_clip_rights: frozenset[str] = frozenset({'public-domain'})) RenderPlan[source]

Filter script into the beats renderable under profile.

braidio.render_format(fmt: Format, script, *, source, out_path: str | Path | None = None, profile: Profile = Profile.PERSONAL, bed_asset: str | None = None, **overrides) Path[source]

Render script under fmt’s defaults; overrides win over them.

Wires the format’s cast / narration_voice / narration_delivery / weave into braidio.render.render_production(). Any beat may still override voice/settings per-beat (e.g. a graver book-narrator inside an otherwise lively presenter piece — pass V2_NARRATOR.voice_settings on that Narration beat).

bed_asset (a path to an app-supplied instrumental) adds a music bed at the gain implied by fmt.music_bed (skipped when the format’s intensity is "none"); pass music_bed=MusicBed(...) in overrides for full control.

braidio.render_multivoice(segments: list[str], pool: list[Voice], *, out_path: str | Path, api_key: str | None = None, work_dir: str | Path = 'data/tts/multivoice', seed: int = 7, min_turn: int = 2, max_turn: int = 4, avoid_immediate_repeat: bool = True, model_id: str = 'eleven_multilingual_v2', base_settings: dict | None = None, speed_base: float = 1.0, speed_jitter: float = 0.04, crossfade_s: float = 0.1, gap_s: float = 0.0, target_lufs: float = -16.0) list[tuple[Voice, str]][source]

Render segments cycling poolout_path.

Segments are first grouped into turns of min_turn..max_turn segments (bigger = each voice talks longer). One voice per turn, no immediate repeat, with a jittered speed even within a speaker. gap_s inserts silence between turns (0 = none). Returns [(voice, turn_text), …] for reporting.

api_key is an optional per-request ElevenLabs key threaded to every braidio.tts.narrate() call; None (default) keeps the $ELEVENLABS_API_KEY fallback.

NOTE: overlapping/interrupting speakers and clip ducking are separate, upcoming parameters (tracked as issues) — this renders turns sequentially.

braidio.render_production(script: Script, *, source: SegmentSource, api_key: str | None = None, config: WeaveConfig | None = None, profile: Profile = Profile.PERSONAL, rights: RightsPolicy | None = None, delivery: Delivery = Delivery(name='v2-tuned', model_id='eleven_multilingual_v2', voice_settings={'stability': 0.35, 'similarity_boost': 0.75, 'style': 0.35, 'use_speaker_boost': True, 'speed': 0.98}, supports_audio_tags=False, note='★ recommended: lower stability + raised style; pairs with annotated text.'), cast: ConversationCast = ConversationCast(roles={'A': 'cgSgspJ2msm6clMCkdW9', 'B': 'iP95p4xoKVk53GoZ742B'}, model_id='eleven_v3', settings={'stability': 0.45}), out_path: str | Path | None = None, voice_id: str | None = None, crossfade_s: float = 0.12, normalize: bool = True, music_bed=None, tts_dir: str | Path = 'data/tts', clips_dir: str | Path = 'data/clips', episodes_dir: str | Path = 'data/episodes') Path[source]

Render script under profile → a single audio file. Returns the path.

Segment beats are resolved through source (a SegmentSource). When config has clip_edge_overlap_s > 0, a clip is placement="under", or a music_bed is given, the parts are woven on a timeline; otherwise they are concatenated. rights (if given) sets which segment rights are publishable. music_bed lays an instrumental underscore under the whole production (see braidio.music.MusicBed).

api_key is an optional per-request ElevenLabs key threaded to every synthesized beat — both narration (braidio.tts.narrate()) and dialogue (braidio.conversation.render_dialogue()). When None (default) each synthesizer falls back to $ELEVENLABS_API_KEY (unchanged behavior); an explicit key lets a caller (e.g. a per-user BYO-key request) override the environment without mutating it. Segment beats never call ElevenLabs, so the key does not touch them.

braidio.resolve_voice_id(voice_id: str | None = None) str[source]

Voice id from arg → VOICE_ENV_VAR env → default.

braidio.split_segments(text: str) list[str][source]

Split narration into sentence-level segments (markup removed).

Splits on sentence-final .?! (not the used for in-thought pacing), so connected clauses stay with one speaker.

braidio.weave_timeline(items: list[TimelineItem], out_path: str | Path, *, clip_edge_overlap_s: float = 0.5, narration_crossfade_s: float = 0.12, target_lufs: float = -16.0, true_peak: float = -1.0, sample_rate: int = 44100, bed=None) Path[source]

Place items on a timeline and mix. Clips overlap neighbours by clip_edge_overlap_s (their faded edges tuck under narration); narration parts butt-join with a small crossfade. Returns out_path.

bed (a MusicBed) lays an instrumental underscore under the whole production: it’s rendered to cover the timeline, attenuated, and mixed in posted by bed.lead_in_s. Falls back to a plain concat feel when clip_edge_overlap_s == 0 and there’s nothing to overlay.