Metadata-Version: 2.5
Name: braidio
Version: 0.0.40
Summary: Weave narration with extracted media segments into audiovisual productions
Project-URL: Homepage, https://github.com/thorwhalen/braidio
Project-URL: Repository, https://github.com/thorwhalen/braidio
Project-URL: Documentation, https://thorwhalen.github.io/braidio
Author: Thor Whalen
License-Expression: MIT
License-File: LICENSE
Keywords: audio,elevenlabs,ffmpeg,narration,podcast,tts
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Sound/Audio
Classifier: Topic :: Multimedia :: Sound/Audio :: Editors
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: elevenlabs
Requires-Dist: mixing>=0.0.28
Provides-Extra: dev
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Provides-Extra: graph
Requires-Dist: lacing; extra == 'graph'
Provides-Extra: mcp
Requires-Dist: dol>=0.3.57; extra == 'mcp'
Requires-Dist: falaw; extra == 'mcp'
Requires-Dist: fastmcp; extra == 'mcp'
Requires-Dist: lacing; extra == 'mcp'
Requires-Dist: nw>=0.0.51; extra == 'mcp'
Requires-Dist: py2mcp>=0.1.9; extra == 'mcp'
Requires-Dist: pypdf; extra == 'mcp'
Requires-Dist: yb[download]>=0.1.7; extra == 'mcp'
Provides-Extra: nw-app
Requires-Dist: falaw; extra == 'nw-app'
Requires-Dist: lacing; extra == 'nw-app'
Requires-Dist: nw>=0.0.51; extra == 'nw-app'
Provides-Extra: video
Requires-Dist: burns>=0.0.9; extra == 'video'
Requires-Dist: pillow; extra == 'video'
Description-Content-Type: text/markdown

# braidio

**Weave *commentary* — solo, duo, panel, debate, interview, documentary — with
source clips into a production.**

`braidio` braids the strands of a piece that talks *about* something (a song, a
book, a film, an event) into one rendered production. The **talk is the spine** —
a single presenter, two hosts, a panel, a narrator — and **source clips and
narration bridges are the "illustration" layers** woven onto it. Audio today,
with visual support to follow (it braids A/V — *braid-io*, and it sounds like
*radio*).

Two things braidio gives you:

1. **Ready-made format templates** under the standard, industry names people
   already recognize — *Deep Dive*, *Song Exploder-style*, *Panel*, *Debate*,
   *Documentary VO* — each a high-quality bundle of defaults you can render in one
   call.
2. **Full parametrization** underneath, so any style is expressible: a `Script`
   of beats, cast via a `ConversationCast`, tuned by `WeaveConfig` + `Delivery`,
   with per-beat voice/delivery overrides.

> `braidio` was extracted from the
> [Hamilton lyrics-podcast](https://github.com/thorwhalen/Hamilton) (its first
> application) into a reusable engine. It is now live infrastructure: released
> on PyPI, served as a multi-tool MCP connector, and imported in production by
> [`reelee`](https://github.com/thorwhalen/reelee). Treat a signature change
> here as having downstream blast radius.

## The model

A production is a **`Script`** — an ordered list of **beats**:

| Beat | What it is |
|---|---|
| `Narration(text, voice?, voice_settings?, …)` | one voice reading — a narrator **or** a solo presenter. `voice` / `voice_settings` override per beat, so one timeline can carry a lively **presenter** *and* a graver **book-narrator**. |
| `Dialogue(turns=[(role, text)])` | a multi-speaker exchange, synthesized in **one pass** (so it sounds like people *talking to each other*, not alternating monologues). A `ConversationCast` maps roles → voices. |
| `SegmentBeat(reference)` | a span of source media to cut and weave in (resolved by a pluggable `SegmentSource` — quote → `[start, end]`). `spotlight=True` drops the music bed out under it. |
| `SceneBreak(label?)` | a structural boundary — "a new section starts here". Renders as a short musical **sting** (your asset) or a beat of silence; costs nothing. |

`WeaveConfig` holds every editing knob (casting, turns, pacing, clip pre/post-roll,
duck, crossfades, loudness). `Delivery` presets bundle the TTS model + voice
settings (e.g. `V2_PRESENTER` lively vs `V2_NARRATOR` grave). The renderer
loudness-normalizes every part, tucks clips under the speech (speech stays
dominant), and masters the result.

## Ready-made formats

Pick a standard format and render it — the template supplies the cast, voices,
delivery and mix defaults:

```python
from braidio import DEEP_DIVE, render_format

render_format(DEEP_DIVE, script, source=my_source, out_path="episode.mp3")
```

| Preset id | Standard name | Shape |
|---|---|---|
| `solo_explainer` | Solo-Presenter Explainer (video/audio essay, close reading) | one presenter + exhibits |
| `deep_dive` | Two-Host Conversation (*"Deep Dive"*, Switched on Pop, NotebookLM) | two hosts; one teaches, one probes |
| `interview` | Interview (host + guest) | Q→A, guest is the center |
| `interview_host_removed` | *Song Exploder*-style (host removed) | guest monologue; each claim illustrated by its isolated stem; full artifact at the tail |
| `panel` | Panel / Roundtable (Pop Culture Happy Hour) | moderator routes distinct voices |
| `debate` | Debate (Oxford-style) | proposition / opposition / moderator, phased |
| `documentary_vo` | Documentary Voice-Over (*"Voice of God"*, This American Life) | narrator on top; interviews + clips + actuality beneath |

**Narration bridges** and **source clips** are optional *illustration layers*
usable with any format. The taxonomy, weaving grammar, exemplar recipes and the
full template specs are in
[`misc/docs/research/commentary-formats-and-styles.md`](misc/docs/research/commentary-formats-and-styles.md).

*Render support note:* the cast, per-role voices, narration deliveries, loudness
master, **per-clip placement** — `SegmentBeat(placement="before" | "under" |
"after")`, where `under` lays the clip concurrently beneath the talk, ducked —
a **music bed** (`MusicBed`, an app-supplied instrumental laid under the whole
production) and the **structural music** are all applied. Structure is a
`SceneBreak` beat in the Script — "a new section starts here" — which renders as
a short **sting** when you pass `render_format(..., sting_asset=…)` (or a beat of
silence when you don't), and `SegmentBeat(spotlight=True)` for
**fade-to-spotlight**: the bed drops out before that exhibit and resumes after
it. Each format's `structure` (`MusicStructure`) sets the defaults — whether a
break stings, whether every clip is spotlit — and a beat overrides them. As with
the bed, braidio ships no music: the sting is your asset. Both render paths carry
this: the graph path takes the same `structure=` / `bed=` (or a `fmt=`, whose
declared structure it uses) on `weave_project`, records the decision as a
`production-structure/v1` node, and re-renders only the episode when you swap the
sting. Calling `weave_project` again on the same project **re-ingests**: beats are
matched by position, unchanged ones reuse their renders, and a changed config,
structure, rights profile or dialogue cast is rewritten under its existing node so
everything derived from it reads stale through provenance — the only correct way
to change a profile after the first weave. Every beat type takes the graph path,
`Dialogue` included: an exchange is one `dialogue-beat/v1` rendered in one
Text-to-Dialogue pass, and the cast (`cast=`, else the format's, else the
default) is one `dialogue-cast/v1` node every dialogue render derives from — so
recasting re-renders the exchanges and nothing else. Design history in
[braidio#25](https://github.com/thorwhalen/braidio/issues/25),
[braidio#39](https://github.com/thorwhalen/braidio/issues/39),
[braidio#46](https://github.com/thorwhalen/braidio/issues/46) and
[braidio#51](https://github.com/thorwhalen/braidio/issues/51).

## Parametrize anything

The templates are just good defaults over the primitives — compose directly for
full control:

```python
from braidio import Script, Dialogue, Narration, SegmentBeat, WeaveConfig
from braidio.conversation import ConversationCast, JESSICA, CHRIS
from braidio import V2_NARRATOR, render_production

script = Script(
    title="…",
    id_slug="01",
    beats=[
        Dialogue(
            (("A", "First thing that gets me…"), ("B", "right — but isn't that…"))
        ),
        SegmentBeat("the lyric being discussed", label="hook"),
        Narration(
            "Here's how the book tells it —", voice_settings=V2_NARRATOR.voice_settings
        ),  # graver, per-beat
    ],
)
render_production(
    script,
    source=my_source,
    cast=ConversationCast(roles={"A": JESSICA, "B": CHRIS}),
    config=WeaveConfig(),
    out_path="out.mp3",
)
```

### Cost tracking

ElevenLabs TTS is braidio's only spend (everything else is local ffmpeg). `braidio.estimate_cost(script)` previews a production's cost before you pay for synthesis, and real renders attribute a per-character cost onto their artifacts. Dollars are a **rate estimate** — set your ElevenLabs plan's rate for exact figures:

| Env var | Meaning |
|---|---|
| `BRAIDIO_TTS_USD_PER_1K_CHARS` | USD per 1000 characters. Unset → a conservative default; `none` → mark spend *unpriced* (character counts still reported, dollars `None`). |
| `BRAIDIO_TTS_VOICE` | Default ElevenLabs voice id for narration. |
| `ELEVENLABS_API_KEY` | The key synthesis bills when a render is given no `api_key=`. |

Every render entry point — `narrate`, `render_dialogue`, `render_production`, `render_format` and the graph driver `weave_project` — takes an explicit `api_key=` for a caller's own ElevenLabs key; `None` (the default) resolves from the environment. On the graph path the key reaches the two synthesis transforms through nw's `execute(secrets=)` seam and is never persisted: not in a node, provenance, a cache key or a usage ledger (thorwhalen/braidio#58). The MCP server reads it from the `X-Elevenlabs-Key` request header, never from a tool argument.

## What it deliberately does *not* do

braidio is a thin orchestration layer. It **delegates**:

| Concern | Owner |
|---|---|
| Content acquisition (Genius, audiobooks, news…) | the consuming app, via `SegmentSource` adapters |
| Linked-artifact graph / content-addressed media | [`lacing`](https://github.com/thorwhalen/lacing) |
| Project workflow, provenance, plan/execute, partial re-render | [`nw`](https://github.com/thorwhalen/nw) |
| Video render + visual support | [`reelee`](https://github.com/thorwhalen/reelee) — except the stills case below |
| Pan/zoom motion over a still | [`burns`](https://github.com/thorwhalen/burns) |
| Raw audio DSP + TTS (crop, concat, duck, loudnorm, synth) | [`mixing`](https://github.com/thorwhalen/mixing) / `falaw` |

braidio orchestrates these; it never reimplements the DSP or the graph.

## Video: a commentary episode you can watch

`braidio[video]` turns a rendered episode into a **Ken Burns film over still
images** — the one video case that stays here rather than going to `reelee`,
because reelee imports braidio and the cuts have to land on braidio's own beat
timeline. `burns` owns the motion; braidio owns only where the picture changes.

```python
import braidio
from braidio.video import plan_spans, assign_stills, render_video

path, timeline = braidio.render_format(
    fmt, script, source=src, out_path="ep.mp3", return_timeline=True
)
panels = assign_stills(plan_spans(timeline), ["a.jpg", "b.jpg", "c.jpg"])
render_video(panels, audio_path="ep.mp3", out_path="ep.mp4")
```

`plan_spans` cuts on beat boundaries — splitting a long passage so no photograph
stalls on screen, merging a short beat so none flickers. Each `Span` carries the
beat's `label` and `kind`, because *which* picture belongs over a sentence needs
to know what the sentence says; `assign_stills` is the mechanical default for when
the images are interchangeable texture. Motion is content-aware by default, so a
slow push stays on the subject. braidio fetches **no images** — supply them, as
with `SegmentSource`.

Subtitles need no ASR: you authored the words and the timeline says when they
play.

```python
Path("ep.srt").write_text(braidio.captions_for(script, timeline, max_chars=42))
```

`braidio.captions` is part of the core (pure, no extra dependencies);
`braidio.video`'s dependencies are imported inside the functions that use them, so
`import braidio.video` works on a bare install and the planners stay usable —
`braidio.HAS_VIDEO` reports whether the render path is available.

## Agent skills

braidio ships skills that install with it, so an agent host can use them without
cloning the repo:

| Skill | For |
|---|---|
| `braidio` | authoring and rendering a commentary production (audio) |
| `braidio-commentary-video` | the video pipeline above — panels, stills, captions, credits, rights |

```bash
ln -s "$(python -c 'import braidio; print(braidio.skills_dir())')/braidio" ~/.claude/skills/braidio
```

## Ecosystem

`braidio` is a **production kind** on top of `nw` — a reusable definition of
"commentary that weaves talk with extracted media" — the way a music video is
another production kind. It sits on `lacing` (graph) + `nw` (workflow /
provenance) + `mixing`/`falaw` (audio/TTS), and will use `reelee` for video. The
optional nw-app layer (`braidio.HAS_GRAPH` / `HAS_NW`) records render choices as
provenance so a change re-renders only the affected parts.

## Install

```bash
pip install braidio
```

ElevenLabs credentials are needed for TTS; `ffmpeg` must be on PATH for
everything else.

## License

MIT
