braidio 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ """Conversational register: render an exchange as people *talking to each other*.
2
+
3
+ The second delivery register beside narration (braidio#1). A
4
+ :class:`ConversationCast` maps role labels (``"A"``/``"B"``) to contrasting
5
+ **conversational** ElevenLabs voices. :func:`render_dialogue` synthesizes the
6
+ whole exchange in **one pass** via :func:`braidio.tts.text_to_dialogue` (eleven_v3)
7
+ so prosody is conditioned across turns — the key to not sounding narrated.
8
+ :func:`render_turns_sequential` is the per-line baseline (each turn synthesized
9
+ alone, then concatenated) used for A/B comparison.
10
+
11
+ Casting note: use conversational-labelled voices, not narrator voices; loosen
12
+ settings so v3 audio tags fire. The scripted exchange itself must carry the
13
+ disfluency (backchannels, interruptions, fragments) — the model won't invent it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from braidio.tts import narrate, text_to_dialogue
25
+
26
+ # Curated conversational voices (casual, not narrator).
27
+ JESSICA = "cgSgspJ2msm6clMCkdW9" # playful, bright, warm (F)
28
+ WILL = "bIHbv24MWmeRgasZH58o" # relaxed optimist (M)
29
+ CHRIS = "iP95p4xoKVk53GoZ742B" # charming, down-to-earth (M)
30
+ LAURA = "FGY2WhTYpPnrIDTdsKH5" # enthusiast, quirky attitude (F)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ConversationCast:
35
+ """Role → voice mapping + model/settings for a conversational exchange.
36
+
37
+ Default cast: **Jessica** (playful/bright, F) + **Chris** (charming/
38
+ down-to-earth, M) — snappier than the "relaxed" voices. ``stability=0.45``
39
+ is the research sweet spot for a lively but coherent read (never 1.0/Robust,
40
+ which mutes v3 tags; ~0.1 is too unstable). See braidio#1 + the pacing doc.
41
+ """
42
+
43
+ roles: dict[str, str] = field(default_factory=lambda: {"A": JESSICA, "B": CHRIS})
44
+ model_id: str = "eleven_v3"
45
+ settings: dict | None = field(default_factory=lambda: {"stability": 0.45})
46
+
47
+
48
+ DEFAULT_CAST = ConversationCast()
49
+
50
+
51
+ def _require_ffmpeg() -> None:
52
+ if shutil.which("ffmpeg") is None:
53
+ raise RuntimeError("ffmpeg not found on PATH.")
54
+
55
+
56
+ def render_dialogue(
57
+ turns: list[tuple[str, str]],
58
+ cast: ConversationCast = DEFAULT_CAST,
59
+ *,
60
+ api_key: str | None = None,
61
+ out_path: str | Path,
62
+ output_format: str = "mp3_44100_128",
63
+ seed: int | None = None,
64
+ cache=True,
65
+ refresh: bool = False,
66
+ tighten_gaps_s: float = 0.6,
67
+ ) -> Path:
68
+ """One-pass render of ``turns`` (``[(role, text), …]``) via Text-to-Dialogue.
69
+
70
+ Cached by default (see :func:`braidio.tts.text_to_dialogue`): an unchanged
71
+ exchange renders instantly on re-run. ``refresh=True`` re-rolls the take.
72
+
73
+ ``api_key`` is an optional per-request ElevenLabs key threaded to
74
+ :func:`braidio.tts.text_to_dialogue`; ``None`` (default) keeps the
75
+ ``$ELEVENLABS_API_KEY`` fallback.
76
+
77
+ ``tighten_gaps_s`` (>0) applies the "R-tight" pass: ffmpeg trims only the
78
+ over-long dead gaps (silences ≥ this many seconds) that make v3 dialogue
79
+ feel draggy, while leaving natural short pauses. Set 0 to keep the raw take.
80
+ """
81
+ vturns = [(cast.roles[role], text) for role, text in turns]
82
+ audio = text_to_dialogue(
83
+ vturns,
84
+ model_id=cast.model_id,
85
+ settings=cast.settings,
86
+ seed=seed,
87
+ output_format=output_format,
88
+ api_key=api_key,
89
+ cache=cache,
90
+ refresh=refresh,
91
+ )
92
+ out = Path(out_path)
93
+ out.parent.mkdir(parents=True, exist_ok=True)
94
+ out.write_bytes(audio)
95
+ if tighten_gaps_s and tighten_gaps_s > 0:
96
+ _require_ffmpeg()
97
+ tmp = out.parent / (out.stem + "-tight.mp3")
98
+ subprocess.run(
99
+ [
100
+ "ffmpeg",
101
+ "-y",
102
+ "-i",
103
+ str(out),
104
+ "-af",
105
+ f"silenceremove=stop_periods=-1:stop_duration={tighten_gaps_s}:stop_threshold=-38dB",
106
+ str(tmp),
107
+ ],
108
+ check=True,
109
+ capture_output=True,
110
+ )
111
+ os.replace(tmp, out)
112
+ return out
113
+
114
+
115
+ def _concat_with_gaps(
116
+ parts: list[Path], out: Path, *, gap_s: float, sample_rate: int = 44100
117
+ ) -> Path:
118
+ """Concatenate audio parts with ``gap_s`` seconds of silence between them."""
119
+ from mixing import concatenate_audio
120
+
121
+ _require_ffmpeg()
122
+ out.parent.mkdir(parents=True, exist_ok=True)
123
+ if gap_s <= 0:
124
+ concatenate_audio(*[str(p) for p in parts], output=str(out), crossfade=0.0)
125
+ return out
126
+ sil = out.parent / "_gap.mp3"
127
+ subprocess.run(
128
+ [
129
+ "ffmpeg",
130
+ "-y",
131
+ "-f",
132
+ "lavfi",
133
+ "-i",
134
+ f"anullsrc=r={sample_rate}:cl=stereo",
135
+ "-t",
136
+ f"{gap_s:.3f}",
137
+ str(sil),
138
+ ],
139
+ check=True,
140
+ capture_output=True,
141
+ )
142
+ interleaved: list[str] = []
143
+ for i, p in enumerate(parts):
144
+ if i:
145
+ interleaved.append(str(sil))
146
+ interleaved.append(str(p))
147
+ concatenate_audio(*interleaved, output=str(out), crossfade=0.0)
148
+ return out
149
+
150
+
151
+ def render_turns_sequential(
152
+ turns: list[tuple[str, str]],
153
+ cast: ConversationCast = DEFAULT_CAST,
154
+ *,
155
+ api_key: str | None = None,
156
+ out_path: str | Path,
157
+ work_dir: str | Path = "data/tts/conversation",
158
+ voice_settings: dict | None = None,
159
+ gap_s: float = 0.25,
160
+ ) -> Path:
161
+ """Per-line baseline: synthesize each turn alone (its role's voice) and
162
+ concatenate with ``gap_s`` gaps. Prosody is NOT shared across turns — this is
163
+ what tends to sound like alternating monologues (the thing to beat).
164
+
165
+ ``api_key`` is threaded to :func:`braidio.tts.narrate`; ``None`` (default)
166
+ keeps the ``$ELEVENLABS_API_KEY`` fallback."""
167
+ work = Path(work_dir)
168
+ work.mkdir(parents=True, exist_ok=True)
169
+ parts: list[Path] = []
170
+ for i, (role, text) in enumerate(turns):
171
+ raw = narrate(
172
+ text,
173
+ work / f"turn{i:02d}-{role}.mp3",
174
+ api_key=api_key,
175
+ voice_id=cast.roles[role],
176
+ model_id=cast.model_id,
177
+ voice_settings=voice_settings,
178
+ )
179
+ parts.append(raw)
180
+ out = Path(out_path)
181
+ return _concat_with_gaps(parts, out, gap_s=gap_s)
braidio/delivery.py ADDED
@@ -0,0 +1,132 @@
1
+ """Narration *delivery* presets — model + voice settings (issue #10, expressiveness).
2
+
3
+ A :class:`Delivery` bundles the ElevenLabs ``model_id`` and ``voice_settings``
4
+ that shape how expressive vs flat a narration reads. The renderer takes one so
5
+ we can A/B the same script under different deliveries and pick what has the most
6
+ relief without editing the script.
7
+
8
+ Presets here are a starting point tuned from ``docs/research/expressive-tts-
9
+ narration.md``; refine as we learn what sounds best. The key monotony levers:
10
+ lower ``stability`` and raise ``style`` add variation (at some cost in
11
+ consistency); ``eleven_v3`` adds inline audio tags for real expressive control.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Delivery:
21
+ """A named narration delivery: which model + voice settings to synthesize with."""
22
+
23
+ name: str
24
+ model_id: str
25
+ voice_settings: dict = field(default_factory=dict)
26
+ supports_audio_tags: bool = False # True for eleven_v3 (bracketed [tags])
27
+ note: str = ""
28
+
29
+
30
+ # --- presets -----------------------------------------------------------------
31
+
32
+ BASELINE = Delivery(
33
+ name="baseline",
34
+ model_id="eleven_multilingual_v2",
35
+ voice_settings={
36
+ "stability": 0.5,
37
+ "similarity_boost": 0.75,
38
+ "style": 0.0,
39
+ "use_speaker_boost": True,
40
+ "speed": 0.97,
41
+ },
42
+ note="Current default — even, safe, tends flat.",
43
+ )
44
+
45
+ # ★ Research-recommended default: v2, moderately loosened + annotated text.
46
+ V2_TUNED = Delivery(
47
+ name="v2-tuned",
48
+ model_id="eleven_multilingual_v2",
49
+ voice_settings={
50
+ "stability": 0.35,
51
+ "similarity_boost": 0.75,
52
+ "style": 0.35,
53
+ "use_speaker_boost": True,
54
+ "speed": 0.98,
55
+ },
56
+ note="★ recommended: lower stability + raised style; pairs with annotated text.",
57
+ )
58
+
59
+ V2_AGGRESSIVE = Delivery(
60
+ name="v2-aggressive",
61
+ model_id="eleven_multilingual_v2",
62
+ voice_settings={
63
+ "stability": 0.28,
64
+ "similarity_boost": 0.75,
65
+ "style": 0.45,
66
+ "use_speaker_boost": True,
67
+ "speed": 0.98,
68
+ },
69
+ note="More variation, more take-to-take variance; may over-emote.",
70
+ )
71
+
72
+ # --- role deliveries: a *presenter* (lively) vs a *narrator* (grave) ----------
73
+ # Two deliveries meant to sit together in one production for tasteful contrast:
74
+ # the host/presenter reads livelier and a touch quicker; the documentary/book
75
+ # narrator reads steadier, flatter-styled and a touch slower (gravitas). Assign
76
+ # per beat via ``Narration.voice_settings`` (see braidio.render).
77
+
78
+ V2_PRESENTER = Delivery(
79
+ name="v2-presenter",
80
+ model_id="eleven_multilingual_v2",
81
+ voice_settings={
82
+ "stability": 0.35,
83
+ "similarity_boost": 0.75,
84
+ "style": 0.35,
85
+ "use_speaker_boost": True,
86
+ "speed": 0.98,
87
+ },
88
+ note="Host/presenter commentary — lively (== v2-tuned), for the spine voice.",
89
+ )
90
+
91
+ V2_NARRATOR = Delivery(
92
+ name="v2-narrator",
93
+ model_id="eleven_multilingual_v2",
94
+ voice_settings={
95
+ "stability": 0.5,
96
+ "similarity_boost": 0.8,
97
+ "style": 0.12,
98
+ "use_speaker_boost": True,
99
+ "speed": 0.94,
100
+ },
101
+ note="Documentary/book-read narrator — steadier, flatter style, a touch "
102
+ "slower for gravitas. Modest contrast against v2-presenter.",
103
+ )
104
+
105
+ V3_NATURAL = Delivery(
106
+ name="v3-natural",
107
+ model_id="eleven_v3",
108
+ voice_settings={"stability": 0.5, "use_speaker_boost": True},
109
+ supports_audio_tags=True,
110
+ note="Eleven v3, clean text (no tags) — v3's baseline is more dynamic.",
111
+ )
112
+
113
+ V3_CREATIVE = Delivery(
114
+ name="v3-creative",
115
+ model_id="eleven_v3",
116
+ voice_settings={"stability": 0.3, "use_speaker_boost": True},
117
+ supports_audio_tags=True,
118
+ note="Eleven v3, low stability, driven by inline audio tags. Alpha; per-take variance.",
119
+ )
120
+
121
+ DELIVERIES: dict[str, Delivery] = {
122
+ d.name: d
123
+ for d in (
124
+ BASELINE,
125
+ V2_TUNED,
126
+ V2_AGGRESSIVE,
127
+ V2_PRESENTER,
128
+ V2_NARRATOR,
129
+ V3_NATURAL,
130
+ V3_CREATIVE,
131
+ )
132
+ }
braidio/formats.py ADDED
@@ -0,0 +1,332 @@
1
+ """Ready-made **format templates** — high-quality presets under standard names.
2
+
3
+ braidio parametrizes *any* commentary style (a :class:`~braidio.script.Script` of
4
+ ``Narration`` / ``Dialogue`` / ``SegmentBeat`` beats, cast via
5
+ :class:`~braidio.conversation.ConversationCast`, tuned by
6
+ :class:`~braidio.weave_config.WeaveConfig` and :class:`~braidio.delivery.Delivery`).
7
+ This module ships the *ready-made* end of that: a small set of named
8
+ :class:`Format` presets that bundle good defaults for the recurring, industry-named
9
+ ways people comment on an artifact — so ``render_format(DEEP_DIVE, script, …)``
10
+ just works, and advanced users still compose the primitives directly.
11
+
12
+ The taxonomy + recipes are in
13
+ ``misc/docs/research/commentary-formats-and-styles.md``. The organizing rule:
14
+ **the talk is the spine; narration bridges and source clips are optional
15
+ "illustration" layers** attached to it.
16
+
17
+ What a :class:`Format` actually drives at render time **today**: the dialogue
18
+ **cast** (role→voice), the **narration voice + delivery**, the **clip
19
+ weave/duck + loudness** (:class:`WeaveConfig`). Per-clip placement renders too —
20
+ set it on each ``SegmentBeat(placement=…)``; ``Format.clip_placement`` is the
21
+ recommended *default* for the format. A **music bed** renders when you pass a
22
+ ``bed_asset`` to :func:`render_format` — the format's ``music_bed`` *intensity*
23
+ picks the gain. Fields tagged *(authoring)* — ``roles``, ``scripting`` — are
24
+ conventions for whoever writes (or generates) the ``Script``; scene stings remain
25
+ **roadmap** on the render side (braidio#1). Preset ids mirror the standard names
26
+ so a UI can label them ("Deep Dive", "Song Exploder-style").
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Mapping
34
+
35
+ from braidio.conversation import CHRIS, JESSICA, LAURA, WILL, ConversationCast
36
+ from braidio.delivery import Delivery, V2_NARRATOR, V2_PRESENTER
37
+ from braidio.rights import Profile
38
+ from braidio.tts import DEFAULT_VOICE_ID
39
+ from braidio.weave_config import WeaveConfig
40
+
41
+ # --- extra role voices (from the curated premade pools, distinct timbres) -----
42
+ GEORGE = DEFAULT_VOICE_ID # warm, captivating storyteller (M) — narrator
43
+ MATILDA = "XrExE9yKIg1WjnnlVkGX" # professional (F) — neutral moderator
44
+ SARAH = "EXAVITQu4vr4xnSDxMaL" # mature, reassuring (F)
45
+ CHARLIE = "IKne3meq5aSn9XLyUdCD" # deep, energetic (M, Australian)
46
+ ERIC = "cjVigY5qzO86Huf0OWal" # smooth, trustworthy (M)
47
+ BILL = "pqHfZKP75CvOlQylNhV4" # wise, mature (M)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Format:
52
+ """A named commentary-format preset: a bundle of high-quality defaults.
53
+
54
+ Rendered fields drive :func:`render_format` → :func:`braidio.render.render_production`.
55
+ Authoring fields document how to write a ``Script`` for this format (and, for
56
+ ``clip_placement`` / ``music_bed``, flag render features still on the roadmap).
57
+ """
58
+
59
+ id: str # preset id (mirrors the standard name), e.g. "deep_dive"
60
+ name: str # standard / industry name for UI labels, e.g. 'Two-Host Conversation ("Deep Dive")'
61
+ summary: str
62
+ aka: tuple[str, ...] = () # alternate names / exemplar shows
63
+
64
+ # --- rendered defaults --------------------------------------------------
65
+ cast: ConversationCast | None = (
66
+ None # Dialogue roles→voices; None = no dialogue spine
67
+ )
68
+ narration_voice: str | None = None # default voice for Narration beats
69
+ narration_delivery: Delivery = V2_PRESENTER # delivery for Narration beats
70
+ weave: WeaveConfig = field(default_factory=WeaveConfig)
71
+
72
+ # --- authoring guidance (documented; render support varies) -------------
73
+ roles: Mapping[str, str] = field(
74
+ default_factory=dict
75
+ ) # role → semantic (narrator/host/guest/…)
76
+ clip_placement: str = "before" # recommended default SegmentBeat.placement (renders; before|under|after)
77
+ music_bed: str = "light" # continuous | light | sparse | none (authoring / roadmap)
78
+ scripting: str = "" # how to author a Script for this format (authoring)
79
+
80
+ def render(
81
+ self,
82
+ script,
83
+ *,
84
+ source,
85
+ out_path: str | Path | None = None,
86
+ profile: Profile = Profile.PERSONAL,
87
+ **overrides,
88
+ ) -> Path:
89
+ """Render ``script`` with this format's defaults (see :func:`render_format`)."""
90
+ return render_format(
91
+ self, script, source=source, out_path=out_path, profile=profile, **overrides
92
+ )
93
+
94
+
95
+ def render_format(
96
+ fmt: Format,
97
+ script,
98
+ *,
99
+ source,
100
+ out_path: str | Path | None = None,
101
+ profile: Profile = Profile.PERSONAL,
102
+ bed_asset: str | None = None,
103
+ **overrides,
104
+ ) -> Path:
105
+ """Render ``script`` under ``fmt``'s defaults; ``overrides`` win over them.
106
+
107
+ Wires the format's ``cast`` / ``narration_voice`` / ``narration_delivery`` /
108
+ ``weave`` into :func:`braidio.render.render_production`. Any beat may still
109
+ override voice/settings per-beat (e.g. a graver book-narrator inside an
110
+ otherwise lively presenter piece — pass ``V2_NARRATOR.voice_settings`` on that
111
+ ``Narration`` beat).
112
+
113
+ ``bed_asset`` (a path to an app-supplied instrumental) adds a music bed at the
114
+ gain implied by ``fmt.music_bed`` (skipped when the format's intensity is
115
+ ``"none"``); pass ``music_bed=MusicBed(...)`` in ``overrides`` for full control.
116
+ """
117
+ from braidio.music import bed_for_intensity
118
+ from braidio.render import render_production
119
+
120
+ kwargs = dict(
121
+ source=source,
122
+ config=fmt.weave,
123
+ profile=profile,
124
+ delivery=fmt.narration_delivery,
125
+ out_path=out_path,
126
+ )
127
+ if fmt.cast is not None:
128
+ kwargs["cast"] = fmt.cast
129
+ if fmt.narration_voice is not None:
130
+ kwargs["voice_id"] = fmt.narration_voice
131
+ if bed_asset is not None:
132
+ bed = bed_for_intensity(bed_asset, fmt.music_bed)
133
+ if bed is not None:
134
+ kwargs["music_bed"] = bed
135
+ kwargs.update(overrides)
136
+ return render_production(script, **kwargs)
137
+
138
+
139
+ # =============================================================================
140
+ # Presets — the ready-made, standard-named format templates.
141
+ # =============================================================================
142
+
143
+ SOLO_EXPLAINER = Format(
144
+ id="solo_explainer",
145
+ name="Solo-Presenter Explainer",
146
+ aka=("video essay", "audio-essay", "close reading", "Dissect", "audio guide"),
147
+ summary="One presenter advances a thesis over exhibits; narration is the spine.",
148
+ cast=None, # no dialogue — a single voice throughout
149
+ narration_voice=GEORGE,
150
+ narration_delivery=V2_PRESENTER,
151
+ weave=WeaveConfig(voices=(GEORGE,), pool_label="single", min_turn=1, max_turn=3),
152
+ roles={"presenter": "narrator=host=expert, collapsed into one authoritative voice"},
153
+ clip_placement="before", # set up every exhibit before it plays
154
+ music_bed="continuous",
155
+ scripting=(
156
+ "Intro (hook + thesis) → body as repeated (claim → clip → analysis) → "
157
+ "conclusion. Per-exhibit micro-shape: hook → describe → meaning → memorable "
158
+ "detail → prompt (~60–90s). Scripted, dense; every exhibit is set up first. "
159
+ "Serialize by emitting one Script per sub-topic with an end-of-episode hook."
160
+ ),
161
+ )
162
+
163
+ DEEP_DIVE = Format(
164
+ id="deep_dive",
165
+ name='Two-Host Conversation ("Deep Dive")',
166
+ aka=(
167
+ "co-hosted chat",
168
+ "two-host teaching dialogue",
169
+ "Switched on Pop",
170
+ "NotebookLM Deep Dive",
171
+ ),
172
+ summary="Two complementary hosts talk it through; one teaches, one probes.",
173
+ cast=ConversationCast(
174
+ roles={"host_a": JESSICA, "host_b": CHRIS}, settings={"stability": 0.45}
175
+ ),
176
+ narration_voice=GEORGE, # for optional bridges between segments
177
+ narration_delivery=V2_NARRATOR,
178
+ weave=WeaveConfig(),
179
+ roles={"host_a": "explainer / driver", "host_b": "prober / curious surrogate"},
180
+ clip_placement="after", # hosts cue, then play/react (the cue is the bridge)
181
+ music_bed="light",
182
+ scripting=(
183
+ "Cold banter → frame the artifact → segment-by-segment walkthrough where one "
184
+ "host teaches the other (roles may trade) → recap → sign-off. Prove each claim "
185
+ "with a clip or a described demonstration. For the DEBATE sub-preset, give the "
186
+ "two hosts opposing stances (raise disagreement) for tension without a third voice."
187
+ ),
188
+ )
189
+
190
+ INTERVIEW = Format(
191
+ id="interview",
192
+ name="Interview (Host + Guest)",
193
+ aka=("host + subject", "Q&A"),
194
+ summary="Host questions a guest to elicit their first-person account.",
195
+ cast=ConversationCast(
196
+ roles={"host": JESSICA, "guest": CHRIS}, settings={"stability": 0.45}
197
+ ),
198
+ narration_voice=GEORGE, # optional chapter bridges only
199
+ narration_delivery=V2_NARRATOR,
200
+ weave=WeaveConfig(),
201
+ roles={
202
+ "host": "curious interviewer",
203
+ "guest": "first-person authority (the center)",
204
+ },
205
+ clip_placement="before",
206
+ music_bed="light",
207
+ scripting=(
208
+ "Host question → guest answer (Q-then-A). Host or guest sets up each exhibit "
209
+ "before it plays. Narration bridges only between chapters. See SONG_EXPLODER "
210
+ "for the host-removed variant."
211
+ ),
212
+ )
213
+
214
+ # Song Exploder architecture — the sharpest "illustration" model.
215
+ SONG_EXPLODER = Format(
216
+ id="interview_host_removed",
217
+ name="Song Exploder-style (host-removed interview)",
218
+ aka=("Song Exploder", "deconstruction", "stems"),
219
+ summary="Guest narrates in first person; every claim illustrated by its isolated stem; full artifact at the tail.",
220
+ cast=None, # host removed → continuous guest monologue (Narration voiced by the guest)
221
+ narration_voice=CHRIS, # the maker, first person
222
+ narration_delivery=V2_PRESENTER,
223
+ weave=WeaveConfig(),
224
+ roles={"guest": "the maker, sole voice; host is the invisible editor/curator"},
225
+ clip_placement="before", # name the element, then play the isolated stem
226
+ music_bed="none", # the artifact's own segments ARE the score
227
+ scripting=(
228
+ "Interview the maker, strip the host's questions → guest narrates first-person "
229
+ "→ drop the exact isolated stem as each element is named → close with the "
230
+ "complete, un-narrated artifact (the payoff). ~15–20 min."
231
+ ),
232
+ )
233
+
234
+ PANEL = Format(
235
+ id="panel",
236
+ name="Panel / Roundtable",
237
+ aka=("roundtable", "Pop Culture Happy Hour"),
238
+ summary="A moderator routes a group of distinct voices around a shared topic.",
239
+ cast=ConversationCast(
240
+ roles={
241
+ "moderator": MATILDA,
242
+ "panelist_1": CHARLIE,
243
+ "panelist_2": SARAH,
244
+ "panelist_3": ERIC,
245
+ },
246
+ settings={"stability": 0.45},
247
+ ),
248
+ narration_voice=GEORGE,
249
+ narration_delivery=V2_NARRATOR,
250
+ weave=WeaveConfig(),
251
+ roles={
252
+ "moderator": "neutral routing voice (spine + traffic control)",
253
+ "panelist_1": "distinct viewpoint",
254
+ "panelist_2": "distinct viewpoint",
255
+ "panelist_3": "distinct viewpoint",
256
+ },
257
+ clip_placement="before",
258
+ music_bed="light", # segment stings mark each round (signposting is critical with many voices)
259
+ scripting=(
260
+ "Moderator frames topic → round-robin takes ('a trip around the table') → clip "
261
+ "drops as shared reference → moderator synthesizes → next topic → close on a "
262
+ "recurring ritual. Keep voices maximally distinct (accent/gender) for legibility."
263
+ ),
264
+ )
265
+
266
+ DEBATE = Format(
267
+ id="debate",
268
+ name="Debate (Oxford-style)",
269
+ aka=("two opposing takes + moderator",),
270
+ summary="Two advocates argue a stated motion; a neutral moderator enforces phases.",
271
+ cast=ConversationCast(
272
+ roles={"moderator": BILL, "proposition": JESSICA, "opposition": CHRIS},
273
+ settings={"stability": 0.45},
274
+ ),
275
+ narration_voice=BILL, # structural announcements, voiced by the moderator
276
+ narration_delivery=V2_NARRATOR,
277
+ weave=WeaveConfig(),
278
+ roles={
279
+ "moderator": "neutral; frames + routes, never argues",
280
+ "proposition": "advocate A",
281
+ "opposition": "advocate B",
282
+ },
283
+ clip_placement="before", # evidence entered by a side, then argued (clips may be re-used across sides)
284
+ music_bed="sparse", # phase stings (open / rebuttal / close) keep structure legible
285
+ scripting=(
286
+ "State the motion up front → phased turns: opening remarks → moderated "
287
+ "exchange/rebuttals → cross-examination → closing arguments. The same clip may "
288
+ "be entered and re-interpreted by both sides. Oxford variant: announce a "
289
+ "before/after opinion frame."
290
+ ),
291
+ )
292
+
293
+ DOCUMENTARY_VO = Format(
294
+ id="documentary_vo",
295
+ name='Documentary Voice-Over (expository "Voice of God")',
296
+ aka=("expository documentary", "This American Life", "99% Invisible"),
297
+ summary="An authoritative narrator drives; interviews, clips and actuality illustrate beneath.",
298
+ cast=ConversationCast(
299
+ roles={"guest": CHRIS, "expert": LAURA}, settings={"stability": 0.45}
300
+ ),
301
+ narration_voice=GEORGE, # omniscient narrator — the top, driest layer
302
+ narration_delivery=V2_NARRATOR,
303
+ weave=WeaveConfig(min_turn=1, max_turn=3),
304
+ roles={
305
+ "narrator": "omniscient, authoritative — the spine, top layer",
306
+ "guest": "first-person testimony",
307
+ "expert": "borrowed-credibility interpretation",
308
+ },
309
+ clip_placement="before", # narration states → clip/interview demonstrates → narration bridges
310
+ music_bed="continuous", # scored; announce scoring early; stings/swells mark scenes
311
+ scripting=(
312
+ "Ira-Glass engine: anecdote → anecdote → a beat of reflection; run on a theme "
313
+ "in numbered 'acts' with a prologue stating the theme; land a 'turn'. Layer "
314
+ "bottom→top: ambience → bed (ducked) → clips/actuality → testimony → narration "
315
+ "on top. Optional cold open (best tape pulled forward). Narration is always the "
316
+ "top layer; everything below illustrates."
317
+ ),
318
+ )
319
+
320
+
321
+ FORMATS: dict[str, Format] = {
322
+ f.id: f
323
+ for f in (
324
+ SOLO_EXPLAINER,
325
+ DEEP_DIVE,
326
+ INTERVIEW,
327
+ SONG_EXPLODER,
328
+ PANEL,
329
+ DEBATE,
330
+ DOCUMENTARY_VO,
331
+ )
332
+ }
braidio/kinds.py ADDED
@@ -0,0 +1,12 @@
1
+ """Production kinds braidio defines. Pure (no optional deps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class WeaveKind(str, Enum):
9
+ """A braidio production kind. ``COMMENTARY_WEAVE`` = narration woven with
10
+ extracted media segments (audio now, video later)."""
11
+
12
+ COMMENTARY_WEAVE = "commentary_weave"