#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Locked Groove (20/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/locked_groove # # A forgotten 78 reaches a groove that closes on itself and becomes a dance record. # # 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/locked_groove.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/locked_groove.mp4 # cover: https://genekogan.com/player_computer/media/locked_groove.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 locked_groove.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) # ═════════════════════════════════════════════════════════════════════════════ """ spiral_fm 15 — "LOCKED GROOVE" NARRATIVE STRUCTURE: the ring. The film is one continuous spiral inward and it closes exactly — the visual parameterisation is periodic with the running time, so the frame at t = DUR is byte-identical to frame 0 and the piece loops forever. This is the slate's self-reference: the show is called ./spiral, and this is the piece that is one. (`--ring` renders the frame at t = DUR and md5-compares it with frame 0. The result is written into PROVENANCE.txt.) THE NUMBERS, which are all one number: The deck is a tired 78 running at 75 RPM — 0.800 s per revolution, exactly 24 frames at 30 fps. A music-hall two-step puts ONE OOM-PAH PER REVOLUTION, so the tempo is 150 bpm in 2/4. The locked groove is two revolutions, 1.600 s, which is exactly one bar of 4/4 at 150 bpm — so when the record jams, the thing it jams into is already a techno loop. Nothing had to be re-timed; the record was always a dance record, it just had to repeat. MUSIC: a vaudeville / music-hall 78 — upright tack piano, banjo, tuba oom-pah, wire brushes, a megaphone vocal — cut with honest 78 character: band-limited to 180–4800 Hz, surface crackle, wow at exactly the rotation rate, and a once-per-revolution thump. At 33.6 s the needle catches: the 1.6 s cell from 32.0–33.6 is spliced back byte-identically twenty times, crackle and wow and all. A four-on-the-floor kick arrives, then hats, sub, claps — and pass by pass the 78's band-limit is lifted off the loop until the record is hi-fi and the room is dancing. THE NEW SUBSTRATE — **a vinyl groove macro**, new to this repo: * The groove wall's lateral modulation IS the waveform being played: the heightfield is driven by np.interp into the rendered wav. Stereo 45/45 — lateral = (L+R)/2 displaces the groove centre, vertical = (L−R)/2 modulates its depth — so the two walls carry the two channels. * A dozen grooves are visible at once, and each one is one revolution EARLIER in the record than the one below it, because that is what the neighbouring turn of a spiral is. * The spiral pitch tightens toward the run-out; the radial scroll rate is dR/dt over the groove pitch. When the needle locks, dR/dt goes to zero and the grooves stop scrolling — the picture freezes because the record did. * Shading is a real surface normal from the height field, Lambert plus a tight Blinn lobe, so one wall lights and the opposite wall goes black. * Dust fixed to the disc, and a fibre the needle drags. Run from repo root: python3 renders/player_computer_final/locked_groove/render.py --sheet python3 renders/player_computer_final/locked_groove/render.py --jobs 2 python3 renders/player_computer_final/locked_groove/render.py --ring python3 renders/player_computer_final/locked_groove/render.py --shots 4,5 --force """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "locked_groove" TITLE = "LOCKED GROOVE" SETDIR = "player_computer_final" SETNUM = "B7" W, H, FPS = 1920, 1080, 30 # ── delivery scale ─────────────────────────────────────────────────────────── # FINAL CUT: native 1920x1080. The groove macro is already resolution-free — # the height field is built in METRES over a fixed view box, so more pixels # means a finer sampling of the same groove, not a bigger one. What scales by # hand is everything measured in PIXELS: the composition reference width WREF # (which every object in the room and on the deck is keyed to), the stylus, the # dust specks, the type, and the grain — which is authored at 720p and # NEAREST-upscaled so a speck stays the same fraction of the frame. WB, HB = 1280, 720 # the authoring frame SCL = H/720.0 def PXi(v): return max(1, int(round(v*SCL))) def PXf(v): return v*SCL WREF = PXi(1120) # the width this was composed at; object SCALE stays # keyed to it so 16:9 reveals more table, never zooms in SR = 44100 T_REV = 0.800 # 75 RPM — a tired 78 BEAT = 0.400 # 150 bpm BARL = 1.600 # 4/4 bar == 2 revolutions == the locked groove REVS = 86 DUR = REVS * T_REV # 68.80 s N_FRAMES = int(round(DUR * FPS)) DROP_T = 2.00 # the needle comes down MUSIC_T = 4.80 # the song starts CELL0, CELL1 = 32.00, 33.60 # the two revolutions that get stuck LOCK_T = 33.60 LIFT_T = 65.60 N_PASS = int(round((LIFT_T - LOCK_T) / BARL)) # 20 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 = [("lead_in", 0.0, MUSIC_T), ("song", MUSIC_T, LOCK_T), ("locked", LOCK_T, 49.6), ("techno", 49.6, LIFT_T), ("return", LIFT_T, DUR)] MUSIC_DESC = ("vaudeville 78 at 150 bpm 2/4 (one oom-pah per revolution) — " "tack piano, banjo, tuba, wire brushes, megaphone vocal — " "band-limited 180-4800 Hz with wow at 1.25 Hz and a " "once-per-revolution thump; from 33.6 s the 1.600 s cell is " "spliced back 20x and becomes 150 bpm techno as the band-limit " "is lifted pass by pass") ENGINE_DESC = ("vinyl groove macro — heightfield driven by np.interp into the " "rendered wav, stereo 45/45 (lateral = groove centre, vertical " "= depth), neighbouring grooves one revolution apart, spiral " "pitch and radial scroll = dR/dt over groove pitch") # ── the disc ─────────────────────────────────────────────────────────────── R_OUT = 0.1270 # 10-inch shellac R_LEAD = 0.1215 R_START = 0.1180 R_LOCK = 0.0685 R_LABEL = 0.0455 PITCH = 0.00026 # ~98 lines per inch GW = 0.000222 # groove width (land is only 38 um) DEPTH0 = 0.000052 LAT = 0.000128 # lateral modulation, peak (~half pitch) VERT = 0.000020 VIEW_H = 0.00460 # 4.6 mm of radius across the frame height VIEW_W = VIEW_H * W / H SLOW = 0.012 # high-speed macro: 1/83 real time 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 sec_of_t(t): for nm, a, b in SECTIONS: if a <= t < b: return nm return SECTIONS[-1][0] def radius(t): """Stylus radius. Periodic: radius(DUR) == radius(0).""" t = t % DUR if t < MUSIC_T: return R_LEAD if t < LOCK_T: u = (t - MUSIC_T) / (LOCK_T - MUSIC_T) return R_START + (R_LOCK - R_START) * u if t < LIFT_T: return R_LOCK u = (t - LIFT_T) / (DUR - LIFT_T) return R_LOCK + (R_LEAD - R_LOCK) * ease_io(u) def theta(t): return 2 * math.pi * (t % DUR) / T_REV def audio_time(t): """Where on the record the needle actually is.""" if t < LOCK_T: return t if t < LIFT_T: return CELL0 + ((t - LOCK_T) % BARL) return t def locked(t): return LOCK_T <= t < LIFT_T # ════════════════════════════════════════════════════════════════════════════ # AUDIO # ════════════════════════════════════════════════════════════════════════════ def mtof(m): return 440.0 * 2.0 ** ((m - 69) / 12.0) _PC = {"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11} def nf(name): i = 2 if (len(name) > 2 and name[1] in "#b") else 1 return mtof(12 * (int(name[i:]) + 1) + _PC[name[:i]]) 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) def tack(f, dur, seed=0, gain=1.0): """Upright piano with tacks in the hammers: three detuned strings, a hard metallic transient, fast decay.""" n = int(dur * SR) if n < 96: return np.zeros(max(0, n)) t = np.arange(n) / SR rng = np.random.RandomState((seed * 7717 + 3) % (2 ** 31 - 1)) o = np.zeros(n) for det in (-2.6, 0.0, 3.1): fr = f * 2 ** (det / 1200.0) ph = 2 * np.pi * np.cumsum(np.full(n, fr)) / SR + rng.uniform(0, 6.28) for k in range(1, 15): if fr * k > SR * 0.45: break inh = 1.0 + 0.00042 * k * k # string inharmonicity o += (np.sin(ph * k * inh) / k ** 1.15) * np.exp(-t * (2.6 + 1.5 * k)) o /= (np.abs(o).max() + 1e-9) tk = bandshape(rng.randn(n), lo=2400, hi=9000) * np.exp(-t * 150) * 0.42 return np.tanh((o + tk) * 1.5) * 0.55 * gain def banjo(f, 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 * 3313 + 11) % (2 ** 31 - 1)) ph = 2 * np.pi * np.cumsum(np.full(n, f) * (1 + 0.02 * np.exp(-t * 60))) / SR o = np.zeros(n) for k in range(1, 20): if f * k > SR * 0.45: break o += np.sin(ph * k + k) / k ** 0.72 * np.exp(-t * (7.0 + 2.2 * k)) o /= (np.abs(o).max() + 1e-9) pk = bandshape(rng.randn(n), lo=2000, hi=8500) * np.exp(-t * 190) * 0.36 return np.tanh((o + pk) * 1.7) * 0.40 * gain def tuba(f, dur, seed=0, gain=1.0): n = int(dur * SR) if n < 96: return np.zeros(max(0, n)) t = np.arange(n) / SR rng = np.random.RandomState((seed * 911 + 5) % (2 ** 31 - 1)) ph = 2 * np.pi * np.cumsum(np.full(n, f) * (1 + 0.010 * np.exp(-t * 26))) / SR o = np.zeros(n) for k, a in ((1, 1.0), (2, .70), (3, .44), (4, .26), (5, .15), (6, .08)): o += a * np.sin(ph * k + rng.uniform(0, 6.28)) o /= 2.6 air = bandshape(rng.randn(n), lo=180, hi=1400) * np.exp(-t * 22) * 0.10 return (o + air) * adsr(n, 0.028, 0.10, 0.72, min(0.16, dur * .5)) * \ 0.85 * gain def brush_swish(dur, seed=0, gain=1.0): """Wire brushes on a coated head: a circular swirl, never a hiss.""" n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed * 733 + 7) % (2 ** 31 - 1)) x = bandshape(rng.randn(n), lo=1500, hi=6200) swirl = 0.35 + 0.65 * (0.5 + 0.5 * np.sin(2 * np.pi * (1 / max(dur, .1)) * t - 1.2)) ** 2 return x * swirl * np.sin(np.pi * np.clip(t / dur, 0, 1)) ** 0.6 * \ 0.24 * gain def brush_tap(seed=0, gain=1.0): n = int(0.20 * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed * 617 + 13) % (2 ** 31 - 1)) body = np.sin(2 * np.pi * 205 * t) * np.exp(-t * 40) * 0.30 wire = bandshape(rng.randn(n), lo=1800, hi=7200) * np.exp(-t * 34) return (body + wire * 0.55) * 0.42 * gain VOWELS = { "a": ((760, .16, 1.7), (1180, .18, 1.3), (2500, .26, .8)), "o": ((560, .16, 1.8), (880, .20, 1.1), (2450, .28, .5)), "e": ((540, .16, 1.5), (1900, .18, 1.2), (2550, .26, .8)), "i": ((320, .15, 1.6), (2300, .20, 1.0), (3050, .28, .7)), } def megaphone(f, dur, vowel="a", seed=0, gain=1.0): """A voice through a paper cone into a horn: honking, band-limited, peaky at 1.8 kHz, and slightly overdriven.""" n = int(dur * SR) if n < 128: return np.zeros(max(0, n)) t = np.arange(n) / SR rng = np.random.RandomState((seed * 5779 + 19) % (2 ** 31 - 1)) vib = 1 + 0.0085 * np.sin(2 * np.pi * 5.4 * t + rng.uniform(0, 6.28)) * \ np.clip((t - dur * 0.30) / max(1e-3, dur * .4), 0, 1) ph = 2 * np.pi * np.cumsum(f * vib) / SR + rng.uniform(0, 6.28) o = np.zeros(n) for k in range(1, 34): if f * k > SR * 0.45: break o += np.sin(ph * k) / k ** 1.05 o /= (np.abs(o).max() + 1e-9) fq = np.maximum(np.fft.rfftfreq(n, 1 / SR), 1e-6) g = np.ones_like(fq) * 0.12 for fc, q, a in VOWELS[vowel]: g += a * np.exp(-((np.log(fq / fc)) ** 2) / (2 * q * q)) g += 1.5 * np.exp(-((np.log(fq / 1800.0)) ** 2) / (2 * 0.16 ** 2)) g *= 1.0 / np.sqrt(1 + (fq / 3400.0) ** 6) g *= 1.0 / np.sqrt(1 + (420.0 / fq) ** 6) o = np.fft.irfft(np.fft.rfft(o) * g, n) o = np.tanh(o * 3.2) * 0.5 br = bandshape(rng.randn(n), lo=1200, hi=4200) * 0.05 return (o + br) * adsr(n, 0.030, 0.10, 0.86, min(0.14, dur * .35)) * gain # ── techno kit ──────────────────────────────────────────────────────────── def kick(seed=0, hard=1.0): dur = 0.55; n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed + 41) % (2 ** 31 - 1)) f = 48.0 * (1 + 3.4 * np.exp(-t * 42)) body = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t * 7.0) clk = bandshape(rng.randn(n), lo=1400, hi=5200) * np.exp(-t * 210) return np.tanh((body * 1.4 + clk * 0.28) * 1.5) * 0.90 * hard def hat(seed=0, open_=0.0, hard=1.0): dur = 0.05 + 0.20 * open_; n = int(dur * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed + 173) % (2 ** 31 - 1)) return bandshape(rng.randn(n), lo=6200, hi=14500) * \ np.exp(-t * (95 - 76 * open_)) * 0.26 * hard def clap_t(seed=0, hard=1.0): n = int(0.30 * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed + 313) % (2 ** 31 - 1)) out = np.zeros(n) for k, off in enumerate((0.0, 0.0091, 0.0163)): i = int(off * SR); m = n - i out[i:] += bandshape(rng.randn(m), lo=1200, hi=5000) * \ np.exp(-np.arange(m) / SR * 165) * (1 - 0.2 * k) tail = bandshape(rng.randn(n), lo=1400, hi=3800) * np.exp(-t * 20) * 0.34 return (out * 0.55 + tail) * 0.52 * hard def sub(f, dur, seed=0, gain=1.0): n = int(dur * SR); t = np.arange(n) / SR ph = 2 * np.pi * f * t o = np.sin(ph) + 0.16 * np.sin(2 * ph) return o * adsr(n, 0.006, 0.05, 0.80, min(0.09, dur * .4)) * gain * 0.7 def rimshot(seed=0, hard=1.0): n = int(0.09 * SR); t = np.arange(n) / SR rng = np.random.RandomState((seed + 577) % (2 ** 31 - 1)) return (np.sin(2 * np.pi * 1720 * t) * np.exp(-t * 130) * 0.5 + bandshape(rng.randn(n), lo=2200, hi=8000) * np.exp(-t * 180)) * \ 0.36 * hard def reverb(x, rt=1.6, mix=.18, seed=29, pre=0.012): 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 = bandshape(ir, lo=140, hi=7000) 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) # ── the tune ────────────────────────────────────────────────────────────── # one chord per revolution (one 2/4 bar); 36 bars of song PROG = ["C", "C", "C", "C", "F", "F", "C", "C", "G7", "G7", "C", "C", "C7", "C7", "F", "F", "F#dim", "F#dim", "C", "A7", "D7", "D7", "G7", "G7", "C", "C", "F", "F", "C", "A7", "D7", "G7", "C", "C", "G7", "G7"] CH = { "C": ("C2", ["E3", "G3", "C4"]), "F": ("F2", ["A3", "C4", "F4"]), "G7": ("G1", ["B3", "F4", "G4"]), "C7": ("C2", ["E3", "Bb3", "C4"]), "A7": ("A1", ["C#4", "G3", "A3"]), "D7": ("D2", ["F#3", "C4", "D4"]), "F#dim": ("F#1", ["A3", "C4", "Eb4"]), } # the refrain: (bar, slot(0|1), note, vowel) slot = half a revolution TUNE = [(2, 0, "G4", "o"), (3, 0, "E4", "a"), (4, 0, "F4", "a"), (5, 0, "A4", "e"), (6, 0, "G4", "o"), (7, 1, "E4", "a"), (10, 0, "C5", "a"), (11, 0, "A4", "o"), (12, 0, "G4", "e"), (13, 1, "E4", "a"), (14, 0, "F4", "o"), (15, 0, "A4", "a"), (18, 0, "A4", "e"), (19, 0, "C5", "a"), (20, 0, "A4", "o"), (21, 1, "F4", "a"), (22, 0, "G4", "a"), (23, 0, "E4", "o"), (26, 0, "F4", "a"), (27, 0, "A4", "e"), (28, 0, "G4", "o"), (29, 1, "E4", "a"), (30, 0, "D4", "a"), (31, 0, "G4", "o"), (33, 0, "E4", "a"), (34, 0, "G4", "o"), # <- the syllable that gets stuck (35, 0, "G4", "o")] def build_song(): n = int(DUR * SR) van = np.zeros((n, 2)) # the performance, before the shellac def put(buf, sig, at, g=1.0, pan=0.0): if len(sig) == 0: return i = int(at * SR); j = min(len(buf), i + len(sig)) if i >= len(buf) or j <= i: return if i < 0: sig = sig[-i:]; i = 0; j = min(len(buf), len(sig)) th = (pan * .5 + .5) * (np.pi / 2) buf[i:j] += np.stack([sig[:j - i] * math.cos(th), sig[:j - i] * math.sin(th)], 1) * g nbars = len(PROG) for b in range(nbars): t0 = MUSIC_T + b * T_REV root, voic = CH[PROG[b]] drive = 0.55 + 0.45 * clamp01(b / 8.0) # tuba: oom on 1 put(van, tuba(nf(root), 0.34, seed=b, gain=0.95 * drive), t0, g=1.0, pan=-0.10) put(van, tuba(nf(root) * 1.5, 0.26, seed=b + 90, gain=0.42 * drive), t0 + BEAT * 1.5, g=1.0, pan=-0.14) # piano: pah on 2, plus a right-hand answer for nm in voic: put(van, tack(nf(nm), 0.36, seed=b * 17 + hash(nm) % 97, gain=0.42 * drive), t0 + BEAT, g=1.0, pan=0.16) if b % 4 == 3: for k, nm in enumerate(voic): put(van, tack(nf(nm) * 2, 0.24, seed=b * 23 + k, gain=0.26 * drive), t0 + BEAT * 1.5 + k * 0.045, g=1.0, pan=0.30) # banjo: both beats + offbeat eighths for q in range(4): for k, nm in enumerate(voic): put(van, banjo(nf(nm) * (2 if q % 2 else 1), 0.22, seed=b * 31 + q * 7 + k, gain=(0.26 if q % 2 else 0.36) * drive), t0 + q * BEAT * 0.5 + k * 0.008, g=1.0, pan=0.34) # brushes put(van, brush_swish(T_REV, seed=b, gain=drive), t0, g=1.0, pan=-0.28) put(van, brush_tap(seed=b, gain=0.9 * drive), t0 + BEAT, g=1.0, pan=-0.22) # the refrain for (bb, sl, nm, vw) in TUNE: if bb != b: continue put(van, megaphone(nf(nm), T_REV * (0.86 if sl == 0 else 0.42), vowel=vw, seed=b * 13 + sl, gain=0.80 * drive), t0 + sl * BEAT, g=1.0, pan=0.02) # ── cut it to shellac ──────────────────────────────────────────────── tt = np.arange(n) / SR warp = tt + 0.00170 * np.sin(2 * np.pi * (1.0 / T_REV) * tt) + \ 0.00062 * np.sin(2 * np.pi * (3.0 / T_REV) * tt + 1.1) sh = np.zeros_like(van) for c in range(2): x = np.interp(tt, warp, van[:, c]) # wow, locked to the rev sh[:, c] = bandshape(bandshape(x, lo=180, order=6), hi=4800, order=6) # surface: a steady filtered rustle plus crackle, and the eccentric thump rng = np.random.RandomState(7801) rustle = bandshape(rng.randn(n), lo=900, hi=5200) * 0.020 crack = np.zeros(n) ncr = int(DUR * 46) pos = (rng.uniform(0, 1, ncr) * (n - 400)).astype(int) for k, pz in enumerate(pos): L = int(rng.uniform(0.0008, 0.0035) * SR) crack[pz:pz + L] += (rng.uniform(-1, 1) * np.exp(-np.arange(L) / SR * 900) * 0.32) crack = bandshape(crack, lo=700, hi=7000) thump = np.zeros(n) tk = 0.0 while tk < DUR: i0 = int(tk * SR) m = min(int(0.16 * SR), n - i0) if m > 0: ti = np.arange(m) / SR thump[i0:i0 + m] += \ np.sin(2 * np.pi * 46 * ti) * np.exp(-ti * 34) * 0.10 tk += T_REV surface = (rustle + crack + thump) for c in range(2): sh[:, c] += surface * (1.0 + 0.06 * c) sh = np.tanh(sh * 1.25) / np.tanh(1.25) # ── THE LOCKED GROOVE ──────────────────────────────────────────────── # one cell = two revolutions = one 4/4 bar. Spliced back byte-identically, # then opened up pass by pass as the band-limit is lifted. ci0, ci1 = int(CELL0 * SR), int(CELL1 * SR) cell = sh[ci0:ci1].copy() cell_dry = van[ci0:ci1].copy() out = sh.copy() out[int(LOCK_T * SR):] = 0.0 for k in range(N_PASS): at = int((LOCK_T + k * BARL) * SR) u = k / max(1, N_PASS - 1) hi = 4800 * (1 + 2.0 * u ** 1.5) lo = 180 * (1 - 0.86 * u ** 1.2) blend = clamp01((u - 0.30) / 0.55) seg = np.zeros_like(cell) for c in range(2): a = cell[:, c] b = bandshape(bandshape(cell_dry[:, c], lo=max(28, lo), order=4), hi=hi, order=4) seg[:, c] = a * (1 - blend) + b * blend m = min(len(seg), len(out) - at) if m > 0: out[at:at + m] += seg[:m] * (0.92 - 0.18 * u) # the arm lifts out[int(LIFT_T * SR):] = 0.0 # ── techno accretes on top of the loop ─────────────────────────────── tk_buf = np.zeros((n, 2)) for k in range(N_PASS): t0 = LOCK_T + k * BARL u = k / max(1, N_PASS - 1) amp = clamp01((k - 1.0) / 3.0) for q in range(4): if k >= 2: put(tk_buf, kick(seed=k * 11 + q, hard=amp * 1.0), t0 + q * BEAT, g=1.0) if k >= 4: put(tk_buf, hat(seed=k * 19 + q, open_=0.0, hard=amp * 0.9), t0 + q * BEAT + BEAT * 0.5, g=1.0, pan=0.28) if k >= 6: put(tk_buf, sub(nf("C1") * (1.0 if q % 2 == 0 else 1.5), BEAT * 0.9, seed=k + q, gain=amp * 0.85), t0 + q * BEAT, g=1.0) if k >= 8 and q in (1, 3): put(tk_buf, clap_t(seed=k * 7 + q, hard=amp * 0.9), t0 + q * BEAT, g=1.0, pan=-0.20) if k >= 11: put(tk_buf, hat(seed=k * 29 + q, open_=0.8, hard=amp * 0.55), t0 + q * BEAT + BEAT * 0.75, g=1.0, pan=-0.30) if k >= 13 and (q * 2 + 1) % 3 == 0: put(tk_buf, rimshot(seed=k * 37 + q, hard=amp * 0.8), t0 + q * BEAT + BEAT * 0.25, g=1.0, pan=0.36) if k >= 15: put(tk_buf, clap_t(seed=k * 91, hard=amp), t0 + BEAT * 3.75, g=1.0, pan=0.10) for c in range(2): tk_buf[:, c] = reverb(tk_buf[:, c], rt=1.3, mix=.13, seed=401 + c) # ── lead-in / lead-out: the same silence, so the ring closes ───────── lead = np.zeros((n, 2)) rng2 = np.random.RandomState(3313) li = bandshape(rng2.randn(n), lo=700, hi=5600) * 0.020 lc = np.zeros(n) for k in range(int(DUR * 22)): pz = int(rng2.uniform(0, n - 400)) L = int(rng2.uniform(0.0008, 0.003) * SR) lc[pz:pz + L] += rng2.uniform(-1, 1) * \ np.exp(-np.arange(L) / SR * 1000) * 0.26 lc = bandshape(lc, lo=700, hi=7000) for c in range(2): lead[:, c] = li + lc + thump * 0.8 gate = np.zeros(n) gate[:int(MUSIC_T * SR)] = 1.0 gate[int(LIFT_T * SR):] = 1.0 kk = int(0.20 * SR) gate = np.convolve(gate, np.ones(kk) / kk, "same") lead *= gate[:, None] mix = out + tk_buf * 1.02 + lead for c in range(2): mix[:, c] = bandshape(mix[:, c], lo=24.0, order=2) mix = np.tanh(mix * 1.18) / np.tanh(1.18) mix = mix / (np.max(np.abs(mix)) + 1e-9) * 0.95 return mix def write_wav(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("= 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) # the geometry source — decimated, normalised, mono lateral + vertical dec = SR // GSR L = bandshape(mix[:, 0], hi=5200, order=6)[::dec] R = bandshape(mix[:, 1], hi=5200, order=6)[::dec] lat = (L + R) * 0.5 ver = (L - R) * 0.5 lat = lat / (np.percentile(np.abs(lat), 99.5) + 1e-9) ver = ver / (np.percentile(np.abs(ver), 99.5) + 1e-9) np.savez(AUD / "groove.npz", lat=np.clip(lat, -1.4, 1.4).astype(np.float32), ver=np.clip(ver, -1.4, 1.4).astype(np.float32)) 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 _GRV = {} def grv(): if not _GRV: z = np.load(AUD / "groove.npz") _GRV["lat"] = z["lat"]; _GRV["ver"] = z["ver"] _GRV["t"] = np.arange(len(z["lat"])) / GSR return _GRV def wav_at(key, ts): """The actual rendered waveform, sampled at arbitrary times.""" G = grv() return np.interp(np.mod(ts, DUR), G["t"], G[key], left=0.0, right=0.0) # ════════════════════════════════════════════════════════════════════════════ # THE GROOVE MACRO # ════════════════════════════════════════════════════════════════════════════ _XY = {} def px_grid(): if "x" not in _XY: _XY["x"] = (np.arange(W) + 0.5) / W - 0.5 _XY["y"] = (np.arange(H) + 0.5) / H - 0.5 return _XY["x"], _XY["y"] def tau_of(shot, t): a = audio_time(shot.i0 / FPS) x = a + SLOW * (t - shot.i0 / FPS) if locked(shot.i0 / FPS): x = CELL0 + ((x - CELL0) % BARL) return x def groove_field(tau0, R, zoom=1.0, nglass=11): """Height field of the groove walls, in metres, over the frame.""" xs, ys = px_grid() vw, vh = VIEW_W / zoom, VIEW_H / zoom vel = 2 * math.pi * R / T_REV dt = vw / vel # seconds across the frame ts = tau0 + xs * dt # (W,) ym = (ys * vh)[:, None] # (H,1) metres roff = (R / PITCH) % 1.0 * PITCH z = np.zeros((H, W), np.float32) hw = GW * 0.5 for k in range(-nglass, nglass + 1): lat = wav_at("lat", ts - k * T_REV) ver = wav_at("ver", ts - k * T_REV) c = (k * PITCH - roff) + LAT * lat # (W,) d = ym - c[None, :] dep = DEPTH0 * (1.0 + 0.42 * ver)[None, :] prof = np.clip(1.0 - np.abs(d) / hw, 0.0, 1.0) z = np.minimum(z, -dep * prof) return z.astype(np.float32), ts, dt, vw, vh def shade_groove(z, vw, vh, t, e, lightang=0.0, gain=1.0): gx = np.gradient(z, axis=1) / (vw / W) gy = np.gradient(z, axis=0) / (vh / H) nx = -gx; ny = -gy; nz = np.ones_like(z) ln = np.sqrt(nx * nx + ny * ny + 1.0) nx /= ln; ny /= ln; nz /= ln la = lightang L = np.array([0.30 * math.cos(la), 0.80, 0.50 + 0.10 * math.sin(la)]) L /= np.linalg.norm(L) Hv = L + np.array([0.0, 0.0, 1.0]); Hv /= np.linalg.norm(Hv) diff = np.clip(nx * L[0] + ny * L[1] + nz * L[2], 0, 1) spec = np.clip(nx * Hv[0] + ny * Hv[1] + nz * Hv[2], 0, 1) ** 26.0 depthf = np.clip(-z / DEPTH0, 0, 1) ao = 1.0 - 0.60 * depthf # the groove swallows light land = np.clip(1.0 + z / (DEPTH0 * 0.10), 0, 1) # the flat top lum = 0.016 + 0.30 * diff * ao + 0.46 * spec * ao # the light bar reflecting off the land between grooves xs, ys = px_grid() bar = np.exp(-(((ys[:, None] * 1.0 - 0.10 * math.sin(t * 1.9)) * 2.6) ** 2)) * land lum = lum + bar * (0.070 + 0.13 * float(e["rms"])) lum *= gain img = np.stack([lum * 1.00, lum * 0.985, lum * 1.010], -1) return img.astype(np.float32) _DUST = {} def dust(): if "d" not in _DUST: rng = np.random.RandomState(60013) n = 900 _DUST["tau"] = rng.uniform(0, DUR, n) _DUST["y"] = rng.uniform(-0.5, 0.5, n) _DUST["s"] = rng.uniform(0.5, 2.6, n) _DUST["b"] = rng.uniform(0.25, 1.0, n) return _DUST def add_dust(img, tau0, dt, zoom): D = dust() dx = (np.mod(D["tau"] - tau0 + DUR / 2, DUR) - DUR / 2) / dt sx = (dx + 0.5) * W sy = (D["y"] * (1.0 / max(zoom, 0.2)) + 0.5) * H ok = (sx > 3) & (sx < W - 4) & (sy > 3) & (sy < H - 4) for j in np.where(ok)[0]: x, y = int(sx[j]), int(sy[j]) r = max(1, int(D["s"][j] * zoom * SCL)) b = float(D["b"][j]) * 0.75 img[y - r:y + r + 1, x - r:x + r + 1] += b * 0.30 img[y, x] += b * 0.55 img[y + r + 1:y + r + 3, x + r:x + r + 3] *= 0.45 return img def draw_stylus(img, tau0, R, dt, zoom, t, e, big=1.0): """The stylus sitting in groove 0, riding the actual waveform.""" im = Image.fromarray(np.clip(img * 255, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(im, "RGBA") lat = float(wav_at("lat", np.array([tau0]))[0]) roff = (R / PITCH) % 1.0 * PITCH c = (-roff + LAT * lat) cy = (c / (VIEW_H / zoom) + 0.5) * H cx = W * 0.50 s = PXf(62.0) * big * max(zoom, 0.6) # the diamond, a tapered cone into the groove d.polygon([(cx - 0.62 * s, cy - 2.05 * s), (cx + 0.62 * s, cy - 2.05 * s), (cx + 0.11 * s, cy + 0.05 * s), (cx - 0.11 * s, cy + 0.05 * s)], fill=(19, 19, 23, 255)) d.line([(cx - 0.60 * s, cy - 2.00 * s), (cx - 0.10 * s, cy + 0.03 * s)], fill=(214, 222, 236, 225), width=max(1, int(0.075 * s))) d.line([(cx + 0.58 * s, cy - 2.00 * s), (cx + 0.09 * s, cy + 0.03 * s)], fill=(88, 92, 104, 190), width=max(1, int(0.055 * s))) d.ellipse([cx - 0.15 * s, cy - 0.16 * s, cx + 0.15 * s, cy + 0.10 * s], fill=(242, 246, 252, 255)) # the cantilever, out of frame to the arm d.line([(cx, cy - 2.02 * s), (W * 1.25, cy - 5.6 * s)], fill=(30, 29, 33, 255), width=max(PXi(3), int(0.42 * s))) d.line([(cx, cy - 2.10 * s), (W * 1.25, cy - 5.7 * s)], fill=(176, 182, 196, 220), width=max(1, int(0.085 * s))) # the fibre it drags ph = t * 2.1 pts = [] for q in range(16): u = q / 15.0 pts.append((cx - 0.16 * s - u * s * 6.2, cy - 0.04 * s + math.sin(ph + u * 5.6) * s * 0.30 * u)) d.line(pts, fill=(206, 196, 176, 200), width=max(1, int(0.05 * s))) return np.asarray(im, np.float32) / 255.0 def eng_groove(sh, k, u, t, e): zoom = sh.p.get("zoom", 1.0) R = radius(t) tau0 = tau_of(sh, t) z, ts, dt, vw, vh = groove_field(tau0, R, zoom=zoom, nglass=sh.p.get("ng", 11)) img = shade_groove(z, vw, vh, t, e, lightang=sh.p.get("la", 0.35), gain=sh.p.get("gain", 1.0)) img = add_dust(img, tau0, dt, zoom) if sh.p.get("stylus"): img = draw_stylus(img, tau0, R, dt, zoom, t, e, big=sh.p.get("big", 1.0)) return img def eng_stylus(sh, k, u, t, e): p = dict(sh.p); p["zoom"] = sh.p.get("zoom", 2.6); p["stylus"] = 1 sh2 = sh old = sh.p; sh.p = p img = eng_groove(sh2, k, u, t, e) sh.p = old # shallow depth of field: the frame edges fall out of focus xs, ys = px_grid() m = np.clip((np.abs(ys[:, None]) * 2.1) ** 2 + (np.abs(xs[None, :]) * 1.2) ** 2, 0, 1) * 0.82 im = Image.fromarray(np.clip(img * 255, 0, 255).astype(np.uint8)) bl = np.asarray(im.filter(ImageFilter.GaussianBlur(PXf(4.0))), np.float32) / 255. return img * (1 - m[..., None]) + bl * m[..., None] # ── the whole record ────────────────────────────────────────────────────── _LABEL = {} def label_img(): if "l" not in _LABEL: S = PXi(512) im = Image.new("RGB", (S, S), (12, 10, 10)) d = ImageDraw.Draw(im) d.ellipse([0, 0, S - 1, S - 1], fill=(126, 26, 24)) d.ellipse([S * .06, S * .06, S * .94, S * .94], outline=(206, 178, 120), width=PXi(3)) f1 = _load_font(_find_font("Georgia Bold.ttf"), PXi(40)) f2 = _load_font(_find_font("Georgia Italic.ttf"), PXi(25)) f3 = _load_font(_find_font("Georgia.ttf"), PXi(21)) for txt, f, y, col in (("FREESIDE", f1, .21, (238, 220, 176)), ("one side only", f2, .32, (222, 196, 156)), ("THE LAST CHORUS", f3, .45, (240, 226, 190)), ("vocal refrain", f2, .53, (216, 190, 150)), ("— 75 r.p.m. —", f3, .66, (226, 200, 158)), ("singer unknown", f2, .74, (206, 176, 138))): lw = d.textlength(txt, font=f) d.text((S / 2 - lw / 2, S * y), txt, font=f, fill=col) d.ellipse([S * .47, S * .47, S * .53, S * .53], fill=(8, 8, 9)) _LABEL["l"] = im return _LABEL["l"] def eng_disc(sh, k, u, t, e): cx = W * sh.p.get("cx", 0.50) cy = H * sh.p.get("cy", 0.54) RPX = WREF * sh.p.get("rpx", 0.36) sq = sh.p.get("sq", 0.46) th = theta(t) X = (np.arange(W)[None, :] - cx) / RPX * R_OUT Y = (np.arange(H)[:, None] - cy) / (RPX * sq) * R_OUT rr = np.sqrt(X * X + Y * Y) aa = np.arctan2(Y, X) - th img = np.zeros((H, W, 3), np.float32) # the table under it img[:] = 0.020 + 0.014 * (np.arange(H)[:, None, None] / H) on = rr < R_OUT # shellac: two bright arcs where the light bar crosses the grooves la = sh.p.get("la", 0.55) streak = np.exp(-((np.sin(aa + th - la)) / 0.34) ** 2) band = (rr > R_LABEL) & (rr < R_LEAD + 0.001) ripple = 0.5 + 0.5 * np.cos(rr / 0.0018 * 2 * np.pi) lum = 0.030 + streak * (0.20 + 0.24 * float(e["rms"])) * band lum = lum + band * ripple * 0.012 lum = lum + (rr > R_LEAD) * (rr < R_OUT) * 0.030 # the locked groove: a hard closed circle if locked(t): lum = lum + np.exp(-((rr - R_LOCK) / 0.0011) ** 2) * \ (0.30 + 0.45 * float(e["hit"])) for c, kk in enumerate((1.00, 0.965, 0.925)): img[..., c] = np.where(on, lum * kk, img[..., c]) im = Image.fromarray(np.clip(img * 255, 0, 255).astype(np.uint8)) # the paper label lab = label_img().rotate(-math.degrees(th), resample=Image.BICUBIC) lw = int(2 * R_LABEL / R_OUT * RPX); lh = max(2, int(lw * sq)) lab = lab.resize((max(2, lw), lh), Image.LANCZOS) mask = Image.new("L", (max(2, lw), lh), 0) ImageDraw.Draw(mask).ellipse([0, 0, lw - 1, lh - 1], fill=255) im.paste(lab, (int(cx - lw / 2), int(cy - lh / 2)), mask) d = ImageDraw.Draw(im, "RGBA") # dust and a scratch, fixed to the disc rngd = np.random.RandomState(4477) for q in range(150): r0 = rngd.uniform(R_LABEL, R_LEAD); a0 = rngd.uniform(0, 6.283) + th x = cx + r0 / R_OUT * RPX * math.cos(a0) y = cy + r0 / R_OUT * RPX * sq * math.sin(a0) dr = PXf(1) d.ellipse([x - dr, y - dr, x + dr, y + dr], fill=(210, 206, 196, int(60 + 90 * rngd.uniform()))) # the tonearm R = radius(t) px = cx + R / R_OUT * RPX * math.cos(-0.55) py = cy + R / R_OUT * RPX * sq * math.sin(-0.55) piv = (cx + RPX * 1.28, cy - RPX * sq * 1.35) d.line([piv, (px, py)], fill=(150, 148, 152, 255), width=max(3, int(RPX * .030))) d.line([piv, (px, py)], fill=(206, 206, 212, 255), width=max(1, int(RPX * .012))) d.polygon([(px - RPX * .050, py - RPX * .052), (px + RPX * .050, py - RPX * .052), (px + RPX * .030, py + RPX * .012), (px - RPX * .030, py + RPX * .012)], fill=(26, 24, 28, 255)) d.ellipse([piv[0] - RPX * .05, piv[1] - RPX * .05, piv[0] + RPX * .05, piv[1] + RPX * .05], fill=(120, 118, 124, 255)) return np.asarray(im, np.float32) / 255.0 # ── the floor fills up ──────────────────────────────────────────────────── # The room does not arrive all at once. One distant silhouette appears while # the locked cell is still only a kick, and the floor accretes body by body as # the band-limit lifts, so the crowd IS the build. Each dancer has an entrance # time and fades up over FADE seconds; the roster is fixed, so nobody moves # when the next one walks on — the room only ever gains people. ROOM_IN = 37.30 # the first silhouette (one bar after the kick lands) ROOM_FULL = 57.60 # the floor is full FADE = 0.70 DANCERS = [ # x fraction, scale (= depth), phase lag, entrance (0.70, 0.60, 0.0, 37.30), # far, small, off to one side (0.34, 0.72, 1.9, 39.70), (0.46, 1.00, 1.1, 42.60), # the one in front (0.84, 0.80, 2.7, 44.40), (0.15, 0.90, 0.6, 48.40), (0.60, 1.08, 3.3, 52.00), (0.25, 1.15, 2.2, 55.40), ] def eng_room(sh, k, u, t, e): """A room somewhere. The strobe is the kick, and the crowd is the build. `pre` is the front half: the same room, hours earlier — the light on, the floor empty, one person waiting. Nobody dances yet; the roster is given explicitly instead of accruing off the clock, and the lights are down. """ flash = float(e["hit"]) rms = float(e["rms"]) pre = sh.p.get("pre") # 0 at the first silhouette, 1 when the floor is full drive = clamp01((t - ROOM_IN) / (ROOM_FULL - ROOM_IN)) lift = 0.55 + 0.45 * drive # the lights come up with the crowd mv = 0.42 + 0.58 * drive # and so does how hard they move arf = 0.40 + 0.60 * drive # hands stay down until it really goes if pre: lift = sh.p.get("lift", 0.30) mv = sh.p.get("mv", 0.05) arf = 0.0 flash *= 0.25 im = Image.new("RGB", (W, H), (7, 7, 9)) d = ImageDraw.Draw(im, "RGBA") # a wide strip of light on the back wall, pumping with the kick g = (58 + 150 * flash + 28 * rms) * lift y0, y1 = H * 0.16, H * 0.80 for q in range(14): # a soft glow around it a = int(20 * (1 - q / 14.0) * (0.35 + 0.9 * flash) * lift) d.rectangle([W * 0.04 - q * PXf(16), y0 - q * PXf(12), W * 0.96 + q * PXf(16), y1 + q * PXf(12)], fill=(int(g * .9), int(g * .82), int(g * .66), a)) d.rectangle([W * 0.06, y0, W * 0.94, y1], fill=(int(g), int(g * 0.90), int(g * 0.72), 255)) # mullions across the light for q in range(1, 4): x = W * (0.06 + 0.88 * q / 4.0) d.rectangle([x - PXf(5), y0, x + PXf(5), y1], fill=(16, 15, 17, 255)) # floor, and the light pooling on it d.rectangle([0, y1, W, H], fill=(24, 22, 24, 255)) for q in range(10): a = int(104 * (1 - q / 10.0) * (0.34 + 0.9 * flash) * lift) d.polygon([(W * 0.06, y1), (W * 0.94, y1), (W * 1.14 + q * PXf(30), H), (W * -0.14 - q * PXf(30), H)], fill=(int(g * .8), int(g * .72), int(g * .58), a)) # the dancers, in front of the light ph = 2 * math.pi * (t % BARL) / BARL pan = sh.p.get("bx", 0.44) - 0.44 # the shot's framing if pre: # an explicit roster: whoever is already in the room, standing still on = [(dxf, sc, lag, al) for (dxf, sc, lag, al) in sh.p.get("who", [])] else: on = [(dxf, sc, lag, clamp01((t - tin) / FADE)) for (dxf, sc, lag, tin) in DANCERS if t > tin] on = [o for o in on if o[3] > 0.004] on.sort(key=lambda o: o[1]) # near ones drawn last for (dxf, sc, lag, al) in on: p2 = ph + lag sw = math.sin(p2 * 2.0); sw2 = math.sin(p2 * 2.0 + 1.9) bob = abs(math.sin(p2 * 2.0)) bx = W * (dxf + pan) + PXf(30) * mv * math.sin(p2) * sc feet = H * 0.985 hh = H * 0.54 * sc # feet -> crown hip = feet - hh * 0.46 - PXf(12) * bob * sc * mv sho = feet - hh * 0.86 - PXf(14) * bob * sc * mv hx = bx + PXf(20) * mv * sw * sc # the lean col = (5, 5, 7, int(255 * al)) # legs for lx, ph2 in ((-1, 0.0), (1, math.pi)): kx = bx + lx * WREF * .020 * sc + \ PXf(34) * mv * math.sin(p2 * 2 + ph2) * sc d.line([(bx + lx * WREF * .017 * sc, hip), (kx, (hip + feet) / 2)], fill=col, width=int(WREF * 0.026 * sc)) d.line([(kx, (hip + feet) / 2), (kx + lx * WREF * .010 * sc, feet)], fill=col, width=int(WREF * 0.022 * sc)) # torso: hips to shoulders only d.polygon([(bx - WREF * .034 * sc, hip), (bx + WREF * .034 * sc, hip), (hx + WREF * .048 * sc, sho), (hx - WREF * .048 * sc, sho)], fill=col) # arms — down at the sides at first, up in the air by the end. # In the `pre` glimpses nobody is dancing yet, so the arms simply # hang: elbow just below the shoulder, hand at the hip. for ax, ph2 in ((-1, 0.0), (1, 2.4)): ex = hx + ax * WREF * .046 * sc if pre: el = (ex + ax * WREF * .004 * sc, sho + hh * .19) wr = (ex + ax * WREF * .010 * sc, sho + hh * .38) d.line([(ex, sho + hh * .02), el], fill=col, width=int(WREF * 0.021 * sc)) d.line([el, wr], fill=col, width=int(WREF * 0.018 * sc)) continue wx = ex + ax * (WREF * .052 + PXf(40) * sw2 * mv) * sc wy = sho - hh * (0.30 + 0.16 * math.sin(p2 * 2 + ph2)) * arf d.line([(ex, sho + hh * .02), (ex + ax * WREF * .030 * sc, sho - hh * .16 * arf)], fill=col, width=int(WREF * 0.021 * sc)) d.line([(ex + ax * WREF * .030 * sc, sho - hh * .16 * arf), (wx, wy)], fill=col, width=int(WREF * 0.018 * sc)) # neck + head d.line([(hx, sho), (hx + PXf(6) * sw, sho - hh * .07)], fill=col, width=int(WREF * 0.018 * sc)) hr = WREF * .030 * sc d.ellipse([hx + PXf(6) * sw - hr, sho - hh * .07 - hr * 1.9, hx + PXf(6) * sw + hr, sho - hh * .07 + hr * 0.30], fill=col) crop = sh.p.get("crop") if crop: x0f, y0f, x1f, y1f = crop im = im.crop((int(W*x0f), int(H*y0f), int(W*x1f), int(H*y1f))) \ .resize((W, H), Image.LANCZOS) return np.asarray(im, np.float32) / 255.0 ENGINES = dict(groove=eng_groove, stylus=eng_stylus, disc=eng_disc, room=eng_room) # ════════════════════════════════════════════════════════════════════════════ # SHOT TABLE — first and last shot are the SAME disc view, so the ring closes # ════════════════════════════════════════════════════════════════════════════ DISC0 = dict(rpx=0.36, sq=0.46, la=0.55, cx=0.50, cy=0.54) # The four ONE-REVOLUTION glimpses at 8.8 / 15.2 / 21.6 / 28.0 are the front # half's answer to the back half's accretion: the room exists before the groove # locks, and it is empty. Each is 0.800 s — exactly one turn of the record — so # the cut lands on the oom-pah and the piece's one number still governs # everything. Nothing dances in them; the lights are down and the roster is # given by hand, so the DANCERS table (and therefore the build from 37.3 s) is # untouched. They also never touch t = 0 or t = DUR, so the ring still closes. SHOTPLAN = [ (0.0, "disc", dict(DISC0)), (3.2, "stylus", dict(zoom=2.4, la=0.30, big=1.0, card=1)), (6.4, "groove", dict(zoom=1.0, la=0.35, stylus=1)), (8.8, "room", dict(bx=0.44, pre="empty", lift=0.30, who=[])), (9.6, "disc", dict(rpx=0.46, sq=0.40, la=1.10, cx=0.44, cy=0.56)), (12.8, "groove", dict(zoom=1.5, la=0.9, stylus=1, big=0.8)), (15.2, "room", dict(bx=0.44, pre="feet", lift=0.34, mv=0.05, who=[(0.46, 1.00, 1.1, 1.0)], crop=(0.34, 0.72, 0.61, 0.99))), (16.0, "stylus", dict(zoom=3.0, la=-0.35)), (19.2, "groove", dict(zoom=0.55, la=0.35, ng=17)), (21.6, "room", dict(bx=0.52, pre="wait", lift=0.26, mv=0.04, who=[(0.70, 0.60, 0.0, 1.0)])), (22.4, "disc", dict(rpx=0.30, sq=0.52, la=-0.30, cx=0.56, cy=0.50)), (25.6, "groove", dict(zoom=1.9, la=1.4, stylus=1, ng=7)), (28.0, "room", dict(bx=0.40, pre="wait", lift=0.30, mv=0.07, who=[(0.70, 0.60, 0.0, 1.0), (0.34, 0.72, 1.9, 0.85)])), (28.8, "stylus", dict(zoom=2.2, la=0.55)), (31.2, "groove", dict(zoom=1.0, la=0.35, stylus=1, ng=8)), (33.6, "groove", dict(zoom=1.0, la=0.35, stylus=1, ng=8, card=2)), (36.0, "disc", dict(rpx=0.52, sq=0.34, la=0.20, cx=0.50, cy=0.58)), (37.6, "room", dict(bx=0.44, door=0.70)), # ← one silhouette (38.4, "stylus", dict(zoom=2.8, la=0.10)), (40.0, "room", dict(bx=0.40, door=0.70)), # ← two of them now (40.8, "groove", dict(zoom=2.1, la=-0.60, stylus=1, ng=7)), (43.2, "room", dict(bx=0.38, door=0.70)), (45.6, "groove", dict(zoom=1.7, la=0.35, stylus=1, ng=9)), (48.0, "disc", dict(rpx=0.40, sq=0.44, la=1.60, cx=0.48, cy=0.52, card=3)), (50.4, "room", dict(bx=0.54, door=0.24)), (52.8, "stylus", dict(zoom=3.4, la=0.75)), (55.2, "groove", dict(zoom=0.45, la=0.35, ng=21)), (57.6, "room", dict(bx=0.44, door=0.66)), (60.0, "disc", dict(rpx=0.58, sq=0.30, la=0.90, cx=0.52, cy=0.60)), (62.4, "groove", dict(zoom=1.0, la=0.35, stylus=1)), (64.8, "stylus", dict(zoom=2.0, la=0.35, card=4)), (66.4, "disc", dict(DISC0)), ] CAPTIONS = [ (5.6, 11.2, "a 78 of a singer nobody remembers"), (14.0, 19.0, "the needle travels inward through her one recording"), (25.0, 30.0, "one side. one take. no second chorus."), (34.4, 38.8, "the groove closes on itself. it does not advance."), (39.4, 43.0, "and a room somewhere starts dancing to it"), (44.0, 49.0, "by the fortieth pass it is not a song any more"), (53.5, 58.5, "the floor fills. the record has not moved."), (66.6, 68.6, "the arm lifts. the last frame is the first."), ] class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "p", "section", "seed") def __init__(self, idx, i0, i1, engine, p): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.p = engine, p self.section = sec_of_t(i0 / FPS) self.seed = 15000 + idx * 7919 def build_shots(): shots = [] for i, (t0, eng, p) in enumerate(SHOTPLAN): a = t0 b = SHOTPLAN[i + 1][0] if i + 1 < len(SHOTPLAN) else DUR i0, i1 = int(round(a * FPS)), int(round(b * FPS)) if i1 <= i0: continue shots.append(Shot(len(shots), i0, i1, eng, p)) if shots: shots[-1].i1 = N_FRAMES shots[-1].n = N_FRAMES - shots[-1].i0 return shots # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> crisp text -> letterbox # ════════════════════════════════════════════════════════════════════════════ # ── 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="Georgia.ttf"): """Size is in authoring units; the loaded face is scaled once by SCL.""" key = (size, name, SCL) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size if SCL == 1.0 else max(2, PXi(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) rr = np.sqrt(nx ** 2 + ny ** 2) / 1.42 _VIG["v"] = np.clip(1.0 - 0.48 * rr ** 2.0, 0, 1)[..., None] \ .astype(np.float32) return _VIG["v"] def shadowed(d, xy, txt, f, fill, sh=(9, 6, 5), a=200): x, y = xy for ox, oy in ((-PXf(2), PXf(2)), (PXf(2), PXf(2)), (0, PXf(3)), (PXf(2), -PXf(1)), (-PXf(2), -PXf(1))): d.text((x + ox, y + oy), txt, font=f, fill=sh + (a,)) d.text((x, y), txt, font=f, fill=fill) def win(t, a, b, fi=0.55, fo=0.8): if t < a or t > b: return 0.0 return clamp01((t - a) / fi) * clamp01((b - t) / fo) def post(arr, i, e, shot): # EVERYTHING below is a function of (i mod N_FRAMES), so the frame at # t = DUR is byte-identical to frame 0 and the film is a true ring. i = i % N_FRAMES t = i / FPS a = np.clip(np.asarray(arr, np.float32), 0, 1.6) * 255.0 # 1. tint — warm shellac, cold steel lum = a.mean(2, keepdims=True) / 255.0 a = a * np.array([1.045, 1.000, 0.955], np.float32) a = a + (1 - lum) * np.array([4, 1, -6], np.float32) # 2. vignette a *= vignette() # 3. grain rng = np.random.RandomState(7700 + i) sd_ = 2.1 + 1.7 * float(e["high"]) if SCL == 1.0: a += rng.normal(0, sd_, a.shape) else: # grain is a look, not a resolution: authored at 1280x720 and blown up # nearest-neighbour so a speck covers the same fraction of the frame. gn = rng.normal(0, sd_, (HB, WB, 3)) * 8.0 + 128.0 gi = Image.fromarray(np.clip(gn, 0, 255).astype(np.uint8)) a += (np.asarray(gi.resize((W, H), Image.NEAREST), np.float32) - 128.0)/8.0 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out, "RGBA") age = i - shot.i0 if shot.p.get("card") == 1 and age < FPS * 4.6: al = min(1.0, age / 12.0) * min(1.0, (FPS * 4.6 - age) / 16.0) f0 = font(17, "Georgia.ttf") slug = "P L A Y E R C O M P U T E R" lw0 = d.textlength(slug, font=f0) shadowed(d, (W / 2 - lw0 / 2, H * 0.10 - PXf(34)), slug, f0, (214, 190, 154, int(210 * al)), a=140) f = font(58, "Georgia Bold.ttf") lw = d.textlength(TITLE, font=f) shadowed(d, (W / 2 - lw / 2, H * 0.10), TITLE, f, (244, 234, 210, int(240 * al))) f2 = font(19, "Georgia Italic.ttf") sub_ = "a ring · the last frame is the first" lw2 = d.textlength(sub_, font=f2) shadowed(d, (W / 2 - lw2 / 2, H * 0.10 + PXf(72)), sub_, f2, (218, 196, 164, int(206 * al)), a=150) for lab, key in (("THE NEEDLE CATCHES", 2), ("IT IS A DANCE RECORD", 3), ("AND BACK TO THE LEAD-IN", 4)): if shot.p.get("card") == key and age < FPS * 2.6: al = min(1.0, age / 8.0) * min(1.0, (FPS * 2.6 - age) / 12.0) f = font(24, "Georgia.ttf") shadowed(d, (W * 0.062, H * 0.845), lab, f, (238, 216, 170, int(228 * al)), a=160) for a0, b0, txt in CAPTIONS: al = win(t, a0, b0) if al > 0.003: f2 = font(25, "Georgia Italic.ttf") lw2 = d.textlength(txt, font=f2) shadowed(d, (W / 2 - lw2 / 2, H * 0.795), txt, f2, (240, 230, 210, int(234 * al))) # (the RPM / radius / pass-counter telemetry strip that used to sit on a # scrim across the bottom was the renderer talking, not the film; it is # gone, and so is the scrim that backed it.) bh = int(H * 0.045) d.rectangle([0, 0, W, bh], fill=(10, 9, 9)) d.rectangle([0, H - bh, W, H], fill=(10, 9, 9)) return out # ════════════════════════════════════════════════════════════════════════════ # RENDER # ════════════════════════════════════════════════════════════════════════════ def render_shot(job): shot, force = job E = env(); grv() fn = ENGINES[shot.engine] made = 0 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} arr = fn(shot, k, k / max(1, shot.n - 1), i / FPS, e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return (f"shot {shot.idx:02d} {shot.engine:7s} {shot.section:8s} " f"{made}/{shot.n}") def ring_check(shots, write=True): """Render the frame at t = DUR with shot 0's parameters and compare it, byte for byte, with frame 0.""" E = env(); grv() sh0 = shots[0] e0 = {kk: float(E[kk][0]) for kk in E} a = ENGINES[sh0.engine](sh0, 0, 0.0, 0.0, e0) b = ENGINES[sh0.engine](sh0, 0, 0.0, DUR, e0) ia = post(a, 0, e0, sh0) ib = post(b, N_FRAMES, e0, sh0) ha = hashlib.md5(ia.tobytes()).hexdigest() hb = hashlib.md5(ib.tobytes()).hexdigest() ok = ha == hb if write: ia.save(OUT / "ring_first.png"); ib.save(OUT / "ring_last.png") return ok, ha, hb def contact_sheet(shots): cols = 6 rows = (len(shots) + cols - 1) // cols tw, th = PXi(300), PXi(193) lab = PXi(26) sheet = Image.new("RGB", (cols * tw, rows * (th + lab)), (10, 10, 12)) sd = ImageDraw.Draw(sheet) E = env(); grv() for nn, 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 = (nn % cols) * tw, (nn // cols) * (th + lab) sheet.paste(im, (cx, cy)) sd.text((cx + PXi(5), cy + th + PXi(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) ok, ha, hb = ring_check(shots) print(f"contact sheet -> {p} ({len(shots)} shots)") print(f"ring: frame(t=0) {ha[:12]} frame(t=DUR) {hb[:12]} " f"{'CLOSED' if ok else 'OPEN — fix before rendering'}") 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("--ring", 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(8, 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 / "groove.npz").exists(): print(f"[1/3] vaudeville 78 -> locked groove -> techno… {DUR:.1f}s, " f"{REVS} revolutions, {N_PASS} passes of the cell") mix = build_song(); write_wav(wav, mix); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots); return if a.ring: ok, ha, hb = ring_check(shots) print(f"frame(t=0) md5 {ha}\nframe(t=DUR) md5 {hb}\n" f"{'RING CLOSED' if ok else 'RING OPEN'}") 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]}") ok, ha, hb = ring_check(shots) print(f"[3/3] mux… ring {'CLOSED' if ok else 'OPEN'}") 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) (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"structure: ring / spiral — the visual parameterisation is periodic " f"with DUR, so frame(t=DUR) == frame(t=0)\n" f"ring check: md5 frame(t=0) = {ha}\n" f"ring check: md5 frame(t=DUR) = {hb}\n" f"ring: {'CLOSED (byte-identical)' if ok else 'OPEN'}\n" f"music: {MUSIC_DESC}\n" f"rotation: {T_REV:.3f} s/rev = 75.0 RPM = 24 frames; " f"{REVS} revolutions; 150 bpm 2/4 = one oom-pah per revolution; " f"locked cell = {BARL:.3f} s = 2 revolutions = one 4/4 bar\n" f"locked groove: cell [{CELL0}, {CELL1}) spliced {N_PASS}x from " f"{LOCK_T}s to {LIFT_T}s, band-limit lifted 4800->14400 Hz pass by " f"pass\n" f"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"substrate: {ENGINE_DESC}\n" f"disc: R_out={R_OUT} R_lead={R_LEAD} R_lock={R_LOCK} " f"pitch={PITCH} groove_w={GW} lat={LAT} vert={VERT}\n" f"macro: {VIEW_H * 1000:.2f} mm of radius across frame height " f"({VIEW_H / PITCH:.1f} grooves), high-speed at {SLOW:.3f}x\n" f"engines: {' '.join(sorted(ENGINES))} (shot-parallel, tier 4-P)\n" f"shots: {len(shots)}\n" f"seeds: perf=deterministic surface=7801 lead=3313 dust=60013 " f"disc-dust=4477 grain=7700+(frame mod N)\n") print(f"DONE {out} ({DUR:.1f}s) ring {'CLOSED' if ok else 'OPEN'}") if __name__ == "__main__": main()