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 +59 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +7 -0
- package/docs/kernels/audio-fairlight-kernel.md +33 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +50 -1
- package/src/utils/mix_plan.py +612 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
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.
|
|
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
|
+
}
|