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/multivoice.py ADDED
@@ -0,0 +1,244 @@
1
+ """Multi-voice narration: cycle a pool of voices across segments (issue #10).
2
+
3
+ When one voice reads flat, variety across voices is a stronger lever than any
4
+ single-voice setting. This renders narration by splitting it into segments
5
+ (sentences) and assigning each a voice drawn from a pool — randomly (seeded, so
6
+ it's reproducible) and avoiding immediate repeats so it doesn't feel like a
7
+ rigid round-robin. Speed is jittered slightly per segment, even within one
8
+ speaker, to break regularity.
9
+
10
+ Two curated pools of **premade** ElevenLabs voices (all production quality,
11
+ deliberately less familiar than "George", 2M/2F in :data:`POOL_4`, wider in
12
+ :data:`POOL_MANY`). For a truly large "many people / interviews" pool, the
13
+ shared Voice Library (thousands) can be tapped, but quality varies there, so
14
+ those need curation — the premade pools below are the safe default.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from mixing import concatenate_audio
27
+
28
+ from braidio.tts import narrate
29
+
30
+ _MARKUP_RE = re.compile(r"<[^>]*>|\[[^\]]*\]")
31
+ _SENT_SPLIT = re.compile(r"(?<=[.?!])\s+(?=[\"“']?[A-Z])")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Voice:
36
+ """A pooled narration voice."""
37
+
38
+ id: str
39
+ name: str
40
+ gender: str # "M" | "F"
41
+ accent: str = ""
42
+ note: str = ""
43
+
44
+
45
+ # 2M / 2F, mixed accents/registers, all less familiar than "George".
46
+ POOL_4: list[Voice] = [
47
+ Voice("EXAVITQu4vr4xnSDxMaL", "Sarah", "F", "American", "mature, reassuring"),
48
+ Voice("Xb7hH8MSUJpSbSDYk0k2", "Alice", "F", "British", "clear, engaging"),
49
+ Voice("IKne3meq5aSn9XLyUdCD", "Charlie", "M", "Australian", "deep, energetic"),
50
+ Voice("cjVigY5qzO86Huf0OWal", "Eric", "M", "American", "smooth, trustworthy"),
51
+ ]
52
+
53
+ # Wider pool for a "lots of different people / interviews" feel (5M / 5F).
54
+ POOL_MANY: list[Voice] = POOL_4 + [
55
+ Voice("cgSgspJ2msm6clMCkdW9", "Jessica", "F", "American", "playful, bright"),
56
+ Voice("XrExE9yKIg1WjnnlVkGX", "Matilda", "F", "American", "professional"),
57
+ Voice("pFZP5JQG7iQjIQuC4Bku", "Lily", "F", "British", "velvety"),
58
+ Voice("N2lVS1w4EtoT3dr4eOWO", "Callum", "M", "American", "husky"),
59
+ Voice("bIHbv24MWmeRgasZH58o", "Will", "M", "American", "relaxed"),
60
+ Voice("pqHfZKP75CvOlQylNhV4", "Bill", "M", "American", "wise, mature"),
61
+ ]
62
+
63
+ POOLS: dict[str, list[Voice]] = {"four": POOL_4, "many": POOL_MANY}
64
+
65
+ _BASE_SETTINGS = {
66
+ "stability": 0.4,
67
+ "similarity_boost": 0.75,
68
+ "style": 0.3,
69
+ "use_speaker_boost": True,
70
+ }
71
+
72
+
73
+ def strip_markup(text: str) -> str:
74
+ return _MARKUP_RE.sub(" ", text)
75
+
76
+
77
+ def split_segments(text: str) -> list[str]:
78
+ """Split narration into sentence-level segments (markup removed).
79
+
80
+ Splits on sentence-final ``.?!`` (not the ``…`` used for in-thought pacing),
81
+ so connected clauses stay with one speaker.
82
+ """
83
+ clean = re.sub(r"\s+", " ", strip_markup(text)).strip()
84
+ return [s.strip() for s in _SENT_SPLIT.split(clean) if s.strip()]
85
+
86
+
87
+ def assign_voices(
88
+ n: int, pool: list[Voice], *, seed: int = 0, avoid_repeats: bool = True
89
+ ) -> list[Voice]:
90
+ """Assign a voice to each of ``n`` turns — random, seeded, no immediate
91
+ repeats (so it isn't a rigid round-robin)."""
92
+ rng = random.Random(seed)
93
+ out: list[Voice] = []
94
+ prev: Voice | None = None
95
+ for _ in range(n):
96
+ choices = [v for v in pool if not (avoid_repeats and v is prev)] or pool
97
+ v = rng.choice(choices)
98
+ out.append(v)
99
+ prev = v
100
+ return out
101
+
102
+
103
+ def group_turns(
104
+ segments: list[str], *, min_turn: int = 1, max_turn: int = 1, seed: int = 0
105
+ ) -> list[str]:
106
+ """Group consecutive segments into *turns* of ``min_turn..max_turn`` segments.
107
+
108
+ A turn is what one voice speaks before the next takes over. Bigger turns =
109
+ each speaker talks longer (fewer switches). Each turn's segments are joined
110
+ into one utterance so prosody is continuous within a speaker. Turn sizes are
111
+ seeded-random within the range.
112
+ """
113
+ if min_turn < 1 or max_turn < min_turn:
114
+ raise ValueError("require 1 <= min_turn <= max_turn")
115
+ rng = random.Random(seed * 17 + 3)
116
+ turns: list[str] = []
117
+ i = 0
118
+ while i < len(segments):
119
+ k = rng.randint(min_turn, max_turn)
120
+ turns.append(" ".join(segments[i : i + k]))
121
+ i += k
122
+ return turns
123
+
124
+
125
+ def _loudnorm(
126
+ src: Path, dst: Path, *, target_lufs: float = -16.0, true_peak: float = -1.5
127
+ ) -> Path:
128
+ if shutil.which("ffmpeg") is None:
129
+ raise RuntimeError("ffmpeg not found on PATH.")
130
+ dst.parent.mkdir(parents=True, exist_ok=True)
131
+ subprocess.run(
132
+ [
133
+ "ffmpeg",
134
+ "-y",
135
+ "-i",
136
+ str(src),
137
+ "-af",
138
+ f"loudnorm=I={target_lufs}:TP={true_peak}:LRA=11",
139
+ "-ar",
140
+ "44100",
141
+ str(dst),
142
+ ],
143
+ check=True,
144
+ capture_output=True,
145
+ )
146
+ return dst
147
+
148
+
149
+ def render_multivoice(
150
+ segments: list[str],
151
+ pool: list[Voice],
152
+ *,
153
+ out_path: str | Path,
154
+ api_key: str | None = None,
155
+ work_dir: str | Path = "data/tts/multivoice",
156
+ seed: int = 7,
157
+ min_turn: int = 2,
158
+ max_turn: int = 4,
159
+ avoid_immediate_repeat: bool = True,
160
+ model_id: str = "eleven_multilingual_v2",
161
+ base_settings: dict | None = None,
162
+ speed_base: float = 1.0,
163
+ speed_jitter: float = 0.04,
164
+ crossfade_s: float = 0.1,
165
+ gap_s: float = 0.0,
166
+ target_lufs: float = -16.0,
167
+ ) -> list[tuple[Voice, str]]:
168
+ """Render ``segments`` cycling ``pool`` → ``out_path``.
169
+
170
+ Segments are first grouped into *turns* of ``min_turn..max_turn`` segments
171
+ (bigger = each voice talks longer). One voice per turn, no immediate repeat,
172
+ with a jittered speed even within a speaker. ``gap_s`` inserts silence
173
+ between turns (0 = none). Returns ``[(voice, turn_text), …]`` for reporting.
174
+
175
+ ``api_key`` is an optional per-request ElevenLabs key threaded to every
176
+ :func:`braidio.tts.narrate` call; ``None`` (default) keeps the
177
+ ``$ELEVENLABS_API_KEY`` fallback.
178
+
179
+ NOTE: overlapping/interrupting speakers and clip ducking are separate,
180
+ upcoming parameters (tracked as issues) — this renders turns sequentially.
181
+ """
182
+ settings = dict(base_settings if base_settings is not None else _BASE_SETTINGS)
183
+ turns = group_turns(segments, min_turn=min_turn, max_turn=max_turn, seed=seed)
184
+ rng = random.Random(seed * 31 + 1)
185
+ voices = assign_voices(
186
+ len(turns), pool, seed=seed, avoid_repeats=avoid_immediate_repeat
187
+ )
188
+ work = Path(work_dir)
189
+ work.mkdir(parents=True, exist_ok=True)
190
+ parts: list[Path] = []
191
+ for i, (turn, v) in enumerate(zip(turns, voices)):
192
+ speed = round(speed_base + rng.uniform(-speed_jitter, speed_jitter), 3)
193
+ raw = narrate(
194
+ turn,
195
+ work / f"turn{i:03d}-{v.name}.mp3",
196
+ api_key=api_key,
197
+ voice_id=v.id,
198
+ model_id=model_id,
199
+ voice_settings={**settings, "speed": speed},
200
+ )
201
+ parts.append(_loudnorm(raw, work / f"norm{i:03d}.mp3", target_lufs=target_lufs))
202
+ out = Path(out_path)
203
+ out.parent.mkdir(parents=True, exist_ok=True)
204
+ if gap_s > 0:
205
+ _concat_with_gaps(parts, out, gap_s=gap_s)
206
+ else:
207
+ concatenate_audio(
208
+ *[str(p) for p in parts], output=str(out), crossfade=crossfade_s
209
+ )
210
+ return list(zip(voices, turns))
211
+
212
+
213
+ def _concat_with_gaps(parts: list[Path], out: Path, *, gap_s: float) -> Path:
214
+ """Concatenate audio parts with ``gap_s`` seconds of silence between them."""
215
+ from mixing import Audio # local import; audio object model
216
+
217
+ silence = None
218
+ pieces: list[str] = []
219
+ for p in parts:
220
+ pieces.append(str(p))
221
+ # Build via ffmpeg: create a silence file and interleave.
222
+ sil = out.parent / "_gap.mp3"
223
+ subprocess.run(
224
+ [
225
+ "ffmpeg",
226
+ "-y",
227
+ "-f",
228
+ "lavfi",
229
+ "-i",
230
+ f"anullsrc=r=44100:cl=stereo",
231
+ "-t",
232
+ f"{gap_s:.3f}",
233
+ str(sil),
234
+ ],
235
+ check=True,
236
+ capture_output=True,
237
+ )
238
+ interleaved: list[str] = []
239
+ for i, p in enumerate(pieces):
240
+ if i:
241
+ interleaved.append(str(sil))
242
+ interleaved.append(p)
243
+ concatenate_audio(*interleaved, output=str(out), crossfade=0.0)
244
+ return out
braidio/music.py ADDED
@@ -0,0 +1,111 @@
1
+ """Music bed — an instrumental underscore laid under the whole production, ducked.
2
+
3
+ The bed is the spanning generalization of a per-clip ``under`` placement: instead
4
+ of one clip beneath one talk beat, an app-supplied **instrumental** asset runs
5
+ beneath the entire timeline at reduced gain, entering after speech onset and
6
+ fading at the ends (the conventions from
7
+ ``docs/research/commentary-formats-and-styles.md``: bed 10–15 dB under voice,
8
+ ~1.5s fade-in, *posted* after the start; beds must be instrumental).
9
+
10
+ braidio ships **no music** — the caller supplies the asset (a licensed/owned
11
+ instrumental). A :class:`~braidio.formats.Format`'s ``music_bed`` *intensity*
12
+ (continuous / light / sparse / none) picks a sensible gain via
13
+ :data:`BED_GAIN_BY_INTENSITY`; :func:`render_format` builds the bed for you when
14
+ given a ``bed_asset``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ import subprocess
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ # Bed gain (dB under the mixed voice) by a Format's music_bed intensity.
25
+ # None means "no bed" for that intensity.
26
+ BED_GAIN_BY_INTENSITY: dict[str, float | None] = {
27
+ "continuous": -20.0,
28
+ "light": -24.0,
29
+ "sparse": -28.0,
30
+ "none": None,
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class MusicBed:
36
+ """An instrumental underscore spanning the production, mixed under the talk.
37
+
38
+ ``asset_path`` is an app-supplied instrumental. ``gain_db`` sets how far under
39
+ the voice it sits; ``lead_in_s`` posts the bed *after* speech onset so its
40
+ entrance feels motivated; ``start_s`` is the in-point into the asset;
41
+ ``loop`` repeats the asset to cover a timeline longer than it.
42
+ """
43
+
44
+ asset_path: str
45
+ gain_db: float = -22.0
46
+ fade_in_s: float = 1.5
47
+ fade_out_s: float = 2.0
48
+ lead_in_s: float = 2.0
49
+ start_s: float = 0.0
50
+ loop: bool = True
51
+
52
+
53
+ def bed_for_intensity(asset_path: str, intensity: str, **overrides) -> MusicBed | None:
54
+ """Build a :class:`MusicBed` at the gain for a Format ``music_bed`` intensity.
55
+
56
+ Returns ``None`` for ``"none"`` (or an unknown intensity), so callers can do
57
+ ``bed = bed_for_intensity(asset, fmt.music_bed)`` and skip when falsy.
58
+ """
59
+ gain = BED_GAIN_BY_INTENSITY.get(intensity)
60
+ if gain is None:
61
+ return None
62
+ return MusicBed(asset_path=asset_path, gain_db=gain, **overrides)
63
+
64
+
65
+ def _require_ffmpeg() -> None:
66
+ if shutil.which("ffmpeg") is None:
67
+ raise RuntimeError("ffmpeg not found on PATH (brew install ffmpeg).")
68
+
69
+
70
+ def prepare_bed(
71
+ bed: MusicBed, target_s: float, out_path: str | Path, *, sample_rate: int = 44100
72
+ ) -> Path:
73
+ """Render ``bed`` to a ready-to-mix underscore of length ``target_s - lead_in_s``.
74
+
75
+ Seeks to ``bed.start_s`` (looping if needed), trims to the covered length, and
76
+ bakes in fades + ``gain_db`` + stereo. The caller mixes the result delayed by
77
+ ``bed.lead_in_s``. Returns ``out_path``.
78
+ """
79
+ _require_ffmpeg()
80
+ length = max(0.05, target_s - bed.lead_in_s)
81
+ fo_start = max(0.0, length - bed.fade_out_s)
82
+ out = Path(out_path)
83
+ out.parent.mkdir(parents=True, exist_ok=True)
84
+ pre_input: list[str] = []
85
+ if bed.loop:
86
+ pre_input += ["-stream_loop", "-1"]
87
+ if bed.start_s > 0:
88
+ pre_input += ["-ss", f"{bed.start_s:.3f}"]
89
+ af = (
90
+ f"afade=t=in:st=0:d={bed.fade_in_s},"
91
+ f"afade=t=out:st={fo_start:.3f}:d={bed.fade_out_s},"
92
+ f"volume={bed.gain_db}dB,"
93
+ f"aformat=sample_rates={sample_rate}:channel_layouts=stereo"
94
+ )
95
+ subprocess.run(
96
+ [
97
+ "ffmpeg",
98
+ "-y",
99
+ *pre_input,
100
+ "-i",
101
+ str(bed.asset_path),
102
+ "-t",
103
+ f"{length:.3f}",
104
+ "-af",
105
+ af,
106
+ str(out),
107
+ ],
108
+ check=True,
109
+ capture_output=True,
110
+ )
111
+ return out
braidio/project.py ADDED
@@ -0,0 +1,26 @@
1
+ """braidio as an nw app: a :class:`nw.Project` for commentary-weave productions.
2
+
3
+ Subclassing ``nw.Project`` inherits the whole folder facade + lacing graph +
4
+ freshness (``stale_after``) machinery; braidio adds its production kind and its
5
+ domain/render body-schema vocabulary (registered on import of
6
+ :mod:`braidio.bodies`). This module requires ``nw``; it is imported lazily by
7
+ ``braidio/__init__`` only when ``nw`` is available.
8
+
9
+ The costed ``plan``/``execute`` Transform pipeline (each transform delegating to
10
+ braidio's functional core for the actual audio, and recording provenance via
11
+ :mod:`braidio.provenance`) is the next increment — see the design doc and
12
+ nw#9 (generalizing nw's shot/mp4 render seam).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import nw
18
+
19
+ from braidio import bodies as _bodies # noqa: F401 (import registers schemas)
20
+ from braidio.kinds import WeaveKind
21
+
22
+
23
+ class Project(nw.Project):
24
+ """An nw project for a braidio commentary-weave production."""
25
+
26
+ KIND: WeaveKind = WeaveKind.COMMENTARY_WEAVE
braidio/provenance.py ADDED
@@ -0,0 +1,152 @@
1
+ """Record render choices as linked artifacts → trace + partial re-render.
2
+
3
+ A render is a *projection* of the graph, so the render **choices** belong in the
4
+ graph too. :func:`record_render` writes the render-provenance nodes
5
+ (weave-config, voice-assignment, narration-render, segment-extraction,
6
+ episode-render) with ``was_derived_from`` edges to their inputs.
7
+ :func:`stale_after` then returns exactly the nodes downstream of a change — the
8
+ partial-re-render frontier (a memoized build DAG for audio; mirrors nw's
9
+ ``stale_after``). See the design doc + nw's render-provenance rationale.
10
+
11
+ Requires ``lacing``; imported lazily by ``braidio/__init__`` only when present.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from uuid import UUID, uuid4
17
+
18
+ from lacing import Annotation, NodeRef, Provenance, RationalTime, TimeInterval
19
+
20
+ from braidio.bodies import register_tiers
21
+ from braidio.bodies._render_nodes import (
22
+ EPISODE_RENDER_V1,
23
+ NARRATION_RENDER_V1,
24
+ SEGMENT_EXTRACTION_V1,
25
+ VOICE_ASSIGNMENT_V1,
26
+ WEAVE_CONFIG_V1,
27
+ )
28
+
29
+ _RATE = 1000
30
+
31
+
32
+ def _node(store, tier: str, uri: str, body: dict, *, derived_from=()) -> UUID:
33
+ """Add a render node to ``store`` with provenance; return its id."""
34
+ aid = uuid4()
35
+ store.add(
36
+ Annotation(
37
+ id=aid,
38
+ tier=tier,
39
+ reference=NodeRef(
40
+ scene_path=f"{tier}/{aid}",
41
+ interval=TimeInterval.from_seconds(0.0, 1.0, rate=_RATE),
42
+ ),
43
+ body=body,
44
+ body_schema_uri=uri,
45
+ provenance=Provenance(
46
+ was_generated_by="braidio:render",
47
+ was_attributed_to="braidio",
48
+ was_derived_from=list(derived_from),
49
+ generated_at_time=RationalTime.now(rate=_RATE),
50
+ activity="derive",
51
+ ),
52
+ )
53
+ )
54
+ return aid
55
+
56
+
57
+ def record_render(
58
+ store, *, weave_config: dict, beats: list[dict], profile: str = "personal"
59
+ ) -> dict:
60
+ """Write the provenance graph for one render into ``store``.
61
+
62
+ ``beats`` is an ordered list of dicts, each either::
63
+
64
+ {"kind": "narration", "source_id": <UUID of the beat/commentary node>,
65
+ "cache_key": str, "voice_id": str, "pool_label": str, "seed": int,
66
+ "duration_s": float, "artifact_id": str | None}
67
+ {"kind": "segment", "source_id": <UUID of the source-media/clip node>,
68
+ "cache_key": str, "start_s": float, "end_s": float, "artifact_id": str | None}
69
+
70
+ Returns ``{"config": UUID, "members": [UUID,…], "episode": UUID}``.
71
+ """
72
+ register_tiers(store) # idempotent
73
+ cfg = _node(store, "weave-configs", WEAVE_CONFIG_V1, {"config": weave_config})
74
+ members: list[UUID] = []
75
+ for b in beats:
76
+ src = b["source_id"]
77
+ if b["kind"] == "narration":
78
+ va = _node(
79
+ store,
80
+ "voice-assignments",
81
+ VOICE_ASSIGNMENT_V1,
82
+ {
83
+ "voice_id": b.get("voice_id", ""),
84
+ "pool_label": b.get("pool_label", "single"),
85
+ "seed": int(b.get("seed", 0)),
86
+ },
87
+ derived_from=[
88
+ src,
89
+ cfg,
90
+ ], # depends on the beat AND the config (pool/seed)
91
+ )
92
+ nid = _node(
93
+ store,
94
+ "narration-renders",
95
+ NARRATION_RENDER_V1,
96
+ {
97
+ "cache_key": b["cache_key"],
98
+ "artifact_id": b.get("artifact_id"),
99
+ "duration_s": float(b.get("duration_s", 0.0)),
100
+ },
101
+ derived_from=[src, va, cfg],
102
+ )
103
+ members.append(nid)
104
+ else: # segment
105
+ sid = _node(
106
+ store,
107
+ "segment-extractions",
108
+ SEGMENT_EXTRACTION_V1,
109
+ {
110
+ "cache_key": b["cache_key"],
111
+ "start_s": float(b.get("start_s", 0.0)),
112
+ "end_s": float(b.get("end_s", 1.0)),
113
+ "artifact_id": b.get("artifact_id"),
114
+ },
115
+ derived_from=[src, cfg],
116
+ )
117
+ members.append(sid)
118
+ episode = _node(
119
+ store,
120
+ "episode-renders",
121
+ EPISODE_RENDER_V1,
122
+ {
123
+ "profile": profile,
124
+ "ordered_member_ids": tuple(str(m) for m in members),
125
+ "artifact_id": None,
126
+ "duration_s": 0.0,
127
+ },
128
+ derived_from=[*members, cfg],
129
+ )
130
+ return {"config": cfg, "members": members, "episode": episode}
131
+
132
+
133
+ def descendants_of(store, changed_id: UUID) -> set[UUID]:
134
+ """Every annotation transitively derived from ``changed_id`` (via
135
+ ``provenance.was_derived_from``). Excludes ``changed_id`` itself."""
136
+ children: dict[UUID, list[UUID]] = {}
137
+ for a in store.all():
138
+ for parent in a.provenance.was_derived_from:
139
+ children.setdefault(parent, []).append(a.id)
140
+ out: set[UUID] = set()
141
+ stack = [changed_id]
142
+ while stack:
143
+ cur = stack.pop()
144
+ for child in children.get(cur, []):
145
+ if child not in out:
146
+ out.add(child)
147
+ stack.append(child)
148
+ return out
149
+
150
+
151
+ # The partial-re-render frontier of a change (mirrors nw.stale_after semantics).
152
+ stale_after = descendants_of