#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Etch A Sketch (24/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/etch_a_sketch # # A kid draws the whole family on an Etch A Sketch in one line, and then somebody shakes 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/etch_a_sketch.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/etch_a_sketch.mp4 # cover: https://genekogan.com/player_computer/media/etch_a_sketch.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 etch_a_sketch.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) # ═════════════════════════════════════════════════════════════════════════════ """ night_watch_2 01 — "ETCH A SKETCH" Garage punk, 180 bpm, two chords (A5 · G5), 48 bars, everything recorded too hot. THE PICTURE — a new substrate for this repo: an ETCH A SKETCH. A sheet of aluminium powder clinging to the inside of the glass, a stylus underneath dragged on two orthogonal rails by two knobs, and one rule that governs everything: **the stylus cannot lift.** So the film is one unbroken line. That rule is not decorated, it is *implemented*: · The drawing is authored as ~80 separate polylines (a dad, a mum, a dog, a baby in a cot, a house, a sun, a cloud, two birds, a ground line). A real single-stroke path planner — nearest-neighbour seeded, then a seeded or-opt improvement pass over both orientations of every feature — finds a Hamiltonian-ish tour through all of them, and the gaps are closed with *travelling moves that are drawn*, because they have to be. Short gaps get an L. Long gaps get routed down to the ground line, across it, and back up — which is the trick every child discovers, because the horizontal run retraces a line that already exists and is therefore free. The vertical drops are not free. They stay on the screen forever as little umbilical scars, and that is the joke. · The tour is then **quantised onto the knob lattice** by Bresenham with no diagonal step allowed — one detent, one axis, ever. Diagonals staircase. A roof, a dog's tail and a woman's dress are the only diagonals in the drawing and all three come out as flights of stairs. · **One knob click per 16th note.** The whole tour is cut into as many chunks as there are 16ths in the section; each 16th the line lurches forward by one chunk and the knobs turn by exactly the number of detents that chunk spent on their axis. So the horizontal knob spins during the ground line and freezes during a leg, and the click track in the mix is panned left when the x-knob moved and right when the y-knob did. The medium itself: `rem` is a monotone removal field (powder scraped away, never returned) over a 1100×750 screen; `ridge` is the powder the stylus *pushed to the sides*, a bright bead riding each edge; `fresh` is loose powder at the point, decaying. Under it all, a granular aluminium sheen (two octaves of value noise plus per-frame glitter) so the grey is never flat. THE SHAKE. Turning it upside down and shaking it re-coats the glass. `rem` collapses toward zero through a vertical judder smear (the powder cascading), and stops at 4% — because a real Etch A Sketch never fully erases, and the ghost of what you drew is still there under the next drawing. Take two is drawn on top of take one's ghost. THE STORY. A kid draws the whole family on one line, getting more ambitious, taking absurd detours to connect things. A hand comes for the frame. The powder trembles. The shake wins. And then — two bars of nothing, one lone hi-hat — they do it again, four times faster, from memory, with a better route and a smile on everyone. Composition: engine : audio-first × sequential-stateful (tier 3 — the removal field is recursive across the whole film) with shot-parallel workers that replay the sim draw-only up to their own range content: audio-groove (fuzz power chords, hot snare, Farfisa, detent clicks) × effects-post × tts-voices (Junior / Ralph / Fred, shouted) Run from repo root: python3 renders/player_computer_final/etch_a_sketch/render.py --sheet python3 renders/player_computer_final/etch_a_sketch/render.py python3 renders/player_computer_final/etch_a_sketch/render.py --shots 12,13 --force python3 renders/player_computer_final/etch_a_sketch/render.py --mux-only """ import argparse, datetime, json, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "etch_a_sketch" TITLE = "ETCH A SKETCH" SETDIR = "player_computer_final" SETNUM = "B10" W, H, FPS = 1920, 1080, 30 # ── delivery scale ─────────────────────────────────────────────────────────── # FINAL CUT: native 1920x1080. The single-stroke TOUR is untouched — the path # planner still runs in the authored 1100x750 screen space on the same knob # lattice, so the cached tours, the detent counts and therefore the 16th-note # click schedule are bit-identical and the picture stays locked to the audio. # Only the RASTER scales: the tabletop, the powder field, the stylus width and # the ridge bead are all multiplied by SCL = H/720, and the lattice index is # turned into pixels with q*SCL at the one place that conversion happens. 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 SR = 44100 BPM = 180.0 BEAT = 60.0/BPM # 0.33333 s BAR = 4*BEAT # 1.33333 s S16 = BEAT/4.0 # 0.08333 s — the knob-click grid 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" # ── the toy, in pixels ────────────────────────────────────────────────────── # player_computer_2 is 16:9, so the TABLETOP grew rather than the toy: 120 px # of extra table on each side, and every fixed coordinate of the toy is pushed # right by the same amount so it stays dead centre at its original size. TPAD_B = 120 # authoring units (the 720p master) TPAD = PXi(TPAD_B) TW, TH = PXi(1680 + 2*TPAD_B), PXi(1080) # the whole tabletop; camera crops it SW, SH = PXi(1100), PXi(750) # the powder screen SX0, SY0 = PXi(250 + TPAD_B), PXi(108) # where the screen sits on the table BODY = tuple(PXi(v) for v in (150 + TPAD_B, 34, 1530 + TPAD_B, 1046)) # red frame KNOB_R = PXi(84) KNOB_H = (PXi(334 + TPAD_B), PXi(952)) # horizontal knob (left) KNOB_V = (PXi(1346 + TPAD_B), PXi(952)) # vertical knob (right) Q = 3.4 # one knob detent, in screen pixels CLICK = 2*math.pi/44.0 # knob rotation per detent GY = 618.0 # the ground line — the free highway SECTIONS = [ ("title", 0, 4), ("dad", 4, 10), ("mum", 10, 16), ("dog", 16, 21), ("baby", 21, 26), ("house",26, 33), ("hand", 33, 37), ("shake",37, 39), ("blank",39, 41), ("again",41, 48), ] N_BARS = SECTIONS[-1][2] TAIL = 1.55 DUR = N_BARS*BAR + TAIL N_FRAMES = int(round(DUR*FPS)) # drawing windows, in bars — (t0, t1) for each take WIN_TITLE = (0.12, 3.72) WIN_T1 = (4.15, 32.80) WIN_T2 = (41.00, 47.35) SHAKE0 = (4.00, 4.30) # the title gets wiped on the first crash SHAKE1 = (37.00, 38.70) # the shake that wins MUSIC_DESC = (f"garage punk, {BPM:.0f}bpm, two chords (A5·G5), {N_BARS} bars, " "fuzz guitar double-tracked out of tune, hot snare, Farfisa organ, " "shouted Junior/Ralph/Fred, one knob-detent click per 16th") ENGINE_DESC = ("etch-a-sketch: monotone aluminium-powder removal field + ridge, " "single-stroke tour planner, Bresenham knob-lattice quantisation, " "shake re-coat down to a 10% ghost that the next drawing is made over") # ════════════════════════════════════════════════════════════════════════════ # DETERMINISM HELPERS (no bare random.*, no hash()) # ════════════════════════════════════════════════════════════════════════════ def _sd(seed, k): return (seed*7919 + k*104729) & 0x7fffffff def _r01(seed, k): x = ((seed*2654435761) ^ (k*40503*2246822519)) & 0xffffffff x = (x*1664525 + 1013904223) & 0xffffffff return (x >> 8)/16777216.0 # ── 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="Impact.ttf"): 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] # ════════════════════════════════════════════════════════════════════════════ # 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 bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping — no raw full-band hiss anywhere (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 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 cents(f, c): return f*2.0**(c/1200.0) # ── the guitar: a fuzz power chord, double-tracked and out of tune ────────── _GC = {} def power_chord(root, dur=0.19, seed=0, drive=7.0, track=0): """Root + fifth + octave, each a stack of detuned saw partials, slammed into a hard clipper and then through a 4×12 cabinet band. `track` picks the double — one is 9 cents sharp, the other 6 flat, because nothing in a garage is in tune.""" key = (round(root, 2), round(dur, 3), seed & 7, round(drive, 1), track) if key in _GC: return _GC[key] n = int(dur*SR); t = np.arange(n)/SR R = np.random.RandomState((seed*7919 + track*131 + 17) & 0x7fffffff) off = 9.0 if track == 0 else -6.0 x = np.zeros(n) for mul, g in ((1.0, 1.00), (1.4983, 0.86), (2.0, 0.62), (2.9966, 0.24)): f0 = cents(root*mul, off + (R.rand()-0.5)*7.0) for k in range(1, 9): # additive saw ph = R.rand()*6.283185307 x += np.sin(2*np.pi*f0*k*t + ph)*(g/k)*(0.94 + 0.12*R.rand()) x /= (np.max(np.abs(x)) + 1e-9) # pick attack: a scrape of filtered noise on the transient pick = bandshape(R.randn(n), lo=1400, hi=6200)*np.exp(-t*160)*0.55 x = np.tanh((x + pick)*drive) # fuzz x = np.tanh(x*1.4) # and again, too hot x = bandshape(x, lo=88, hi=4300, order=5) # cabinet x *= adsr(n, 0.0022, 0.05, 0.78, min(0.06, dur*0.35)) x /= (np.max(np.abs(x)) + 1e-9) _GC[key] = x*0.92 return _GC[key] _BS = {} def bass_note(root, dur=0.155, seed=0): key = (round(root, 2), round(dur, 3), seed & 3) if key in _BS: return _BS[key] n = int(dur*SR); t = np.arange(n)/SR R = np.random.RandomState((seed*104729 + 7) & 0x7fffffff) x = np.zeros(n) for k in range(1, 13): x += np.sin(2*np.pi*root*k*t + R.rand()*6.283)*(1.0/k) sub = np.sin(2*np.pi*root*t)*1.1 click = bandshape(R.randn(n), lo=900, hi=3400)*np.exp(-t*220)*0.35 x = np.tanh((x/np.max(np.abs(x)) + sub + click)*2.6) x = bandshape(x, lo=42, hi=2300) x *= adsr(n, 0.0016, 0.03, 0.72, 0.035) _BS[key] = x/(np.max(np.abs(x)) + 1e-9)*0.95 return _BS[key] # ── drums: a real kit in a small room, hit hard ───────────────────────────── def kick(seed=1): n = int(0.30*SR); t = np.arange(n)/SR f = 52 + 128*np.exp(-t*58) body = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*17) beater = bandshape(np.random.RandomState(seed).randn(n), lo=800, hi=4200) beater *= np.exp(-t*180)*0.5 return np.tanh((body + beater)*2.2)*0.98 def snare(seed=2, hard=1.0): n = int(0.30*SR); t = np.arange(n)/SR R = np.random.RandomState(seed & 0x7fffffff) crack = bandshape(R.randn(n), lo=1100, hi=8200)*np.exp(-t*46) wires = bandshape(R.randn(n), lo=2600, hi=6800)*np.exp(-t*16)*0.55 tone = (np.sin(2*np.pi*196*t)*0.7 + np.sin(2*np.pi*291*t)*0.4)*np.exp(-t*30) x = np.tanh((crack + wires + tone)*(2.4 + 1.4*hard)) return bandshape(x, lo=150, hi=11000)*0.95 def hat(seed=3, open_=False): dur = 0.16 if open_ else 0.036 n = int(dur*SR); t = np.arange(n)/SR R = np.random.RandomState(seed & 0x7fffffff) x = bandshape(R.randn(n), lo=5200, hi=13500) x *= np.exp(-t*(24 if open_ else 130)) return np.tanh(x*1.8)*(0.52 if open_ else 0.44) def crash(seed=4, dur=1.7): n = int(dur*SR); t = np.arange(n)/SR R = np.random.RandomState(seed & 0x7fffffff) x = bandshape(R.randn(n), lo=700, hi=15000)*np.exp(-t*(2.6/dur)) shim = np.zeros(n) for f in (523.0, 741.0, 967.0, 1319.0, 1861.0): shim += np.sin(2*np.pi*cents(f, (R.rand()-0.5)*40)*t)*np.exp(-t*3.1) x = np.tanh((x + shim*0.10)*2.0) x *= adsr(n, 0.0012, 0.02, 0.9, dur*0.5) return x/(np.max(np.abs(x)) + 1e-9)*0.90 def tom(f0=150, seed=5): n = int(0.24*SR); t = np.arange(n)/SR f = f0*(1 + 0.55*np.exp(-t*30)) x = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*13) sk = bandshape(np.random.RandomState(seed).randn(n), lo=400, hi=3000) return np.tanh((x + sk*np.exp(-t*90)*0.4)*2.0)*0.85 # ── the Farfisa: a cheap, thin, reedy pulse organ, sharp by 14 cents ──────── _OG = {} def organ(freqs, dur, seed=0, g=1.0): key = (tuple(round(f, 2) for f in freqs), round(dur, 3), seed & 3) if key in _OG: return _OG[key] n = int(dur*SR); t = np.arange(n)/SR R = np.random.RandomState((seed*40503 + 3) & 0x7fffffff) vib = 1.0 + 0.007*np.sin(2*np.pi*6.4*t + R.rand()*6.283) x = np.zeros(n) for f in freqs: f = cents(f, 14.0 + (R.rand()-0.5)*9.0) # out of tune, on purpose ph = 2*np.pi*np.cumsum(f*vib)/SR + R.rand()*6.283 # narrow pulse ≈ reedy Farfisa x += (np.sign(np.sin(ph)) * 0.55 + np.sign(np.sin(ph*2 + 0.7))*0.30 + np.sign(np.sin(ph*3 + 1.9))*0.17) x = bandshape(x, lo=260, hi=5400, order=5) x = np.tanh(x*1.5) x *= adsr(n, 0.006, 0.05, 0.86, min(0.09, dur*0.4)) _OG[key] = x/(np.max(np.abs(x)) + 1e-9)*g return _OG[key] # ── the knob detent: a plastic ratchet tick ──────────────────────────────── _KC = {} def detent(axis=0, seed=0): key = (axis, seed & 7) if key in _KC: return _KC[key] n = int(0.020*SR); t = np.arange(n)/SR R = np.random.RandomState((seed*2654435761 + axis*77) & 0x7fffffff) lo, hi, pin = (1800, 7200, 2400.0) if axis == 0 else (2600, 10500, 3900.0) x = bandshape(R.randn(n), lo=lo, hi=hi)*np.exp(-t*420) x += np.sin(2*np.pi*pin*t)*np.exp(-t*300)*0.45 x = np.tanh(x*2.6) _KC[key] = x/(np.max(np.abs(x)) + 1e-9)*0.85 return _KC[key] # ── FX ───────────────────────────────────────────────────────────────────── def delay(x, time=0.09, fb=0.30, mix=0.22, taps=5): d = int(time*SR); out = x.copy() for i in range(1, taps+1): s = d*i if s >= len(x): break out[s:] += x[:len(x)-s]*(mix*(fb**i)) return out def reverb(x, rt=0.9, mix=0.18, seed=7, pre=0.010): n = int(rt*SR); t = np.arange(n)/SR R = np.random.RandomState(seed & 0x7fffffff) ir = R.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 garage(x, seed=11): """A two-car garage: short, boxy, slappy, way too much of it.""" return reverb(delay(x, 0.037, 0.28, 0.20, taps=4), rt=0.55, mix=0.20, seed=seed) # ── the multitrack ───────────────────────────────────────────────────────── class Song: def __init__(self, dur): self.n = int(dur*SR); self.tr = {} def put(self, track, sig, at, g=1.0, pan=0.0): b = self.tr.setdefault(track, np.zeros((self.n, 2))) i = int(at*SR) if i >= self.n: return if i < 0: if -i >= len(sig): return sig = sig[-i:]; i = 0 j = min(self.n, i+len(sig)) if j <= i: return th = (pan*0.5 + 0.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.11): 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(N_BARS*BAR*SR):] = levels.get("tail", 0.5) k = max(1, int(glide*SR)) return np.convolve(env, np.ones(k)/k, "same") def mixdown(self, gains, levels=None): mix = np.zeros((self.n, 2)) for k, b in self.tr.items(): mix += b*gains.get(k, 1.0) if levels is not None: mix *= self.sec_env(levels)[:, None] mix = np.tanh(mix*1.55)/np.tanh(1.55) # recorded too hot return mix/(np.max(np.abs(mix)) + 1e-9)*0.945 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(" 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, voice, 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, voice, rate, path): return _tts_silence(text, rate) return read_wav(path) def resamp(x, k): n = max(2, int(len(x)/k)) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) # Junior is a kid. Ralph is the kid's older brother who owns the amp. Fred # is whoever is standing nearest the one microphone. VOX = {"lead": [("Junior", 250, 1.06, 0.00)], "gang": [("Junior", 262, 1.09, -0.02), ("Ralph", 248, 0.97, 0.55), ("Fred", 256, 1.03, -0.55)]} _VC = {} def vox(text, voice, rate, k, grit=1.0): key = (text, voice, rate, round(k, 3)) if key in _VC: return _VC[key] p = AUD/f"say_{_slug(voice)}_{_slug(text)}_{rate}.wav" x = resamp(say_wav(text, voice, rate, p), k) x = bandshape(x, lo=330, hi=3900, order=5) x /= (np.max(np.abs(x)) + 1e-9) hot = np.tanh(x*(5.5*grit)) fizz = np.tanh(bandshape(x, lo=1300, hi=3200)*14.0)*0.35 y = np.tanh((hot + fizz)*1.2) y = delay(y, 0.088, 0.26, 0.20, taps=3) _VC[key] = y/(np.max(np.abs(y)) + 1e-9) return _VC[key] # ════════════════════════════════════════════════════════════════════════════ # THE DRAWING — every feature is one polyline, and there are eighty of them # ════════════════════════════════════════════════════════════════════════════ def rect(x0, y0, x1, y1): return [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] def circ(cx, cy, r, n=18, ph=0.0, ry=None): ry = r if ry is None else ry return [(cx + math.cos(ph + 6.283185307*i/n)*r, cy + math.sin(ph + 6.283185307*i/n)*ry) for i in range(n+1)] def dad(x, g, sc=1.0, smile=False): S = lambda v: v*sc F = [] F.append(rect(x-S(46), g-S(306), x+S(46), g-S(222))) # head F.append([(x-S(46), g-S(306)), (x-S(30), g-S(326)), (x-S(14), g-S(306)), (x+S(2), g-S(326)), (x+S(18), g-S(306)), (x+S(34), g-S(326)), (x+S(46), g-S(306))]) # hair F.append(rect(x-S(32), g-S(288), x-S(10), g-S(268))) # glasses L F.append(rect(x+S(10), g-S(288), x+S(32), g-S(268))) # glasses R F.append([(x-S(10), g-S(278)), (x+S(10), g-S(278))]) # bridge if smile: F.append([(x-S(22), g-S(250)), (x-S(10), g-S(238)), (x+S(10), g-S(238)), (x+S(22), g-S(250))]) else: F.append([(x-S(20), g-S(242)), (x+S(20), g-S(242))]) F.append(rect(x-S(58), g-S(222), x+S(58), g-S(104))) # body F.append([(x, g-S(218)), (x-S(15), g-S(190)), (x, g-S(140)), (x+S(15), g-S(190)), (x, g-S(218))]) # tie F.append([(x-S(58), g-S(206)), (x-S(126), g-S(164))]) # arm L F.append([(x+S(58), g-S(206)), (x+S(126), g-S(164))]) # arm R F.append(circ(x-S(136), g-S(156), S(15), 10)) F.append(circ(x+S(136), g-S(156), S(15), 10)) F.append([(x-S(30), g-S(104)), (x-S(30), g-S(8))]) # leg L F.append([(x+S(30), g-S(104)), (x+S(30), g-S(8))]) # leg R F.append([(x-S(30), g-S(8)), (x-S(70), g-S(8))]) # foot L F.append([(x+S(30), g-S(8)), (x+S(70), g-S(8))]) # foot R return F def mum(x, g, sc=1.0, smile=False): S = lambda v: v*sc F = [] F.append(circ(x, g-S(272), S(46), 16)) # head F.append([(x-S(52), g-S(276)), (x-S(40), g-S(312)), (x-S(20), g-S(292)), (x, g-S(324)), (x+S(20), g-S(292)), (x+S(40), g-S(312)), (x+S(52), g-S(276))]) # hair F.append(circ(x-S(17), g-S(282), S(7), 8)) F.append(circ(x+S(17), g-S(282), S(7), 8)) if smile: F.append([(x-S(20), g-S(258)), (x-S(8), g-S(246)), (x+S(8), g-S(246)), (x+S(20), g-S(258))]) else: F.append([(x-S(16), g-S(252)), (x+S(16), g-S(252))]) F.append([(x-S(18), g-S(226)), (x+S(18), g-S(226)), (x+S(84), g-S(26)), (x-S(84), g-S(26)), (x-S(18), g-S(226))]) # dress F.append([(x-S(24), g-S(214)), (x-S(100), g-S(158))]) # arm L F.append([(x+S(24), g-S(214)), (x+S(100), g-S(158))]) # arm R F.append(circ(x-S(109), g-S(151), S(14), 10)) F.append(circ(x+S(109), g-S(151), S(14), 10)) F.append([(x-S(34), g-S(26)), (x-S(34), g-S(6))]) F.append([(x+S(34), g-S(26)), (x+S(34), g-S(6))]) F.append([(x-S(34), g-S(6)), (x-S(66), g-S(6))]) F.append([(x+S(34), g-S(6)), (x+S(66), g-S(6))]) return F def dog(x, g, sc=1.0): S = lambda v: v*sc F = [] F.append(rect(x-S(72), g-S(96), x+S(54), g-S(42))) # body F.append(rect(x+S(54), g-S(120), x+S(122), g-S(60))) # head F.append(rect(x+S(122), g-S(96), x+S(156), g-S(70))) # snout F.append([(x+S(64), g-S(120)), (x+S(56), g-S(162)), (x+S(94), g-S(122))]) # ear F.append(circ(x+S(86), g-S(102), S(7), 8)) F.append([(x-S(72), g-S(90)), (x-S(118), g-S(134))]) # tail (stairs) for k, bx in enumerate((-56, -18, 20, 44)): F.append([(x+S(bx), g-S(42)), (x+S(bx), g-S(4))]) F.append([(x+S(bx), g-S(4)), (x+S(bx+26), g-S(4))]) return F def baby(x, g, sc=1.0): S = lambda v: v*sc F = [] F.append(rect(x-S(66), g-S(100), x+S(66), g-S(14))) # cot for bx in (-33, 0, 33): F.append([(x+S(bx), g-S(100)), (x+S(bx), g-S(14))]) # bars F.append([(x-S(66), g-S(58)), (x+S(66), g-S(58))]) # blanket F.append(circ(x, g-S(130), S(27), 14)) # head F.append([(x-S(9), g-S(154)), (x, g-S(172)), (x+S(9), g-S(154))]) # tuft F.append(circ(x-S(10), g-S(136), S(5), 8)) F.append(circ(x+S(10), g-S(136), S(5), 8)) F.append([(x-S(9), g-S(120)), (x, g-S(114)), (x+S(9), g-S(120))]) return F def house(x, g, sc=1.0): S = lambda v: v*sc F = [] F.append(rect(x-S(122), g-S(196), x+S(122), g)) # box F.append([(x-S(160), g-S(196)), (x, g-S(304)), (x+S(160), g-S(196))]) # roof F.append([(x-S(160), g-S(196)), (x+S(160), g-S(196))]) # eave F.append(rect(x-S(34), g-S(100), x+S(34), g)) # door F.append(circ(x+S(22), g-S(50), S(7), 8)) # knob F.append(rect(x-S(104), g-S(168), x-S(48), g-S(112))) # window L F.append([(x-S(76), g-S(168)), (x-S(76), g-S(112))]) F.append([(x-S(104), g-S(140)), (x-S(48), g-S(140))]) F.append(rect(x+S(48), g-S(168), x+S(104), g-S(112))) # window R F.append([(x+S(76), g-S(168)), (x+S(76), g-S(112))]) F.append([(x+S(48), g-S(140)), (x+S(104), g-S(140))]) F.append(rect(x+S(72), g-S(292), x+S(108), g-S(232))) # chimney F.append([(x+S(90), g-S(292)), (x+S(72), g-S(324)), (x+S(112), g-S(346)), (x+S(78), g-S(374)), (x+S(118), g-S(396))]) # smoke return F def kid(x, g, sc=1.0): S = lambda v: v*sc F = [] F.append(circ(x, g-S(184), S(34), 14)) F.append(circ(x-S(12), g-S(192), S(5), 8)) F.append(circ(x+S(12), g-S(192), S(5), 8)) F.append([(x-S(16), g-S(170)), (x-S(6), g-S(160)), (x+S(6), g-S(160)), (x+S(16), g-S(170))]) F.append(rect(x-S(34), g-S(150), x+S(34), g-S(66))) F.append([(x-S(34), g-S(138)), (x-S(84), g-S(178))]) F.append([(x+S(34), g-S(138)), (x+S(84), g-S(178))]) F.append([(x-S(18), g-S(66)), (x-S(18), g-S(6))]) F.append([(x+S(18), g-S(66)), (x+S(18), g-S(6))]) F.append([(x-S(18), g-S(6)), (x-S(46), g-S(6))]) F.append([(x+S(18), g-S(6)), (x+S(46), g-S(6))]) return F def sky(seed=1, extra=False): F = [] F.append(circ(975, 176, 44, 20)) # sun for i in range(8): a = 6.283185307*i/8 + 0.20 F.append([(975 + math.cos(a)*56, 176 + math.sin(a)*56), (975 + math.cos(a)*88, 176 + math.sin(a)*88)]) # rays F.append([(140, 152), (162, 118), (198, 112), (218, 84), (256, 94), (276, 122), (306, 130), (294, 164), (140, 152)]) # cloud F.append([(408, 258), (432, 234), (456, 258)]) # bird F.append([(494, 224), (518, 198), (542, 224)]) # bird F.append([(612, 268), (636, 244), (660, 268)]) # bird if extra: F.append([(700, 206), (724, 182), (748, 206)]) # bird 4 F.append([(330, 214), (352, 192), (374, 214)]) # bird 5 return F def ground(): return [[(56, GY), (1044, GY)]] def family(take=1): """take 1 is what a kid draws. take 2 is what they draw the second time — same picture, everyone smiling, an extra kid in the doorway, two more birds, and a route that has learned something.""" F = [] F += ground() F += dad(145, GY, 0.94, smile=(take == 2)) F += mum(355, GY, 0.94, smile=(take == 2)) F += dog(520, GY, 0.85) F += baby(715, GY, 0.85) F += house(900, GY, 0.95) F += sky(extra=(take == 2)) if take == 2: F += kid(900, GY, 0.56) return F # ── the stroke font ─────────────────────────────────────────────────────── # Every glyph is ONE polyline that enters at the bottom-left and leaves at the # bottom-right, retracing itself wherever it has to double back — which is # free, because the line is already there. Written left to right, the whole # title therefore sits on a single unbroken baseline, exactly the way you # would have to letter it on the real toy. GLYPH = { " ": [(0, 1), (1, 1)], "A": [(0, 1), (0.5, 0), (1, 1), (0.80, 0.60), (0.20, 0.60), (0.80, 0.60), (1, 1)], "C": [(0, 1), (0, 0), (1, 0), (0, 0), (0, 1), (1, 1)], "E": [(0, 1), (0, 0), (1, 0), (0, 0), (0, 0.5), (0.74, 0.5), (0, 0.5), (0, 1), (1, 1)], "H": [(0, 1), (0, 0), (0, 0.5), (1, 0.5), (1, 0), (1, 0.5), (1, 1)], "K": [(0, 1), (0, 0), (0, 0.52), (1, 0), (0, 0.52), (1, 1)], "S": [(0, 1), (1, 1), (1, 0.5), (0, 0.5), (0, 0), (1, 0), (0, 0), (0, 0.5), (1, 0.5), (1, 1)], "T": [(0, 1), (0.5, 1), (0.5, 0), (0, 0), (1, 0), (0.5, 0), (0.5, 1), (1, 1)], } def lettered(text, x0, base, gw, gh, gap): """One polyline for a whole word, glyph after glyph along its baseline.""" pts = [] x = x0 for ch in text: if ch not in GLYPH: raise SystemExit(f"stroke font has no glyph for {ch!r}") for (u, v) in GLYPH[ch]: pt = (x + u*gw, base - gh + v*gh) if not pts or pt != pts[-1]: pts.append(pt) x += gw + gap if pts[-1] != (x, base): pts.append((x, base)) return pts def title_stroke(): """ETCH A / SKETCH — two lines, joined by one honest return sweep.""" a = lettered("ETCH A", 45, 300, 130, 168, 46) b = lettered("SKETCH", 45, 632, 130, 168, 46) # the return: drop at the right edge, then run left along what will become # the second line's baseline return a + [(a[-1][0], 632)] + b # ════════════════════════════════════════════════════════════════════════════ # THE SINGLE-STROKE PATH PLANNER # # The whole point of the medium. Given N disjoint polylines, find one order + # orientation that minimises the length of the connecting travel, then emit # ONE polyline: features and travel concatenated, nothing lifted, ever. # ════════════════════════════════════════════════════════════════════════════ def _man(a, b): return abs(a[0]-b[0]) + abs(a[1]-b[1]) def plan_tour(feats, start, seed, iters=4000): """Nearest-neighbour from several seeds, then a delta-costed or-opt + 2-opt improvement over both orientations of every feature. All connector costs are Manhattan, because the travel move is an L, not a line.""" ends = [(f[0], f[-1]) for f in feats] n = len(feats) def head(it): return ends[it[0]][1 if it[1] else 0] def tail(it): return ends[it[0]][0 if it[1] else 1] def nn_from(first, ffl): unused = set(range(n)); unused.discard(first) order = [(first, ffl)] cur = tail(order[0]) while unused: best, bf, bd = -1, False, 1e18 for i in unused: a, b = ends[i] da, db = _man(cur, a), _man(cur, b) if da < bd: best, bf, bd = i, False, da if db < bd: best, bf, bd = i, True, db order.append((best, bf)); cur = tail(order[-1]); unused.discard(best) return order def cost(o): c = _man(start, head(o[0])); p = tail(o[0]) for m in range(1, len(o)): c += _man(p, head(o[m])); p = tail(o[m]) return c # a handful of deterministic nearest-neighbour restarts order, c0 = None, 1e18 for r in range(6): f0 = int(_r01(seed, 900+r)*n) % n for fl in (False, True): o = nn_from(f0, fl); c = cost(o) if c < c0: order, c0 = o, c def link(a, b): # cost of the join before index b return _man(start if a < 0 else tail(order[a]), head(order[b])) for it in range(iters): m = len(order) j = int(_r01(seed, it*3+1)*m) % m # ---- or-opt: pull j out and reinsert it anywhere, either way round item = order[j] pt = start if j == 0 else tail(order[j-1]) nh = None if j == m-1 else head(order[j+1]) rem = _man(pt, head(item)) + (0.0 if nh is None else _man(tail(item), nh)) rem -= (0.0 if nh is None else _man(pt, nh)) rest = order[:j] + order[j+1:] bestd, bestk, bestfl = 0.0, -1, item[1] for k in range(len(rest)+1): p2 = start if k == 0 else tail(rest[k-1]) n2 = None if k == len(rest) else head(rest[k]) base = 0.0 if n2 is None else _man(p2, n2) for fl in (False, True): cand = (item[0], fl) h = ends[cand[0]][1 if fl else 0] t2 = ends[cand[0]][0 if fl else 1] add = _man(p2, h) + (0.0 if n2 is None else _man(t2, n2)) - base d = add - rem if d < bestd - 1e-9: bestd, bestk, bestfl = d, k, fl if bestk >= 0: order = rest[:bestk] + [(item[0], bestfl)] + rest[bestk:] c0 += bestd continue # ---- 2-opt: reverse a span (which flips every feature inside it) b = int(_r01(seed, it*3+2)*m) % m i0, i1 = (j, b) if j < b else (b, j) if i1 - i0 < 1: continue p0 = start if i0 == 0 else tail(order[i0-1]) n1 = None if i1 == m-1 else head(order[i1+1]) old = _man(p0, head(order[i0])) + (0.0 if n1 is None else _man(tail(order[i1]), n1)) new = _man(p0, tail(order[i1])) + (0.0 if n1 is None else _man(head(order[i0]), n1)) # inner joins reverse direction but Manhattan is symmetric, so only # the two boundary joins change if new < old - 1e-9: span = [(i, not fl) for (i, fl) in order[i0:i1+1]][::-1] order = order[:i0] + span + order[i1+1:] c0 += new - old return order, cost(order) SKY_RAIL = 96.0 def travel(p, q, seed, k, rail=GY, detour_every=5, l_max=165.0): """The connecting move. It gets drawn, because it has to. Short hops take an L. Long hops dive to the ground line, run along it (retracing a line that is already there — free), and climb back up. Every fifth long hop takes the scenic route along the very bottom rail, which is not free at all, and which is the funniest thing on the screen.""" d = _man(p, q) if d < 6: return [] if rail == GY and max(p[1], q[1]) < GY - 300: rail = SKY_RAIL # both ends are up in the sky — join up there if d < l_max: if _r01(seed, k) > 0.5: return [(q[0], p[1]), q] return [(p[0], q[1]), q] if k % detour_every == detour_every-1: y = SH - 26 return [(p[0], y), (q[0], y), q] return [(p[0], rail), (q[0], rail), q] def single_stroke(feats, start, seed, iters=4000, detour_every=5, l_max=165.0, rail=GY): order, c = plan_tour(feats, start, seed, iters) pts = [start] for k, (i, fl) in enumerate(order): f = feats[i][::-1] if fl else feats[i] pts += travel(pts[-1], f[0], seed, k, rail=rail, detour_every=detour_every, l_max=l_max) pts += list(f[1:]) if pts[-1] == f[0] else list(f) return pts, c # ── quantise onto the knob lattice: Bresenham, never a diagonal step ──────── def quantise(pts, q=Q): gx = int(round(pts[0][0]/q)); gy = int(round(pts[0][1]/q)) ax = [0]*0 xs = [gx]; ys = [gy]; axes = [] for (tx, ty) in pts[1:]: x1 = int(round(tx/q)); y1 = int(round(ty/q)) dx = abs(x1-gx); sx = 1 if x1 > gx else -1 dy = -abs(y1-gy); sy = 1 if y1 > gy else -1 err = dx + dy guard = 0 while (gx != x1 or gy != y1) and guard < 100000: guard += 1 e2 = 2*err if e2 >= dy and gx != x1: err += dy; gx += sx; axes.append(0) elif e2 <= dx and gy != y1: err += dx; gy += sy; axes.append(1) else: break xs.append(gx); ys.append(gy) return (np.array(xs, np.int32), np.array(ys, np.int32), np.array(axes, np.int8)) class Take: """A quantised drawing: lattice positions, the axis of each detent, and the running knob angles (cumulative sums, so the knob angle at any point in the film is O(1)).""" __slots__ = ("xs", "ys", "axes", "hang", "vang", "nd", "cost") def __init__(self, feats, start, seed, iters=4000, detour_every=5, q=Q, l_max=165.0, rail=GY): pts, self.cost = single_stroke(feats, start, seed, iters, detour_every, l_max, rail) self.xs, self.ys, self.axes = quantise(pts, q) self._derive() def _derive(self): self.nd = len(self.axes) self.hang = np.concatenate([[0.0], np.cumsum(np.diff(self.xs).astype(np.float64))*CLICK]) self.vang = np.concatenate([[0.0], np.cumsum(np.diff(self.ys).astype(np.float64))*CLICK]) @classmethod def from_points(cls, pts, q=Q): t = cls.__new__(cls) t.xs, t.ys, t.axes = quantise(pts, q) t.cost = 0.0 t._derive() return t @classmethod def from_arrays(cls, xs, ys, ax, cost): t = cls.__new__(cls) t.xs, t.ys, t.axes, t.cost = xs, ys, ax, cost t._derive() return t # features start seed iters detour q l_max rail TAKE_SPEC = { "title": (None, None, 0, 0, 0, Q, 0.0, 0.0), "t1": (lambda: family(1), (56.0, GY), 8101, 26000, 5, Q, 165.0, GY), # take two has learned the room: many more improvement passes and almost # no scenic detours, so the second drawing is genuinely a better route "t2": (lambda: family(2), (56.0, GY), 9209, 70000, 23, Q*1.28, 175.0, GY), } _TAKES = {} def takes(): """Planned once, cached to disk — every worker loads the same tour.""" if _TAKES: return _TAKES cache = AUD/"tours.npz" if cache.exists(): z = np.load(cache) for k in TAKE_SPEC: if f"{k}_xs" not in z.files: break else: for k in TAKE_SPEC: _TAKES[k] = Take.from_arrays(z[f"{k}_xs"], z[f"{k}_ys"], z[f"{k}_ax"], float(z[f"{k}_c"])) return _TAKES _TAKES.clear() blob = {} for k, (fn, st, sd, it, de, q, lm, rl) in TAKE_SPEC.items(): if fn is None: tk = Take.from_points(title_stroke(), q=q) # hand-lettered else: tk = Take(fn(), st, sd, iters=it, detour_every=de, q=q, l_max=lm, rail=rl) _TAKES[k] = tk blob[f"{k}_xs"] = tk.xs; blob[f"{k}_ys"] = tk.ys blob[f"{k}_ax"] = tk.axes; blob[f"{k}_c"] = np.float64(tk.cost) np.savez(cache, **blob) return _TAKES # ── the 16th-note schedule: one knob click per 16th ──────────────────────── def draw_windows(): T = takes() return [("title", WIN_TITLE[0]*BAR, WIN_TITLE[1]*BAR, T["title"]), ("t1", WIN_T1[0]*BAR, WIN_T1[1]*BAR, T["t1"]), ("t2", WIN_T2[0]*BAR, WIN_T2[1]*BAR, T["t2"])] _SCHED = {} def _ease16(s, n16): """The kid is keenest at the start of a take — progress is front-loaded, identically in the schedule and in the picture, so the click track and the line can never drift apart.""" return (s/float(n16))**0.86 def schedule(): """For every 16th inside a drawing window: the detent slice it consumes, and how many of those detents each knob is responsible for.""" if not _SCHED: rows = [] for key, t0, t1, tk in draw_windows(): n16 = max(1, int(round((t1-t0)/S16))) for s in range(n16): a = int(round(tk.nd*_ease16(s, n16))) b = int(round(tk.nd*_ease16(s+1, n16))) seg = tk.axes[a:b] nx = int(np.count_nonzero(seg == 0)); ny = int(len(seg)-nx) rows.append(dict(t=t0 + s*S16, key=key, a=a, b=b, nx=nx, ny=ny)) _SCHED["rows"] = rows _SCHED["max"] = max([r["nx"]+r["ny"] for r in rows] + [1]) return _SCHED def detents_at(t): """(take-key, detent index) at time t — stepping once per 16th, which is why the line lurches instead of gliding.""" out = {} for key, t0, t1, tk in draw_windows(): n16 = max(1, int(round((t1-t0)/S16))) if t <= t0: out[key] = 0 elif t >= t1: out[key] = tk.nd else: s = min(int((t-t0)/S16) + 1, n16) out[key] = int(round(tk.nd*_ease16(s, n16))) return out # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ CH = {"A": nf("A1"), "G": nf("G1"), "D": nf("D2"), "C": nf("C2")} ORG = {"A": [nf("A3"), nf("E4"), nf("A4"), nf("C#5")], "G": [nf("G3"), nf("D4"), nf("G4"), nf("B4")], "D": [nf("D3"), nf("A3"), nf("D4"), nf("F#4")], "C": [nf("C3"), nf("G3"), nf("C4"), nf("E4")]} PROG = { "title": ["A", "A", "A", "A"], "dad": ["A", "A", "A", "G", "A", "A"], "mum": ["A", "A", "G", "G", "A", "A"], "dog": ["A", "A", "G", "A", "A"], "baby": ["G", "G", "A", "A", "A"], "house": ["A", "A", "G", "G", "A", "G", "A"], "hand": ["A", "A", "A", "A"], "shake": ["D", "D"], "blank": ["A", "A"], "again": ["A", "A", "A", "G", "G", "A", "A"], } # guitar: which 8ths of the bar get a downstroke (None = 16ths tremolo) GTR = {"title": [], "dad": [0, 1, 2, 3, 4, 5, 6, 7], "mum": [0, 1, 2, 3, 4, 5, 6, 7], "dog": [0, 2, 3, 4, 6, 7], "baby": [0, 3, 4, 7], "house": [0, 1, 2, 3, 4, 5, 6, 7], "hand": None, "shake": None, "blank": [], "again": [0, 1, 2, 3, 4, 5, 6, 7]} KICK = {"title": [0, 8], "dad": [0, 6, 8, 14], "mum": [0, 6, 8, 14], "dog": [0, 3, 8, 11], "baby": [0, 8], "house": [0, 4, 8, 12], "hand": [0, 8], "shake": [0, 2, 4, 6, 8, 10, 12, 14], "blank": [], "again": [0, 4, 6, 8, 12, 14]} SNR = {"title": [12], "dad": [4, 12], "mum": [4, 12], "dog": [4, 12], "baby": [8], "house": [4, 12], "hand": [4, 12], "shake": [1, 3, 5, 7, 9, 11, 13, 15], "blank": [], "again": [4, 12]} HAT = {"title": [0, 2, 4, 6, 8, 10, 12, 14], "dad": [0, 2, 4, 6, 8, 10, 12, 14], "mum": [0, 2, 4, 6, 8, 10, 12, 14], "dog": list(range(16)), "baby": [0, 4, 8, 12], "house": list(range(16)), "hand": list(range(16)), "shake": [], "blank": [0], "again": list(range(16))} LYRICS = [ (2, 0.0, "ONE LINE!", "gang"), (4, 0.0, "THAT'S MY DAD!", "lead"), (6, 2.0, "HE'S GOT A TIE!", "lead"), (8, 0.0, "DON'T LIFT THE LINE!", "gang"), (10, 0.0, "THAT'S MY MUM!", "lead"), (12, 2.0, "LOOK AT THAT HAIR!", "lead"), (14, 0.0, "NEVER LIFT THE LINE!", "gang"), (16, 0.0, "THAT'S THE DOG!", "lead"), (18, 2.0, "THE DOG! THE DOG!", "gang"), (21, 0.0, "THAT'S THE BABY!", "lead"), (23, 2.0, "GO ROUND! GO ROUND!", "gang"), (26, 0.0, "THAT'S THE HOUSE!", "lead"), (28, 2.0, "CHIMNEY! SMOKE!", "lead"), (30, 0.0, "ONE LINE! ONE LINE!", "gang"), (33, 0.0, "THE HAND! THE HAND!", "lead"), (35, 0.0, "NO NO NO NO!", "gang"), (37, 0.0, "SHAAAAKE!", "gang"), (39, 2.4, "...okay.", "lead"), (41, 0.0, "AGAIN!", "gang"), (43, 0.0, "FASTER!", "gang"), (45, 0.0, "FROM MEMORY!", "lead"), (47, 0.0, "ONE! LINE!", "gang"), ] def sec_of_bar(b): for nm, a, c in SECTIONS: if a <= b < c: return nm return SECTIONS[-1][0] def build_song(): s = Song(DUR) R = np.random.RandomState(180180) EV = [] def ev(t, kind, sub="", g=1.0): EV.append((round(float(t), 5), kind, str(sub), float(g))) for bar in range(N_BARS): sec = sec_of_bar(bar) t0 = bar*BAR pr = PROG[sec]; bi = bar - dict((n, a) for n, a, _ in SECTIONS)[sec] ch = pr[bi % len(pr)] root = CH[ch] last_bar = (bar+1) == dict((n, c) for n, _, c in SECTIONS)[sec] push = -0.006 if sec == "again" else 0.0 # the drummer rushes # ── guitar ───────────────────────────────────────────────────────── pat = GTR[sec] if pat is None: # tremolo 16ths for k in range(16): u = (bar - dict((n, a) for n, a, _ in SECTIONS)[sec])/max(1, len(pr)) g = 0.30 + 0.62*(k/16.0)*0.5 + 0.42*u if sec == "shake": g = 1.05 r2 = root*(1.0 if sec != "shake" else 2.0**(-k*0.035 - bi*0.4)) for tk in (0, 1): s.put(f"gtr{tk}", power_chord(r2, 0.085, seed=bar*31+k, track=tk), t0 + k*S16 + (R.rand()-0.5)*0.004, g=0.34*g, pan=-0.62 + 1.24*tk) ev(t0 + k*S16, "gtr", ch, g) else: for e8 in pat: t = t0 + e8*(BEAT/2.0) if sec == "title" and bar < 3: continue if sec == "title" and e8 < 4: continue dur = 0.17 if (e8 % 2 == 0) else 0.135 g = 1.0 if (e8 % 2 == 0) else 0.80 if last_bar and e8 >= 6: g *= 1.12 for tk in (0, 1): s.put(f"gtr{tk}", power_chord(root, dur, seed=bar*17+e8, track=tk), t + (R.rand()-0.5)*0.005 + push, g=0.40*g, pan=-0.66 + 1.32*tk) ev(t, "gtr", ch, g) # ── bass: driving eighths ────────────────────────────────────────── if sec not in ("blank",) and not (sec == "title" and bar < 2): bl = root*2.0 for e8 in range(8): t = t0 + e8*(BEAT/2.0) f = bl if last_bar and e8 >= 6 and sec in ("dad", "mum", "dog", "baby", "house"): f = bl*(1.1892 if e8 == 6 else 1.3348) # a walk-up fill if sec == "shake": f = bl*2.0**(-(bi*8 + e8)*0.06) s.put("bass", bass_note(f, 0.152, seed=bar*13+e8), t + (R.rand()-0.5)*0.004 + push, g=0.62, pan=0.0) ev(t, "bass", "", 0.9) # ── drums ────────────────────────────────────────────────────────── for k in KICK[sec]: s.put("kick", kick(seed=bar*7+k), t0 + k*S16 + (R.rand()-0.5)*0.005 + push, g=0.98, pan=0.0) ev(t0 + k*S16, "kick", "", 1.0) for k in SNR[sec]: hard = 1.0 + (0.4 if sec in ("house", "again", "shake") else 0.0) s.put("snare", snare(seed=bar*11+k, hard=hard), t0 + k*S16 + (R.rand()-0.5)*0.006 + push, g=0.80, pan=0.06) if R.rand() < 0.22: # flams — a human arm s.put("snare", snare(seed=bar*11+k+900, hard=0.6), t0 + k*S16 - 0.023, g=0.30, pan=0.02) ev(t0 + k*S16, "snare", "", 1.0) for k in HAT[sec]: op = (k % 8 == 6) and sec in ("dad", "mum", "house", "again") s.put("hat", hat(seed=bar*19+k, open_=op), t0 + k*S16 + (R.rand()-0.5)*0.007 + push, g=0.46*(0.72 + 0.5*(k % 4 == 0)), pan=-0.30) ev(t0 + k*S16, "hat", "open" if op else "", 0.6) # fills if last_bar and sec in ("dad", "mum", "dog", "baby", "house", "hand"): for j, k in enumerate((10, 11, 12, 13, 14, 15)): s.put("tom", tom(f0=(210 - j*22), seed=bar*23+k), t0 + k*S16, g=0.62, pan=-0.4 + 0.16*j) ev(t0 + k*S16, "tom", "", 0.9) # crashes crash_at = [] if bar in (4, 10, 16, 21, 26, 33, 37, 41, 45): crash_at.append(0) if sec in ("house", "again") and bar % 2 == 0: crash_at.append(8) if sec == "shake": crash_at += [0, 4, 8, 12] if bar == N_BARS-1: crash_at.append(0) for k in crash_at: s.put("crash", crash(seed=bar*29+k, dur=1.9 if k == 0 else 1.1), t0 + k*S16, g=0.62, pan=0.24 if k else -0.20) ev(t0 + k*S16, "crash", "", 1.2) # ── organ ────────────────────────────────────────────────────────── if sec in ("mum", "house", "again"): s.put("org", organ(ORG[ch], BAR*0.94, seed=bar), t0 + 0.004, g=0.30, pan=0.42) ev(t0, "org", ch, 0.8) elif sec == "dog": for k in (3, 7, 11, 15): s.put("org", organ(ORG[ch], 0.10, seed=bar*3+k), t0 + k*S16, g=0.38, pan=0.46) ev(t0 + k*S16, "org", ch, 0.7) elif sec == "baby": s.put("org", organ(ORG[ch][:3], BAR*0.9, seed=bar), t0, g=0.24, pan=0.40) elif sec == "hand": fr = [f*2.0**(bi*0.084) for f in ORG["A"]] s.put("org", organ(fr, BAR*0.98, seed=bar), t0, g=0.26 + 0.10*bi, pan=0.38) ev(t0, "org", "rise", 1.0) elif sec == "shake": for k in range(0, 16, 2): fr = [f*2.0**(-(bi*16+k)*0.028) for f in ORG["D"]] s.put("org", organ(fr, 0.14, seed=bar*5+k), t0 + k*S16, g=0.34, pan=0.30) # ── knob detents: one click per 16th, panned to the knob that moved ─ # (filled below, from the drawing schedule — geometry drives audio) # the knob track sch = schedule() mx = sch["max"] for r in sch["rows"]: tot = r["nx"] + r["ny"] if tot <= 0: continue amp = 0.35 + 0.65*min(1.0, tot/(mx*0.72)) if r["nx"] >= r["ny"] and r["nx"]: s.put("knob", detent(0, seed=int(r["t"]*1000) & 0xffff), r["t"], g=0.34*amp*(r["nx"]/tot + 0.35), pan=-0.80) if r["ny"] >= r["nx"] and r["ny"]: s.put("knob", detent(1, seed=int(r["t"]*1000+7) & 0xffff), r["t"], g=0.30*amp*(r["ny"]/tot + 0.35), pan=0.80) ev(r["t"], "click", "x" if r["nx"] >= r["ny"] else "y", amp) # ── the shakes: a rattle of powder and plastic ───────────────────────── for (b0, b1), n in ((SHAKE0, 5), (SHAKE1, 26)): for j in range(n): t = b0*BAR + j*(b1-b0)*BAR/n R2 = np.random.RandomState(4400 + j) nn = int(0.05*SR); tt = np.arange(nn)/SR rat = bandshape(R2.randn(nn), lo=420, hi=5200)*np.exp(-tt*70) s.put("shk", np.tanh(rat*2.4)*0.8, t, g=0.34, pan=(-1 if j % 2 else 1)*0.55) ev(t, "rattle", "", 1.0) # ── the vocal ────────────────────────────────────────────────────────── for bar, beat, text, mode in LYRICS: t = bar*BAR + beat*BEAT for voice, rate, k, pan in VOX[mode]: g = 0.52 if mode == "lead" else 0.34 if text.startswith("..."): g = 0.40 s.put("vox", vox(text, voice, rate, k, grit=1.0 if mode == "lead" else 0.85), t + (0.0 if pan == 0 else abs(pan)*0.012), g=g, pan=pan) ev(t, "vox", mode, 1.0) # a last let-ring chord over the tail tf = N_BARS*BAR for tk in (0, 1): s.put(f"gtr{tk}", power_chord(CH["A"], 1.5, seed=77771, track=tk, drive=6.0), tf, g=0.34, pan=-0.6 + 1.2*tk) s.put("crash", crash(seed=77772, dur=2.4), tf, g=0.60) s.put("bass", bass_note(CH["A"]*2, 1.3, seed=77773), tf, g=0.55) ev(tf, "crash", "", 1.4) s.bus("snare", lambda x: garage(x, seed=131)) s.bus("tom", lambda x: garage(x, seed=137)) s.bus("vox", lambda x: reverb(x, rt=0.8, mix=0.22, seed=139)) s.bus("org", lambda x: delay(x, BEAT*0.5, 0.20, 0.14, taps=3)) s.bus("knob", lambda x: delay(x, 0.041, 0.18, 0.10, taps=2)) mix = s.mixdown(dict(gtr0=1.0, gtr1=1.0, bass=1.0, kick=1.0, snare=1.0, hat=1.0, crash=1.0, tom=1.0, org=1.0, knob=1.0, vox=1.0, shk=1.0), levels=dict(title=0.72, dad=1.0, mum=1.0, dog=0.94, baby=0.86, house=1.05, hand=1.0, shake=1.08, blank=0.30, again=1.06, tail=0.72)) wav = AUD/"final.wav" s.write(wav, mix) EV.sort() (AUD/"events.json").write_text(json.dumps(EV)) print(f" {len(EV)} events · {sum(1 for e in EV if e[1]=='click')} knob clicks · " f"{sum(1 for e in EV if e[1]=='vox')} shouts") 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 < 190].sum() E["mid"][f] = sp[(fr >= 190) & (fr < 2400)].sum() E["high"][f] = sp[fr >= 2400].sum() for k in E: p = np.percentile(E[k], 95) + 1e-9 E[k] = np.clip(E[k]/p, 0, 1.35) np.savez(AUD/"env.npz", **E) return E _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV _EVF = {} def ev_frames(): if not _EVF: for t, kind, sub, g in json.loads((AUD/"events.json").read_text()): i = min(N_FRAMES-1, int(round(t*FPS))) _EVF.setdefault(i, []).append((kind, sub, g)) _EVF.setdefault(-1, []) return _EVF # ════════════════════════════════════════════════════════════════════════════ # THE SUBSTRATE — aluminium powder on the inside of the glass # ════════════════════════════════════════════════════════════════════════════ def _vnoise(h, w, scale, seed): R = np.random.RandomState(seed & 0x7fffffff) gh, gw = int(h/scale)+2, int(w/scale)+2 g = R.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) g00 = g[np.ix_(y0, x0)]; g01 = g[np.ix_(y0, x0+1)] g10 = g[np.ix_(y0+1, x0)]; g11 = g[np.ix_(y0+1, x0+1)] return ((g00*(1-sx)+g01*sx)*(1-sy) + (g10*(1-sx)+g11*sx)*sy).astype(np.float32) _POW = {} def powder_fields(): """The aluminium: two octaves of granular value noise, a broad sheen that catches the room light off the top-left, and a tooth field the stylus drags against.""" if not _POW: gr = (0.58*_vnoise(SH, SW, PXf(1.7), 9001) + 0.42*_vnoise(SH, SW, PXf(4.6), 9002)) _POW["grain"] = (0.86 + 0.30*gr).astype(np.float32) yy, xx = np.mgrid[0:SH, 0:SW] nx = xx/SW; ny = yy/SH sheen = (1.06 - 0.16*ny + 0.05*np.cos(nx*3.0) + 0.05*np.exp(-((nx-0.22)**2 + (ny-0.16)**2)/0.10)) _POW["sheen"] = sheen.astype(np.float32) _POW["tooth"] = (0.80 + 0.40*_vnoise(SH, SW, PXf(2.3), 9003)).astype(np.float32) _POW["burr"] = (0.55 + 0.75*_vnoise(SH, SW, PXf(1.25), 9004)).astype(np.float32) return _POW DARK = np.array([41.0, 39.0, 40.0], np.float32) # the plastic behind BASE = 172.0 # powder luminance HWID = PXf(3.15) # stylus half-width, px RW = PXf(3.6) # the ridge band class Screen: __slots__ = ("rem", "ridge", "fresh", "G", "S", "T", "B", "ref") def __init__(self): self.rem = np.zeros((SH, SW), np.float32) self.ridge = np.zeros((SH, SW), np.float32) self.fresh = np.zeros((SH, SW), np.float32) P = powder_fields() self.G, self.S, self.T, self.B = P["grain"], P["sheen"], P["tooth"], P["burr"] self.ref = None def scrape(self, x0, y0, x1, y1): """One axis-aligned run of detents. The stylus takes powder off (into `rem`, monotone) and pushes a bead of it to either side (`ridge`).""" pad = HWID + RW + PXf(2.0) ax0 = int(max(0, min(x0, x1) - pad)); ax1 = int(min(SW, max(x0, x1) + pad + 1)) ay0 = int(max(0, min(y0, y1) - pad)); ay1 = int(min(SH, max(y0, y1) + pad + 1)) if ax1 <= ax0 or ay1 <= ay0: return if abs(y1-y0) < 0.5: # horizontal run d = np.abs(np.arange(ay0, ay1, dtype=np.float32) - y0)[:, None] lo, hi = min(x0, x1)-HWID*0.55, max(x0, x1)+HWID*0.55 along = np.arange(ax0, ax1, dtype=np.float32)[None, :] ed = 2.4/SCL # edge softness, 1/px inside = np.clip((along-lo)*ed, 0, 1)*np.clip((hi-along)*ed, 0, 1) else: # vertical run d = np.abs(np.arange(ax0, ax1, dtype=np.float32) - x0)[None, :] lo, hi = min(y0, y1)-HWID*0.55, max(y0, y1)+HWID*0.55 along = np.arange(ay0, ay1, dtype=np.float32)[:, None] ed = 2.4/SCL inside = np.clip((along-lo)*ed, 0, 1)*np.clip((hi-along)*ed, 0, 1) T = self.T[ay0:ay1, ax0:ax1] core = np.clip((HWID - d)/PXf(1.15) + 0.5, 0.0, 1.0)*inside core = core*(0.90 + 0.10*T) np.maximum(self.rem[ay0:ay1, ax0:ax1], core.astype(np.float32), out=self.rem[ay0:ay1, ax0:ax1]) np.maximum(self.fresh[ay0:ay1, ax0:ax1], core.astype(np.float32), out=self.fresh[ay0:ay1, ax0:ax1]) band = (np.clip((d - HWID*0.86)/PXf(1.5), 0, 1) * np.clip((HWID + RW - d)/PXf(1.9), 0, 1))*inside band = band*self.B[ay0:ay1, ax0:ax1] np.maximum(self.ridge[ay0:ay1, ax0:ax1], band.astype(np.float32), out=self.ridge[ay0:ay1, ax0:ax1]) def spark(self, x, y, r=None, amt=0.9): if r is None: r = PXf(9.0) ax0 = int(max(0, x-r)); ax1 = int(min(SW, x+r+1)) ay0 = int(max(0, y-r)); ay1 = int(min(SH, y+r+1)) if ax1 <= ax0 or ay1 <= ay0: return yy, xx = np.mgrid[ay0:ay1, ax0:ax1] v = np.clip(1.0 - np.sqrt((xx-x)**2 + (yy-y)**2)/r, 0, 1)**1.6*amt np.maximum(self.fresh[ay0:ay1, ax0:ax1], v.astype(np.float32), out=self.fresh[ay0:ay1, ax0:ax1]) def decay(self, k=0.80): self.fresh *= k def shake_step(self, p, floor=0.075, first=False): """Turn it over and shake. The powder cascades — every pass smears the field vertically and drops its amplitude — and stops at a ghost of what was there, because a real one never fully erases. The ghost is frozen off the drawing as it stood at the first shake, so the next drawing is made on top of the last one.""" if first or self.ref is None: self.ref = (self.rem*floor).astype(np.float32) dy = max(1, int(round(PXf(1 + 6*(1.0-p))))) sm = 0.55*self.rem + 0.24*np.roll(self.rem, dy, 0) + 0.21*np.roll(self.rem, -dy, 0) k = 1.0 - 0.115*(0.40 + 0.60*p) self.rem = np.maximum(sm*k, self.ref).astype(np.float32) self.ridge *= 0.90 self.fresh *= 0.55 def settle(self): self.rem = (self.ref if self.ref is not None else np.zeros_like(self.rem)).astype(np.float32) self.ridge *= 0.0 self.fresh *= 0.0 def rgb(self, i, tremble=0.0, glint=0.0): rem = self.rem g = self.G*self.S if tremble > 0.001: R = np.random.RandomState(31000 + i) g = g*(1.0 + tremble*0.10*R.standard_normal((SH, SW)).astype(np.float32)) else: R = np.random.RandomState(31000 + i) g = g*(1.0 + 0.022*R.standard_normal((SH, SW)).astype(np.float32)) pw = BASE*(1.0 + 0.05*glint)*g pw = pw + self.ridge*(58.0*(1.0 - rem*0.75)) + self.fresh*26.0 v = DARK[None, None, :] + (pw[:, :, None] - DARK[None, None, :])*(1.0 - rem)[:, :, None] v[:, :, 2] *= 1.028 # aluminium is cold v[:, :, 0] *= 0.988 return v # ════════════════════════════════════════════════════════════════════════════ # THE TOY — red frame, two knobs, a tabletop # ════════════════════════════════════════════════════════════════════════════ RED = (188, 26, 32) RED_L = (226, 62, 60) RED_D = (118, 12, 18) _CHROME = {} def chrome(): if "a" not in _CHROME: im = Image.new("RGB", (TW, TH), (58, 44, 38)) d = ImageDraw.Draw(im) # tabletop for k in range(34): y = int(TH*k/34) sh = 1.0 - 0.22*(k/34.0) d.rectangle([0, y, TW, y + TH//34 + 1], fill=(int(62*sh), int(47*sh), int(40*sh))) # body shadow e7, e11, e16, e22, e24, e26, e30 = (PXi(7), PXi(11), PXi(16), PXi(22), PXi(24), PXi(26), PXi(30)) d.rounded_rectangle([BODY[0]+e16, BODY[1]+e22, BODY[2]+e24, BODY[3]+e26], radius=PXi(64), fill=(26, 18, 16)) d.rounded_rectangle(list(BODY), radius=PXi(58), fill=RED) d.rounded_rectangle([BODY[0]+e7, BODY[1]+e7, BODY[2]-e7, BODY[1]+e30], radius=e16, fill=RED_L) d.rounded_rectangle([BODY[0]+e7, BODY[3]-e26, BODY[2]-e7, BODY[3]-e7], radius=e16, fill=RED_D) d.rounded_rectangle([BODY[0]+e7, BODY[1]+e7, BODY[0]+e26, BODY[3]-e7], radius=e16, fill=RED_L) d.rounded_rectangle([BODY[2]-e26, BODY[1]+e7, BODY[2]-e7, BODY[3]-e7], radius=e16, fill=RED_D) # screen recess d.rounded_rectangle([SX0-e22, SY0-e22, SX0+SW+e22, SY0+SH+e22], radius=PXi(20), fill=(96, 12, 16)) d.rounded_rectangle([SX0-e11, SY0-e11, SX0+SW+e11, SY0+SH+e11], radius=PXi(12), fill=(22, 20, 20)) # brand plate f = font(52, "Impact.ttf") txt = "ONE LINE" bb = d.textbbox((0, 0), txt, font=f) d.text(((TW-(bb[2]-bb[0]))//2 - bb[0], PXi(918)), txt, font=f, fill=(244, 228, 214)) f2 = font(26, "Georgia.ttf") t2 = "magic screen · no. 505" bb2 = d.textbbox((0, 0), t2, font=f2) d.text(((TW-(bb2[2]-bb2[0]))//2 - bb2[0], PXi(986)), t2, font=f2, fill=(212, 156, 150)) _CHROME["a"] = np.asarray(im, np.uint8).copy() return _CHROME["a"] _KNOB = {} def knob_sprite(step): """A fluted cream knob. 44 detents per turn means 44 flutes; we bake 44 rotations, which is exactly one sprite per detent.""" if step not in _KNOB: SS = 3 r = KNOB_R*SS pad8 = max(2, PXi(8)) im = Image.new("RGBA", (2*r+pad8, 2*r+pad8), (0, 0, 0, 0)) d = ImageDraw.Draw(im) c = r+pad8//2 d.ellipse([c-r, c-r+PXf(7), c+r, c+r+PXf(9)], fill=(24, 14, 12, 150)) d.ellipse([c-r, c-r, c+r, c+r], fill=(242, 234, 218, 255)) a0 = step*(2*math.pi/44.0) for k in range(22): a = a0 + 2*math.pi*k/22.0 p = [] for j, (rr, da) in enumerate(((1.00, -0.055), (1.00, 0.055), (0.80, 0.042), (0.80, -0.042))): p.append((c + math.cos(a+da)*r*rr, c + math.sin(a+da)*r*rr)) d.polygon(p, fill=(206, 196, 180, 255)) d.ellipse([c-int(r*0.78), c-int(r*0.78), c+int(r*0.78), c+int(r*0.78)], fill=(248, 241, 227, 255)) d.ellipse([c-int(r*0.70), c-int(r*0.74), c+int(r*0.62), c+int(r*0.58)], fill=(253, 248, 238, 255)) # the index dimple, so the rotation is unmistakable dx, dy = math.cos(a0)*r*0.50, math.sin(a0)*r*0.50 d.ellipse([c+dx-r*0.10, c+dy-r*0.10, c+dx+r*0.10, c+dy+r*0.10], fill=(176, 162, 146, 255)) d.ellipse([c-r, c-r, c+r, c+r], outline=(168, 152, 138, 255), width=SS*PXi(3)) _KNOB[step] = im.resize(((2*r+pad8)//SS, (2*r+pad8)//SS), Image.LANCZOS) return _KNOB[step] _HAND = {} def hand_sprite(pose): """A hand. It is coming for the frame, and it is much bigger than the frame is.""" if pose not in _HAND: SS = 2 im = Image.new("RGBA", (TW//SS, TH//SS), (0, 0, 0, 0)) d = ImageDraw.Draw(im) cx, cy = (TW + PXi(210))//SS, PXi(690)//SS col = (30, 20, 19, 240) d.ellipse([cx-PXi(430)//SS, cy-PXi(360)//SS, cx+PXi(380)//SS, cy+PXi(400)//SS], fill=col) spread = {"far": 1.30, "near": 1.05, "grip": 0.82, "grip2": 0.75}[pose] curl = {"far": 1.14, "near": 1.02, "grip": 0.88, "grip2": 0.84}[pose] for (ang, ln0, wd0) in ((-1.34, 470, 78), (-0.74, 610, 82), (-0.14, 630, 80), (0.44, 545, 74), (1.30, 380, 92)): ln, wd = PXi(ln0), PXi(wd0) a = ang*spread + math.pi ex = cx + int(math.cos(a)*ln*curl)//SS ey = cy + int(math.sin(a)*ln*curl)//SS d.line([(cx, cy), (ex, ey)], fill=col, width=int(wd*2)//SS) d.ellipse([ex-wd//SS, ey-wd//SS, ex+wd//SS, ey+wd//SS], fill=col) im = im.filter(ImageFilter.GaussianBlur(PXf(2.2))) _HAND[pose] = im.resize((TW, TH), Image.LANCZOS) return _HAND[pose] def hand_state(t): """(pose, x-offset) — or None. The hand arrives over `hand`, grips through `shake`, and comes back for one last look at the very end.""" b = t/BAR if 33.0 <= b < 37.0: u = (b-33.0)/4.0 return ("far" if u < 0.45 else "near", int(PXf(620)*(1-u)**1.4)) if 37.0 <= b < 39.0: return ("grip" if int(t*22) % 2 == 0 else "grip2", 0) if b >= N_BARS - 0.9: u = min(1.0, (b - (N_BARS-0.9))/1.3) return ("far", int(PXf(700)*(1-u))) return None # ════════════════════════════════════════════════════════════════════════════ # THE SIM — one continuous stroke across the whole film # ════════════════════════════════════════════════════════════════════════════ class Sim: def __init__(self): self.sc = Screen() self.T = takes() self.di = {"title": 0, "t1": 0, "t2": 0} self.cur = (PXf(60.0), PXf(GY)) self.hang = 0.0; self.vang = 0.0 self.shk0 = 0; self.shk1 = 0 self.wiped = False def _run_to(self, key, target): tk = self.T[key] i = self.di[key] if target <= i: return target = min(target, tk.nd) q = TAKE_SPEC[key][5]*SCL # lattice index -> real pixel xs, ys, axes = tk.xs, tk.ys, tk.axes j = i while j < target: a = axes[j]; k = j while k < target and axes[k] == a: k += 1 x0, y0 = xs[j]*q, ys[j]*q x1, y1 = xs[k]*q, ys[k]*q self.sc.scrape(x0, y0, x1, y1) j = k self.di[key] = target self.cur = (float(xs[target]*q), float(ys[target]*q)) self.hang = float(tk.hang[target]); self.vang = float(tk.vang[target]) def step(self, i): t = i/FPS self.sc.decay(0.78) # the title is wiped on the first crash if SHAKE0[0]*BAR <= t < SHAKE0[1]*BAR: p = (t - SHAKE0[0]*BAR)/((SHAKE0[1]-SHAKE0[0])*BAR) self.sc.shake_step(p, floor=0.055, first=(self.shk0 == 0)) self.shk0 = 1 elif self.shk0 == 1 and t >= SHAKE0[1]*BAR: self.sc.settle(); self.shk0 = 2 if SHAKE1[0]*BAR <= t < SHAKE1[1]*BAR: p = (t - SHAKE1[0]*BAR)/((SHAKE1[1]-SHAKE1[0])*BAR) self.sc.shake_step(p, floor=0.105, first=(self.shk1 == 0)) self.shk1 = 1 elif self.shk1 == 1 and t >= SHAKE1[1]*BAR: self.sc.settle(); self.shk1 = 2 d = detents_at(t) for key in ("title", "t1", "t2"): self._run_to(key, d[key]) drawing = any(0 < d[k] < self.T[k].nd for k in d) if drawing: self.sc.spark(self.cur[0], self.cur[1], r=PXf(11.0), amt=0.95) return drawing def tremble(self, t): b = t/BAR if 33.0 <= b < 37.0: return 0.16 + 0.84*((b-33.0)/4.0)**1.5 if 37.0 <= b < 38.8: return 1.0 return 0.0 # ════════════════════════════════════════════════════════════════════════════ # CAMERA — crops of the tabletop # ════════════════════════════════════════════════════════════════════════════ SCX, SCY = SX0 + SW/2.0, SY0 + SH/2.0 def cam_for(kind, p, sim, k, u, i, e): t = i/FPS sh = PXf(3.0 + 14.0*e["low"] + 26.0*sim.tremble(t)) if kind == "toy": return (TW/2, TH/2 + PXf(6)*math.sin(u*2.4), 1.0 + 0.045*u*p.get("push", 0.0), sh*0.6) if kind == "tilt": return (TW/2 + PXf(60)*(u-0.5)*p.get("pan", 1.0), TH/2 - PXf(30), 1.13, sh*0.7) if kind == "screen": return (SCX + PXf(30)*(u-0.5)*p.get("pan", 0.0), SCY, 1.47 + 0.05*u, sh*0.8) if kind == "half": return (SX0 + SW*p.get("x", 0.3), SY0 + SH*p.get("y", 0.55), 1.72 + 0.09*u, sh*0.9) if kind == "macro": # follows the stylus, but wide enough that you can see what it is cx = SX0 + sim.cur[0]; cy = SY0 + sim.cur[1] z = p.get("z", 2.7) return cx, cy, z, sh*1.15 if kind == "knobs": # the knobs AND the bottom of the drawing, so the rotation reads as a # cause and the line reads as its effect if p.get("side", 0) == 0: return TW/2, PXf(812) + PXf(12)*u, 1.34, sh kx = KNOB_V[0] if p.get("right", 1) else KNOB_H[0] return (kx + PXf(-210 if p.get("right", 1) else 210), PXf(762) + PXf(12)*u, 1.66, sh) if kind == "corner": return (SX0 + SW*0.86, SY0 + SH*0.18, 2.35, sh*1.1) return TW/2, TH/2, 1.0, sh def compose(sim, i, kind, p, k, u, e): t = i/FPS trem = sim.tremble(t) glint = float(e["high"])*0.8 + float(e["rms"])*0.4 scr = sim.sc.rgb(i, tremble=trem, glint=glint) base = chrome().copy() si = np.clip(scr, 0, 255).astype(np.uint8) if trem > 0.02: # the powder trembles R = np.random.RandomState(52000 + i) oy = int(round((R.rand()-0.5)*PXf(6)*trem)) ox = int(round((R.rand()-0.5)*PXf(5)*trem)) si = np.roll(np.roll(si, oy, 0), ox, 1) base[SY0:SY0+SH, SX0:SX0+SW] = si im = Image.fromarray(base) # ── the show mark. The piece letters its OWN title into the powder over # the first four bars (that is the title card, and the first crash # wipes it); this is the other half, and it is stamped on the toy — # for three seconds the nameplate's second line reads PLAYER COMPUTER # instead of "magic screen · no. 505", then goes back to being a toy. tt = i/FPS if 1.35 <= tt < 3.60: al = min(1.0, (tt-1.35)/0.30)*min(1.0, (3.60-tt)/0.45) dm2 = ImageDraw.Draw(im, "RGBA") rw = TW*0.24 dm2.rectangle([TW/2-rw/2, PXi(978), TW/2+rw/2, PXi(1016)], fill=RED + (int(255*al),)) fo = font(25, "Impact.ttf") txt = "PLAYER COMPUTER" bb3 = dm2.textbbox((0, 0), txt, font=fo) dm2.text(((TW-(bb3[2]-bb3[0]))//2 - bb3[0], PXi(980)), txt, font=fo, fill=(238, 208, 198, int(255*al))) # the knobs, turned by exactly the detents their axis spent for (cx, cy), ang in ((KNOB_H, sim.hang), (KNOB_V, sim.vang)): st = int(round(ang/CLICK)) % 44 spr = knob_sprite(st) im.paste(spr, (int(cx - spr.width//2), int(cy - spr.height//2)), spr) hs = hand_state(t) if hs: pose, dx = hs hnd = hand_sprite(pose) if dx: hnd = hnd.transform(hnd.size, Image.AFFINE, (1, 0, -dx, 0, 1, 0)) im = Image.alpha_composite(im.convert("RGBA"), hnd).convert("RGB") # glass glare over the screen, last cx, cy, z, shk = cam_for(kind, p, sim, k, u, i, e) if shk: R = np.random.RandomState(61000 + i) cx += (R.rand()-0.5)*shk; cy += (R.rand()-0.5)*shk*0.8 vh = TH/z; vw = vh*(W/H) # crop at the DELIVERY aspect, never stretch cx = min(max(cx, vw/2), TW - vw/2); cy = min(max(cy, vh/2), TH - vh/2) box = (cx - vw/2, cy - vh/2, cx + vw/2, cy + vh/2) im = im.resize((W, H), Image.LANCZOS, box=box) return np.asarray(im, np.float32) # ════════════════════════════════════════════════════════════════════════════ # POST — tint -> vignette -> grain -> letterbox # ════════════════════════════════════════════════════════════════════════════ _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.40*r**2.0, 0, 1)[..., None].astype(np.float32) return _VIG["v"] TINT = np.array([1.030, 0.998, 0.958], np.float32) def post(arr, i): a = np.asarray(arr, np.float32)*TINT[None, None, :] a *= vignette() R = np.random.RandomState(71000 + i) if SCL == 1.0: a += R.normal(0, 3.4, 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 = R.normal(0, 3.4, (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) bh = int(H*0.045) d.rectangle([0, 0, W, bh], fill=(9, 8, 8)) d.rectangle([0, H-bh, W, H], fill=(9, 8, 8)) return out # ════════════════════════════════════════════════════════════════════════════ # THE SHOT PLAN — cut lengths in beats of a 180 bpm bar # ════════════════════════════════════════════════════════════════════════════ PLAN = { "title": ([("toy", dict(push=1.0)), ("screen", dict()), ("knobs", dict(side=0)), ("macro", dict(z=2.5))], [4, 6, 4]), "dad": ([("screen", dict()), ("macro", dict(z=2.8)), ("half", dict(x=0.14, y=0.62)), ("knobs", dict(side=1, right=1)), ("toy", dict(push=0.6)), ("macro", dict(z=3.3))], [3, 4, 6]), "mum": ([("half", dict(x=0.32, y=0.60)), ("macro", dict(z=2.9)), ("screen", dict(pan=1.0)), ("knobs", dict(side=0)), ("tilt", dict(pan=1.0)), ("macro", dict(z=2.4))], [3, 4, 6]), "dog": ([("macro", dict(z=3.1)), ("half", dict(x=0.47, y=0.72)), ("toy", dict(push=0.9)), ("knobs", dict(side=1)), ("screen", dict())], [3, 4, 5]), "baby": ([("half", dict(x=0.65, y=0.66)), ("macro", dict(z=2.6)), ("screen", dict(pan=-1.0)), ("toy", dict(push=0.4)), ("macro", dict(z=3.2))], [4, 5, 6]), "house": ([("half", dict(x=0.82, y=0.56)), ("macro", dict(z=2.8)), ("knobs", dict(side=0)), ("screen", dict()), ("tilt", dict(pan=-1.0)), ("macro", dict(z=3.4)), ("toy", dict(push=1.0))], [3, 4, 5]), "hand": ([("toy", dict(push=1.0)), ("screen", dict()), ("corner", dict()), ("half", dict(x=0.52, y=0.52)), ("knobs", dict(side=1, right=0))], [4, 5, 6]), "shake": ([("toy", dict()), ("screen", dict()), ("toy", dict(push=0.5)), ("corner", dict())], [2, 3, 4]), "blank": ([("toy", dict(push=0.2)), ("screen", dict())], [5, 8]), "again": ([("screen", dict()), ("macro", dict(z=3.0)), ("knobs", dict(side=0)), ("half", dict(x=0.30, y=0.60)), ("toy", dict(push=1.0)), ("macro", dict(z=3.6)), ("knobs", dict(side=1, right=1)), ("half", dict(x=0.80, y=0.55))], [2, 3, 4, 6]), } class Shot: __slots__ = ("idx", "i0", "i1", "n", "kind", "params", "section") def __init__(self, idx, i0, i1, kind, params, section): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1-i0 self.kind, self.params, self.section = kind, dict(params), section def build_shots(): R = np.random.RandomState(180001) shots = []; idx = 0; last = None for nm, b0, b1 in SECTIONS: pool, menu = PLAN[nm] t = b0*BAR; bag = [] 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.6: t2 = b1*BAR i0, i1 = int(round(t*FPS)), int(round(t2*FPS)) if i1 > i0: if not bag: bag = list(pool); R.shuffle(bag) if len(bag) > 1 and bag[0][0] == last: bag.append(bag.pop(0)) kind, par = bag.pop(0) last = kind shots.append(Shot(idx, i0, i1, kind, par, nm)) idx += 1 t = t2 if shots: shots[-1].i1 = N_FRAMES; shots[-1].n = N_FRAMES - shots[-1].i0 # the landing is the whole toy with the whole redrawn family on it — # never a macro of one corner shots[-1].kind, shots[-1].params = "toy", dict(push=0.30) if len(shots) > 1: shots[-2].kind, shots[-2].params = "screen", dict(pan=0.0) return shots # ════════════════════════════════════════════════════════════════════════════ def render_range(job): """A worker replays the sim from frame 0 draw-only (cheap — a few detents per frame), then composites only the frames it owns.""" lo, hi, force, want = job E = env(); ev_frames() sim = Sim() made = 0 for i in range(hi): sim.step(i) if i < lo or i not in want: continue p = FRAMES/f"f{i:05d}.png" if p.exists() and not force: continue sh = want[i] k = i - sh.i0; u = k/max(1, sh.n-1) e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} post(compose(sim, i, sh.kind, sh.params, k, u, e), i).save(p, compress_level=1) made += 1 return f"[{lo:5d}..{hi:5d}) {made} frames" def contact_sheet(shots): E = env(); ev_frames() sim = Sim() picks = {} for sh in shots: picks[min(N_FRAMES-1, sh.i0 + int(sh.n*0.62))] = sh 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)), (12, 12, 14)) sd = ImageDraw.Draw(sheet) n = 0 for i in range(N_FRAMES): sim.step(i) sh = picks.get(i) if sh is None: continue k = i - sh.i0; u = k/max(1, sh.n-1) e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} im = post(compose(sim, i, sh.kind, sh.params, k, u, e), i) im = im.resize((tw, th), Image.LANCZOS) cx, cy = (sh.idx % cols)*tw, (sh.idx//cols)*(th+lab) sheet.paste(im, (cx, cy)) sd.text((cx+PXi(5), cy+th+PXi(5)), f"{sh.idx:02d} {sh.kind} · {sh.section} · {sh.i0/FPS:.1f}s " f"({sh.n/FPS:.1f}s)", font=font(13, "Menlo.ttc"), fill=(198, 202, 210)) n += 1 p = OUT/"contact_sheet.png"; sheet.save(p) print(f"contact sheet -> {p} ({n}/{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("--plan-only", action="store_true") ap.add_argument("--jobs", type=int, default=min(14, os.cpu_count() or 4)) a = ap.parse_args() if a.plan_only: T = takes() for k, tk in T.items(): print(f" {k:6s} detents={tk.nd:6d} travel-cost={tk.cost:9.0f}px " f"path={tk.nd*Q:8.0f}px") sch = schedule() print(f" {len(sch['rows'])} sixteenths scheduled, max {sch['max']} detents/16th") return wav = AUD/"final.wav" need = (not wav.exists() or not (AUD/"env.npz").exists() or not (AUD/"events.json").exists()) if need or (a.force and not a.shots): print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") T = takes() for k, tk in T.items(): print(f" take {k}: {tk.nd} detents, travel {tk.cost:.0f}px") wav, mix = build_song(); analyze(mix) if a.audio_only: print(f"audio -> {wav}"); return shots = build_shots() if a.sheet: contact_sheet(shots); return if not a.mux_only: sel = set(int(x) for x in a.shots.split(",") if x.strip() != "") chosen = [s for s in shots if not sel or s.idx in sel] want = {} for s in chosen: for i in range(s.i0, s.i1): want[i] = s idxs = sorted(want) if not idxs: raise SystemExit("no frames selected") nj = max(1, min(a.jobs, len(idxs)//30 + 1)) bounds = [idxs[int(len(idxs)*j/nj)] for j in range(nj)] + [N_FRAMES] jobs = [] for j in range(nj): lo, hi = bounds[j], bounds[j+1] sub = {i: want[i] for i in idxs if lo <= i < hi} if sub: jobs.append((lo, max(sub)+1, a.force, sub)) print(f"[2/3] frames… {len(chosen)}/{len(shots)} shots, {len(idxs)} frames, " f"{len(jobs)} workers") import multiprocessing as mp with mp.get_context("fork").Pool(len(jobs)) as pool: for r in pool.imap_unordered(render_range, 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, first={missing[0]}") print("[3/3] 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" ts = datetime.datetime.now().astimezone().isoformat() stamp = (f"renders/{SETDIR}/{NAME}/render.py | git {sha} ({br}) | {ts} | " f"{DUR:.2f}s {FPS}fps {W}x{H} 16:9 | {MUSIC_DESC} | {ENGINE_DESC}") 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", "artist=poop / generative film", "-metadata", f"comment={stamp}", "-metadata", f"description={stamp}", str(out)], check=True, capture_output=True) T = takes() (OUT/"PROVENANCE.txt").write_text( f"generator: renders/{SETDIR}/{NAME}/render.py\n" f"git: {sha} branch: {br}\n" f"timestamp: {ts}\n" f"duration: {DUR:.2f}s fps: {FPS} size: {W}x{H} (16:9)\n" f"music: {MUSIC_DESC}\n" f"grid: {BPM:.0f}bpm, bar {BAR*1000:.0f}ms, 16th {S16*1000:.1f}ms, " f"{N_BARS} bars\n" f"sections: {' '.join(f'{n}({b-a})' for n, a, b in SECTIONS)}\n" f"substrate: {ENGINE_DESC}\n" f"screen: {SW}x{SH} powder field on a {TW}x{TH} tabletop; " f"detent {Q}px; {2*math.pi/CLICK:.0f} detents per knob turn\n" f"single stroke: title {T['title'].nd} detents (travel " f"{T['title'].cost:.0f}px) · take1 {T['t1'].nd} detents (travel " f"{T['t1'].cost:.0f}px) · take2 {T['t2'].nd} detents (travel " f"{T['t2'].cost:.0f}px) — one unbroken line each, no lift\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()