#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Seismo (32/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/seismo # # A night at a seismic station, told by the machine that stays awake for it. # # 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/seismo.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/seismo.mp4 # cover: https://genekogan.com/player_computer/media/seismo.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 seismo.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 — "SEISMO" (round-2 curation) Musique concrète that becomes techno. 130 bpm, C minor. 44 bars (83.4s). roomtone(4) objects(6) kit(6) quake(8) strip(4) surge(6) after(6) heart(4) ROUND 2. Gene: "great story, this could be longer — as much as 1:25. The main part is really groovy but it doesn't play for long enough." So the techno block goes from 10 bars to 18 and gets an ARRANGEMENT instead of a plateau: quake drops as before and then admits an acid line at bar 20 and a ringing bell drone at 22; STRIP is a four-bar breakdown where the kick vanishes and the found objects walk back in through the hole (a raw pen scratch, a splice, an un-gated bell) over a splice roll that doubles and doubles again under two tape-rewind risers; SURGE is the second rupture — bigger kit, doubled kicks on the peak bars, the acid filter wide open, the bleep melody every bar. The intro and the landing are untouched: roomtone/objects/kit are the same six bars each, and after/heart still decay to one thud at 65bpm. Because the ink buffer is persistent the chart had to be re-planned, not looped: at 83.4s the pen makes ~20 revolutions before the tear instead of 16, so the drum carries ROWS=25 rows (was 21) and the deflection scale is raised by the same 25/21 so the mountain range is exactly as tall on screen as it was. THE MUSIC. Every drum in this piece is a found object that was recorded first and promoted later. A pen nib scratching paper, a desk bell, a book dropped on a desk, tape hiss, splice clicks, a rewind. Each object generator takes a `tight` parameter: at 0 it is the recording, at 1 it is the drum. The scratch tightens into the hi-hat, the bell gates into the ride, the thud sweeps into the kick. `tight` ramps across the piece, so the transformation is literally audible — the same function, morphed, not two different sounds. By bar 16 it is Detroit techno. By bar 32 it has decayed back to one object: a thud at 65 bpm, lub-dub. THE PICTURE. A new substrate: a chart recorder. An upright paper drum turning under an inked stylus, one revolution per six minutes of station time, the carriage descending a leadscrew so successive traces stack as parallel rows. The trace is not drawn per frame and thrown away — ink is DEPOSITED into a paper buffer and stays there for the rest of the film. Ink per pixel goes as 1/(1+k·span), so a fast pen draws a pale tall line and a still pen pools into a fat black one; the turning points get extra ink because that is where the nib decelerates. Minute marks are a notch in the trace itself. And the stylus is driven by the actual rendered audio: for every paper column the min/max of the corresponding ~122 audio samples becomes the vertical extent of the ink. The picture is the sound, at paper resolution. THE STORY. A night at a seismic station. Flat trace, nothing, nothing. The operator sleeps. Then the drop, and the earthquake writes itself across the drum and tears the line into a mountain range. The aftershocks decay, the operator tears off the chart — and the pen keeps writing on the fresh paper, and the last tremor it draws is a heartbeat: his own, leaning on the desk. Composition: engine : audio-first x shot-parallel (tier 4-P), stage-camera crops, persistent ink buffer advanced incrementally per shot content: audio-groove (concrète objects morphed into a techno kit) x effects-post x tts-voices (Samantha = station log, Whisper = last line) Run from repo root: python3 renders/player_computer_2/seismo/render.py --sheet python3 renders/player_computer_2/seismo/render.py python3 renders/player_computer_2/seismo/render.py --shots 12,13 --force python3 renders/player_computer_2/seismo/render.py --mux-only 1080p VARIANT ------------- `--1080p` renders the same film natively at 1920x1080 by setting a single global S = H/720 = 1.5 and multiplying every pixel-space quantity by it. Three kinds of pixel space exist here and all three scale together: * the STAGE (the room the camera crops from) — drawing code keeps writing in base 1920x1080 coordinates and goes through `SDraw`, a proxy that scales every coordinate and stroke width on the way to Pillow; the stage bitmap itself is 2880x1620, so a `mid` crop is 1.5x more pixels, not an upscale. * the PAPER / INK BUFFER — PW/PH/ROWPITCH/deflection all x1.5, so the persistent chart is a 1998x1875 buffer instead of 1332x1250 and the trace stays crisp at the macro zooms. The ink-deposit falloff constant is divided by S so ink-per-pixel (and therefore line darkness) is unchanged. * the FRAME — fonts, HUD, captions, blurs, grain, letterbox. Nothing about the audio changes; `audio/final.wav` and `audio/env.npz` are shared. The stroke table is resolution-dependent, so it is cached separately as `audio/chart_1080p.npz`, frames go to `frames_1080p/`, and the mux target is `seismo_1080p.mp4`. Default (no flag) behaviour is bit-for-bit unchanged. """ import argparse, datetime, hashlib, math, os, subprocess, sys, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "seismo" TITLE = "SEISMO" SETDIR = "player_computer_final" SETNUM = "05" # ── the one global: everything in pixel space is multiplied by S ───────────── # parsed from argv at import time so that the module-level geometry below is # already correct, and so that fork()ed render workers inherit it. HD = True # final cut: 1080p is the ONLY mode S = 1.5 def si(v): return int(round(v*S)) # scaled int (a coordinate/size) def sf(v): return v*S # scaled float W, H, FPS = 1920, 1080, 30 SUF = "" BPM = 130.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 OUT = Path(__file__).resolve().parent FRAMES = OUT / f"frames{SUF}"; FRAMES.mkdir(exist_ok=True) AUD = OUT / "audio"; AUD.mkdir(exist_ok=True) ROOT = OUT # standalone: was repo root (used for git provenance) FONTS = ROOT / "fonts" # the stroke table is in PAPER pixels, so it is resolution-dependent and gets # its own cache; the wav and the envelope analysis are shared. CHART_NPZ = AUD / f"chart{SUF}.npz" SECTIONS = [ ("roomtone", 0, 4), ("objects", 4, 10), ("kit", 10, 16), ("quake", 16, 24), # the drop, then two new layers enter ("strip", 24, 28), # breakdown: the kick goes, the objects return ("surge", 28, 34), # the second rupture — the peak ("after", 34, 40), ("heart", 40, 44), ] HOT = ("quake", "surge") # sections that shake the camera / redden the post LOUD = ("quake", "strip", "surge") N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 2.20 N_FRAMES = int(DUR * FPS) TEAR_F = int(40 * BAR * FPS) # the chart is torn off here MUSIC_DESC = f"musique concrete -> detroit techno w/ breakdown + second drop, {BPM:.0f}bpm, C minor, {N_BARS} bars" ENGINE_DESC = "chart recorder: persistent ink buffer, audio-driven stylus, cylindrical drum" # ── paper geometry (paper-local pixels) ────────────────────────────────────── # All of this scales with S: the ink buffer is the picture's real substrate, # so at 1080p it is 1998x1875 rather than 1332x1250 and the pen writes 18 # paper columns per video frame instead of 12. COLS = si(12) # paper columns written per video frame REV_FRAMES = 111 # frames per drum revolution (~2 bars) PW = REV_FRAMES * COLS # 1332 (1998 @1080p) — the circumference ROWS = 25 # r2: 20 revolutions before the tear, not 16 ROWPITCH = si(50) PRE_ROWS = 4 # hours already written before we arrive PH = ROWS * ROWPITCH # 1250 (1875 @1080p) MARK_EVERY = PW // 6 # a minute mark every 1/6 revolution # AMP_K and AMP_CLIP both scale, so _defl() is exactly S x its 720p self AMP_K, AMP_EXP, AMP_CLIP = sf(393.0), 2.15, sf(155.0) # r2: scaled 25/21 INK_FLOW = 3.1 # ink PER PIXEL — deliberately not scaled # station clock: one revolution = six minutes T0_MIN = 2*60 + 14 def lerp(a, b, u): return a + (b - a) * u def ease_io(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def ease_in(u): return u**3 def spring(u, freq=3.0, damp=5.0): if u <= 0: return 0.0 return 1 - math.exp(-damp*u)*math.cos(freq*math.tau*u) def clock(frame): m = T0_MIN + (frame / REV_FRAMES) * 6.0 s = int((m % 1.0) * 60) return f"{int(m//60)%24:02d}:{int(m)%60:02d}:{s:02d}" # ════════════════════════════════════════════════════════════════════════════ # AUDIO PRIMITIVES # ════════════════════════════════════════════════════════════════════════════ 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) 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 boxcar(x, k): """O(n) moving average. np.convolve with a 17k-tap kernel over three million samples is minutes of wall clock; this is milliseconds.""" k = max(1, int(k)) pad = k//2 xp = np.concatenate([np.full(pad, x[0]), x, np.full(k-pad+1, x[-1])]) c = np.cumsum(np.concatenate([[0.0], xp])) return ((c[k:] - c[:-k])/k)[:len(x)] def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Every noise source in this piece goes through it — nothing is allowed to be a raw full-band blast (AESTHETIC 13a).""" 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 voice(freq, dur, kind="saw", nh=24, c0=5200, c1=700, ck=8.0, res=0.0, detune=(0.0,), a=.005, d=.09, s=.7, r=.10, seed=0): """Additive voice through a *moving* emulated filter.""" n = int(dur * SR) if n <= 0: return np.zeros(0) t = np.arange(n) / SR co = c1 + (c0 - c1) * np.exp(-t * ck) rng = np.random.RandomState(seed) out = np.zeros(n) for det in detune: f0 = freq * (1 + det * 0.006) for k in range(1, nh + 1): if kind == "saw": base = 1.0 / k elif kind == "square": base = (1.0 / k) if k % 2 else 0.0 elif kind == "tri": base = (1.0 / (k*k)) if k % 2 else 0.0 elif kind == "sine": base = 1.0 if k == 1 else 0.0 else: base = 1.0 / k if base == 0.0: continue fk = f0 * k if fk > SR * 0.45: break g = base / np.sqrt(1.0 + (fk / co) ** 4) if res: g = g + res * base * np.exp(-((fk - co) / (0.3 * co + 1)) ** 2) out += g * np.sin(2*np.pi*fk*t + rng.uniform(0, 2*np.pi)) out /= len(detune) return out * adsr(n, a, d, s, r) def reverb(x, rt=1.6, mix=.3, seed=29, pre=0.02): n = int(rt*SR); t = np.arange(n)/SR ir = np.random.RandomState(seed).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=.25, fb=.38, mix=.25, taps=7): d = int(time*SR); out = x.copy() for i in range(1, taps+1): g = mix * (fb ** i); s = d*i if s >= len(x): break out[s:] += x[:len(x)-s] * g return out # ════════════════════════════════════════════════════════════════════════════ # THE CONCRÈTE OBJECTS # # Six things recorded in a room. Each takes `tight` in [0,1]: 0 is the # recording as it was found, 1 is the same object promoted to a drum. The # arrangement ramps `tight` across the piece, so the kit does not replace the # objects — it IS the objects, squeezed. # ════════════════════════════════════════════════════════════════════════════ def o_pen(dur=0.46, tight=0.0, seed=0, open_h=False): """Pen nib scratching paper -> hi-hat. The nib chatters at ~70 Hz when it drags; tightening raises the chatter out of hearing, lifts the band and collapses the envelope.""" d = dur * lerp(1.0, 0.16 if not open_h else 0.44, tight) n = max(8, int(d*SR)); t = np.arange(n)/SR rng = np.random.RandomState(1000+seed) nz = bandshape(rng.randn(n), lo=lerp(1500, 5600, tight), hi=lerp(6800, 11500, tight)) chat = 1.0 + (1-tight)*0.85*np.sin(2*np.pi*lerp(68, 260, tight)*t + 0.7*np.sin(2*np.pi*11*t)) env = np.exp(-t*lerp(7.0, 82.0 if not open_h else 15.0, tight)) * \ np.clip(t*lerp(160, 4000, tight), 0, 1) return nz*chat*env*lerp(0.30, 0.44, tight) def o_bell(dur=1.9, tight=0.0, seed=0): """Small desk bell -> ride cymbal. Same inharmonic partial set; tightening shortens the ring, adds a wash of band-limited noise and a harder strike.""" d = dur * lerp(1.0, 0.42, tight) n = max(8, int(d*SR)); t = np.arange(n)/SR f0 = lerp(1290.0, 1640.0, tight) parts = (1.0, 2.76, 5.40, 8.93, 13.34) x = np.zeros(n) for i, p in enumerate(parts): fk = f0*p if fk > SR*0.45: break x += np.sin(2*np.pi*fk*t + i*1.31) * (1.0/(1+i*1.15)) * \ np.exp(-t*lerp(1.6+i*0.9, 5.0+i*2.4, tight)) rng = np.random.RandomState(2000+seed) wash = bandshape(rng.randn(n), lo=3600, hi=9200) * np.exp(-t*lerp(9, 4.2, tight)) strike = bandshape(rng.randn(n), lo=2200, hi=8000) * np.exp(-t*220) return (x*0.5 + wash*lerp(0.04, 0.20, tight) + strike*lerp(0.10, 0.26, tight)) \ * lerp(0.42, 0.40, tight) def o_thud(dur=0.62, tight=0.0, seed=0, sub=0.0): """A book dropped on a desk -> kick drum (and, at the end, a heart). Tightening steepens the pitch sweep, kills the wood crack and saturates.""" d = dur * lerp(1.0, 0.46, tight) n = max(8, int(d*SR)); t = np.arange(n)/SR f_hi = lerp(112.0, 158.0, tight); f_lo = lerp(62.0, 46.0, tight) - 6*sub k = lerp(9.0, 30.0, tight) f = f_lo + (f_hi-f_lo)*np.exp(-t*k) body = np.sin(2*np.pi*np.cumsum(f)/SR) * np.exp(-t*lerp(6.0, 11.0, tight)) rng = np.random.RandomState(3000+seed) wood = bandshape(rng.randn(n), lo=380, hi=3200) * np.exp(-t*lerp(26, 120, tight)) x = body + wood*lerp(0.55, 0.11, tight) drive = lerp(1.05, 1.85, tight) return np.tanh(x*drive)/np.tanh(drive) * lerp(0.72, 0.95, tight) def o_splice(tight=0.0, seed=0): """A tape splice going past the head -> rim / clave.""" n = int(lerp(0.085, 0.055, tight)*SR); t = np.arange(n)/SR rng = np.random.RandomState(4000+seed) step = np.zeros(n); step[0] = 1.0 step = np.convolve(step, np.exp(-np.arange(90)/lerp(30, 9, tight)), "same") tone = np.sin(2*np.pi*lerp(920, 1760, tight)*t) * np.exp(-t*lerp(50, 96, tight)) tick = bandshape(rng.randn(n), lo=1400, hi=lerp(6000, 9000, tight)) * np.exp(-t*180) return (step*0.5 + tone*lerp(0.25, 0.5, tight) + tick*0.45) * 0.55 def o_hiss(dur, seed=0, wow=1.0): """The tape bed: filtered hiss with wow and flutter, plus dust.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(5000+seed) h = bandshape(rng.randn(n), lo=260, hi=5200) am = 1.0 + wow*(0.22*np.sin(2*np.pi*0.41*t) + 0.10*np.sin(2*np.pi*7.3*t)) dust = np.zeros(n) idx = rng.choice(n, size=max(1, n//5200), replace=False) dust[idx] = rng.uniform(-1, 1, len(idx)) dust = np.convolve(dust, np.exp(-np.arange(70)/11), "same") return h*am*0.055 + dust*0.09 def o_room(dur, seed=0): """Room tone: mains hum, a fridge, and the building.""" n = int(dur*SR); t = np.arange(n)/SR x = (np.sin(2*np.pi*50*t)*0.55 + np.sin(2*np.pi*100*t)*0.24 + np.sin(2*np.pi*150*t)*0.09) rng = np.random.RandomState(6000+seed) rumble = bandshape(rng.randn(n), lo=26, hi=90) return x*(0.9+0.1*np.sin(2*np.pi*0.13*t))*0.055 + rumble*0.10 def o_rewind(dur=1.7, seed=0): """Tape rewind -> riser. Noise through a band that climbs with the sweep, so it is pitched motion and never a static blast.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(7000+seed) nz = np.zeros(n); blk = 2048 for i in range(0, n, blk): m = min(blk, n-i); u = (i/max(1, n))**1.35 fc = 400 + 5000*u nz[i:i+m] = bandshape(rng.randn(m+256), lo=fc*0.7, hi=fc*1.6)[:m] spool = np.sin(2*np.pi*np.cumsum(np.linspace(28, 190, n))/SR) * 0.5 env = (t/dur)**1.5 return (nz*0.85 + spool*np.clip(env*1.6, 0, 1)*0.30) * env * 0.75 def o_tear(dur=1.15, seed=0): """Paper tearing off the drum: a rip is a dense train of tiny fibres.""" n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(8000+seed) grain = np.zeros(n) idx = rng.choice(n, size=n//26, replace=False) grain[idx] = rng.uniform(-1, 1, len(idx)) grain = np.convolve(grain, np.exp(-np.arange(40)/6), "same") body = bandshape(rng.randn(n), lo=900, hi=7600) env = np.clip(t*24, 0, 1) * np.exp(-np.maximum(0, t-0.28)*4.4) return (grain*0.85 + body*0.35) * env * 0.65 # ── speech ─────────────────────────────────────────────────────────────────── def _h(*parts): return hashlib.md5("|".join(str(p) for p in parts).encode()).hexdigest()[:16] def read_wav(p): with wave.open(str(p)) as w: ch = w.getnchannels() x = np.frombuffer(w.readframes(w.getnframes()), " macOS `say` (the canonical voices) -> espeak-ng / # espeak (Linux; language mapped from the say voice name, rate is wpm in both) # -> Windows SAPI (default voice, rate mapped from wpm) -> timed silence as # the last resort (duration from a chars/wpm heuristic, loud warning, never # cached so a later run with an engine present re-voices). A re-voiced film is # a different performance of the same score; that is by design. # Force a tier with POOP_TTS=say|espeak|sapi|none. def _tts_lang(voice): v = str(voice) if "Spanish" in v: return "es-mx" if "Mexico" in v else "es" if "Portuguese" in v: return "pt-br" if "Brazil" in v else "pt" if "English (UK)" in v: return "en-gb" return "en-us" def _tts_engine(): import shutil, platform want = os.environ.get("POOP_TTS", "").strip().lower() if want: return want if shutil.which("say"): return "say" if shutil.which("espeak-ng") or shutil.which("espeak"): return "espeak" if platform.system() == "Windows": return "sapi" return "none" def _tts_render(text, voice, rate, path): """Synthesize text -> mono 44.1k wav at `path` with the best available engine. Returns False if no engine (caller falls back to timed silence).""" import shutil, sys, base64 eng = _tts_engine() tmp = path.with_suffix(".tts.wav") try: if eng == "say": aiff = path.with_suffix(".aiff") subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", str(aiff), text], check=True) subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) aiff.unlink(missing_ok=True) return True if eng == "espeak": exe = shutil.which("espeak-ng") or shutil.which("espeak") or "espeak-ng" subprocess.run([exe, "-v", _tts_lang(voice), "-s", str(int(rate)), "-w", str(tmp), str(text)], check=True) elif eng == "sapi": r = max(-10, min(10, round((int(rate) - 175) / 25))) esc = str(text).replace("'", "''") ps = ("Add-Type -AssemblyName System.Speech;" "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" f"$s.Rate={r};$s.SetOutputToWaveFile('{tmp}');" f"$s.Speak('{esc}');$s.Dispose()") enc = base64.b64encode(ps.encode("utf-16-le")).decode() subprocess.run(["powershell", "-NoProfile", "-EncodedCommand", enc], check=True) else: return False subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-ar", str(SR), "-ac", "1", str(path)], check=True, capture_output=True) return True except Exception as e: print(f"[tts] {eng} failed ({e}) — falling back to timed silence", file=sys.stderr) return False finally: tmp.unlink(missing_ok=True) def _tts_silence(text, rate): import sys dur = max(0.6, len(str(text)) / (max(60, int(rate)) * 5.0 / 60.0)) print(f'[tts] no speech engine — timed silence ({dur:.2f}s): ' f'"{str(text)[:48]}"', file=sys.stderr) return np.zeros(int(dur * SR)) def say_wav(text, vname, rate, path): """text -> mono 44.1k voice wav (cached on disk; deterministic per engine).""" path = Path(path) if not path.exists(): if not _tts_render(text, vname, rate, path): return _tts_silence(text, rate) return read_wav(path) def fit(x, n): if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def speak(text, vname="Samantha", rate=170, dur=None, lo=0.0, hi=0.0, pitch=1.0): """A spoken line, optionally band-limited into the station intercom.""" x = say_wav(text, vname, rate, AUD/("say_"+_h(text, vname, rate)+".wav")) if pitch != 1.0: x = fit(x, max(8, int(len(x)/pitch))) if lo or hi: x = bandshape(x, lo=lo, hi=hi) if dur: n = int(dur*SR) x = fit(x, n) if len(x) > n else np.pad(x, (0, n-len(x))) return x/(np.max(np.abs(x))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {}; self.kick_t = [] def t(self, bar, step=0, swing=0.0): sw = swing * (BEAT/4) if (step % 2) else 0.0 return bar*BAR + step*(BEAT/4) + sw def put(self, track, sig, at, g=1.0, pan=0.0): 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 or 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 sec_env(self, levels, glide=0.40): env = np.ones(self.n) for nm, b0, b1 in SECTIONS: i0, i1 = int(b0*BAR*SR), min(self.n, int(b1*BAR*SR)) if i1 > i0: env[i0:i1] = levels.get(nm, 1.0) env[int(SECTIONS[-1][2]*BAR*SR):] = levels.get(SECTIONS[-1][0], 1.0) return boxcar(env, int(glide*SR)) def mixdown(self, gains, pump_depth=.30, pump_rel=.14, levels=None): mix = np.zeros((self.n, 2)) for k, b in self.tr.items(): mix += b * gains.get(k, 1.0) if levels: mix *= self.sec_env(levels)[:, None] if self.kick_t: env = np.ones(self.n); rl = int(pump_rel*SR) shape = 1 - pump_depth*np.exp(-np.arange(rl)/(pump_rel*SR/4)) for at in self.kick_t: i = int(at*SR); j = min(self.n, i+rl) if i < self.n: env[i:j] = np.minimum(env[i:j], shape[:j-i]) mix *= boxcar(env, 320)[:, None] for c in range(2): # sub-25Hz trim mix[:, c] = mix[:, c] - boxcar(mix[:, c], int(SR/25)) mix = np.tanh(mix*1.22)/np.tanh(1.22) return mix / (np.max(np.abs(mix))+1e-9) * .94 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(" drum -> back to object.""" if bar < 4: return 0.0 if bar < 10: return 0.06 + 0.40*(bar-4)/6.0 if bar < 16: return 0.46 + 0.42*(bar-10)/6.0 if bar < 34: return 1.0 # quake + strip + surge if bar < 40: return 1.0 - 0.46*(bar-34)/6.0 return 0.52 ACID = [0, 0, 12, 0, 3, 0, 7, 0, 10, 0, 12, 7, 3, 0, 7, 10] def put_acid(s, bar, R, cut, gain=.085, density=1.0): """The 303 that threads the whole techno block. `cut` is the filter start in Hz: it opens across quake, chokes shut in the breakdown and blows wide open in the surge, so the same sixteen notes read as three different instruments.""" for st in range(16): if st % 2 and R.rand() > density: continue iv = ACID[(st + bar) % 16] s.put("acid", voice(C2*2*2**(iv/12.0), BEAT*.22, kind="saw", nh=16, c0=cut, c1=230, ck=13, res=.95, a=.003, d=.07, s=.35, r=.06, seed=bar*37+st), s.t(bar, st, .06), g=gain, pan=-.12+.24*((st//4) % 2)) def build_song(): s = Song(DUR) R = np.random.RandomState(13000) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "heart" s.put("bed", o_room(DUR, seed=1), 0.0, g=1.0) s.put("bed", o_hiss(DUR, seed=2), 0.0, g=0.85, pan=0.12) for bar in range(N_BARS): sec = sec_of(bar); T = tight_at(bar) # ── roomtone: found objects, off the grid, sparse ──────────────────── if sec == "roomtone": if bar == 0: s.put("obj", o_pen(0.52, 0.0, seed=bar), s.t(bar, 3)+0.07, g=.55, pan=-.25) if bar == 1: s.put("obj", o_splice(0.0, seed=bar), s.t(bar, 6), g=.5, pan=.3) s.put("obj", o_pen(0.40, 0.0, seed=bar+9), s.t(bar, 11), g=.42, pan=-.1) if bar == 2: s.put("obj", o_bell(1.9, 0.0, seed=bar), s.t(bar, 2), g=.55, pan=.2) if bar == 3: s.put("obj", o_thud(0.62, 0.0, seed=bar), s.t(bar, 5), g=.62, pan=-.05) s.put("obj", o_splice(0.0, seed=bar+3), s.t(bar, 13), g=.42, pan=.35) # ── objects: the grid catches them ─────────────────────────────────── elif sec == "objects": for st in (0, 8): s.put("kick", o_thud(0.62, T, seed=bar*4+st), s.t(bar, st), g=lerp(.62, .82, T)) if st == 0: s.kick_t.append(s.t(bar, st)) for st in range(2, 16, 4): s.put("hat", o_pen(0.46, T, seed=bar*7+st), s.t(bar, st, .10), g=.42, pan=-.22+.44*((st//4) % 2)) if bar % 2 == 0: s.put("ride", o_bell(1.9, T, seed=bar), s.t(bar, 6), g=.40, pan=.24) if bar >= 6: for st in (7, 14): s.put("rim", o_splice(T, seed=bar*3+st), s.t(bar, st), g=.38, pan=.30 if st == 7 else -.30) if bar >= 7: for j, iv in enumerate(BP): if j % 4: continue s.put("bass", voice(C2*2**(iv/12.0), BEAT*.30, kind="saw", nh=10, c0=520, c1=170, ck=9, res=.5, a=.004, d=.10, s=.55, r=.06, seed=bar*5+j), s.t(bar, j), g=.24) if bar == 9: s.put("fx", o_rewind(1.7, seed=bar), (bar+1)*BAR - 1.7, g=.42) # ── kit: the objects are now a drum kit ────────────────────────────── elif sec == "kit": for b2 in range(4): at = s.t(bar, b2*4) s.put("kick", o_thud(0.62, T, seed=bar*4+b2), at, g=.90) if b2 == 0: s.kick_t.append(at) for st in range(2, 16, 2): s.put("hat", o_pen(0.46, T, seed=bar*7+st), s.t(bar, st, .09), g=.30, pan=-.26+.10*((st//2) % 3)) for st in (2, 6, 10, 14): s.put("ride", o_bell(1.9, T, seed=bar*3+st), s.t(bar, st), g=.26, pan=.28) for st in (7, 11): s.put("rim", o_splice(T, seed=bar*5+st), s.t(bar, st), g=.36, pan=-.28 if st == 7 else .30) for j, iv in enumerate(BP): if j % 2 and R.rand() < .35: continue s.put("bass", voice(C2*2**(iv/12.0), BEAT*.24, kind="saw", nh=12, c0=lerp(560, 1400, (bar-10)/6.0), c1=180, ck=10, res=.6, a=.003, d=.09, s=.6, r=.05, seed=bar*11+j), s.t(bar, j), g=.30) if bar >= 13: for st in (3, 11): for k2, iv in enumerate(STAB): s.put("stab", voice(nf("C4")*2**(iv/12.0), BEAT*.32, kind="saw", nh=16, c0=1900, c1=780, ck=9, res=.5, detune=(-1.1, 1.2), a=.004, d=.09, s=.28, r=.08, seed=bar*13+st+k2), s.t(bar, st), g=.055, pan=-.30+.20*k2) if bar == 15: s.put("fx", o_rewind(2.0, seed=bar), (bar+1)*BAR - 2.0, g=.62) # ── quake: full techno ─────────────────────────────────────────────── elif sec == "quake": hard = bar >= 18 for b2 in range(4): at = s.t(bar, b2*4) s.put("kick", o_thud(0.62, 1.0, seed=bar*4+b2, sub=.5), at, g=1.0) if b2 == 0: s.kick_t.append(at) for b2 in range(4): s.put("hat", o_pen(0.46, 1.0, seed=bar*9+b2, open_h=True), s.t(bar, b2*4+2), g=.30, pan=.16) for st in range(1, 16, 2): s.put("hat", o_pen(0.46, 1.0, seed=bar*17+st), s.t(bar, st, .07), g=.16, pan=-.34+.68*R.rand()) for st in (4, 12): s.put("clap", o_splice(1.0, seed=bar*7+st), s.t(bar, st), g=.34, pan=-.08) for q in range(4): # a clap is four splices s.put("clap", o_splice(1.0, seed=bar*7+st+q+40), s.t(bar, st) + 0.009*q + 0.004*R.rand(), g=.20, pan=-.08) for st in (2, 6, 10, 14): s.put("ride", o_bell(1.9, 1.0, seed=bar*3+st), s.t(bar, st), g=.30 if hard else .22, pan=.26) for j, iv in enumerate(BP): s.put("bass", voice(C2*2**(iv/12.0), BEAT*.22, kind="saw", nh=14, c0=760+420*math.sin(bar*.8), c1=180, ck=10, res=.7, a=.003, d=.09, s=.62, r=.05, seed=bar*19+j), s.t(bar, j), g=.34) for st in (3, 11) if not hard else (3, 7, 11, 14): for k2, iv in enumerate(STAB): s.put("stab", voice(nf("C4")*2**(iv/12.0), BEAT*.34, kind="saw", nh=18, c0=2700, c1=900, ck=9, res=.55, detune=(-1.2, 1.3), a=.004, d=.10, s=.30, r=.08, seed=bar*23+st+k2), s.t(bar, st), g=.085, pan=-.32+.21*k2) if hard and bar % 2 == 0: MEL = [12, 15, 19, 22, 19, 15, 12, 10] for j in (0, 3, 6, 10, 13): iv = MEL[(bar+j) % 8] s.put("bleep", voice(nf("C4")*2**(iv/12.0), .13, kind="sine", nh=3, c0=4200, c1=2100, ck=8, a=.002, d=.06, s=.30, r=.05, seed=bar*29+j), s.t(bar, j), g=.13, pan=.26) # LAYER 1 — the acid arrives at bar 20 and opens across four bars if bar >= 20: put_acid(s, bar, R, lerp(700, 2400, (bar-20)/4.0), density=.72) # LAYER 2 — the desk bell, un-gated, left to ring under everything if bar >= 22: s.put("drone", o_bell(3.6, 0.10, seed=bar*3+5), s.t(bar, 0), g=.20, pan=-.20) s.put("drone", o_bell(3.0, 0.10, seed=bar*3+9), s.t(bar, 10), g=.13, pan=.28) if bar in (16, 22): s.put("fx", o_tear(1.15, seed=bar), bar*BAR, g=.50) # ── strip: the kick vanishes; the objects walk back in ─────────────── elif sec == "strip": q = bar - 24 # 0..3 # what is left of the kit: a thinning pen-hat, loosening as it goes for st in range(0, 16, 4): if q >= 2 and st % 8: continue s.put("hat", o_pen(0.46, lerp(1.0, .52, q/3.0), seed=bar*7+st), s.t(bar, st, .10), g=.24, pan=-.26+.17*((st//4) % 3)) # the found objects, raw again, in the hole the kick left if q < 2: s.put("obj", o_bell(2.6, 0.22, seed=bar*3), s.t(bar, 2), g=.36, pan=.26) s.put("obj", o_splice(0.14, seed=bar*5), s.t(bar, 9), g=.30, pan=-.30) s.put("obj", o_pen(0.52, 0.0, seed=bar*11), s.t(bar, 13), g=.30, pan=.12) else: s.put("obj", o_thud(0.62, 0.30, seed=bar*17), s.t(bar, 0), g=.44) # one long bass note a bar, filter crawling open s.put("bass", voice(C2*2**(BP[0]/12.0), BAR*0.92, kind="saw", nh=12, c0=lerp(360, 1500, q/3.0), c1=170, ck=1.4, res=.85, a=.02, d=.5, s=.72, r=.4, seed=bar*41), s.t(bar, 0), g=.27) # the acid never stops — it is the thread through the hole put_acid(s, bar, R, lerp(520, 1500, q/3.0), gain=.075, density=.55) # drenched stabs on the odd bars if q % 2: for k2, iv in enumerate(STAB): s.put("stab", voice(nf("C4")*2**(iv/12.0), BEAT*1.4, kind="saw", nh=18, c0=2200, c1=700, ck=2.2, res=.5, detune=(-1.2, 1.3), a=.02, d=.5, s=.35, r=.5, seed=bar*43+k2), s.t(bar, 6), g=.075, pan=-.32+.21*k2) s.put("drone", o_bell(3.4, 0.10, seed=bar*7), s.t(bar, 0), g=.16, pan=-.14) # the rebuild: a splice roll that doubles, then doubles again if q >= 2: nrl = 8 if q == 2 else 16 for j in range(nrl): s.put("rim", o_splice(1.0, seed=bar*61+j), s.t(bar, j*16.0/nrl), g=lerp(.10, .42, j/(nrl-1.0)), pan=-.32+.64*(j/(nrl-1.0))) if bar == 26: s.put("fx", o_rewind(1.7, seed=bar), s.t(bar, 8), g=.46) if bar == 27: s.put("fx", o_rewind(2.4, seed=bar), 28*BAR - 2.4, g=.78) # ── surge: the second rupture. Everything, and then more. ──────────── elif sec == "surge": q = bar - 28 # 0..5 peak = q >= 4 for b2 in range(4): at = s.t(bar, b2*4) s.put("kick", o_thud(0.62, 1.0, seed=bar*4+b2, sub=.62), at, g=1.0) if b2 == 0: s.kick_t.append(at) if peak: # doubled kick at the top for b2 in range(4): at = s.t(bar, b2*4+2) s.put("kick", o_thud(0.50, 1.0, seed=bar*4+b2+80, sub=.30), at, g=.44) for b2 in range(4): s.put("hat", o_pen(0.46, 1.0, seed=bar*9+b2, open_h=True), s.t(bar, b2*4+2), g=.32, pan=.16) for st in range(1, 16, 2): s.put("hat", o_pen(0.46, 1.0, seed=bar*17+st), s.t(bar, st, .07), g=.18, pan=-.34+.68*R.rand()) for st in (4, 12): s.put("clap", o_splice(1.0, seed=bar*7+st), s.t(bar, st), g=.36, pan=-.08) for qq in range(4): s.put("clap", o_splice(1.0, seed=bar*7+st+qq+40), s.t(bar, st) + 0.009*qq + 0.004*R.rand(), g=.21, pan=-.08) for st in (2, 6, 10, 14): s.put("ride", o_bell(1.9, 1.0, seed=bar*3+st), s.t(bar, st), g=.32, pan=.26) for j, iv in enumerate(BP): s.put("bass", voice(C2*2**(iv/12.0), BEAT*.22, kind="saw", nh=14, c0=860+460*math.sin(bar*.8), c1=180, ck=10, res=.75, a=.003, d=.09, s=.64, r=.05, seed=bar*19+j), s.t(bar, j), g=.35) for st in (3, 7, 11, 14): for k2, iv in enumerate(STAB): s.put("stab", voice(nf("C4")*2**(iv/12.0), BEAT*.34, kind="saw", nh=18, c0=2900, c1=940, ck=9, res=.58, detune=(-1.2, 1.3), a=.004, d=.10, s=.30, r=.08, seed=bar*23+st+k2), s.t(bar, st), g=.090, pan=-.32+.21*k2) # the bleep melody now every bar, and up an octave at the peak MEL2 = [12, 15, 19, 22, 19, 15, 12, 10] for j in (0, 3, 6, 10, 13): iv = MEL2[(bar+j) % 8] + (12 if peak and j in (6, 13) else 0) s.put("bleep", voice(nf("C4")*2**(iv/12.0), .13, kind="sine", nh=3, c0=4200, c1=2100, ck=8, a=.002, d=.06, s=.30, r=.05, seed=bar*29+j), s.t(bar, j), g=.14, pan=.26) put_acid(s, bar, R, lerp(1400, 3400, q/5.0), gain=.10, density=1.0) s.put("drone", o_bell(3.6, 0.10, seed=bar*3+5), s.t(bar, 0), g=.17, pan=-.20) if bar in (28, 31): s.put("fx", o_tear(1.15, seed=bar), bar*BAR, g=.52) if bar == 28: s.put("fx", o_thud(1.1, 1.0, seed=777, sub=1.0), bar*BAR, g=.9) if bar == 33: s.put("fx", o_rewind(2.0, seed=bar), 34*BAR - 2.0, g=.58) # ── after: aftershocks, the kit loosening back into objects ────────── elif sec == "after": k = 1.0 - (bar-34)/6.0 for b2 in range(4): if b2 % 2 and R.rand() > k: continue at = s.t(bar, b2*4) s.put("kick", o_thud(0.62, T, seed=bar*4+b2), at, g=.62*k+.22) if b2 == 0: s.kick_t.append(at) for st in range(2, 16, 4): if R.rand() > k*0.9: continue s.put("hat", o_pen(0.46, T, seed=bar*7+st), s.t(bar, st, .10), g=.26*k+.06, pan=-.2+.4*R.rand()) if bar % 2 == 0: s.put("ride", o_bell(1.9, T, seed=bar), s.t(bar, 6), g=.26*k+.10, pan=.22) for j, iv in enumerate(BP): if j % 4: continue if R.rand() > k: continue s.put("bass", voice(C2*2**(iv/12.0), BEAT*.30, kind="saw", nh=10, c0=440, c1=150, ck=9, res=.4, a=.004, d=.10, s=.5, r=.06, seed=bar*31+j), s.t(bar, j), g=.24*k+.05) # the aftershocks themselves: a decaying burst every two bars if bar % 2 == 0: amp = k*0.9 + 0.08 for q in range(5): s.put("shock", o_thud(0.62, 0.35, seed=bar*13+q), s.t(bar, 0) + q*0.055 + 0.02*R.rand(), g=amp*(0.9**q)*.6) s.put("shock", o_tear(1.15, seed=bar+70), s.t(bar, 0), g=amp*.30) # ── heart: one object, 65 bpm, lub-dub ─────────────────────────────── else: for b2 in range(0, 4, 2): at = s.t(bar, b2*4) s.put("heart", o_thud(0.26, 0.62, seed=bar*4+b2), at, g=.95) s.put("heart", o_thud(0.19, 0.48, seed=bar*4+b2+1), at+0.155, g=.55) s.kick_t.append(at) if bar == 40: s.put("fx", o_tear(1.15, seed=99), s.t(bar, 0)-0.55, g=.62) # the tail after the last bar keeps beating tb = N_BARS*BAR for q in range(4): at = tb + q*BEAT*2 if at + 0.5 < DUR: s.put("heart", o_thud(0.26, 0.62, seed=400+q), at, g=.95) s.put("heart", o_thud(0.19, 0.48, seed=500+q), at+0.155, g=.55) # ── voices ─────────────────────────────────────────────────────────────── s.put("vox", speak("Station nine. Zero two fourteen. Nothing to report.", "Samantha", 168, lo=380, hi=3100), BAR*1.05, g=.34, pan=-.10) chop = speak("magnitude six point two", "Samantha", 210, lo=300, hi=3400) for bar in (19, 23): s.put("vox", chop, s.t(bar, 8), g=.30, pan=.14) chop2 = speak("magnitude six point eight", "Samantha", 210, lo=300, hi=3400) for bar in (29, 32): s.put("vox", chop2, s.t(bar, 8), g=.30, pan=-.14) s.put("vox", speak("still coming", "Samantha", 190, lo=320, hi=3200), BAR*26.4, g=.26, pan=.20) s.put("vox", speak("that one is mine", "Whisper", 132, lo=200, hi=4200), BAR*42.1, g=.52, pan=.05) s.bus("stab", lambda x: reverb(delay(x, BEAT*.75, .42, .30), rt=2.4, mix=.40, seed=401)) s.bus("bleep", lambda x: reverb(delay(x, BEAT*.75, .50, .34), rt=2.8, mix=.44, seed=403)) s.bus("ride", lambda x: reverb(x, rt=1.9, mix=.26, seed=405)) s.bus("obj", lambda x: reverb(x, rt=2.6, mix=.34, seed=407)) s.bus("acid", lambda x: reverb(delay(x, BEAT*.5, .34, .22), rt=1.8, mix=.22, seed=417)) s.bus("drone", lambda x: reverb(x, rt=4.4, mix=.58, seed=419)) s.bus("shock", lambda x: reverb(x, rt=3.2, mix=.42, seed=409)) s.bus("heart", lambda x: reverb(x, rt=3.6, mix=.30, seed=411)) s.bus("vox", lambda x: reverb(x, rt=2.2, mix=.34, seed=413)) s.bus("fx", lambda x: reverb(x, rt=2.4, mix=.30, seed=415)) mix = s.mixdown(dict(bed=1.0, obj=1.0, kick=1.0, hat=1.0, ride=1.0, rim=1.0, clap=1.0, bass=1.0, stab=1.0, bleep=1.0, shock=1.0, heart=1.0, vox=1.0, fx=1.0, acid=1.0, drone=1.0), pump_depth=.28, pump_rel=.12, levels=dict(roomtone=.28, objects=.44, kit=.62, quake=1.0, strip=.62, surge=1.0, after=.52, heart=.50)) 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 < 170].sum() E["mid"][f] = sp[(fr >= 170) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].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["kick"] = 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 # ════════════════════════════════════════════════════════════════════════════ # THE CHART RECORDER — the new substrate # # `build_chart` turns the finished mix into a table of INK PIXELS: for every # paper column, the min/max of that column's ~122 audio samples becomes a # vertical run of deposited ink. Ink per pixel falls off as 1/(1+k·span) — a # racing pen leaves a pale tall stroke, a still pen pools into a fat black # one — with extra deposit at the two turning points, where a real nib # decelerates. Nothing is ever erased; `Paper` accumulates and only ever adds. # ════════════════════════════════════════════════════════════════════════════ def _defl(v): a = np.abs(v)**AMP_EXP * AMP_K return np.sign(v) * AMP_CLIP*np.tanh(a/AMP_CLIP) def _deposit(xx, y0, y1): """Turn column extents into deposited ink pixels. Ink per pixel falls off with the span the nib had to cover; the two turning points get extra, because that is where it decelerated; a nearly still nib bleeds sideways and pools.""" iy0 = np.floor(np.minimum(y0, y1)).astype(np.int64) iy1 = np.ceil(np.maximum(y0, y1)).astype(np.int64) span = np.maximum(1, iy1-iy0) stalled = span <= si(4) bleed = max(1, si(1)) # sideways bleed of a still nib iy0 = iy0 - stalled*bleed span = span + stalled*2*bleed # 0.105 is divided by S so that a stroke of the same PHYSICAL height gets # the same ink per pixel at either resolution — line darkness is preserved # while the line itself is 1.5x more pixels wide. per = INK_FLOW/(1.0 + (0.105/S)*span) * np.where(stalled, 2.1, 1.0) off = np.arange(span.sum()) - np.repeat(np.cumsum(span)-span, span) px = np.repeat(xx, span).astype(np.int64) py = np.repeat(iy0, span) + off ink = np.repeat(per, span).astype(np.float32) # the turning points get extra ink because the nib decelerated there. That # is a PHYSICAL extent, so at 1080p the same extra ink is spread over 1.5 # pixels via a ramp rather than dumped on one — at S == 1 the ramp collapses # to exactly the original "x2.5 on the two end pixels". dist = np.minimum(off, np.repeat(span, span)-1-off) endw = np.clip(sf(1.0) - dist, 0.0, 1.0) ink = (ink * (1.0 + 1.5*endw)).astype(np.float32) return px, py, ink, span def _pre_ink(): """The hours of chart already on the drum when the film starts. Same depositing rules, so the old rows are the same kind of object.""" rng = np.random.RandomState(31337) xs = np.arange(PW) minute = ((xs % MARK_EVERY) < COLS).astype(np.float64) hour = (xs < COLS).astype(np.float64) FL, IK = [], [] ksm = max(1, si(5)) # smoothing kernel, in paper px for r in range(PRE_ROWS): base = r*ROWPITCH + ROWPITCH*0.5 v = np.abs(np.convolve(rng.normal(0, 1, PW), np.ones(ksm)/ksm, "same")) amp = sf(1.5 + 0.9*rng.rand()) if r in (1, 3): # two small events, earlier tonight g = np.exp(-((xs - PW*(0.24+0.46*rng.rand()))/(PW*0.017))**2) amp = amp + g*sf(rng.uniform(9, 20)) y0 = base - v*amp - minute*sf(11) - hour*sf(8) y1 = base + v*amp - minute*sf(2) px, py, ink, _ = _deposit(xs, y0, y1) ok = (py >= 0) & (py < PH) FL.append((py[ok]*PW + px[ok]).astype(np.int32)); IK.append(ink[ok]) return np.concatenate(FL), np.concatenate(IK) def build_chart(mono): total = N_FRAMES * COLS spc = SR/(FPS*COLS) need = int(total*spc) + 8 x = np.pad(mono, (0, max(0, need-len(mono))))[:need] b = (np.arange(total)*spc).astype(np.int64) if S == 1.0: cmin = np.minimum.reduceat(x, b) cmax = np.maximum.reduceat(x, b) else: # A column's ink height is the min/max of the audio the nib saw while # it was over that column. If that aperture shrank with COLS, a finer # paper would resolve the waveform better and draw a THINNER trace — # the chart would lose its ink density. So the aperture is PINNED to # the 720p column duration (~2.8 ms) and the columns overlap instead; # cmin/cmax then sample the same function of time 1.5x more densely, # and _defl's built-in S makes every extent exactly 1.5x its 720p self. WIN = int(round(SR/(FPS*12))) st = np.clip(b + int(spc/2) - WIN//2, 0, len(x)-WIN) cmin = np.empty(total); cmax = np.empty(total) ar = np.arange(WIN) for i0 in range(0, total, 8192): # chunked: the gather is big sl = slice(i0, min(total, i0+8192)) w = x[st[sl][:, None] + ar[None, :]] cmin[sl] = w.min(1); cmax[sl] = w.max(1) frame = np.arange(total)//COLS origin = np.where(frame < TEAR_F, 0, TEAR_F) sheet = (frame >= TEAR_F).astype(np.int8) lf = frame - origin row = np.minimum(lf//REV_FRAMES + np.where(sheet == 0, PRE_ROWS, 0), ROWS-1) xx = (lf % REV_FRAMES)*COLS + (np.arange(total) % COLS) base = row*ROWPITCH + ROWPITCH*0.5 y0 = base + _defl(cmin); y1 = base + _defl(cmax) # minute marks: the pen kicks up a notch every 1/6 revolution minute = (xx % MARK_EVERY) < COLS hour = xx < COLS y0 = y0 - minute*sf(11.0) - hour*sf(8.0) y1 = y1 - minute*sf(2.0) px, py, ink, span = _deposit(xx, y0, y1) fr = np.repeat(frame, span).astype(np.int32) sh = np.repeat(sheet, span) ok = (py >= 0) & (py < PH) px, py, ink, fr, sh = px[ok], py[ok], ink[ok], fr[ok], sh[ok] flat = (py*PW + px).astype(np.int32) # the pre-existing chart is deposited at frame 0, on sheet 0 pf, pi = _pre_ink() flat = np.concatenate([pf, flat]) ink = np.concatenate([pi, ink]).astype(np.float32) fr = np.concatenate([np.zeros(len(pf), np.int32), fr]) sh = np.concatenate([np.zeros(len(pf), np.int8), sh]) counts = np.bincount(fr, minlength=N_FRAMES) fidx = np.concatenate([[0], np.cumsum(counts)]).astype(np.int64) # per-frame pen state, for the arm and the macro camera fmax = np.zeros(N_FRAMES); fx = np.zeros(N_FRAMES); frow = np.zeros(N_FRAMES, np.int32) c0 = np.arange(N_FRAMES)*COLS fx[:] = xx[c0 + COLS//2] frow[:] = row[c0] # The pen's visible swing is a SIGNED, largely self-cancelling mean of the # per-column extremes, so its magnitude goes as 1/sqrt(number of columns) — # averaging COLS terms would shrink the arm's throw at 1080p rather than # scale it. It gets its own fixed 12-columns-per-frame grid at the 720p # aperture, which _defl then scales by exactly S. At S == 1 this grid IS # the column grid, so the result is bit-for-bit the original. spc12 = SR/(FPS*12) b12 = (np.arange(N_FRAMES*12)*spc12).astype(np.int64) d0 = np.minimum.reduceat(x, b12); d1 = np.maximum.reduceat(x, b12) dev = _defl(np.where(np.abs(d1) > np.abs(d0), d1, d0)) fmax[:] = dev.reshape(N_FRAMES, 12).mean(1) np.savez(CHART_NPZ, flat=flat, ink=ink, sheet=sh, fidx=fidx, pen_x=fx, pen_row=frow, pen_d=fmax) return dict(flat=flat, ink=ink, sheet=sh, fidx=fidx) _CH = {} def chart(): if not _CH: z = np.load(CHART_NPZ) for k in z.files: _CH[k] = z[k] # paper coordinates, kept for the macro rasteriser _CH["px"] = (_CH["flat"] % PW).astype(np.int32) _CH["py"] = (_CH["flat"] // PW).astype(np.int32) return _CH def ink_window(frame, x0, y0, mw, mh): """Every ink pixel inside a paper window that has been laid down by `frame`. The stroke table is ordered by frame, so 'already written' is a slice, not a search.""" c = chart() lim = int(c["fidx"][min(frame+1, N_FRAMES)]) px = c["px"][:lim]; py = c["py"][:lim] m = ((px >= x0) & (px < x0+mw) & (py >= y0) & (py < y0+mh) & (c["sheet"][:lim] == (1 if frame >= TEAR_F else 0))) return px[m]-x0, py[m]-y0, c["ink"][:lim][m] class Paper: """Two sheets of ink density (before and after the tear), accumulated.""" def __init__(self, upto): c = chart() self.buf = [np.zeros(PH*PW, np.float32), np.zeros(PH*PW, np.float32)] k = int(c["fidx"][min(upto, N_FRAMES)]) f, i, s = c["flat"][:k], c["ink"][:k], c["sheet"][:k] for sh in (0, 1): m = s == sh if m.any(): self.buf[sh] += np.bincount(f[m], weights=i[m], minlength=PH*PW).astype(np.float32) self.k = k def advance(self, frame): c = chart() k2 = int(c["fidx"][min(frame+1, N_FRAMES)]) if k2 > self.k: f = c["flat"][self.k:k2]; i = c["ink"][self.k:k2]; s = c["sheet"][self.k:k2] for sh in (0, 1): m = s == sh if m.any(): np.add.at(self.buf[sh], f[m], i[m]) self.k = k2 def sheet(self, frame): return self.buf[1 if frame >= TEAR_F else 0].reshape(PH, PW) # ── paper stock: fibre, cream, printed grid ───────────────────────────────── def _vnoise(h, w, scale, seed): rng = np.random.RandomState(seed) gh, gw = int(h/scale)+2, int(w/scale)+2 g = rng.rand(gh, gw) ys = np.linspace(0, gh-1-1e-3, h); xs = np.linspace(0, gw-1-1e-3, w) y0 = ys.astype(int); x0 = xs.astype(int) fy = (ys-y0)[:, None]; fx = (xs-x0)[None, :] sy = fy*fy*(3-2*fy); sx = fx*fx*(3-2*fx) return ((g[np.ix_(y0, x0)]*(1-sx) + g[np.ix_(y0, x0+1)]*sx)*(1-sy) + (g[np.ix_(y0+1, x0)]*(1-sx) + g[np.ix_(y0+1, x0+1)]*sx)*sy) _PB = {} def paper_stock(): """(PH, PW, 3) float — aged chart paper with printed salmon grid.""" if "b" not in _PB: rng = np.random.RandomState(20260826) fib = (_vnoise(PH, PW, sf(2.4), 11)*0.55 + _vnoise(PH, PW, sf(9.0), 12)*0.30 + _vnoise(PH, PW, sf(40.0), 13)*0.55) fib = (fib - fib.mean()) streak = _vnoise(PH, PW, sf(1.2), 14) base = np.zeros((PH, PW, 3), np.float32) cream = np.array([196, 181, 152], np.float32) for c in range(3): base[..., c] = cream[c] + fib*30 + (streak-0.5)*8 + rng.normal(0, 1.8, (PH, PW)) # printed grid: salmon, fine every MARK/10, minute lines, hour line gx = np.arange(PW) fine = (gx % (MARK_EVERY//10) == 0) minute = (gx % MARK_EVERY == 0) grid = np.zeros(PW, np.float32) grid[fine] = 0.34; grid[minute] = 0.84; grid[0] = 1.0 salmon = np.array([168, 100, 80], np.float32) for c in range(3): base[..., c] = base[..., c]*(1-grid*0.52)[None, :] + \ (salmon[c]*grid*0.52)[None, :] gy = np.arange(PH) rowline = np.zeros(PH, np.float32) rowline[(gy % ROWPITCH) == ROWPITCH//2] = 0.44 rowline[(gy % ROWPITCH) == 0] = 0.20 for c in range(3): base[..., c] = base[..., c]*(1-rowline*0.44)[:, None] + \ (salmon[c]*rowline*0.44)[:, None] _PB["b"] = np.clip(base, 0, 255) return _PB["b"] _MF = {} def macro_fibre(h): if h not in _MF: _MF[h] = _vnoise(h, W, sf(3.4), 31).astype(np.float32) return _MF[h] INK_RGB = np.array([14, 16, 27], np.float32) def ink_over(bg, ink): """Composite deposited ink over paper. Slight bleed, as on real stock.""" a = 1.0 - np.exp(-1.55*ink) bl = np.asarray(Image.fromarray((a*255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(sf(1.1))), np.float32)/255.0 a = np.clip(a + bl*0.34, 0, 1)[..., None] return bg*(1-a) + INK_RGB*a # ════════════════════════════════════════════════════════════════════════════ # STAGE + CAMERA # ════════════════════════════════════════════════════════════════════════════ SW_S, SH_S = 1920, 1080 # r2: 16:9 stage (was 1680x1080) XO = (SW_S - 1680) // 2 # every room object shifts right by this SWR, SHR = si(SW_S), si(SH_S) # the stage BITMAP (2880x1620 @1080p) ASPECT = W / H SHOT_W = {"wide": 1.00, "full": 0.80, "mid": 0.58, "ots": 0.46, "cu": 0.32, "ins": 0.22, "macro": 0.15} def _sxy(xy): """Scale a Pillow xy argument — flat [x0,y0,x1,y1] or [(x,y), ...].""" if len(xy) and isinstance(xy[0], (list, tuple)): return [(v[0]*S, v[1]*S) for v in xy] return [v*S for v in xy] _SDRAW_COORD = {"line", "rectangle", "rounded_rectangle", "ellipse", "polygon", "arc", "chord", "pieslice", "point", "text", "multiline_text"} class SDraw: """An ImageDraw proxy for the STAGE. Every room object in this film is authored in base 1920x1080 stage coordinates. Rather than hand-scaling several hundred literals, the stage bitmap is allocated at SWR x SHR and every draw call passes through here, which multiplies coordinates and stroke widths by S on the way to Pillow. Font sizes are already scaled by `font()`, so base sizes work unchanged. At S == 1.0 this is a transparent pass-through. """ __slots__ = ("_d",) def __init__(self, im): object.__setattr__(self, "_d", ImageDraw.Draw(im)) def __getattr__(self, name): f = getattr(object.__getattribute__(self, "_d"), name) if S == 1.0 or name not in _SDRAW_COORD: return f def wrapped(xy, *a, **kw): w = kw.get("width") if isinstance(w, (int, float)): kw["width"] = max(1, int(round(w*S))) return f(_sxy(xy), *a, **kw) return wrapped def stage_arr(rgb): """A blank stage as float32 (SHR, SWR, 3), plus base-space coord grids.""" a = np.zeros((SHR, SWR, 3), np.float32) a[:] = np.array(rgb, np.float32) return a def stage_grid(): """(yy, xx) over the stage bitmap, expressed in BASE stage coordinates.""" yy, xx = np.mgrid[0:SHR, 0:SWR].astype(np.float32) return yy/S, xx/S def _box(anchors, shot): kind, _, target = shot.partition("_") fw = SHOT_W.get(kind, 1.0) cx, cy = anchors.get(target or "_", anchors.get("_", (SW_S/2, SH_S/2))) if kind == "wide": cx, cy = SW_S/2, SH_S/2 elif kind == "full": cx = (cx + SW_S/2)/2; cy = (cy + SH_S/2)/2 bw = SW_S*fw; bh = bw/ASPECT if bh > SH_S: bh = SH_S; bw = bh*ASPECT return cx, cy, bw, bh def cam_box(anchors, shot, f01, jseed=0, push=0.04, drift=1.0, shake=0.0): if ">" in shot: a, b = shot.split(">", 1) ca = _box(anchors, a.strip()); cb = _box(anchors, b.strip()) e = ease_io(min(max(f01, 0.0), 1.0)) cx, cy, bw, bh = (ca[i] + (cb[i]-ca[i])*e for i in range(4)) else: cx, cy, bw, bh = _box(anchors, shot) k = 1.0 - push*f01 bw *= k; bh *= k fw = bw/SW_S jx = math.sin(f01*1.525 + jseed)*8*(1-fw*.6)*drift jy = math.cos(f01*1.175 + jseed*1.7)*6*(1-fw*.6)*drift if shake: jx += math.sin(f01*137.0 + jseed*3.1)*shake*26 jy += math.cos(f01*109.0 + jseed*2.3)*shake*20 x0 = cx - bw/2 + jx; y0 = cy - bh/2 + jy x0 = max(0, min(SW_S-bw, x0)); y0 = max(0, min(SH_S-bh, y0)) return (int(x0), int(y0), int(x0+bw), int(y0+bh)) def shoot(stage_img, anchors, shot, f01, jseed=0, push=0.04, drift=1.0, shake=0.0): # cam_box works in base stage coordinates; the crop happens on the (S x) # stage bitmap, so a given shot size is 1.5x more real pixels at 1080p. box = tuple(si(v) for v in cam_box(anchors, shot, f01, jseed, push, drift, shake)) return stage_img.crop(box).resize((W, H), Image.LANCZOS) # ── the room ───────────────────────────────────────────────────────────────── DARK = (13, 15, 21) STEEL = (58, 63, 72) STEEL_D = (34, 38, 46) BRASS = (168, 132, 68) BRASS_L = (222, 186, 108) LAMP = (255, 198, 116) PALE = (206, 210, 216) DIMTXT = (128, 140, 152) ALARM = (222, 84, 62) _LF = {} def lamp_field(lx=430.0+XO, ly=170.0, k=1.0): key = (lx, ly, k) if key not in _LF: yy, xx = np.mgrid[0:SH_S, 0:SW_S].astype(np.float32) r = np.sqrt(((xx-lx)/1180)**2 + ((yy-ly)/900)**2) f = np.clip(1.22 - 0.86*r**1.35, 0.16, 1.30)*k _LF[key] = f[..., None].astype(np.float32) return _LF[key] # ── the drum: cylindrical sampling map ─────────────────────────────────────── DRUM_CX = 858 + XO DRUM_HW = 434 # half-width of the visible drum face DRUM_TOP = 186 # paper top, on stage PDH = 648 # displayed height of the paper VIS_FRAC = 0.44 # fraction of the circumference in view CAP_RY = 42 def dy_of(py): return DRUM_TOP + py*(PDH/PH) _CM = {} def drum_map(): """screen column -> (paper offset from the pen, lambert shading).""" if "o" not in _CM: # sampled at the real bitmap width of the drum face, so the paper is # resolved at the resolution it is actually displayed at u = np.linspace(-1, 1, si(DRUM_HW*2)) th = u*(math.pi/2) off = np.sin(th)*(0.5*VIS_FRAC*PW) shade = np.cos(th)**0.85*0.90 + 0.10 shade = shade + 0.26*np.exp(-((u+0.26)/0.17)**2) # sheen _CM["o"] = off; _CM["s"] = shade.astype(np.float32) return _CM["o"], _CM["s"] def draw_drum(paper, frame, e, alarm=0.0): """Compose the whole machine onto the stage. Returns (Image, anchors).""" c = chart() px_pen = float(c["pen_x"][min(frame, N_FRAMES-1)]) ink = paper.sheet(frame) bg = paper_stock() off, shade = drum_map() cols = np.mod(np.round(px_pen + off).astype(np.int64), PW) face_ink = ink[:, cols] face_bg = bg[:, cols, :] face = ink_over(face_bg, face_ink) * shade[None, :, None] face = np.asarray(Image.fromarray(np.clip(face, 0, 255).astype(np.uint8)) .resize((si(DRUM_HW*2), si(PDH)), Image.LANCZOS), np.float32) stage = stage_arr(DARK) # back wall, lit from the desk lamp off to the left yy, xx = stage_grid() rr = np.sqrt(((xx-140-XO)/1360)**2 + ((yy-90)/1000)**2) stage += (np.clip(1.20-rr, 0, 1)[..., None] * np.array([40, 32, 23], np.float32)) x0 = DRUM_CX - DRUM_HW B = DRUM_TOP + PDH # drum bottom, on stage im = Image.fromarray(np.clip(stage, 0, 255).astype(np.uint8)) d = SDraw(im) # a shelf of past charts on the back wall, out of focus for q in range(6): # r2: two more boxes for the wider wall d.rectangle([40+q*112, 120, 130+q*112, 300], fill=(46, 42, 38), outline=(72, 64, 54), width=2) d.line([20, 316, 700, 316], fill=(74, 66, 56), width=6) # plinth d.rectangle([x0-120, B+52, x0+DRUM_HW*2+120, SH_S], fill=(25, 27, 33), outline=(62, 66, 76), width=4) d.rectangle([x0-120, B+52, x0+DRUM_HW*2+120, B+74], fill=(54, 58, 68)) d.rectangle([x0-44, B+112, x0+250, B+156], fill=(38, 34, 28), outline=BRASS, width=2) d.text((x0-32, B+120), "SEISMO · TYPE 4B", font=font(23), fill=(150, 128, 92)) for fx2 in (x0-90, x0+DRUM_HW*2+50): # dial faces on the plinth d.ellipse([fx2, B+108, fx2+56, B+164], fill=(30, 32, 38), outline=BRASS, width=3) aa = math.radians(210 + 240*min(1.0, e["rms"])) d.line([fx2+28, B+136, fx2+28+math.cos(aa)*20, B+136+math.sin(aa)*20], fill=ALARM, width=3) stage2 = np.asarray(im, np.float32) stage2[si(DRUM_TOP):si(DRUM_TOP)+si(PDH), si(x0):si(x0)+si(DRUM_HW*2)] = face im = Image.fromarray(np.clip(stage2, 0, 255).astype(np.uint8)) d = SDraw(im) # drum caps — brass, with radial spokes proving the rotation rot = (px_pen/PW)*math.tau for cy, lit in ((DRUM_TOP, True), (B, False)): d.ellipse([x0, cy-CAP_RY, x0+DRUM_HW*2, cy+CAP_RY], fill=(28, 28, 32) if not lit else (52, 47, 40), outline=BRASS, width=5) for q in range(8): a = rot + q*math.tau/8 d.line([DRUM_CX, cy, DRUM_CX+math.cos(a)*DRUM_HW*0.94, cy+math.sin(a)*CAP_RY*0.94], fill=BRASS if q % 2 else (104, 82, 44), width=2) d.ellipse([DRUM_CX-26, cy-13, DRUM_CX+26, cy+13], fill=STEEL, outline=BRASS_L, width=3) # the wrap seam seam = int((PW - px_pen) % PW) su = np.abs(off - (seam if seam < PW/2 else seam-PW)) if su.min() < sf(30): # threshold is in paper px sx = x0 + int(np.argmin(su)*(DRUM_HW*2)/len(off)) d.line([sx, DRUM_TOP, sx, B], fill=(148, 122, 92), width=2) # side posts + the leadscrew the carriage rides down for rx in (x0-34, x0+DRUM_HW*2+34): d.rectangle([rx-11, DRUM_TOP-92, rx+11, B+56], fill=STEEL, outline=STEEL_D, width=2) d.rectangle([rx-21, DRUM_TOP-108, rx+21, DRUM_TOP-92], fill=(74, 78, 88)) lsx = x0+DRUM_HW*2+34 for ty in range(DRUM_TOP-80, B+50, 15): d.line([lsx-11, ty, lsx+11, ty+7], fill=(96, 100, 110), width=2) d.line([x0-34, DRUM_TOP-100, lsx, DRUM_TOP-100], fill=(88, 92, 102), width=9) # the carriage + pen arm + counterweight row = int(c["pen_row"][min(frame, N_FRAMES-1)]) dev = float(c["pen_d"][min(frame, N_FRAMES-1)]) ky = PDH/PH car_y = dy_of(row*ROWPITCH + ROWPITCH*0.5) ny = car_y + dev*ky cyx = lsx d.rectangle([cyx-24, car_y-28, cyx+24, car_y+28], fill=STEEL_D, outline=BRASS, width=3) d.line([cyx, car_y, DRUM_CX, ny], fill=(186, 190, 198), width=7) d.line([cyx, car_y, DRUM_CX, ny], fill=(238, 242, 248), width=2) # counterweight, opposite the deflection bw_x = cyx + 132; bw_y = car_y - dev*ky*0.42 d.line([cyx, car_y, bw_x, bw_y], fill=(150, 156, 164), width=5) d.ellipse([bw_x-24, bw_y-24, bw_x+24, bw_y+24], fill=(80, 68, 44), outline=BRASS_L, width=3) # ink reservoir + capillary d.rectangle([cyx+36, car_y-104, cyx+80, car_y-46], fill=(22, 24, 34), outline=BRASS, width=2) d.rectangle([cyx+40, car_y-72, cyx+76, car_y-50], fill=(30, 32, 48)) d.line([cyx+58, car_y-46, cyx+12, car_y-8], fill=(60, 62, 74), width=3) # the nib d.polygon([(DRUM_CX+16, ny-10), (DRUM_CX+16, ny+10), (DRUM_CX-3, ny)], fill=(228, 232, 238), outline=(140, 148, 158)) d.ellipse([DRUM_CX-7, ny-5, DRUM_CX+3, ny+5], fill=(16, 18, 28)) if alarm > 0.02: g2 = min(1.0, alarm) d.ellipse([x0-170, DRUM_TOP-160, x0-104, DRUM_TOP-94], fill=tuple(int(v*g2) for v in ALARM), outline=(90, 60, 50), width=3) anchors = {"_": (DRUM_CX, DRUM_TOP+PDH*0.5), "nib": (DRUM_CX, ny), "cap": (DRUM_CX, DRUM_TOP), "arm": (cyx+50, car_y), "row": (DRUM_CX, car_y), "base": (DRUM_CX, B+70)} return im, anchors # ════════════════════════════════════════════════════════════════════════════ # ENGINES # ════════════════════════════════════════════════════════════════════════════ class Drum: """The machine, front on. The trace scrolls as the drum turns.""" CAMS = ["wide", "wide>full_row", "full_row", "mid_nib", "full_cap>mid_nib", "mid_row", "cu_nib>full_row"] def __init__(self, shot, rng, paper): self.i0 = shot.i0; self.sec = shot.section; self.paper = paper pool = self.CAMS if shot.section not in HOT else \ ["mid_nib", "cu_nib", "full_row", "wide", "mid_row", "cu_nib>mid_row"] self.cam = str(pool[int(rng.integers(len(pool)))]) def frame(self, k, u, e): i = self.i0+k al = e["low"]*1.1 if self.sec in HOT else 0.0 im, anchors = draw_drum(self.paper, i, e, alarm=al) sh = (e["low"]*0.9) if self.sec in HOT else 0.0 return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0, push=0.05, shake=sh), np.float32) class Nib: """Macro on the nib. Strokes are re-rasterised at 7x from the ink table, so the ink is sharp and the fibre is full resolution.""" ZOOM = {"roomtone": 330, "objects": 250, "kit": 200, "quake": 150, "strip": 230, "surge": 140, "after": 200, "heart": 380} def __init__(self, shot, rng, paper): self.i0 = shot.i0; self.sec = shot.section; self.paper = paper # window in PAPER px: scales with the buffer, so the macro shot frames # the same physical patch of chart and `sc` below is resolution-free self.MW = si(self.ZOOM[shot.section]) self.MH = int(self.MW*(H/W)) self.lead = float(rng.uniform(0.56, 0.70)) def frame(self, k, u, e): i = min(self.i0+k, N_FRAMES-1) c = chart() sx = float(c["pen_x"][i]); row = int(c["pen_row"][i]); dev = float(c["pen_d"][i]) x0 = int(max(0, min(PW-self.MW, sx - self.lead*self.MW))) yc = row*ROWPITCH + ROWPITCH*0.5 # always centred on the nib, even where that runs off the top of the # sheet — past the paper edge you are looking at the drum's metal y0 = int(round(yc + dev*0.72 - self.MH*0.5)) sc = W/self.MW BH = int(self.MH*sc) # ink first, at paper resolution, then blown up: ink soaking into # fibre is soft-edged, so a nearest blow-up plus a blur is truer # than an interpolated line — and 60x faster than splatting. gx, gy, vv = ink_window(i, x0, y0, self.MW, self.MH) small = np.zeros(self.MH*self.MW, np.float32) if len(gx): np.add.at(small, gy.astype(np.int64)*self.MW + gx.astype(np.int64), vv) small = small.reshape(self.MH, self.MW) can = np.asarray(Image.fromarray( np.clip(small*20, 0, 255).astype(np.uint8)).resize( (W, BH), Image.NEAREST).filter( ImageFilter.GaussianBlur(max(1.0, sc*0.32))), np.float32)/20.0 # background: paper stock, magnified, plus full-res fibre on top bgc = np.zeros((self.MH, self.MW, 3), np.float32) bgc[:] = np.array((36, 34, 36), np.float32) # drum metal sy0, sy1 = max(0, y0), min(PH, y0+self.MH) if sy1 > sy0: bgc[sy0-y0:sy1-y0] = paper_stock()[sy0:sy1, x0:x0+self.MW] for edge in (0-y0, PH-y0): # the sheet edge if 0 <= edge < self.MH: bgc[edge] = np.array((214, 202, 178), np.float32) bg = np.asarray(Image.fromarray(bgc.astype(np.uint8)).resize( (W, BH), Image.LANCZOS), np.float32) bg = np.clip(bg + (macro_fibre(BH)[..., None]-0.5)*26, 0, 255) arr = ink_over(bg, can*1.45) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) if BH >= H: im = im.crop((0, (BH-H)//2, W, (BH-H)//2+H)) else: im = im.resize((W, H), Image.LANCZOS) dy0 = (BH-H)//2 if BH >= H else 0 d = ImageDraw.Draw(im) # the nib itself, sitting in the fresh ink (frame-space px -> x S) nx = (sx-x0)*sc; ny = (yc+dev-y0)*sc - dy0 d.polygon([(nx+sf(150), ny-sf(104)), (nx+sf(150), ny+sf(104)), (nx-sf(6), ny)], fill=(206, 212, 222), outline=(92, 100, 112)) d.polygon([(nx+sf(150), ny-sf(30)), (nx+sf(150), ny+sf(30)), (nx+sf(10), ny)], fill=(126, 134, 146)) d.ellipse([nx-sf(18), ny-sf(18), nx+sf(20), ny+sf(20)], fill=(18, 20, 30)) # wet gloss on the newest ink gl = Image.new("RGBA", (W, H), (0, 0, 0, 0)); gd = ImageDraw.Draw(gl) gd.ellipse([nx-sf(34), ny-sf(30), nx+sf(8), ny+sf(12)], fill=(190, 210, 235, 76)) gd.ellipse([nx-sf(14), ny-sf(14), nx+sf(4), ny+sf(2)], fill=(226, 238, 252, 110)) im = Image.alpha_composite(im.convert("RGBA"), gl).convert("RGB") return np.asarray(im, np.float32) class Arm: """The galvanometer: coil, magnet, pivot, damping vane, counterweight.""" def __init__(self, shot, rng, paper): self.i0 = shot.i0; self.sec = shot.section; self.paper = paper self.cam = str(["wide", "wide>full_coil", "full_paper>full_coil", "wide", "full_paper", "full_coil>cu_pivot" ][int(rng.integers(6))]) def frame(self, k, u, e): i = min(self.i0+k, N_FRAMES-1) c = chart() dev = float(c["pen_d"][i]); t = i/FPS stage = stage_arr((11, 13, 18)) yy2, xx2 = stage_grid() rr2 = np.sqrt(((xx2-320-XO)/1200)**2 + ((yy2-420)/900)**2) stage += np.clip(1.15-rr2, 0, 1)[..., None]*np.array([36, 29, 21], np.float32) # the drum surface, seen edge-on from close: real paper, real trace stripw = 620 # base stage px sx0 = int(float(c["pen_x"][i])) - si(160) # paper px cols2 = np.mod(np.arange(sx0, sx0+si(300)), PW) strip_ink = self.paper.sheet(i)[:, cols2] strip = ink_over(paper_stock()[:, cols2, :], strip_ink) strip = np.asarray(Image.fromarray(np.clip(strip, 0, 255).astype(np.uint8)) .resize((si(stripw), si(980)), Image.LANCZOS), np.float32) # cylindrical falloff across the visible strip uu = np.linspace(-1, 1, si(stripw)) strip *= (np.cos(uu*1.05)**0.7*0.44+0.60)[None, :, None] stage[si(60):si(60)+si(980), si(150+XO):si(150+XO)+si(stripw)] = strip im = Image.fromarray(np.clip(stage, 0, 255).astype(np.uint8)) d = SDraw(im) d.line([150+XO+stripw, 26, 150+XO+stripw, 1054], fill=(84, 70, 52), width=10) d.line([144+XO, 26, 144+XO, 1054], fill=(84, 70, 52), width=10) # magnet block + coil mx, my = 1318+XO, 552 d.rectangle([mx-140, my-190, mx+140, my+190], fill=(40, 43, 52), outline=(96, 102, 112), width=4) d.rectangle([mx-140, my-190, mx-70, my+190], fill=(72, 40, 40)) d.rectangle([mx+70, my-190, mx+140, my+190], fill=(40, 56, 74)) # the coil hums with the low end gl = int(60 + 150*e["low"]) for q in range(9): yy = my-120 + q*30 d.line([mx-58, yy, mx+58, yy], fill=(gl, int(gl*0.72), 48), width=4) d.text((mx-130, my+206), "MOVING COIL", font=font(24), fill=DIMTXT) # pivot + arm pvx, pvy = mx, my ang = math.atan2(dev*2.4, 620) ex = pvx - 860*math.cos(ang); ey = pvy + 860*math.sin(ang) d.line([pvx, pvy, ex, ey], fill=(190, 196, 204), width=8) d.line([pvx, pvy, ex, ey], fill=(238, 242, 248), width=2) bx = pvx + 210*math.cos(ang); by = pvy - 210*math.sin(ang) d.line([pvx, pvy, bx, by], fill=(150, 156, 164), width=6) d.ellipse([bx-34, by-34, bx+34, by+34], fill=(78, 66, 44), outline=BRASS_L, width=4) d.ellipse([pvx-26, pvy-26, pvx+26, pvy+26], fill=STEEL, outline=BRASS_L, width=4) # damping vane in its oil bath vx = pvx + 120*math.cos(ang); vy = pvy - 120*math.sin(ang) d.rectangle([vx-16, vy-46, vx+16, vy+46], fill=(52, 58, 68), outline=(96, 102, 112), width=2) d.rectangle([pvx+86, pvy+60, pvx+156, pvy+140], fill=(20, 30, 38), outline=(70, 84, 96), width=3) # nib end d.polygon([(ex+24, ey-8), (ex+24, ey+8), (ex+2, ey)], fill=(226, 230, 236)) # the readout is a physical quantity, so it is reported in BASE px: # the same instant shows the same "mm" at either resolution d.text((SW_S-430, 60), f"DEFLECTION {abs(dev)/S:5.1f} mm", font=font(26), fill=PALE) d.line([SW_S-440, 100, SW_S-440+int(min(1, abs(dev)/AMP_CLIP)*380), 100], fill=ALARM if abs(dev)/S > 60 else BRASS_L, width=8) anchors = {"_": (840+XO, 540), "coil": (mx, my), "pivot": (pvx, pvy), "paper": (460+XO, 552), "nib": (ex, ey)} sh = (e["low"]*0.8) if self.sec in HOT else 0.0 return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0, push=0.04, shake=sh), np.float32) class Desk: """The operator's desk. He sleeps; the quake does not wake him at first.""" def __init__(self, shot, rng, paper): self.i0 = shot.i0; self.sec = shot.section self.cam = str(["full_head", "mid_head", "wide", "wide>mid_head", "full_head>mid_rec", "mid_head"][int(rng.integers(6))]) def frame(self, k, u, e): i = min(self.i0+k, N_FRAMES-1); t = i/FPS im = Image.new("RGB", (SWR, SHR), (9, 11, 16)); d = SDraw(im) # lamp cone lx, ly = 430+XO, 150 d.polygon([(lx, ly+40), (lx-460, SH_S), (lx+640, SH_S)], fill=(52, 42, 30)) d.polygon([(lx, ly+46), (lx-260, SH_S), (lx+380, SH_S)], fill=(76, 60, 40)) # desk dy = 760 d.rectangle([0, dy, SW_S, SH_S], fill=(62, 46, 32)) d.polygon([(lx-240, dy), (lx+360, dy), (lx+470, SH_S), (lx-350, SH_S)], fill=(88, 66, 44)) d.line([0, dy, SW_S, dy], fill=(132, 100, 66), width=6) # the recorder, small, behind d.rectangle([1140+XO, 470, 1600+XO, dy], fill=(30, 33, 40), outline=(78, 84, 94), width=4) d.ellipse([1200+XO, 500, 1540+XO, 580], fill=(52, 48, 42), outline=BRASS, width=3) d.rectangle([1200+XO, 540, 1540+XO, 730], fill=(196, 186, 164)) for q in range(int(i/REV_FRAMES)+1): y2 = 560 + q*9 if y2 > 720: break rr = np.random.RandomState(4242+q) # stable per row amp = 14 if (q >= 8 and i >= int(16*BAR*FPS)) else 2 xs = np.arange(1206+XO, 1536+XO, 3) pts = [(int(x), int(y2 + rr.uniform(-amp, amp))) for x in xs] d.line(pts, fill=(40, 42, 52), width=1) # lamp d.line([lx, 0, lx, ly], fill=(70, 74, 82), width=8) d.polygon([(lx-96, ly+44), (lx+96, ly+44), (lx+52, ly-26), (lx-52, ly-26)], fill=(58, 62, 70), outline=(120, 126, 134)) gl2 = int(200 + 40*e["high"]) d.ellipse([lx-40, ly+24, lx+40, ly+62], fill=(gl2, int(gl2*0.78), int(gl2*0.44))) # the operator: head on folded arms, rim-lit by the lamp hx = 620 + XO woke = 0.0 if self.sec == "quake": woke = ease_out(max(0.0, min(1.0, (t - 18.6*BAR)/1.6))) elif self.sec in ("strip", "surge", "after", "heart"): woke = 1.0 SKIN, SKIN_L = (74, 56, 46), (206, 158, 106) COAT, COAT_L = (33, 36, 46), (136, 140, 150) arm_top = dy - 104 head_y = arm_top - 96 - 186*woke # the head RESTS on the arms lean = 0.26*(1-woke) # slumped when asleep # shoulders / back, behind everything d.polygon([(hx-330, SH_S), (hx-250, arm_top+34), (hx+250, arm_top+34), (hx+330, SH_S)], fill=COAT) d.line([(hx-250, arm_top+34), (hx-330, SH_S)], fill=COAT_L, width=5) # head, tilted onto the arms hxx = hx + 46*lean d.ellipse([hxx-108, head_y-100, hxx+108, head_y+108], fill=SKIN) d.arc([hxx-108, head_y-100, hxx+108, head_y+108], 176, 350, fill=SKIN_L, width=10) # hair d.chord([hxx-112, head_y-118, hxx+112, head_y+64], 172, 368, fill=(30, 24, 23)) d.arc([hxx-112, head_y-118, hxx+112, head_y+64], 186, 300, fill=(104, 82, 62), width=7) # an ear, and an eye — closed, or open once he is awake d.ellipse([hxx-96, head_y+2, hxx-58, head_y+52], fill=(62, 46, 40), outline=SKIN_L, width=4) if woke < 0.5: d.arc([hxx+16, head_y+4, hxx+84, head_y+48], 186, 354, fill=(196, 150, 100), width=6) else: d.ellipse([hxx+22, head_y+2, hxx+82, head_y+42], fill=(216, 212, 204)) d.ellipse([hxx+42, head_y+10, hxx+66, head_y+36], fill=(24, 22, 26)) d.arc([hxx+14, head_y-24, hxx+90, head_y+16], 196, 344, fill=(104, 82, 62), width=6) # folded arms on the desk, in front of the head d.polygon([(hx-276, dy+8), (hx+300, dy+8), (hx+228, arm_top), (hx-206, arm_top)], fill=COAT) d.line([(hx-206, arm_top), (hx+228, arm_top)], fill=COAT_L, width=7) d.line([(hx-206, arm_top), (hx-276, dy+8)], fill=(66, 70, 80), width=3) d.arc([hx+150, arm_top-6, hx+300, arm_top+90], 250, 10, fill=(70, 74, 84), width=5) if self.sec == "heart": # a hand flat on the desk — the tremor the pen is reading d.ellipse([hx+300, dy-52, hx+490, dy+20], fill=(62, 48, 44), outline=SKIN_L, width=5) for q in range(4): d.line([hx+470-q*4, dy-40+q*16, hx+512, dy-46+q*16], fill=SKIN_L, width=7) # mug + logbook d.rectangle([1000+XO, dy-96, 1090+XO, dy], fill=(58, 54, 60), outline=(112, 108, 112), width=3) d.arc([1078+XO, dy-80, 1128+XO, dy-30], 270, 90, fill=(112, 108, 112), width=6) d.polygon([(150+XO, dy-16), (470+XO, dy-40), (470+XO, dy+4), (150+XO, dy+24)], fill=(206, 198, 178), outline=(120, 112, 96)) for q in range(6): d.line([176+XO, dy-8+q*6, 448+XO, dy-30+q*6], fill=(140, 132, 118), width=1) anchors = {"_": (700+XO, 640), "head": (hx, head_y), "lamp": (lx, ly+40), "rec": (1370+XO, 620)} sh = (e["low"]*0.7) if self.sec in HOT else 0.0 return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0, push=0.05, shake=sh), np.float32) def fake_chart(seed, w, h, event=0.0): """A past chart, for the wall. Deterministic random walk with events. Wall dressing is authored at base resolution and resampled up, which keeps the seeded pattern (and its line weight relative to the sheet) identical at 1080p instead of re-rolling a different walk at a different length. """ if S != 1.0: base = _fake_chart(seed, max(1, int(round(w/S))), max(1, int(round(h/S))), event) return np.asarray(Image.fromarray(base, "F").resize((w, h), Image.BILINEAR), np.float32) return _fake_chart(seed, w, h, event) def _fake_chart(seed, w, h, event=0.0): rng = np.random.RandomState(seed) ink = np.zeros((h, w), np.float32) rows = max(1, h//22) for r in range(rows): base = 11 + r*22 v = rng.normal(0, 1, w) v = np.convolve(v, np.ones(5)/5, "same") amp = 4.4 if event > 0 and r == rows//2: g = np.exp(-((np.arange(w)-w*0.45)/(w*0.06))**2) amp = 4.4 + g*event*30 y = np.clip(base + v*amp, 0, h-1).astype(int) span = np.maximum(1, (np.abs(v)*amp).astype(int)) for q in range(4): ink[np.clip(y+q-2, 0, h-1), np.arange(w)] += 1.9/(1+q*0.8) if event > 0 and r == rows//2: for q in range(-12, 13): ink[np.clip(y+q, 0, h-1), np.arange(w)] += 0.55*np.exp(-abs(q)/5) if r % 1 == 0: ink[np.clip(base-8, 0, h-1), ::max(1, w//6)] += 2.0 return ink class Wall: """Torn-off charts pinned to the wall. The newest one is really ours.""" def __init__(self, shot, rng, paper): self.i0 = shot.i0; self.sec = shot.section; self.paper = paper self.cam = str(["wide", "wide>full_new", "full_new", "full_old", "full_old>full_new", "wide>full_old"][int(rng.integers(6))]) self.seeds = [int(rng.integers(1, 9999)) for _ in range(5)] def frame(self, k, u, e): i = min(self.i0+k, N_FRAMES-1) im = Image.new("RGB", (SWR, SHR), (17, 16, 20)); d = SDraw(im) # plaster wall, lit from the left (quarter-res, base-space coords) qh, qw = SHR//4, SWR//4 wn = _vnoise(qh, qw, sf(6.0), 77) gy2, gx2 = np.mgrid[0:qh, 0:qw].astype(np.float32) gy2, gx2 = gy2/S, gx2/S fall = np.clip(1.25 - np.sqrt(((gx2-40-XO/4)/360)**2 + ((gy2-40)/300)**2), 0.12, 1.0) wall = np.zeros((qh, qw, 3), np.float32) for c2 in range(3): wall[..., c2] = ((34, 31, 30)[c2] + wn*26) * fall im = Image.fromarray(np.clip(wall, 0, 255).astype(np.uint8)).resize( (SWR, SHR), Image.BILINEAR) d = SDraw(im) LAY = [(300+XO, 330, 560, 400, -5, "11 MAR", 0.0), (880+XO, 270, 600, 420, 3, "02 APR", 0.42), (1420+XO, 360, 520, 380, -2, "17 APR", 0.0), (420+XO, 800, 620, 420, 2, "29 APR", 0.18), (1180+XO, 810, 660, 440, -3, "TONIGHT", 0.0)] for q, (cx, cy, sw, shh, rot, lab, ev) in enumerate(LAY): # cx/cy/sw/shh stay in BASE stage units for the draw calls below; # swr/shr are the sheet's real bitmap size swr, shr = si(sw), si(shh) newest = (q == 4) if newest and i >= TEAR_F - 4: src = self.paper.buf[0].reshape(PH, PW)[:, ::2] ink = np.zeros((shr, swr), np.float32) hh = min(shr, src.shape[0]); ww = min(swr, src.shape[1]) ink[:hh, :ww] = src[:hh, :ww] elif newest: ink = fake_chart(self.seeds[q], swr, shr, event=0.0) else: ink = fake_chart(self.seeds[q], swr, shr, event=ev) stock = paper_stock()[:shr, :swr] arr = ink_over(stock, ink) # each sheet catches the light differently arr = arr * (0.52 + 0.62*max(0.0, 1.0 - math.hypot(cx-160-XO, cy-140)/1700)) tile = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) tile = tile.rotate(rot, expand=True, resample=Image.BICUBIC, fillcolor=(0, 0, 0)) im.paste(tile, (si(cx)-tile.size[0]//2, si(cy)-tile.size[1]//2), tile.convert("L").point(lambda v: 255 if v > 4 else 0)) d = SDraw(im) # torn top edge + pin + pencil label rr = np.random.RandomState(900+q) pts = [(cx-sw//2+xq, cy-shh//2 + int(rr.uniform(-8, 8))) for xq in range(0, sw+1, 18)] if len(pts) > 1: d.line(pts, fill=(180, 168, 146), width=4) d.ellipse([cx-10, cy-shh//2-20, cx+10, cy-shh//2+0], fill=(198, 76, 62), outline=(122, 40, 32), width=2) d.text((cx-sw//2+18, cy+shh//2-40), lab, font=font(26), fill=(58, 54, 52)) anchors = {"_": (SW_S/2, SH_S/2), "new": (1180+XO, 810), "old": (300+XO, 330)} sh = (e["low"]*0.6) if self.sec in HOT else 0.0 return np.asarray(shoot(im, anchors, self.cam, ease_io(u), jseed=self.i0, push=0.05, shake=sh), np.float32) class Tear: """The chart comes off the drum: a ragged edge crossing the frame.""" def __init__(self, shot, rng, paper): self.i0 = shot.i0; self.paper = paper def frame(self, k, u, e): i = min(self.i0+k, N_FRAMES-1) old = self.paper.buf[0].reshape(PH, PW) c = chart() px_pen = float(c["pen_x"][max(0, TEAR_F-1)]) off, shade = drum_map() cols = np.mod(np.round(px_pen + off).astype(np.int64), PW) FW, FH = si(DRUM_HW*2), si(PDH) # the drum face, in real px def fit_face(a): return np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((FW, FH), Image.LANCZOS), np.float32) face = fit_face(ink_over(paper_stock()[:, cols, :], old[:, cols]) * shade[None, :, None]) stage = stage_arr(DARK) yy, xx = stage_grid() rr2 = np.sqrt(((xx-180-XO)/1500)**2 + ((yy-120)/1100)**2) stage += np.clip(1.25-rr2, 0, 1)[..., None]*np.array([34, 27, 20], np.float32) x0 = DRUM_CX-DRUM_HW # the fresh paper underneath fresh = fit_face(paper_stock()[:, cols, :]*shade[None, :, None]*0.86) B = DRUM_TOP + PDH stage[si(DRUM_TOP):si(DRUM_TOP)+FH, si(x0):si(x0)+FW] = fresh im = Image.fromarray(np.clip(stage, 0, 255).astype(np.uint8)) d = SDraw(im) for cy, ry in ((DRUM_TOP, CAP_RY), (B, CAP_RY)): d.ellipse([x0, cy-ry, x0+DRUM_HW*2, cy+ry], fill=(48, 46, 42), outline=BRASS, width=5) # the torn sheet peeling off, right to left, curling as it comes cut = ease_io(min(1.0, u*1.30)) wcutR = int(FW*(1-cut)) # real px wcut = wcutR/S # base px tx = x0 + wcut if wcutR > si(8): lift = int(cut*300) # base px piece = face[:, FW-wcutR:] ph2 = max(8, int(FH*(1-cut*0.22))) pim = Image.fromarray(np.clip(piece*(1.0+cut*0.28), 0, 255) .astype(np.uint8)).resize((wcutR, ph2), Image.LANCZOS) pim = pim.rotate(-cut*11, expand=True, resample=Image.BICUBIC, fillcolor=(0, 0, 0)) im.paste(pim, (si(tx + lift), si(DRUM_TOP) - si(lift)//3), pim.convert("L").point(lambda v: 255 if v > 4 else 0)) d = SDraw(im) # the ragged tear line, and the hand pulling it rr = np.random.RandomState(7777) pts = [(tx + int(rr.uniform(-15, 15)), DRUM_TOP + yq) for yq in range(0, PDH+1, 22)] if len(pts) > 1: d.line(pts, fill=(244, 236, 216), width=6) d.line(pts, fill=(122, 112, 94), width=2) if cut > 0.05 and cut < 0.97: hy = DRUM_TOP + PDH*0.30 hx2 = tx + int(cut*300) + 130 d.ellipse([hx2-96, hy-64, hx2+104, hy+64], fill=(64, 50, 44), outline=(192, 148, 100), width=5) for q in range(4): d.line([hx2-90+q*6, hy-40+q*22, hx2-176, hy-30+q*22], fill=(186, 142, 96), width=13) d.line([hx2+104, hy, SW_S, hy+120], fill=(52, 42, 40), width=64) anchors = {"_": (DRUM_CX, DRUM_TOP+PDH*0.5), "cut": (tx, DRUM_TOP+PDH*0.5)} return np.asarray(shoot(im, anchors, "full_cut" if u < 0.5 else "wide", ease_io(u), jseed=self.i0, push=0.06), np.float32) ENGINES = {"drum": Drum, "nib": Nib, "arm": Arm, "desk": Desk, "wall": Wall, "tear": Tear} PLAN = { "roomtone": (["drum", "nib", "wall", "desk", "drum"], [5, 6, 8]), "objects": (["nib", "drum", "arm", "drum", "desk", "wall"], [3, 4, 6]), "kit": (["drum", "nib", "arm", "drum", "desk", "nib"], [3, 4, 5]), "quake": (["nib", "drum", "arm", "nib", "drum", "desk", "wall", "drum"], [2, 2, 3, 4]), # the breakdown holds longer on fewer things — the room, the arm, the paper "strip": (["arm", "drum", "desk", "nib", "wall", "arm"], [4, 6, 8]), # the second drop cuts hardest of all "surge": (["nib", "drum", "arm", "nib", "drum", "wall", "desk", "nib"], [2, 2, 2, 3]), "after": (["drum", "nib", "wall", "arm", "drum", "desk"], [3, 4, 6]), "heart": (["nib", "drum", "desk", "nib", "drum"], [4, 5, 6, 8]), } FORCE_FIRST = {"roomtone": "drum", "quake": "nib", "strip": "arm", "surge": "nib", "heart": "tear"} LABELS = [ (0.55, 3.0, "STATION IX-7 · NIGHT SHIFT"), (4.10, 2.6, "TRACE: FLAT"), (7.10, 2.6, "TRACE: FLAT"), (10.1, 3.0, "THE PEN IS THE ONLY THING AWAKE"), (13.2, 2.6, "THE OPERATOR SLEEPS"), (16.0, 1.7, "P-WAVE"), (18.0, 1.7, "S-WAVE"), (20.0, 2.0, "M 6.2 · 41 KM · 03:13"), (22.2, 1.6, "THE LINE BECOMES A MOUNTAIN RANGE"), (24.1, 2.0, "THE ROOM GOES QUIET"), (26.3, 1.5, "THE GROUND IS NOT DONE"), (28.0, 1.7, "SECOND RUPTURE"), (30.0, 2.0, "M 6.8 · 12 KM · 03:43"), (32.3, 1.4, "GAIN ×2400 · OFF SCALE"), (34.1, 2.0, "AFTERSHOCK"), (36.6, 2.0, "AFTERSHOCK"), (38.7, 1.2, "aftershock"), (40.1, 2.6, "04:13 · CHART TORN OFF"), (42.5, 3.6, "BUT THE PEN KEEPS WRITING"), ] CARDS = {"roomtone": "SEISMO", "objects": None, "kit": None, "quake": None, "strip": None, "surge": None, "after": None, "heart": None} class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "card") def __init__(self, idx, i0, i1, engine, section, card=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.engine, self.section = engine, section self.seed = 60613 + idx*7919 self.card = card def build_shots(): R = np.random.RandomState(1906) shots = []; idx = 0; last = None for nm, b0, b1 in SECTIONS: engs, menu = PLAN[nm] t = b0*BAR; j = 0 while t < b1*BAR - 1e-6: step = menu[R.randint(len(menu))]*BEAT t2 = min(t+step, b1*BAR) if (b1*BAR - t2) < BEAT*1.5: t2 = b1*BAR i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: if j == 0 and nm in FORCE_FIRST: eng = FORCE_FIRST[nm] else: pool = [x for x in engs if x != last] or list(engs) eng = pool[R.randint(len(pool))] last = eng shots.append(Shot(idx, i0, i1, eng, nm, CARDS[nm] if j == 0 else None)) idx += 1; j += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 shots[-1].engine = "nib" # end on the pulse itself 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_raw(size, name="Menlo.ttc"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, size) return _FC[key] def font(size, name="Menlo.ttc"): """Type sizes are authored at 720p and scale with the frame. `font_raw` exists for the contact sheet, whose thumbnail grid is fixed.""" return font_raw(si(size), name) _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.60*r**1.75, 0, 1)[..., None] return _VIG["v"] _TL = {} def lamp_tint(): """A desk-lamp pool: warm centre-left, cold blue falling off.""" if "t" not in _TL: yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) r = np.sqrt(((xx-W*0.40)/(W*0.80))**2 + ((yy-H*0.34)/(H*0.86))**2) warm = np.clip(1.0-r, 0, 1)[..., None] _TL["t"] = warm return _TL["t"] def post(arr, i, e, shot): a = np.asarray(arr, np.float32) if a.shape[0] != H or a.shape[1] != W: a = np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) .resize((W, H), Image.LANCZOS), np.float32) # 1. tint — warm lamp pool over cold room wp = lamp_tint() a = a*(0.82 + wp*np.array([0.30, 0.20, 0.02], np.float32)) \ + (1-wp)*np.array([-6.0, -2.0, 9.0], np.float32) if shot.section in HOT: a = a*(1.0 + e["low"]*np.array([0.12, -0.01, -0.04], np.float32)) # bloom on the highlights only im0 = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) sm = im0.resize((W//4, H//4), Image.BILINEAR).filter( ImageFilter.GaussianBlur(sf(5))).resize((W, H), Image.BILINEAR) sa = np.asarray(sm, np.float32) a = np.clip(a + np.maximum(0, sa-172)*(0.20+0.16*e["high"]), 0, 255) # 2. vignette a *= vignette() # 3. grain — generated at 720p and blown up NEAREST so the GRAIN SIZE # scales with the frame (and its amplitude is preserved exactly) rng = np.random.RandomState(6100+i) if S == 1.0: a += rng.normal(0, 2.6, a.shape) else: g = rng.normal(0, 2.6, (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) out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) t = i/FPS # captions — technical annotation, crisp, never RGB-shifted for (b0, db, text) in LABELS: t0 = b0*BAR; t1 = t0 + db*BAR if t0 <= t < t1: ag = min(1.0, (t-t0)/0.18) * min(1.0, (t1-t)/0.25) f = font(23) nch = int(len(text)*min(1.0, (t-t0)/0.42)) shown = text[:nch] lw = d.textlength(text, font=f) bx, by = W*0.065, H*0.115 d.rectangle([bx-sf(11), by-sf(9), bx+lw+sf(15), by+sf(31)], fill=(int(10*ag), int(12*ag), int(17*ag))) d.rectangle([bx-sf(11), by-sf(9), bx+lw+sf(15), by+sf(31)], outline=tuple(int(v*ag) for v in (150, 158, 170)), width=si(2)) d.text((bx, by), shown, font=f, fill=tuple(int(v*ag) for v in (236, 240, 246))) d.line([bx+lw+sf(15), by+sf(11), bx+lw+sf(66), by+sf(42)], fill=tuple(int(v*ag) for v in (150, 158, 170)), width=si(2)) break # the last annotation: the pen is reading a pulse if t > 42.0*BAR: ag = min(1.0, (t-42.0*BAR)/0.5) f = font(30) d.line([W*0.455, H*0.50, W*0.585, H*0.695], fill=tuple(int(v*ag) for v in ALARM), width=si(3)) d.text((W*0.595, H*0.690), "65 / MIN", font=f, fill=tuple(int(v*ag) for v in (244, 214, 200))) if shot.card: age = i - shot.i0 if age < FPS*3.0: al = min(1.0, age/8.0)*min(1.0, (FPS*3.0-age)/12.0) f = font(64, "Georgia Bold.ttf") lw = d.textlength(shot.card, font=f) d.text((W/2-lw/2, H*0.42), shot.card, font=f, fill=tuple(int(v*al) for v in (240, 236, 228))) f2 = font(20) sub = "a night at a seismic station" lw2 = d.textlength(sub, font=f2) d.text((W/2-lw2/2, H*0.42+sf(82)), sub, font=f2, fill=tuple(int(v*al) for v in (176, 172, 166))) # the show mark, in the station's own annotation type f3 = font(17) show = "P L A Y E R C O M P U T E R" lw3 = d.textlength(show, font=f3) d.text((W/2-lw3/2, H*0.42-sf(40)), show, font=f3, fill=tuple(int(v*al) for v in (228, 220, 206))) # HUD d.text((sf(26), H-sf(44)), "STATION IX-7 · VERTICAL · GAIN ×2400", font=font(15), fill=(112, 122, 134)) ct = clock(i) d.text((W-sf(26)-d.textlength(ct, font=font(15)), H-sf(44)), ct, font=font(15), fill=(112, 122, 134)) # 4. letterbox bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(6, 7, 11)) d.rectangle([0, H-bh, W, H], fill=(6, 7, 11)) return out # ════════════════════════════════════════════════════════════════════════════ _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) paper = Paper(shot.i0) eng = ENGINES[shot.engine](shot, rng, paper) made = 0 for k in range(shot.n): i = shot.i0 + k paper.advance(i) e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} p = FRAMES / f"f{i:05d}.png" if p.exists() and not force: continue u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:5s} {shot.section:9s} {made}/{shot.n}" def sheet_one(sh): E = env() rng = np.random.default_rng(sh.seed) mid = sh.n//2 paper = Paper(sh.i0 + mid) eng = ENGINES[sh.engine](sh, rng, paper) i = sh.i0+mid e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(mid, mid/max(1, sh.n-1), e) return sh.idx, np.asarray(post(arr, i, e, sh).resize((300, 193), Image.LANCZOS)) def contact_sheet(shots, jobs=8): cols = 6; rows = (len(shots)+cols-1)//cols tw, th = 300, 193 sheet = Image.new("RGB", (cols*tw, rows*(th+24)), (10, 10, 14)) sd = ImageDraw.Draw(sheet) import multiprocessing as mp with mp.get_context("fork").Pool(jobs) as pool: for idx, arr in pool.imap_unordered(sheet_one, shots): sh = shots[idx] cx, cy = (idx % cols)*tw, (idx//cols)*(th+24) sheet.paste(Image.fromarray(arr), (cx, cy)) sd.text((cx+5, cy+th+4), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:.1f}s", font=font_raw(13), fill=(190, 195, 205)) p = OUT/f"contact_sheet{SUF}.png"; sheet.save(p) print(f"contact sheet -> {p} ({len(shots)} shots)") 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("--jobs", type=int, default=min(12, os.cpu_count() or 4)) ap.add_argument("--1080p", dest="hd", action="store_true", help="native 1920x1080 (global S=1.5); read at import time") ap.add_argument("--hd", dest="hd", action="store_true", help=argparse.SUPPRESS) a = ap.parse_args() wav = AUD/"final.wav"; mix = None # the wav/envelope are resolution-free; the chart table is not if not wav.exists() or not (AUD/"env.npz").exists() or a.force: print(f"[1/4] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") wav, mix = build_song(); analyze(mix) if not CHART_NPZ.exists() or a.force: if mix is None: # reuse the wav we already have mix = np.stack([read_wav(wav)]*2, 1) for nm, b0, b1 in SECTIONS: seg = mix[int(b0*BAR*SR):int(b1*BAR*SR)].mean(1) pk = float(np.max(np.abs(seg))) if len(seg) else 0.0 print(f" {nm:9s} peak {pk:.3f} -> deflection " f"{float(_defl(np.array([pk]))[0]):6.1f} px") print(f"[2/4] chart… {PW}x{PH} paper px") c = build_chart(mix.mean(1)) print(f" {len(c['ink']):,} ink pixels deposited") if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots, a.jobs); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") jl = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[3/4] frames… {len(jl)} shots / {N_FRAMES} frames on {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, jl): print(" ", r) print("[4/4] mux…") try: sha = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT).decode().strip() br = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT).decode().strip() except Exception: sha = br = "unknown" stamp = (f"renders/{SETDIR}/{NAME}/render.py · git {sha} · " f"{datetime.datetime.now().astimezone().isoformat()} · " f"{MUSIC_DESC} · {ENGINE_DESC}" + (f" · 1080p ({W}x{H}, S={S})" if HD else "")) out = OUT/f"{NAME}{SUF}.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}" + (" (1080p)" if HD else ""), "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", "-metadata", "artist=poop / generative film", str(out)], check=True, capture_output=True) (OUT/f"PROVENANCE{SUF}.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\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"sections: {' '.join(n for n, _, _ in SECTIONS)}\n" f"engine: {ENGINE_DESC}\n" f"paper: {PW}x{PH} px, {ROWS} rows @ {ROWPITCH}px, " f"{REV_FRAMES} frames/revolution, {COLS} columns/frame\n" f"voices: Samantha (station log), Whisper (last line)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()