#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Rings (04/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/rings # # A tree's cross-section as a story that keeps every ending. # # This single file IS the piece: it draws every frame, synthesizes every sound, # and muxes them into the final video with ffmpeg. No other project files are # needed. You (or your agent) are invited to make a VARIATION of it: # # Generate a variation of this music video using only code. # Start from https://genekogan.com/player_computer/code/rings.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/rings.mp4 # cover: https://genekogan.com/player_computer/media/rings.jpg # # Requirements: python3, numpy, pillow, and ffmpeg on PATH. # pip install numpy "pillow<13" # Speech/vocals (in pieces that have them) use the macOS `say` command; on # other platforms swap in espeak-ng / any TTS at the say_wav()/speak() calls, # or mute those lines. git provenance stamps degrade gracefully outside a repo. # Run: python3 rings.py (writes frames/, audio/, and the final mp4 # next to the script; writes ~8 GB of frames, # takes 5-15 min on a modern machine) # ═════════════════════════════════════════════════════════════════════════════ """ player_computer_2 / B — "RINGS" (round 2: the picture is film) NARRATIVE STRUCTURE: **branching.** The piece keeps forking and refuses to choose. At each event year the story splits — the house burns / the house does not burn — and instead of picking one, both stay on screen, and the next fork splits both again. By the end every ending that was ever possible is showing at once, which is exactly what a cross-section is: 180 years of decisions all present in the same object. The last ring is incomplete, because it is still this year. MUSIC: **mbira / chimurenga.** Two interlocking thumb-piano parts — kushaura and kutsinhira — genuinely offset on the 48-pulse cycle, so the line you hear is in neither part. Hosho gourd shakers run the 3-against-4 that makes the cycle ambiguous. The deza buzz (bottle caps on the soundboard) is modelled as a real rattle: discrete impacts triggered off the note envelope, band-limited and gated, not a hiss. From the middle the cycle opens into a band — electric guitar transcribing the mbira line, bass, kit — and closes back down to one thumb piano for the unfinished ring. THE NEW SUBSTRATE — **dendrochronology**, new to this repo: * The rings are GROWN, not drawn: a negative-exponential age trend times an exp of an AR(1) climate signal, with real dendro pathology laid on top — fire scars, frost rings, reaction wood, missing rings, false rings. * Ring boundaries live in a warped radial coordinate. Reaction wood is a per-ring eccentricity applied as an angular gain on rho and solved by two fixed-point iterations, so the eccentric rings are genuinely off-centre rather than being an offset circle. * A fire scar is a sector where rho is pushed outward by a Gaussian in the angle — which is what a callus lobe is — with char and radial checking inside the wound. * Cell structure is real: the tangential cell wall spacing is a function of ARC LENGTH (rho·theta), so cell files stay a constant width as the radius grows, and the walls tighten through the earlywood→latewood transition. Medullary rays cross the rings; resin ducts sit in the latewood. * The crossdating diagram is the actual procedure: two ring-width series slid against each other, with the running correlation drawn under them, locking at the offset where they agree. * The specimen sits under a lens: circular field stop, a specular sheen and a focal radius, so only one annulus is ever truly sharp. Composition: engine : audio-first × shot-parallel (tier 4-P) × radial warped-coordinate field renderer content: audio-groove (mbira kit — tines, deza buzz, hosho, chimurenga band) × NEW dendrochronology substrate × effects-post Run from repo root: python3 renders/spiral_fm/rings/render.py --sheet python3 renders/spiral_fm/rings/render.py --jobs 2 python3 renders/spiral_fm/rings/render.py --shots 9,10 --force python3 renders/spiral_fm/rings/render.py --mux-only """ import argparse, datetime, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "rings" TITLE = "RINGS" SETDIR = "player_computer_final" SETNUM = "B3" W, H, FPS = 1920, 1080, 30 # final cut: native 1080p. Almost every quantity in this file is already # expressed as a fraction of the supersampled canvas (sw/shh/ww/hh/fh), so the # picture scales on its own; S exists for the handful of literals that are in # absolute pixels (the gate weave, a couple of hairlines) and for the grain, # which is generated at 720p and NEAREST-upscaled so the GRAIN SIZE scales too. S = H / 720.0 # 1.5 SR = 44100 DUR = 74.0 # round 2b: +6 s of proper ending N_FRAMES = int(DUR * FPS) OUT = Path(__file__).resolve().parent FRAMES = OUT / "frames"; FRAMES.mkdir(exist_ok=True) AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = Path(__file__).resolve().parent # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" SECTIONS = [ ("core", 0.0, 7.0), ("growth", 7.0, 20.0), ("crossdate",20.0, 30.0), ("scars", 30.0, 42.0), ("branch", 42.0, 58.0), ("thisyear", 58.0, 68.0), ("coda", 68.0, 74.0), ] MUSIC_DESC = ("mbira / chimurenga — kushaura + kutsinhira interlocked on a " "48-pulse cycle, modelled deza bottle-cap buzz, hosho 3:4, " "opening into electric guitar, bass and kit, closing on one " "thumb piano with a last tine left ringing; 112→146→70 bpm") ENGINE_DESC = ("direct-on-film scratch animation — 35 mm strip with real " "sprocket geometry, splice tape, gate weave and a variable-" "area optical track that IS the mix; every mark on the " "emulsion is gated on an onset in the tine, hosho or bass " "band, so the drawing is the record read back; the reel runs " "out to tail leader and leaves an empty gate") YEAR0 = 1846 NY = 181 # 1846 .. 2026 inclusive def sec_of_t(t): for nm, a, b in SECTIONS: if a <= t < b: return nm return SECTIONS[-1][0] def clamp01(x): return 0.0 if x < 0 else (1.0 if x > 1 else x) def ease_io(u): return u * u * (3 - 2 * u) def ease_out(u): return 1 - (1 - u) ** 3 def lerp(a, b, u): return a + (b - a) * u # ════════════════════════════════════════════════════════════════════════════ # PULSE GRID — the mbira cycle is 48 pulses. A beat is 4 pulses; twelve beats # make the cycle, and the hosho's threes cut across it. # ════════════════════════════════════════════════════════════════════════════ TEMPO = [(0.0, 108), (7.0, 112), (16.0, 118), (24.0, 122), (30.0, 128), (38.0, 134), (46.0, 142), (54.0, 146), (58.0, 146), (61.0, 122), (64.0, 100), (68.0, 96), # the ritard into the close — nothing before 68 s is touched (70.5, 80), (74.0, 70)] def bpm_at(t): if t <= TEMPO[0][0]: return TEMPO[0][1] for (t0, b0), (t1, b1) in zip(TEMPO, TEMPO[1:]): if t0 <= t <= t1: return b0 + (b1 - b0) * (t - t0) / max(1e-6, t1 - t0) return TEMPO[-1][1] def build_beats(): out = []; t = 0.0 while t < DUR + 2.0: out.append(t); t += 60.0 / bpm_at(t) return out BEATS = build_beats() PULSES = [] for _i in range(len(BEATS) - 1): for _k in range(4): PULSES.append(BEATS[_i] + (BEATS[_i + 1] - BEATS[_i]) * _k / 4.0) PULSES.append(BEATS[-1]) PULSES = np.array(PULSES) def snap(t): return float(BEATS[int(np.argmin([abs(b - t) for b in BEATS]))]) def pulse_before(t): i = int(np.searchsorted(PULSES, t, "right")) - 1 return max(0, min(len(PULSES) - 1, i)) # ── ring growth is quantized to the pulse: rings pop in on the shaker ------- GROW_T0, GROW_T1 = 3.0, 59.0 _P0, _P1 = pulse_before(GROW_T0), pulse_before(GROW_T1) def rings_at(t): if t <= GROW_T0: return 1 p = pulse_before(min(t, GROW_T1)) n = int(round(NY * (p - _P0) / max(1, _P1 - _P0))) return max(1, min(NY, n)) # ════════════════════════════════════════════════════════════════════════════ # THE GROWTH MODEL — this is where the rings come from. # # w(age) = trend(age) * exp(climate), climate an AR(1) series with a few # multi-year droughts pushed in. Then the pathology. # ════════════════════════════════════════════════════════════════════════════ def y2i(year): return year - YEAR0 FIRE = [(1871, 0.62, 0.46), (1934, 2.30, 0.30)] # (year, angle, half-width) FROST = [1889, 1955] MISSING = [1902, 1903] FALSE = [1926, 1979] LEAN0 = 1958 # reaction wood begins def growth_model(): rng = np.random.RandomState(18460914) age = np.arange(NY, dtype=np.float64) trend = 0.62 * np.exp(-age / 46.0) + 0.135 # mm/yr, plausible cl = np.zeros(NY) e = rng.normal(0, 0.30, NY) for i in range(1, NY): cl[i] = 0.55 * cl[i - 1] + e[i] # AR(1), phi = .55 for y0, ln, d in ((1876, 6, -0.75), (1901, 4, -1.15), (1936, 3, -0.85), (1959, 5, -0.60), (1988, 4, -0.70), (2012, 3, -0.55), (1863, 4, 0.62), (1922, 5, 0.55), (1971, 6, 0.68)): a = y2i(y0) cl[a:a + ln] += d * np.hanning(ln * 2)[ln:] if ln > 1 else d w = trend * np.exp(cl * 0.42) for y in MISSING: w[y2i(y)] = 0.012 # locally absent for y, _a, _hw in FIRE: w[y2i(y)] *= 0.42 w[y2i(y) + 1:y2i(y) + 5] *= np.array([1.9, 1.6, 1.35, 1.2]) # release for y in FROST: w[y2i(y)] *= 0.70 w[-1] *= 0.45 # this year, unfinished w = np.clip(w, 0.010, 1.4) # latewood proportion: dry years put down a fatter dark band lw0 = np.clip(0.70 - 0.16 * np.clip(cl, -2, 2) / 2.0, 0.42, 0.86) # eccentricity — the tree leans from LEAN0 and lays reaction wood ecc = np.zeros(NY) a0 = y2i(LEAN0) ecc[a0:] = np.clip(np.linspace(0, 0.34, NY - a0), 0, 0.34) R = np.concatenate([[0.0], np.cumsum(w)]) # NY+1 boundaries return w, lw0, ecc, R WID, LW0, ECC, RAD = growth_model() R_TOT = float(RAD[-1]) # reaction wood: each eccentric ring adds a little extra radius on the lee # side. The OFFSET is cumulative, so inner rings stay concentric and only the # post-lean rings ride off-centre — which is what a leaning trunk actually does. OFFSET = np.cumsum(WID * ECC) FROST_A = np.zeros(NY + 2, np.float32) for _y in FROST: FROST_A[y2i(_y) + 1] = 1.0 FALSE_A = np.zeros(NY + 2, np.float32) for _y in FALSE: FALSE_A[y2i(_y) + 1] = 1.0 ECC_PAD = np.concatenate([[0.0], ECC]) PHI_LEAN = 2.05 # ── the branching ledger. Every fork stays on screen. ---------------------- BRANCH = [ (1846, 0, "planted at the corner of the field"), (1871, 1, "the fire — the house burns"), (1871, 1, "the fire — the house does not burn"), (1889, 2, "the frost — the child lives"), (1889, 2, "the frost — the child does not"), (1902, 3, "two years missing. nobody writes anything down"), (1926, 4, "a false spring — they marry in March"), (1926, 4, "a false spring — they marry in October"), (1926, 4, "a false spring — they do not marry"), (1934, 5, "the second fire — only the barn"), (1934, 5, "the second fire — not only the barn"), (1955, 6, "the frost again — she stays"), (1955, 6, "the frost again — she goes to the city"), (1958, 7, "the tree leans. the road comes"), (1958, 7, "the tree leans. the road does not come"), (1971, 8, "they sell the field"), (1971, 8, "they keep it and it keeps them"), (1988, 9, "the house stands empty"), (1988, 9, "the house is full and nobody notices"), (2004, 10, "someone counts the rings"), (2004, 10, "no one ever does"), (2026, 11, "this ring is not finished"), ] def branch_t(year): """The wall-clock time at which a given year's ring is laid down.""" n = y2i(year) + 1 if n <= 1: return GROW_T0 u = n / NY return GROW_T0 + (GROW_T1 - GROW_T0) * u # ════════════════════════════════════════════════════════════════════════════ # AUDIO # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0 * 2.0 ** ((m - 69) / 12.0) SCALE = [0, 2, 4, 5, 7, 9, 11] def deg(n, base=60): return base + 12 * (n // 7) + SCALE[n % 7] def adsr(n, a, d, s, r): e = np.zeros(n) if n <= 0: return e ai, di, ri = max(1, int(a * SR)), max(1, int(d * SR)), max(1, int(r * SR)) ai = min(ai, n); e[:ai] = np.linspace(0, 1, ai) if ai < n: dd = min(di, n - ai) e[ai:ai + dd] = np.linspace(1, s, dd); e[ai + dd:] = s if ri < n: e[-ri:] *= np.linspace(1, 0, ri) return e def bandshape(x, lo=0.0, hi=0.0, order=4): n = len(x) if n < 8: return x X = np.fft.rfft(x); fq = np.maximum(np.fft.rfftfreq(n, 1 / SR), 1e-6) g = np.ones_like(fq) if lo: g *= 1.0 / np.sqrt(1.0 + (lo / fq) ** order) if hi: g *= 1.0 / np.sqrt(1.0 + (fq / hi) ** order) return np.fft.irfft(X * g, n) _MB = {} def mbira(midi, dur, seed=0, gain=1.0, buzz=1.0, bright=1.0): """A struck steel tine on a gourd. Inharmonic partials, a wooden body resonance, and the deza — bottle caps rattling on the soundboard, made of DISCRETE impacts triggered off the note envelope.""" key = (int(round(midi)), round(dur, 3), seed % 5, round(buzz, 2), round(bright, 2)) if key in _MB: return _MB[key] * gain n = int(dur * SR) if n < 64: _MB[key] = np.zeros(max(0, n)); return _MB[key] t = np.arange(n) / SR rng = np.random.RandomState((abs(int(midi * 31 + seed * 17)) % 99991) + 3) f = mtof(midi) y = np.zeros(n) for h, a, dk in ((1.000, 1.00, 3.4), (2.756, 0.44, 6.8), (5.404, 0.20, 11.0), (8.933, 0.09, 17.0), (13.34, 0.04, 24.0)): y += a * bright ** (h > 1.5) * np.sin( 2 * np.pi * f * h * t + rng.uniform(0, 6.28)) * np.exp(-t * dk) # gourd body: a low resonance excited by the strike for bf, ba, bd in ((96.0, .34, 9.0), (168.0, .22, 12.0), (247.0, .12, 15.0)): y += ba * np.sin(2 * np.pi * bf * t) * np.exp(-t * bd) # attack: thumbnail on steel y += bandshape(rng.randn(n), lo=1800, hi=9000) * np.exp(-t * 260) * 0.30 envl = np.abs(y) k = max(1, int(0.004 * SR)) envl = np.convolve(envl, np.ones(k) / k, "same") envl /= envl.max() + 1e-9 # ── the deza: caps lift and fall while the tine is loud ----------------- if buzz > 0.01: imp = np.zeros(n) rate = 190.0 # caps strike ~190/s while ringing tt = 0.0 while tt < dur: i = int(tt * SR) if i < n and envl[i] > 0.06: imp[i] += rng.uniform(0.35, 1.0) * envl[i] tt += (1.0 / rate) * rng.uniform(0.55, 1.55) dec = np.exp(-np.arange(int(0.010 * SR)) / SR * 420) rat = np.convolve(imp, dec)[:n] rat = bandshape(rat * bandshape(rng.randn(n), lo=1500, hi=8000) * 3.0, lo=2000, hi=7600) # round 2b: the caps come down 20 % (0.55 -> 0.44) so the TINE reads. # Only the buzz component is scaled; the struck steel is untouched. y += rat * 0.44 * buzz y *= adsr(n, 0.001, 0.02, 0.85, dur * 0.30) y /= np.max(np.abs(y)) + 1e-9 if len(_MB) > 700: _MB.clear() _MB[key] = y return y * gain _HS = {} def hosho(seed=0, gain=1.0, accent=0.0, dur=0.085): key = (seed % 6, round(accent, 2), round(dur, 3)) if key in _HS: return _HS[key] * gain n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed * 37) % 99991 + 11) # hota seeds inside a gourd: a swarm of tiny impacts, tightly grouped imp = np.zeros(n) m = int(38 + 26 * accent) for _ in range(m): i = int(abs(rng.normal(0, 0.0055 + 0.004 * (1 - accent))) * SR) if i < n: imp[i] += rng.uniform(0.3, 1.0) dec = np.exp(-np.arange(int(0.004 * SR)) / SR * 900) y = np.convolve(imp, dec)[:n] y = bandshape(y * (1 + 0.6 * rng.randn(n)), lo=2600 - 600 * accent, hi=11000) y *= np.exp(-t * (26 - 8 * accent)) y /= np.max(np.abs(y)) + 1e-9 if len(_HS) > 60: _HS.clear() _HS[key] = y return y * gain def eguitar(midi, dur, seed=0, gain=1.0, drive=1.6): n = int(dur * SR) if n < 64: return np.zeros(max(0, n)) t = np.arange(n) / SR rng = np.random.RandomState((seed * 13) % 99991 + 5) f = mtof(midi) y = np.zeros(n) for h in range(1, 13): y += (np.sin(2 * np.pi * f * h * (1 + 0.0006 * h * h) * t + rng.uniform(0, 6.28)) / (h ** 1.15) * np.exp(-t * (2.6 + h * 0.95))) y += bandshape(rng.randn(n), lo=1400, hi=6000) * np.exp(-t * 120) * 0.16 y = np.tanh(y * drive) / np.tanh(drive) y = bandshape(y, lo=110, hi=4600) return y * adsr(n, 0.003, 0.06, 0.72, dur * 0.34) * gain * 0.5 def ebass(midi, dur, seed=0, gain=1.0): n = int(dur * SR) if n < 64: return np.zeros(max(0, n)) t = np.arange(n) / SR rng = np.random.RandomState((seed * 7) % 99991 + 2) f = mtof(midi) y = (np.sin(2 * np.pi * f * t) * np.exp(-t * 3.0) + 0.42 * np.sin(4 * np.pi * f * t) * np.exp(-t * 6.0) + 0.16 * np.sin(6 * np.pi * f * t) * np.exp(-t * 10.0)) y += bandshape(rng.randn(n), lo=1200, hi=4800) * np.exp(-t * 150) * 0.20 return y * adsr(n, 0.002, 0.04, 0.80, dur * 0.30) * gain * 0.9 def kick(seed=0, gain=1.0, dur=0.34, f0=52.0): n = int(dur * SR); t = np.arange(n) / SR f = f0 * (1 + 3.0 * np.exp(-t * 38)) y = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t * 11) rng = np.random.RandomState(seed % 99991) y += bandshape(rng.randn(n), lo=300, hi=2600) * np.exp(-t * 160) * 0.20 return y * gain def snare(seed=0, gain=1.0, dur=0.22): n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState(seed % 99991) y = bandshape(rng.randn(n), lo=900, hi=8200) * np.exp(-t * 26) for f, dk in ((188, 16), (272, 20)): y += np.sin(2 * np.pi * f * t) * np.exp(-t * dk) * 0.28 return y * gain * 0.8 def hat(seed=0, gain=1.0, dur=0.11, open_=0.0): n = int(dur * (1 + 3 * open_) * SR); t = np.arange(n) / SR rng = np.random.RandomState(seed % 99991) y = bandshape(rng.randn(n), lo=6200, hi=15000) * np.exp( -t * (46 - 34 * open_)) return y * gain * 0.45 def room(n, seed=811): rng = np.random.RandomState(seed) return (bandshape(rng.randn(n), lo=55, hi=460) * 0.017 + bandshape(rng.randn(n), lo=3200, hi=10000) * 0.004) def reverb(x, rt=1.9, mix=.24, seed=29, pre=0.016): n = int(rt * SR); t = np.arange(n) / SR rng = np.random.RandomState(seed) ir = rng.randn(n) * np.exp(-t * (5.0 / rt)) ir[:int(pre * SR)] = 0 ir /= np.abs(ir).sum() / 40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x) + n))) wet = irfft(rfft(x, L) * rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet)) + 1e-9 return x * (1 - mix) + wet * mix * (np.max(np.abs(x)) + 1e-9) def delay(x, time=.30, fb=.32, mix=.13, taps=5): d = int(time * SR); out = x.copy() for i in range(1, taps + 1): s = d * i if s >= len(x): break out[s:] += x[:len(x) - s] * mix * (fb ** (i - 1)) return out class Song: def __init__(self, dur): self.n = int(dur * SR); self.tr = {} def put(self, track, sig, at, g=1.0, pan=0.0): if len(sig) == 0: return b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at * SR); j = min(self.n, i + len(sig)) if i >= self.n: return if i < 0: sig = sig[-i:]; i = 0; j = min(self.n, len(sig)) if j <= i: return th = (pan * .5 + .5) * (np.pi / 2) b[i:j] += np.stack([sig[:j - i] * np.cos(th), sig[:j - i] * np.sin(th)], 1) * g def bus(self, track, fn): if track in self.tr: b = self.tr[track] self.tr[track] = np.stack([fn(b[:, 0]), fn(b[:, 1])], 1) def duck(self, tracks, times, depth=0.30, dur=0.15): env = np.ones(self.n) L = int(dur * SR); shape = 1.0 - depth * np.exp( -np.arange(L) / (L * 0.32)) for t in times: i = int(t * SR); j = min(self.n, i + L) if i < 0 or i >= self.n: continue env[i:j] = np.minimum(env[i:j], shape[:j - i]) for k in tracks: if k in self.tr: self.tr[k] *= env[:, None] def mixdown(self, gains, levels=None): mix = np.zeros((self.n, 2)) for k, b in self.tr.items(): mix += b * gains.get(k, 1.0) if levels: env = np.ones(self.n) for nm, a, b in SECTIONS: i0, i1 = int(a * SR), min(self.n, int(b * SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) kk = max(1, int(0.5 * SR)) env = np.convolve(env, np.ones(kk) / kk, "same") mix *= env[:, None] for c in range(2): mix[:, c] = bandshape(mix[:, c], lo=30.0, order=2) mix = np.tanh(mix * 1.20) / np.tanh(1.20) mix = mix / (np.max(np.abs(mix)) + 1e-9) * .94 # the tail: the last tine's own release already reaches zero at DUR, # so this only catches the truncated reverb tail. 0.7 s, raised cosine. fl = int(0.7 * SR) if self.n > fl: mix[-fl:] *= (0.5 * (1 + np.cos( np.linspace(0, np.pi, fl))))[:, None] return mix def write(self, path, mix): with wave.open(str(path), "w") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) w.writeframes((np.clip(mix, -1, 1) * 32767).astype(" list of (pulse, midi, voice). The two parts sit on DIFFERENT pulse residues; the composite line belongs to neither.""" out = [] for ph in range(4): r = PHRASE_ROOT[ph] for q in range(4): p0 = ph * 12 + q * 3 if part == "ku": out.append((p0, deg(r + KU_LOW[q], 48), "low")) out.append((p0 + 2, deg(r + KU_HIGH[q], 60), "high")) if q % 2 == 0: out.append((p0 + 1, deg(r + KU_HIGH[q] + 2, 60), "high")) else: out.append((p0 + 1, deg(r + KT_LOW[q], 48), "low")) out.append((p0 + 3, deg(r + KT_HIGH[q], 60), "high")) if q % 2 == 1: out.append((p0 + 2, deg(r + KT_HIGH[q] - 2, 72), "high")) return sorted(out) KU, KT = cycle_notes("ku"), cycle_notes("kt") def build_song(): s = Song(DUR) kicks = [] npul = len(PULSES) ku_map = {p: [] for p in range(48)} kt_map = {p: [] for p in range(48)} for p, m, v in KU: ku_map[p % 48].append((m, v)) for p, m, v in KT: kt_map[p % 48].append((m, v)) for pi in range(npul - 1): t = float(PULSES[pi]) if t > DUR: break pd = float(PULSES[pi + 1]) - t c = pi % 48 heat = clamp01((t - 4.0) / 40.0) sec = sec_of_t(t) tail = clamp01((t - 60.0) / 6.0) # everything thins at the end # the coda: after 68 s the last hand lets go of the cycle. Zero for # every t < 68, so nothing before the close is altered. coda = clamp01((t - 68.0) / 2.6) # ── kushaura: present from the top --------------------------------- if t > 1.4 and coda < 1.0: for m, v in ku_map[c]: g = (0.95 if v == "low" else 0.80) * (1 - 0.55 * tail) * \ (1 - coda) if sec == "crossdate": g *= 1.05 s.put("ku", mbira(m, min(1.5, pd * 7.0), seed=pi, gain=g, buzz=0.75 + 0.45 * heat, bright=1.0), t, g=1.0, pan=-0.30 if v == "low" else -0.16) # ── kutsinhira: enters at the growth section ----------------------- if t > 7.4 and t < 63.0: fade = clamp01((t - 7.4) / 3.0) * (1 - 0.85 * tail) for m, v in kt_map[c]: s.put("kt", mbira(m, min(1.4, pd * 6.0), seed=pi + 900, gain=(0.82 if v == "low" else 0.66) * fade, buzz=0.9, bright=1.06), t, g=1.0, pan=0.30 if v == "low" else 0.16) # ── hosho: every pulse, accent on the threes ----------------------- if t > 3.0 and t < 62.0: ac = 1.0 if c % 3 == 0 else 0.0 g = (0.55 + 0.40 * heat) * (1 - 0.8 * tail) s.put("hosho", hosho(seed=pi, gain=g * (1.0 if ac else 0.62), accent=ac), t, g=1.0, pan=0.42 if c % 3 == 1 else -0.42 if c % 3 == 2 else 0.05) # ── the band. Guitar transcribes the mbira line; bass and kit under it. for pi in range(npul - 1): t = float(PULSES[pi]) if t < 29.0 or t > 61.0: continue pd = float(PULSES[pi + 1]) - t c = pi % 48 fade = clamp01((t - 29.0) / 5.0) * (1 - clamp01((t - 58.0) / 3.0)) for m, v in ku_map[c]: if v != "high": continue s.put("gtr", eguitar(m, min(0.9, pd * 4.2), seed=pi, gain=0.80 * fade, drive=1.5 + 0.9 * fade), t, g=1.0, pan=0.22) if c % 6 == 0: root = deg(PHRASE_ROOT[(c // 12) % 4], 36) s.put("bass", ebass(root, min(1.1, pd * 5.5), seed=pi, gain=0.95 * fade), t, g=1.0, pan=-0.06) elif c % 6 == 4: root = deg(PHRASE_ROOT[(c // 12) % 4] + 4, 36) s.put("bass", ebass(root, min(0.8, pd * 3.4), seed=pi + 3, gain=0.62 * fade), t, g=1.0, pan=-0.06) if c % 12 == 0 or c % 12 == 7: s.put("kit", kick(seed=pi, gain=0.95 * fade), t, g=1.0) kicks.append(t) if c % 12 == 4 or c % 12 == 10: s.put("kit", snare(seed=pi + 40, gain=0.80 * fade), t, g=1.0, pan=0.10) if c % 2 == 0: s.put("kit", hat(seed=pi + 70, gain=0.55 * fade, open_=0.6 if c % 12 == 6 else 0.0), t, g=1.0, pan=0.34) # ── event years get a struck tine two octaves down: fires, frosts ------ for y, _a, _hw in FIRE: s.put("event", mbira(deg(0, 24), 2.6, seed=y, gain=1.0, buzz=1.4, bright=1.3), branch_t(y), g=1.0) for y in FROST: s.put("event", mbira(deg(2, 36), 1.8, seed=y, gain=0.75, buzz=1.2), branch_t(y), g=1.0, pan=0.2) # ── THE CLOSE (68 → 74 s) ────────────────────────────────────────────── # The band went at 61, the hosho at 62, the kutsinhira at 63. Above, the # kushaura lets go of the cycle across 68–70.6. What is left is one thumb # piano finishing the line: four notes, each further apart than the last — # the ritard is in the spacing, not just the tempo map — settling onto the # tonic, which is struck once and left to decay on its own. Nothing is cut: # the last note's own release reaches zero exactly at DUR. for (ct, mnote, gg, dd) in ((69.55, deg(4, 60), 0.60, 1.05), (70.10, deg(2, 60), 0.56, 1.05), (70.72, deg(0, 60), 0.52, 1.25), (71.42, deg(4, 48), 0.46, 1.45)): s.put("coda", mbira(mnote, dd, seed=int(ct * 10), gain=gg, buzz=0.45, bright=0.96), ct, g=1.0, pan=-0.10) FIN_T = 71.95 # the last tine, left ringing s.put("coda", mbira(deg(0, 48), DUR - FIN_T, seed=7717, gain=0.74, buzz=0.30, bright=0.88), FIN_T, g=1.0, pan=-0.04) s.put("coda", mbira(deg(0, 36), DUR - FIN_T, seed=7719, gain=0.30, buzz=0.0, bright=0.80), FIN_T, g=1.0, pan=0.08) s.put("room", room(s.n), 0.0, g=1.0) return s, kicks def finish_song(): s, kicks = build_song() s.duck(["ku", "kt", "gtr", "hosho"], kicks, depth=0.22, dur=0.12) s.bus("ku", lambda x: reverb(delay(x, 0.27, .28, .10), rt=1.7, mix=.22, seed=401)) s.bus("kt", lambda x: reverb(delay(x, 0.33, .24, .09), rt=1.7, mix=.24, seed=403)) s.bus("hosho", lambda x: reverb(x, rt=0.9, mix=.12, seed=405)) s.bus("gtr", lambda x: reverb(delay(x, 0.22, .30, .14), rt=2.2, mix=.26, seed=407)) s.bus("kit", lambda x: reverb(x, rt=1.1, mix=.12, seed=409)) s.bus("event", lambda x: reverb(x, rt=3.6, mix=.44, seed=411)) s.bus("coda", lambda x: reverb(x, rt=2.6, mix=.30, seed=413)) # round 2b: hosho 0.72 -> 0.576 (x0.8, -1.94 dB). The gourd shaker is the # other noise source in the kit; the bells stay where they were. mix = s.mixdown(dict(ku=1.00, kt=0.94, hosho=0.576, gtr=0.72, bass=0.90, kit=0.80, event=0.95, coda=1.00, room=1.0), levels=dict(core=.58, growth=.88, crossdate=.82, scars=.98, branch=1.06, thisyear=.66, coda=.62)) wav = AUD / "final.wav" s.write(wav, mix) return wav, mix def analyze(mix): x = mix.mean(1) hop = SR / FPS; win = int(hop * 1.7) E = {k: np.zeros(N_FRAMES) for k in ("rms", "low", "mid", "high")} for f in range(N_FRAMES): i = int(f * hop); seg = x[i:i + win] if len(seg) < 16: continue E["rms"][f] = np.sqrt((seg ** 2).mean()) sp = np.abs(np.fft.rfft(seg * np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1 / SR) E["low"][f] = sp[fr < 200].sum() E["mid"][f] = sp[(fr >= 200) & (fr < 2600)].sum() E["high"][f] = sp[fr >= 2600].sum() for k in E: p = np.percentile(E[k], 96) + 1e-9 E[k] = np.clip(E[k] / p, 0, 1.25) lo = E["low"] flux = np.maximum(0, lo - np.concatenate([[0], lo[:-1]])) E["hit"] = np.clip(np.convolve(flux, [.25, .5, .25], "same") / (np.percentile(flux, 97) + 1e-9), 0, 1) np.savez(AUD / "env.npz", **E) return E _ENV = {} def env(): if not _ENV: z = np.load(AUD / "env.npz") for k in z.files: _ENV[k] = z[k] return _ENV # ════════════════════════════════════════════════════════════════════════════ # ROUND 2 — THE PICTURE IS 35 mm FILM, SCRATCHED BY THE RECORD ITSELF # # The dendro renderer is gone. What replaces it is the direct-on-film engine # (perforations, frame lines, splice tape, dust, tramlines, the optical track # down the edge) with one rule: NOTHING IS DRAWN THAT THE TRACK DID NOT ASK # FOR. The wav is analysed a second time for onsets — separately in the tine # band, the hosho band and the bass — and every mark on the emulsion is an # event in one of those lists, held for a few film frames the way a scratch # animator holds a drawing on twos. # # * mbira tine onsets -> RINGS. Concentric circles scratched through the # emulsion, expanding for four film frames from where the note landed. # Kushaura marks lean left of centre, kutsinhira right, so you can see the # interlock: the composite pattern belongs to neither hand. # * hosho triplets -> SCRATCH BURSTS. Short hard diagonals, count and # length gated on the 4–12 kHz band. # * bass / kick -> INK. A stencilled disc punched on the low flux. # * section changes -> SPLICES. Yellow tape with its bubbles crosses the # gate exactly at the section boundary, and the reel number changes. # # The optical track is not decorative either: it is the actual mix, rectified # and drawn as a variable-area envelope at 1200 samples per second, so the # shape crossing the sound gate is the sample you are hearing. # ════════════════════════════════════════════════════════════════════════════ SS = 2 SP = SS * S # supersampled px per authored (720p) px FILM_FPS = 24.0 TRK_SR = 1200.0 # samples per second of drawn optical track # 35 mm, in units of the film's own width. Pitch is 19 of those 35. FILM_U = 35.0 PITCH_U = 19.0 PERF_L = (1.2, 2.8) TRK_U = (4.4, 2.6) PIC_U = (7.6, 23.0) PERF_R = (31.0, 2.8) PIC_H = 16.0 / PITCH_U INKS = [(228, 46, 40), (248, 192, 30), (34, 112, 198), (40, 176, 104), (236, 116, 26), (206, 44, 150), (240, 238, 228)] SPLICE_T = tuple(a for _, a, _ in SECTIONS[1:]) def film_frame(t): return int(t * FILM_FPS) def weave(ff): rng = np.random.RandomState((ff * 2749 + 11) % (2 ** 31 - 1)) return rng.uniform(-1, 1), rng.uniform(-1, 1) def _stroke(d, pts, w, fill): d.line(pts, fill=fill, width=max(1, int(w)), joint="curve") # ── the second analysis: onsets, per band ────────────────────────────────── def analyze_marks(mix): """Onset lists and a drawable optical track, straight off the mix.""" x = mix.mean(1) hop = SR / FPS win = int(hop * 1.5) nf = N_FRAMES B = {k: np.zeros(nf) for k in ("tine", "hos", "bas")} for f in range(nf): i = int(f * hop); seg = x[i:i + win] if len(seg) < 32: continue sp = np.abs(np.fft.rfft(seg * np.hanning(len(seg)))) fr = np.fft.rfftfreq(len(seg), 1 / SR) B["bas"][f] = sp[fr < 190].sum() B["tine"][f] = sp[(fr >= 320) & (fr < 2400)].sum() B["hos"][f] = sp[(fr >= 4200) & (fr < 12000)].sum() out = {} for k, v in B.items(): p = np.percentile(v, 96) + 1e-9 v = v / p fx = np.maximum(0.0, v - np.concatenate([[0.0], v[:-1]])) fx = np.convolve(fx, [0.2, 0.6, 0.2], "same") out[k] = np.clip(v, 0, 1.4) out[k + "_f"] = np.clip(fx / (np.percentile(fx, 97) + 1e-9), 0, 1.6) # the optical track: rectified, lightly smoothed, at TRK_SR step = int(SR / TRK_SR) n = len(x) // step seg = np.abs(x[:n * step]).reshape(n, step).max(1) seg = np.convolve(seg, np.ones(3) / 3.0, "same") out["trk"] = (seg / (np.percentile(seg, 99) + 1e-9)).astype(np.float32) np.savez(AUD / "marks.npz", **out) return out _MK = {} def marks(): if not _MK: z = np.load(AUD / "marks.npz") for k in z.files: _MK[k] = z[k] # the event lists: a local peak in a band's flux above a floor for band, thr, hold in (("tine", 0.24, 4), ("hos", 0.30, 2), ("bas", 0.34, 5)): f = _MK[band + "_f"] ev = [] last = -99 for i in range(1, len(f) - 1): if f[i] >= thr and f[i] >= f[i - 1] and f[i] > f[i + 1] and \ i - last >= 2: ev.append((i, float(min(1.0, f[i])))) last = i _MK[band + "_ev"] = ev _MK[band + "_hold"] = hold return _MK def _ffi(ff): """Camera-frame index for a film frame.""" return int(min(N_FRAMES - 1, max(0, round(ff / FILM_FPS * FPS)))) def events_near(band, ff, span): """Events whose camera-frame index falls in the film frame's window, plus their age in film frames.""" M = marks() i0 = _ffi(ff) out = [] for (i, v) in M[band + "_ev"]: age = (i0 - i) * FILM_FPS / FPS if -0.6 <= age <= span: out.append((i, v, age)) return out # ════════════════════════════════════════════════════════════════════════════ # THE EMULSION — every mark is an onset # ════════════════════════════════════════════════════════════════════════════ def draw_marks(d, box, ff, e, kind="both", seed=0): x0, y0, x1, y1 = box ww, hh = x1 - x0, y1 - y0 if ww < 3 or hh < 3: return d.rectangle([x0, y0, x1, y1], fill=(9, 8, 10)) t = ff / FILM_FPS sec = sec_of_t(t) rng = np.random.RandomState((ff * 9781 + seed * 131 + 7) % (2 ** 31 - 1)) hi = float(e.get("high", 0.3)) rms = float(e.get("rms", 0.4)) lw = max(1.0, ww * 0.0055) # ── bass / kick: stencilled ink, punched and gone ------------------ if kind in ("both", "ink"): for (i, v, age) in events_near("bas", ff, 5): if age < 0: continue r = min(ww, hh) * (0.10 + 0.30 * v) * (1.0 - age / 6.0) if r <= 1: continue rr = np.random.RandomState(i * 7717) cx = x0 + ww * (0.16 + 0.68 * rr.rand()) cy = y0 + hh * (0.18 + 0.64 * rr.rand()) col = INKS[(i // 3) % len(INKS)] pts = [] for q in range(13): a = q / 13 * 2 * math.pi k = r * (0.80 + 0.34 * rr.rand()) pts.append((cx + math.cos(a) * k, cy + math.sin(a) * k * 0.86)) d.polygon(pts, fill=col) # ── mbira tines: rings scratched through the emulsion -------------- if kind in ("both", "rings"): for (i, v, age) in events_near("tine", ff, 4): if age < 0: continue rr = np.random.RandomState(i * 3313 + seed) hand = 0 if (i % 2 == 0) else 1 # kushaura / kutsinhira cx = x0 + ww * ((0.10 + 0.34 * rr.rand()) if hand == 0 else (0.56 + 0.34 * rr.rand())) cy = y0 + hh * (0.14 + 0.72 * rr.rand()) base = min(ww, hh) * (0.055 + 0.24 * v) nring = 2 + int(3 * v) for q in range(nring): r = base * (0.32 + 0.70 * q / max(1, nring - 1)) * \ (1.0 + age * 0.42) if r < 1.5: continue col = (246, 244, 236) if hand == 0 else (236, 214, 150) jj = r * 0.10 d.ellipse([cx - r + rr.uniform(-jj, jj), cy - r * 0.92 + rr.uniform(-jj, jj), cx + r + rr.uniform(-jj, jj), cy + r * 0.92 + rr.uniform(-jj, jj)], outline=col, width=max(1, int(lw * (1.6 - 0.2 * q)))) # ── hosho: scratch bursts, hard and short --------------------------- if kind in ("both", "scratch"): nb = 0 for (i, v, age) in events_near("hos", ff, 2): if age < 0: continue nb += int(3 + 9 * v) nb = min(46, nb + int(2 * hi)) for q in range(nb): ax = x0 + ww * rng.rand(); ay = y0 + hh * rng.rand() ln = ww * (0.03 + 0.10 * rng.rand()) * (0.6 + rms) a = rng.uniform(0.5, 1.1) * (1 if rng.rand() < 0.5 else -1) _stroke(d, [(ax, ay), (ax + math.cos(a) * ln, ay + math.sin(a) * ln)], lw * (0.7 + 0.8 * rng.rand()), (244, 242, 234)) # ── the held drawing: the cycle itself, breathing on the rms ------- if kind in ("both", "rings"): cx, cy = x0 + ww * 0.5, y0 + hh * 0.5 base = min(ww, hh) * (0.16 + 0.20 * rms) for q in range(3): r = base * (0.55 + 0.34 * q) * (1.0 + 0.05 * math.sin(ff * 0.32 + q * 1.1)) jj = r * 0.045 d.ellipse([cx - r + rng.uniform(-jj, jj), cy - r * 0.90 + rng.uniform(-jj, jj), cx + r + rng.uniform(-jj, jj), cy + r * 0.90 + rng.uniform(-jj, jj)], outline=(196, 192, 178), width=max(1, int(lw * 0.8))) # two tramlines: this print has been through the projector before for q, fx in ((0, 0.213), (1, 0.681)): xx = x0 + ww * fx + math.sin(ff * 0.11 + q * 2.0) * ww * 0.006 d.line([(xx, y0), (xx, y1)], fill=(206, 204, 194), width=max(1, int(lw * 0.55))) # ── the section's own signature ------------------------------------- if sec == "core" and kind in ("both", "rings"): cx, cy = x0 + ww * 0.5, y0 + hh * 0.5 for q in range(7): r = min(ww, hh) * (0.06 + 0.062 * q) * (1.0 + 0.05 * math.sin(ff * 0.4 + q)) d.ellipse([cx - r, cy - r * 0.9, cx + r, cy + r * 0.9], outline=(230, 228, 218), width=max(1, int(lw * 0.9))) if sec == "thisyear" and kind in ("both", "rings"): cx, cy = x0 + ww * 0.5, y0 + hh * 0.5 r = min(ww, hh) * 0.34 d.arc([cx - r, cy - r * 0.9, cx + r, cy + r * 0.9], -60, 190 + 60 * math.sin(ff * 0.2), fill=(246, 244, 238), width=max(1, int(lw * 1.6))) if sec in ("scars", "branch") and kind in ("both", "scratch"): for q in range(int(1 + 4 * rms)): xx = x0 + ww * rng.rand() _stroke(d, [(xx, y0), (xx + rng.uniform(-1, 1) * ww * 0.22, y1)], lw * 0.8, INKS[(ff + q) % len(INKS)]) def draw_perfs(d, x, y0, y1, fp, fh, wdt): k0 = int(fp) - 4 for k in range(k0, k0 + 9): for q in range(4): yy = y0 + (fp - k - q / 4.0) * fh if yy < y0 - fh or yy > y1 + fh: continue hh_ = fh * 0.115 d.rounded_rectangle([x, yy - hh_ / 2, x + wdt, yy + hh_ / 2], radius=max(1, int(wdt * 0.16)), fill=(214, 210, 198)) def draw_track_strip(d, x0, x1, ytop, ybot, t, fh, gate_y, big=1.0): """The variable-area optical track: the mix itself, drawn.""" M = marks() trk = M["trk"] cx = (x0 + x1) * 0.5 hw = (x1 - x0) * 0.5 d.rectangle([x0, ytop, x1, ybot], fill=(16, 15, 17)) pxps = fh * FILM_FPS # screen px per second span = (ybot - ytop) / max(1e-6, pxps) lo = max(0.0, t - span * 0.62) hi = min(DUR, t + span * 0.62) n = max(2, int((hi - lo) * TRK_SR)) ts = np.linspace(lo, hi, n) idx = np.clip((ts * TRK_SR).astype(np.int32), 0, len(trk) - 1) amp = np.clip(trk[idx], 0, 1.2) ** 1.7 * 0.92 ys = gate_y + (t - ts) * pxps left = cx - amp * hw * 0.94 right = cx + amp * hw * 0.94 poly = [(float(left[k]), float(ys[k])) for k in range(n)] + \ [(float(right[k]), float(ys[k])) for k in range(n - 1, -1, -1)] d.polygon(poly, fill=(228, 226, 218)) d.line([x0 - hw * 0.5, gate_y, x1 + hw * 0.5, gate_y], fill=(250, 96, 60), width=max(1, int(2 * big))) def draw_hair(d, box, t, seed=3): x0, y0, x1, y1 = box pts = [] bx = x0 + (x1 - x0) * 0.62 for k in range(12): u = k / 11.0 pts.append((bx + math.sin(u * 5.2 + t * 7.0) * (x1 - x0) * 0.045 * u, y1 - (y1 - y0) * 0.52 * u)) _stroke(d, pts, max(2, int((x1 - x0) * 0.004)), (14, 12, 14)) def dust_and_scratches(d, box, ff, n=1.0): x0, y0, x1, y1 = box rng = np.random.RandomState((ff * 3617 + 5) % (2 ** 31 - 1)) for q in range(int(14 * n)): cx = x0 + (x1 - x0) * rng.rand(); cy = y0 + (y1 - y0) * rng.rand() r = (x1 - x0) * rng.uniform(0.0015, 0.006) d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(20, 18, 20) if rng.rand() < .6 else (238, 236, 228)) for q in range(int(3 * n)): if rng.rand() < 0.55: continue xx = x0 + (x1 - x0) * rng.rand() d.line([xx, y0, xx + rng.uniform(-6, 6) * S, y1], fill=(226, 224, 214), width=max(1, int(round(S)))) def draw_splices(d, left, right, t, fh, gate_y, stripw, shh): for sp in SPLICE_T: yy = gate_y + (t - sp) * FILM_FPS * fh if not (-fh * 2 < yy < shh + fh * 2): continue d.rectangle([left, yy - fh * 0.06, right, yy + fh * 0.06], fill=(198, 178, 122)) rng = np.random.RandomState(int(sp * 100)) for q in range(18): bx = left + stripw * rng.rand() by = yy + (rng.rand() - 0.5) * fh * 0.10 r = fh * rng.uniform(0.004, 0.014) d.ellipse([bx - r, by - r, bx + r, by + r], fill=(234, 218, 170)) d.line([left, yy - fh * 0.06, right, yy - fh * 0.06], fill=(120, 104, 66), width=max(1, int(2 * SP))) def _canvas(bg=(12, 11, 13)): im = Image.new("RGB", (W * SS, H * SS), bg) return im, ImageDraw.Draw(im) def _dn(im): return np.asarray(im, np.float32).reshape(H, SS, W, SS, 3).mean((1, 3)) def _strip(d, sw, shh, t, ff, zoom, cx_off, kind="both", seed=0, hair=False, splices=True): fh = shh * 0.50 * zoom stripw = fh * (FILM_U / PITCH_U) U = stripw / FILM_U scx = sw * (0.5 + cx_off) gate_y = shh * 0.5 wx, wy = weave(ff) scx += wx * 2 * SP; gate_y += wy * 2 * SP left = scx - stripw / 2; right = left + stripw d.rectangle([left, 0, right, shh], fill=(26, 24, 28)) picx0 = left + PIC_U[0] * U; picx1 = picx0 + PIC_U[1] * U trkx0 = left + TRK_U[0] * U; trkx1 = trkx0 + TRK_U[1] * U ph = fh * PIC_H for kf in range(ff - 2, ff + 3): yc = gate_y + (ff - kf) * fh if yc < -fh or yc > shh + fh: continue e = env_at(_ffi(kf)) draw_marks(d, (picx0, yc - ph / 2, picx1, yc + ph / 2), kf, e, kind, seed) dust_and_scratches(d, (picx0, yc - ph / 2, picx1, yc + ph / 2), kf, 0.9) d.rectangle([picx0, yc - fh * 0.5, picx1, yc - ph / 2], fill=(20, 18, 22)) d.rectangle([picx0, yc + ph / 2, picx1, yc + fh * 0.5], fill=(20, 18, 22)) draw_perfs(d, left + PERF_L[0] * U, 0, shh, float(ff), fh, PERF_L[1] * U) draw_perfs(d, left + PERF_R[0] * U, 0, shh, float(ff), fh, PERF_R[1] * U) draw_track_strip(d, trkx0, trkx1, 0, shh, t, fh, gate_y, big=SP) if splices: draw_splices(d, left, right, t, fh, gate_y, stripw, shh) if hair: draw_hair(d, (left, 0, right, shh), t) return left, right, fh _EV = {} def env_at(i): if "E" not in _EV: _EV["E"] = env() E = _EV["E"] i = int(min(N_FRAMES - 1, max(0, i))) return {k: float(E[k][i]) for k in E} ENGINES = {} def eng(name): def deco(fn): ENGINES[name] = fn; return fn return deco @eng("strip") def e_strip(sh, k, u, t, e): im, d = _canvas() z = lerp(sh.p.get("z0", 1.0), sh.p.get("z1", 1.05), ease_io(u)) _strip(d, W * SS, H * SS, t, film_frame(t), z, sh.p.get("cx", 0.0), sh.p.get("kind", "both"), hair=sh.p.get("hair", False)) return _dn(im) @eng("twin") def e_twin(sh, k, u, t, e): """Two strips, side by side: kushaura's marks on one, kutsinhira's on the other. The interlock is the picture.""" im, d = _canvas((10, 9, 11)) z = lerp(sh.p.get("z0", 0.60), sh.p.get("z1", 0.64), ease_io(u)) _strip(d, W * SS, H * SS, t, film_frame(t), z, -0.235, "rings", seed=0, splices=False) _strip(d, W * SS, H * SS, t, film_frame(t) - 1, z, 0.235, "rings", seed=9, splices=False) return _dn(im) @eng("gate") def e_gate(sh, k, u, t, e): im, d = _canvas((6, 6, 7)) sw, shh = W * SS, H * SS ff = film_frame(t) wx, wy = weave(ff) z = lerp(sh.p.get("z0", 1.0), sh.p.get("z1", 1.04), ease_io(u)) m = sw * 0.035 / z box = (m + wx * 4 * SP, m * 0.62 + wy * 4 * SP, sw - m + wx * 4 * SP, shh - m * 0.62 + wy * 4 * SP) draw_marks(d, box, ff, e, sh.p.get("kind", "both")) dust_and_scratches(d, box, ff, n=2.0) if sh.p.get("hair"): draw_hair(d, box, t) if sh.p.get("slip"): yy = box[1] + (box[3] - box[1]) * (0.10 + 0.80 * ((t * 1.6) % 1.0)) d.rectangle([box[0], yy - shh * 0.014, box[2], yy + shh * 0.014], fill=(20, 18, 22)) draw_marks(d, (box[0], box[1], box[2], yy - shh * 0.014), ff - 1, e, sh.p.get("kind", "both"), seed=5) return _dn(im) @eng("track") def e_track(sh, k, u, t, e): """Macro on the optical track: the mix, going past the sound gate.""" im, d = _canvas((10, 9, 11)) sw, shh = W * SS, H * SS z = lerp(sh.p.get("z0", 1.0), sh.p.get("z1", 1.18), ease_io(u)) fh = shh * 1.45 * z stripw = fh * (FILM_U / PITCH_U) U = stripw / FILM_U gate_y = shh * (0.5 + sh.p.get("gy", 0.0)) left = sw * (0.05 + sh.p.get("cx", 0.0)) d.rectangle([left, 0, left + stripw, shh], fill=(26, 24, 28)) trkx0 = left + TRK_U[0] * U; trkx1 = trkx0 + TRK_U[1] * U picx0 = left + PIC_U[0] * U picx1 = min(sw, picx0 + PIC_U[1] * U) draw_perfs(d, left + PERF_L[0] * U, 0, shh, t * FILM_FPS, fh, PERF_L[1] * U) ff = film_frame(t) pw = max(2, int(picx1 - picx0)) sub = Image.new("RGB", (pw, shh), (10, 9, 11)) sd = ImageDraw.Draw(sub) for kf in range(ff - 1, ff + 2): yc = shh * 0.5 + (ff - kf) * fh draw_marks(sd, (0, yc - fh * PIC_H / 2, pw, yc + fh * PIC_H / 2), kf, env_at(_ffi(kf)), "both") sub = sub.filter(ImageFilter.GaussianBlur(sw * 0.006)) im.paste(sub, (int(picx0), 0)) d = ImageDraw.Draw(im) draw_track_strip(d, trkx0, trkx1, 0, shh, t, fh, gate_y, big=SP * 2) lay = Image.new("RGB", (sw, shh), (0, 0, 0)) ld = ImageDraw.Draw(lay) ld.rectangle([trkx0 - U * 3.0, gate_y - shh * 0.050, trkx1 + U * 3.0, gate_y + shh * 0.050], fill=(160, 102, 44)) lay = lay.filter(ImageFilter.GaussianBlur(shh * 0.030)) arr = np.clip(np.asarray(im, np.float32) + np.asarray(lay, np.float32) * 0.95, 0, 255) return arr.reshape(H, SS, W, SS, 3).mean((1, 3)) @eng("dark") def e_dark(sh, k, u, t, e): """One thumb piano left. Almost nothing on the emulsion.""" im, d = _canvas((7, 7, 8)) sw, shh = W * SS, H * SS ff = film_frame(t) box = (sw * 0.18, shh * 0.20, sw * 0.82, shh * 0.76) draw_marks(d, box, ff, e, "rings") dust_and_scratches(d, box, ff, n=1.4) draw_track_strip(d, sw * 0.895, sw * 0.955, 0, shh, t, shh * 0.9, shh * 0.5, big=SP) draw_perfs(d, sw * 0.032, 0, shh, t * FILM_FPS, shh * 0.9, sw * 0.020) return _dn(im) @eng("runout") def e_runout(sh, k, u, t, e): """THE CLOSE. The reel ends, and it ends the way a reel ends. Three things happen in sequence and they are all the same event seen from different parts of the strip: * the marks thin out, because the onsets do — the emulsion is still gated on the track, so the drawing empties itself as the mbira empties; * at `clear` the picture cells go to tail leader — bare amber stock, the end of what was ever exposed — and the optical track, which is the rectified mix, narrows to a hairline on its own as the last tine decays. Nothing forces it: the line IS the sample; * at `out` the physical end of the film passes the gate. Above the tail there is no film, so there is nothing: the strip runs off downward and leaves an empty gate. """ im, d = _canvas((5, 5, 6)) sw, shh = W * SS, H * SS ff = film_frame(t) z = lerp(sh.p.get("z0", 0.96), sh.p.get("z1", 0.86), ease_io(u)) fh = shh * 0.50 * z stripw = fh * (FILM_U / PITCH_U) U = stripw / FILM_U wx, wy = weave(ff) scx = sw * 0.5 + wx * 2 * SP gate_y = shh * 0.5 + wy * 2 * SP left = scx - stripw / 2 right = left + stripw t_clear = sh.p.get("clear", 71.6) t_out = sh.p.get("out", 73.1) d.rectangle([left, 0, right, shh], fill=(26, 24, 28)) picx0 = left + PIC_U[0] * U; picx1 = picx0 + PIC_U[1] * U trkx0 = left + TRK_U[0] * U; trkx1 = trkx0 + TRK_U[1] * U ph = fh * PIC_H for kf in range(ff - 2, ff + 3): yc = gate_y + (ff - kf) * fh if yc < -fh or yc > shh + fh: continue box = (picx0, yc - ph / 2, picx1, yc + ph / 2) if kf / FILM_FPS >= t_clear: d.rectangle(box, fill=(148, 126, 82)) # tail leader dust_and_scratches(d, box, kf, 0.5) else: draw_marks(d, box, kf, env_at(_ffi(kf)), "rings") dust_and_scratches(d, box, kf, 0.7) d.rectangle([picx0, yc - fh * 0.5, picx1, yc - ph / 2], fill=(20, 18, 22)) d.rectangle([picx0, yc + ph / 2, picx1, yc + fh * 0.5], fill=(20, 18, 22)) draw_perfs(d, left + PERF_L[0] * U, 0, shh, float(ff), fh, PERF_L[1] * U) draw_perfs(d, left + PERF_R[0] * U, 0, shh, float(ff), fh, PERF_R[1] * U) draw_track_strip(d, trkx0, trkx1, 0, shh, t, fh, gate_y, big=SP) # The physical end of the film, descending through the gate. At true # projection speed the tail would cross the frame in three frames; the # sweep is stretched to 1.6 s so the runout is legible as an image — the # one place in the piece where the film's own clock is overruled. p_out = clamp01((t - (t_out - 1.1)) / 1.6) end_y = -fh * 0.6 + p_out * (shh + fh * 0.6) if end_y > 0: yy = min(shh, end_y) d.rectangle([0, 0, sw, yy], fill=(5, 5, 6)) # the cut end of the stock catches a little light if yy < shh: d.line([(left, yy), (right, yy)], fill=(206, 186, 130), width=max(1, int(SP * 2))) return _dn(im) # ════════════════════════════════════════════════════════════════════════════ # SHOT TABLE — the cut follows the sections, and the splices sit on them # ════════════════════════════════════════════════════════════════════════════ SHOTPLAN = [ (0.00, "strip", dict(z0=0.92, z1=1.00, card=1)), (2.60, "track", dict(z0=1.00, z1=1.16)), (4.20, "gate", dict(z0=1.00, z1=1.05, kind="rings")), (5.60, "strip", dict(z0=1.02, z1=0.96)), # growth ------------------------------------------------------------- (7.00, "gate", dict(z0=1.00, z1=1.06, label=1)), (8.60, "twin", dict(z0=0.58, z1=0.64)), (10.40, "strip", dict(z0=0.96, z1=1.05, cx=-0.09)), (11.80, "track", dict(z0=1.10, z1=1.30, gy=-0.05)), (13.20, "gate", dict(z0=1.05, z1=0.99)), (14.60, "strip", dict(z0=1.04, z1=0.94, hair=1)), (16.00, "twin", dict(z0=0.64, z1=0.58)), (17.60, "gate", dict(kind="scratch", z0=1.0, z1=1.07)), (18.80, "strip", dict(z0=0.98, z1=1.08, cx=0.09)), # crossdate ---------------------------------------------------------- (20.00, "twin", dict(z0=0.56, z1=0.66, label=2)), (21.80, "track", dict(z0=1.00, z1=1.24, cx=0.05)), (23.20, "gate", dict(z0=1.00, z1=1.06)), (24.60, "strip", dict(z0=1.06, z1=0.96, cx=-0.06)), (26.00, "gate", dict(slip=1, z0=1.0, z1=1.05)), (27.40, "strip", dict(z0=0.94, z1=1.04)), (28.60, "track", dict(z0=1.22, z1=1.02, gy=0.06)), # scars -------------------------------------------------------------- (30.00, "gate", dict(z0=1.0, z1=1.08, label=3)), (31.40, "strip", dict(z0=1.00, z1=1.10, hair=1)), (32.80, "gate", dict(kind="ink", z0=1.06, z1=1.00)), (34.00, "track", dict(z0=1.06, z1=1.28, cx=-0.04)), (35.40, "strip", dict(z0=1.08, z1=0.94, cx=0.10)), (36.80, "twin", dict(z0=0.62, z1=0.56)), (38.40, "gate", dict(slip=1, z0=1.0, z1=1.08)), (39.80, "strip", dict(z0=0.92, z1=1.02, cx=-0.11)), (41.00, "track", dict(z0=1.26, z1=1.04)), # branch ------------------------------------------------------------- (42.00, "gate", dict(z0=1.00, z1=1.09, label=4)), (43.40, "twin", dict(z0=0.56, z1=0.68)), (45.20, "strip", dict(z0=1.02, z1=1.12, cx=0.07)), (46.60, "gate", dict(z0=1.08, z1=1.00)), (47.80, "track", dict(z0=1.02, z1=1.30, gy=-0.06)), (49.20, "strip", dict(z0=1.10, z1=0.96, hair=1)), (50.60, "twin", dict(z0=0.68, z1=0.58)), (52.20, "gate", dict(kind="scratch", z0=1.0, z1=1.10)), (53.40, "strip", dict(z0=0.94, z1=1.06, cx=-0.08)), (54.80, "track", dict(z0=1.28, z1=1.02, cx=0.06)), (56.20, "gate", dict(z0=1.00, z1=1.06)), # this year ---------------------------------------------------------- (58.00, "dark", dict(label=5)), (60.40, "track", dict(z0=1.00, z1=1.22)), (62.20, "dark", dict()), (64.40, "strip", dict(z0=0.98, z1=0.90, kind="rings")), # the close: the card holds through the mbira's last phrase, then the # reel runs out. One cut in six seconds — the ending doesn't cut, it stops. (66.20, "dark", dict(card=2)), (70.20, "runout", dict(z0=0.96, z1=0.84, clear=71.5, out=73.1)), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "p", "section", "seed") def __init__(self, idx, i0, i1, engine, p): self.idx = idx; self.i0 = i0; self.i1 = i1; self.n = i1 - i0 self.engine = engine; self.p = p self.section = sec_of_t(i0 / FPS); self.seed = 71000 + idx * 7919 def build_shots(): shots = [] for i, (t0, e_, p) in enumerate(SHOTPLAN): b = SHOTPLAN[i + 1][0] if i + 1 < len(SHOTPLAN) else DUR i0, i1 = int(round(t0 * FPS)), int(round(b * FPS)) if i1 <= i0: continue shots.append(Shot(len(shots), i0, i1, e_, p)) shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint → vignette → grain → letterbox, then crisp text # ════════════════════════════════════════════════════════════════════════════ # ── portable font resolution (cross-platform; replaces the repo-only lookup) ── import warnings as _warnings _FONT_ALIASES = { "Menlo.ttc": ["Menlo.ttc", "DejaVuSansMono.ttf", "consola.ttf", "LiberationMono-Regular.ttf"], "Georgia.ttf": ["Georgia.ttf", "georgia.ttf", "DejaVuSerif.ttf", "LiberationSerif-Regular.ttf"], "Georgia Bold.ttf": ["Georgia Bold.ttf", "georgiab.ttf", "DejaVuSerif-Bold.ttf", "LiberationSerif-Bold.ttf"], "Georgia Italic.ttf": ["Georgia Italic.ttf", "georgiai.ttf", "DejaVuSerif-Italic.ttf", "LiberationSerif-Italic.ttf"], "Impact.ttf": ["Impact.ttf", "impact.ttf", "Anton-Regular.ttf", "DejaVuSans-Bold.ttf"], "Helvetica.ttc": ["Helvetica.ttc", "arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"], } def _font_dirs(): here = Path(__file__).resolve() dirs = [here.parent / "fonts"] + [p / "fonts" for p in list(here.parents)[1:4]] try: home = Path.home() except Exception: home = None dirs += [Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"), Path("/Library/Fonts"), Path("C:/Windows/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")] if home: dirs += [home / "Library/Fonts", home / ".fonts", home / ".local/share/fonts"] return dirs _FONT_DIRS = _font_dirs() _FF = {} def _find_font(name): """Path of a usable font file for `name`, or None. Cached per name.""" if name in _FF: return _FF[name] found = None for cand in _FONT_ALIASES.get(name, [name]): for d in _FONT_DIRS: if not d.is_dir(): continue p = d / cand if p.is_file(): found = p; break try: found = next(iter(d.rglob(cand)), None) except OSError: found = None if found: break if found: break if found is None: _warnings.warn(f"font {name} not found in fonts/ or system font dirs; " f"using Pillow default (layout will differ)") _FF[name] = found return found def _load_font(p, size): """ImageFont for path `p` (from _find_font) at `size`; Pillow default if p is None.""" if p is None: try: return ImageFont.load_default(size=int(size)) except TypeError: return ImageFont.load_default() return ImageFont.truetype(str(p), size) _FC = {} def font(size, name="Helvetica.ttc"): size = max(6, int(size)) key = (size, name) if key not in _FC: p = _find_font(name) if len(_FC) > 60: _FC.clear() _FC[key] = _load_font(p, size) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx - W / 2) / (W / 2); ny = (yy - H / 2) / (H / 2) r = np.sqrt(nx ** 2 + ny ** 2) / 1.42 _VIG["v"] = np.clip(1.0 - 0.44 * r ** 2.0, 0, 1)[..., None].astype( np.float32) return _VIG["v"] LABELS = {1: "REEL 2 · THE CYCLE OPENS", 2: "REEL 3 · TWO HANDS, ONE LINE", 3: "REEL 4 · THE BAND", 4: "REEL 5 · EVERY FORK AT ONCE", 5: "REEL 6 · ONE THUMB PIANO"} def post(arr, i, e, shot): a = np.asarray(arr, np.float32).copy() rng = np.random.RandomState(5100 + i) # 1. tint — warm print stock, cool in the shadows lum = a.mean(2, keepdims=True) / 255.0 a = a * np.array([1.035, 0.995, 0.945], np.float32) a += (1 - lum) * np.array([2, 4, 9], np.float32) # 2. vignette a *= vignette() # 3. grain — rolled at 720p and blown up NEAREST so the GRAIN SIZE scales # with the frame instead of getting finer (amplitude preserved exactly) sig = 3.0 + 2.6 * float(e["high"]) if S == 1.0: a += rng.normal(0, sig, a.shape) else: g = rng.normal(0, sig, (int(H / S), int(W / S), 3)).astype(np.float32) a += np.stack([np.asarray(Image.fromarray(g[..., c], "F") .resize((W, H), Image.NEAREST), np.float32) for c in range(3)], -1) # 3b. projector flicker, on the film's own 24 a *= 1.0 + 0.030 * math.sin(i / FPS * FILM_FPS * 2 * math.pi) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out, "RGBA") age = i - shot.i0 t = i / FPS if shot.p.get("card") == 1 and age < FPS * 4.0: al = min(1.0, age / 12.0) * min(1.0, (FPS * 4.0 - age) / 16.0) # the show mark, set as an academy leader slug above the title f0 = font(int(H * 0.026), "Helvetica.ttc") show = "P L A Y E R C O M P U T E R" lw0 = d.textlength(show, font=f0) d.text((W / 2 - lw0 / 2 + 2, H * 0.30 - H * 0.052 + 2), show, font=f0, fill=(8, 8, 10, int(180 * al))) d.text((W / 2 - lw0 / 2, H * 0.30 - H * 0.052), show, font=f0, fill=(212, 190, 128, int(230 * al))) f = font(int(H * 0.115), "Impact.ttf") lw = d.textlength(TITLE, font=f) d.text((W / 2 - lw / 2 + 3, H * 0.30 + 3), TITLE, font=f, fill=(8, 8, 10, int(200 * al))) d.text((W / 2 - lw / 2, H * 0.30), TITLE, font=f, fill=(242, 240, 230, int(244 * al))) f2 = font(int(H * 0.028), "Helvetica.ttc") sub = "mbira, scratched straight onto the emulsion" lw2 = d.textlength(sub, font=f2) d.text((W / 2 - lw2 / 2, H * 0.30 + H * 0.135), sub, font=f2, fill=(214, 210, 196, int(216 * al))) if shot.p.get("card") == 2: # The card holds exactly as it did, then lets go with the kushaura # (68.4 → 69.8 s). NOTE: Pillow ignores the fill alpha on an RGB # canvas, so the other text fades in this file are already no-ops — # this one is faded in the COLOUR instead, and at al = 1 it is the # same pixels as before. al = clamp01((69.8 - t) / 1.4) if al > 0.02: f2 = font(int(H * 0.032), "Helvetica.ttc") ln = "the last ring is not finished" lw2 = d.textlength(ln, font=f2) d.text((W / 2 - lw2 / 2, H * 0.82), ln, font=f2, fill=(int(232 * al), int(228 * al), int(214 * al))) lb = shot.p.get("label") if lb and age < FPS * 2.4: al = min(1.0, age / 8.0) * min(1.0, (FPS * 2.4 - age) / 12.0) f = font(int(H * 0.026), "Helvetica.ttc") d.text((W * 0.055, H * 0.865), LABELS[lb], font=f, fill=(230, 226, 210, int(226 * al))) # (final cut) the "GROWTH · FILM FRAME 00xxx · 24 fps UNDER 30 · OPTICAL # TRACK = THE MIX" counter is gone: it was the renderer describing itself, # not the projector. The strip has to say it on its own now, and it does. bh = int(H * 0.045) d.rectangle([0, 0, W, bh], fill=(9, 9, 10)) d.rectangle([0, H - bh, W, H], fill=(9, 9, 10)) return out def render_shot(job): shot, force = job E = env(); made = 0 fn = ENGINES[shot.engine] for k in range(shot.n): i = shot.i0 + k p = FRAMES / f"f{i:05d}.png" if p.exists() and not force: continue e = {kk: float(E[kk][min(i, N_FRAMES - 1)]) for kk in E} u = k / max(1, shot.n - 1) arr = fn(shot, k, u, i / FPS, e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:6s} {shot.section:10s} {made}/{shot.n}" def contact_sheet(shots): cols = 6 rows = (len(shots) + cols - 1) // cols tw, th = 320, 180 sheet = Image.new("RGB", (cols * tw, rows * (th + 26)), (10, 10, 12)) sd = ImageDraw.Draw(sheet) E = env() for n, sh in enumerate(shots): mid = sh.n // 2; i = sh.i0 + mid e = {kk: float(E[kk][min(i, N_FRAMES - 1)]) for kk in E} arr = ENGINES[sh.engine](sh, mid, mid / max(1, sh.n - 1), i / FPS, e) im = post(arr, i, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols) * tw, (n // cols) * (th + 26) sheet.paste(im, (cx, cy)) sd.text((cx + 5, cy + th + 5), f"{sh.idx:02d} {sh.engine} · {sh.i0/FPS:5.1f}s " f"({sh.n/FPS:.1f}s)", font=font(13, "Menlo.ttc"), fill=(190, 195, 205)) p = OUT / "contact_sheet.png" sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") def _git(*args): try: return subprocess.check_output(["git", "rev-parse", *args], cwd=ROOT).decode().strip() except Exception: return "unknown" def main(): ap = argparse.ArgumentParser() ap.add_argument("--sheet", action="store_true") ap.add_argument("--shots", default="") ap.add_argument("--force", action="store_true") ap.add_argument("--mux-only", action="store_true") ap.add_argument("--audio-only", action="store_true") ap.add_argument("--audio", default=None) ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count() or 4)) a = ap.parse_args() wav = Path(a.audio) if a.audio else AUD / "final.wav" if not wav.exists() or not (AUD / "env.npz").exists() or \ not (AUD / "marks.npz").exists(): print(f"[1/3] mbira… {DUR:.1f}s, {len(PULSES)} pulses, " f"{bpm_at(0):.0f}→{bpm_at(56):.0f}→{bpm_at(73):.0f} bpm " f"(from spiral_fm/rings; round 2b: noise layers −20%, +6 s coda)") wav, mix = finish_song(); analyze(mix); analyze_marks(mix) if a.audio_only: print(f"audio -> {wav}"); return M = marks() print(f" marks: {len(M['tine_ev'])} tine · {len(M['hos_ev'])} hosho " f"· {len(M['bas_ev'])} bass onsets drive the emulsion") shots = build_shots() if a.sheet: contact_sheet(shots); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames on " f"{a.jobs} workers") import multiprocessing as mp with mp.get_context("fork").Pool(a.jobs) as pool: for r in pool.imap_unordered(render_shot, jobs): print(" ", r) if sel: print("partial render — rerun with --mux-only to reassemble"); return missing = [i for i in range(N_FRAMES) if not (FRAMES / f"f{i:05d}.png").exists()] if missing: raise SystemExit(f"{len(missing)} frames missing, " f"first={missing[0]}") print("[3/3] mux…") out = OUT / f"{NAME}.mp4" subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES / "f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"title={SETDIR} {SETNUM} — {TITLE}", "-metadata", f"artist=poop / {SETDIR}", "-metadata", f"date={datetime.date.today().isoformat()}", "-metadata", ("comment=generator=renders/" f"{SETDIR}/{NAME}/render.py; " f"git={_git('HEAD')[:12]}; {MUSIC_DESC}; " f"{ENGINE_DESC}"), "-metadata", (f"description={TITLE} — {MUSIC_DESC} " f"— {ENGINE_DESC}"), str(out)], check=True, capture_output=True) M = marks() (OUT / "PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {_git('HEAD')[:12]} branch: {_git('--abbrev-ref', 'HEAD')}\n" f"timestamp: {datetime.datetime.now().astimezone().isoformat()}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f" [from spiral_fm/rings; round 2b: deza buzz 0.55->0.44 and hosho\n" f" bus 0.72->0.576 (both x0.8, -1.94 dB) so the tines read; 68 -> 74 s\n" f" with a coda — kushaura releases 68-70.6, a four-note settling\n" f" phrase, one tine at 71.95 s decaying to zero at DUR]\n" f"tempo map: {TEMPO}\n" f"cycle: 48 pulses; kushaura on p%3, kutsinhira offset; hosho 3:4\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"substrate: {ENGINE_DESC}\n" f"coupling: {len(M['tine_ev'])} tine onsets -> rings, " f"{len(M['hos_ev'])} hosho onsets -> scratch bursts, " f"{len(M['bas_ev'])} bass onsets -> ink; " f"section boundaries -> splices; optical track = the mix at " f"{TRK_SR:.0f} samples/s\n" f"engines: {' '.join(sorted(ENGINES))} (shot-parallel, tier 4-P)\n" f"shots: {len(build_shots())}\n" f"seeds: shot=71000+idx*7919 grain=5100+frame weave=ff*2749+11\n") print(f"DONE {out} ({DUR:.2f}s)") if __name__ == "__main__": main()