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.
Files changed (83) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/skills-registry.yaml +38 -29
  4. package/src/bmm-skills/module.yaml +1 -1
  5. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/04-criativos.md +1 -1
  6. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/11-checklist-roi.md +1 -1
  7. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/12-mineracao-ofertas.md +1 -1
  8. package/src/modules/lowticket/skills/wizz-lowticket-metodologia/knowledge/pontos-em-aberto.md +2 -2
  9. package/src/modules/wizz/_shared/video-pipeline.md +12 -4
  10. package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
  11. package/src/modules/wizz/agents/wizz-maestro/SKILL.md +1 -1
  12. package/src/modules/wizz/agents/wizz-social/SKILL.md +1 -0
  13. package/src/skills-lib/wizz-remotion-best-practices/rules/sfx.md +3 -1
  14. package/src/skills-lib/wizz-router/references/routing-table-flat.md +3 -2
  15. package/src/skills-lib/wizz-video-effects/SKILL.md +85 -0
  16. package/src/skills-lib/wizz-video-effects/assets/effects.config.template.json +178 -0
  17. package/src/skills-lib/wizz-video-effects/assets/remotion/EffectsLayer.tsx +87 -0
  18. package/src/skills-lib/wizz-video-effects/assets/remotion/README.md +37 -0
  19. package/src/skills-lib/wizz-video-effects/assets/remotion/useEffects.ts +120 -0
  20. package/src/skills-lib/wizz-video-effects/assets/sfx/CREDITS.md +56 -0
  21. package/src/skills-lib/wizz-video-effects/assets/sfx/cash.mp3 +0 -0
  22. package/src/skills-lib/wizz-video-effects/assets/sfx/chime.mp3 +0 -0
  23. package/src/skills-lib/wizz-video-effects/assets/sfx/click-soft.mp3 +0 -0
  24. package/src/skills-lib/wizz-video-effects/assets/sfx/click.mp3 +0 -0
  25. package/src/skills-lib/wizz-video-effects/assets/sfx/coin.mp3 +0 -0
  26. package/src/skills-lib/wizz-video-effects/assets/sfx/error.mp3 +0 -0
  27. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-1.mp3 +0 -0
  28. package/src/skills-lib/wizz-video-effects/assets/sfx/impact-bass-2.mp3 +0 -0
  29. package/src/skills-lib/wizz-video-effects/assets/sfx/key-press.mp3 +0 -0
  30. package/src/skills-lib/wizz-video-effects/assets/sfx/notif.mp3 +0 -0
  31. package/src/skills-lib/wizz-video-effects/assets/sfx/notification.mp3 +0 -0
  32. package/src/skills-lib/wizz-video-effects/assets/sfx/paste.mp3 +0 -0
  33. package/src/skills-lib/wizz-video-effects/assets/sfx/ping.mp3 +0 -0
  34. package/src/skills-lib/wizz-video-effects/assets/sfx/pop.mp3 +0 -0
  35. package/src/skills-lib/wizz-video-effects/assets/sfx/riser-metal.mp3 +0 -0
  36. package/src/skills-lib/wizz-video-effects/assets/sfx/riser.mp3 +0 -0
  37. package/src/skills-lib/wizz-video-effects/assets/sfx/send.mp3 +0 -0
  38. package/src/skills-lib/wizz-video-effects/assets/sfx/sparkle.mp3 +0 -0
  39. package/src/skills-lib/wizz-video-effects/assets/sfx/stamp.mp3 +0 -0
  40. package/src/skills-lib/wizz-video-effects/assets/sfx/success.mp3 +0 -0
  41. package/src/skills-lib/wizz-video-effects/assets/sfx/type.mp3 +0 -0
  42. package/src/skills-lib/wizz-video-effects/assets/sfx/typing.mp3 +0 -0
  43. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-cinematic.mp3 +0 -0
  44. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh-short.mp3 +0 -0
  45. package/src/skills-lib/wizz-video-effects/assets/sfx/whoosh.mp3 +0 -0
  46. package/src/skills-lib/wizz-video-effects/references/audio.md +87 -0
  47. package/src/skills-lib/wizz-video-effects/references/data-contract.md +145 -0
  48. package/src/skills-lib/wizz-video-effects/references/do-not-use.md +54 -0
  49. package/src/skills-lib/wizz-video-effects/references/editing-patterns.md +58 -0
  50. package/src/skills-lib/wizz-video-effects/references/gates.md +37 -0
  51. package/src/skills-lib/wizz-video-effects/references/sfx-licensing.md +40 -0
  52. package/src/skills-lib/wizz-video-effects/references/when-to-use.md +129 -0
  53. package/src/skills-lib/wizz-video-effects/scripts/budget.py +211 -0
  54. package/src/skills-lib/wizz-video-effects/scripts/check_excess.py +218 -0
  55. package/src/skills-lib/wizz-video-effects/scripts/check_loudness.py +175 -0
  56. package/src/skills-lib/wizz-video-effects/scripts/check_rhythm.py +137 -0
  57. package/src/skills-lib/wizz-video-effects/scripts/check_safe_zone.py +221 -0
  58. package/src/skills-lib/wizz-video-effects/scripts/config.py +583 -0
  59. package/src/skills-lib/wizz-video-effects/scripts/fill_breaths.py +355 -0
  60. package/src/skills-lib/wizz-video-effects/scripts/measure_density.py +122 -0
  61. package/src/skills-lib/wizz-video-effects/scripts/normalize_audio.py +189 -0
  62. package/src/skills-lib/wizz-video-effects/scripts/sound.py +342 -0
  63. package/src/skills-lib/wizz-video-effects/scripts/test_budget.py +131 -0
  64. package/src/skills-lib/wizz-video-effects/scripts/test_check_loudness.py +156 -0
  65. package/src/skills-lib/wizz-video-effects/scripts/test_checks.py +190 -0
  66. package/src/skills-lib/wizz-video-effects/scripts/test_config.py +135 -0
  67. package/src/skills-lib/wizz-video-effects/scripts/test_fill_breaths.py +179 -0
  68. package/src/skills-lib/wizz-video-effects/scripts/test_sound.py +173 -0
  69. package/src/skills-lib/wizz-video-effects/scripts/timeline.py +85 -0
  70. package/tools/installer/banner.js +80 -29
  71. package/tools/installer/commands/catalog.js +87 -0
  72. package/tools/installer/commands/install.js +9 -0
  73. package/tools/installer/guided/detect-tools.js +53 -0
  74. package/tools/installer/guided/flow.js +203 -0
  75. package/tools/installer/guided/labels.js +185 -0
  76. package/tools/installer/guided/messages.js +132 -0
  77. package/tools/installer/guided/outro.js +32 -0
  78. package/tools/installer/guided/profiles.js +95 -0
  79. package/tools/installer/guided/steps.js +154 -0
  80. package/tools/installer/install-messages.yaml +3 -3
  81. package/tools/installer/modules/external-manager.js +4 -0
  82. package/tools/installer/ui.js +57 -22
  83. 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()