#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Errands (30/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/errands # # The future arrived as admin, and something reschedules your dentist while you sleep. # # 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/errands.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/errands.mp4 # cover: https://genekogan.com/player_computer/media/errands.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 errands.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_final — "ERRANDS" (tightened cut of second_nature 09) UK garage / 2-step, 136bpm, G minor. Chopped and pitched vocal. The future arrived as admin. Not the singularity — the dentist rescheduled, the return shipped, the form filled, the seventeen tabs closed for you while you were asleep. Something is doing your errands and the strangest part is how quickly that stopped being strange. Skippy 2-step drums, because the genre is about small movements happening constantly. The vocal is chopped, because so is the day. Composition: engine : audio-first x shot-parallel content: audio-groove (2-step kit, sub, organ bass, garage stabs) x tts-voices (chopped/pitched vocoder) x generative-art x effects-post Tightened for final curation: 64 bars -> 36 bars. Same groove, same chopped hook, same deadpan landing — recomposed, not sped up or truncated. See NOTES.md. FINAL CUT (player_computer_final): * Native 1920x1080. Two coordinate systems, one scale: the 512x288 engine canvas and the 1280x720 delivery frame are both authored units, and S = 1.5 turns either into pixels through a ScaledDraw proxy. The canvas is rasterised at 768x432 so the deliberate 2.5x LANCZOS bloom on top of it is exactly the bloom it always was — not a softer 3.75x one. Bloom radius, chroma offset and grain cell all scale with S. * Stripped: the section-name / running-timecode strip along the bottom of the frame. That was the renderer talking. Everything inside the picture — "SOLVED n TIMES", the done/running task rows, the MTWTFSS calendar header, the node labels — is the software the film is about, and stays. * Title flash: the ERRANDS card gains "PLAYER COMPUTER" under it, set in the same mono UI register with a mint underscore rule. """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "errands" TITLE = "ERRANDS" SETNUM = "09" # ── delivery scale ─────────────────────────────────────────────────────────── # AW/AH are the authored delivery frame; W/H are real pixels; S is the only # number the look scales by. The engine canvas (SW_/SH_, below) is authored in # its own units and rasterised through the same S, so the 2.5x upscale that # gives this piece its soft-sim surface is preserved exactly. AW, AH = 1280, 720 W, H, FPS = 1920, 1080, 30 S = H / AH def P(v): return int(round(v*S)) def B(r): return r*S def _sxy(v, s): if isinstance(v, (list, tuple)): return [_sxy(u, s) for u in v] return v*s class ScaledDraw: """ImageDraw proxy: authored units in, pixels out. Only the first positional arg (xy) and `width` are touched — arc/chord/pieslice take angles positionally and those must pass through untouched.""" __slots__ = ("_d", "_s") _GEOM = frozenset(("line", "rectangle", "rounded_rectangle", "ellipse", "polygon", "arc", "chord", "pieslice", "point", "text")) def __init__(self, d, s): self._d, self._s = d, s def __getattr__(self, name): f = getattr(self._d, name) if name not in self._GEOM: return f s = self._s def wrapped(xy, *a, **kw): w = kw.get("width") if w is not None: kw["width"] = max(2, int(round(w*s))) return f(_sxy(xy, s), *a, **kw) return wrapped def mkdraw(im): d = ImageDraw.Draw(im) return d if S == 1.0 else ScaledDraw(d, S) BPM = 136.0 BEAT = 60.0 / BPM BAR = 4 * BEAT SR = 44100 OUT = Path(__file__).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" # --- TIGHTENED ARRANGEMENT ------------------------------------------------- # The 64-bar original had two full 8-bar hook statements plus a long 8-bar # coast-out. The day it describes is short and repetitive, so the cut is too: # one full hook, a breakdown, a single-line reprise, and the landing. # wake 3 | list 8 | run1 8 | hold 4 | run2 6 | done 4 | idle 3 = 36 bars SECTIONS = [ ("wake", 0, 3), ("list", 3, 11), ("run1", 11, 19), ("hold", 19, 23), ("run2", 23, 29), ("done", 29, 33), ("idle", 33, 36), ] N_BARS = SECTIONS[-1][2] TAIL = 2.5 DUR = N_BARS * BAR + TAIL N_FRAMES = int(DUR * FPS) MUSIC_DESC = f"UK garage / 2-step, {BPM:.0f}bpm, G minor, {N_BARS} bars, chopped vocal" ENGINE_DESC = "iso / route / queue / parcels / calendar / handoff" 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 voice(freq, dur, kind="saw", nh=26, c0=5200, c1=700, ck=8.0, res=0.0, detune=(0.0,), a=.005, d=.09, s=.7, r=.10, vib=(0.0, 0.0), seed=0): """Additive voice through a *moving* emulated filter (cutoff array). The cutoff glides c0->c1 at rate ck; `res` bumps harmonics near the cutoff. This is what gives plucks, reeses and stabs their motion. """ 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) vd, vr = vib 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) ph = rng.uniform(0, 2*np.pi) phase = 2*np.pi*fk*t + ph if vd: phase = phase + vd * np.sin(2*np.pi*vr*t) out += g * np.sin(phase) out /= len(detune) return out * adsr(n, a, d, s, r) def fm(freq, dur, ratio=2.0, index=4.0, idec=6.0, a=.002, d=.4, s=.0, r=.2, seed=0): """2-op FM — rhodes / bells / glassy leads.""" n = int(dur*SR); t = np.arange(n)/SR mod = np.sin(2*np.pi*freq*ratio*t) * index * np.exp(-t*idec) return np.sin(2*np.pi*freq*t + mod) * adsr(n, a, d, s, r) def ks(freq, dur, damp=0.996, seed=0): """Karplus-Strong pluck — guitar / harp.""" n = int(dur*SR); L = max(2, int(SR/freq)) rng = np.random.RandomState(seed) buf = rng.uniform(-1, 1, L) out = np.zeros(n); j = 0 for i in range(n): out[i] = buf[j] buf[j] = damp * 0.5 * (buf[j] + buf[(j+1) % L]) j = (j+1) % L return out * adsr(n, .001, .05, .85, .25) def bandshape(x, lo=0.0, hi=0.0, order=4): """Exact FFT band shaping. Noise sources go through this so nothing in the kit is 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 kick(dur=.30, f0=155, f1=48, punch=30, click=.5, seed=1): n = int(dur*SR); t = np.arange(n)/SR f = f1 + (f0-f1)*np.exp(-t*punch) body = np.sin(2*np.pi*np.cumsum(f)/SR) * np.exp(-t*10.5) ck = np.random.RandomState(seed).randn(n) * np.exp(-t*300) * click return np.tanh((body + ck) * 1.7) * .95 def snare(dur=.22, tone=196, bright=1.0, seed=2): n = int(dur*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) nz = bandshape(rng.randn(n), lo=280, hi=6200) body = np.sin(2*np.pi*tone*t) + .6*np.sin(2*np.pi*tone*1.58*t) return nz*np.exp(-t*19)*.85*bright + body*np.exp(-t*26)*.50 def hat(dur=.055, openh=False, seed=7): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=5200, hi=9800) return nz * np.exp(-t*(14 if openh else 85)) * .40 def ride(dur=.6, seed=9): n = int(dur*SR); t = np.arange(n)/SR bell = sum(np.sin(2*np.pi*f*t) for f in (522, 831, 1180, 1567, 2103)) nz = bandshape(np.random.RandomState(seed).randn(n), lo=3800, hi=9000) return bell*np.exp(-t*10)*.10 + nz*np.exp(-t*5)*.16 def rim(dur=.09, seed=3): n = int(dur*SR); t = np.arange(n)/SR return (np.sin(2*np.pi*1750*t) + .5*np.sin(2*np.pi*2600*t)) * np.exp(-t*90) * .5 def shaker(dur=.09, seed=5): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=3600, hi=8600) return nz * (np.exp(-t*40) * np.clip(t*260, 0, 1)) * .40 def crash(dur=1.6, seed=13): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=8200) return nz * (np.exp(-t*2.6) + .3*np.exp(-t*.6)) * .55 def riser(dur=2.0, seed=17): n = int(dur*SR); t = np.arange(n)/SR env = (t/dur) ** 1.7 sweep = np.sin(2*np.pi*np.cumsum(140 + 3000*(t/dur)**2)/SR) # noise through a band that *rises with the sweep* — pitched motion, # not a static full-band hiss (AESTHETIC 13a) rng = np.random.RandomState(seed) nz = np.zeros(n); blk = 2048 for i in range(0, n, blk): u = (i/max(1, n)) ** 1.4 fc = 300 + 5200*u seg = rng.randn(min(blk, n-i) + 256) nz[i:i+min(blk, n-i)] = bandshape(seg, lo=fc*.72, hi=fc*1.5)[:min(blk, n-i)] return (nz*env*.55 + sweep*env*.22) * .8 def vinyl(n, seed=23): """Surface noise: filtered hiss + sparse crackle.""" rng = np.random.RandomState(seed) hiss = bandshape(rng.randn(n), lo=140, hi=5200) * .022 cr = np.zeros(n) idx = rng.choice(n, size=max(1, n//2400), replace=False) cr[idx] = rng.uniform(-1, 1, len(idx)) * .10 cr = np.convolve(cr, np.exp(-np.arange(60)/9), "same") return hiss + cr def reverb(x, rt=1.6, mix=.3, seed=29, pre=0.02): """FFT convolution with a synthetic exponentially-decaying noise IR.""" n = int(rt*SR); t = np.arange(n)/SR rng = np.random.RandomState(seed) ir = rng.randn(n) * np.exp(-t*(5.0/rt)) ir[:int(pre*SR)] = 0 ir /= np.abs(ir).sum() / 40.0 + 1e-9 from numpy.fft import rfft, irfft L = 1 << int(np.ceil(np.log2(len(x) + n))) wet = irfft(rfft(x, L) * rfft(ir, L))[:len(x)] wet /= np.max(np.abs(wet)) + 1e-9 return x * (1-mix) + wet * mix * (np.max(np.abs(x)) + 1e-9) def delay(x, time=.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 def lowpass(x, fc): a = np.exp(-2*np.pi*fc/SR); z = 0.0; y = np.empty_like(x) for i in range(len(x)): z = (1-a)*x[i] + a*z; y[i] = z return y class Song: """A multitrack canvas placed on an absolute bar/beat grid.""" def __init__(self, dur): self.n = int(dur*SR) self.tr = {} self.kick_t = [] def t(self, bar, step=0, swing=0.0): """absolute seconds of 16th-step `step` inside `bar`.""" 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) st = np.stack([sig[:j-i]*np.cos(th), sig[:j-i]*np.sin(th)], 1) * g b[i:j] += st 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.35): """Section dynamics: a smooth per-sample gain built from {section_name: level}. Arrangement alone tends to come out flat — this is the macro arc the ear actually follows.""" 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) k = max(1, int(glide*SR)) return np.convolve(env, np.ones(k)/k, "same") def mixdown(self, gains, pump_depth=.30, pump_rel=.16, 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]) env = np.convolve(env, np.ones(320)/320, "same") mix *= env[:, None] # DC / sub-30Hz rumble trim (one-pole HP per channel, vectorised # via cumulative difference of a one-pole LP) a = math.exp(-2*math.pi*30.0/SR) for c in range(2): lp = np.empty(self.n); z = 0.0 col = mix[:, c] for i in range(0, self.n, 4096): blk = col[i:i+4096] for j in range(len(blk)): z = (1-a)*blk[j] + a*z; lp[i+j] = z mix[:, c] = col - lp mix = np.tanh(mix*1.25)/np.tanh(1.25) 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(" 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 fit(x, n): """Resample to exactly n samples. Shifts formants a little; pitch is the carrier's job, so this is free.""" if len(x) < 2: return np.zeros(n) return np.interp(np.linspace(0, len(x)-1, n), np.arange(len(x)), x) def carrier(f_per_sample, nh=30, detune=(0.0, -0.55, 0.62), vib=(0.0, 0.0)): """Band-limited additive carrier with continuous phase across note changes.""" n = len(f_per_sample) t = np.arange(n)/SR out = np.zeros(n) for d in detune: f = f_per_sample*(1 + d*0.005) if vib[0]: f = f*(1 + vib[0]*np.sin(2*np.pi*vib[1]*t)) ph = 2*np.pi*np.cumsum(f)/SR for k in range(1, nh+1): live = (f*k) < SR*0.45 if not live.any(): break out += np.sin(ph*k)/k * live return out/len(detune) def vocode(mod, car, nfft=1024, hop=256, bands=26, lo=110, hi=6500, gmax=12.0, rel=0.55, sib=0.06, tilt=4200.0): """Transfer mod's band envelope onto car. Gains are clamped and the band set is bounded — an unclamped vocoder turns carrier aliasing into hiss.""" n = max(len(mod), len(car)) mod = np.pad(mod, (0, n-len(mod))); car = np.pad(car, (0, n-len(car))) win = np.hanning(nfft); nfr = 1 + max(0, (n-nfft))//hop fr = np.fft.rfftfreq(nfft, 1/SR) edges = np.geomspace(lo, hi, bands+1) idx = [np.where((fr >= edges[b]) & (fr < edges[b+1]))[0] for b in range(bands)] keep = np.zeros(len(fr), bool) for ii in idx: keep[ii] = True out = np.zeros(n); wsum = np.zeros(n)+1e-9 prev = np.zeros(bands) for f in range(nfr): s = f*hop M = np.fft.rfft(mod[s:s+nfft]*win); C = np.fft.rfft(car[s:s+nfft]*win) am = np.abs(M); ac = np.abs(C) g = np.zeros(len(fr)) for b, ii in enumerate(idx): if not len(ii): continue em = np.sqrt((am[ii]**2).mean()); ec = np.sqrt((ac[ii]**2).mean()) gb = np.clip(em/(ec+1e-4), 0, gmax) gb = prev[b]*rel + gb*(1-rel) prev[b] = gb; g[ii] = gb out[s:s+nfft] += np.fft.irfft(C*g*keep)*win wsum[s:s+nfft] += win**2 # floor the window sum: at the ramp-in/out edges it -> 0 and the divide # detonates into a single enormous spike ws = np.maximum(wsum, 0.35*np.median(wsum[nfft:max(nfft+1, n-nfft)])) y = out/ws y[:hop] = 0.0; y[-hop:] = 0.0 y /= (np.max(np.abs(y))+1e-9) hp = np.zeros_like(mod); hp[1:] = mod[1:]-mod[:-1] for _ in range(2): hp = np.convolve(hp, [1, -0.93], "same") hp = np.clip(hp/(np.percentile(np.abs(hp), 99.5)+1e-9), -1, 1) a = math.exp(-2*math.pi*tilt/SR); z = 0.0; lp = np.empty_like(y) for i in range(len(y)): z = (1-a)*y[i] + a*z; lp[i] = z y = 0.55*y + 0.85*lp + sib*hp return y/(np.max(np.abs(y))+1e-9) def sing(text, notes, dur, voice="Moira", rate=170, cache=None, nh=30, detune=(0.0, -0.55, 0.62), vib=(0.012, 5.2), gliss=0.012, **vk): """A sung line. `notes` = [(freq, weight), …] carved across `dur` seconds.""" n = int(dur*SR) key = cache/("say_"+_h(text, voice, rate)+".wav") mod = fit(say_wav(text, voice, rate, key), n) tot = sum(w for _, w in notes) or 1.0 f = np.zeros(n); at = 0 for i, (fq, w) in enumerate(notes): ln = int(n*w/tot) if i < len(notes)-1 else n-at f[at:at+ln] = fq; at += ln if gliss: # portamento: smooth the note edges k = max(3, int(gliss*SR)); f = np.convolve(f, np.ones(k)/k, "same") f[:k] = f[k]; f[-k:] = f[-k-1] car = carrier(f, nh=nh, detune=detune, vib=vib) return vocode(mod, car, **vk) def speak(text, dur=None, voice="Alex", rate=170, cache=None, pitch=1.0): """Plain spoken line (no vocoder) — for verses that shouldn't sing.""" key = cache/("say_"+_h(text, voice, rate)+".wav") x = say_wav(text, voice, rate, key) if pitch != 1.0: x = fit(x, int(len(x)/pitch)) if dur: x = fit(x, int(dur*SR)) if len(x) > int(dur*SR) else \ np.pad(x, (0, int(dur*SR)-len(x))) return x/(np.max(np.abs(x))+1e-9) # ════════════════════════════════════════════════════════════════════════════ # THE SONG # ════════════════════════════════════════════════════════════════════════════ SW = 0.14 # the skip PROG = [ # Gm9 - Eb - Cm7 - F (nf("G1"), [nf("Bb3"), nf("D4"), nf("A4")]), (nf("Eb1"), [nf("G3"), nf("Bb3"), nf("D4")]), (nf("C1"), [nf("Eb4"), nf("G4"), nf("Bb4")]), (nf("F1"), [nf("A3"), nf("C4"), nf("F4")]), ] G4, A4, Bb4, C5, D5, Eb5, F5 = (nf("G4"), nf("A4"), nf("Bb4"), nf("C5"), nf("D5"), nf("Eb5"), nf("F5")) HOOK = [("something is doing my errands while i sleep", [(D5,2),(D5,1),(C5,1),(Bb4,2),(C5,1),(D5,3)]), ("i wake up and the day is already halfway done", [(Bb4,2),(C5,1),(D5,1),(Eb5,2),(D5,1),(Bb4,3)])] LIST = ["reschedule the dentist", "return the blue one", "cancel the trial", "call the landlord back", "book the aisle seat", "dispute the charge", "renew it before friday", "close the seventeen tabs"] # All eight still label the queue / handoff graphs; only six get spoken in the # tightened list section — the two flattest ("call the landlord back", # "renew it before friday") are the ones cut. SPOKEN = [0, 1, 2, 4, 5, 7] DONE = [("nothing left to ask for", [(G4,2),(Bb4,1),(A4,1),(G4,2),(nf("D4"),4)])] def build_song(): s = Song(DUR) R = np.random.RandomState(1360) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "idle" for bar in range(N_BARS): sec = sec_of(bar) root, notes = PROG[bar % 4] runny = sec in ("run1", "run2") # wake is 3 bars now, so the kit enters on its last bar as a pickup kit = sec not in ("wake", "hold") or (sec == "wake" and bar >= 2) # ---- 2-step: kick on 1 and the and-of-3, snare on 3, skippy hats --- if kit: for st, v in ((0, 1.0), (10, .82)): at = s.t(bar, st, SW) s.put("drums", kick(dur=.26, f0=140, f1=50, punch=34, click=.4)*v, at, g=.92) s.kick_t.append(at) s.put("drums", snare(dur=.20, tone=214, bright=1.1), s.t(bar, 8, SW), g=.76, pan=-.04) if runny: s.put("drums", snare(dur=.12, tone=230, bright=.7), s.t(bar, 14, SW), g=.30, pan=.16) s.put("drums", rim(), s.t(bar, 6, SW), g=.30, pan=.30) for st in (2, 5, 7, 11, 13, 15): s.put("drums", hat(dur=.042, openh=(st in (5, 13))), s.t(bar, st, SW), g=.20 + .08*R.rand(), pan=-.3+.6*R.rand()) if bar % 8 == 7: for j, st in enumerate((12, 13, 14, 15)): s.put("drums", shakerhit(), s.t(bar, st, SW), g=.24, pan=-.3+.2*j) # ---- sub + organ bass --------------------------------------------- if sec != "wake" or bar >= 1: for st, ln in ((0, 1.1), (10, .8)): s.put("sub", voice(root*2, BEAT*ln, kind="sine", nh=3, c0=240, c1=110, ck=3, a=.006, d=.16, s=.85, r=.1, seed=bar*3+st), s.t(bar, st, SW), g=.46) if runny: for st in (4, 6, 12, 14): s.put("bass", voice(root*4, BEAT*.30, kind="square", nh=12, c0=1500, c1=520, ck=10, res=.5, a=.003, d=.08, s=.3, r=.06, seed=bar*5+st), s.t(bar, st, SW), g=.15, pan=-.2+.4*(st % 3)/2) # ---- garage organ stabs -------------------------------------------- if sec != "wake": hits = (2, 6, 11, 14) if runny else (2, 11) for st in hits: for k2, f2 in enumerate(notes): s.put("stab", voice(f2, BEAT*.30, kind="saw", nh=18, c0=2800, c1=1100, ck=12, res=.5, detune=(-1.0, 1.1), a=.003, d=.07, s=.30, r=.07, seed=bar*7+k2+st), s.t(bar, st, SW), g=.13, pan=-.35+.35*k2) # ---- pad / rhodes --------------------------------------------------- oc = dict(wake=900, list=1500, run1=2400, hold=1100, run2=2800, done=1600, idle=800).get(sec, 1500) for k2, f2 in enumerate(notes[:3]): s.put("pad", voice(f2/2, BAR*1.1, kind="saw", nh=18, c0=oc, c1=oc*.6, ck=.9, detune=(-1.2, 0, 1.3), a=.5, d=.6, s=.7, r=.8, seed=bar*11+k2), s.t(bar, 0), g=.10, pan=-.5+.5*k2) if sec in ("hold", "done", "idle"): s.put("keys", fm(notes[(bar) % 3]*2, 1.8, ratio=2.0, index=2.2, idec=5.5, d=1.0, r=.6, seed=bar*13), s.t(bar, 4, SW), g=.14, pan=.25) # ---- VOICES --------------------------------------------------------------- SECD = {n: a for n, a, b in SECTIONS} # the list: six errands spoken, dry, one per bar, alternating sides, with # the last bar of the section left empty to breathe into the drop for i, li in enumerate(SPOKEN): s.put("vox_sp", speak(LIST[li], voice="Samantha" if i % 2 else "Alex", rate=180, cache=AUD), (SECD["list"] + i + 1)*BAR + BEAT*0.25, g=.40, pan=-.30 + .60*(i % 2)) # the hook, sung, then chopped into 16ths that stutter on the beat. # run1 states it whole (both lines); run2 reprises only the second line, # so the second half is a shorter answer to the first, not a repeat of it. HOOK_PLAN = [(SECD["run1"], 0), (SECD["run1"] + 4, 1), (SECD["run2"], 1)] for hb, hi in HOOK_PLAN: text, mel = HOOK[hi] dur = BAR*4*0.92 sig = sing(text, mel, dur, voice="Moira", rate=172, cache=AUD, detune=(0.0, -0.7, 0.8), vib=(.012, 5.4)) at = hb*BAR + BEAT*0.25 s.put("vox", sig, at, g=.48) # the chop: slices of the same line thrown back two bars later step = BEAT/4 nsl = int(dur/step) RC = np.random.RandomState(hb*31 + hi) for q in range(nsl): if RC.rand() > 0.26: continue a0 = int(q*step*SR); a1 = int(min(len(sig), a0 + step*SR*1.1)) sl = sig[a0:a1] if len(sl) < 200: continue pit = [0.5, 1.0, 1.0, 1.5, 2.0][RC.randint(5)] sl = fit(sl, max(64, int(len(sl)/pit))) s.put("chop", sl, at + BAR*2 + q*step, g=.26, pan=-.5 + RC.rand()) for i, (t, mel) in enumerate(DONE): s.put("vox", sing(t, mel, BAR*3.0, voice="Moira", rate=160, cache=AUD, detune=(0.0, -0.9), vib=(.010, 4.6)), (SECD["done"] + 1)*BAR + BEAT*0.25, g=.44) # ---- one-shots -------------------------------------------------------------- for b in (SECD["run1"], SECD["run2"], SECD["done"]): s.put("fx", crash(dur=1.4), b*BAR, g=.24, pan=.1) s.put("fx", riser(BAR*3.0), (SECD["run1"] - 3)*BAR, g=.24) s.put("fx", riser(BAR*3.0), (SECD["run2"] - 3)*BAR, g=.24) s.put("fx", chime(1.4), SECD["done"]*BAR, g=.20) # ---- bus FX ------------------------------------------------------------------- s.bus("stab", lambda x: reverb(delay(x, BEAT*.75, .32, .22), rt=1.6, mix=.28, seed=281)) s.bus("pad", lambda x: reverb(x, rt=3.2, mix=.46, seed=283)) s.bus("keys", lambda x: reverb(delay(x, BEAT*.75, .34, .26), rt=2.8, mix=.44, seed=293)) s.bus("vox", lambda x: reverb(delay(x, BEAT*.75, .28, .18), rt=2.0, mix=.30, seed=307)) s.bus("chop", lambda x: delay(x, BEAT*.25, .36, .28)) s.bus("vox_sp", lambda x: reverb(x, rt=1.0, mix=.14, seed=311)) mix = s.mixdown(dict(drums=1.0, sub=1.0, bass=1.0, stab=1.0, pad=1.0, keys=1.0, vox=1.0, chop=1.0, vox_sp=1.0, fx=1.0), pump_depth=.28, pump_rel=.14, levels=dict(wake=.36, list=.70, run1=1.0, hold=.52, run2=1.0, done=.68, idle=.34)) wav = AUD / "final.wav" s.write(wav, mix) return wav, mix def shakerhit(dur=.08, seed=97): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=4000, hi=9200) return nz*(np.exp(-t*44)*np.clip(t*300, 0, 1))*0.45 def chime(dur=1.4): n = int(dur*SR); t = np.arange(n)/SR x = sum(np.sin(2*np.pi*f*t)*w for f, w in ((880, 1.0), (1320, .5), (1760, .3))) return x*np.exp(-t*3.2)*0.35 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 _ENV = {} def env(): if not _ENV: z = np.load(AUD/"env.npz") for k in z.files: _ENV[k] = z[k] return _ENV # ════════════════════════════════════════════════════════════════════════════ # VISUAL ENGINES # ════════════════════════════════════════════════════════════════════════════ SW_, SH_ = 512, 288 # round 2: 16:9 engine canvas (was 448x288) NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} PAL = { "night": (14, 16, 26), "slate": (44, 52, 70), "steel": (96, 110, 136), "mint": (110, 226, 190), "lilac": (168, 150, 236), "coral": (250, 122, 108), "amber": (248, 190, 88), "bone": (236, 236, 240), "grid": (30, 36, 52), } def ramp(stops, n=256): stops = np.array(stops, np.float32) xs = np.linspace(0, 1, len(stops)); g = np.linspace(0, 1, n) return np.stack([np.interp(g, xs, stops[:, c]) for c in range(3)], 1) def apply_ramp(v01, lut): i = np.clip(v01*(len(lut)-1), 0, len(lut)-1).astype(np.int32) return lut[i] def value_noise(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 = (fx0 := (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 def fbm(h, w, scale, seed, oct=4): out = np.zeros((h, w)); amp = 1.0; nrm = 0.0 for o in range(oct): out += amp*value_noise(h, w, max(2, scale/(2**o)), seed+o) nrm += amp; amp *= .5 return out/nrm RAMPS = { "city": ramp([PAL["night"], PAL["slate"], PAL["steel"], PAL["mint"], PAL["bone"]]), "warm": ramp([PAL["night"], (80, 50, 90), PAL["coral"], PAL["amber"], PAL["bone"]]), "cool": ramp([PAL["night"], PAL["slate"], PAL["lilac"], PAL["mint"], PAL["bone"]]), } def _img(w=SW_, h=SH_, bg=PAL["night"]): """Canvas in engine units; the raster is S times bigger and the proxy scales every coordinate into it, so nothing in the engines changes.""" im = Image.new("RGB", (P(w), P(h)), bg); return im, mkdraw(im) class Iso: """An isometric block of city, centred in frame and different every time. The first version projected from SH_*0.34 with a flat height field, so the grid floated in the top half and the bottom half was empty. Now the whole plan is measured, centred on its own bounding box, and each shot draws a different DISTRICT — towers, terraces, a park, a yard, water. """ KINDS = ("downtown", "terraces", "park", "yard", "waterfront") def __init__(self, shot, rng): self.rng = rng self.n = int(rng.integers(6, 10)) self.kind = self.KINDS[int(rng.integers(0, len(self.KINDS)))] self.zoom = float(rng.uniform(0.85, 1.25)) self.spin = float(rng.uniform(-0.10, 0.10)) n = self.n R = np.random.RandomState(int(rng.integers(1e6))) self.h = np.zeros((n, n)) self.type = np.zeros((n, n), int) # 0 block 1 road 2 park 3 water 4 roof-lot for r in range(n): for c in range(n): if self.kind == "downtown": self.h[r, c] = 0.4 + R.rand()**1.7*3.4 elif self.kind == "terraces": self.h[r, c] = 0.5 + (r % 2)*0.35 + R.rand()*0.5 elif self.kind == "park": self.h[r, c] = 0.15 + R.rand()*0.35 elif self.kind == "yard": self.h[r, c] = 0.25 + R.rand()*0.9 else: self.h[r, c] = 0.3 + R.rand()*1.6 # roads on a couple of lines for q in range(int(R.randint(1, 3))): if R.rand() < .5: self.type[R.randint(0, n), :] = 1 else: self.type[:, R.randint(0, n)] = 1 if self.kind == "park": for r in range(n): for c in range(n): if self.type[r, c] == 0 and R.rand() < .55: self.type[r, c] = 2 if self.kind == "waterfront": cut = R.randint(1, max(2, n-1)) self.type[:, :cut] = 3 if self.kind == "yard": for r in range(n): for c in range(n): if self.type[r, c] == 0 and R.rand() < .35: self.type[r, c] = 4 self.h[self.type == 1] = 0.06 self.h[self.type == 2] = 0.10 self.h[self.type == 3] = 0.0 self.win = R.rand(n, n, 5, 3) > 0.42 self.lit = R.rand(n, n) # agents move along the road cells roads = [(r, c) for r in range(n) for c in range(n) if self.type[r, c] == 1] self.roads = roads or [(0, 0)] self.na = int(rng.integers(8, 22)) self.ap = rng.random((self.na, 2))*n self.at = np.array([self.roads[int(rng.integers(0, len(self.roads)))] for _ in range(self.na)], float) self.aph = rng.random(self.na) self.spd = rng.uniform(.004, .018, self.na) self.trees = [(R.rand()*n, R.rand()*n) for _ in range(18)] def _ax(self): # sized so an n=9 plan fills the 448x288 canvas without clipping base = 22.0*self.zoom*(7.5/max(6, self.n)) return base, base*0.50, base*0.68 def _proj(self, gx, gy, gz, cx, cy): ax, ay, az = self._ax() return (cx + (gx-gy)*ax, cy + (gx+gy)*ay - gz*az) def frame(self, k, u, e): im, d = _img() n = self.n ax, ay, az = self._ax() # centre the whole plan on its own bounding box, then nudge with a # slow drift — this is what stops it sitting in the top half # the engine canvas is SW_ x SH_, NOT the delivery size — using W/H # here put the whole city off the bottom-right corner. maxz = float(self.h.max())*az # plan spans y in [cy - n*ay - maxz, cy + n*ay]; centre that on SH_/2 cx = SW_*0.5 + math.sin(k*0.006 + self.spin*9)*14 cy = SH_*0.5 + maxz*0.5 order = sorted([(r, c) for r in range(n) for c in range(n)], key=lambda rc: rc[0]+rc[1]) for (r, c) in order: gx, gy = c-n/2, r-n/2 h = self.h[r, c] ty = self.type[r, c] top = [self._proj(gx-.48, gy-.48, h, cx, cy), self._proj(gx+.48, gy-.48, h, cx, cy), self._proj(gx+.48, gy+.48, h, cx, cy), self._proj(gx-.48, gy+.48, h, cx, cy)] base = [self._proj(gx+.48, gy-.48, 0, cx, cy), self._proj(gx+.48, gy+.48, 0, cx, cy), self._proj(gx-.48, gy+.48, 0, cx, cy)] if ty == 3: wob = math.sin(k*0.05 + gx+gy)*2 tp = [(x, y+wob) for (x, y) in top] d.polygon(tp, fill=(28, 52, 92)) d.line([tp[0], tp[1]], fill=(52, 92, 142), width=2) continue gl = 0.30 + 0.55*self.lit[r, c]*(0.4+0.9*e["mid"]) face_r = tuple(int(v*gl*0.58) for v in PAL["slate"]) face_l = tuple(int(v*gl*0.36) for v in PAL["slate"]) topc = PAL["steel"] if ty == 0 else ( (54, 60, 78) if ty == 1 else ( (46, 104, 74) if ty == 2 else (74, 78, 96))) d.polygon([top[1], top[2], base[1], base[0]], fill=face_r) d.polygon([top[2], top[3], base[2], base[1]], fill=face_l) d.polygon(top, fill=tuple(int(v*gl) for v in topc)) if ty == 0 and h > 0.5: for wr in range(5): for wc in range(3): if not self.win[r, c, wr, wc]: continue fx = (wc+0.5)/3.0; fz = (wr+0.5)/5.0*h p0 = self._proj(gx+.48, gy-.48+fx*.96, fz, cx, cy) wglow = 0.35+0.65*abs(math.sin(k*0.06 + wr + wc + r + c)) d.rectangle([p0[0]-2, p0[1]-3, p0[0]+2, p0[1]+3], fill=tuple(min(255, int(v*wglow)) for v in PAL["amber"])) if ty == 1: # road markings d.line([top[0], top[2]], fill=(96, 104, 126), width=1) if ty == 4: # yard: crates on the roof for q in range(3): p0 = self._proj(gx-.2+q*0.2, gy-.2+q*0.15, h, cx, cy) col = [PAL["coral"], PAL["amber"], PAL["lilac"]][q] d.rectangle([p0[0]-7, p0[1]-9, p0[0]+7, p0[1]+2], fill=tuple(int(v*gl) for v in col)) if self.kind == "park": for (tx, ty2) in self.trees: gx, gy = tx-n/2, ty2-n/2 if self.type[min(n-1, int(ty2)), min(n-1, int(tx))] != 2: continue p0 = self._proj(gx, gy, self.h[min(n-1, int(ty2)), min(n-1, int(tx))], cx, cy) d.line([p0[0], p0[1], p0[0], p0[1]-12], fill=(62, 46, 34), width=2) d.ellipse([p0[0]-8, p0[1]-24, p0[0]+8, p0[1]-8], fill=(56, 126, 82)) # a crane, because a city is always half-built if self.kind in ("downtown", "yard"): gx, gy = -n/2+1.0, -n/2+1.0 p0 = self._proj(gx, gy, 0, cx, cy) topz = float(self.h.max())+1.4 p1 = self._proj(gx, gy, topz, cx, cy) d.line([p0, p1], fill=(150, 140, 90), width=3) swing = math.sin(k*0.012)*0.9 p2 = self._proj(gx+math.cos(swing)*2.6, gy+math.sin(swing)*2.6, topz, cx, cy) d.line([p1, p2], fill=(150, 140, 90), width=3) d.line([p2, (p2[0], p2[1]+26)], fill=(120, 112, 78), width=1) # agents for i in range(self.na): t2 = (self.aph[i] + k*self.spd[i]) % 1.0 gx = self.ap[i, 0]*(1-t2) + self.at[i, 0]*t2 - n/2 gy = self.ap[i, 1]*(1-t2) + self.at[i, 1]*t2 - n/2 gz = self.h[min(n-1, max(0, int(gy+n/2))), min(n-1, max(0, int(gx+n/2)))] x, y = self._proj(gx, gy, gz + 0.9 + 0.16*math.sin(k*0.1+i), cx, cy) rr = 3.0 + 2.0*e["kick"] d.ellipse([x-rr, y-rr, x+rr, y+rr], fill=PAL["mint"]) d.line([x, y+rr, x, y+rr+9], fill=tuple(int(v*0.42) for v in PAL["mint"])) return np.asarray(im, np.float32) class Route: """A route being solved, and re-solved, and re-solved.""" def __init__(self, shot, rng): self.rng = rng self.n = int(rng.integers(6, 16)) self.p = np.stack([rng.uniform(.08, .92, self.n), rng.uniform(.12, .88, self.n)], 1) self.order = np.arange(self.n) self.ramp = RAMPS["cool"] self.roads = [(rng.uniform(0, 1), rng.random() < .5) for _ in range(int(rng.integers(4, 12)))] self.tick = 0 def frame(self, k, u, e): if k % max(6, int(26 - 18*e["rms"])) == 0: # re-solve i, j = self.rng.integers(0, self.n, 2) self.order[[i, j]] = self.order[[j, i]] self.tick += 1 im, d = _img(bg=(12, 14, 22)) for (pos, horiz) in self.roads: if horiz: d.line([0, pos*SH_, SW_, pos*SH_], fill=PAL["grid"], width=3) else: d.line([pos*SW_, 0, pos*SW_, SH_], fill=PAL["grid"], width=3) pts = [(self.p[i, 0]*SW_, self.p[i, 1]*SH_) for i in self.order] prog = np.clip(u*1.3, 0, 1) m = max(2, int(len(pts)*prog)) d.line(pts[:m], fill=tuple(int(v*(0.4+0.7*e["mid"])) for v in PAL["mint"]), width=2, joint="curve") for idx, (x, y) in enumerate(pts): done = idx < m r = 4.0 if done else 2.5 c = PAL["amber"] if done else PAL["steel"] d.ellipse([x-r, y-r, x+r, y+r], fill=tuple(int(v) for v in c)) f = font(12) d.text((10, SH_-20), "SOLVED %d TIMES" % self.tick, font=f, fill=PAL["steel"]) return np.asarray(im, np.float32) class Queue: """A list that ticks itself off.""" def __init__(self, shot, rng): self.rng = rng self.rows = int(rng.integers(6, 13)) self.pick = rng.integers(0, len(LIST), self.rows) self.when = np.sort(rng.uniform(0.05, 0.95, self.rows)) self.warm = bool(rng.random() < .4) self.spawn = rng.random(self.rows) > 0.72 def frame(self, k, u, e): bg = (18, 18, 26) if not self.warm else (26, 20, 24) im, d = _img(bg=bg) f = font(15); fb = font(13) rh = SH_*0.86/self.rows for r in range(self.rows): y = SH_*0.07 + r*rh done = u > self.when[r] col = PAL["steel"] if not done else PAL["mint"] col = tuple(int(v*(0.5+0.7*e["mid"])) for v in col) d.rectangle([22, y+3, 22+16, y+19], outline=col, width=2) if done: d.line([25, y+11, 29, y+16], fill=col, width=3) d.line([29, y+16, 37, y+5], fill=col, width=3) txt = LIST[self.pick[r]] d.text((52, y+3), txt, font=f, fill=col) if done: d.line([50, y+12, 52+7.0*len(txt), y+12], fill=col, width=1) d.text((SW_-86, y+5), "done", font=fb, fill=col) elif self.spawn[r] and u > self.when[r]-0.12: d.text((SW_-96, y+5), "running", font=fb, fill=tuple(int(v*(0.4+0.8*abs(math.sin(k*0.2)))) for v in PAL["amber"])) return np.asarray(im, np.float32) class Parcels: """A belt. Things going somewhere on your behalf.""" def __init__(self, shot, rng): self.rng = rng self.nb = int(rng.integers(2, 5)) self.by = np.sort(rng.uniform(0.18, 0.86, self.nb)) self.spd = rng.uniform(0.6, 2.8, self.nb)*np.where(rng.random(self.nb) < .5, -1, 1) self.np_ = int(rng.integers(4, 12)) self.px = rng.random((self.nb, self.np_)) self.psz = rng.uniform(0.5, 1.3, (self.nb, self.np_)) self.pc = rng.integers(0, 3, (self.nb, self.np_)) def frame(self, k, u, e): im, d = _img(bg=(16, 17, 24)) cols = [PAL["amber"], PAL["coral"], PAL["lilac"]] for b in range(self.nb): y = self.by[b]*SH_ d.rectangle([0, y+16, SW_, y+24], fill=PAL["slate"]) for q in range(0, SW_, 14): # belt teeth xx = (q + k*self.spd[b]*2.2) % SW_ d.line([xx, y+16, xx, y+24], fill=(20, 24, 34), width=2) for i in range(self.np_): x = ((self.px[b, i] + k*self.spd[b]*0.0038) % 1.0)*SW_ w2 = 16*self.psz[b, i]; h2 = 13*self.psz[b, i] c = cols[self.pc[b, i]] g = 0.45 + 0.55*e["mid"] d.rectangle([x-w2, y+16-h2*2, x+w2, y+16], fill=tuple(int(v*g*0.75) for v in c), outline=tuple(int(v*g) for v in c)) d.line([x-w2, y+16-h2, x+w2, y+16-h2], fill=tuple(int(v*g*0.4) for v in PAL["bone"]), width=1) d.line([x, y+16-h2*2, x, y+16], fill=tuple(int(v*g*0.4) for v in PAL["bone"]), width=1) return np.asarray(im, np.float32) class Calendar: """A week filling in without being asked.""" def __init__(self, shot, rng): self.rng = rng self.cols = int(rng.integers(5, 8)); self.rows = int(rng.integers(8, 16)) n = self.cols*self.rows self.when = rng.random(n) self.kind = rng.integers(0, 3, n) self.span = rng.integers(1, 4, n) self.mine = rng.random(n) > 0.72 def frame(self, k, u, e): im, d = _img(bg=(20, 21, 28)) cw = SW_/self.cols; rh = SH_*0.88/self.rows for c in range(self.cols+1): d.line([c*cw, SH_*0.10, c*cw, SH_], fill=PAL["grid"], width=1) for r in range(self.rows+1): d.line([0, SH_*0.10+r*rh, SW_, SH_*0.10+r*rh], fill=PAL["grid"], width=1) f = font(11) cols = [PAL["mint"], PAL["lilac"], PAL["coral"]] for c in range(self.cols): for r in range(self.rows): i = c*self.rows+r if self.when[i] > u*1.15: continue y0 = SH_*0.10 + r*rh hgt = rh*min(self.span[i], self.rows-r) - 3 col = cols[self.kind[i]] if not self.mine[i] else PAL["steel"] g = 0.45 + 0.55*e["mid"] d.rectangle([c*cw+3, y0+2, (c+1)*cw-3, y0+hgt], fill=tuple(int(v*g*0.45) for v in col), outline=tuple(int(v*g) for v in col)) d.rectangle([0, 0, SW_, SH_*0.10], fill=(14, 15, 20)) for c in range(self.cols): d.text((c*cw+8, SH_*0.03), "MTWTFSS"[c % 7], font=f, fill=PAL["steel"]) return np.asarray(im, np.float32) class Handoff: """Tokens passed between things that never explain themselves.""" def __init__(self, shot, rng): self.rng = rng self.n = int(rng.integers(4, 10)) ang = np.linspace(0, 2*np.pi, self.n, endpoint=False) + rng.random()*6.28 rad = float(rng.uniform(0.26, 0.40)) self.p = np.stack([0.5+np.cos(ang)*rad*1.5, 0.5+np.sin(ang)*rad], 1) self.tok = [] self.label = rng.integers(0, len(LIST), self.n) self.ramp = RAMPS["cool"] for q in range(30): self.frame(q, 0.0, NEUTRAL) def frame(self, k, u, e): rng = self.rng if e["kick"] > 0.30 or rng.random() < 0.14: a = int(rng.integers(0, self.n)); b = int(rng.integers(0, self.n)) if a != b: self.tok.append([a, b, 0.0]) alive = [] for t in self.tok: t[2] += 0.035 + 0.06*e["mid"] if t[2] < 1.0: alive.append(t) self.tok = alive[-140:] im, d = _img(bg=(12, 14, 22)) for i in range(self.n): for j in range(self.n): if i >= j: continue x0, y0 = self.p[i]*[SW_, SH_]; x1, y1 = self.p[j]*[SW_, SH_] d.line([x0, y0, x1, y1], fill=(26, 32, 46), width=1) for t in self.tok: a, b, tt = t x = self.p[a][0]+(self.p[b][0]-self.p[a][0])*tt y = self.p[a][1]+(self.p[b][1]-self.p[a][1])*tt r = 2.2 + 2.4*e["high"] d.ellipse([x*SW_-r, y*SH_-r, x*SW_+r, y*SH_+r], fill=PAL["mint"]) f = font(11) for i in range(self.n): x, y = self.p[i]*[SW_, SH_] r = 12 + 5*e["kick"] d.ellipse([x-r, y-r, x+r, y+r], outline=PAL["steel"], width=2) d.ellipse([x-r*.5, y-r*.5, x+r*.5, y+r*.5], fill=tuple(int(v*(0.3+0.7*e["mid"])) for v in PAL["lilac"])) d.text((x-r, y+r+3), LIST[self.label[i]][:12], font=f, fill=(96, 108, 132)) return np.asarray(im, np.float32) ENGINES = {"iso": Iso, "route": Route, "queue": Queue, "parcels": Parcels, "calendar": Calendar, "handoff": Handoff} # shot-length menus are in BEATS, and are re-scaled to the tightened sections # so no section is a single held shot and no section ticks metronomically PLAN = { "wake": (["iso", "calendar", "route"], [6, 6, 12]), "list": (["queue", "iso", "handoff", "calendar"], [8, 4, 8, 4]), "run1": (["iso", "parcels", "handoff", "route", "queue"], [4, 2, 4, 8, 2]), "hold": (["calendar", "route", "queue"], [8, 8, 16]), "run2": (["parcels", "iso", "handoff", "queue", "route"], [2, 4, 2, 8, 4]), "done": (["queue", "calendar", "iso"], [8, 8, 16]), "idle": (["iso", "route", "calendar"], [8, 12, 16]), } CARDS = { "wake": "ERRANDS", "list": None, "run1": None, "hold": "YOU DID NOT ASK FOR MOST OF THIS", "run2": None, "done": None, "idle": "NOTHING LEFT TO ASK FOR", } SYSTEM_NAMES = ["TASK", "RETRY", "CONFIRM", "REFUND", "BOOKED", "CLOSED"] # Contact-sheet fixes. Two shots drew near-empty on the first pass — 00 (route # with barely any stops placed under the title card) and 15 (handoff whose # node ring rotated out to the frame edges). Salting only those two seeds # leaves every other shot byte-identical. SEED_SALT = {0: 3, 15: 5} class Shot: __slots__ = ("idx", "i0", "i1", "n", "engine", "section", "seed", "text", "card") def __init__(self, idx, i0, i1, engine, section, text=None, card=None): self.idx, self.i0, self.i1 = idx, i0, i1 self.n = i1 - i0 self.engine, self.section = engine, section self.seed = 90210 + idx*7919 + SEED_SALT.get(idx, 0)*104729 self.text, self.card = text, card def build_shots(): """Deterministic, but not a cycle: each section draws from its pool with no immediate repeats, and shot lengths come from a menu so the cut rhythm breathes instead of ticking.""" R = np.random.RandomState(5150) 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 # no orphan sliver i0, i1 = int(t*FPS), int(t2*FPS) if i1 > i0: pool = [x for x in engs if x != last] or list(engs) eng = pool[R.randint(len(pool))] last = eng txt = [SYSTEM_NAMES[(idx+q) % len(SYSTEM_NAMES)] for q in range(2)] shots.append(Shot(idx, i0, i1, eng, nm, txt, 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 return shots # ── 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="Menlo.ttc"): key = (size, name) if key not in _FC: p = _find_font(name) _FC[key] = _load_font(p, max(1, P(size))) return _FC[key] _VIG = {} def vignette(): if "v" not in _VIG: yy, xx = np.mgrid[0:H, 0:W] nx = (xx-W/2)/(W/2); ny = (yy-H/2)/(H/2) # normalised — resolution-free r = np.sqrt(nx**2+ny**2)/1.42 _VIG["v"] = np.clip(1.0-0.40*r**2.2, 0, 1)[..., None] return _VIG["v"] def post(arr_small, i, e, shot): img = Image.fromarray(np.clip(arr_small, 0, 255).astype(np.uint8)) if img.size != (W, H): img = img.resize((W, H), Image.LANCZOS) sm = img.resize((W//4, H//4), Image.BILINEAR).filter( ImageFilter.GaussianBlur(B(6))).resize((W, H), Image.BILINEAR) a = np.clip(np.asarray(img, np.float32) + np.asarray(sm, np.float32)*(0.28+0.30*e["high"]), 0, 255) lum = a.mean(2, keepdims=True)/255.0 a = a + (1-lum)*np.array([-4, 0, 16], np.float32) + lum*np.array([12, 8, -4], np.float32) sh = int(round((1 + 5*e["kick"])*S)) # a pixel-count effect: scale it if sh > 1: a[..., 0] = np.roll(a[..., 0], sh, axis=1) a[..., 2] = np.roll(a[..., 2], -sh, axis=1) a *= vignette() rng = np.random.RandomState(9900 + i) if S == 1.0: a += rng.normal(0, 2.2, a.shape) else: # grain is a look, not a resolution: authored on the 1280x720 grid and # blown up nearest-neighbour so a speck keeps its size on screen gn = rng.normal(0, 2.2, (AH, AW, 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 = mkdraw(out) # (the section-name / timecode strip that used to live along the bottom # was renderer debug and is gone in the final cut) if shot.card: age = i - shot.i0 if age < FPS*2.4: al = min(1.0, age/6.0)*min(1.0, (FPS*2.4-age)/10.0) col = tuple(int(c*al) for c in PAL["bone"]) d.text((28, 34), shot.card, font=font(22), fill=col) # the show, set as a second UI line under a mint rule — only under # the TITLE card, not under the two later lyric cards if shot.card == TITLE: d.rectangle([28, 64, 28 + d.textlength(shot.card, font=font(22))/S, 66], fill=tuple(int(c*al) for c in PAL["mint"])) d.text((28, 74), "PLAYER COMPUTER", font=font(13), fill=tuple(int(c*al*0.92) for c in PAL["mint"])) bh = int(AH*0.045) d.rectangle([0, 0, AW, bh], fill=(0, 0, 0)); d.rectangle([0, AH-bh, AW, AH], fill=(0, 0, 0)) return out def render_shot(job): shot, force = job E = env() rng = np.random.default_rng(shot.seed) eng = ENGINES[shot.engine](shot, rng) made = 0 for k in range(shot.n): i = shot.i0 + k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} p = FRAMES / f"f{i:05d}.png" u = k/max(1, shot.n-1) arr = eng.frame(k, u, e) # ALWAYS step the engine if p.exists() and not force: continue post(arr, i, e, shot).save(p, compress_level=1) made += 1 return f"shot {shot.idx:02d} {shot.engine:8s} {shot.section:9s} {made}/{shot.n}" def contact_sheet(shots): 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) E = env() for n, sh in enumerate(shots): rng = np.random.default_rng(sh.seed) eng = ENGINES[sh.engine](sh, rng) mid = sh.n//2 arr = None for k in range(mid+1): i = sh.i0+k e = {kk: float(E[kk][min(i, N_FRAMES-1)]) for kk in E} arr = eng.frame(k, k/max(1, sh.n-1), e) im = post(arr, sh.i0+mid, e, sh).resize((tw, th), Image.LANCZOS) cx, cy = (n % cols)*tw, (n//cols)*(th+24) sheet.paste(im, (cx, cy)) sd.text((cx+5, cy+th+4), f"{sh.idx:02d} {sh.engine} · {sh.section} · {sh.i0/FPS:.1f}s", font=font(13), fill=(190, 195, 205)) p = OUT/"contact_sheet.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(14, os.cpu_count())) a = ap.parse_args() wav = AUD/"final.wav" if not wav.exists() or not (AUD/"env.npz").exists() or a.force: print(f"[1/3] song… {N_BARS} bars @ {BPM:.0f}bpm = {DUR:.1f}s") 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() != "") jobs = [(s, a.force) for s in shots if not sel or s.idx in sel] print(f"[2/3] frames… {len(jobs)} shots / {N_FRAMES} frames on {a.jobs} workers") import multiprocessing as mp with mp.get_context("fork").Pool(a.jobs) as pool: for r in pool.imap_unordered(render_shot, jobs): print(" ", r) print("[3/3] mux…") out = OUT/f"{NAME}.mp4" subprocess.run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", str(FRAMES/"f%05d.png"), "-i", str(wav), "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", "-metadata", f"generator=renders/player_computer_final/{NAME}/render.py", "-metadata", f"title=player_computer_final — {TITLE}", str(out)], check=True, capture_output=True) 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" (OUT/"PROVENANCE.txt").write_text( f"generator: renders/player_computer_final/{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"engines: {ENGINE_DESC} (shot-parallel, stateful per shot)\n") print(f"DONE {out} ({DUR:.1f}s)") if __name__ == "__main__": main()