davinci-resolve-mcp 2.68.2 → 2.69.1

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.
@@ -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
- DEFAULT_PRE_HEAD_FRAMES = 0
62
- DEFAULT_POST_TAIL_FRAMES = 1
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}