xevol 0.16.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.
- package/dist/index.js +291 -265
- package/package.json +3 -1
- package/skills/xevol-film/SKILL.md +170 -0
- package/starters/blank/assets/fonts/TestSans.woff2 +1 -0
- package/starters/blank/assets/plate.txt +1 -0
- package/starters/blank/film.html +8 -0
- package/starters/blank/notes.txt +1 -0
- package/starters/blank/out/audio.wav +1 -0
- package/starters/blank/render.json +26 -0
- package/starters/blank/scripts/finish.py +1 -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,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()
|