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/sources.py ADDED
@@ -0,0 +1,249 @@
1
+ """Segment sources: resolve a *reference* to a cuttable ``[start, end]`` window.
2
+
3
+ A production weaves in extracted segments of source media. *How* a reference
4
+ (a lyric quote, an audiobook passage, a news phrase, an SFX cue) maps to a
5
+ concrete ``[start, end]`` span of a source asset is pluggable via the
6
+ :class:`SegmentSource` protocol — the weave engine never needs to know it is
7
+ lyrics.
8
+
9
+ This module ships one generic implementation: :class:`TimedLineSegmentSource`,
10
+ a token-F1 matcher over time-aligned lines (:class:`TimedLine`). It answers a
11
+ reference by the best contiguous run of lines — handling exact, sub-line, and
12
+ multi-line references. Consumers bind it to their own timed lines + asset (e.g.
13
+ Hamilton binds LRCLIB line timings + owned song audio).
14
+
15
+ Also provides the lower-level resolver (:func:`find_segment`, :func:`load_timing`)
16
+ and the resolve-and-cut convenience (:func:`cut_quote`) used directly by the
17
+ Hamilton pilot; new code should prefer the :class:`SegmentSource` protocol +
18
+ :func:`braidio.weave.extract_padded`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import shutil
26
+ import subprocess
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Protocol, runtime_checkable
30
+
31
+ _WS_RE = re.compile(r"\s+")
32
+ _PUNCT_RE = re.compile(r"[^\w\s]")
33
+
34
+
35
+ def _norm(text: str) -> str:
36
+ return _WS_RE.sub(" ", _PUNCT_RE.sub(" ", text.lower())).strip()
37
+
38
+
39
+ def _tokens(text: str) -> list[str]:
40
+ return _norm(text).split()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TimedLine:
45
+ """A source line with a ``[start_s, end_s)`` window (end may be None = tail)."""
46
+
47
+ index: int
48
+ start_s: float
49
+ end_s: float | None
50
+ text: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Segment:
55
+ """A resolved window for a reference, with the matched line span."""
56
+
57
+ start_s: float
58
+ end_s: float
59
+ score: float # token-F1 of the matched run against the reference (0..1)
60
+ line_start: int
61
+ line_end: int
62
+ matched_text: str
63
+
64
+ @property
65
+ def duration_s(self) -> float:
66
+ return round(self.end_s - self.start_s, 3)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ResolvedSegment:
71
+ """A cuttable span of a source asset — what a :class:`SegmentSource` returns."""
72
+
73
+ asset_path: Path
74
+ start_s: float
75
+ end_s: float
76
+ score: float = 1.0
77
+ matched_text: str = ""
78
+
79
+ @property
80
+ def duration_s(self) -> float:
81
+ return round(self.end_s - self.start_s, 3)
82
+
83
+
84
+ @runtime_checkable
85
+ class SegmentSource(Protocol):
86
+ """Resolve an opaque ``reference`` to a :class:`ResolvedSegment` (or None)."""
87
+
88
+ def resolve(self, reference: str) -> ResolvedSegment | None: ...
89
+
90
+
91
+ def load_timing(path: str | Path) -> list[TimedLine]:
92
+ """Load a ``{lines: [{index,start_s,end_s,text}]}`` JSON into `TimedLine`s."""
93
+ rec = json.loads(Path(path).read_text())
94
+ lines = rec["lines"] if isinstance(rec, dict) else rec
95
+ return [
96
+ TimedLine(
97
+ index=int(l["index"]),
98
+ start_s=float(l["start_s"]),
99
+ end_s=None if l.get("end_s") is None else float(l["end_s"]),
100
+ text=l["text"],
101
+ )
102
+ for l in lines
103
+ ]
104
+
105
+
106
+ def _line_end(lines: list[TimedLine], i: int, *, song_end_s: float | None) -> float:
107
+ line = lines[i]
108
+ if line.end_s is not None:
109
+ return line.end_s
110
+ if i + 1 < len(lines):
111
+ return lines[i + 1].start_s
112
+ if song_end_s is not None:
113
+ return song_end_s
114
+ return line.start_s + 3.0
115
+
116
+
117
+ def find_segment(
118
+ lines: list[TimedLine],
119
+ quote: str,
120
+ *,
121
+ max_span: int = 12,
122
+ min_score: float = 0.5,
123
+ song_end_s: float | None = None,
124
+ ) -> Segment | None:
125
+ """Best contiguous run of timed lines matching ``quote``, or ``None``.
126
+
127
+ Scores every run ``lines[i..j]`` (up to ``max_span`` lines) by token F1
128
+ against the reference's tokens and returns the highest-scoring run clearing
129
+ ``min_score``. Handles single-line, sub-line, and multi-line references.
130
+ """
131
+ q = set(_tokens(quote))
132
+ if not q or not lines:
133
+ return None
134
+
135
+ best: Segment | None = None
136
+ for i in range(len(lines)):
137
+ acc: list[str] = []
138
+ for j in range(i, min(i + max_span, len(lines))):
139
+ acc += _tokens(lines[j].text)
140
+ if not acc:
141
+ continue
142
+ aset = set(acc)
143
+ inter = len(q & aset)
144
+ if not inter:
145
+ continue
146
+ recall = inter / len(q)
147
+ precision = inter / len(aset)
148
+ f1 = 2 * precision * recall / (precision + recall)
149
+ if best is None or f1 > best.score:
150
+ best = Segment(
151
+ start_s=lines[i].start_s,
152
+ end_s=_line_end(lines, j, song_end_s=song_end_s),
153
+ score=round(f1, 4),
154
+ line_start=lines[i].index,
155
+ line_end=lines[j].index,
156
+ matched_text=" / ".join(lines[k].text for k in range(i, j + 1)),
157
+ )
158
+ if best is None or best.score < min_score:
159
+ return None
160
+ return best
161
+
162
+
163
+ class TimedLineSegmentSource:
164
+ """A :class:`SegmentSource` over time-aligned lines + one source asset.
165
+
166
+ Binds the generic :func:`find_segment` matcher to a concrete asset so the
167
+ weave engine can ``resolve(reference) -> ResolvedSegment``.
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ *,
173
+ lines: list[TimedLine],
174
+ asset_path: str | Path,
175
+ song_end_s: float | None = None,
176
+ min_score: float = 0.5,
177
+ ) -> None:
178
+ self._lines = lines
179
+ self._asset = Path(asset_path)
180
+ self._song_end = song_end_s
181
+ self._min_score = min_score
182
+
183
+ def resolve(self, reference: str) -> ResolvedSegment | None:
184
+ seg = find_segment(
185
+ self._lines, reference, min_score=self._min_score, song_end_s=self._song_end
186
+ )
187
+ if seg is None:
188
+ return None
189
+ return ResolvedSegment(
190
+ asset_path=self._asset,
191
+ start_s=seg.start_s,
192
+ end_s=seg.end_s,
193
+ score=seg.score,
194
+ matched_text=seg.matched_text,
195
+ )
196
+
197
+
198
+ def _require_ffmpeg() -> None:
199
+ if shutil.which("ffmpeg") is None:
200
+ raise RuntimeError("ffmpeg not found on PATH (brew install ffmpeg).")
201
+
202
+
203
+ def cut_quote(
204
+ audio_path: str | Path,
205
+ lines: list[TimedLine],
206
+ quote: str,
207
+ out_path: str | Path,
208
+ *,
209
+ pad_pre_s: float = 0.15,
210
+ pad_post_s: float = 0.35,
211
+ fade_s: float = 0.04,
212
+ min_score: float = 0.5,
213
+ song_end_s: float | None = None,
214
+ ) -> Segment:
215
+ """Resolve ``quote`` → segment and cut it from ``audio_path`` (pad + fades).
216
+
217
+ Convenience combining :func:`find_segment` + an ffmpeg cut. Returns the
218
+ resolved :class:`Segment` (raises ``LookupError`` if unmatched). New code
219
+ should prefer a :class:`SegmentSource` + :func:`braidio.weave.extract_padded`.
220
+ """
221
+ _require_ffmpeg()
222
+ seg = find_segment(lines, quote, min_score=min_score, song_end_s=song_end_s)
223
+ if seg is None:
224
+ raise LookupError(f"Could not align reference to audio: {quote[:60]!r}")
225
+
226
+ start = max(0.0, seg.start_s - pad_pre_s)
227
+ end = seg.end_s + pad_post_s
228
+ dur = end - start
229
+ out = Path(out_path)
230
+ out.parent.mkdir(parents=True, exist_ok=True)
231
+ fade_out_start = max(0.0, dur - fade_s)
232
+ subprocess.run(
233
+ [
234
+ "ffmpeg",
235
+ "-y",
236
+ "-ss",
237
+ f"{start:.3f}",
238
+ "-i",
239
+ str(audio_path),
240
+ "-t",
241
+ f"{dur:.3f}",
242
+ "-af",
243
+ f"afade=t=in:st=0:d={fade_s},afade=t=out:st={fade_out_start:.3f}:d={fade_s}",
244
+ str(out),
245
+ ],
246
+ check=True,
247
+ capture_output=True,
248
+ )
249
+ return seg
braidio/tts.py ADDED
@@ -0,0 +1,163 @@
1
+ """ElevenLabs narration synthesis.
2
+
3
+ Thin wrapper over :func:`mixing.text_to_speech` (the ElevenLabs entry point,
4
+ with on-disk caching) applying a voice preset: the ``eleven_multilingual_v2``
5
+ quality model and a locked voice + settings so a whole production sounds like
6
+ one narrator.
7
+
8
+ Voice defaults to "George — Warm, Captivating Storyteller"; override with the
9
+ ``BRAIDIO_TTS_VOICE`` env var (:data:`VOICE_ENV_VAR`) or the ``voice_id`` arg.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ from mixing import text_to_speech
18
+
19
+ # "George — Warm, Captivating Storyteller" (ElevenLabs premade voice).
20
+ DEFAULT_VOICE_ID = "JBFqnCBsd6RMkjVDRZzb"
21
+ DEFAULT_MODEL_ID = "eleven_multilingual_v2"
22
+ VOICE_ENV_VAR = "BRAIDIO_TTS_VOICE"
23
+
24
+ # Preset from research: neutral-expressive documentary narration.
25
+ DEFAULT_VOICE_SETTINGS: dict[str, float | bool] = {
26
+ "stability": 0.5,
27
+ "similarity_boost": 0.75,
28
+ "style": 0.0,
29
+ "use_speaker_boost": True,
30
+ "speed": 0.97,
31
+ }
32
+
33
+
34
+ def resolve_voice_id(voice_id: str | None = None) -> str:
35
+ """Voice id from arg → :data:`VOICE_ENV_VAR` env → default."""
36
+ return voice_id or os.environ.get(VOICE_ENV_VAR) or DEFAULT_VOICE_ID
37
+
38
+
39
+ def narrate(
40
+ text: str,
41
+ out_path: str | Path,
42
+ *,
43
+ api_key: str | None = None,
44
+ voice_id: str | None = None,
45
+ model_id: str = DEFAULT_MODEL_ID,
46
+ voice_settings: dict | None = None,
47
+ output_format: str = "mp3_44100_128",
48
+ refresh: bool = False,
49
+ ) -> Path:
50
+ """Synthesize ``text`` to ``out_path`` (mp3). Returns the path.
51
+
52
+ Caching is handled by ``mixing.text_to_speech`` (keyed on text+voice+model);
53
+ pass ``refresh=True`` to regenerate.
54
+
55
+ ``api_key`` is an optional per-request ElevenLabs key: when given it wins
56
+ over the environment; when ``None`` (default) resolution falls back to
57
+ ``$ELEVENLABS_API_KEY`` (unchanged behavior). This is what lets a caller
58
+ thread a per-user BYO key without touching the process environment.
59
+ """
60
+ audio = text_to_speech(
61
+ text,
62
+ resolve_voice_id(voice_id),
63
+ api_key=api_key,
64
+ model_id=model_id,
65
+ output_format=output_format,
66
+ voice_settings=voice_settings or DEFAULT_VOICE_SETTINGS,
67
+ refresh=refresh,
68
+ )
69
+ out = Path(out_path)
70
+ out.parent.mkdir(parents=True, exist_ok=True)
71
+ out.write_bytes(audio)
72
+ return out
73
+
74
+
75
+ DIALOGUE_MODEL_ID = "eleven_v3"
76
+ DIALOGUE_CACHE_ENV_KEY = "BRAIDIO_DIALOGUE_CACHE_DIR"
77
+
78
+
79
+ def _dialogue_cache_dir():
80
+ from mixing import _cache
81
+
82
+ return _cache.default_cache_dir("braidio-dialogue", env_key=DIALOGUE_CACHE_ENV_KEY)
83
+
84
+
85
+ def text_to_dialogue(
86
+ turns,
87
+ *,
88
+ model_id: str = DIALOGUE_MODEL_ID,
89
+ output_format: str = "mp3_44100_128",
90
+ settings: dict | None = None,
91
+ seed: int | None = None,
92
+ api_key: str | None = None,
93
+ cache=True,
94
+ refresh: bool = False,
95
+ ) -> bytes:
96
+ """Synthesize a multi-speaker exchange in ONE pass (ElevenLabs Text-to-Dialogue).
97
+
98
+ Unlike per-line :func:`narrate`, this renders the whole conversation together
99
+ so prosody is conditioned across turns — the key to sounding like people
100
+ *talking to each other* rather than alternating monologues (see braidio#1 /
101
+ ``docs/research/conversational-vs-narration-tts.md``). ``eleven_v3`` only.
102
+
103
+ **Cached** (like :func:`narrate`/``mixing.text_to_speech``): the result is
104
+ keyed on the SHA-256 of every parameter that affects the audio (turns,
105
+ voices, model, settings, seed, format). A cache hit returns the *same* bytes
106
+ instantly — no API call, deterministic master, and the basis for partial
107
+ re-render (change one exchange → only its key changes). ``cache=True``
108
+ (default) uses :data:`DIALOGUE_CACHE_ENV_KEY` / the default cache dir;
109
+ ``cache=False`` disables it; a path uses that dir. ``refresh=True`` forces a
110
+ re-render (to re-roll a v3 take). Because v3 is nondeterministic, caching a
111
+ seedless call freezes one random take — pass a ``seed`` for reproducibility.
112
+
113
+ Args:
114
+ turns: ordered ``(voice_id, text)`` pairs (or ``{"voice_id", "text"}``).
115
+ Keep each request under ~2000 chars total (API limit).
116
+ settings: optional model settings dict (e.g. ``{"stability": 0.45}``).
117
+
118
+ Returns: raw audio bytes in ``output_format``.
119
+ """
120
+ import json
121
+
122
+ from mixing import _cache
123
+
124
+ inputs = []
125
+ for t in turns:
126
+ if isinstance(t, (tuple, list)):
127
+ inputs.append({"voice_id": t[0], "text": t[1]})
128
+ else:
129
+ inputs.append({"voice_id": t["voice_id"], "text": t["text"]})
130
+
131
+ cache_dir = _cache.resolve_cache_dir(cache, default_factory=_dialogue_cache_dir)
132
+ key = None
133
+ if cache_dir is not None:
134
+ key = _cache.sha256_key(
135
+ "text_to_dialogue",
136
+ json.dumps(inputs, sort_keys=True, ensure_ascii=False),
137
+ model_id,
138
+ output_format,
139
+ json.dumps(settings or {}, sort_keys=True),
140
+ "" if seed is None else str(seed),
141
+ )
142
+ if not refresh:
143
+ cached = _cache.read_cache(cache_dir, key, suffix=".audio")
144
+ if cached is not None:
145
+ return cached
146
+
147
+ from elevenlabs.client import ElevenLabs
148
+
149
+ client = ElevenLabs(
150
+ api_key=api_key
151
+ or os.environ.get("ELEVENLABS_API_KEY")
152
+ or os.environ.get("ELEVEN_API_KEY")
153
+ )
154
+ kwargs = {"inputs": inputs, "model_id": model_id, "output_format": output_format}
155
+ if settings is not None:
156
+ kwargs["settings"] = settings
157
+ if seed is not None:
158
+ kwargs["seed"] = seed
159
+ audio = b"".join(client.text_to_dialogue.convert(**kwargs))
160
+
161
+ if cache_dir is not None and key is not None:
162
+ _cache.write_cache(cache_dir, key, audio, suffix=".audio")
163
+ return audio
braidio/weave.py ADDED
@@ -0,0 +1,260 @@
1
+ """Weave narration + audio clips on a timeline (#21) — the reusable mix engine.
2
+
3
+ Two generic, Hamilton-agnostic primitives:
4
+
5
+ - :func:`extract_padded` — extract ``[start, end]`` from a source asset but
6
+ **padded** by pre/post-roll (so the words are captured cleanly) with in/out
7
+ fades on the padded edges. The padded, faded edges are what we tuck under the
8
+ neighbouring narration.
9
+ - :func:`weave_timeline` — place ordered narration/clip parts on a timeline
10
+ where each clip **overlaps its neighbours** by ``clip_edge_overlap_s`` and its
11
+ faded padded edges duck under the speech, so **speech stays dominant**, then
12
+ mix (``amix`` sum) and loudness-normalize.
13
+
14
+ This is the audio counterpart of a video timeline; it consumes plain file paths
15
+ + numbers, no Genius/lyrics knowledge (that resolution lives in
16
+ ``graph/align.py`` and the Hamilton adapters). Ducking here is achieved by
17
+ fade-shaped overlap; a dynamic sidechain duck (``duck_db``) is a documented
18
+ refinement (see #21 / the research).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import shutil
25
+ import subprocess
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+
30
+ def _require_ffmpeg() -> None:
31
+ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
32
+ raise RuntimeError("ffmpeg/ffprobe not found on PATH (brew install ffmpeg).")
33
+
34
+
35
+ def duration_s(path: str | Path) -> float:
36
+ _require_ffmpeg()
37
+ r = subprocess.run(
38
+ ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)],
39
+ capture_output=True,
40
+ text=True,
41
+ check=True,
42
+ )
43
+ return float(json.loads(r.stdout)["format"]["duration"])
44
+
45
+
46
+ def extract_padded(
47
+ asset_path: str | Path,
48
+ start_s: float,
49
+ end_s: float,
50
+ out_path: str | Path,
51
+ *,
52
+ pre_roll_s: float = 0.4,
53
+ post_roll_s: float = 0.3,
54
+ fade_in_s: float = 0.5,
55
+ fade_out_s: float = 0.8,
56
+ ) -> Path:
57
+ """Extract ``[start_s-pre_roll, end_s+post_roll]`` with in/out fades.
58
+
59
+ The target words sit in the middle; the padded, faded head/tail are the
60
+ parts that overlap (tuck under) neighbouring narration in the weave.
61
+ """
62
+ _require_ffmpeg()
63
+ start = max(0.0, start_s - pre_roll_s)
64
+ end = end_s + post_roll_s
65
+ dur = max(0.05, end - start)
66
+ fo_start = max(0.0, dur - fade_out_s)
67
+ out = Path(out_path)
68
+ out.parent.mkdir(parents=True, exist_ok=True)
69
+ subprocess.run(
70
+ [
71
+ "ffmpeg",
72
+ "-y",
73
+ "-ss",
74
+ f"{start:.3f}",
75
+ "-i",
76
+ str(asset_path),
77
+ "-t",
78
+ f"{dur:.3f}",
79
+ "-af",
80
+ f"afade=t=in:st=0:d={fade_in_s},afade=t=out:st={fo_start:.3f}:d={fade_out_s}",
81
+ str(out),
82
+ ],
83
+ check=True,
84
+ capture_output=True,
85
+ )
86
+ return out
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class TimelineItem:
91
+ """One part on the weave timeline.
92
+
93
+ ``placement`` ``"sequential"`` (default — narration, and clean ``before`` /
94
+ ``after`` clips) lays the part in its own slot. ``"under"`` overlays the part
95
+ beneath the *following* sequential part (it does not consume its own slot),
96
+ attenuated by ``duck_db`` — a ducked underlay (a clip talked over, or later a
97
+ music bed).
98
+ """
99
+
100
+ kind: str # "narration" | "clip"
101
+ path: str
102
+ placement: str = "sequential" # "sequential" | "under"
103
+ duck_db: float = 0.0 # attenuation applied when placement == "under" (e.g. -15)
104
+
105
+
106
+ def layout_placed(
107
+ kinds: list[str],
108
+ durs: list[float],
109
+ placements: list[str],
110
+ *,
111
+ clip_edge_overlap_s: float,
112
+ narration_crossfade_s: float,
113
+ ) -> list[float]:
114
+ """Start offset (s) of each part, placement-aware. Pure function.
115
+
116
+ Sequential parts advance a running cursor: a clip (or a part following a clip)
117
+ starts ``clip_edge_overlap_s`` before the cursor so its faded edges tuck under
118
+ the neighbour; narration-after-narration overlaps by ``narration_crossfade_s``.
119
+ An ``"under"`` part starts *at* the cursor (concurrent with the next
120
+ sequential part) and does **not** advance it — so it overlays what follows.
121
+ Clamped ≥ 0.
122
+ """
123
+ starts: list[float] = [0.0] * len(kinds)
124
+ cursor = 0.0
125
+ prev_seq_kind: str | None = None
126
+ for i, kind in enumerate(kinds):
127
+ if placements[i] == "under":
128
+ starts[i] = (
129
+ cursor # overlay the following sequential part; cursor unchanged
130
+ )
131
+ continue
132
+ if prev_seq_kind is None:
133
+ start = 0.0
134
+ elif kind == "clip" or prev_seq_kind == "clip":
135
+ start = max(0.0, cursor - clip_edge_overlap_s)
136
+ else:
137
+ start = max(0.0, cursor - narration_crossfade_s)
138
+ starts[i] = start
139
+ cursor = start + durs[i]
140
+ prev_seq_kind = kind
141
+ return starts
142
+
143
+
144
+ def layout_starts(
145
+ kinds: list[str],
146
+ durs: list[float],
147
+ *,
148
+ clip_edge_overlap_s: float,
149
+ narration_crossfade_s: float,
150
+ ) -> list[float]:
151
+ """Start offset (s) of each part (all sequential). Thin wrapper over
152
+ :func:`layout_placed` — kept for callers that don't use placement."""
153
+ return layout_placed(
154
+ kinds,
155
+ durs,
156
+ ["sequential"] * len(kinds),
157
+ clip_edge_overlap_s=clip_edge_overlap_s,
158
+ narration_crossfade_s=narration_crossfade_s,
159
+ )
160
+
161
+
162
+ def weave_timeline(
163
+ items: list[TimelineItem],
164
+ out_path: str | Path,
165
+ *,
166
+ clip_edge_overlap_s: float = 0.5,
167
+ narration_crossfade_s: float = 0.12,
168
+ target_lufs: float = -16.0,
169
+ true_peak: float = -1.0,
170
+ sample_rate: int = 44100,
171
+ bed=None, # optional braidio.music.MusicBed — instrumental underscore under all
172
+ ) -> Path:
173
+ """Place items on a timeline and mix. Clips overlap neighbours by
174
+ ``clip_edge_overlap_s`` (their faded edges tuck under narration); narration
175
+ parts butt-join with a small crossfade. Returns ``out_path``.
176
+
177
+ ``bed`` (a :class:`~braidio.music.MusicBed`) lays an instrumental underscore
178
+ under the whole production: it's rendered to cover the timeline, attenuated,
179
+ and mixed in posted by ``bed.lead_in_s``. Falls back to a plain concat feel
180
+ when ``clip_edge_overlap_s == 0`` and there's nothing to overlay.
181
+ """
182
+ _require_ffmpeg()
183
+ if not items:
184
+ raise ValueError("weave_timeline needs at least one item")
185
+
186
+ durs = [duration_s(it.path) for it in items]
187
+ starts = layout_placed(
188
+ [it.kind for it in items],
189
+ durs,
190
+ [it.placement for it in items],
191
+ clip_edge_overlap_s=clip_edge_overlap_s,
192
+ narration_crossfade_s=narration_crossfade_s,
193
+ )
194
+ out = Path(out_path)
195
+ out.parent.mkdir(parents=True, exist_ok=True)
196
+
197
+ # Build one amix graph: (duck →) delay each input to its start, then sum.
198
+ inputs: list[str] = []
199
+ for it in items:
200
+ inputs += ["-i", it.path]
201
+ filters: list[str] = []
202
+ labels: list[str] = []
203
+ for i, (start, it) in enumerate(zip(starts, items)):
204
+ delay_ms = int(round(start * 1000))
205
+ lbl = f"a{i}"
206
+ # normalize every input to stereo @ sample_rate so amix keeps stereo
207
+ # (narration is mono, song clips are stereo) — else it collapses to mono.
208
+ chain = f"[{i}:a]aformat=sample_rates={sample_rate}:channel_layouts=stereo"
209
+ # ducked underlay: attenuate before delaying so it sits beneath the talk.
210
+ if it.placement == "under" and it.duck_db:
211
+ chain += f",volume={it.duck_db}dB"
212
+ chain += f",adelay={delay_ms}:all=1[{lbl}]"
213
+ filters.append(chain)
214
+ labels.append(f"[{lbl}]")
215
+
216
+ # Optional music bed: prepare it to cover the timeline, then mix it in posted.
217
+ if bed is not None:
218
+ from braidio.music import prepare_bed
219
+
220
+ total_s = max(s + d for s, d in zip(starts, durs))
221
+ bed_path = prepare_bed(
222
+ bed, total_s, out.parent / f"_bed_{out.stem}.mp3", sample_rate=sample_rate
223
+ )
224
+ idx = len(items)
225
+ inputs += ["-i", str(bed_path)]
226
+ lead_ms = int(round(bed.lead_in_s * 1000))
227
+ filters.append(
228
+ f"[{idx}:a]aformat=sample_rates={sample_rate}:channel_layouts=stereo,"
229
+ f"adelay={lead_ms}:all=1[bed]"
230
+ )
231
+ labels.append("[bed]")
232
+
233
+ n_inputs = len(labels)
234
+ mix = (
235
+ "".join(labels) + f"amix=inputs={n_inputs}:normalize=0:dropout_transition=0[m]"
236
+ )
237
+ norm = f"[m]loudnorm=I={target_lufs}:TP={true_peak}:LRA=11[out]"
238
+ filtergraph = ";".join(filters + [mix, norm])
239
+
240
+ subprocess.run(
241
+ [
242
+ "ffmpeg",
243
+ "-y",
244
+ *inputs,
245
+ "-filter_complex",
246
+ filtergraph,
247
+ "-map",
248
+ "[out]",
249
+ "-ar",
250
+ str(sample_rate),
251
+ "-ac",
252
+ "2",
253
+ "-b:a",
254
+ "192k",
255
+ str(out),
256
+ ],
257
+ check=True,
258
+ capture_output=True,
259
+ )
260
+ return out