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.
@@ -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()