davinci-resolve-mcp 2.68.1 → 2.69.0
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.
- package/CHANGELOG.md +213 -0
- package/README.md +46 -3
- package/docs/SKILL.md +181 -1
- package/docs/install.md +27 -1
- package/install.py +26 -1
- package/package.json +1 -1
- package/resolve-advanced/LICENSE +21 -0
- package/resolve-advanced/package.json +2 -1
- package/resolve-advanced/vendor/conform-qc/package.json +1 -0
- package/resolve-advanced/vendor/drp-format/package.json +1 -0
- package/resolve-advanced/vendor/drt-format/package.json +1 -0
- package/scripts/bridge_differential.py +22 -4
- package/scripts/doctor.py +129 -3
- package/scripts/install_resolve_bridge.py +13 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +639 -18
- package/src/utils/beat_detection.py +242 -0
- package/src/utils/bridge_differential.py +27 -1
- package/src/utils/broll_placement.py +201 -0
- package/src/utils/conform_lint.py +437 -0
- package/src/utils/edit_engine.py +633 -1
- package/src/utils/edit_handles.py +206 -0
- package/src/utils/edit_report.py +360 -0
- package/src/utils/first_impression.py +212 -0
- package/src/utils/prebalance.py +471 -0
- package/src/utils/project_journal.py +360 -0
- package/src/utils/reference_match.py +203 -0
- package/src/utils/rhythm_audit.py +252 -0
- package/src/utils/rule_of_six.py +217 -0
- package/src/utils/setup_sheet.py +121 -0
- package/src/utils/shot_assembly.py +259 -0
- package/src/utils/silence_ripple.py +89 -2
- package/src/utils/sound_density.py +253 -0
- package/src/utils/split_edits.py +180 -0
- package/src/utils/take_ranking.py +206 -0
- package/src/utils/transcript_edit.py +101 -5
- package/src/utils/turnover.py +187 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Assembly for footage that does not talk.
|
|
2
|
+
|
|
3
|
+
Everything else in this codebase finds edit points in speech — word boundaries,
|
|
4
|
+
pauses, fillers. Point that at motorsport, a music video, a live performance or
|
|
5
|
+
an event recap and it does not degrade, it inverts: engine noise reads as
|
|
6
|
+
content, a quiet straight reads as dead space, and the cuts land in all the
|
|
7
|
+
wrong places. A viewer predicted exactly that before trying it, and he was
|
|
8
|
+
right, because the tool was answering a question his footage never asked.
|
|
9
|
+
|
|
10
|
+
Speechless footage has its own structure. Not words: **shots and motion**.
|
|
11
|
+
|
|
12
|
+
- **Shot boundaries** are where the camera or the subject already changed. They
|
|
13
|
+
are the cut points the material is offering.
|
|
14
|
+
- **Motion energy** says which of those shots is doing something, and where
|
|
15
|
+
inside a shot the action peaks or resolves.
|
|
16
|
+
|
|
17
|
+
## What this does not do
|
|
18
|
+
|
|
19
|
+
It does not decide what the sequence is *about*. Ranking shots by how much is
|
|
20
|
+
moving in them is a measurement, not an edit — a static wide of a landscape may
|
|
21
|
+
be the most important shot in the piece and will score last on every metric here.
|
|
22
|
+
The output is a **string-out in a defensible order with the evidence attached**,
|
|
23
|
+
which is what an assistant hands an editor, not a cut.
|
|
24
|
+
|
|
25
|
+
## No-script mode
|
|
26
|
+
|
|
27
|
+
The same machinery answers "I have no script, just footage." Cluster what is
|
|
28
|
+
there, propose a structure, and **require approval before anything is cut** —
|
|
29
|
+
which is what the tutorial author himself concluded after watching an agent
|
|
30
|
+
freelance: handhold it, and do not let it cut without permission.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
36
|
+
|
|
37
|
+
#: Shots shorter than this are almost always flash frames or transition
|
|
38
|
+
#: artefacts rather than content, and putting them in a string-out is noise.
|
|
39
|
+
MIN_USABLE_SECONDS = 0.5
|
|
40
|
+
|
|
41
|
+
#: Motion energy below this reads as a locked-off or near-static shot. Static is
|
|
42
|
+
#: not a defect — it is a fact about the shot, reported rather than penalised.
|
|
43
|
+
STATIC_MOTION_THRESHOLD = 0.05
|
|
44
|
+
|
|
45
|
+
#: Group boundary: a gap between shots larger than this suggests a different
|
|
46
|
+
#: setup, location or time, so it starts a new cluster.
|
|
47
|
+
CLUSTER_GAP_SECONDS = 30.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _duration(shot: Mapping[str, Any]) -> float:
|
|
51
|
+
return max(0.0, float(shot.get("end_seconds", 0)) - float(shot.get("start_seconds", 0)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rank_shots(shots: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
55
|
+
"""Order shots by measurable activity, with the measurement attached.
|
|
56
|
+
|
|
57
|
+
Each shot: `{"name", "start_seconds", "end_seconds", "motion_energy"?}`.
|
|
58
|
+
`motion_energy` is 0-1; absent means unmeasured, which is reported as such
|
|
59
|
+
and never treated as zero — a shot nobody measured is not a static shot.
|
|
60
|
+
"""
|
|
61
|
+
usable, rejected = [], []
|
|
62
|
+
for shot in shots:
|
|
63
|
+
duration = _duration(shot)
|
|
64
|
+
if duration < MIN_USABLE_SECONDS:
|
|
65
|
+
rejected.append({
|
|
66
|
+
"shot": shot.get("name"),
|
|
67
|
+
"reason": f"{duration:.2f}s — under the {MIN_USABLE_SECONDS}s floor; "
|
|
68
|
+
"almost certainly a flash frame or transition artefact",
|
|
69
|
+
})
|
|
70
|
+
continue
|
|
71
|
+
motion = shot.get("motion_energy")
|
|
72
|
+
usable.append({
|
|
73
|
+
"shot": shot.get("name"),
|
|
74
|
+
"start_seconds": round(float(shot.get("start_seconds", 0)), 3),
|
|
75
|
+
"end_seconds": round(float(shot.get("end_seconds", 0)), 3),
|
|
76
|
+
"duration_seconds": round(duration, 3),
|
|
77
|
+
"motion_energy": round(float(motion), 4) if motion is not None else None,
|
|
78
|
+
"motion_measured": motion is not None,
|
|
79
|
+
"character": _character(motion, duration),
|
|
80
|
+
})
|
|
81
|
+
ranked = sorted(
|
|
82
|
+
usable,
|
|
83
|
+
key=lambda s: (s["motion_energy"] is None, -(s["motion_energy"] or 0.0), -s["duration_seconds"]),
|
|
84
|
+
)
|
|
85
|
+
for position, shot in enumerate(ranked, start=1):
|
|
86
|
+
shot["activity_rank"] = position
|
|
87
|
+
return {
|
|
88
|
+
"shots": ranked,
|
|
89
|
+
"rejected": rejected,
|
|
90
|
+
"unmeasured_count": sum(1 for s in ranked if not s["motion_measured"]),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _character(motion: Optional[float], duration: float) -> str:
|
|
95
|
+
if motion is None:
|
|
96
|
+
return "unmeasured — not the same as static"
|
|
97
|
+
if float(motion) < STATIC_MOTION_THRESHOLD:
|
|
98
|
+
return "static or locked off — a fact about the shot, not a fault"
|
|
99
|
+
if duration > 10:
|
|
100
|
+
return "sustained action"
|
|
101
|
+
return "action"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cluster_shots(
|
|
105
|
+
shots: Sequence[Mapping[str, Any]],
|
|
106
|
+
*,
|
|
107
|
+
gap_seconds: float = CLUSTER_GAP_SECONDS,
|
|
108
|
+
) -> List[Dict[str, Any]]:
|
|
109
|
+
"""Group shots into candidate sequences by temporal proximity.
|
|
110
|
+
|
|
111
|
+
A crude proxy for "these belong together" and labelled as one. Real grouping
|
|
112
|
+
is by content and intent; this groups by the clock, which correlates with
|
|
113
|
+
location and setup often enough to be a useful starting point and not often
|
|
114
|
+
enough to be trusted.
|
|
115
|
+
"""
|
|
116
|
+
ordered = sorted(shots, key=lambda s: float(s.get("start_seconds", 0)))
|
|
117
|
+
clusters: List[Dict[str, Any]] = []
|
|
118
|
+
current: List[Mapping[str, Any]] = []
|
|
119
|
+
for shot in ordered:
|
|
120
|
+
if current and float(shot.get("start_seconds", 0)) - float(current[-1].get("end_seconds", 0)) > gap_seconds:
|
|
121
|
+
clusters.append(_close_cluster(current, len(clusters) + 1))
|
|
122
|
+
current = []
|
|
123
|
+
current.append(shot)
|
|
124
|
+
if current:
|
|
125
|
+
clusters.append(_close_cluster(current, len(clusters) + 1))
|
|
126
|
+
return clusters
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _close_cluster(shots: Sequence[Mapping[str, Any]], index: int) -> Dict[str, Any]:
|
|
130
|
+
return {
|
|
131
|
+
"cluster": index,
|
|
132
|
+
"shot_count": len(shots),
|
|
133
|
+
"start_seconds": round(float(shots[0].get("start_seconds", 0)), 3),
|
|
134
|
+
"end_seconds": round(float(shots[-1].get("end_seconds", 0)), 3),
|
|
135
|
+
"shots": [s.get("name") for s in shots],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def plan_string_out(
|
|
140
|
+
shots: Sequence[Mapping[str, Any]],
|
|
141
|
+
*,
|
|
142
|
+
order: str = "chronological",
|
|
143
|
+
gap_seconds: float = CLUSTER_GAP_SECONDS,
|
|
144
|
+
) -> Dict[str, Any]:
|
|
145
|
+
"""A reviewable string-out of speechless footage. Cuts nothing.
|
|
146
|
+
|
|
147
|
+
`order`: `chronological` (default — the safest, and what an assistant hands
|
|
148
|
+
over) or `activity` (most movement first, for finding the action in a long
|
|
149
|
+
unstructured shoot).
|
|
150
|
+
"""
|
|
151
|
+
if order not in ("chronological", "activity"):
|
|
152
|
+
return {"success": False, "error": f"unknown order {order!r}; expected chronological or activity"}
|
|
153
|
+
if not shots:
|
|
154
|
+
return {"success": False, "error": "No shots supplied"}
|
|
155
|
+
|
|
156
|
+
ranked = rank_shots(shots)
|
|
157
|
+
if not ranked["shots"]:
|
|
158
|
+
return {
|
|
159
|
+
"success": False,
|
|
160
|
+
"error": "No shots long enough to use",
|
|
161
|
+
"rejected": ranked["rejected"],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
sequence = (
|
|
165
|
+
sorted(ranked["shots"], key=lambda s: s["start_seconds"])
|
|
166
|
+
if order == "chronological" else ranked["shots"]
|
|
167
|
+
)
|
|
168
|
+
clusters = cluster_shots(shots, gap_seconds=gap_seconds)
|
|
169
|
+
total = sum(s["duration_seconds"] for s in sequence)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"success": True,
|
|
173
|
+
"kind": "shot_string_out",
|
|
174
|
+
"order": order,
|
|
175
|
+
"shot_count": len(sequence),
|
|
176
|
+
"total_seconds": round(total, 2),
|
|
177
|
+
"sequence": sequence,
|
|
178
|
+
"clusters": clusters,
|
|
179
|
+
"rejected": ranked["rejected"],
|
|
180
|
+
"unmeasured_motion_count": ranked["unmeasured_count"],
|
|
181
|
+
"clustering_basis": (
|
|
182
|
+
f"temporal proximity, {gap_seconds:g}s gap — a PROXY for 'these belong "
|
|
183
|
+
"together'. Real grouping is by content and intent; this groups by the "
|
|
184
|
+
"clock, which tracks location and setup often enough to be useful and "
|
|
185
|
+
"not often enough to trust."
|
|
186
|
+
),
|
|
187
|
+
"note": (
|
|
188
|
+
f"A string-out of {len(sequence)} shots in {order} order — **not a cut**. "
|
|
189
|
+
"Speechless footage is cut from shots and motion, not from silence: a "
|
|
190
|
+
"speech gate reads engine noise as content and a quiet straight as dead "
|
|
191
|
+
"space. Ranking by movement is a measurement, not an edit — a locked-off "
|
|
192
|
+
"wide may be the most important shot here and will rank last on every "
|
|
193
|
+
"metric. Decide the order yourself; this hands you the material with the "
|
|
194
|
+
"evidence attached."
|
|
195
|
+
+ (f" {ranked['unmeasured_count']} shots have no motion measurement and "
|
|
196
|
+
"sit at the end — unmeasured is not static."
|
|
197
|
+
if ranked["unmeasured_count"] else "")
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── no-script mode ───────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def propose_structure(
|
|
206
|
+
topics: Sequence[Mapping[str, Any]],
|
|
207
|
+
*,
|
|
208
|
+
min_topic_seconds: float = 5.0,
|
|
209
|
+
) -> Dict[str, Any]:
|
|
210
|
+
"""Propose an order for material that arrived without a script.
|
|
211
|
+
|
|
212
|
+
Each topic: `{"label", "total_seconds", "clip_count", "first_seconds"?}` —
|
|
213
|
+
typically the output of clustering transcript content.
|
|
214
|
+
|
|
215
|
+
**Requires approval and says so.** The tutorial that prompted this programme
|
|
216
|
+
ended with its author's own conclusion after watching an agent freelance:
|
|
217
|
+
handhold it, and do not let it cut without permission. A structure proposed
|
|
218
|
+
from clustering is a hypothesis about what the piece is, and that is the one
|
|
219
|
+
decision least safe to take silently.
|
|
220
|
+
"""
|
|
221
|
+
if not topics:
|
|
222
|
+
return {"success": False, "error": "No topics supplied"}
|
|
223
|
+
|
|
224
|
+
usable, thin = [], []
|
|
225
|
+
for topic in topics:
|
|
226
|
+
seconds = float(topic.get("total_seconds") or 0.0)
|
|
227
|
+
record = {
|
|
228
|
+
"label": topic.get("label"),
|
|
229
|
+
"total_seconds": round(seconds, 2),
|
|
230
|
+
"clip_count": int(topic.get("clip_count") or 0),
|
|
231
|
+
"first_seconds": topic.get("first_seconds"),
|
|
232
|
+
}
|
|
233
|
+
(usable if seconds >= min_topic_seconds else thin).append(record)
|
|
234
|
+
|
|
235
|
+
ordered = sorted(usable, key=lambda t: -t["total_seconds"])
|
|
236
|
+
for position, topic in enumerate(ordered, start=1):
|
|
237
|
+
topic["proposed_position"] = position
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"success": True,
|
|
241
|
+
"kind": "proposed_structure",
|
|
242
|
+
"requires_approval": True,
|
|
243
|
+
"topic_count": len(ordered),
|
|
244
|
+
"proposed_order": ordered,
|
|
245
|
+
"thin_topics": thin,
|
|
246
|
+
"basis": (
|
|
247
|
+
"ordered by how much material each topic has — a measure of what was "
|
|
248
|
+
"SHOT, not of what matters. The most-covered subject is frequently not "
|
|
249
|
+
"the point of the piece."
|
|
250
|
+
),
|
|
251
|
+
"note": (
|
|
252
|
+
f"{len(ordered)} topics proposed in coverage order. **This is a "
|
|
253
|
+
"hypothesis about what the piece is, and nothing is cut.** Approve, "
|
|
254
|
+
"reorder or discard it before any assembly — a structure inferred from "
|
|
255
|
+
"clustering is the single decision least safe to take silently."
|
|
256
|
+
+ (f" {len(thin)} topics under {min_topic_seconds:g}s were set aside as "
|
|
257
|
+
"thin, not deleted." if thin else "")
|
|
258
|
+
),
|
|
259
|
+
}
|
|
@@ -58,8 +58,33 @@ from src.utils.media_analysis import _parse_silencedetect
|
|
|
58
58
|
|
|
59
59
|
DEFAULT_THRESHOLD_DB = -30.0
|
|
60
60
|
DEFAULT_MIN_STRIP_FRAMES = 10
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
|
|
62
|
+
#: Guard bands around a removed silence. These are the difference between a cut
|
|
63
|
+
#: that sounds clean and one that clips speech, and they were previously 0 and 1
|
|
64
|
+
#: frame — effectively none.
|
|
65
|
+
#:
|
|
66
|
+
#: `apply_silence_handles` shrinks each detected silence [s, e] inward:
|
|
67
|
+
#: `s + pre_head` and `e - post_tail`. Only what remains is removed. So:
|
|
68
|
+
#:
|
|
69
|
+
#: - **pre_head protects the OUTGOING word's decay.** `silencedetect` marks `s`
|
|
70
|
+
#: the moment amplitude falls below the gate, but a word's tail — and the room
|
|
71
|
+
#: reverb after it — stays audible below that gate. Stripping from `s` clips
|
|
72
|
+
#: the release and the cut sounds abrupt.
|
|
73
|
+
#: - **post_tail protects the INCOMING word's onset.** `silencedetect` marks `e`
|
|
74
|
+
#: when amplitude rises back above the gate. A soft attack (s, f, th, or any
|
|
75
|
+
#: unstressed syllable) crosses the gate *later* than the word actually
|
|
76
|
+
#: begins, so `e` sits inside the word. Stripping up to `e` eats the attack.
|
|
77
|
+
#:
|
|
78
|
+
#: post_tail is the larger of the two because that asymmetry is the one users
|
|
79
|
+
#: hear: reviewers of the first public tutorial for this project independently
|
|
80
|
+
#: reported that "the first split seconds of your clips are trimmed so the audio
|
|
81
|
+
#: is a bit cut off" — an eaten onset, produced by a one-frame onset guard.
|
|
82
|
+
#:
|
|
83
|
+
#: Frames, not milliseconds, to match Resolve's own handle model and the rest of
|
|
84
|
+
#: this module's API. At 24 fps these are 83 ms and 167 ms; at 30 fps, 67 ms and
|
|
85
|
+
#: 133 ms — both inside the range dialogue editors use by hand.
|
|
86
|
+
DEFAULT_PRE_HEAD_FRAMES = 2
|
|
87
|
+
DEFAULT_POST_TAIL_FRAMES = 4
|
|
63
88
|
|
|
64
89
|
#: Where in the trough→peak bracket the gate sits. Above 0.5 deliberately: see
|
|
65
90
|
#: the module docstring — silencedetect compares instantaneous amplitude while
|
|
@@ -73,6 +98,68 @@ DIGITAL_SILENCE_HEADROOM_DB = 24.0
|
|
|
73
98
|
GATE_MIN_DB = -60.0
|
|
74
99
|
GATE_MAX_DB = -24.0
|
|
75
100
|
|
|
101
|
+
#: How aggressively to tighten. The default is deliberately the *loosest* one.
|
|
102
|
+
#:
|
|
103
|
+
#: Editorial practice is that a first assembly contains everything and tightens
|
|
104
|
+
#: over successive passes — the first cut is supposed to be long. Tools default
|
|
105
|
+
#: the other way because tight output demos better, and that is backwards for
|
|
106
|
+
#: the person doing the work: trimming a generous assembly is fast and visible,
|
|
107
|
+
#: while recovering material the machine already discarded is slow and, worse,
|
|
108
|
+
#: invisible — you cannot review a cut that was never proposed.
|
|
109
|
+
#:
|
|
110
|
+
#: Each preset scales the guard bands and the minimum strip length. `generous`
|
|
111
|
+
#: widens the guards and only removes long, unambiguous gaps. `tight` reproduces
|
|
112
|
+
#: roughly the historical behaviour for anyone who wants it back.
|
|
113
|
+
TIGHTNESS_PRESETS = {
|
|
114
|
+
"generous": {"guard_scale": 1.5, "min_strip_scale": 1.5},
|
|
115
|
+
"balanced": {"guard_scale": 1.0, "min_strip_scale": 1.0},
|
|
116
|
+
"tight": {"guard_scale": 0.5, "min_strip_scale": 0.6},
|
|
117
|
+
}
|
|
118
|
+
DEFAULT_TIGHTNESS = "generous"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_tightness(tightness: Optional[str]) -> Tuple[str, dict]:
|
|
122
|
+
"""Map a tightness name to its scaling factors.
|
|
123
|
+
|
|
124
|
+
Unknown names are an error rather than a silent fall-back to the default:
|
|
125
|
+
a caller asking for "aggressive" and quietly getting "generous" would be
|
|
126
|
+
told nothing and see cuts they did not ask for.
|
|
127
|
+
"""
|
|
128
|
+
name = (tightness or DEFAULT_TIGHTNESS).strip().lower()
|
|
129
|
+
if name not in TIGHTNESS_PRESETS:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"unknown tightness {tightness!r}; expected one of "
|
|
132
|
+
f"{', '.join(sorted(TIGHTNESS_PRESETS))}"
|
|
133
|
+
)
|
|
134
|
+
return name, dict(TIGHTNESS_PRESETS[name])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def apply_tightness(
|
|
138
|
+
*,
|
|
139
|
+
tightness: Optional[str],
|
|
140
|
+
pre_head_frames: float,
|
|
141
|
+
post_tail_frames: float,
|
|
142
|
+
min_strip_frames: float,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""Scale guards and minimum strip length by the chosen tightness.
|
|
145
|
+
|
|
146
|
+
Guards are floored at the module defaults regardless of preset: `tight` is
|
|
147
|
+
allowed to remove more, never to start clipping speech again. That floor is
|
|
148
|
+
the whole point of the guard bands and is not a knob.
|
|
149
|
+
"""
|
|
150
|
+
name, scales = resolve_tightness(tightness)
|
|
151
|
+
return {
|
|
152
|
+
"tightness": name,
|
|
153
|
+
"pre_head_frames": max(
|
|
154
|
+
DEFAULT_PRE_HEAD_FRAMES, pre_head_frames * scales["guard_scale"]
|
|
155
|
+
),
|
|
156
|
+
"post_tail_frames": max(
|
|
157
|
+
DEFAULT_POST_TAIL_FRAMES, post_tail_frames * scales["guard_scale"]
|
|
158
|
+
),
|
|
159
|
+
"min_strip_frames": max(1.0, min_strip_frames * scales["min_strip_scale"]),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
76
163
|
#: ffmpeg spells the trough "RMS through"; accept a corrected spelling too so a
|
|
77
164
|
#: future ffmpeg fixing the typo does not silently drop us to the fallback.
|
|
78
165
|
_ASTATS_RMS_TROUGH_RE = re.compile(r"RMS (?:through|trough) dB:\s*(-?[0-9.]+|-?inf)", re.IGNORECASE)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The Law of Two-and-a-Half — how much sound an audience can actually follow.
|
|
2
|
+
|
|
3
|
+
A long-standing observation in sound editing: an audience can follow roughly **two
|
|
4
|
+
and a half simultaneous streams** of sound before the remainder stops being
|
|
5
|
+
distinguishable and becomes texture. Past that point the mix is not richer, it
|
|
6
|
+
is just louder — the extra elements are present, paid for, mixed, and not heard.
|
|
7
|
+
|
|
8
|
+
This is the only principle in the classical set that is *fully* measurable, and
|
|
9
|
+
nothing in this
|
|
10
|
+
codebase touched sound as more than a track to mirror picture cuts onto.
|
|
11
|
+
|
|
12
|
+
## The distinction that makes or breaks it
|
|
13
|
+
|
|
14
|
+
The naive implementation counts active tracks and flags every mixed timeline in
|
|
15
|
+
existence, because almost every finished sequence has music under dialogue under
|
|
16
|
+
atmos. That tool is useless on its first run.
|
|
17
|
+
|
|
18
|
+
The real question is not how many streams are *playing*, it is how many are
|
|
19
|
+
**competing for the same attention**. A music bed sitting well under dialogue is
|
|
20
|
+
one stream plus texture. Two overlapping voices at the same level are two
|
|
21
|
+
streams, and adding a third is where the boundary bites.
|
|
22
|
+
|
|
23
|
+
So each active stream at each moment is classified against the loudest one:
|
|
24
|
+
|
|
25
|
+
- Within `BED_MARGIN_DB` of the loudest → **competitor**. Same attentional
|
|
26
|
+
foreground.
|
|
27
|
+
- Further below → **bed**. Present, felt, not competing.
|
|
28
|
+
|
|
29
|
+
That is a heuristic and it is stated as one. It is also the difference between a
|
|
30
|
+
tool an editor uses twice and one they keep.
|
|
31
|
+
|
|
32
|
+
## On the threshold
|
|
33
|
+
|
|
34
|
+
Two-and-a-half is **an observation, not a measured constant**. It is a soft
|
|
35
|
+
boundary that varies with material, mix and audience. It is a parameter here,
|
|
36
|
+
and its provenance travels with every result — nobody should cite a number this
|
|
37
|
+
module returns as though it were physics.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
43
|
+
|
|
44
|
+
#: The two-and-a-half boundary. A soft observation, not a constant,
|
|
45
|
+
#: and a parameter everywhere it is used.
|
|
46
|
+
STREAM_LIMIT = 2.5
|
|
47
|
+
|
|
48
|
+
#: A stream this far below the loudest active one reads as underneath it rather
|
|
49
|
+
#: than alongside it — a bed, not a competitor. 12 dB is roughly the point where
|
|
50
|
+
#: an element stops pulling focus and starts supporting.
|
|
51
|
+
BED_MARGIN_DB = 12.0
|
|
52
|
+
|
|
53
|
+
#: Below this a stream is not audibly present at all.
|
|
54
|
+
AUDIBILITY_FLOOR_DB = -50.0
|
|
55
|
+
|
|
56
|
+
#: Shortest overcrowded stretch worth reporting. A momentary pile-up on a hard
|
|
57
|
+
#: effect is normal; a sustained one is a mix problem.
|
|
58
|
+
MIN_CROWDED_SECONDS = 1.5
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def classify_moment(
|
|
62
|
+
levels: Mapping[str, float],
|
|
63
|
+
*,
|
|
64
|
+
bed_margin_db: float = BED_MARGIN_DB,
|
|
65
|
+
audibility_floor_db: float = AUDIBILITY_FLOOR_DB,
|
|
66
|
+
) -> Dict[str, Any]:
|
|
67
|
+
"""Split the streams audible at one moment into competitors and beds.
|
|
68
|
+
|
|
69
|
+
`levels` maps a stream name to its level in dBFS at this moment.
|
|
70
|
+
"""
|
|
71
|
+
audible = {k: float(v) for k, v in levels.items() if float(v) > audibility_floor_db}
|
|
72
|
+
if not audible:
|
|
73
|
+
return {"competitors": [], "beds": [], "silent": True, "loudest_db": None}
|
|
74
|
+
loudest = max(audible.values())
|
|
75
|
+
competitors, beds = [], []
|
|
76
|
+
for name, level in sorted(audible.items(), key=lambda kv: -kv[1]):
|
|
77
|
+
(competitors if level >= loudest - bed_margin_db else beds).append(
|
|
78
|
+
{"stream": name, "level_db": round(level, 1)}
|
|
79
|
+
)
|
|
80
|
+
return {
|
|
81
|
+
"competitors": competitors,
|
|
82
|
+
"beds": beds,
|
|
83
|
+
"silent": False,
|
|
84
|
+
"loudest_db": round(loudest, 1),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def audit(
|
|
89
|
+
samples: Sequence[Mapping[str, Any]],
|
|
90
|
+
*,
|
|
91
|
+
stream_limit: float = STREAM_LIMIT,
|
|
92
|
+
bed_margin_db: float = BED_MARGIN_DB,
|
|
93
|
+
min_crowded_seconds: float = MIN_CROWDED_SECONDS,
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""Report attentional density over time.
|
|
96
|
+
|
|
97
|
+
`samples`: `[{"time_seconds": float, "levels": {stream: dBFS}}, ...]`,
|
|
98
|
+
ordered. Kept as plain numbers so the analysis is testable without decoding
|
|
99
|
+
audio.
|
|
100
|
+
"""
|
|
101
|
+
if not samples:
|
|
102
|
+
return {"success": False, "error": "No audio samples supplied"}
|
|
103
|
+
|
|
104
|
+
curve: List[Dict[str, Any]] = []
|
|
105
|
+
for sample in samples:
|
|
106
|
+
moment = classify_moment(
|
|
107
|
+
sample.get("levels") or {}, bed_margin_db=bed_margin_db
|
|
108
|
+
)
|
|
109
|
+
curve.append({
|
|
110
|
+
"time_seconds": round(float(sample.get("time_seconds") or 0.0), 3),
|
|
111
|
+
"competitors": len(moment["competitors"]),
|
|
112
|
+
"beds": len(moment["beds"]),
|
|
113
|
+
"competing_streams": [c["stream"] for c in moment["competitors"]],
|
|
114
|
+
"bed_streams": [b["stream"] for b in moment["beds"]],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
# Contiguous stretches over the limit.
|
|
118
|
+
crowded: List[Dict[str, Any]] = []
|
|
119
|
+
run_start: Optional[float] = None
|
|
120
|
+
run_peak = 0
|
|
121
|
+
for index, point in enumerate(curve):
|
|
122
|
+
over = point["competitors"] > stream_limit
|
|
123
|
+
if over and run_start is None:
|
|
124
|
+
run_start, run_peak = point["time_seconds"], point["competitors"]
|
|
125
|
+
elif over:
|
|
126
|
+
run_peak = max(run_peak, point["competitors"])
|
|
127
|
+
elif run_start is not None:
|
|
128
|
+
_close_run(crowded, run_start, point["time_seconds"], run_peak, min_crowded_seconds)
|
|
129
|
+
run_start, run_peak = None, 0
|
|
130
|
+
if run_start is not None:
|
|
131
|
+
_close_run(crowded, run_start, curve[-1]["time_seconds"], run_peak, min_crowded_seconds)
|
|
132
|
+
|
|
133
|
+
peak = max((p["competitors"] for p in curve), default=0)
|
|
134
|
+
findings = [{
|
|
135
|
+
"criterion": "rhythm",
|
|
136
|
+
"scope": "scene",
|
|
137
|
+
"at": run["start_seconds"],
|
|
138
|
+
"summary": (
|
|
139
|
+
f"{run['peak_competitors']} sounds competing for "
|
|
140
|
+
f"{run['duration_seconds']}s"
|
|
141
|
+
),
|
|
142
|
+
"detail": (
|
|
143
|
+
f"From {run['start_seconds']}s to {run['end_seconds']}s, up to "
|
|
144
|
+
f"{run['peak_competitors']} streams sit within {bed_margin_db:.0f} dB of "
|
|
145
|
+
"each other, so they are all asking for the same attention. An audience "
|
|
146
|
+
"follows about two and a half streams at once; past that the extra elements "
|
|
147
|
+
"are mixed, paid for, and not heard. Either duck something or lose it."
|
|
148
|
+
),
|
|
149
|
+
} for run in crowded]
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"success": True,
|
|
153
|
+
"kind": "sound_density_audit",
|
|
154
|
+
"sample_count": len(curve),
|
|
155
|
+
"peak_competitors": peak,
|
|
156
|
+
"stream_limit": stream_limit,
|
|
157
|
+
"crowded_stretch_count": len(crowded),
|
|
158
|
+
"crowded_stretches": crowded,
|
|
159
|
+
"density_curve": curve,
|
|
160
|
+
"findings": findings,
|
|
161
|
+
"threshold_provenance": (
|
|
162
|
+
f"{stream_limit} comes from a long-standing observation in sound editing "
|
|
163
|
+
"about how many simultaneous streams an audience can follow. It is NOT a "
|
|
164
|
+
"measured constant — it is a soft boundary that moves with material, mix and "
|
|
165
|
+
"audience, and it is a parameter here. Do not cite it as physics."
|
|
166
|
+
),
|
|
167
|
+
"method": (
|
|
168
|
+
f"A stream within {bed_margin_db:.0f} dB of the loudest active one counts as "
|
|
169
|
+
"competing; anything further under counts as a bed. That is what stops a "
|
|
170
|
+
"music bed under dialogue reading as two competitors — it is one stream "
|
|
171
|
+
"plus texture. Heuristic, and deliberately so."
|
|
172
|
+
),
|
|
173
|
+
"note": (
|
|
174
|
+
f"Peak of {peak} competing streams across {len(curve)} samples; "
|
|
175
|
+
+ (f"{len(crowded)} stretches over the limit." if crowded
|
|
176
|
+
else "nothing sustained over the limit.")
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _close_run(
|
|
182
|
+
out: List[Dict[str, Any]], start: float, end: float, peak: int, minimum: float
|
|
183
|
+
) -> None:
|
|
184
|
+
duration = end - start
|
|
185
|
+
if duration >= minimum:
|
|
186
|
+
out.append({
|
|
187
|
+
"start_seconds": round(start, 2),
|
|
188
|
+
"end_seconds": round(end, 2),
|
|
189
|
+
"duration_seconds": round(duration, 2),
|
|
190
|
+
"peak_competitors": peak,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def measure_track_levels(
|
|
195
|
+
media_paths: Mapping[str, str],
|
|
196
|
+
*,
|
|
197
|
+
window_seconds: float = 0.5,
|
|
198
|
+
duration_seconds: Optional[float] = None,
|
|
199
|
+
) -> Dict[str, Any]:
|
|
200
|
+
"""Sample per-stream levels from files via ffmpeg. Honest-refuses if absent.
|
|
201
|
+
|
|
202
|
+
Separated from the analysis so the classification above stays testable
|
|
203
|
+
without decoding audio.
|
|
204
|
+
"""
|
|
205
|
+
import shutil
|
|
206
|
+
import subprocess
|
|
207
|
+
|
|
208
|
+
if not shutil.which("ffmpeg"):
|
|
209
|
+
return {"success": False, "error": "ffmpeg not found on PATH — cannot measure levels"}
|
|
210
|
+
try:
|
|
211
|
+
import numpy as np
|
|
212
|
+
except Exception:
|
|
213
|
+
return {"success": False, "error": "numpy is required", "remediation": "pip install numpy"}
|
|
214
|
+
|
|
215
|
+
rate = 8000 # plenty for an energy envelope
|
|
216
|
+
per_stream: Dict[str, Any] = {}
|
|
217
|
+
length = 0
|
|
218
|
+
for name, path in media_paths.items():
|
|
219
|
+
cmd = ["ffmpeg", "-v", "error", "-i", path, "-vn", "-ac", "1",
|
|
220
|
+
"-ar", str(rate), "-f", "f32le", "-"]
|
|
221
|
+
if duration_seconds:
|
|
222
|
+
cmd[4:4] = ["-t", str(duration_seconds)]
|
|
223
|
+
try:
|
|
224
|
+
proc = subprocess.run(cmd, capture_output=True, timeout=120)
|
|
225
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
226
|
+
return {"success": False, "error": f"decode failed for {name}: {exc}"}
|
|
227
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
228
|
+
return {
|
|
229
|
+
"success": False,
|
|
230
|
+
"error": (proc.stderr or b"").decode("utf-8", "replace").strip()
|
|
231
|
+
or f"no audio decoded from {name}",
|
|
232
|
+
}
|
|
233
|
+
signal = np.frombuffer(proc.stdout, dtype="<f4")
|
|
234
|
+
step = max(1, int(rate * window_seconds))
|
|
235
|
+
windows = signal[: len(signal) // step * step].reshape(-1, step)
|
|
236
|
+
rms = np.sqrt((windows.astype("float64") ** 2).mean(axis=1))
|
|
237
|
+
with np.errstate(divide="ignore"):
|
|
238
|
+
db = 20.0 * np.log10(np.maximum(rms, 1e-9))
|
|
239
|
+
per_stream[name] = db
|
|
240
|
+
length = max(length, len(db))
|
|
241
|
+
|
|
242
|
+
samples = [
|
|
243
|
+
{
|
|
244
|
+
"time_seconds": index * window_seconds,
|
|
245
|
+
"levels": {
|
|
246
|
+
name: float(values[index])
|
|
247
|
+
for name, values in per_stream.items()
|
|
248
|
+
if index < len(values)
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
for index in range(length)
|
|
252
|
+
]
|
|
253
|
+
return {"success": True, "samples": samples, "window_seconds": window_seconds}
|