# Small Sun — score and foley, built from the film's own event log import json, wave, numpy as np from scipy import signal SR = 44100; END = 151.0 N = int(SR * (END + 4)) rng = np.random.default_rng(11) EV = json.load(open('/home/claude/afterlight/film2/build/events.json')) def at(k): return [e for e in EV if e['k'] == k] def first(k): a = at(k); return a[0]['t'] if a else None mus = np.zeros(N); fx = np.zeros(N); amb = np.zeros(N) def put(buf, sig, t, g=1.0): i = int(t * SR); n = min(len(sig), N - i) if n > 0 and i >= 0: buf[i:i + n] += sig[:n] * g def env(n, a, r, sus=1.0): e = np.ones(n) * sus; A = max(1, int(a * SR)); R = max(1, int(r * SR)) e[:A] = np.linspace(0, sus, A); e[-R:] *= np.linspace(1, 0, R); return e def mtof(m): return 440 * 2 ** ((m - 69) / 12) def bp(sig, lo, hi, order=2): b, a = signal.butter(order, [lo / (SR / 2), hi / (SR / 2)], 'band'); return signal.lfilter(b, a, sig) def lp(sig, f, order=2): b, a = signal.butter(order, f / (SR / 2), 'low'); return signal.lfilter(b, a, sig) # ---------- instruments ---------- def kalimba(m, t, g=0.18, d=2.2, buf=None): f = mtof(m); n = int(d * SR); tt = np.arange(n) / SR s = (np.sin(2 * np.pi * f * tt) + 0.35 * np.sin(2 * np.pi * f * 2.01 * tt) * np.exp(-tt * 6) + 0.12 * np.sin(2 * np.pi * f * 5.4 * tt) * np.exp(-tt * 18)) s *= np.exp(-tt * 2.4) * np.minimum(1, tt / 0.003) put(buf if buf is not None else mus, s, t, g) def flute(m, t, d, g=0.12, vib=5.2): f = mtof(m); n = int(d * SR); tt = np.arange(n) / SR ph = 2 * np.pi * np.cumsum(f * (1 + 0.006 * np.sin(2 * np.pi * vib * tt) * np.minimum(1, tt / 0.4))) / SR s = np.sin(ph) + 0.18 * np.sin(2 * ph) + 0.05 * np.sin(3 * ph) br = bp(rng.standard_normal(n), f * 0.8, min(f * 3, 15000)) * 0.08 put(mus, (s + br) * env(n, 0.12, min(0.5, d * 0.4)), t, g) def pad(ms, t, d, g=0.05, bright=1800): n = int(d * SR); tt = np.arange(n) / SR; s = np.zeros(n) for m in ms: f = mtof(m) for det in (-0.12, 0.0, 0.11): fr = f * 2 ** (det / 12); s += signal.sawtooth(2 * np.pi * fr * tt + rng.random() * 6.28) * 0.33 s = lp(s, bright, 2) * env(n, min(2.0, d * 0.4), min(2.5, d * 0.45)) put(mus, s / len(ms), t, g) def bell(f, t, g=0.06, d=3.0, buf=None): n = int(d * SR); tt = np.arange(n) / SR s = sum(a * np.sin(2 * np.pi * f * r * tt) * np.exp(-tt * k) for r, a, k in [(1, 1, 1.4), (2.76, 0.45, 2.6), (5.4, 0.25, 4.5), (8.93, 0.12, 7)]) put(buf if buf is not None else mus, s * np.minimum(1, tt / 0.002), t, g) # ---------- ambience ---------- def noise(n): return rng.standard_normal(n) def room(t0, t1, g=0.012): n = int((t1 - t0) * SR); put(amb, lp(noise(n), 260) * env(n, 0.8, 0.8), t0, g * 6) def wind(t0, t1, g=0.05): n = int((t1 - t0) * SR); tt = np.arange(n) / SR w = bp(noise(n), 250, 1400) * (0.55 + 0.45 * np.sin(2 * np.pi * 0.09 * tt + 1) * np.sin(2 * np.pi * 0.023 * tt)) w += bp(noise(n), 2500, 6000) * 0.15 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.13 * tt)) put(amb, w * env(n, 2.0, 2.0), t0, g) room(0, 18.2, 0.01); wind(17.8, 101.5, 0.055); room(100.8, 131.5, 0.01); wind(130.5, 142.5, 0.035) # ---------- foley from events ---------- for e in at('step'): t = e['t']; n = int(0.09 * SR); tt = np.arange(n) / SR if e.get('surf') == 'wood': s = (np.sin(2 * np.pi * 95 * tt) * np.exp(-tt * 45) + bp(noise(n), 900, 3000) * 0.25 * np.exp(-tt * 70)); put(fx, s, t, 0.22 if e.get('run') else 0.14) else: s = bp(noise(n), 700, 3800) * np.exp(-tt * 38) + np.sin(2 * np.pi * 70 * tt) * np.exp(-tt * 60) * 0.4; put(fx, s, t, 0.12 if e.get('run') else 0.08) for e in at('breath'): n = int(0.9 * SR); tt = np.arange(n) / SR; sh = np.sin(np.pi * np.clip(tt / 0.9, 0, 1)) ** 1.5 put(fx, bp(noise(n), 500, 2400) * sh, e['t'] + 0.05, 0.035 if e.get('soft') else 0.06) def creak(t, g=0.08): n = int(1.1 * SR); tt = np.arange(n) / SR; f = 160 + 90 * np.sin(np.pi * tt / 1.1) s = signal.sawtooth(2 * np.pi * np.cumsum(f) / SR) * (0.5 + 0.5 * (rng.random(n) > 0.6)); put(fx, lp(s, 1200) * env(n, 0.05, 0.3), t, g) for k in ('door', 'door_in'): for e in at(k): creak(e['t']) for e in at('pick'): bell(1760, e['t'], 0.04, 1.0, fx) for e in at('unwrap'): n = int(1.0 * SR); put(fx, bp(noise(n), 1500, 6000) * env(n, 0.05, 0.4), e['t'], 0.04) for e in at('shawl'): n = int(1.6 * SR); put(fx, bp(noise(n), 600, 3000) * env(n, 0.3, 0.8), e['t'], 0.04) for k in ('kneel', 'give'): for e in at(k): n = int(0.15 * SR); tt = np.arange(n) / SR; put(fx, np.sin(2 * np.pi * 80 * tt) * np.exp(-tt * 30), e['t'], 0.12) for e in at('wrap'): n = int(1.2 * SR); put(fx, bp(noise(n), 1200, 5000) * env(n, 0.1, 0.5) * (0.6 + 0.4 * np.sin(np.arange(n) / SR * 40)), e['t'], 0.05) for e in at('climb'): n = int(0.25 * SR); put(fx, bp(noise(n), 200, 1200) * env(n, 0.01, 0.2), e['t'], 0.08) # ---------- music ---------- D = 62 # D4 BEAT = 60 / 66 def phrase(notes, t, inst='kal', g=None, oct=0): for m, b in notes: if m is not None: if inst == 'kal': kalimba(m + oct, t, g or 0.16) else: flute(m + oct, t, b * BEAT * 1.05, g or 0.11) t += b * BEAT return t LONG = [(69, 1), (74, 1), (76, 1), (77, 2), (76, 1), (74, 2)] ANSW = [(72, 1), (74, 1), (69, 3)] WARM = [(69, 1), (74, 1), (76, 1), (78, 2), (76, 1), (74, 1), (69, 1), (74, 3)] DOWN = [(77, 1), (76, 1), (74, 1), (72, 1), (69, 3)] # A: the cold house pad([50, 57, 62, 65], 0.5, 17.5, 0.035, 900) phrase(LONG, 2.0, 'kal', 0.13); phrase(ANSW, 9.5, 'kal', 0.12) # B: the run — ostinato locked to tempo, theme on flute t = 18.2 while t < 31.0: for m in (62, 69, 74, 69): kalimba(m, t, 0.07); t += BEAT / 2 pad([50, 57, 62], 18.2, 13, 0.03, 1200) phrase(LONG, 22.5, 'flute', 0.09) # sun caught: hope (F major colour), then loss fl = first('flash') or 34.2 bell(mtof(81), fl, 0.07); bell(mtof(88), fl + 0.12, 0.05) pad([53, 60, 65, 69], fl - 0.5, 9.5, 0.05, 2200) phrase([(72, 1), (77, 1), (79, 2), (81, 3)], fl + 0.6, 'flute', 0.1) po = first('patchout') or 43.4 phrase(DOWN, po - 1.6, 'flute', 0.09) pad([46, 53, 58, 62], po - 1.0, 9.0, 0.04, 900) # dusk: almost nothing pad([45, 52, 57, 60], 52.5, 12, 0.03, 700) # moonrise: cold high mr = first('moonrise') or 56 flute(81, mr + 0.5, 6.0, 0.05, 3.5) fc = first('flash_cold') or 59.8 bell(mtof(93), fc, 0.04) pm = first('palm') or 64.2 for i, m in enumerate([69, 72, 69, 67]): kalimba(m, pm + i * 1.3, 0.08) # fireflies: two voices in 3:2, pentatonic walk, growing (Whitney) ff0 = first('ff_first') or 72.2 penta = [74, 76, 79, 81, 83, 86, 88] ia, ib = 2, 4 for k in range(int((86.5 - ff0) / (2.0 / 3))): t = ff0 + k * (2.0 / 3); dens = min(1.0, (t - ff0) / 6) ia = int(np.clip(ia + rng.integers(-1, 2), 0, 6)); kalimba(penta[ia], t, 0.05 * dens + 0.015) for k in range(int((86.5 - ff0) / 1.0)): t = ff0 + 0.33 + k * 1.0; dens = min(1.0, (t - ff0) / 6) ib = int(np.clip(ib + rng.integers(-1, 2), 0, 6)); kalimba(penta[ib] - 12, t, 0.045 * dens + 0.012) pad([50, 57, 62, 64, 69], ff0, 14, 0.035, 1600) ca = first('catch') or 76.4 bell(mtof(86), ca, 0.05) rl = first('release') or 80.6 for i, m in enumerate([74, 79, 81, 86, 88]): kalimba(m, rl + i * 0.16, 0.08) # the stone: silence, then warmth t1 = first('touch1') or 88.2 t2 = first('touch2') or 91.2 ch = first('cheek') or 92.4 kalimba(50, t2, 0.18, 3.5) pad([50, 57, 62, 66, 69], ch - 0.3, 9.0, 0.06, 2000) phrase(WARM, ch + 0.6, 'flute', 0.1) rn = first('run') or 97.9 t = rn while t < 101.0: for m in (62, 66, 69, 74): kalimba(m, t, 0.08); t += BEAT / 2 # E: home — hold back until the hands close, then the warm theme; the shawl brings the full chord uw = first('unwrap') or 105 kalimba(74, uw + 0.3, 0.07); kalimba(78, uw + 0.9, 0.06) hc = first('hands_close') or 108.6 pad([50, 57, 62, 66], hc + 0.6, 12, 0.045, 1400) phrase(WARM, hc + 0.9, 'kal', 0.13) th = first('touch_head') or 112.4 bell(mtof(78), th, 0.03) sh = first('shawl') or 114.8 pad([38, 50, 57, 62, 66, 69], sh, 16, 0.06, 2200) phrase(WARM, sh + 1.2, 'flute', 0.09) # F: out into the night, final statement, short pad([50, 57, 62, 66, 69], 130.6, 11.8, 0.045, 1600) phrase(LONG[:4] + [(74, 4)], 131.5, 'kal', 0.11) # credits: very quiet phrase(ANSW, 143.0, 'kal', 0.05) # ---------- ducking at the first touch: a held breath ---------- duck = np.ones(N) for (a, b, lvl) in [(t1 - 0.05, t2 - 0.1, 0.15)]: ia, ib = int(a * SR), int(b * SR); ramp = int(0.25 * SR) duck[ia:ib] = lvl; duck[ia - ramp:ia] = np.linspace(1, lvl, ramp); duck[ib:ib + ramp] = np.linspace(lvl, 1, ramp) # reverb (synthetic IR) on music and a little on fx def reverb(sig, dur=2.6, mix=0.28): n = int(dur * SR); tt = np.arange(n) / SR ir = rng.standard_normal(n) * np.exp(-tt * 3.2); ir = lp(ir, 5000); ir /= np.sqrt((ir ** 2).sum()) wet = signal.fftconvolve(sig, ir)[:len(sig)]; return sig * (1 - mix) + wet * mix * 1.6 mix = reverb(mus, 2.8, 0.32) + reverb(fx, 1.2, 0.12) + amb * duck mix = mix[:int(END * SR)] fade = np.ones(len(mix)); fo = int(1.5 * SR); fade[-fo:] = np.linspace(1, 0, fo); mix *= fade mix = np.tanh(mix / (np.abs(mix).max() * 0.7)) * 0.9 st = np.stack([mix, np.roll(mix, int(0.0007 * SR))], 1) with wave.open('/home/claude/afterlight/film2/build/score.wav', 'wb') as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes((st * 32767).astype(np.int16).tobytes()) print('ok', len(EV), 'events')