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.
- braidio/__init__.py +242 -0
- braidio/bodies/__init__.py +42 -0
- braidio/bodies/_domain.py +120 -0
- braidio/bodies/_render_nodes.py +107 -0
- braidio/bodies/_tiers.py +43 -0
- braidio/compose.py +67 -0
- braidio/conversation.py +181 -0
- braidio/delivery.py +132 -0
- braidio/formats.py +332 -0
- braidio/kinds.py +12 -0
- braidio/multivoice.py +244 -0
- braidio/music.py +111 -0
- braidio/project.py +26 -0
- braidio/provenance.py +152 -0
- braidio/render.py +228 -0
- braidio/rights.py +197 -0
- braidio/script.py +117 -0
- braidio/sources.py +249 -0
- braidio/tts.py +163 -0
- braidio/weave.py +260 -0
- braidio/weave_config.py +121 -0
- braidio-0.0.2.dist-info/METADATA +168 -0
- braidio-0.0.2.dist-info/RECORD +25 -0
- braidio-0.0.2.dist-info/WHEEL +4 -0
- braidio-0.0.2.dist-info/licenses/LICENSE +21 -0
braidio/conversation.py
ADDED
|
@@ -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"
|