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/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
|