wizz-method 2.11.2 → 2.13.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/README.md +2 -1
- package/package.json +1 -1
- package/skills-registry.yaml +38 -29
- package/src/bmm-skills/module.yaml +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
- package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
- package/src/modules/wizz/_shared/video-pipeline.md +12 -4
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-maestro/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
- package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +3 -2
- package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
- package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
- package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
- package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
- package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
- package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
- package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
- package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
- package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
- package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
- package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
- package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
- package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
- package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
- package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
- package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
- package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
- package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
- package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
- package/tools/installer/banner.js +80 -29
- package/tools/installer/commands/catalog.js +87 -0
- package/tools/installer/commands/install.js +9 -0
- package/tools/installer/guided/detect-tools.js +53 -0
- package/tools/installer/guided/flow.js +203 -0
- package/tools/installer/guided/labels.js +185 -0
- package/tools/installer/guided/messages.js +132 -0
- package/tools/installer/guided/outro.js +32 -0
- package/tools/installer/guided/profiles.js +95 -0
- package/tools/installer/guided/steps.js +154 -0
- package/tools/installer/install-messages.yaml +3 -3
- package/tools/installer/modules/external-manager.js +4 -0
- package/tools/installer/ui.js +57 -22
- package/wizz-modules.yaml +1 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Map story happenings to the SFX that marks them, and duck the narration.
|
|
3
|
+
|
|
4
|
+
Why it exists. Without a rule that ties a sound to a happening, every SFX is
|
|
5
|
+
picked by hand, and by hand the neutral one always wins (the source project
|
|
6
|
+
measured two files covering almost every use while nine sat idle). Whoever
|
|
7
|
+
assembles asks for "verdict" or "message", never "stamp.mp3". The table
|
|
8
|
+
event -> file -> volume lives in the config (`sound.events`); this module only
|
|
9
|
+
holds the logic that accepts or refuses a request.
|
|
10
|
+
|
|
11
|
+
Three refusals, applied in this order to each request (sorted by t):
|
|
12
|
+
1. unknown_event never falls back to a default sound. A wrong sound
|
|
13
|
+
is worse than no sound.
|
|
14
|
+
2. collision closer than sound.collision_window_s to the last
|
|
15
|
+
ACCEPTED shot (a refused request never plays, so
|
|
16
|
+
there is nothing to collide with).
|
|
17
|
+
3. same_sound_in_row the same file cannot repeat more than
|
|
18
|
+
sound.max_same_sound_in_row times without another
|
|
19
|
+
ACCEPTED sound between them.
|
|
20
|
+
|
|
21
|
+
Volume always comes from the config: `map_requests` does not even take a
|
|
22
|
+
volume argument. The ceiling relative to the voice is validated in config.py.
|
|
23
|
+
|
|
24
|
+
Ducking. `ducking_envelope` returns narration duck windows, applied ONLY
|
|
25
|
+
where a SFX sounds over the start of a spoken word. Numbers come from the
|
|
26
|
+
`ducking` block of the config (marked there as an extrapolation).
|
|
27
|
+
|
|
28
|
+
CLI:
|
|
29
|
+
python3 sound.py coverage [--config PATH] [--sfx-dir DIR]
|
|
30
|
+
python3 sound.py duck TIMELINE.json [--words WORDS.json] [--write]
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from config import Config, ConfigError, add_config_arg, load
|
|
43
|
+
import timeline as tl
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ structures
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Shot:
|
|
50
|
+
t: float
|
|
51
|
+
event: str # semantic key requested
|
|
52
|
+
src: str # "sfx/stamp.mp3", via cfg.sound.src()
|
|
53
|
+
vol: float
|
|
54
|
+
triggers: tuple[str, ...] # visual effects this sound also fires
|
|
55
|
+
strength: float | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SoundRefusal:
|
|
60
|
+
t: float
|
|
61
|
+
event: str
|
|
62
|
+
rule: str # "unknown_event" | "same_sound_in_row" | "collision"
|
|
63
|
+
detail: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class SoundResult:
|
|
68
|
+
shots: tuple[Shot, ...] # sorted by t
|
|
69
|
+
refusals: tuple[SoundRefusal, ...]
|
|
70
|
+
|
|
71
|
+
def by_sound(self) -> dict[str, int]:
|
|
72
|
+
"""Accepted shots per FILE (several events can share one file)."""
|
|
73
|
+
count: dict[str, int] = {}
|
|
74
|
+
for s in self.shots:
|
|
75
|
+
name = Path(s.src).stem
|
|
76
|
+
count[name] = count.get(name, 0) + 1
|
|
77
|
+
return count
|
|
78
|
+
|
|
79
|
+
def report(self) -> str:
|
|
80
|
+
lines = [f"shots: {len(self.shots)} refusals: {len(self.refusals)}"]
|
|
81
|
+
if self.shots:
|
|
82
|
+
lines.append("by sound:")
|
|
83
|
+
for name, n in sorted(self.by_sound().items(), key=lambda kv: (-kv[1], kv[0])):
|
|
84
|
+
lines.append(f" {name:12} {n}")
|
|
85
|
+
if self.refusals:
|
|
86
|
+
by_rule: dict[str, int] = {}
|
|
87
|
+
for r in self.refusals:
|
|
88
|
+
by_rule[r.rule] = by_rule.get(r.rule, 0) + 1
|
|
89
|
+
lines.append("refusals by rule:")
|
|
90
|
+
for rule, n in sorted(by_rule.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
91
|
+
lines.append(f" {rule:20} {n}")
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------------- map
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def map_requests(requests: Iterable[tuple[float, str]], cfg: Config) -> SoundResult:
|
|
99
|
+
"""Accept or refuse each (t, event) request. Evaluated on the final order
|
|
100
|
+
by t, never on arrival order. sorted() is stable: two requests at the same
|
|
101
|
+
t keep arrival order, which decides who is 'first' on a tie."""
|
|
102
|
+
ordered = sorted(requests, key=lambda r: r[0])
|
|
103
|
+
window = cfg.sound.collision_window_s
|
|
104
|
+
limit = cfg.sound.max_same_sound_in_row
|
|
105
|
+
shots: list[Shot] = []
|
|
106
|
+
refusals: list[SoundRefusal] = []
|
|
107
|
+
last_t: float | None = None
|
|
108
|
+
last_sound: str | None = None
|
|
109
|
+
in_row = 0
|
|
110
|
+
|
|
111
|
+
for t, event in ordered:
|
|
112
|
+
ev = cfg.sound.events.get(event)
|
|
113
|
+
if ev is None:
|
|
114
|
+
refusals.append(SoundRefusal(
|
|
115
|
+
t, event, "unknown_event",
|
|
116
|
+
f"unknown sound event {event!r}. Valid events: "
|
|
117
|
+
f"{', '.join(sorted(cfg.sound.events))}"))
|
|
118
|
+
continue
|
|
119
|
+
if last_t is not None and (t - last_t) < window:
|
|
120
|
+
refusals.append(SoundRefusal(
|
|
121
|
+
t, event, "collision",
|
|
122
|
+
f"{t - last_t:.3f}s from the previous accepted shot "
|
|
123
|
+
f"(t={last_t:.3f}s); minimum {window}s"))
|
|
124
|
+
continue
|
|
125
|
+
candidate_row = in_row + 1 if ev.sound == last_sound else 1
|
|
126
|
+
if candidate_row > limit:
|
|
127
|
+
refusals.append(SoundRefusal(
|
|
128
|
+
t, event, "same_sound_in_row",
|
|
129
|
+
f"'{ev.sound}' already played {in_row}x in a row (max {limit}); "
|
|
130
|
+
f"needs another accepted sound between them"))
|
|
131
|
+
continue
|
|
132
|
+
shots.append(Shot(t=t, event=event, src=cfg.sound.src(event), vol=ev.vol,
|
|
133
|
+
triggers=ev.triggers, strength=ev.strength))
|
|
134
|
+
last_t, last_sound, in_row = t, ev.sound, candidate_row
|
|
135
|
+
|
|
136
|
+
return SoundResult(shots=tuple(shots), refusals=tuple(refusals))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def event_of(entry: dict, cfg: Config) -> str | None:
|
|
140
|
+
"""Semantic event of a sound already in the timeline: its `event`, or
|
|
141
|
+
rebuilt from the (file, vol) pair when that pair is unique in the config.
|
|
142
|
+
Ambiguous or unknown pairs return None; the caller decides, never guessed."""
|
|
143
|
+
if entry.get("event") in cfg.sound.events:
|
|
144
|
+
return entry["event"]
|
|
145
|
+
name, vol = Path(str(entry.get("src", ""))).stem, float(entry.get("vol", -1))
|
|
146
|
+
found = [k for k, e in cfg.sound.events.items() if e.sound == name and abs(e.vol - vol) < 1e-9]
|
|
147
|
+
return found[0] if len(found) == 1 else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def effect_candidates(res: SoundResult) -> list[dict]:
|
|
151
|
+
"""Bridge to budget.py: one plain dict per visual effect a shot fires.
|
|
152
|
+
Plain dicts on purpose, so the two modules never import each other.
|
|
153
|
+
|
|
154
|
+
`strength` comes from the event's own field (narrative weight), never from
|
|
155
|
+
the volume. Without it, vol is the fallback, and the reason says so."""
|
|
156
|
+
out = []
|
|
157
|
+
for s in res.shots:
|
|
158
|
+
fallback = s.strength is None
|
|
159
|
+
for kind in s.triggers:
|
|
160
|
+
reason = f"sound:{s.event}" + (" (strength from volume)" if fallback else "")
|
|
161
|
+
out.append({"t": s.t, "kind": kind,
|
|
162
|
+
"strength": s.vol if fallback else s.strength,
|
|
163
|
+
"reason": reason})
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -------------------------------------------------------------------- coverage
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def coverage(cfg: Config, sfx_dir: str | Path | None = None) -> dict:
|
|
171
|
+
"""Disk vs config, both directions: a sound without event is idle work; an
|
|
172
|
+
event without file is a build that has not broken yet, but will."""
|
|
173
|
+
folder = Path(sfx_dir) if sfx_dir is not None else cfg.sfx_dir
|
|
174
|
+
exists = folder.is_dir()
|
|
175
|
+
on_disk = sorted(p.stem for p in folder.glob("*.mp3")) if exists else []
|
|
176
|
+
used = cfg.sound.sounds_used()
|
|
177
|
+
return {
|
|
178
|
+
"sfx_dir": str(folder),
|
|
179
|
+
"sfx_dir_exists": exists,
|
|
180
|
+
"on_disk": on_disk,
|
|
181
|
+
"sounds_without_event": sorted(set(on_disk) - used),
|
|
182
|
+
"events_without_file": sorted(
|
|
183
|
+
k for k, e in cfg.sound.events.items() if e.sound not in on_disk),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------- ducking
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _sfx_interval(s: dict, cfg: Config, durations: dict[str, float]) -> tuple[float, float]:
|
|
191
|
+
start = float(s["at"])
|
|
192
|
+
dur = s.get("dur")
|
|
193
|
+
if dur is None:
|
|
194
|
+
dur = durations.get(str(s.get("src", "")), cfg.ducking.assumed_sfx_duration_s)
|
|
195
|
+
return start, start + float(dur)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _merge(windows: list[dict]) -> list[dict]:
|
|
199
|
+
"""Union of overlapping windows, deepest depth wins."""
|
|
200
|
+
merged: list[dict] = []
|
|
201
|
+
for w in sorted(windows, key=lambda w: w["start"]):
|
|
202
|
+
if merged and w["start"] <= merged[-1]["end"]:
|
|
203
|
+
last = merged[-1]
|
|
204
|
+
merged[-1] = {**last, "end": max(last["end"], w["end"]),
|
|
205
|
+
"depth_db": max(last["depth_db"], w["depth_db"]),
|
|
206
|
+
"cause": last["cause"] + "; " + w["cause"]}
|
|
207
|
+
else:
|
|
208
|
+
merged.append(dict(w))
|
|
209
|
+
return merged
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ducking_envelope(sfx: Iterable[dict], words: Iterable[dict], cfg: Config,
|
|
213
|
+
fps: int | None = None,
|
|
214
|
+
durations: dict[str, float] | None = None) -> list[dict]:
|
|
215
|
+
"""Narration duck windows, one per SFX that sounds over a word onset.
|
|
216
|
+
|
|
217
|
+
A SFX collides when a word `start` falls inside [sfx.at - attack, sfx end].
|
|
218
|
+
The window ramps down over `attack` so full depth is in place when the SFX
|
|
219
|
+
hits, holds until the SFX ends, then releases over `release`. Windows that
|
|
220
|
+
overlap are merged. Output shape: references/data-contract.md, `ducking[]`.
|
|
221
|
+
"""
|
|
222
|
+
d = cfg.ducking
|
|
223
|
+
fps = fps or cfg.frame.fps
|
|
224
|
+
durations = durations or {}
|
|
225
|
+
onsets = sorted((float(w["start"]), str(w.get("text", ""))) for w in words)
|
|
226
|
+
windows = []
|
|
227
|
+
for s in sorted(sfx, key=lambda s: float(s["at"])):
|
|
228
|
+
start, end = _sfx_interval(s, cfg, durations)
|
|
229
|
+
hit = next(((t, txt) for t, txt in onsets if start - d.attack_s <= t <= end), None)
|
|
230
|
+
if hit is None:
|
|
231
|
+
continue
|
|
232
|
+
label = s.get("event") or Path(str(s.get("src", "?"))).stem
|
|
233
|
+
windows.append({
|
|
234
|
+
"start": round(max(0.0, start - d.attack_s), 4),
|
|
235
|
+
"end": round(end + d.release_s, 4),
|
|
236
|
+
"depth_db": d.depth_db, "attack_s": d.attack_s, "release_s": d.release_s,
|
|
237
|
+
"cause": f"{label}@{start:.3f}s over word '{hit[1]}'@{hit[0]:.3f}s"})
|
|
238
|
+
merged = _merge(windows)
|
|
239
|
+
for w in merged:
|
|
240
|
+
w["from_frame"] = int(w["start"] * fps)
|
|
241
|
+
w["to_frame"] = int(-(-w["end"] * fps // 1))
|
|
242
|
+
return merged
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def gain_db_at(t: float, windows: Iterable[dict]) -> float:
|
|
246
|
+
"""Narration gain in dB at instant t (0.0 outside every window). The
|
|
247
|
+
Remotion volume callback in data-contract.md is this same function."""
|
|
248
|
+
g = 0.0
|
|
249
|
+
for w in windows:
|
|
250
|
+
s, e = w["start"], w["end"]
|
|
251
|
+
if not s <= t <= e:
|
|
252
|
+
continue
|
|
253
|
+
shape = 1.0
|
|
254
|
+
if t < s + w["attack_s"]:
|
|
255
|
+
shape = (t - s) / w["attack_s"]
|
|
256
|
+
elif t > e - w["release_s"]:
|
|
257
|
+
shape = (e - t) / w["release_s"]
|
|
258
|
+
g = min(g, -w["depth_db"] * max(0.0, min(1.0, shape)))
|
|
259
|
+
return g
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def probe_duration(path: Path) -> float | None:
|
|
263
|
+
"""Duration of a media file via ffprobe, or None when it cannot be read."""
|
|
264
|
+
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
265
|
+
"-of", "csv=p=0", str(path)], capture_output=True, text=True)
|
|
266
|
+
try:
|
|
267
|
+
return float(r.stdout.strip())
|
|
268
|
+
except ValueError:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------------- CLI
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _cmd_coverage(cfg: Config, sfx_dir: str | None) -> int:
|
|
276
|
+
cov = coverage(cfg, sfx_dir)
|
|
277
|
+
print(f"sfx dir: {cov['sfx_dir']} (exists: {cov['sfx_dir_exists']})")
|
|
278
|
+
print(f"sounds on disk ({len(cov['on_disk'])}): {', '.join(cov['on_disk']) or '-'}")
|
|
279
|
+
idle = cov["sounds_without_event"]
|
|
280
|
+
print(f"sounds without event ({len(idle)}): {', '.join(idle) or 'none'}")
|
|
281
|
+
missing = cov["events_without_file"]
|
|
282
|
+
if missing:
|
|
283
|
+
print(f"events without file, broken build ({len(missing)}): {', '.join(missing)}")
|
|
284
|
+
return 1
|
|
285
|
+
print("events without file: none")
|
|
286
|
+
print("ok: coverage has no problem.")
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _cmd_duck(cfg: Config, args) -> int:
|
|
291
|
+
data = tl.load(args.timeline)
|
|
292
|
+
words = json.loads(Path(args.words).read_text(encoding="utf-8")) if args.words \
|
|
293
|
+
else data.get("words") or []
|
|
294
|
+
if not words:
|
|
295
|
+
print("ERROR: no words: pass --words (ctc-align output) or put words[] in the timeline")
|
|
296
|
+
return 2
|
|
297
|
+
sfx_list = tl.sounds(data)
|
|
298
|
+
sfx_dir = Path(args.sfx_dir) if args.sfx_dir else cfg.sfx_dir
|
|
299
|
+
durations = {}
|
|
300
|
+
for s in sfx_list:
|
|
301
|
+
src = str(s.get("src", ""))
|
|
302
|
+
if src and "dur" not in s and src not in durations:
|
|
303
|
+
dur = probe_duration(sfx_dir / Path(src).name)
|
|
304
|
+
if dur is not None:
|
|
305
|
+
durations[src] = dur
|
|
306
|
+
windows = ducking_envelope(sfx_list, words, cfg, tl.fps(data, cfg.frame.fps), durations)
|
|
307
|
+
print(f"sounds: {len(sfx_list)} duck windows: {len(windows)}")
|
|
308
|
+
for w in windows:
|
|
309
|
+
print(f" {w['start']:7.3f}s -> {w['end']:7.3f}s -{w['depth_db']:.1f} dB {w['cause']}")
|
|
310
|
+
if args.write:
|
|
311
|
+
tl.save(args.timeline, {**data, "ducking": windows})
|
|
312
|
+
print(f"written: {args.timeline}")
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv: list[str] | None = None) -> int:
|
|
317
|
+
ap = argparse.ArgumentParser(description="Sound coverage audit and narration ducking.")
|
|
318
|
+
add_config_arg(ap)
|
|
319
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
320
|
+
cov = sub.add_parser("coverage", help="compare SFX on disk with the config events")
|
|
321
|
+
cov.add_argument("--sfx-dir", help="SFX folder (default: from the config paths)")
|
|
322
|
+
duck = sub.add_parser("duck", help="compute narration duck windows for a timeline")
|
|
323
|
+
duck.add_argument("timeline", help="timeline JSON (see references/data-contract.md)")
|
|
324
|
+
duck.add_argument("--words", help="ctc-align words JSON (default: timeline words[])")
|
|
325
|
+
duck.add_argument("--sfx-dir", help="SFX folder, to probe durations")
|
|
326
|
+
duck.add_argument("--write", action="store_true", help="write ducking[] into the timeline")
|
|
327
|
+
for sp in (cov, duck): # --config also accepted after the subcommand
|
|
328
|
+
sp.add_argument("--config", metavar="PATH", default=argparse.SUPPRESS,
|
|
329
|
+
help="effects config JSON (same as before the subcommand)")
|
|
330
|
+
args = ap.parse_args(argv)
|
|
331
|
+
try:
|
|
332
|
+
cfg = load(args.config)
|
|
333
|
+
if args.cmd == "coverage":
|
|
334
|
+
return _cmd_coverage(cfg, args.sfx_dir)
|
|
335
|
+
return _cmd_duck(cfg, args)
|
|
336
|
+
except (ConfigError, tl.TimelineError) as e:
|
|
337
|
+
print(f"ERROR: {e}")
|
|
338
|
+
return 2
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
sys.exit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tests of the anti-excess engine (budget.py).
|
|
3
|
+
|
|
4
|
+
Each test builds a synthetic budget (never the template numbers) to isolate
|
|
5
|
+
the rule it proves: a future change of the project numbers does not break
|
|
6
|
+
these tests by accident.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import unittest
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from budget import Candidate, select
|
|
14
|
+
from config import Budget, CurveSection, EffectRule
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class _FakeCfg:
|
|
19
|
+
"""budget.py only reads `budget`."""
|
|
20
|
+
budget: Budget
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _single_section() -> tuple[CurveSection, ...]:
|
|
24
|
+
return (CurveSection(name="all", until_frac=1.0, weight=1.0),)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rule(max_per_min=60.0, gap_min=0.0, max_in_row=99, dominant=False) -> EffectRule:
|
|
28
|
+
return EffectRule(max_per_min=max_per_min, gap_min=gap_min, max_in_row=max_in_row,
|
|
29
|
+
dominant=dominant)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cfg(per_effect, curve=None, gap_min_global=0.0) -> _FakeCfg:
|
|
33
|
+
return _FakeCfg(budget=Budget(gap_min_global=gap_min_global, burst_gap_s=5.0,
|
|
34
|
+
per_effect=per_effect, curve=curve or _single_section()))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def C(t, kind, strength, reason="x"):
|
|
38
|
+
return Candidate(t=t, kind=kind, strength=strength, reason=reason)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestCeiling(unittest.TestCase):
|
|
42
|
+
def test_ceiling_kept_and_strongest_win(self):
|
|
43
|
+
cfg = _cfg({"punch": _rule(max_per_min=2.0)})
|
|
44
|
+
res = select([C(1, "punch", .9), C(10, "punch", .8), C(20, "punch", .7),
|
|
45
|
+
C(30, "punch", .6)], total=60.0, cfg=cfg)
|
|
46
|
+
self.assertEqual({c.t for c in res.accepted}, {1, 10})
|
|
47
|
+
self.assertTrue(all(r.rule == "ceiling" for r in res.refusals))
|
|
48
|
+
|
|
49
|
+
def test_heavier_section_gets_its_own_slice(self):
|
|
50
|
+
curve = (CurveSection("hook", 0.1, 2.0), CurveSection("body", 1.0, 1.0))
|
|
51
|
+
cfg = _cfg({"punch": _rule(max_per_min=6.0)}, curve=curve)
|
|
52
|
+
# total ceiling 6; hook slice = 6*12/66 ~ 1.09 -> 1.
|
|
53
|
+
res = select([C(1, "punch", .9), C(2, "punch", .8)], total=60.0, cfg=cfg)
|
|
54
|
+
self.assertEqual(len(res.accepted), 1)
|
|
55
|
+
self.assertEqual(res.refusals[0].rule, "ceiling")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestGap(unittest.TestCase):
|
|
59
|
+
def test_same_kind_gap(self):
|
|
60
|
+
cfg = _cfg({"punch": _rule(gap_min=2.0)})
|
|
61
|
+
res = select([C(1.0, "punch", .9), C(1.5, "punch", .5)], total=30.0, cfg=cfg)
|
|
62
|
+
self.assertEqual([c.t for c in res.accepted], [1.0])
|
|
63
|
+
self.assertEqual(res.refusals[0].rule, "gap_min")
|
|
64
|
+
|
|
65
|
+
def test_global_gap_only_holds_dominant(self):
|
|
66
|
+
cfg = _cfg({"punch": _rule(dominant=True), "flash": _rule()}, gap_min_global=5.0)
|
|
67
|
+
res = select([C(1, "punch", .9), C(2, "punch", .8), C(2, "flash", .7)],
|
|
68
|
+
total=30.0, cfg=cfg)
|
|
69
|
+
acc = {(c.t, c.kind) for c in res.accepted}
|
|
70
|
+
self.assertEqual(acc, {(1, "punch"), (2, "flash")})
|
|
71
|
+
self.assertEqual(res.refusals[0].rule, "gap_min_global")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestInRow(unittest.TestCase):
|
|
75
|
+
def test_max_in_row(self):
|
|
76
|
+
cfg = _cfg({"flash": _rule(max_in_row=2)})
|
|
77
|
+
res = select([C(1, "flash", .9), C(2, "flash", .8), C(3, "flash", .7)], total=30.0, cfg=cfg)
|
|
78
|
+
self.assertEqual([c.t for c in res.accepted], [1, 2])
|
|
79
|
+
self.assertEqual(res.refusals[0].rule, "max_in_row")
|
|
80
|
+
|
|
81
|
+
def test_counted_on_final_time_order_not_processing_order(self):
|
|
82
|
+
cfg = _cfg({"shake": _rule(max_in_row=2)})
|
|
83
|
+
res = select([C(10, "shake", .9, "A"), C(1, "shake", .8, "B"),
|
|
84
|
+
C(5, "shake", .7, "C, squeezed between B and A")], total=30.0, cfg=cfg)
|
|
85
|
+
self.assertEqual(sorted(c.t for c in res.accepted), [1, 10])
|
|
86
|
+
self.assertEqual(res.refusals[0].rule, "max_in_row")
|
|
87
|
+
|
|
88
|
+
def test_bursts_far_apart_do_not_chain(self):
|
|
89
|
+
cfg = _cfg({"punch": _rule(max_in_row=1)})
|
|
90
|
+
res = select([C(1, "punch", .9), C(20, "punch", .8)], total=60.0, cfg=cfg)
|
|
91
|
+
self.assertEqual(len(res.accepted), 2)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestMisc(unittest.TestCase):
|
|
95
|
+
def test_strength_breaks_the_tie(self):
|
|
96
|
+
cfg = _cfg({"flash": _rule(max_per_min=1.0)})
|
|
97
|
+
res = select([C(1, "flash", .3, "weak"), C(2, "flash", .9, "strong")], total=60.0, cfg=cfg)
|
|
98
|
+
self.assertEqual(res.accepted[0].reason, "strong")
|
|
99
|
+
|
|
100
|
+
def test_unknown_kind(self):
|
|
101
|
+
res = select([C(1, "magic-sparkle", .9)], total=30.0, cfg=_cfg({"punch": _rule()}))
|
|
102
|
+
self.assertEqual(res.refusals[0].rule, "unknown_kind")
|
|
103
|
+
|
|
104
|
+
def test_empty(self):
|
|
105
|
+
res = select([], total=30.0, cfg=_cfg({"punch": _rule()}))
|
|
106
|
+
self.assertEqual((res.accepted, res.refusals, res.by_kind()), ((), (), {}))
|
|
107
|
+
|
|
108
|
+
def test_accepts_dicts_from_sound_py(self):
|
|
109
|
+
res = select([{"t": 1.0, "kind": "punch", "strength": 0.5}], total=30.0,
|
|
110
|
+
cfg=_cfg({"punch": _rule()}))
|
|
111
|
+
self.assertEqual(len(res.accepted), 1)
|
|
112
|
+
|
|
113
|
+
def test_bad_dict_names_the_problem(self):
|
|
114
|
+
with self.assertRaises(TypeError):
|
|
115
|
+
select([{"t": 1.0}], total=30.0, cfg=_cfg({"punch": _rule()}))
|
|
116
|
+
|
|
117
|
+
def test_deterministic(self):
|
|
118
|
+
cfg = _cfg({"punch": _rule(10, 3.5, 2, True), "flash": _rule(10, 2.5, 3)},
|
|
119
|
+
gap_min_global=2.0)
|
|
120
|
+
cands = [C(.5, "punch", .7), C(1.2, "flash", .6), C(4, "punch", .9),
|
|
121
|
+
C(4.1, "flash", .9), C(6, "punch", .5), C(9, "unknown", .9)]
|
|
122
|
+
self.assertEqual(select(cands, 20.0, cfg), select(list(reversed(cands)), 20.0, cfg))
|
|
123
|
+
|
|
124
|
+
def test_report_is_text(self):
|
|
125
|
+
res = select([C(1, "punch", .9), C(1.1, "punch", .1)], total=30.0,
|
|
126
|
+
cfg=_cfg({"punch": _rule(gap_min=1.0)}))
|
|
127
|
+
self.assertIn("refusals", res.report())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
unittest.main()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Tests of check_loudness.py: parsing of the loudnorm JSON (no media), the
|
|
3
|
+
gate decision, the printed fix command, and an optional integration test on
|
|
4
|
+
synthetic audio generated by ffmpeg (skipped when ffmpeg is absent)."""
|
|
5
|
+
import math
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import check_loudness as CL
|
|
14
|
+
from config import TEMPLATE, load
|
|
15
|
+
|
|
16
|
+
LO = load(TEMPLATE).loudness
|
|
17
|
+
|
|
18
|
+
STDERR = """[Parsed_loudnorm_0 @ 0x600]
|
|
19
|
+
{
|
|
20
|
+
\t"input_i" : "-19.52",
|
|
21
|
+
\t"input_tp" : "-4.10",
|
|
22
|
+
\t"input_lra" : "3.20",
|
|
23
|
+
\t"input_thresh" : "-29.80",
|
|
24
|
+
\t"output_i" : "-14.03",
|
|
25
|
+
\t"output_tp" : "-1.00",
|
|
26
|
+
\t"output_lra" : "2.90",
|
|
27
|
+
\t"output_thresh" : "-24.30",
|
|
28
|
+
\t"normalization_type" : "dynamic",
|
|
29
|
+
\t"target_offset" : "0.03"
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
SILENT = STDERR.replace('"-19.52"', '"-inf"').replace('"-4.10"', '"-inf"')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _m(i, tp):
|
|
37
|
+
return {"input_i": i, "input_tp": tp, "input_lra": 3.0, "input_thresh": -24.0,
|
|
38
|
+
"target_offset": 0.0}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestParse(unittest.TestCase):
|
|
42
|
+
def test_parse_values(self):
|
|
43
|
+
m = CL.parse_loudnorm("noise before {not json}\n" + STDERR)
|
|
44
|
+
self.assertEqual(m["input_i"], -19.52)
|
|
45
|
+
self.assertEqual(m["input_tp"], -4.10)
|
|
46
|
+
self.assertEqual(m["target_offset"], 0.03)
|
|
47
|
+
|
|
48
|
+
def test_silence_is_minus_inf(self):
|
|
49
|
+
m = CL.parse_loudnorm(SILENT)
|
|
50
|
+
self.assertTrue(math.isinf(m["input_i"]))
|
|
51
|
+
|
|
52
|
+
def test_no_json_raises(self):
|
|
53
|
+
with self.assertRaises(CL.LoudnessError):
|
|
54
|
+
CL.parse_loudnorm("ffmpeg: no such file")
|
|
55
|
+
|
|
56
|
+
def test_missing_field_raises(self):
|
|
57
|
+
with self.assertRaises(CL.LoudnessError):
|
|
58
|
+
CL.parse_loudnorm('{"input_i": "-14"}')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestGate(unittest.TestCase):
|
|
62
|
+
def test_on_target_passes(self):
|
|
63
|
+
self.assertEqual(CL.evaluate(_m(LO.target_lufs, LO.max_true_peak_db - 0.5), LO), [])
|
|
64
|
+
|
|
65
|
+
def test_edges_of_tolerance(self):
|
|
66
|
+
self.assertEqual(CL.evaluate(_m(LO.target_lufs - LO.tolerance_lu, -3.0), LO), [])
|
|
67
|
+
self.assertTrue(CL.evaluate(_m(LO.target_lufs - LO.tolerance_lu - 0.1, -3.0), LO))
|
|
68
|
+
self.assertTrue(CL.evaluate(_m(LO.target_lufs + LO.tolerance_lu + 0.1, -3.0), LO))
|
|
69
|
+
|
|
70
|
+
def test_true_peak_over_ceiling_fails(self):
|
|
71
|
+
errs = CL.evaluate(_m(LO.target_lufs, LO.max_true_peak_db + 0.2), LO)
|
|
72
|
+
self.assertTrue(any("true peak" in e for e in errs))
|
|
73
|
+
|
|
74
|
+
def test_silence_fails(self):
|
|
75
|
+
self.assertTrue(CL.evaluate(CL.parse_loudnorm(SILENT), LO))
|
|
76
|
+
|
|
77
|
+
def test_fix_command_is_second_pass(self):
|
|
78
|
+
m = CL.parse_loudnorm(STDERR)
|
|
79
|
+
cmd = CL.fix_command(Path("out/piece.mp4"), m, LO)
|
|
80
|
+
self.assertIn("measured_I=-19.52", cmd)
|
|
81
|
+
self.assertIn("measured_TP=-4.1", cmd)
|
|
82
|
+
self.assertIn("linear=true", cmd)
|
|
83
|
+
self.assertIn(f"I={LO.target_lufs:g}", cmd)
|
|
84
|
+
self.assertIn("-c:v copy", cmd)
|
|
85
|
+
self.assertIn("piece.loudnorm.mp4", cmd)
|
|
86
|
+
self.assertNotIn("-c:v", CL.fix_command(Path("voice.wav"), m, LO))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_linear_when_the_gain_fits_under_the_peak(self):
|
|
90
|
+
# needs +2 dB; peak goes from -6 to -4, under -1 minus the lossy margin
|
|
91
|
+
self.assertIsNone(CL.dynamic_warning(_m(LO.target_lufs - 2, -6.0), Path("p.mp4"), LO))
|
|
92
|
+
|
|
93
|
+
def test_warns_when_the_gain_will_compress(self):
|
|
94
|
+
# needs +12.8 dB on a -10 dBTP peak: linear would clip, loudnorm compresses
|
|
95
|
+
w = CL.dynamic_warning(_m(LO.target_lufs - 12.8, -10.0), Path("p.mp4"), LO)
|
|
96
|
+
self.assertIsNotNone(w)
|
|
97
|
+
self.assertIn("dynamic", w)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg not installed")
|
|
101
|
+
class TestIntegration(unittest.TestCase):
|
|
102
|
+
def _gen(self, tmp, name, src):
|
|
103
|
+
p = Path(tmp) / name
|
|
104
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", src,
|
|
105
|
+
"-t", "4", str(p)], check=True)
|
|
106
|
+
return p
|
|
107
|
+
|
|
108
|
+
def test_sine_measures_and_silence_fails(self):
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
110
|
+
tone = self._gen(tmp, "tone.wav", "sine=frequency=440:sample_rate=48000")
|
|
111
|
+
m, errs = CL.check(tone, LO)
|
|
112
|
+
self.assertTrue(-40.0 < m["input_i"] < 0.0, m)
|
|
113
|
+
self.assertEqual(bool(errs), abs(m["input_i"] - LO.target_lufs) > LO.tolerance_lu
|
|
114
|
+
or m["input_tp"] > LO.max_true_peak_db)
|
|
115
|
+
silent = self._gen(tmp, "silent.wav", "anullsrc=r=48000:cl=mono")
|
|
116
|
+
_, errs = CL.check(silent, LO)
|
|
117
|
+
self.assertTrue(errs)
|
|
118
|
+
|
|
119
|
+
def test_fix_command_brings_tone_to_target(self):
|
|
120
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
121
|
+
tone = self._gen(tmp, "tone.wav", "sine=frequency=440:sample_rate=48000")
|
|
122
|
+
m, _ = CL.check(tone, LO)
|
|
123
|
+
fixed = Path(tmp) / "tone.loudnorm.wav"
|
|
124
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(tone),
|
|
125
|
+
"-af", CL.loudnorm_filter(LO, m), "-ar", "48000", str(fixed)],
|
|
126
|
+
check=True)
|
|
127
|
+
m2, errs = CL.check(fixed, LO)
|
|
128
|
+
self.assertEqual(errs, [], m2)
|
|
129
|
+
|
|
130
|
+
def test_fix_command_passes_the_gate_on_an_aac_mp4(self):
|
|
131
|
+
# Found assembling a real piece (rodada 5, 2026-09-24): the printed fix
|
|
132
|
+
# targeted TP exactly at the ceiling, then re-encoded to AAC, and the
|
|
133
|
+
# lossy encode overshot it (-1.0 -> -0.8 dBTP). The gate failed its
|
|
134
|
+
# own fix. The command must leave room for the encoder.
|
|
135
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
136
|
+
src = Path(tmp) / "quiet.mp4"
|
|
137
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y",
|
|
138
|
+
"-f", "lavfi", "-i", "anoisesrc=color=pink:amplitude=0.08:"
|
|
139
|
+
"sample_rate=48000:seed=7",
|
|
140
|
+
"-f", "lavfi", "-i", "color=c=black:s=64x64:r=30",
|
|
141
|
+
"-t", "6", "-c:v", "libx264", "-c:a", "aac", "-b:a", "192k",
|
|
142
|
+
"-shortest", str(src)], check=True)
|
|
143
|
+
m, errs = CL.check(src, LO)
|
|
144
|
+
self.assertTrue(errs, "fixture must start outside the target")
|
|
145
|
+
cmd = shlex.split(CL.fix_command(src, m, LO))
|
|
146
|
+
subprocess.run([cmd[0], "-v", "error", "-y"] + cmd[1:], check=True)
|
|
147
|
+
m2, errs2 = CL.check(src.with_name("quiet.loudnorm.mp4"), LO)
|
|
148
|
+
self.assertEqual(errs2, [], m2)
|
|
149
|
+
|
|
150
|
+
def test_missing_file_is_an_error(self):
|
|
151
|
+
with self.assertRaises(CL.LoudnessError):
|
|
152
|
+
CL.check(Path("/no/such/piece.mp4"), LO)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
unittest.main()
|