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/render.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Render a :class:`~braidio.script.Script` into an audio file.
|
|
2
|
+
|
|
3
|
+
Walks the beats (filtered by the rights :class:`~braidio.rights.Profile`):
|
|
4
|
+
narration beats are synthesized (:mod:`braidio.tts`); segment beats are resolved
|
|
5
|
+
via a :class:`~braidio.sources.SegmentSource` and extracted (padded + faded).
|
|
6
|
+
Every part is loudness-normalized, then either woven on a timeline (clips tuck
|
|
7
|
+
under narration — :func:`braidio.weave.weave_timeline`) when a
|
|
8
|
+
:class:`~braidio.weave_config.WeaveConfig` enables it, or concatenated.
|
|
9
|
+
|
|
10
|
+
This is the no-graph fast path; the same core is reused by the nw-app
|
|
11
|
+
transforms (which add provenance + partial re-render).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from mixing import concatenate_audio
|
|
21
|
+
|
|
22
|
+
from braidio.conversation import DEFAULT_CAST, ConversationCast, render_dialogue
|
|
23
|
+
from braidio.delivery import V2_TUNED, Delivery
|
|
24
|
+
from braidio.rights import (
|
|
25
|
+
PUBLISHABLE_CLIP_RIGHTS,
|
|
26
|
+
Profile,
|
|
27
|
+
RightsPolicy,
|
|
28
|
+
plan_production,
|
|
29
|
+
)
|
|
30
|
+
from braidio.script import Script
|
|
31
|
+
from braidio.sources import SegmentSource
|
|
32
|
+
from braidio.tts import narrate
|
|
33
|
+
from braidio.weave import TimelineItem, extract_padded, weave_timeline
|
|
34
|
+
from braidio.weave_config import WeaveConfig
|
|
35
|
+
|
|
36
|
+
_DEFAULT_LUFS = -16.0
|
|
37
|
+
_TRUE_PEAK = -1.5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_ffmpeg() -> None:
|
|
41
|
+
if shutil.which("ffmpeg") is None:
|
|
42
|
+
raise RuntimeError("ffmpeg not found on PATH (brew install ffmpeg).")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _lead_gap(src: Path, dst: Path, *, gap_s: float) -> Path:
|
|
46
|
+
"""Prepend ``gap_s`` seconds of silence (breathing room before a beat)."""
|
|
47
|
+
_require_ffmpeg()
|
|
48
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
ms = int(round(gap_s * 1000))
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["ffmpeg", "-y", "-i", str(src), "-af", f"adelay={ms}:all=1", str(dst)],
|
|
52
|
+
check=True,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
)
|
|
55
|
+
return dst
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _loudnorm(src: Path, dst: Path, *, target_lufs: float = _DEFAULT_LUFS) -> Path:
|
|
59
|
+
_require_ffmpeg()
|
|
60
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
subprocess.run(
|
|
62
|
+
[
|
|
63
|
+
"ffmpeg",
|
|
64
|
+
"-y",
|
|
65
|
+
"-i",
|
|
66
|
+
str(src),
|
|
67
|
+
"-af",
|
|
68
|
+
f"loudnorm=I={target_lufs}:TP={_TRUE_PEAK}:LRA=11",
|
|
69
|
+
"-ar",
|
|
70
|
+
"44100",
|
|
71
|
+
str(dst),
|
|
72
|
+
],
|
|
73
|
+
check=True,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
)
|
|
76
|
+
return dst
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def render_production(
|
|
80
|
+
script: Script,
|
|
81
|
+
*,
|
|
82
|
+
source: SegmentSource,
|
|
83
|
+
api_key: str | None = None,
|
|
84
|
+
config: WeaveConfig | None = None,
|
|
85
|
+
profile: Profile = Profile.PERSONAL,
|
|
86
|
+
rights: RightsPolicy | None = None,
|
|
87
|
+
delivery: Delivery = V2_TUNED,
|
|
88
|
+
cast: ConversationCast = DEFAULT_CAST,
|
|
89
|
+
out_path: str | Path | None = None,
|
|
90
|
+
voice_id: str | None = None,
|
|
91
|
+
crossfade_s: float = 0.12,
|
|
92
|
+
normalize: bool = True,
|
|
93
|
+
music_bed=None, # optional braidio.music.MusicBed — instrumental underscore
|
|
94
|
+
tts_dir: str | Path = "data/tts",
|
|
95
|
+
clips_dir: str | Path = "data/clips",
|
|
96
|
+
episodes_dir: str | Path = "data/episodes",
|
|
97
|
+
) -> Path:
|
|
98
|
+
"""Render ``script`` under ``profile`` → a single audio file. Returns the path.
|
|
99
|
+
|
|
100
|
+
Segment beats are resolved through ``source`` (a :class:`SegmentSource`).
|
|
101
|
+
When ``config`` has ``clip_edge_overlap_s > 0``, a clip is ``placement="under"``,
|
|
102
|
+
or a ``music_bed`` is given, the parts are woven on a timeline; otherwise they
|
|
103
|
+
are concatenated. ``rights`` (if given) sets which segment rights are
|
|
104
|
+
publishable. ``music_bed`` lays an instrumental underscore under the whole
|
|
105
|
+
production (see :class:`braidio.music.MusicBed`).
|
|
106
|
+
|
|
107
|
+
``api_key`` is an optional per-request ElevenLabs key threaded to every
|
|
108
|
+
synthesized beat — both narration (:func:`braidio.tts.narrate`) and dialogue
|
|
109
|
+
(:func:`braidio.conversation.render_dialogue`). When ``None`` (default) each
|
|
110
|
+
synthesizer falls back to ``$ELEVENLABS_API_KEY`` (unchanged behavior); an
|
|
111
|
+
explicit key lets a caller (e.g. a per-user BYO-key request) override the
|
|
112
|
+
environment without mutating it. Segment beats never call ElevenLabs, so the
|
|
113
|
+
key does not touch them.
|
|
114
|
+
"""
|
|
115
|
+
publishable = rights.publishable_clip_rights if rights else PUBLISHABLE_CLIP_RIGHTS
|
|
116
|
+
plan = plan_production(script, profile, publishable_clip_rights=publishable)
|
|
117
|
+
target_lufs = config.target_lufs if config is not None else _DEFAULT_LUFS
|
|
118
|
+
duck_db = config.duck_db if config is not None else -15.0
|
|
119
|
+
|
|
120
|
+
out = (
|
|
121
|
+
Path(out_path)
|
|
122
|
+
if out_path
|
|
123
|
+
else Path(episodes_dir) / f"{script.id_slug}-{profile.value}.mp3"
|
|
124
|
+
)
|
|
125
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
work = Path(tts_dir) / f"_render_{script.id_slug}_{profile.value}"
|
|
127
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
# extraction pads: from config when present, else small defaults
|
|
130
|
+
if config is not None:
|
|
131
|
+
pre, post, fi, fo = (
|
|
132
|
+
config.clip_pre_roll_s,
|
|
133
|
+
config.clip_post_roll_s,
|
|
134
|
+
config.clip_fade_in_s,
|
|
135
|
+
config.clip_fade_out_s,
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
pre, post, fi, fo = 0.15, 0.35, 0.04, 0.04
|
|
139
|
+
|
|
140
|
+
parts: list[Path] = []
|
|
141
|
+
kinds: list[str] = []
|
|
142
|
+
placements: list[str] = [] # "sequential" | "under" (per part, for the weave)
|
|
143
|
+
for i, pb in enumerate(plan.beats):
|
|
144
|
+
if pb.kind == "narration":
|
|
145
|
+
orig = script.beats[pb.from_index]
|
|
146
|
+
beat_voice = getattr(orig, "voice", None) or voice_id # per-beat override
|
|
147
|
+
beat_settings = (
|
|
148
|
+
getattr(orig, "voice_settings", None) or delivery.voice_settings
|
|
149
|
+
)
|
|
150
|
+
raw = narrate(
|
|
151
|
+
pb.content,
|
|
152
|
+
Path(tts_dir)
|
|
153
|
+
/ f"{script.id_slug}-{profile.value}-{delivery.name}-beat{i:02d}.mp3",
|
|
154
|
+
api_key=api_key,
|
|
155
|
+
voice_id=beat_voice,
|
|
156
|
+
model_id=delivery.model_id,
|
|
157
|
+
voice_settings=beat_settings,
|
|
158
|
+
)
|
|
159
|
+
# breathing room before a register change (Narration.lead_gap_s)
|
|
160
|
+
gap = getattr(orig, "lead_gap_s", 0.0)
|
|
161
|
+
if gap and gap > 0:
|
|
162
|
+
raw = _lead_gap(
|
|
163
|
+
raw, Path(tts_dir) / f"{script.id_slug}-lead{i:02d}.mp3", gap_s=gap
|
|
164
|
+
)
|
|
165
|
+
elif pb.kind == "dialogue":
|
|
166
|
+
raw = render_dialogue(
|
|
167
|
+
list(pb.turns),
|
|
168
|
+
cast,
|
|
169
|
+
api_key=api_key,
|
|
170
|
+
out_path=Path(tts_dir)
|
|
171
|
+
/ f"{script.id_slug}-{profile.value}-dlg{i:02d}.mp3",
|
|
172
|
+
)
|
|
173
|
+
else: # segment
|
|
174
|
+
rs = source.resolve(pb.content)
|
|
175
|
+
if rs is None:
|
|
176
|
+
raise LookupError(f"segment did not resolve: {pb.content[:50]!r}")
|
|
177
|
+
raw = Path(clips_dir) / f"{script.id_slug}-seg{i:02d}.mp3"
|
|
178
|
+
extract_padded(
|
|
179
|
+
rs.asset_path,
|
|
180
|
+
rs.start_s,
|
|
181
|
+
rs.end_s,
|
|
182
|
+
raw,
|
|
183
|
+
pre_roll_s=pre,
|
|
184
|
+
post_roll_s=post,
|
|
185
|
+
fade_in_s=fi,
|
|
186
|
+
fade_out_s=fo,
|
|
187
|
+
)
|
|
188
|
+
parts.append(
|
|
189
|
+
_loudnorm(raw, work / f"part{i:02d}.mp3", target_lufs=target_lufs)
|
|
190
|
+
if normalize
|
|
191
|
+
else raw
|
|
192
|
+
)
|
|
193
|
+
# dialogue + narration are spoken → treated as "narration" on the timeline
|
|
194
|
+
kinds.append("clip" if pb.kind == "clip" else "narration")
|
|
195
|
+
# a "under" clip becomes a ducked underlay; "before"/"after" play clean
|
|
196
|
+
seg_place = getattr(script.beats[pb.from_index], "placement", "before")
|
|
197
|
+
placements.append(
|
|
198
|
+
"under" if (pb.kind == "clip" and seg_place == "under") else "sequential"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
has_under = "under" in placements
|
|
202
|
+
edge_overlap = config.clip_edge_overlap_s if config is not None else 0.0
|
|
203
|
+
crossfade = config.crossfade_s if config is not None else crossfade_s
|
|
204
|
+
# a music bed also needs the mix path (even for narration-only productions)
|
|
205
|
+
woven = music_bed is not None or (
|
|
206
|
+
"clip" in kinds and (edge_overlap > 0 or has_under)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if woven:
|
|
210
|
+
items = [
|
|
211
|
+
TimelineItem(
|
|
212
|
+
k, str(p), placement=pl, duck_db=(duck_db if pl == "under" else 0.0)
|
|
213
|
+
)
|
|
214
|
+
for k, p, pl in zip(kinds, parts, placements)
|
|
215
|
+
]
|
|
216
|
+
weave_timeline(
|
|
217
|
+
items,
|
|
218
|
+
out,
|
|
219
|
+
clip_edge_overlap_s=edge_overlap,
|
|
220
|
+
narration_crossfade_s=crossfade,
|
|
221
|
+
target_lufs=target_lufs,
|
|
222
|
+
bed=music_bed,
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
concatenate_audio(
|
|
226
|
+
*[str(p) for p in parts], output=str(out), crossfade=crossfade_s
|
|
227
|
+
)
|
|
228
|
+
return out
|
braidio/rights.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Render profiles: enforce personal-vs-published rights as data.
|
|
2
|
+
|
|
3
|
+
Rights are encoded on the beats (``SegmentBeat.rights``, and — mechanically —
|
|
4
|
+
the presence of forbidden verbatim text in narration) and a **render profile**
|
|
5
|
+
filters the script into the beats that may actually render:
|
|
6
|
+
|
|
7
|
+
- ``PERSONAL`` — include everything, including owned/copyrighted segment audio.
|
|
8
|
+
- ``PUBLISHED`` — exclude any non-publishable segment audio and any beat
|
|
9
|
+
carrying forbidden verbatim text; keep original narration, publishable
|
|
10
|
+
segments, and short transformative substitutes.
|
|
11
|
+
|
|
12
|
+
The rule is enforced *mechanically*: :func:`plan_production` routes beats by
|
|
13
|
+
their ``rights`` flag, and :func:`find_verbatim_text` / :func:`content_violations`
|
|
14
|
+
scan the resulting published beats against a **caller-supplied** set of
|
|
15
|
+
forbidden texts (via :class:`RightsPolicy`) — so a leak is a test failure, not a
|
|
16
|
+
judgment call. braidio owns the scanner + profile filter; the consumer injects
|
|
17
|
+
*what* is forbidden (e.g. Hamilton injects the song's lyric lines).
|
|
18
|
+
|
|
19
|
+
Not legal advice.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Callable, Iterable
|
|
28
|
+
|
|
29
|
+
from braidio.script import Dialogue, Narration, Script, SegmentBeat
|
|
30
|
+
|
|
31
|
+
# Segment ``rights`` values safe to render in the published cut.
|
|
32
|
+
PUBLISHABLE_CLIP_RIGHTS: frozenset[str] = frozenset({"public-domain"})
|
|
33
|
+
|
|
34
|
+
_WORD_RE = re.compile(r"\w+")
|
|
35
|
+
_MARKUP_RE = re.compile(r"<[^>]*>|\[[^\]]*\]") # SSML <break…> and v3 [audio tags]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _strip_markup(text: str) -> str:
|
|
39
|
+
"""Drop SSML/audio-tag markup so delivery markup doesn't affect the scan."""
|
|
40
|
+
return _MARKUP_RE.sub(" ", text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Profile(str, Enum):
|
|
44
|
+
"""Which projection of the production we render."""
|
|
45
|
+
|
|
46
|
+
PERSONAL = "personal"
|
|
47
|
+
PUBLISHED = "published"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RightsPolicy:
|
|
52
|
+
"""Injected rights configuration for the published profile.
|
|
53
|
+
|
|
54
|
+
``forbidden_texts`` yields the strings that must not appear verbatim in
|
|
55
|
+
published narration (e.g. copyrighted lyric lines). ``publishable_clip_rights``
|
|
56
|
+
is the set of segment ``rights`` values allowed in the published cut.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
forbidden_texts: Callable[[Script], Iterable[str]] = lambda _s: ()
|
|
60
|
+
publishable_clip_rights: frozenset[str] = PUBLISHABLE_CLIP_RIGHTS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PlannedBeat:
|
|
65
|
+
"""A beat resolved for a profile — what the renderer actually plays.
|
|
66
|
+
|
|
67
|
+
``kind`` is ``"narration"`` (synthesize ``content``) or ``"clip"`` (resolve
|
|
68
|
+
``content`` as a segment reference and cut audio). ``from_index`` points at
|
|
69
|
+
the source beat; ``note`` records any substitution/drop reasoning.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
kind: str # "narration" | "clip" | "dialogue"
|
|
73
|
+
content: str # narration/dialogue text (for scanning); clip = the reference
|
|
74
|
+
from_index: int
|
|
75
|
+
note: str = ""
|
|
76
|
+
turns: tuple | None = None # (role, text) pairs, for dialogue beats
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RenderPlan:
|
|
81
|
+
profile: Profile
|
|
82
|
+
beats: list[PlannedBeat] = field(default_factory=list)
|
|
83
|
+
dropped: list[str] = field(default_factory=list)
|
|
84
|
+
substituted: list[str] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def segment_is_publishable(
|
|
88
|
+
beat: SegmentBeat, publishable: frozenset[str] = PUBLISHABLE_CLIP_RIGHTS
|
|
89
|
+
) -> bool:
|
|
90
|
+
return beat.rights in publishable
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plan_production(
|
|
94
|
+
script: Script,
|
|
95
|
+
profile: Profile,
|
|
96
|
+
*,
|
|
97
|
+
publishable_clip_rights: frozenset[str] = PUBLISHABLE_CLIP_RIGHTS,
|
|
98
|
+
) -> RenderPlan:
|
|
99
|
+
"""Filter ``script`` into the beats renderable under ``profile``."""
|
|
100
|
+
plan = RenderPlan(profile=profile)
|
|
101
|
+
for i, beat in enumerate(script.beats):
|
|
102
|
+
if isinstance(beat, SegmentBeat):
|
|
103
|
+
if profile is Profile.PERSONAL or segment_is_publishable(
|
|
104
|
+
beat, publishable_clip_rights
|
|
105
|
+
):
|
|
106
|
+
plan.beats.append(PlannedBeat("clip", beat.reference, i))
|
|
107
|
+
elif beat.published_substitute:
|
|
108
|
+
plan.beats.append(
|
|
109
|
+
PlannedBeat(
|
|
110
|
+
"narration", beat.published_substitute, i, "segment→substitute"
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
plan.substituted.append(beat.label or beat.reference[:40])
|
|
114
|
+
else:
|
|
115
|
+
plan.dropped.append(beat.label or beat.reference[:40])
|
|
116
|
+
elif isinstance(beat, Narration):
|
|
117
|
+
if profile is Profile.PUBLISHED and beat.published_text is not None:
|
|
118
|
+
plan.beats.append(
|
|
119
|
+
PlannedBeat(
|
|
120
|
+
"narration", beat.published_text, i, "narration→published_text"
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
plan.substituted.append(f"narration[{i}]")
|
|
124
|
+
else:
|
|
125
|
+
plan.beats.append(PlannedBeat("narration", beat.text, i))
|
|
126
|
+
elif isinstance(beat, Dialogue):
|
|
127
|
+
# our commentary → always included; content is the joined text so the
|
|
128
|
+
# published verbatim-scan can see it; turns carry the render input.
|
|
129
|
+
plan.beats.append(
|
|
130
|
+
PlannedBeat(
|
|
131
|
+
"dialogue",
|
|
132
|
+
" ".join(text for _role, text in beat.turns),
|
|
133
|
+
i,
|
|
134
|
+
turns=tuple(beat.turns),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
else: # pragma: no cover - exhaustive
|
|
138
|
+
raise TypeError(f"unknown beat type {type(beat).__name__}")
|
|
139
|
+
return plan
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- mechanical verbatim-text detection --------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _word_ngrams(text: str, n: int) -> set[tuple[str, ...]]:
|
|
146
|
+
words = [w.lower() for w in _WORD_RE.findall(_strip_markup(text))]
|
|
147
|
+
return (
|
|
148
|
+
{tuple(words[i : i + n]) for i in range(len(words) - n + 1)}
|
|
149
|
+
if len(words) >= n
|
|
150
|
+
else set()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def find_verbatim_text(
|
|
155
|
+
text: str, forbidden: Iterable[str], *, min_words: int = 5
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
"""Forbidden lines that appear (near-)verbatim in ``text``.
|
|
158
|
+
|
|
159
|
+
A line counts as leaked if ``text`` shares a run of ``min_words`` consecutive
|
|
160
|
+
words with it (case-insensitive, word-level). Single words and short common
|
|
161
|
+
phrases don't trip it — only substantial verbatim quoting.
|
|
162
|
+
"""
|
|
163
|
+
text_ngrams = _word_ngrams(text, min_words)
|
|
164
|
+
if not text_ngrams:
|
|
165
|
+
return []
|
|
166
|
+
hits: list[str] = []
|
|
167
|
+
for line in forbidden:
|
|
168
|
+
line_ngrams = _word_ngrams(line, min_words)
|
|
169
|
+
if line_ngrams and (line_ngrams & text_ngrams):
|
|
170
|
+
hits.append(line)
|
|
171
|
+
return hits
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def content_violations(
|
|
175
|
+
plan: RenderPlan, forbidden: Iterable[str], *, min_words: int = 5
|
|
176
|
+
) -> list[str]:
|
|
177
|
+
"""Rights violations in a *published* plan (empty list = clean).
|
|
178
|
+
|
|
179
|
+
Fails if any planned beat plays non-publishable segment audio, or any
|
|
180
|
+
narration beat contains forbidden verbatim text.
|
|
181
|
+
"""
|
|
182
|
+
if plan.profile is not Profile.PUBLISHED:
|
|
183
|
+
return []
|
|
184
|
+
forbidden = list(forbidden)
|
|
185
|
+
violations: list[str] = []
|
|
186
|
+
for b in plan.beats:
|
|
187
|
+
if b.kind == "clip":
|
|
188
|
+
violations.append(
|
|
189
|
+
f"beat {b.from_index}: segment audio present in published cut"
|
|
190
|
+
)
|
|
191
|
+
elif b.kind in ("narration", "dialogue"):
|
|
192
|
+
leaks = find_verbatim_text(b.content, forbidden, min_words=min_words)
|
|
193
|
+
if leaks:
|
|
194
|
+
violations.append(
|
|
195
|
+
f"beat {b.from_index}: verbatim forbidden text in {b.kind} → {leaks[0]!r}"
|
|
196
|
+
)
|
|
197
|
+
return violations
|
braidio/script.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Composition model — the ordered beats a render walks.
|
|
2
|
+
|
|
3
|
+
A :class:`Script` is an ordered list of beats, each either a :class:`Narration`
|
|
4
|
+
(spoken, synthesized) or a :class:`SegmentBeat` (a *reference* to a span of
|
|
5
|
+
source media to resolve and weave in). This is the generic, media-agnostic
|
|
6
|
+
projection a renderer consumes; how a reference maps to audio is a
|
|
7
|
+
:class:`braidio.sources.SegmentSource` concern, and what the beats are backed by
|
|
8
|
+
(lyrics, a graph, hand-authoring) is the consumer's concern.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Union
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Narration:
|
|
19
|
+
"""A spoken narration beat (authored, synthesized by TTS).
|
|
20
|
+
|
|
21
|
+
``published_text`` is an optional rights-safe rewrite the published profile
|
|
22
|
+
uses when the default ``text`` quotes forbidden (e.g. copyrighted) content;
|
|
23
|
+
leave it ``None`` when the narration is already clean.
|
|
24
|
+
|
|
25
|
+
``lead_gap_s`` prepends a beat of silence — breathing room before a
|
|
26
|
+
register change (e.g. a book-read entering after a conversation), so it
|
|
27
|
+
doesn't feel glued to the previous speaker.
|
|
28
|
+
|
|
29
|
+
``voice`` and ``voice_settings`` are per-beat overrides so one timeline can
|
|
30
|
+
carry contrasting roles — e.g. a lively *presenter* and a graver *book
|
|
31
|
+
narrator* (different voice, and/or a different delivery preset's
|
|
32
|
+
``voice_settings``). Both fall back to the render's production-level defaults
|
|
33
|
+
when ``None``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
text: str
|
|
37
|
+
style: str | None = None # optional delivery hint (e.g. "book-passage")
|
|
38
|
+
published_text: str | None = None
|
|
39
|
+
lead_gap_s: float = 0.0
|
|
40
|
+
voice: str | None = (
|
|
41
|
+
None # per-beat voice override (e.g. presenter vs book-narrator)
|
|
42
|
+
)
|
|
43
|
+
voice_settings: dict | None = None # per-beat delivery override (e.g. V2_NARRATOR)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
CLIP_PLACEMENTS = ("before", "under", "after")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class SegmentBeat:
|
|
51
|
+
"""A span of source media to weave in, addressed by an opaque ``reference``.
|
|
52
|
+
|
|
53
|
+
The renderer resolves ``reference`` → ``[start,end)`` via a
|
|
54
|
+
:class:`~braidio.sources.SegmentSource` and cuts it. ``rights`` (e.g.
|
|
55
|
+
``owned-local`` / ``copyrighted`` / ``public-domain``) drives the render
|
|
56
|
+
profile; ``published_substitute`` is a transformative narration the
|
|
57
|
+
published profile swaps in when the segment's audio can't be used.
|
|
58
|
+
|
|
59
|
+
``placement`` is how the clip sits against the talk (the weaving grammar):
|
|
60
|
+
``"before"`` (default) and ``"after"`` play the clip **clean** in its own
|
|
61
|
+
slot — the set-up→clip and clip→payoff patterns, distinguished by where you
|
|
62
|
+
order the beat relative to the talk. ``"under"`` plays the clip
|
|
63
|
+
**concurrently beneath the following talk beat**, ducked by ``duck_db`` —
|
|
64
|
+
the "host talks over the clip" technique (place the clip *immediately before*
|
|
65
|
+
the talk it should sit under).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
reference: str
|
|
69
|
+
label: str = ""
|
|
70
|
+
rights: str = "owned-local"
|
|
71
|
+
published_substitute: str | None = None
|
|
72
|
+
placement: str = "before"
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if self.placement not in CLIP_PLACEMENTS:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"SegmentBeat.placement must be one of {CLIP_PLACEMENTS}, "
|
|
78
|
+
f"got {self.placement!r}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Dialogue:
|
|
84
|
+
"""A multi-speaker commentary exchange (the conversational register).
|
|
85
|
+
|
|
86
|
+
``turns`` is an ordered tuple of ``(role, text)`` pairs (roles like
|
|
87
|
+
``"A"``/``"B"`` map to voices via a :class:`~braidio.conversation.ConversationCast`
|
|
88
|
+
at render time). Rendered in ONE pass via Text-to-Dialogue so it sounds like
|
|
89
|
+
people talking to each other. This is our own commentary → always publishable
|
|
90
|
+
(its text is still scanned for forbidden verbatim quotes in the published cut).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
turns: tuple[tuple[str, str], ...]
|
|
94
|
+
label: str = ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
Beat = Union[Narration, SegmentBeat, Dialogue]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Script:
|
|
102
|
+
"""An ordered production script."""
|
|
103
|
+
|
|
104
|
+
title: str
|
|
105
|
+
id_slug: str # stable id for this production (e.g. "01"); no media semantics
|
|
106
|
+
beats: list[Beat] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def narration_segments(script: Script) -> list[str]:
|
|
110
|
+
"""All narration (default text) of a script, as sentence-level segments."""
|
|
111
|
+
from braidio.multivoice import split_segments
|
|
112
|
+
|
|
113
|
+
segs: list[str] = []
|
|
114
|
+
for beat in script.beats:
|
|
115
|
+
if isinstance(beat, Narration):
|
|
116
|
+
segs.extend(split_segments(beat.text))
|
|
117
|
+
return segs
|