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 ADDED
@@ -0,0 +1,242 @@
1
+ """braidio — weave narration with extracted media segments into productions.
2
+
3
+ Braid two kinds of strand into one production: authored **narration** (TTS —
4
+ single voice or a cycled pool) and extracted **segments** of source media (song
5
+ clips, audiobook passages, news, SFX). The result renders to an audiovisual
6
+ object.
7
+
8
+ This is the **functional core** (pure Python over files + numbers; deps:
9
+ ``mixing``, ``elevenlabs``, and ``ffmpeg`` on PATH). An optional nw-app layer
10
+ (graph bodies, transforms, provenance / partial re-render) will be added on top
11
+ and imported only when ``nw`` is available — ``import braidio`` never requires
12
+ it.
13
+
14
+ Extracted from the Hamilton lyrics-podcast; see that project + issues #18/#28.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ # --- composition model ---
20
+ from braidio.script import ( # noqa: F401
21
+ Script,
22
+ Narration,
23
+ SegmentBeat,
24
+ Dialogue,
25
+ Beat,
26
+ narration_segments,
27
+ )
28
+
29
+ # --- rights profiles ---
30
+ from braidio.rights import ( # noqa: F401
31
+ Profile,
32
+ RightsPolicy,
33
+ RenderPlan,
34
+ PlannedBeat,
35
+ plan_production,
36
+ find_verbatim_text,
37
+ content_violations,
38
+ segment_is_publishable,
39
+ PUBLISHABLE_CLIP_RIGHTS,
40
+ )
41
+
42
+ # --- segment sources (reference -> cuttable window) ---
43
+ from braidio.sources import ( # noqa: F401
44
+ SegmentSource,
45
+ ResolvedSegment,
46
+ Segment,
47
+ TimedLine,
48
+ TimedLineSegmentSource,
49
+ find_segment,
50
+ load_timing,
51
+ cut_quote,
52
+ )
53
+
54
+ # --- narration synthesis ---
55
+ from braidio.tts import ( # noqa: F401
56
+ narrate,
57
+ text_to_dialogue,
58
+ resolve_voice_id,
59
+ DEFAULT_VOICE_ID,
60
+ DEFAULT_MODEL_ID,
61
+ DEFAULT_VOICE_SETTINGS,
62
+ VOICE_ENV_VAR,
63
+ )
64
+ from braidio.conversation import ( # noqa: F401
65
+ ConversationCast,
66
+ DEFAULT_CAST,
67
+ render_dialogue,
68
+ render_turns_sequential,
69
+ )
70
+ from braidio.delivery import ( # noqa: F401
71
+ Delivery,
72
+ DELIVERIES,
73
+ BASELINE,
74
+ V2_TUNED,
75
+ V2_AGGRESSIVE,
76
+ V2_PRESENTER,
77
+ V2_NARRATOR,
78
+ V3_NATURAL,
79
+ V3_CREATIVE,
80
+ )
81
+
82
+ # --- multi-voice casting ---
83
+ from braidio.multivoice import ( # noqa: F401
84
+ Voice,
85
+ POOL_4,
86
+ POOL_MANY,
87
+ POOLS,
88
+ strip_markup,
89
+ split_segments,
90
+ assign_voices,
91
+ group_turns,
92
+ render_multivoice,
93
+ )
94
+
95
+ # --- configuration ---
96
+ from braidio.weave_config import WeaveConfig, PRESETS # noqa: F401
97
+
98
+ # --- music bed (instrumental underscore) ---
99
+ from braidio.music import ( # noqa: F401
100
+ MusicBed,
101
+ bed_for_intensity,
102
+ BED_GAIN_BY_INTENSITY,
103
+ )
104
+
105
+ # --- ready-made format templates (standard-named presets) ---
106
+ from braidio.formats import ( # noqa: F401
107
+ Format,
108
+ render_format,
109
+ FORMATS,
110
+ SOLO_EXPLAINER,
111
+ DEEP_DIVE,
112
+ INTERVIEW,
113
+ SONG_EXPLODER,
114
+ PANEL,
115
+ DEBATE,
116
+ DOCUMENTARY_VO,
117
+ )
118
+
119
+ # --- composition + weaving (audio) ---
120
+ from braidio.compose import compose_narration # noqa: F401
121
+ from braidio.render import render_production # noqa: F401
122
+ from braidio.weave import ( # noqa: F401
123
+ TimelineItem,
124
+ extract_padded,
125
+ weave_timeline,
126
+ layout_starts,
127
+ duration_s,
128
+ )
129
+
130
+ # --- production kinds (pure) ---
131
+ from braidio.kinds import WeaveKind # noqa: F401
132
+
133
+ # --- optional nw-app layer (graph bodies + provenance) --------------------
134
+ # Registers braidio's domain/render body schemas with lacing and exposes the
135
+ # provenance / partial-re-render helpers. Guarded: `import braidio` never needs
136
+ # lacing/nw. `HAS_GRAPH` / `HAS_NW` report what's available.
137
+ HAS_GRAPH = False
138
+ HAS_NW = False
139
+ try: # needs lacing
140
+ from braidio import bodies # noqa: F401 (registers schemas)
141
+ from braidio.provenance import ( # noqa: F401
142
+ record_render,
143
+ stale_after,
144
+ descendants_of,
145
+ )
146
+
147
+ HAS_GRAPH = True
148
+ except ImportError: # pragma: no cover - optional dep
149
+ pass
150
+ try: # needs nw
151
+ from braidio.project import Project # noqa: F401
152
+
153
+ HAS_NW = True
154
+ except ImportError: # pragma: no cover - optional dep
155
+ pass
156
+
157
+ __all__ = [
158
+ # production kinds
159
+ "WeaveKind",
160
+ "HAS_GRAPH",
161
+ "HAS_NW",
162
+ # script
163
+ "Script",
164
+ "Narration",
165
+ "SegmentBeat",
166
+ "Dialogue",
167
+ "Beat",
168
+ "narration_segments",
169
+ # rights
170
+ "Profile",
171
+ "RightsPolicy",
172
+ "RenderPlan",
173
+ "PlannedBeat",
174
+ "plan_production",
175
+ "find_verbatim_text",
176
+ "content_violations",
177
+ "segment_is_publishable",
178
+ "PUBLISHABLE_CLIP_RIGHTS",
179
+ # render
180
+ "render_production",
181
+ # sources
182
+ "SegmentSource",
183
+ "ResolvedSegment",
184
+ "Segment",
185
+ "TimedLine",
186
+ "TimedLineSegmentSource",
187
+ "find_segment",
188
+ "load_timing",
189
+ "cut_quote",
190
+ # tts
191
+ "narrate",
192
+ "resolve_voice_id",
193
+ "DEFAULT_VOICE_ID",
194
+ "DEFAULT_MODEL_ID",
195
+ "DEFAULT_VOICE_SETTINGS",
196
+ "VOICE_ENV_VAR",
197
+ # delivery
198
+ "Delivery",
199
+ "DELIVERIES",
200
+ "BASELINE",
201
+ "V2_TUNED",
202
+ "V2_AGGRESSIVE",
203
+ "V2_PRESENTER",
204
+ "V2_NARRATOR",
205
+ "V3_NATURAL",
206
+ "V3_CREATIVE",
207
+ # multivoice
208
+ "Voice",
209
+ "POOL_4",
210
+ "POOL_MANY",
211
+ "POOLS",
212
+ "strip_markup",
213
+ "split_segments",
214
+ "assign_voices",
215
+ "group_turns",
216
+ "render_multivoice",
217
+ # config
218
+ "WeaveConfig",
219
+ "PRESETS",
220
+ # music bed
221
+ "MusicBed",
222
+ "bed_for_intensity",
223
+ "BED_GAIN_BY_INTENSITY",
224
+ # formats (ready-made templates)
225
+ "Format",
226
+ "render_format",
227
+ "FORMATS",
228
+ "SOLO_EXPLAINER",
229
+ "DEEP_DIVE",
230
+ "INTERVIEW",
231
+ "SONG_EXPLODER",
232
+ "PANEL",
233
+ "DEBATE",
234
+ "DOCUMENTARY_VO",
235
+ # compose + weave
236
+ "compose_narration",
237
+ "TimelineItem",
238
+ "extract_padded",
239
+ "weave_timeline",
240
+ "layout_starts",
241
+ "duration_s",
242
+ ]
@@ -0,0 +1,42 @@
1
+ """braidio's lacing body schemas + tiers (the nw-app domain vocabulary).
2
+
3
+ Importing this package **registers every schema** with lacing (side effect of
4
+ importing the submodules). Requires ``lacing``; it is imported lazily by
5
+ ``braidio/__init__.py`` only when available, so the functional core never
6
+ needs it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from braidio.bodies._domain import ( # noqa: F401 (import registers)
12
+ COMMENTARY_V1,
13
+ SOURCE_V1,
14
+ AUDIO_CLIP_V1,
15
+ NARRATIVE_BEAT_V1,
16
+ EPISODE_V1,
17
+ CommentaryBodyV1,
18
+ SourceBodyV1,
19
+ AudioClipBodyV1,
20
+ NarrativeBeatBodyV1,
21
+ EpisodeBodyV1,
22
+ DOMAIN_SCHEMAS,
23
+ )
24
+ from braidio.bodies._render_nodes import ( # noqa: F401 (import registers)
25
+ WEAVE_CONFIG_V1,
26
+ SOURCE_MEDIA_V1,
27
+ VOICE_ASSIGNMENT_V1,
28
+ NARRATION_RENDER_V1,
29
+ SEGMENT_EXTRACTION_V1,
30
+ EPISODE_RENDER_V1,
31
+ WeaveConfigBodyV1,
32
+ SourceMediaBodyV1,
33
+ VoiceAssignmentBodyV1,
34
+ NarrationRenderBodyV1,
35
+ SegmentExtractionBodyV1,
36
+ EpisodeRenderBodyV1,
37
+ RENDER_SCHEMAS,
38
+ )
39
+ from braidio.bodies._tiers import TIERS, register_tiers # noqa: F401
40
+
41
+ # All schema URIs braidio registers (domain + render).
42
+ SCHEMA_URIS: dict = {**DOMAIN_SCHEMAS, **RENDER_SCHEMAS}
@@ -0,0 +1,120 @@
1
+ """Generic authoring body schemas for a commentary-weave production.
2
+
3
+ The media-agnostic domain vocabulary: curated commentary, cited sources,
4
+ playable media clips, narrative beats, and the episode container. Registered
5
+ with lacing on import. (Consumer-specific schemas — e.g. Hamilton's Genius
6
+ ``song``/``lyric-line``/``referent``/``annotation`` — live in the consumer.)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal, Optional
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from lacing.schema import register_body_schema
16
+
17
+ COMMENTARY_V1 = "annot://schema/commentary/v1"
18
+ SOURCE_V1 = "annot://schema/source/v1"
19
+ AUDIO_CLIP_V1 = "annot://schema/audio-clip/v1"
20
+ NARRATIVE_BEAT_V1 = "annot://schema/narrative-beat/v1"
21
+ EPISODE_V1 = "annot://schema/episode/v1"
22
+
23
+ CommentaryFacet = Literal["historical", "musical", "biographical", "production"]
24
+ ClipRights = Literal["copyrighted", "owned-local", "public-domain"]
25
+ """Rights posture of a playable segment: ``owned-local`` plays only in the
26
+ personal cut; ``copyrighted`` never renders to audio; ``public-domain`` is safe."""
27
+
28
+
29
+ class CommentaryBodyV1(BaseModel):
30
+ """A curated/generated commentary note, grounded in ``source`` nodes."""
31
+
32
+ model_config = {"frozen": True, "extra": "forbid"}
33
+
34
+ text: str = Field(..., description="The commentary, as narrated/derived.")
35
+ facet: CommentaryFacet = Field(..., description="Lens: historical/musical/…")
36
+ source_ids: tuple[str, ...] = Field(
37
+ default_factory=tuple,
38
+ description="Ids of the source/v1 nodes this draws on.",
39
+ )
40
+ generated_by: str = Field(
41
+ ..., description="'human:<handle>' or 'agent:<model>@<hash>'."
42
+ )
43
+
44
+
45
+ class SourceBodyV1(BaseModel):
46
+ """A citable source node — primary material, a book, a web page."""
47
+
48
+ model_config = {"frozen": True, "extra": "forbid"}
49
+
50
+ kind: str = Field(
51
+ ..., description="Open source class (e.g. 'founders-online', 'book')."
52
+ )
53
+ title: str = Field(..., description="Source title / heading.")
54
+ citation: str = Field(..., description="Human-readable citation string.")
55
+ url: str = Field("", description="Canonical URL, if any.")
56
+ public_domain: bool = Field(..., description="True if usable in the published cut.")
57
+ excerpt: Optional[str] = Field(None, description="Optional quoted excerpt.")
58
+
59
+
60
+ class AudioClipBodyV1(BaseModel):
61
+ """A playable media segment. The interval is on the annotation's ``MediaRef``;
62
+ this body carries clip metadata and the load-bearing ``rights`` flag."""
63
+
64
+ model_config = {"frozen": True, "extra": "forbid"}
65
+
66
+ source_node_id: str = Field(
67
+ ..., description="Id of the source-media/song node this clip cuts."
68
+ )
69
+ label: str = Field(..., description="Human label, e.g. 'opening 8 bars'.")
70
+ rights: ClipRights = Field(
71
+ ..., description="Rights posture (drives render profile)."
72
+ )
73
+ gain_db: Optional[float] = Field(None, description="Optional gain (dB) when mixed.")
74
+ fade: Optional[tuple[float, float]] = Field(
75
+ None,
76
+ description="Optional (fade_in_s, fade_out_s) applied when this clip plays.",
77
+ )
78
+
79
+
80
+ class NarrativeBeatBodyV1(BaseModel):
81
+ """One unit of the script: spoken narration, ordered, optionally playing a clip."""
82
+
83
+ model_config = {"frozen": True, "extra": "forbid"}
84
+
85
+ beat_id: str = Field(..., description="Zero-padded ordering key (e.g. '0007').")
86
+ text: str = Field(..., description="Spoken narration text.")
87
+ style: Optional[str] = Field(
88
+ None, description="Optional delivery/voice style hint."
89
+ )
90
+ draws_on: tuple[str, ...] = Field(
91
+ default_factory=tuple,
92
+ description="Ids this beat is derived from (commentary / source / clip nodes).",
93
+ )
94
+ plays_clip: Optional[str] = Field(
95
+ None, description="Id of an audio-clip/v1 to play here."
96
+ )
97
+
98
+
99
+ class EpisodeBodyV1(BaseModel):
100
+ """An ordered container of narrative beats (and the clips they reference)."""
101
+
102
+ model_config = {"frozen": True, "extra": "forbid"}
103
+
104
+ title: str = Field(..., description="Episode title.")
105
+ ordered_member_ids: tuple[str, ...] = Field(
106
+ default_factory=tuple,
107
+ description="Ids of narrative-beat nodes, in play order.",
108
+ )
109
+
110
+
111
+ DOMAIN_SCHEMAS: dict[str, type[BaseModel]] = {
112
+ COMMENTARY_V1: CommentaryBodyV1,
113
+ SOURCE_V1: SourceBodyV1,
114
+ AUDIO_CLIP_V1: AudioClipBodyV1,
115
+ NARRATIVE_BEAT_V1: NarrativeBeatBodyV1,
116
+ EPISODE_V1: EpisodeBodyV1,
117
+ }
118
+
119
+ for _uri, _model in DOMAIN_SCHEMAS.items():
120
+ register_body_schema(_uri, _model)
@@ -0,0 +1,107 @@
1
+ """Render-provenance body schemas — record the render *choices* + outputs.
2
+
3
+ These make a render traceable and **partially re-renderable**: each output node
4
+ records what it was derived from (via the annotation's
5
+ ``provenance.was_derived_from``) plus a **cache key** hashing the inputs that
6
+ actually affect the audio. Change one beat or one parameter → only its
7
+ descendants are stale (see :mod:`braidio.provenance`). See the design in
8
+ ``misc/docs/Extraction and nw-App Design.md`` and nw's render-provenance doc.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Optional
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from lacing.schema import register_body_schema
18
+
19
+ WEAVE_CONFIG_V1 = "annot://schema/weave-config/v1"
20
+ SOURCE_MEDIA_V1 = "annot://schema/source-media/v1"
21
+ VOICE_ASSIGNMENT_V1 = "annot://schema/voice-assignment/v1"
22
+ NARRATION_RENDER_V1 = "annot://schema/narration-render/v1"
23
+ SEGMENT_EXTRACTION_V1 = "annot://schema/segment-extraction/v1"
24
+ EPISODE_RENDER_V1 = "annot://schema/episode-render/v1"
25
+
26
+
27
+ class WeaveConfigBodyV1(BaseModel):
28
+ """A frozen snapshot of every render choice (``WeaveConfig.to_dict()``)."""
29
+
30
+ model_config = {"frozen": True, "extra": "forbid"}
31
+
32
+ config: dict[str, Any] = Field(..., description="The full WeaveConfig snapshot.")
33
+
34
+
35
+ class SourceMediaBodyV1(BaseModel):
36
+ """A pointer to an imported source asset (its content-addressed artifact)."""
37
+
38
+ model_config = {"frozen": True, "extra": "forbid"}
39
+
40
+ label: str = Field(
41
+ ..., description="Human label for the source (e.g. a song title)."
42
+ )
43
+ asset_id: str = Field(
44
+ ..., description="lacing Artifact asset_id of the source media."
45
+ )
46
+ rights: str = Field("owned-local", description="Rights posture of the source.")
47
+
48
+
49
+ class VoiceAssignmentBodyV1(BaseModel):
50
+ """Which voice a turn got (+ pool + seed) — a render choice."""
51
+
52
+ model_config = {"frozen": True, "extra": "forbid"}
53
+
54
+ voice_id: str = Field(..., description="Voice id used for this turn.")
55
+ pool_label: str = Field("single", description="Pool the voice was drawn from.")
56
+ seed: int = Field(0, description="Assignment seed (for reproducibility).")
57
+
58
+
59
+ class NarrationRenderBodyV1(BaseModel):
60
+ """The audio Artifact for one synthesized narration turn + its cache key."""
61
+
62
+ model_config = {"frozen": True, "extra": "forbid"}
63
+
64
+ cache_key: str = Field(..., description="hash(text, voice, model, settings).")
65
+ artifact_id: Optional[str] = Field(
66
+ None, description="lacing Artifact asset_id (once rendered)."
67
+ )
68
+ duration_s: float = Field(0.0, description="Rendered duration, seconds.")
69
+
70
+
71
+ class SegmentExtractionBodyV1(BaseModel):
72
+ """The cut+padded audio Artifact for one segment + its cache key."""
73
+
74
+ model_config = {"frozen": True, "extra": "forbid"}
75
+
76
+ cache_key: str = Field(
77
+ ..., description="hash(source asset, start, end, pads, fades)."
78
+ )
79
+ start_s: float = Field(..., description="Padded extraction start (seconds).")
80
+ end_s: float = Field(..., description="Padded extraction end (seconds).")
81
+ artifact_id: Optional[str] = Field(None, description="lacing Artifact asset_id.")
82
+
83
+
84
+ class EpisodeRenderBodyV1(BaseModel):
85
+ """The assembled output Artifact for an episode + its profile."""
86
+
87
+ model_config = {"frozen": True, "extra": "forbid"}
88
+
89
+ profile: str = Field(..., description="Render profile: personal | published.")
90
+ ordered_member_ids: tuple[str, ...] = Field(
91
+ default_factory=tuple, description="Ids of the member render nodes, in order."
92
+ )
93
+ artifact_id: Optional[str] = Field(None, description="lacing Artifact of the mix.")
94
+ duration_s: float = Field(0.0, description="Total duration, seconds.")
95
+
96
+
97
+ RENDER_SCHEMAS: dict[str, type[BaseModel]] = {
98
+ WEAVE_CONFIG_V1: WeaveConfigBodyV1,
99
+ SOURCE_MEDIA_V1: SourceMediaBodyV1,
100
+ VOICE_ASSIGNMENT_V1: VoiceAssignmentBodyV1,
101
+ NARRATION_RENDER_V1: NarrationRenderBodyV1,
102
+ SEGMENT_EXTRACTION_V1: SegmentExtractionBodyV1,
103
+ EPISODE_RENDER_V1: EpisodeRenderBodyV1,
104
+ }
105
+
106
+ for _uri, _model in RENDER_SCHEMAS.items():
107
+ register_body_schema(_uri, _model)
@@ -0,0 +1,43 @@
1
+ """Generic tiers for a commentary-weave production + its render nodes.
2
+
3
+ Standoff layers (no temporal nesting); a consumer adds its own structural spine
4
+ (e.g. Hamilton's ``songs → sections → lyric-lines → words``). Add to a store
5
+ with :func:`register_tiers`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from lacing.tier import Tier
11
+
12
+ # Authoring layers.
13
+ _COMMENTARY = Tier("commentary")
14
+ _SOURCES = Tier("sources")
15
+ _AUDIO_CLIPS = Tier("audio-clips")
16
+ _NARRATIVE_BEATS = Tier("narrative-beats")
17
+ # Render-provenance layers.
18
+ _WEAVE_CONFIGS = Tier("weave-configs")
19
+ _SOURCE_MEDIA = Tier("source-media")
20
+ _VOICE_ASSIGNMENTS = Tier("voice-assignments")
21
+ _NARRATION_RENDERS = Tier("narration-renders")
22
+ _SEGMENT_EXTRACTIONS = Tier("segment-extractions")
23
+ _EPISODE_RENDERS = Tier("episode-renders")
24
+
25
+ TIERS: tuple[Tier, ...] = (
26
+ _COMMENTARY,
27
+ _SOURCES,
28
+ _AUDIO_CLIPS,
29
+ _NARRATIVE_BEATS,
30
+ _WEAVE_CONFIGS,
31
+ _SOURCE_MEDIA,
32
+ _VOICE_ASSIGNMENTS,
33
+ _NARRATION_RENDERS,
34
+ _SEGMENT_EXTRACTIONS,
35
+ _EPISODE_RENDERS,
36
+ )
37
+ """braidio's generic + render tiers."""
38
+
39
+
40
+ def register_tiers(store) -> None:
41
+ """Add every tier in :data:`TIERS` to ``store`` (has ``add_tier``)."""
42
+ for tier in TIERS:
43
+ store.add_tier(tier)
braidio/compose.py ADDED
@@ -0,0 +1,67 @@
1
+ """Config-driven narration composition (#20) — the reusable entrypoint.
2
+
3
+ Turn a list of narration segments + a :class:`WeaveConfig` into audio. Dispatches
4
+ single-voice and multi-voice uniformly (single = a pool of one), reading *every*
5
+ knob from the config. This is the seed of the reusable weave engine (#18/#19):
6
+ it has no Hamilton-specifics — it takes plain text segments and a config.
7
+
8
+ Clip weaving (padded extraction + ducking) is composed separately (#21); this
9
+ module renders the narration track.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from braidio.multivoice import POOL_MANY, Voice, render_multivoice
17
+ from braidio.tts import DEFAULT_VOICE_ID
18
+ from braidio.weave_config import WeaveConfig
19
+
20
+ # Registry so pooled ids render with human-readable turn labels; unknown ids
21
+ # fall back to the id itself.
22
+ _KNOWN_VOICES: dict[str, Voice] = {v.id: v for v in POOL_MANY}
23
+ _KNOWN_VOICES.setdefault(
24
+ DEFAULT_VOICE_ID, Voice(DEFAULT_VOICE_ID, "George", "M", "British", "storyteller")
25
+ )
26
+
27
+
28
+ def _pool_from_config(config: WeaveConfig) -> list[Voice]:
29
+ return [_KNOWN_VOICES.get(vid, Voice(vid, vid[:6], "?")) for vid in config.voices]
30
+
31
+
32
+ def compose_narration(
33
+ segments: list[str],
34
+ config: WeaveConfig,
35
+ *,
36
+ out_path: str | Path,
37
+ api_key: str | None = None,
38
+ work_dir: str | Path = "data/tts/compose",
39
+ ) -> list[tuple[Voice, str]]:
40
+ """Render ``segments`` under ``config`` → ``out_path``.
41
+
42
+ Single-voice and multi-voice go through the same turn-based path (a single
43
+ voice is just a one-voice pool). Returns the ``(voice, turn_text)``
44
+ assignment for reporting / provenance.
45
+
46
+ ``api_key`` is an optional per-request ElevenLabs key threaded to
47
+ :func:`braidio.multivoice.render_multivoice` (and thence every synthesized
48
+ turn); ``None`` (default) keeps the ``$ELEVENLABS_API_KEY`` fallback.
49
+ """
50
+ return render_multivoice(
51
+ segments,
52
+ _pool_from_config(config),
53
+ out_path=out_path,
54
+ api_key=api_key,
55
+ work_dir=work_dir,
56
+ seed=config.voice_seed,
57
+ min_turn=config.min_turn,
58
+ max_turn=config.max_turn,
59
+ avoid_immediate_repeat=config.avoid_immediate_repeat,
60
+ model_id=config.model_id,
61
+ base_settings=dict(config.voice_settings),
62
+ speed_base=config.speed_base,
63
+ speed_jitter=config.speed_jitter,
64
+ crossfade_s=config.crossfade_s,
65
+ gap_s=config.gap_turn_s,
66
+ target_lufs=config.target_lufs,
67
+ )