# AFTERLIGHT, fourth cut: the score is the archive's own sound, tuned. # Found noise is passed through resonators on the harmonic series of D; partials enter one by one as the film goes on. # Each voice speaks plainly, then its last word is frozen, tuned to the same series, and left to sound: the echo of the archive. # Writes score4.wav and env.npy (per-frame loudness of the voices, which the picture uses to brighten when the archive speaks). import sys, wave, glob, subprocess, numpy as np from scipy import signal sys.path.insert(0, '/home/claude/afterlight/trailer4') import timeline4 as T SR = 44100; END = T.END; N = int(SR * (END + 3)); rng = np.random.default_rng(404) def load(p, t0=0, dur=None): cmd = ['ffmpeg', '-v', 'error', '-ss', str(t0), '-i', p] + (['-t', str(dur)] if dur else []) + ['-ac', '1', '-ar', str(SR), '-f', 'f32le', '-'] x = np.frombuffer(subprocess.run(cmd, capture_output=True).stdout, np.float32).astype(np.float64); return x def arch(fid): return glob.glob('/home/claude/afterlight/archive/clips/' + fid + '*.mp4')[0] L = np.zeros(N); R = np.zeros(N); RV = np.zeros(N); VO = np.zeros(N) def put(sig, t, g=1.0, pan=0.0, rev=0.0, vo=False): i = int(t * SR); n = min(len(sig), N - i) if n <= 0 or i < 0: return s = sig[:n] * g; L[i:i + n] += s * np.sqrt(0.5 * (1 - pan)); R[i:i + n] += s * np.sqrt(0.5 * (1 + pan)); RV[i:i + n] += s * rev if vo: VO[i:i + n] += np.abs(s) def env(n, a, r): e = np.ones(n); A = max(1, int(a * SR)); Rr = max(1, int(r * SR)); e[:A] = np.linspace(0, 1, A); e[-Rr:] *= np.linspace(1, 0, Rr); return e def bp(x, lo, hi, o=2): b, a = signal.butter(o, [lo / (SR / 2), hi / (SR / 2)], 'band'); return signal.lfilter(b, a, x) def lp(x, f, o=2): b, a = signal.butter(o, min(f, SR / 2.2) / (SR / 2), 'low'); return signal.lfilter(b, a, x) def hp(x, f, o=2): b, a = signal.butter(o, f / (SR / 2), 'high'); return signal.lfilter(b, a, x) def norm(x): return x / (np.abs(x).max() + 1e-9) F0 = 73.42 # D2 def tuned(x, partials, Q=90, weights=None): """resonate a found sound on the harmonic series of D: what the archive's noise contains at those frequencies, and only that""" out = np.zeros_like(x) for j, k in enumerate(partials): f = F0 * k if f > SR / 2.3: continue b, a = signal.iirpeak(f / (SR / 2), Q); w = (weights[j] if weights is not None else 1.0 / k ** 0.6) out += signal.lfilter(b, a, x) * w return out # ---------------- the found noise: projector hiss, room tone, a railroad, a score held still ---------------- rail = load(arch('b013393e')) cab = load(arch('74442368')) room = np.concatenate([load(arch(k)) for k in ('22ba9c11', '84ad980c', '8ccec8f2') if glob.glob('/home/claude/afterlight/archive/clips/' + k + '*')] or [rng.standard_normal(SR * 10) * 0.01]) def loopto(x, n): x = x - x.mean(); x = norm(x); reps = int(np.ceil(n / len(x))) + 1; y = np.tile(x, reps)[:n] return y EXC = loopto(hp(rail, 60), N) * 0.6 + loopto(hp(room, 60), N) * 0.5 + rng.standard_normal(N) * 0.05 # partials enter as the film goes: the series climbs while the archive speaks ENTER = [(1, 0.0), (2, 0.0), (3, 4.0), (4, 11.0), (5, 21.0), (6, 27.0), (8, 33.0), (7, 38.0), (9, 47.0), (10, 52.0), (12, 56.5), (11, 60.0), (14, 62.0), (15, 64.0), (16, 66.0)] drone = np.zeros(N); tt = np.arange(N) / SR for k, t0 in ENTER: seg = tuned(EXC, [k], Q=120 if k < 6 else 70, weights=[1.0 / k ** 0.55]) amp = np.clip((tt - t0) / 5.0, 0, 1) ** 2 * (0.75 + 0.25 * np.sin(2 * np.pi * (0.05 + 0.013 * k) * tt + k)) # each partial breathes at its own rate drone += seg * amp drone = norm(drone) dmix = np.ones(N); a = int(T.SILENCE * SR); dmix[a:] = 0; dmix[a - int(0.04 * SR):a] = np.linspace(1, 0, int(0.04 * SR)) grow = 0.55 + 0.45 * np.clip((tt - 50) / (T.AV1 - 50), 0, 1) L += drone * 0.28 * dmix * grow; R += np.roll(drone, int(0.011 * SR)) * 0.28 * dmix * grow; RV += drone * 0.1 * dmix # the found chord (Cabeza Prieta's most tonal moment, frozen), low, under the middle and the avalanche def freeze(x, t0, dur, out_len, grain=0.2, dens=36, jitter=0.7): out = np.zeros(int(out_len * SR) + int(grain * SR) + 10); g = int(grain * SR); w = np.hanning(g); src = x[int(t0 * SR):int((t0 + dur) * SR)] for k in range(int(out_len * dens)): at = int(k / dens * SR); o = int(rng.random() * jitter * max(1, len(src) - g)) if o + g < len(src): out[at:at + g] += src[o:o + g] * w return norm(out[:int(out_len * SR)]) chord = freeze(cab, 4.75, 2.5, 40) chord = np.interp(np.arange(int(len(chord) / 2 ** (-3 / 12))) * 2 ** (-3 / 12), np.arange(len(chord)), chord) # to D chord = lp(chord, 2200) put(chord * env(len(chord), 6, 6), 22.0, 0.1, -0.2, 0.5) # ---------------- the voices, and their afterlife ---------------- TAILS = [] for (f, a_, b_, at, text) in T.VOICES: x = load(T.C2 + f, a_ - 0.05, (b_ - a_) + 0.15) x = hp(x, 90); x = norm(x) * env(len(x), 0.03, 0.12) put(x, at, 0.62, 0.0, 0.18, vo=True) # the last word, frozen and tuned: it keeps sounding after the voice has gone last = x[-int(0.7 * SR):] fr = np.zeros(int(14 * SR)); g = int(0.18 * SR); w = np.hanning(g) for k in range(int(14 * 30)): o = int(rng.random() * max(1, len(last) - g)); s = int(k / 30 * SR) if s + g < len(fr) and o + g <= len(last): fr[s:s + g] += last[o:o + g] * w tail = norm(tuned(fr, [2, 3, 4, 5, 6, 8, 10, 12], Q=55)) * env(len(fr), 1.2, 9.0) TAILS.append(fr) put(tail, at + (b_ - a_) - 0.25, 0.16, rng.uniform(-0.5, 0.5), 0.6) # 'only an echo': a tape echo whose repeats walk into the avalanche ox = load(T.C2 + T.VOICES[-1][0], T.VOICES[-1][1] - 0.05, T.VOICES[-1][2] - T.VOICES[-1][1] + 0.15); ox = norm(hp(ox, 120)) for k in range(1, 9): rep = lp(bp(ox, 300, 3400), 3400 - k * 300) * 0.55 ** k put(rep, T.VOICES[-1][3] + k * 0.43, 0.6, (-1) ** k * 0.35, 0.4) # ---------------- the avalanche: every voice's afterlife at once, tuned, rising ---------------- n = int((T.AV1 - T.AV0 + 1.0) * SR); choir = np.zeros(n) for i, fr in enumerate(TAILS): s = fr[:n] if len(fr) >= n else np.pad(fr, (0, n - len(fr))) choir += norm(tuned(s, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16], Q=80)) * (0.6 + 0.4 * (i % 3) / 2) u = np.arange(n) / n; choir = norm(choir) * (u ** 1.6) * env(n, 0.3, 0.05) put(choir, T.AV0 - 0.5, 0.42, 0.0, 0.5) put(chord[:n] * u ** 2, T.AV0 - 0.5, 0.18, 0.3, 0.5) # embers: crackle, densest when the pixels leave def crackle(t0, dur, dens, g): n = int(dur * SR); x = np.zeros(n); idx = rng.integers(0, n, int(dur * dens)); x[idx] = rng.random(len(idx)) ** 2.5 * rng.choice([-1, 1], len(idx)) x = bp(x, 1800, 9000) + lp(x, 600) * 0.5; put(x * env(n, 0.4, 0.6), t0, g, rng.uniform(-0.6, 0.6), 0.15) for s in T.SHOTS: crackle(s[2] - 0.6, 2.8, 70, 0.22) crackle(T.AV0, T.AV1 - T.AV0, 110, 0.16) crackle(T.TITLE0 + 0.5, 4.0, 40, 0.18) # ---------------- silence, 'Good night.', one tuned bloom ---------------- gx = load(T.C2 + '22ba9c11-a391-5b70-aa85-ec95c9fe45ba.mp4', 9.15, 0.95); gx = norm(hp(gx, 100)) * env(len(gx), 0.02, 0.15) put(gx, T.GOODNIGHT, 0.55, 0.0, 0.35, vo=True) n = int((END - T.TITLE0 + 1) * SR); bloom = norm(tuned(EXC[:n] + loopto(hp(rail, 60), n) * 0.3, [1, 2, 3, 4, 5, 6, 8], Q=140)) put(bloom * env(n, 2.5, 4.0), T.TITLE0, 0.3, 0.0, 0.6) hiss = hp(rng.standard_normal(int((END - T.SILENCE + 1) * SR)), 3000) * (1 + 0.2 * np.sin(2 * np.pi * 24 * np.arange(int((END - T.SILENCE + 1) * SR)) / SR)) put(hiss * env(len(hiss), 0.05, 2), T.SILENCE, 0.012) # ---------------- mix ---------------- def reverb(x, dur=5.0, damp=3000): n = int(dur * SR); t_ = np.arange(n) / SR; ir = rng.standard_normal(n) * np.exp(-t_ * 1.6); ir = lp(ir, damp); ir /= np.sqrt((ir ** 2).sum()) return signal.fftconvolve(x, ir)[:len(x)] wet = reverb(RV) M = np.stack([L + wet * 0.5, R + np.roll(wet, int(0.019 * SR)) * 0.5], 1)[:int(END * SR)] fo = int(2.5 * SR); M[-fo:] *= np.linspace(1, 0, fo)[:, None] M = np.tanh(M / (np.abs(M).max() * 0.85) * 1.2) / np.tanh(1.2) * 0.9 with wave.open('/home/claude/afterlight/trailer4/score4.wav', 'wb') as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes((M * 32767).astype(np.int16).tobytes()) hop = SR // T.FPS; nf = int(END * T.FPS) v = np.array([VO[i * hop:(i + 1) * hop].mean() for i in range(nf)]); v = np.convolve(v, np.ones(5) / 5, 'same'); v = v / (v.max() + 1e-9) m = M.mean(1); a_ = np.array([np.sqrt((m[i * hop:(i + 1) * hop] ** 2).mean()) for i in range(nf)]); a_ = a_ / (a_.max() + 1e-9) np.save('/home/claude/afterlight/trailer4/env.npy', np.stack([v, a_], 1).astype(np.float32)) print('ok', M.shape)