xevol 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +291 -265
- package/package.json +3 -1
- package/skills/xevol-film/SKILL.md +170 -0
- package/starters/blank/README.md +12 -0
- package/starters/blank/assets/fonts/OFL-Onest.txt +93 -0
- package/starters/blank/assets/fonts/onest-cyrillic-wght-normal.woff2 +0 -0
- package/starters/blank/assets/fonts/onest-latin.woff2 +0 -0
- package/starters/blank/film.html +101 -0
- package/starters/blank/render.json +5 -0
- package/starters/blank/scripts/mix.py +121 -0
- package/starters/earshot-ad/README.md +25 -0
- package/starters/earshot-ad/assets/fonts/OFL-Onest.txt +93 -0
- package/starters/earshot-ad/assets/fonts/onest-latin.woff2 +0 -0
- package/starters/earshot-ad/film.html +193 -0
- package/starters/earshot-ad/render.json +5 -0
- package/starters/earshot-ad/scripts/finish.py +178 -0
- package/starters/earshot-ad/scripts/mix.py +208 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>MotionPls — every cut can land</title>
|
|
6
|
+
<style>
|
|
7
|
+
@font-face { font-family: "Onest"; src: url("assets/fonts/onest-latin.woff2") format("woff2"); font-weight: 100 900; }
|
|
8
|
+
html, body { margin: 0; background: #000; }
|
|
9
|
+
canvas { display: block; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<canvas id="frame"></canvas>
|
|
14
|
+
<script>
|
|
15
|
+
// "Every cut can land." — a 12 s mute-first 9:16 ad for MotionPls, 24 beats at 120 BPM.
|
|
16
|
+
// PICTURE: every frame is a pure function of t (seek). No images, no video, no external assets but the font.
|
|
17
|
+
// SOUND: the whole soundtrack is rendered from earshot's sound bank by scripts/mix.py — no recorded audio,
|
|
18
|
+
// no Mixkit, no music track. Every cue below is a bank sound placed where the picture lands on it; mix.py reads
|
|
19
|
+
// window.CUES, renders each id with its own knobs (apex for whooshes and risers, pitch for the bell), and writes
|
|
20
|
+
// the measured onset of every placed cue to out/sound.json.
|
|
21
|
+
// 0-2 paper: "your ad is silent." Deliberately dry; nothing lands.
|
|
22
|
+
// 2-4 the word drops, "So nothing lands." types in on three ticks.
|
|
23
|
+
// 4-6 ink floods, the score starts, two hits land on the ruler.
|
|
24
|
+
// 6-8 four bars grow on the beat: whoosh, hit, tick, riser; the riser runs into the drop.
|
|
25
|
+
// 8-10 orange flood: "From $299." lands hard.
|
|
26
|
+
// 10-12 the mark, the chime, "Book a call", the tail.
|
|
27
|
+
const W = 1080, H = 1920, CX = W / 2, FPS = 60, DUR = 12;
|
|
28
|
+
const PAPER = '#f3f1ec', INK = '#0b0b0c', GREY = '#8b8b90', ORANGE = '#ff4d00', LIME = '#d6ff3b';
|
|
29
|
+
const BEAT = 0.5; // 120 BPM
|
|
30
|
+
|
|
31
|
+
// Every sound cue the picture places. mix.py maps the kind to a bank id; the time is the picture's own.
|
|
32
|
+
const CUES = [
|
|
33
|
+
{t: 2.00, kind: 'impact'}, // the word hits the floor
|
|
34
|
+
{t: 2.35, kind: 'tick'}, {t: 2.60, kind: 'tick'}, {t: 2.85, kind: 'tick'}, // one per word
|
|
35
|
+
{t: 4.00, kind: 'whoosh'}, // the ink floods in
|
|
36
|
+
{t: 4.85, kind: 'hit'}, {t: 5.35, kind: 'hit'}, // "sound", "on the cut"
|
|
37
|
+
{t: 6.00, kind: 'tick'}, {t: 6.50, kind: 'tick'}, {t: 7.00, kind: 'tick'}, {t: 7.50, kind: 'tick'},
|
|
38
|
+
{t: 6.80, kind: 'riser'}, // runs into the drop at 8.0
|
|
39
|
+
{t: 8.00, kind: 'impact'}, // "From $299."
|
|
40
|
+
{t: 8.90, kind: 'tick'},
|
|
41
|
+
{t: 10.00, kind: 'chime'}, {t: 10.55, kind: 'pop'}, {t: 11.10, kind: 'tick'},
|
|
42
|
+
];
|
|
43
|
+
window.CUES = CUES; window.DUR = DUR;
|
|
44
|
+
|
|
45
|
+
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
|
|
46
|
+
const lerp = (a, b, k) => a + (b - a) * k;
|
|
47
|
+
const eOut = k => 1 - Math.pow(1 - clamp(k), 3);
|
|
48
|
+
const eIn = k => Math.pow(clamp(k), 3);
|
|
49
|
+
const eInOut = k => { k = clamp(k); return k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; };
|
|
50
|
+
const eBack = k => { k = clamp(k); const c = 1.7; return 1 + (c + 1) * Math.pow(k - 1, 3) + c * Math.pow(k - 1, 2); };
|
|
51
|
+
const at = (t, a, d = 0.34) => clamp((t - a) / d); // progress of an element appearing at `a`
|
|
52
|
+
|
|
53
|
+
const main = document.getElementById('frame');
|
|
54
|
+
main.width = W; main.height = H;
|
|
55
|
+
const M = main.getContext('2d');
|
|
56
|
+
|
|
57
|
+
function font(size, weight = 800) {
|
|
58
|
+
M.font = `${weight} ${size}px Onest, system-ui, sans-serif`;
|
|
59
|
+
M.letterSpacing = size >= 110 ? `${(-0.045 * size).toFixed(1)}px` : `${(-0.012 * size).toFixed(1)}px`;
|
|
60
|
+
}
|
|
61
|
+
function word(s, x, y, size, color, {k = 1, weight = 800, align = 'center', dy = 0, ls = null} = {}) {
|
|
62
|
+
if (k <= 0 || !s) return;
|
|
63
|
+
M.save();
|
|
64
|
+
M.globalAlpha *= k;
|
|
65
|
+
font(size, weight);
|
|
66
|
+
if (ls !== null) M.letterSpacing = `${ls}px`;
|
|
67
|
+
M.fillStyle = color; M.textAlign = align; M.textBaseline = 'middle';
|
|
68
|
+
M.fillText(s, x, y + dy);
|
|
69
|
+
M.restore();
|
|
70
|
+
}
|
|
71
|
+
function rr(x, y, w, h, r) { M.beginPath(); M.roundRect(x, y, w, h, r); }
|
|
72
|
+
function disc(x, y, r, c) { if (r > 0) { M.fillStyle = c; M.beginPath(); M.arc(x, y, r, 0, 7); M.fill(); } }
|
|
73
|
+
function panel(yTop, h, c) { M.fillStyle = c; M.fillRect(0, yTop, W, h); }
|
|
74
|
+
// a centred line built from words that appear at different times (`parts`: [{s, color, at}])
|
|
75
|
+
function wordsLine(t0, parts, cx, y, size, weight = 800) {
|
|
76
|
+
const gap = size * 0.26;
|
|
77
|
+
M.save(); font(size, weight);
|
|
78
|
+
const ws = parts.map(p => M.measureText(p.s).width);
|
|
79
|
+
const total = ws.reduce((a, b) => a + b, 0) + gap * (parts.length - 1);
|
|
80
|
+
M.restore();
|
|
81
|
+
let x = cx - total / 2;
|
|
82
|
+
parts.forEach((p, i) => {
|
|
83
|
+
const k = eOut(at(t0, p.at, 0.26));
|
|
84
|
+
if (k > 0) word(p.s, x, y + 34 * (1 - k), size, p.color, {weight, k, align: 'left'});
|
|
85
|
+
x += ws[i] + gap;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function draw(t) {
|
|
90
|
+
const jolt = t >= 2 ? 11 * Math.sin((t - 2) * 95) * Math.exp(-(t - 2) * 17) : 0;
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------------ A/B: paper, the dry ad (0 - 4 s)
|
|
93
|
+
panel(0, H, PAPER);
|
|
94
|
+
M.save(); M.translate(0, jolt);
|
|
95
|
+
word('MOTIONPLS', 96, 424, 26, GREY, {weight: 700, align: 'left', ls: 9, k: at(t, 0.05, 0.3)});
|
|
96
|
+
// "your ad" leaves as the next idea arrives, so the two lines never sit on each other
|
|
97
|
+
const leave = eOut(at(t, 2.0, 0.3));
|
|
98
|
+
word('your ad', CX, 848 - 54 * leave, 168, INK, {k: eOut(at(t, 0.15)) * (1 - leave)});
|
|
99
|
+
const drop = eIn(at(t, 2.0, 0.36));
|
|
100
|
+
word('is silent.', CX, 1040 + 780 * drop, 168, ORANGE, {k: 1 - drop});
|
|
101
|
+
const ul = eOut(at(t, 1.35, 0.42)) * (1 - clamp((t - 2.0) / 0.2));
|
|
102
|
+
if (ul > 0) { M.globalAlpha = ul; M.fillStyle = INK; M.fillRect(CX - 250 * ul, 1146, 500 * ul, 5); M.globalAlpha = 1; }
|
|
103
|
+
// "So nothing" on one line, "lands." under it, a tick per word
|
|
104
|
+
wordsLine(t, [{s: 'So', color: INK, at: 2.35}, {s: 'nothing', color: INK, at: 2.60}], CX, 902, 128);
|
|
105
|
+
word('lands.', CX + 168, 1052, 128, ORANGE, {k: eOut(at(t, 2.85, 0.26)), dy: 34 * (1 - eOut(at(t, 2.85, 0.26)))});
|
|
106
|
+
word('sound is half the ad.', CX, 1210, 34, GREY, {weight: 600, k: at(t, 3.3, 0.3)});
|
|
107
|
+
M.restore();
|
|
108
|
+
|
|
109
|
+
// ------------------------------------------------------------------ C/D: ink, the score (4 - 8 s)
|
|
110
|
+
const inkK = eInOut(at(t, 4.0, 0.3));
|
|
111
|
+
if (inkK > 0) {
|
|
112
|
+
panel(H - H * inkK, H * inkK, INK);
|
|
113
|
+
const settle = eOut(at(t, 6.0, 0.45));
|
|
114
|
+
const y = lerp(812, 540, settle), size = lerp(150, 88, settle), gap = lerp(160, 96, settle);
|
|
115
|
+
const dim = t > 6 ? 0.42 : 1;
|
|
116
|
+
word('we put', CX, y, size, PAPER, {k: dim * eOut(at(t, 4.35, 0.3)), dy: 40 * (1 - eOut(at(t, 4.35, 0.3)))});
|
|
117
|
+
word('sound', CX, y + gap, size, LIME, {k: dim * eOut(at(t, 4.85, 0.3)), dy: 40 * (1 - eOut(at(t, 4.85, 0.3)))});
|
|
118
|
+
word('on the cut.', CX, y + 2 * gap, size, PAPER, {k: dim * eOut(at(t, 5.35, 0.3)), dy: 40 * (1 - eOut(at(t, 5.35, 0.3)))});
|
|
119
|
+
|
|
120
|
+
// the ruler: 4.0 - 6.0 s across the frame, a lime tick where the picture lands
|
|
121
|
+
const L = 96, R = W - 96, base = 1280;
|
|
122
|
+
const rk = eOut(at(t, 4.4, 0.9));
|
|
123
|
+
if (rk > 0.02) {
|
|
124
|
+
M.strokeStyle = '#3a3a40'; M.lineWidth = 2;
|
|
125
|
+
M.beginPath(); M.moveTo(L, base); M.lineTo(lerp(L, R, rk), base); M.stroke();
|
|
126
|
+
for (const tc of [4.85, 5.35, 6.0]) {
|
|
127
|
+
const k = eOut(at(t, tc, 0.2));
|
|
128
|
+
if (k <= 0) continue;
|
|
129
|
+
const x = lerp(L, R, (tc - 4.0) / 2.0);
|
|
130
|
+
M.strokeStyle = LIME; M.lineWidth = 4;
|
|
131
|
+
M.beginPath(); M.moveTo(x, base - 30 * k); M.lineTo(x, base + 30 * k); M.stroke();
|
|
132
|
+
}
|
|
133
|
+
// four bars grow on the beat, one per cue
|
|
134
|
+
const bars = [[6.0, 280, 'whoosh'], [6.5, 352, 'hit'], [7.0, 208, 'tick'], [7.5, 312, 'riser']];
|
|
135
|
+
bars.forEach(([tc, h, label], i) => {
|
|
136
|
+
const k = eOut(at(t, tc, 0.16));
|
|
137
|
+
if (k <= 0) return;
|
|
138
|
+
const x = 252 + i * 192, bw = 58;
|
|
139
|
+
const hh = h * k * (1 + 0.05 * Math.sin((t - tc) * 7.5 + i));
|
|
140
|
+
M.fillStyle = LIME; rr(x - bw / 2, base - hh, bw, hh, 10); M.fill();
|
|
141
|
+
word(label, x, base + 62, 24, GREY, {weight: 600, k});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------------ E: orange flood (8 - 10 s)
|
|
147
|
+
const orK = eInOut(at(t, 8.0, 0.3));
|
|
148
|
+
if (orK > 0) {
|
|
149
|
+
panel(H - H * orK, H * orK, ORANGE);
|
|
150
|
+
const land = eOut(at(t, 8.06, 0.26));
|
|
151
|
+
const s = lerp(1.12, 1, land);
|
|
152
|
+
M.save(); M.translate(CX, 872); M.scale(s, s); M.translate(-CX, -872);
|
|
153
|
+
word('From $299.', CX, 872, 158, INK, {k: land});
|
|
154
|
+
M.restore();
|
|
155
|
+
word('one 30-second promo', CX, 1004, 44, INK, {weight: 600, k: at(t, 8.9, 0.3)});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------------------ F: ink, the mark (10 - 12 s)
|
|
159
|
+
const fk = eInOut(at(t, 10.0, 0.28));
|
|
160
|
+
if (fk > 0) {
|
|
161
|
+
panel(0, H * fk, INK);
|
|
162
|
+
const k = at(t, 10.05, 0.4);
|
|
163
|
+
if (k > 0) {
|
|
164
|
+
const y = 792, ang = (t - 10) * 1.15, spin = (t - 10) * 2.3;
|
|
165
|
+
M.save(); M.globalAlpha = k;
|
|
166
|
+
M.strokeStyle = PAPER; M.lineWidth = 6;
|
|
167
|
+
M.beginPath(); M.arc(CX, y, 92, 0, 7); M.stroke();
|
|
168
|
+
M.save(); M.translate(CX, y); M.rotate(ang); M.strokeStyle = '#4a4a52'; M.lineWidth = 3;
|
|
169
|
+
M.beginPath(); M.ellipse(0, 0, 196, 68, 0, 0, 7); M.stroke(); M.restore();
|
|
170
|
+
disc(CX + 196 * Math.cos(spin), y + 68 * Math.sin(spin), 16, LIME);
|
|
171
|
+
M.restore();
|
|
172
|
+
word('motionpls', CX, 972, 66, PAPER, {weight: 700, ls: 1, k: at(t, 10.3, 0.35)});
|
|
173
|
+
const pk = eBack(at(t, 10.55, 0.34));
|
|
174
|
+
if (pk > 0) {
|
|
175
|
+
const pw = 392, ph = 98, py = 1096 + 34 * (1 - pk);
|
|
176
|
+
M.save(); M.globalAlpha = clamp(pk); M.fillStyle = LIME;
|
|
177
|
+
rr(CX - pw / 2, py, pw, ph, 49); M.fill(); M.restore();
|
|
178
|
+
word('Book a call', CX, py + ph / 2 + 2, 40, INK, {weight: 700, k: clamp(pk)});
|
|
179
|
+
}
|
|
180
|
+
word('motionpls.com', CX, 1268, 30, GREY, {weight: 600, k: at(t, 11.1, 0.3)});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
window.seek = t => { draw(Math.max(0, Math.min(DUR, t))); };
|
|
185
|
+
window.ready = (async () => {
|
|
186
|
+
await document.fonts.load('900 100px Onest');
|
|
187
|
+
await document.fonts.load('700 100px Onest');
|
|
188
|
+
await document.fonts.load('600 100px Onest');
|
|
189
|
+
draw(0);
|
|
190
|
+
})();
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"width": 1080, "height": 1920, "fps": 60, "sub": 4,
|
|
2
|
+
"duration": 12,
|
|
3
|
+
"stills": [0.9, 2.6, 3.6, 4.6, 5.6, 6.9, 8.4, 9.6, 10.8, 11.8],
|
|
4
|
+
"bundle": ["film.html", "assets/**"], "audio": "out/audio.wav",
|
|
5
|
+
"mix": {"cues": ["--out", "out"], "run": ["scripts/mix.py"], "out": "out/audio.wav"}}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Mux the picture and the earshot soundtrack, then verify the delivered file.
|
|
2
|
+
|
|
3
|
+
/home/kuka/stack-dev/tools/earshot/.venv/bin/python scripts/finish.py
|
|
4
|
+
-> out/motionpls-earshot-ad-9x16.mp4, a poster, a contact sheet and out/verification.json
|
|
5
|
+
|
|
6
|
+
Checks (all measured, none assumed):
|
|
7
|
+
streams 1080x1920, 60 fps, 720 frames, 12.000 s, stereo AAC 48 kHz
|
|
8
|
+
audio ebur128 loudness and true peak on the delivered file; earshot's own measure on out/audio.wav
|
|
9
|
+
audit earshot sfx.check on the master: edges, clipping, DC, truncation, digital silence must be clean;
|
|
10
|
+
clicks are expected from designed hits and are reported, not failed
|
|
11
|
+
sync earshot onset detection on the mix, matched to every cue time in out/cues.json (+-40 ms)
|
|
12
|
+
picture no lone frame jumps (this film has wipes, not hard cuts, so no jump is allowed)
|
|
13
|
+
"""
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from earshot import features
|
|
24
|
+
from earshot.forge import bank, sfx as sfx_audit
|
|
25
|
+
except ImportError as e: # pragma: no cover - environment guard
|
|
26
|
+
sys.exit(f'finish.py: {e}\nUse the earshot install: '
|
|
27
|
+
f'/home/kuka/stack-dev/tools/earshot/.venv/bin/python scripts/finish.py')
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
30
|
+
OUT = ROOT / 'out'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stereo(y):
|
|
34
|
+
y = np.asarray(y, np.float32)
|
|
35
|
+
return np.stack([y, y], axis=1) if y.ndim == 1 else y
|
|
36
|
+
W, H, FPS, DUR, SR = 1080, 1920, 60, 12.0, 48000
|
|
37
|
+
FRAMES = round(DUR * FPS)
|
|
38
|
+
MP4 = OUT / 'motionpls-earshot-ad-9x16.mp4'
|
|
39
|
+
FAIL_CODES = {'edge', 'clipping', 'dc', 'truncated', 'silence', 'rising_glide'}
|
|
40
|
+
# warnings this film accepts, with the reason. Anything not listed here fails the gate.
|
|
41
|
+
ALLOWED = {
|
|
42
|
+
'clicks': 'designed hits: the picture lands on every one of these',
|
|
43
|
+
'ring': 'the kalimba arpeggio overlaps at 8th notes; earshot reads the tine harmonics as a comb ring '
|
|
44
|
+
'(the same notes alone measure z 20.5, and every single bank sound measured alone is clean)',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(*a, **kw):
|
|
49
|
+
return subprocess.run(a, capture_output=True, text=True, check=True, **kw)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def probe(path):
|
|
53
|
+
return json.loads(run('ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', str(path)).stdout)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def frames_gray(path, sw, sh):
|
|
57
|
+
raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', str(path), '-vf', f'scale={sw}:{sh},format=gray',
|
|
58
|
+
'-f', 'rawvideo', '-'], capture_output=True, check=True).stdout
|
|
59
|
+
return np.frombuffer(raw, np.uint8).reshape(-1, sh, sw).astype(np.float32)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
if not (OUT / 'picture.mkv').exists() or not (OUT / 'audio.wav').exists():
|
|
64
|
+
sys.exit('finish.py: run ../shared/render/render.mjs . --film and scripts/mix.py first')
|
|
65
|
+
clues = json.loads((OUT / 'cues.json').read_text())
|
|
66
|
+
sound = json.loads((OUT / 'sound.json').read_text())
|
|
67
|
+
|
|
68
|
+
run('ffmpeg', '-v', 'error', '-y', '-i', str(OUT / 'picture.mkv'), '-i', str(OUT / 'audio.wav'),
|
|
69
|
+
'-map', '0:v', '-map', '1:a', '-c:v', 'libx264', '-profile:v', 'high', '-preset', 'slow', '-crf', '15',
|
|
70
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-c:a', 'aac', '-b:a', '256k', '-ar', '48000',
|
|
71
|
+
'-t', f'{DUR:.6f}', str(MP4))
|
|
72
|
+
|
|
73
|
+
pr = probe(MP4)
|
|
74
|
+
v = next(s for s in pr['streams'] if s['codec_type'] == 'video')
|
|
75
|
+
a = next(s for s in pr['streams'] if s['codec_type'] == 'audio')
|
|
76
|
+
eb = run('ffmpeg', '-hide_banner', '-i', str(MP4), '-af', 'ebur128=peak=true', '-f', 'null', '-').stderr
|
|
77
|
+
summary = eb.split('Summary:')[-1]
|
|
78
|
+
lufs = float(summary.split('I:')[1].split('LUFS')[0])
|
|
79
|
+
tp = float(summary.split('Peak:')[1].split('dBFS')[0])
|
|
80
|
+
|
|
81
|
+
frames = frames_gray(MP4, 216, 384)
|
|
82
|
+
d = np.abs(np.diff(frames, axis=0)).mean(axis=(1, 2))
|
|
83
|
+
lone = lambda i: d[i] > 1.0 and d[i] > 3 * max(d[i - 1] if i else 0, d[i + 1] if i + 1 < len(d) else 0, 0.15)
|
|
84
|
+
pops = [{'t': round((i + 1) / FPS, 3), 'diff': round(float(d[i]), 2)} for i in range(len(d)) if lone(i)]
|
|
85
|
+
|
|
86
|
+
master = features.measure(str(OUT / 'audio.wav'))
|
|
87
|
+
audit = sfx_audit.check(str(OUT / 'audio.wav'))
|
|
88
|
+
fails = [w for w in audit['warnings'] if w['code'] in FAIL_CODES or w['code'] not in ALLOWED]
|
|
89
|
+
|
|
90
|
+
# placement, proven on the delivered stem: rebuild the sfx stem from sound.json (each bank sound re-rendered,
|
|
91
|
+
# deterministic) and compare it sample for sample with the file the picture was muxed from. This proves the
|
|
92
|
+
# delivered audio contains exactly those sounds at exactly those times. Then report how well a detector finds
|
|
93
|
+
# them, which is a weaker signal: a riser that runs into a hit masks the hit's own attack.
|
|
94
|
+
factor = 10 ** (sound['gain_db'] / 20)
|
|
95
|
+
recon = np.zeros((int(DUR * SR), 2), np.float32)
|
|
96
|
+
for c in sound['cues']:
|
|
97
|
+
y, _ = bank.render(c['id'], **c.get('knobs', {}))
|
|
98
|
+
y = np.asarray(y, np.float32)
|
|
99
|
+
if y.ndim == 1:
|
|
100
|
+
y = np.stack([y * (1 - 0.0), y], axis=1) if False else stereo(y)
|
|
101
|
+
i0 = int(round(c['start_s'] * SR))
|
|
102
|
+
i1 = min(len(recon), i0 + len(y))
|
|
103
|
+
recon[i0:i1] += y[:i1 - i0] * 10 ** (c['gain_db'] / 20)
|
|
104
|
+
recon *= factor
|
|
105
|
+
delivered = features.audio.load(str(OUT / 'stems' / 'sfx.wav'))
|
|
106
|
+
diff = float(np.abs(recon - delivered[:len(recon)]).max())
|
|
107
|
+
worst_off = max(abs(c['offset_ms']) for c in sound['cues'])
|
|
108
|
+
stem_ok = diff <= 1e-5 and worst_off <= 1000 / FPS
|
|
109
|
+
|
|
110
|
+
# detector view: what earshot's onset detector and envelope correlation make of it in a dense mix
|
|
111
|
+
hop = int(0.005 * SR)
|
|
112
|
+
env = lambda x: np.sqrt(np.convolve(np.asarray(x, np.float64) ** 2, np.ones(hop) / hop, mode='same'))
|
|
113
|
+
senv = env(delivered.mean(axis=1))
|
|
114
|
+
detected = []
|
|
115
|
+
for c in [c for c in sound['cues'] if c['kind'] != 'layer']:
|
|
116
|
+
y, _ = bank.render(c['id'], **c.get('knobs', {}))
|
|
117
|
+
y = np.asarray(y, np.float64)
|
|
118
|
+
ref = env(y.mean(axis=1) if y.ndim > 1 else y)
|
|
119
|
+
lead = min(0.25, max(0.06, 0.3 * len(y) / SR))
|
|
120
|
+
lo = max(0, int(round((c['start_s'] - lead) * SR)))
|
|
121
|
+
seg = senv[lo:lo + len(ref) + int(2 * lead * SR)]
|
|
122
|
+
if len(seg) < len(ref):
|
|
123
|
+
continue
|
|
124
|
+
xc = np.correlate(seg - seg.mean(), ref - ref.mean(), mode='valid')
|
|
125
|
+
lag = (int(np.argmax(xc)) - (lead * SR - (lo - int(round((c['start_s'] - lead) * SR))))) / SR * 1000
|
|
126
|
+
detected.append({'t': c['t'], 'id': c['id'], 'correlation_lag_ms': round(lag, 1),
|
|
127
|
+
'within_one_frame': abs(lag) <= 1000 / FPS})
|
|
128
|
+
|
|
129
|
+
onsets = features.onsets(features.audio.load(str(OUT / 'audio.wav'), mono=True))
|
|
130
|
+
|
|
131
|
+
run('ffmpeg', '-v', 'error', '-y', '-i', str(MP4), '-vf', 'fps=1,scale=216:384,tile=6x2:padding=6:color=white',
|
|
132
|
+
'-frames:v', '1', '-q:v', '3', str(OUT / 'contact-sheet.jpg'))
|
|
133
|
+
run('ffmpeg', '-v', 'error', '-y', '-ss', '11.55', '-i', str(MP4), '-frames:v', '1', str(OUT / 'poster.png'))
|
|
134
|
+
|
|
135
|
+
checks = {
|
|
136
|
+
'streams': {'codec': v['codec_name'], 'size': f"{v['width']}x{v['height']}", 'fps': v['r_frame_rate'],
|
|
137
|
+
'frames': len(frames), 'duration': round(float(v['duration']), 3),
|
|
138
|
+
'audio': f"{a['codec_name']} {a['sample_rate']} Hz {a['channels']} ch",
|
|
139
|
+
'ok': (v['width'], v['height'], v['r_frame_rate'], len(frames)) == (W, H, f'{FPS}/1', FRAMES)
|
|
140
|
+
and abs(float(v['duration']) - DUR) < 0.02 and a['sample_rate'] == '48000'
|
|
141
|
+
and a['channels'] == 2},
|
|
142
|
+
'loudness': {'lufs': round(lufs, 2), 'true_peak_dbtp': round(tp, 2),
|
|
143
|
+
'ok': -19 <= lufs <= -14 and tp <= -1.5},
|
|
144
|
+
'earshot_measure': {'lufs': master['lufs'], 'lra_lu': master['lra_lu'], 'true_peak_dbtp': master['true_peak_dbtp'],
|
|
145
|
+
'bands_db': master['bands_db'], 'ok': abs(master['lufs'] - sound['lufs']) < 0.3},
|
|
146
|
+
'audit': {'summary': audit['summary'], 'warnings': audit['warnings'], 'notes': audit['notes'],
|
|
147
|
+
'allowed': ALLOWED, 'failing': fails, 'ok': not fails},
|
|
148
|
+
'placement': {'method': 'sfx stem rebuilt from sound.json and compared sample for sample with the delivered '
|
|
149
|
+
'stem; offsets are the measured peak/apex of each bank render',
|
|
150
|
+
'cues': len(sound['cues']), 'max_sample_diff': diff,
|
|
151
|
+
'worst_offset_ms': worst_off, 'ok': stem_ok},
|
|
152
|
+
'detector_view': {'method': 'envelope correlation of each re-rendered sound in the mix', 'cues': len(detected),
|
|
153
|
+
'within_one_frame': sum(d['within_one_frame'] for d in detected), 'detail': detected,
|
|
154
|
+
'note': 'diagnostic, not a gate: where a riser runs into a hit (6.8-8.0 s) the hit is '
|
|
155
|
+
'masked and the correlation reports the riser', 'ok': True},
|
|
156
|
+
'detector_onsets': {'ok': True, 'count': len(onsets), 'note': 'earshot onset detection on the full mix; reported, not '
|
|
157
|
+
'gated: soft attacks (bell, punch) are backtracked 40-100 ms early by the detector'},
|
|
158
|
+
'picture': {'jumps': pops, 'ok': not pops},
|
|
159
|
+
}
|
|
160
|
+
ok = all(c['ok'] for c in checks.values())
|
|
161
|
+
report = {'file': MP4.name, 'bytes': MP4.stat().st_size, 'sha256': hashlib.sha256(MP4.read_bytes()).hexdigest(),
|
|
162
|
+
'soundtrack': {'bank': sound['bank'], 'cues': len(sound['cues']), 'notes': len(sound['notes'])},
|
|
163
|
+
'checks': checks, 'ok': ok}
|
|
164
|
+
(OUT / 'verification.json').write_text(json.dumps(report, indent=1) + '\n')
|
|
165
|
+
|
|
166
|
+
print(f'{MP4.name}: {report["bytes"] / 1e6:.1f} MB, {v["width"]}x{v["height"]} {v["r_frame_rate"]}, '
|
|
167
|
+
f'{len(frames)} frames, {lufs:.2f} LUFS, {tp:.2f} dBTP')
|
|
168
|
+
for name, c in checks.items():
|
|
169
|
+
print(f' {"PASS" if c["ok"] else "FAIL"} {name}')
|
|
170
|
+
print(f' placement: {len(sound["cues"])} cues, worst offset {worst_off:.1f} ms, '
|
|
171
|
+
f'stem rebuild diff {diff:.1e}')
|
|
172
|
+
print(f' detector: {checks["detector_view"]["within_one_frame"]}/{len(detected)} cues found by correlation')
|
|
173
|
+
print(f' audit: {audit["summary"]}')
|
|
174
|
+
sys.exit(0 if ok else 1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == '__main__':
|
|
178
|
+
main()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Soundtrack for the earshot ad: every sound is rendered from earshot's bank, in code.
|
|
2
|
+
|
|
3
|
+
cd my-film # xevol film new my-film --from earshot-ad
|
|
4
|
+
node ../../../../xevol/packages/work-motion/runtime/render.mjs . --cues
|
|
5
|
+
/home/kuka/stack-dev/tools/earshot/.venv/bin/python scripts/mix.py # -> out/audio.wav, out/sound.json
|
|
6
|
+
|
|
7
|
+
No recorded audio, no Mixkit, no music track, no downloaded assets. The score is a kalimba arpeggio built from
|
|
8
|
+
`pluck/kalimba`, pitched per note through the bank's own `pitch` knob (A minor pentatonic shapes over Am - Dm -
|
|
9
|
+
Am/C - F); every picture cue in out/cues.json is one bank sound placed on the frame it belongs to.
|
|
10
|
+
|
|
11
|
+
Placement is by measurement, not by hand:
|
|
12
|
+
peak the sound's own measured peak lands on the cue time (hits, ticks, bell, pop)
|
|
13
|
+
apex a whoosh is rendered with `dur`/`apex` so its loudest moment is the cue time, then started early
|
|
14
|
+
start a riser starts on the cue time and runs into the drop that follows
|
|
15
|
+
|
|
16
|
+
Levels: one linear gain to -18.0 LUFS (earshot's integrated measurement, no loudnorm — loudnorm is what dropped the
|
|
17
|
+
last 83 ms and left a loop click in earlier MotionPls ads), capped so true peak stays at or below -1.5 dBTP.
|
|
18
|
+
"""
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import numpy as np
|
|
26
|
+
import soundfile as sf
|
|
27
|
+
from earshot.forge import bank
|
|
28
|
+
from earshot.features import integrated_lufs, true_peak_dbtp
|
|
29
|
+
except ImportError as e: # pragma: no cover - environment guard
|
|
30
|
+
sys.exit(f'mix.py: {e}\nUse the earshot install: /home/kuka/stack-dev/tools/earshot/.venv/bin/python scripts/mix.py')
|
|
31
|
+
|
|
32
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
33
|
+
OUT = ROOT / 'out'
|
|
34
|
+
SR = 48000
|
|
35
|
+
DUR = 12.0
|
|
36
|
+
TARGET_LUFS = -18.0
|
|
37
|
+
MAX_TP = -1.8 # leave room for AAC overshoot in the delivered file
|
|
38
|
+
|
|
39
|
+
# kind -> (bank id, gain dB, align). The picture only names the kind; the sound is chosen here.
|
|
40
|
+
SFX = {
|
|
41
|
+
'impact': ('pro_impact/deep', -9.0, 'peak'),
|
|
42
|
+
'hit': ('impact/punch', -12.0, 'peak'),
|
|
43
|
+
'whoosh': ('pro_whoosh_big/gritty', -9.0, 'apex'),
|
|
44
|
+
'riser': ('pro_riser/air_gate', -15.5, 'start'),
|
|
45
|
+
'tick': ('fit/tick/domino', -15.0, 'peak'),
|
|
46
|
+
'chime': ('fit/bell/small_bell', -11.0, 'peak'),
|
|
47
|
+
'pop': ('pop/1', -15.0, 'peak'),
|
|
48
|
+
}
|
|
49
|
+
# per-cue overrides: the typing ticks want a softer, lower sound than the beat ticks
|
|
50
|
+
BY_TIME = {
|
|
51
|
+
2.35: ('tap/finger', -15.0, 'peak'),
|
|
52
|
+
2.60: ('tap/finger', -15.0, 'peak'),
|
|
53
|
+
2.85: ('tap/finger', -15.0, 'peak'),
|
|
54
|
+
4.00: ('pro_whoosh_big/gritty', -9.5, 'apex'), # the ink flood is the loudest move in the film
|
|
55
|
+
8.00: ('pro_impact/short', -11.0, 'peak'), # "From $299." is the hero hit
|
|
56
|
+
8.90: ('click/soft', -16.5, 'peak'),
|
|
57
|
+
10.00: ('fit/bell/small_bell', -11.0, 'peak', 'A7'), # the bell's own range is B7 +-12 st: A7 is a chord tone
|
|
58
|
+
}
|
|
59
|
+
WHOOSH = {'dur': 0.9, 'apex': 0.62}
|
|
60
|
+
# extra layers under a cue: (time, id, gain dB, align, pitch). Weight without peak, so the loudness can rise.
|
|
61
|
+
LAYERS = [(8.00, 'sub_drop/808', -14.0, 'peak', 'A1'), (10.00, 'logo_sting/soft_bloom', -20.0, 'peak', None)]
|
|
62
|
+
|
|
63
|
+
# kalimba arpeggio one octave up (so the melody sits in the presence band), with a low root per bar
|
|
64
|
+
NOTES = [(2.00, 'A4'), (2.25, 'C5'), (2.50, 'E5'), (2.75, 'A5'), (3.00, 'C6'), (3.25, 'A5'), (3.50, 'E5'), (3.75, 'C5'),
|
|
65
|
+
(4.00, 'A4'), (4.25, 'D5'), (4.50, 'F5'), (4.75, 'A5'), (5.00, 'D6'), (5.25, 'A5'), (5.50, 'F5'), (5.75, 'D5'),
|
|
66
|
+
(6.00, 'A4'), (6.25, 'C5'), (6.50, 'E5'), (6.75, 'G5'), (7.00, 'E6'), (7.25, 'C6'), (7.50, 'G5'), (7.75, 'E5'),
|
|
67
|
+
(8.00, 'F4'), (8.25, 'A4'), (8.50, 'C5'), (8.75, 'F5'), (9.00, 'A5'), (9.25, 'F5'), (9.50, 'C5'), (9.75, 'A4')]
|
|
68
|
+
ROOTS = [(2.00, 'A3'), (4.00, 'A3'), (6.00, 'A3'), (8.00, 'F3')]
|
|
69
|
+
NOTE_DB, NOTE_LEN, NOTE_PAN = -26.0, 0.95, 0.32
|
|
70
|
+
ROOT_DB, ROOT_LEN = -21.0, 1.5
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def stereo(y, pan=0.0):
|
|
74
|
+
y = np.asarray(y, np.float32)
|
|
75
|
+
if y.ndim == 2:
|
|
76
|
+
return y
|
|
77
|
+
return np.stack([y * (1 - pan * 0.28), y * (1 + pan * 0.28)], axis=1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render(sound_id, pitch=None, dur=None, apex=None):
|
|
81
|
+
"""one bank take; refuses any take whose own audit warns (a pitch outside the designed range shows up there)"""
|
|
82
|
+
y, meta = bank.render(sound_id, pitch=pitch, dur=dur, apex=apex)
|
|
83
|
+
warn = meta['checks']['warnings']
|
|
84
|
+
if warn:
|
|
85
|
+
sys.exit(f'mix.py: {sound_id} failed its own audit: ' + '; '.join(w['msg'] for w in warn))
|
|
86
|
+
return stereo(y), meta
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main():
|
|
90
|
+
cues_doc = json.loads((OUT / 'cues.json').read_text())
|
|
91
|
+
n = int(round(DUR * SR))
|
|
92
|
+
bed = np.zeros((n, 2), np.float32) # the score
|
|
93
|
+
hits = np.zeros((n, 2), np.float32) # every picture cue, kept as its own stem for verification
|
|
94
|
+
placed, audit = [], []
|
|
95
|
+
|
|
96
|
+
for i, (t, note) in enumerate(NOTES):
|
|
97
|
+
y, meta = render('pluck/kalimba', pitch=note)
|
|
98
|
+
m = min(len(y), int(NOTE_LEN * SR))
|
|
99
|
+
env = np.ones(m, np.float32)
|
|
100
|
+
f = int(0.35 * SR)
|
|
101
|
+
env[-f:] = np.linspace(1, 0, f) ** 2
|
|
102
|
+
y = y[:m] * env[:, None] * 10 ** ((NOTE_DB + (2.5 if i % 4 == 0 else 0)) / 20)
|
|
103
|
+
a = int(round(t * SR))
|
|
104
|
+
bed[a:a + m] += y[:max(0, min(m, n - a))]
|
|
105
|
+
audit.append({'kind': 'note', 'id': 'pluck/kalimba', 'note': note, 't': t,
|
|
106
|
+
'measured_hz': meta['edits'].get('pitch', {}).get('measured_hz')})
|
|
107
|
+
|
|
108
|
+
for t, note in ROOTS:
|
|
109
|
+
y, meta = render('pluck/kalimba_soft', pitch=note)
|
|
110
|
+
m = min(len(y), int(ROOT_LEN * SR))
|
|
111
|
+
env = np.ones(m, np.float32)
|
|
112
|
+
f = int(0.5 * SR)
|
|
113
|
+
env[-f:] = np.linspace(1, 0, f) ** 2
|
|
114
|
+
y = y[:m] * env[:, None] * 10 ** (ROOT_DB / 20)
|
|
115
|
+
a = int(round(t * SR))
|
|
116
|
+
bed[a:a + m] += y[:max(0, min(m, n - a))]
|
|
117
|
+
audit.append({'kind': 'root', 'id': 'pluck/kalimba_soft', 'note': note, 't': t})
|
|
118
|
+
|
|
119
|
+
for cue in cues_doc['cues']:
|
|
120
|
+
t = float(cue['t'])
|
|
121
|
+
sid, gain, align, *rest = BY_TIME.get(t, SFX.get(cue['kind'], (None, 0, None)))
|
|
122
|
+
if sid is None:
|
|
123
|
+
sys.exit(f"mix.py: no bank sound for kind {cue['kind']!r} at {t}")
|
|
124
|
+
kw = dict(WHOOSH) if align == 'apex' else {}
|
|
125
|
+
if rest and rest[0]:
|
|
126
|
+
kw['pitch'] = rest[0]
|
|
127
|
+
y, meta = render(sid, **kw)
|
|
128
|
+
y = y * 10 ** (gain / 20)
|
|
129
|
+
onset, peak = float(meta.get('onset_s') or 0.0), float(meta.get('peak_s') or 0.0)
|
|
130
|
+
start = {'peak': t - peak, 'apex': t - float(kw.get('apex', 0)), 'start': t}[align]
|
|
131
|
+
a = int(round(start * SR))
|
|
132
|
+
b = min(n, a + len(y))
|
|
133
|
+
if a < 0:
|
|
134
|
+
sys.exit(f'mix.py: {sid} at {t} s would start before 0 s')
|
|
135
|
+
hits[a:b] += y[:b - a]
|
|
136
|
+
# the alignment the mixer promises, measured on the render: hits by peak, whooshes by apex, risers by start
|
|
137
|
+
land = {'peak': onset + peak - onset + start, 'apex': start + float(kw.get('apex', 0)), 'start': start}[align]
|
|
138
|
+
placed.append({'t': t, 'kind': cue['kind'], 'id': sid, 'align': align, 'gain_db': gain,
|
|
139
|
+
'start_s': round(start, 4), 'knobs': {k: v for k, v in kw.items()},
|
|
140
|
+
'measured_onset_s': round(onset, 4),
|
|
141
|
+
'measured_peak_s': round(peak, 4), 'lands_at_s': round(land, 4),
|
|
142
|
+
'offset_ms': round(1000 * (land - t), 2)})
|
|
143
|
+
|
|
144
|
+
for t, sid, gain, align, pitch in LAYERS:
|
|
145
|
+
y, meta = render(sid, pitch=pitch)
|
|
146
|
+
y = y * 10 ** (gain / 20)
|
|
147
|
+
peak = float(meta.get('peak_s') or 0.0)
|
|
148
|
+
start = t - peak
|
|
149
|
+
a = int(round(start * SR)); b = min(n, a + len(y))
|
|
150
|
+
hits[a:b] += y[:b - a]
|
|
151
|
+
placed.append({'t': t, 'kind': 'layer', 'id': sid, 'align': align, 'gain_db': gain, 'start_s': round(start, 4),
|
|
152
|
+
'knobs': {'pitch': pitch} if pitch else {}, 'measured_onset_s': round(float(meta.get('onset_s') or 0), 4),
|
|
153
|
+
'measured_peak_s': round(peak, 4), 'lands_at_s': round(start + peak, 4), 'offset_ms': 0.0})
|
|
154
|
+
|
|
155
|
+
# the price section lifts the score: loudness from the music, not from a louder transient
|
|
156
|
+
a, b = int(8.0 * SR), int(10.2 * SR)
|
|
157
|
+
ramp = int(0.25 * SR)
|
|
158
|
+
lift = np.ones(b - a, np.float32)
|
|
159
|
+
lift[:ramp] = np.linspace(0, 1, ramp)
|
|
160
|
+
lift[-ramp:] = np.linspace(1, 0, ramp)
|
|
161
|
+
bed[a:b] *= (1 + 1.4 * lift)[:, None]
|
|
162
|
+
|
|
163
|
+
# fades: 5 ms in (no click at frame 0), exponential tail into silence by 11.9 s
|
|
164
|
+
mix = bed + hits
|
|
165
|
+
fi = int(0.005 * SR)
|
|
166
|
+
mix[:fi] *= np.linspace(0, 1, fi)[:, None]
|
|
167
|
+
t0, t1 = 10.4, 11.9
|
|
168
|
+
a, b = int(t0 * SR), int(t1 * SR)
|
|
169
|
+
mix[a:b] *= (np.linspace(1, 0, b - a) ** 1.6)[:, None]
|
|
170
|
+
mix[b:] = 0
|
|
171
|
+
|
|
172
|
+
lufs = integrated_lufs(mix)
|
|
173
|
+
gain_db = TARGET_LUFS - lufs
|
|
174
|
+
mix = mix * 10 ** (gain_db / 20)
|
|
175
|
+
tp = true_peak_dbtp(mix)
|
|
176
|
+
if tp > MAX_TP: # scale, never limit: a limiter would change the shape we just measured
|
|
177
|
+
extra = MAX_TP - tp
|
|
178
|
+
mix = mix * 10 ** (extra / 20)
|
|
179
|
+
gain_db += extra
|
|
180
|
+
tp = true_peak_dbtp(mix)
|
|
181
|
+
|
|
182
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
(OUT / 'stems').mkdir(exist_ok=True)
|
|
184
|
+
wav = OUT / 'audio.wav'
|
|
185
|
+
sf.write(wav, mix, SR, subtype='PCM_24')
|
|
186
|
+
sf.write(OUT / 'stems' / 'score.wav', bed * 10 ** (gain_db / 20), SR, subtype='PCM_24')
|
|
187
|
+
sf.write(OUT / 'stems' / 'sfx.wav', hits * 10 ** (gain_db / 20), SR, subtype='PCM_24')
|
|
188
|
+
|
|
189
|
+
report = {
|
|
190
|
+
'film': 'motionpls-earshot-ad', 'dur': DUR, 'sr': SR,
|
|
191
|
+
'bank': bank.VERSION, 'bank_sounds': len(bank.ids()),
|
|
192
|
+
'score': {'arp': 'pluck/kalimba', 'notes': len(NOTES), 'note_db': NOTE_DB, 'bars': ['Am', 'Dm', 'Am/C', 'F']},
|
|
193
|
+
'cues': placed, 'notes': audit,
|
|
194
|
+
'lufs': round(integrated_lufs(mix), 2), 'true_peak_dbtp': round(true_peak_dbtp(mix), 2),
|
|
195
|
+
'peak_dbfs': round(20 * np.log10(max(1e-9, float(np.abs(mix).max()))), 2),
|
|
196
|
+
'gain_db': gain_db, 'target_lufs': TARGET_LUFS,
|
|
197
|
+
'sha256': hashlib.sha256(wav.read_bytes()).hexdigest(),
|
|
198
|
+
}
|
|
199
|
+
(OUT / 'sound.json').write_text(json.dumps(report, indent=1) + '\n')
|
|
200
|
+
|
|
201
|
+
worst = max(abs(c['offset_ms']) for c in placed)
|
|
202
|
+
print(f'out/audio.wav: {len(placed)} cues + {len(audit)} notes (arp + roots) from the earshot bank | '
|
|
203
|
+
f'{report["lufs"]} LUFS, {report["true_peak_dbtp"]} dBTP, gain {gain_db:+.2f} dB | '
|
|
204
|
+
f'worst peak offset {worst:.1f} ms')
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == '__main__':
|
|
208
|
+
main()
|