davinci-resolve-mcp 2.101.0 → 2.102.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 CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.102.0
6
+
7
+ **A rough mix that reports what it achieved, not what it intended.** The pieces were
8
+ already here — `media_analysis` measures EBU R128 loudness and detects silence,
9
+ `delivery_targets` holds the standards, `loudness_qc` grades a finished file. What was
10
+ missing is the step between measuring and grading: deciding the gains.
11
+
12
+ ### Added
13
+
14
+ - **`media_analysis mix_plan`** — dialogue-normalisation gain, a music-bed level relative
15
+ to it, and ducking windows derived from silence detection **on the dialogue stem**, so
16
+ the bed follows the words rather than a hand-placed envelope. `dry_run` defaults to
17
+ true and renders nothing.
18
+ - **The achieved loudness is measured, not derived.** The premix is rendered, then
19
+ re-measured; `achieved` carries integrated LUFS, true peak, loudness range, and the
20
+ delta from target. A plan that hits its target on paper and clips on true peak is a
21
+ failed plan, and only the measurement tells you which one you have.
22
+ - **Dialogue-anchored, then programme-trimmed.** Anchoring dialogue at target is right
23
+ for a dialogue-gated standard and wrong for a full-programme one the moment a bed is
24
+ added. For non-dialogue-gated standards one measured trim is applied to everything
25
+ equally — preserving the dialogue-to-bed relationship — and reported as
26
+ `program_normalize.trim_db`. It never runs on a dialogue-gated standard, where
27
+ dialogue is the figure being graded.
28
+ - **Nothing else is corrected.** `loudness_off_target`, `true_peak_over`, and `clipped`
29
+ come back as flags with remedies, never as a quietly normalised file.
30
+ - Standards come from `delivery_targets` (`web`, `podcast`, `ebu_r128`, `atsc_a85`,
31
+ `ott_dialogue_gated`) — the table the delivery tools already grade against, not a
32
+ second copy.
33
+ - **`media_analysis measure_loudness`** — integrated LUFS, loudness range, and true peak
34
+ per file.
35
+ - **`media_analysis mix_plan_capabilities`** — dependency state, known standards, and the
36
+ defaults, including the music-bed offset, which is the number most likely to be argued
37
+ with and so is named rather than buried.
38
+
39
+ ### Fixed while building it
40
+
41
+ The new loudness parser reads the `Summary:` block **and** drops ebur128's per-frame
42
+ progress lines, which carry their own `I:` and `LRA:` fields. A plain last-match-wins
43
+ parse is correct only because ffmpeg happens to print the summary last, and scoping to
44
+ the summary alone still swallows a progress line printed after it. Both steps are needed;
45
+ a test with a trailing progress line pins it.
46
+
47
+ ### Scope
48
+
49
+ A rough mix: gain staging, a bed, and ducking. No EQ, compression, de-essing, or
50
+ limiting, and the module says so in its capabilities rather than leaving it implied.
51
+
52
+ ### Validation
53
+
54
+ - Offline suite: 2924 passed, 1 skipped, 711 subtests, 0 failures.
55
+ - End-to-end through real ffmpeg on generated tones: target hit from measurement, the
56
+ programme trim landing a hot bed on R128, a dialogue-gated standard refusing the trim,
57
+ and clipping reported rather than normalised away.
58
+ - Three deliberate mutations (silent peak normalisation, trimming a dialogue-gated
59
+ standard, and dropping the parser scoping) were each caught. The parser mutation was
60
+ caught only after the test was strengthened — the first version of it passed against
61
+ both the fix and its absence.
62
+ - No Resolve behavior changed; live test not required.
63
+
5
64
  ## What's New in v2.101.0
6
65
 
7
66
  **A grade can now reject itself.** `assess_grade` has measured grade damage since
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.101.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.102.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.101.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.102.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.101.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.102.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -552,6 +552,13 @@ Key actions:
552
552
  `load_user_preferences_preset`
553
553
  - `quit` — terminates Resolve (destructive; confirm with user first)
554
554
 
555
+ **Offline audio and image QC on `media_analysis`** — no Resolve connection required:
556
+ `measure_loudness`, `mix_plan` / `mix_plan_capabilities` (dialogue-anchored rough mix
557
+ with dialogue-following ducking, rendered and re-measured), and `assess_grade` /
558
+ `grade_loop` / `grade_loop_capabilities` (numeric grade-damage QC and the retry ladder
559
+ that backs a look off until it stops damaging the picture). See
560
+ `docs/kernels/audio-fairlight-kernel.md` and `docs/kernels/color-grade-kernel.md`.
561
+
555
562
  **`layout_presets`** — Save, load, export, import, delete UI layout presets.
556
563
  `list` (Resolve 21.0.4+) enumerates the saved preset names the other actions
557
564
  take.
@@ -53,6 +53,39 @@ All kernel actions are exposed through `timeline`.
53
53
  - Subtitle generation from the generated timeline returned true.
54
54
  - Fairlight preset listing and the full boundary report worked.
55
55
 
56
+ ## Rough mix (`media_analysis`)
57
+
58
+ Offline and file-based — no Resolve connection — sitting between measuring loudness and
59
+ grading it against a spec. The step nothing else covered was deciding the gains.
60
+
61
+ - `measure_loudness(path|paths)` — integrated LUFS, loudness range, and true peak per
62
+ file via ffmpeg `ebur128`.
63
+ - `mix_plan(dialogue[], music?, sfx?, standard?, target_lufs?, bed_offset_lu?, duck_db?,
64
+ attack_s?, release_s?, hold_s?, dry_run?, output_path?, program_normalize?)` —
65
+ dialogue-normalisation gain, a bed level relative to it, and ducking windows derived
66
+ from silence detection **on the dialogue stem**, so the bed follows the words rather
67
+ than a hand-placed envelope. `dry_run` defaults to true and renders nothing.
68
+ - `mix_plan_capabilities()` — dependency state, the standards it knows, and the defaults.
69
+
70
+ Three things worth knowing before using it:
71
+
72
+ - **The achieved loudness is measured, not derived.** The premix is rendered, then
73
+ re-measured, and `achieved` carries what came back. A plan that hits its target on
74
+ paper and clips on true peak is a failed plan; only the measurement distinguishes them.
75
+ - **Dialogue-anchored, then programme-trimmed.** Anchoring dialogue at target is right
76
+ for a dialogue-gated standard and wrong for a full-programme one the moment a bed is
77
+ added. For non-dialogue-gated standards a single measured trim is applied to everything
78
+ equally — preserving the dialogue-to-bed relationship — and reported as
79
+ `program_normalize.trim_db`. It never runs on a dialogue-gated standard.
80
+ - **Nothing else is corrected.** Over true peak or clipped comes back as a flag with a
81
+ remedy, never as a quietly normalised file. Pulling the mix down to fix a peak would
82
+ move the loudness off the target it just hit.
83
+
84
+ Standards come from `src/utils/delivery_targets.py` (`web`, `podcast`, `ebu_r128`,
85
+ `atsc_a85`, `ott_dialogue_gated`) — the same table the delivery tools grade against, not
86
+ a second copy. It is a rough mix: gain staging, a bed, and ducking. No EQ, compression,
87
+ de-essing, or limiting.
88
+
56
89
  ## Boundaries
57
90
 
58
91
  - Timeline item audio properties may be readable as `None` and can reject writes
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.101.0"
40
+ VERSION = "2.102.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.101.0",
3
+ "version": "2.102.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.101.0"
90
+ VERSION = "2.102.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.101.0"
14
+ VERSION = "2.102.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -20128,6 +20128,52 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
20128
20128
  from src.utils import grade_loop as _grade_loop_mod
20129
20129
 
20130
20130
  return _ok(**_grade_loop_mod.capabilities())
20131
+ if action in {"mix_plan", "measure_loudness", "mix_plan_capabilities"}:
20132
+ # Gain staging between measuring loudness and grading it: dialogue-norm gain,
20133
+ # a bed level relative to it, and ducking windows the dialogue itself implies.
20134
+ # The render is measured afterwards, so what comes back is the loudness
20135
+ # achieved rather than the arithmetic meant to produce it.
20136
+ from src.utils import mix_plan as _mix_plan_mod
20137
+
20138
+ if action == "mix_plan_capabilities":
20139
+ return _ok(**_mix_plan_mod.capabilities())
20140
+ try:
20141
+ if action == "measure_loudness":
20142
+ paths = p.get("paths") or ([p["path"]] if p.get("path") else [])
20143
+ if not paths:
20144
+ return _err("measure_loudness requires path or paths")
20145
+ return _ok(measurements=[_mix_plan_mod.measure(str(item)) for item in paths])
20146
+
20147
+ dialogue = p.get("dialogue") or ([p["path"]] if p.get("path") else [])
20148
+ if isinstance(dialogue, str):
20149
+ dialogue = [dialogue]
20150
+ kwargs = dict(
20151
+ music=p.get("music") or [],
20152
+ sfx=p.get("sfx") or [],
20153
+ standard=str(p.get("standard") or _mix_plan_mod.DEFAULT_STANDARD),
20154
+ target_lufs=(
20155
+ float(p["target_lufs"]) if p.get("target_lufs") is not None else None
20156
+ ),
20157
+ bed_offset_lu=float(p.get("bed_offset_lu", _mix_plan_mod.DEFAULT_BED_OFFSET_LU)),
20158
+ duck_db=float(p.get("duck_db", _mix_plan_mod.DEFAULT_DUCK_DB)),
20159
+ attack_s=float(p.get("attack_s", _mix_plan_mod.DEFAULT_ATTACK_S)),
20160
+ release_s=float(p.get("release_s", _mix_plan_mod.DEFAULT_RELEASE_S)),
20161
+ hold_s=float(p.get("hold_s", _mix_plan_mod.DEFAULT_HOLD_S)),
20162
+ )
20163
+ if p.get("dry_run", True):
20164
+ return _ok(**_mix_plan_mod.plan(dialogue, **kwargs))
20165
+ return _ok(**_mix_plan_mod.render(
20166
+ dialogue,
20167
+ output_path=(p.get("output_path") or p.get("outputPath")) or None,
20168
+ program_normalize=p.get("program_normalize"),
20169
+ **kwargs,
20170
+ ))
20171
+ except _mix_plan_mod.MixPlanError as exc:
20172
+ return _err(str(exc), code="MIX_PLAN_REFUSED", category="invalid_input",
20173
+ remediation=(
20174
+ "Supply dialogue=[...] stems that carry audio. The mix is "
20175
+ "anchored to dialogue, so it cannot be planned without one."
20176
+ ))
20131
20177
  if action == "image_qc_capabilities":
20132
20178
  from src.utils import image_qc as _image_qc_mod
20133
20179
 
@@ -21082,6 +21128,9 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
21082
21128
  "assess_grade",
21083
21129
  "grade_loop",
21084
21130
  "grade_loop_capabilities",
21131
+ "mix_plan",
21132
+ "mix_plan_capabilities",
21133
+ "measure_loudness",
21085
21134
  "image_qc_capabilities",
21086
21135
  "install_guidance",
21087
21136
  "resolve_output_root",
@@ -0,0 +1,612 @@
1
+ """A rough mix that reports what it actually achieved, not what it intended.
2
+
3
+ The pieces for this have been here for a while: `media_analysis` measures EBU R128
4
+ loudness and detects silence, `delivery_targets` holds the standards, and the advanced
5
+ server's `loudness_qc` grades a finished file against a target. What was missing is the
6
+ step between measuring and grading — deciding the gains.
7
+
8
+ `plan()` derives the dialogue-normalisation gain, the music-bed level relative to it,
9
+ and the ducking windows the dialogue itself implies. `render()` mixes them
10
+ sample-accurately and then **measures the result**, so what comes back is the achieved
11
+ integrated loudness, true peak, and range — not the arithmetic that was supposed to
12
+ produce them. A plan that hits its target on paper and clips on true peak is a failed
13
+ plan, and only measuring the render can tell you which one you have.
14
+
15
+ ## Measuring an isolated dialogue stem is already dialogue-gated
16
+
17
+ `ffmpeg`'s `ebur128` measures full program. That is why `delivery_targets` refuses to
18
+ assert a dialogue-gated integrated figure against a whole-mix measurement. Here the
19
+ dialogue stem is measured *alone*, which is the closest thing to a gated measurement
20
+ there is — so a dialogue-gated standard applies to the stem, and the module says so
21
+ rather than silently reusing a full-program number.
22
+
23
+ ## Two things can be at the target, and they are not the same thing
24
+
25
+ Anchoring dialogue at the target is right for a dialogue-gated standard. For a
26
+ full-programme standard like R128 it is wrong the moment a bed is added: dialogue sits at
27
+ target, the music sits on top, and the programme lands above it. Anchoring the programme
28
+ is right there, and wrong for dialogue-gated.
29
+
30
+ So the mix is built dialogue-anchored, and for a non-dialogue-gated standard a single
31
+ **measured** programme trim is then applied to everything equally — preserving the
32
+ dialogue-to-bed relationship — and the result is measured again. `program_trim_db` and
33
+ both measurements are reported. This is a declared step with its own number, not a quiet
34
+ correction: `program_normalize=false` turns it off, and it never runs on a dialogue-gated
35
+ standard.
36
+
37
+ ## Nothing else is silently fixed
38
+
39
+ If the mix overshoots true peak, that is reported with its remedy. Pulling the whole mix
40
+ down to fix it would move the integrated loudness off the target it just hit, and
41
+ reporting the pre-trim number would then be a lie. The caller decides which constraint
42
+ gives.
43
+
44
+ ## Everything lands in scratch
45
+
46
+ Premixes are derived audio and go to an explicit output path or a temp directory —
47
+ never beside the source stems.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import os
53
+ import re
54
+ import shutil
55
+ import subprocess
56
+ import tempfile
57
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
58
+
59
+ from . import delivery_targets
60
+
61
+ try:
62
+ import numpy as _np
63
+ except ImportError: # pragma: no cover - guarded by capabilities()
64
+ _np = None # type: ignore
65
+
66
+ #: Every entry point that touches an array calls `_require()` first, so the mixing
67
+ #: arithmetic treats `_np` as present. See tests/test_optional_dependency_guards.py.
68
+ _OPTIONAL_DEPENDENCY_CONTRACT = (
69
+ "numpy: every mixing entry point calls _require() first; internals assume it is present"
70
+ )
71
+
72
+ SAMPLE_RATE = 48000
73
+ CHANNELS = 2
74
+
75
+ #: How far under dialogue a music bed sits by default, in LU. Named rather than buried:
76
+ #: it is the single number most likely to be argued with, and a caller who disagrees
77
+ #: should be able to find and change it.
78
+ DEFAULT_BED_OFFSET_LU = -12.0
79
+
80
+ #: Ducking shape. Attack is short enough that the bed is already down under the first
81
+ #: syllable; release is long enough that it does not pump between words.
82
+ DEFAULT_DUCK_DB = -6.0
83
+ DEFAULT_ATTACK_S = 0.15
84
+ DEFAULT_RELEASE_S = 0.40
85
+ #: Gaps shorter than this between speech regions are bridged rather than ducked out of
86
+ #: and back into — a bed that lifts for half a second between sentences is a distraction.
87
+ DEFAULT_HOLD_S = 0.35
88
+
89
+ #: Silence detection on the dialogue stem. -40 dB is below room tone on a normalised
90
+ #: stem but above the noise floor of a clean recording.
91
+ SILENCE_NOISE_DB = -40.0
92
+ SILENCE_MIN_S = 0.30
93
+
94
+ DEFAULT_STANDARD = "web"
95
+
96
+
97
+ class MixPlanError(Exception):
98
+ """Bad inputs or a missing tool. A mix that misses its target is a result."""
99
+
100
+
101
+ def _require() -> None:
102
+ if _np is None:
103
+ raise MixPlanError("numpy is required for mixing (pip install numpy)")
104
+ if not shutil.which("ffmpeg"):
105
+ raise MixPlanError("ffmpeg is required for loudness measurement and rendering")
106
+
107
+
108
+ # ── measurement ──────────────────────────────────────────────────────────────
109
+
110
+ # These patterns mirror `media_analysis._parse_loudness`. The duplication is deliberate
111
+ # — this module stays importable without pulling in the analysis engine — and a test
112
+ # asserts both parsers agree on the same ffmpeg output, so the copy cannot drift.
113
+ _INTEGRATED_RE = r"I:\s*(-?\d+(?:\.\d+)?)\s*LUFS"
114
+ _LRA_RE = r"LRA:\s*(-?\d+(?:\.\d+)?)\s*LU"
115
+ _PEAK_RE = r"Peak:\s*(-?\d+(?:\.\d+)?)\s*dBFS"
116
+
117
+
118
+ def parse_loudness(stderr: str) -> Dict[str, Optional[float]]:
119
+ """Pull the ebur128 summary out of ffmpeg's stderr.
120
+
121
+ Scoped to the text after `Summary:`, with ebur128's per-frame progress lines removed.
122
+ Both steps are needed and neither is enough alone: the progress line carries its own
123
+ `I:` and `LRA:`, so a plain last-match-wins parse is right only because the summary
124
+ happens to print last, and scoping to the summary still swallows any progress line
125
+ that prints after it. Progress lines are identified by the `TARGET:` field, which
126
+ appears on every one of them and on nothing in the summary block.
127
+ """
128
+ marker = stderr.rfind("Summary:")
129
+ scope = "\n".join(
130
+ line for line in (stderr[marker:] if marker >= 0 else stderr).splitlines()
131
+ if "TARGET:" not in line
132
+ )
133
+
134
+ def latest(pattern: str) -> Optional[float]:
135
+ matches = re.findall(pattern, scope)
136
+ return float(matches[-1]) if matches else None
137
+
138
+ return {
139
+ "integrated_lufs": latest(_INTEGRATED_RE),
140
+ "loudness_range_lu": latest(_LRA_RE),
141
+ "true_peak_dbtp": latest(_PEAK_RE),
142
+ }
143
+
144
+
145
+ def _run(args: Sequence[str], *, stdin_bytes: Optional[bytes] = None) -> Tuple[int, bytes, str]:
146
+ process = subprocess.run(
147
+ list(args), input=stdin_bytes, capture_output=True, check=False, timeout=600
148
+ )
149
+ return process.returncode, process.stdout, process.stderr.decode("utf-8", "replace")
150
+
151
+
152
+ def measure(path: str) -> Dict[str, Any]:
153
+ """Integrated LUFS, loudness range, and true peak for one file."""
154
+ _require()
155
+ if not os.path.isfile(path):
156
+ raise MixPlanError(f"file not found: {path}")
157
+ code, _, stderr = _run(
158
+ ["ffmpeg", "-v", "info", "-nostats", "-i", path,
159
+ "-filter_complex", "ebur128=peak=true", "-f", "null", "-"]
160
+ )
161
+ parsed = parse_loudness(stderr)
162
+ if parsed["integrated_lufs"] is None:
163
+ raise MixPlanError(
164
+ f"could not measure loudness of {os.path.basename(path)} "
165
+ f"(no audio stream, or ffmpeg produced no ebur128 summary; exit {code})"
166
+ )
167
+ return {"path": path, **parsed}
168
+
169
+
170
+ def _speech_regions(path: str, duration: float) -> List[Dict[str, float]]:
171
+ """Regions where the dialogue stem is NOT silent — the complement of silencedetect."""
172
+ _, _, stderr = _run(
173
+ ["ffmpeg", "-v", "info", "-nostats", "-i", path, "-af",
174
+ f"silencedetect=noise={SILENCE_NOISE_DB}dB:d={SILENCE_MIN_S}", "-f", "null", "-"]
175
+ )
176
+ starts = [float(value) for value in re.findall(r"silence_start:\s*(-?[0-9.]+)", stderr)]
177
+ ends = [float(value) for value in re.findall(r"silence_end:\s*([0-9.]+)", stderr)]
178
+
179
+ silences: List[Tuple[float, float]] = []
180
+ for index, start in enumerate(starts):
181
+ end = ends[index] if index < len(ends) else duration
182
+ silences.append((max(0.0, start), min(duration, end)))
183
+
184
+ regions: List[Dict[str, float]] = []
185
+ cursor = 0.0
186
+ for start, end in silences:
187
+ if start > cursor:
188
+ regions.append({"start": round(cursor, 3), "end": round(start, 3)})
189
+ cursor = max(cursor, end)
190
+ if cursor < duration:
191
+ regions.append({"start": round(cursor, 3), "end": round(duration, 3)})
192
+ return [region for region in regions if region["end"] > region["start"]]
193
+
194
+
195
+ def _merge_regions(regions: Sequence[Dict[str, float]], hold: float) -> List[Dict[str, float]]:
196
+ """Bridge gaps shorter than `hold` so the bed does not pump between sentences."""
197
+ merged: List[Dict[str, float]] = []
198
+ for region in sorted(regions, key=lambda item: item["start"]):
199
+ if merged and region["start"] - merged[-1]["end"] <= hold:
200
+ merged[-1]["end"] = max(merged[-1]["end"], region["end"])
201
+ else:
202
+ merged.append(dict(region))
203
+ return [{"start": round(r["start"], 3), "end": round(r["end"], 3)} for r in merged]
204
+
205
+
206
+ def probe_duration(path: str) -> float:
207
+ """Duration in seconds via ffprobe, or 0.0 when it cannot be read."""
208
+ if not shutil.which("ffprobe"):
209
+ raise MixPlanError("ffprobe is required to read stem durations")
210
+ code, stdout, _ = _run(
211
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
212
+ "-of", "default=nw=1:nk=1", path]
213
+ )
214
+ try:
215
+ return float(stdout.decode("utf-8", "replace").strip())
216
+ except (TypeError, ValueError):
217
+ return 0.0
218
+
219
+
220
+ # ── planning ─────────────────────────────────────────────────────────────────
221
+
222
+
223
+ def _standard(name: Any) -> Any:
224
+ key = delivery_targets.normalize_loudness_standard(name or DEFAULT_STANDARD)
225
+ if key is None:
226
+ raise MixPlanError(
227
+ f"unknown loudness standard '{name}'. Known: "
228
+ f"{', '.join(sorted(delivery_targets.LOUDNESS_STANDARDS))}"
229
+ )
230
+ return delivery_targets.LOUDNESS_STANDARDS[key]
231
+
232
+
233
+ def plan(
234
+ dialogue: Sequence[str],
235
+ *,
236
+ music: Optional[Sequence[str]] = None,
237
+ sfx: Optional[Sequence[str]] = None,
238
+ standard: str = DEFAULT_STANDARD,
239
+ target_lufs: Optional[float] = None,
240
+ bed_offset_lu: float = DEFAULT_BED_OFFSET_LU,
241
+ duck_db: float = DEFAULT_DUCK_DB,
242
+ attack_s: float = DEFAULT_ATTACK_S,
243
+ release_s: float = DEFAULT_RELEASE_S,
244
+ hold_s: float = DEFAULT_HOLD_S,
245
+ ) -> Dict[str, Any]:
246
+ """Measure the stems and derive the gains. Renders nothing."""
247
+ _require()
248
+ dialogue_paths = [str(path) for path in (dialogue or [])]
249
+ music_paths = [str(path) for path in (music or [])]
250
+ sfx_paths = [str(path) for path in (sfx or [])]
251
+ if not dialogue_paths:
252
+ raise MixPlanError("supply at least one dialogue stem — the mix is anchored to it")
253
+ for path in dialogue_paths + music_paths + sfx_paths:
254
+ if not os.path.isfile(path):
255
+ raise MixPlanError(f"file not found: {path}")
256
+
257
+ spec = _standard(standard)
258
+ target = float(target_lufs) if target_lufs is not None else float(spec.integrated)
259
+
260
+ dialogue_measurements = [measure(path) for path in dialogue_paths]
261
+ music_measurements = [measure(path) for path in music_paths]
262
+ sfx_measurements = [measure(path) for path in sfx_paths]
263
+
264
+ # The anchor is the loudest dialogue stem: normalising to the quietest would push
265
+ # the others past the target, and normalising to an average leaves both wrong.
266
+ anchor = max(dialogue_measurements, key=lambda item: item["integrated_lufs"])
267
+ dialogue_gain_db = round(target - float(anchor["integrated_lufs"]), 2)
268
+
269
+ bed_target = target + float(bed_offset_lu)
270
+ music_gains = [
271
+ {
272
+ "path": item["path"],
273
+ "measured_lufs": item["integrated_lufs"],
274
+ "gain_db": round(bed_target - float(item["integrated_lufs"]), 2),
275
+ }
276
+ for item in music_measurements
277
+ ]
278
+ # Effects sit with dialogue rather than under it — they are events, not a bed.
279
+ sfx_gains = [
280
+ {
281
+ "path": item["path"],
282
+ "measured_lufs": item["integrated_lufs"],
283
+ "gain_db": round(target - float(item["integrated_lufs"]), 2),
284
+ }
285
+ for item in sfx_measurements
286
+ ]
287
+
288
+ duration = max(probe_duration(path) for path in dialogue_paths + music_paths + sfx_paths)
289
+ speech: List[Dict[str, float]] = []
290
+ for path in dialogue_paths:
291
+ speech.extend(_speech_regions(path, probe_duration(path)))
292
+ duck_windows = _merge_regions(speech, hold_s)
293
+
294
+ ducked_seconds = sum(window["end"] - window["start"] for window in duck_windows)
295
+ return {
296
+ "dry_run": True,
297
+ "standard": {
298
+ "id": spec.id,
299
+ "label": spec.label,
300
+ "integrated_lufs": spec.integrated,
301
+ "tolerance_lu": spec.tolerance_lu,
302
+ "true_peak_max_dbtp": spec.true_peak_max_dbtp,
303
+ "dialogue_gated": bool(getattr(spec, "dialogue_gated", False)),
304
+ "source": spec.source,
305
+ },
306
+ "target_lufs": target,
307
+ "dialogue": {
308
+ "stems": dialogue_measurements,
309
+ "anchor_path": anchor["path"],
310
+ "anchor_measured_lufs": anchor["integrated_lufs"],
311
+ "gain_db": dialogue_gain_db,
312
+ "note": (
313
+ "Measured on the isolated dialogue stem, which is the closest thing to a "
314
+ "dialogue-gated measurement available here."
315
+ ),
316
+ },
317
+ "music": {
318
+ "stems": music_gains,
319
+ "bed_offset_lu": float(bed_offset_lu),
320
+ "bed_target_lufs": round(bed_target, 2),
321
+ },
322
+ "sfx": {"stems": sfx_gains},
323
+ "ducking": {
324
+ "duck_db": float(duck_db),
325
+ "attack_s": float(attack_s),
326
+ "release_s": float(release_s),
327
+ "hold_s": float(hold_s),
328
+ "windows": duck_windows,
329
+ "window_count": len(duck_windows),
330
+ "ducked_seconds": round(ducked_seconds, 2),
331
+ "ducked_fraction": round(ducked_seconds / duration, 4) if duration else 0.0,
332
+ "note": (
333
+ "Derived from silence detection on the dialogue stem, so the bed follows "
334
+ "the words rather than a hand-placed envelope."
335
+ ),
336
+ },
337
+ "duration_seconds": round(duration, 3),
338
+ "renders": False,
339
+ "next": (
340
+ "Re-run with dry_run=false to render the premix and report the achieved "
341
+ "loudness. Nothing is written until then."
342
+ ),
343
+ }
344
+
345
+
346
+ # ── rendering ────────────────────────────────────────────────────────────────
347
+
348
+
349
+ def _decode(path: str) -> "Any":
350
+ """Decode to float32 interleaved stereo at SAMPLE_RATE. Shape (n, CHANNELS)."""
351
+ code, raw, stderr = _run(
352
+ ["ffmpeg", "-v", "error", "-i", path, "-map", "0:a:0",
353
+ "-ac", str(CHANNELS), "-ar", str(SAMPLE_RATE),
354
+ "-f", "f32le", "-acodec", "pcm_f32le", "pipe:1"]
355
+ )
356
+ if code != 0 or not raw:
357
+ raise MixPlanError(
358
+ f"could not decode audio from {os.path.basename(path)}: {stderr[-300:]}"
359
+ )
360
+ samples = _np.frombuffer(raw, dtype=_np.float32).astype(_np.float64)
361
+ usable = (samples.size // CHANNELS) * CHANNELS
362
+ return samples[:usable].reshape(-1, CHANNELS)
363
+
364
+
365
+ def _pad_to(block: "Any", length: int) -> "Any":
366
+ if block.shape[0] >= length:
367
+ return block[:length]
368
+ return _np.vstack([block, _np.zeros((length - block.shape[0], CHANNELS))])
369
+
370
+
371
+ def duck_envelope(
372
+ length: int,
373
+ windows: Sequence[Dict[str, float]],
374
+ *,
375
+ duck_db: float,
376
+ attack_s: float,
377
+ release_s: float,
378
+ ) -> "Any":
379
+ """A gain envelope that is 1.0 outside the windows and `duck_db` inside them.
380
+
381
+ Ramps are linear in gain across the attack and release, placed so the bed is already
382
+ down at the window's start rather than beginning to move there.
383
+ """
384
+ _require()
385
+ envelope = _np.ones(length, dtype=_np.float64)
386
+ floor = float(10.0 ** (float(duck_db) / 20.0))
387
+ attack = max(1, int(round(float(attack_s) * SAMPLE_RATE)))
388
+ release = max(1, int(round(float(release_s) * SAMPLE_RATE)))
389
+
390
+ for window in windows:
391
+ start = int(round(float(window["start"]) * SAMPLE_RATE))
392
+ end = int(round(float(window["end"]) * SAMPLE_RATE))
393
+ start, end = max(0, start), min(length, end)
394
+ if end <= start:
395
+ continue
396
+ ramp_in_from = max(0, start - attack)
397
+ if start > ramp_in_from:
398
+ envelope[ramp_in_from:start] = _np.minimum(
399
+ envelope[ramp_in_from:start],
400
+ _np.linspace(1.0, floor, start - ramp_in_from),
401
+ )
402
+ envelope[start:end] = _np.minimum(envelope[start:end], floor)
403
+ ramp_out_to = min(length, end + release)
404
+ if ramp_out_to > end:
405
+ envelope[end:ramp_out_to] = _np.minimum(
406
+ envelope[end:ramp_out_to],
407
+ _np.linspace(floor, 1.0, ramp_out_to - end),
408
+ )
409
+ return envelope
410
+
411
+
412
+ def _write_wav(path: str, mix: "Any") -> str:
413
+ directory = os.path.dirname(os.path.abspath(path))
414
+ if directory:
415
+ os.makedirs(directory, exist_ok=True)
416
+ payload = _np.clip(mix, -1.0, 1.0).astype(_np.float32).tobytes()
417
+ code, _, stderr = _run(
418
+ ["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SAMPLE_RATE),
419
+ "-ac", str(CHANNELS), "-i", "pipe:0", "-c:a", "pcm_s24le", path],
420
+ stdin_bytes=payload,
421
+ )
422
+ if code != 0:
423
+ raise MixPlanError(f"could not write premix: {stderr[-300:]}")
424
+ return path
425
+
426
+
427
+ def render(
428
+ dialogue: Sequence[str],
429
+ *,
430
+ music: Optional[Sequence[str]] = None,
431
+ sfx: Optional[Sequence[str]] = None,
432
+ output_path: Optional[str] = None,
433
+ standard: str = DEFAULT_STANDARD,
434
+ target_lufs: Optional[float] = None,
435
+ bed_offset_lu: float = DEFAULT_BED_OFFSET_LU,
436
+ duck_db: float = DEFAULT_DUCK_DB,
437
+ attack_s: float = DEFAULT_ATTACK_S,
438
+ release_s: float = DEFAULT_RELEASE_S,
439
+ hold_s: float = DEFAULT_HOLD_S,
440
+ program_normalize: Optional[bool] = None,
441
+ ) -> Dict[str, Any]:
442
+ """Render the planned premix and report the loudness it actually achieved."""
443
+ plan_result = plan(
444
+ dialogue, music=music, sfx=sfx, standard=standard, target_lufs=target_lufs,
445
+ bed_offset_lu=bed_offset_lu, duck_db=duck_db, attack_s=attack_s,
446
+ release_s=release_s, hold_s=hold_s,
447
+ )
448
+
449
+ target = plan_result["target_lufs"]
450
+ dialogue_blocks = [_decode(item["path"]) for item in plan_result["dialogue"]["stems"]]
451
+ music_blocks = [(_decode(item["path"]), item["gain_db"]) for item in plan_result["music"]["stems"]]
452
+ sfx_blocks = [(_decode(item["path"]), item["gain_db"]) for item in plan_result["sfx"]["stems"]]
453
+
454
+ length = max(
455
+ [block.shape[0] for block in dialogue_blocks]
456
+ + [block.shape[0] for block, _ in music_blocks]
457
+ + [block.shape[0] for block, _ in sfx_blocks]
458
+ )
459
+ mix = _np.zeros((length, CHANNELS), dtype=_np.float64)
460
+
461
+ dialogue_gain = 10.0 ** (plan_result["dialogue"]["gain_db"] / 20.0)
462
+ for block in dialogue_blocks:
463
+ mix += _pad_to(block, length) * dialogue_gain
464
+ for block, gain_db in sfx_blocks:
465
+ mix += _pad_to(block, length) * (10.0 ** (gain_db / 20.0))
466
+
467
+ envelope = duck_envelope(
468
+ length, plan_result["ducking"]["windows"],
469
+ duck_db=duck_db, attack_s=attack_s, release_s=release_s,
470
+ )
471
+ for block, gain_db in music_blocks:
472
+ mix += _pad_to(block, length) * (10.0 ** (gain_db / 20.0)) * envelope[:, None]
473
+
474
+ peak_before_clip = float(_np.abs(mix).max()) if mix.size else 0.0
475
+ clipped_samples = int((_np.abs(mix) > 1.0).sum())
476
+
477
+ destination = output_path or os.path.join(
478
+ tempfile.mkdtemp(prefix="mix_plan_"), "premix.wav"
479
+ )
480
+ _write_wav(destination, mix)
481
+ achieved = measure(destination)
482
+
483
+ spec = plan_result["standard"]
484
+ # Dialogue-anchored mixing puts dialogue at target; adding a bed puts the PROGRAMME
485
+ # above it. For a full-programme standard that is a miss, so trim once — measured,
486
+ # applied to everything equally so the dialogue-to-bed relationship survives — and
487
+ # measure again. Never on a dialogue-gated standard, where dialogue is the thing
488
+ # being graded.
489
+ normalize = (
490
+ (not spec["dialogue_gated"]) if program_normalize is None else bool(program_normalize)
491
+ )
492
+ dialogue_anchored = dict(achieved)
493
+ program_trim_db = 0.0
494
+ if normalize and not spec["dialogue_gated"] and achieved["integrated_lufs"] is not None:
495
+ program_trim_db = round(target - float(achieved["integrated_lufs"]), 2)
496
+ if abs(program_trim_db) >= 0.1:
497
+ mix = mix * (10.0 ** (program_trim_db / 20.0))
498
+ peak_before_clip = float(_np.abs(mix).max()) if mix.size else 0.0
499
+ clipped_samples = int((_np.abs(mix) > 1.0).sum())
500
+ _write_wav(destination, mix)
501
+ achieved = measure(destination)
502
+ else:
503
+ program_trim_db = 0.0
504
+
505
+ flags = _flags(achieved, target, spec, clipped_samples, peak_before_clip)
506
+ return {
507
+ **plan_result,
508
+ "dry_run": False,
509
+ "renders": True,
510
+ "premix_path": destination,
511
+ "program_normalize": {
512
+ "applied": bool(program_trim_db),
513
+ "trim_db": program_trim_db,
514
+ "reason": (
515
+ "dialogue-gated standard: dialogue stays the anchor, the programme is not trimmed"
516
+ if spec["dialogue_gated"] else
517
+ "full-programme standard: the whole mix was trimmed equally so the "
518
+ "programme hits the target and the dialogue-to-bed relationship survives"
519
+ if program_trim_db else
520
+ "no trim needed" if normalize else "disabled by program_normalize=false"
521
+ ),
522
+ "dialogue_anchored_lufs": dialogue_anchored["integrated_lufs"],
523
+ },
524
+ "achieved": {
525
+ "integrated_lufs": achieved["integrated_lufs"],
526
+ "true_peak_dbtp": achieved["true_peak_dbtp"],
527
+ "loudness_range_lu": achieved["loudness_range_lu"],
528
+ "delta_from_target_lu": (
529
+ round(float(achieved["integrated_lufs"]) - target, 2)
530
+ if achieved["integrated_lufs"] is not None else None
531
+ ),
532
+ "peak_before_clip": round(peak_before_clip, 4),
533
+ "clipped_samples": clipped_samples,
534
+ },
535
+ "flags": flags,
536
+ "on_target": not flags,
537
+ "next": (
538
+ "Measured on the rendered premix, not derived from the plan. "
539
+ + ("Nothing to correct." if not flags else "Read the remedies before delivering.")
540
+ ),
541
+ }
542
+
543
+
544
+ def _flags(
545
+ achieved: Dict[str, Any],
546
+ target: float,
547
+ spec: Dict[str, Any],
548
+ clipped_samples: int,
549
+ peak_before_clip: float,
550
+ ) -> List[Dict[str, str]]:
551
+ """Every flag carries a remedy. Nothing here is corrected automatically."""
552
+ flags: List[Dict[str, str]] = []
553
+
554
+ def flag(flag_id: str, detail: str, remedy: str) -> None:
555
+ flags.append({"id": flag_id, "detail": detail, "remedy": remedy})
556
+
557
+ integrated = achieved.get("integrated_lufs")
558
+ tolerance = float(spec.get("tolerance_lu") or 0.0)
559
+ if integrated is None:
560
+ flag("unmeasurable", "the rendered premix produced no ebur128 summary",
561
+ "check the stems carry audio; do not deliver on the plan's arithmetic alone")
562
+ elif abs(float(integrated) - target) > tolerance:
563
+ flag(
564
+ "loudness_off_target",
565
+ f"achieved {integrated} LUFS against a target of {target} "
566
+ f"+/-{tolerance} LU",
567
+ "a stem shorter than the programme drags the integrated figure; check stem "
568
+ "lengths, or set target_lufs deliberately",
569
+ )
570
+
571
+ peak_max = spec.get("true_peak_max_dbtp")
572
+ true_peak = achieved.get("true_peak_dbtp")
573
+ if peak_max is not None and true_peak is not None and float(true_peak) > float(peak_max):
574
+ flag(
575
+ "true_peak_over",
576
+ f"true peak {true_peak} dBTP exceeds {peak_max} dBTP for {spec.get('id')}",
577
+ "lower the bed or the effects rather than the whole mix — trimming the mix "
578
+ "moves the integrated loudness off the target it just hit",
579
+ )
580
+ if clipped_samples:
581
+ flag(
582
+ "clipped",
583
+ f"{clipped_samples} samples exceeded full scale before the write "
584
+ f"(peak {round(peak_before_clip, 3)})",
585
+ "reduce the loudest stem's gain; the premix was written clipped, not "
586
+ "silently normalised",
587
+ )
588
+ return flags
589
+
590
+
591
+ def capabilities() -> Dict[str, Any]:
592
+ return {
593
+ "numpy_available": _np is not None,
594
+ "ffmpeg_available": shutil.which("ffmpeg") is not None,
595
+ "ffprobe_available": shutil.which("ffprobe") is not None,
596
+ "sample_rate": SAMPLE_RATE,
597
+ "channels": CHANNELS,
598
+ "standards": sorted(delivery_targets.LOUDNESS_STANDARDS),
599
+ "defaults": {
600
+ "standard": DEFAULT_STANDARD,
601
+ "bed_offset_lu": DEFAULT_BED_OFFSET_LU,
602
+ "duck_db": DEFAULT_DUCK_DB,
603
+ "attack_s": DEFAULT_ATTACK_S,
604
+ "release_s": DEFAULT_RELEASE_S,
605
+ "hold_s": DEFAULT_HOLD_S,
606
+ },
607
+ "note": (
608
+ "Rough mix only: gain staging, a music bed, and dialogue-following ducking, "
609
+ "measured after rendering. It does not EQ, compress, de-ess, or limit, and "
610
+ "it never corrects a flagged result automatically."
611
+ ),
612
+ }