xevol 0.15.0 → 0.17.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.
@@ -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()