# AFTERLIGHT, sixth cut: the song is the bed; one close voice leads and the archive answers. # The lead is dry and near. Archive voices sit behind a radio-like boundary and leave tuned tails. # Writes score6.wav, env6.npy (per-frame features the picture reacts to) and beats6.json. import sys, os, wave, json, subprocess, numpy as np from scipy import signal sys.path.insert(0, '/home/claude/afterlight/trailer6') import timeline6 as T SR = 44100; END = T.END; N = int(SR * (END + 3)); rng = np.random.default_rng(606) D6 = '/home/claude/afterlight/trailer6/' def load(p, t0=0, dur=None, ch=1): cmd = ['ffmpeg', '-v', 'error', '-ss', str(t0), '-i', p] + (['-t', str(dur)] if dur else []) + ['-ac', str(ch), '-ar', str(SR), '-f', 'f32le', '-'] x = np.frombuffer(subprocess.run(cmd, capture_output=True).stdout, np.float32).astype(np.float64) return x.reshape(-1, ch) if ch > 1 else x L = np.zeros(N); R = np.zeros(N); RV = np.zeros(N); VL = np.zeros(N); VA = np.zeros(N); WL = np.zeros(N); WA = np.zeros(N) def put(sig, t, g=1.0, pan=0.0, rev=0.0, track=None): 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 track is not None: track[i:i + n] += np.abs(s); (WL if track is VL else WA)[i:i + n] += 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) TONES = [123.47, 146.83, 164.81, 185.0, 220.0, 246.94, 293.66, 329.63, 369.99, 440.0, 493.88, 659.26] def tuned(x, freqs, Q=60, weights=None): out = np.zeros_like(x) for j, f in enumerate(freqs): b, a = signal.iirpeak(f / (SR / 2), Q); out += signal.lfilter(b, a, x) * (weights[j] if weights is not None else (164.81 / f) ** 0.4) return out def freeze(src, out_len, grain=0.18, dens=30): fr = np.zeros(int(out_len * SR)); g = int(grain * SR); w = np.hanning(g) for k in range(int(out_len * dens)): o = int(rng.random() * max(1, len(src) - g)); s = int(k / dens * SR) if s + g < len(fr) and o + g <= len(src): fr[s:s + g] += src[o:o + g] * w return fr # ---------------- the lead voice (Kokoro), synthesized once and cached ---------------- os.makedirs(D6 + 'lead', exist_ok=True) def lead_wav(i, text): p = D6 + f'lead/{i:02d}.wav' if not os.path.exists(p): from kokoro_onnx import Kokoro import soundfile as sf global KK if 'KK' not in globals(): KK = Kokoro('/tmp/claude-0/kok/kokoro-v1.0.onnx', '/tmp/claude-0/kok/voices-v1.0.bin') s, sr = KK.create(text, voice=T.LEAD_VOICE, speed=T.LEAD_SPEED, lang='en-us'); sf.write(p, s, sr) return load(p) LINES = [] for i, (at, kind, text, *src) in enumerate(T.SCRIPT): if kind == 'lead': x = lead_wav(i, text) nz = np.nonzero(np.abs(x) > 0.01)[0]; x = x[max(0, nz[0] - 800):nz[-1] + 2000] # trim silence else: f, a_, b_, film, year = src[0]; x = load(f, a_ - 0.04, (b_ - a_) + 0.12) LINES.append((at, kind, text, x, at + len(x) / SR)) for i in range(len(LINES)): at, kind, text, x, e = LINES[i]; nxt = LINES[i + 1][0] if i + 1 < len(LINES) else 99 print(f'{at:6.2f}-{e:6.2f} {kind:4s} {"OVERLAP " if e > nxt - 0.15 else ""}{text}') # ---------------- the song, stepping back under every voice ---------------- song = load(T.SONG, ch=2); ns = len(song); mono = song.mean(1) duck = np.ones(ns) for at, kind, text, x, e in LINES: s0 = int((at - 0.25) * SR); s1 = int((e + 0.3) * SR); duck[max(0, s0):min(ns, s1)] = 0 k = int(0.3 * SR); duck = np.convolve(duck, np.ones(k) / k, 'same') g = 0.45 + 0.55 * duck mid = np.stack([bp(song[:, c], 300, 3800) for c in (0, 1)], 1) sg = song * g[:, None] - mid * (1 - duck)[:, None] * 0.32 L[:ns] += sg[:, 0] * 0.9; R[:ns] += sg[:, 1] * 0.9; RV[:ns] += mono * 0.04 # ---------------- voices ---------------- for i, (at, kind, text, x, e) in enumerate(LINES): loc = np.sqrt((mono[int(at * SR):int(e * SR)] ** 2).mean()) if at < T.SONG_END else 0.0 lift = max(1.0, loc / 0.09) ** 0.5 if kind == 'lead': y = hp(x, 70); y = norm(y) y = np.tanh(y * 1.6) / np.tanh(1.6) # close and even put(y * env(len(y), 0.01, 0.08), at, 0.5 * lift, 0.0, 0.08, VL) else: y = norm(bp(hp(x, 110), 180, 5200)) * env(len(x), 0.02, 0.1) # the radio boundary: intelligibility wavers slightly wob = 1 - 0.18 * (0.5 + 0.5 * np.sin(2 * np.pi * 3.1 * np.arange(len(y)) / SR + i)) y = y * wob put(y, at, 0.5 * lift, (-0.25, 0.25)[i % 2], 0.22, VA) last = y[-int(0.6 * SR):]; fr = freeze(last, 9) tail = norm(tuned(fr, TONES, Q=70)) * env(len(fr), 0.9, 7.0) put(tail, e - 0.15, 0.085, (-0.5, 0.5)[i % 2], 0.6) # 'only an echo': tape repeats on the half-note, losing their top ox = [l for l in LINES if l[2] == 'only an echo'][0] for k in range(1, 7): rep = lp(bp(ox[3], 300, 3400), 3400 - k * 380) * 0.55 ** k put(norm(rep) * 0.55 ** k, ox[0] + k * 0.536, 0.5, (-1) ** k * 0.4, 0.45) # ---------------- the end: the E outlives the bass ---------------- src = mono[int(60.5 * SR):int(63.4 * SR)] held = freeze(src, END - 63.0 + 1, grain=0.25, dens=24) E = norm(tuned(held, [329.63, 659.26, 164.81, 493.88], Q=160, weights=[1.0, 0.45, 0.35, 0.18])) e_env = np.clip((np.arange(len(E)) / SR - 0.6) / 1.5, 0, 1) * env(len(E), 0.01, 5.0) put(E * e_env, 63.0, 0.15, 0.1, 0.7) hiss = hp(rng.standard_normal(int((END - T.SILENCE + 1) * SR)), 3000) put(hiss * env(len(hiss), 0.4, 2), T.SILENCE, 0.007) # ---------------- mix ---------------- def reverb(x, dur=4.5, damp=3200): 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.45, R + np.roll(wet, int(0.019 * SR)) * 0.45], 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.9) * 1.1) / np.tanh(1.1) * 0.9 with wave.open(D6 + 'score6.wav', 'wb') as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes((M * 32767).astype(np.int16).tobytes()) # ---------------- what the picture listens to ---------------- fps = T.FPS; hop = SR // fps; nf = int(END * fps) def per_frame(sig, f): return np.array([f(sig[i * hop:(i + 1) * hop]) if (i + 1) * hop <= len(sig) else 0.0 for i in range(nf)]) rms = lambda s: np.sqrt((s ** 2).mean()) low = lp(mono, 120, 4); mid_ = bp(mono, 300, 2500); hi = hp(mono, 5000, 4) lo_e = per_frame(low, rms); mi_e = per_frame(mid_, rms); hi_e = per_frame(hi, rms) def onset(e, w=6): d = np.maximum(e - np.convolve(e, np.ones(w) / w, 'same'), 0); return d / (d.max() + 1e-9) kick = onset(lo_e); hat = onset(hi_e, 4); stab = onset(mi_e, 8) vl = per_frame(VL, np.mean); va = per_frame(VA, np.mean) vl = np.convolve(vl, np.ones(3) / 3, 'same'); va = np.convolve(va, np.ones(3) / 3, 'same') vl /= vl.max() + 1e-9; va /= va.max() + 1e-9 mix = per_frame(M.mean(1), rms); mix /= mix.max() + 1e-9 lo_s = np.convolve(lo_e, np.ones(20) / 20, 'same'); lo_s /= lo_s.max() + 1e-9 def scope(sig): b, a = signal.butter(2, 900 / (SR / 2), 'low'); x = signal.lfilter(b, a, sig) out = np.zeros((nf, 96), np.float32) for i in range(nf): seg = x[i * hop:i * hop + hop * 2] if len(seg) == hop * 2: out[i] = seg[::max(1, len(seg) // 96)][:96] return out / (np.abs(out).max() + 1e-9) np.save(D6 + 'scope6.npy', np.stack([scope(WL), scope(WA)], 1)) np.save(D6 + 'env6.npy', np.stack([mix, lo_s, kick, hat, stab, vl, va, lo_e / (lo_e.max() + 1e-9), hi_e / (hi_e.max() + 1e-9)], 1).astype(np.float32)) # kicks: peak pick on the low onset pk, _ = signal.find_peaks(kick, height=0.22, distance=int(0.22 * fps)) json.dump(dict(kicks=[round(i / fps, 3) for i in pk], lines=[(l[0], round(l[4], 2), l[1], l[2]) for l in LINES]), open(D6 + 'beats6.json', 'w')) print('kicks', len(pk), 'first', pk[:6] / fps) print('ok', M.shape)