#!/usr/bin/env python3 # ═════════════════════════════════════════════════════════════════════════════ # PLAYER COMPUTER — Two Fish (23/32) # by Gene Kogan · 2026 · https://genekogan.com/player_computer/two_fish # # A big fish and a small fish cross open water, and one of them waits. # # 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/two_fish.py.txt # # The original render (for reference, yours should differ): # video: https://genekogan.com/player_computer/media/two_fish.mp4 # cover: https://genekogan.com/player_computer/media/two_fish.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 two_fish.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 — "TWO FISH" (fork of renders/side_quests/two_fish) Bossa nova, 132bpm, A minor 7ths. 36 bars, instrumental. Tightening (2026-08-26): the source ran 48 bars / 90.2s. Recomposed to 36 bars / ~68s by shortening the *slack* sections and leaving the waiting untouched — shallows 6→4, open 10→6, behind 8→6, **waiting 8→8**, together 8→6, reef 8→6. Every story beat survives; the held section is now a larger share of the piece than it was, which is the point. The bossa arrangement follows the same bar map, so nothing is sped up or truncated. Two structural fixes forced by the new (shorter) shot list: the "falling behind" gap and the small fish's return are now driven by **section** progress, not per-shot progress, so the small one drops back once and comes back once instead of re-running the beat inside every cut. A big fish and a small fish are crossing. The small one keeps up for a while. Then it doesn't. The big one goes on ahead, notices, comes back, and waits — for a long time, in the middle of open water, doing nothing, which is the only part of the story that matters. Then they go on together and reach a reef, which is loud and full of strangers. Look: pixel art. Everything is drawn on a 160x90 grid and blown up with nearest-neighbour, so it has real pixels and dithered gradients — a completely different surface from anything else in the set. Composition: engine : audio-first x shot-parallel content: audio-groove (bossa kit — brushes, rim, Karplus nylon guitar, upright bass, vibraphone) x generative-art (dither, parallax) x effects-post FINAL CUT (player_computer_final): * Native 1920x1080. The 160x90 pixel grid is UNTOUCHED — that is the whole look — and the nearest-neighbour blow-up simply goes to 12x instead of 8x, so a pixel is a bigger square and nothing softens. The only rasterised things that needed a scale factor are the title type and the vignette (already resolution-independent). * No debug strip existed on this piece and none was added. * Title flash: the existing TWO FISH card gains "PLAYER COMPUTER" under it, hand-set on the pixel grid's own baseline with a chunky rule. Run from repo root: python3 renders/player_computer_final/two_fish/render.py --sheet python3 renders/player_computer_final/two_fish/render.py --jobs 3 """ import argparse, datetime, hashlib, math, os, subprocess, wave from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter NAME = "two_fish" TITLE = "TWO FISH" SETNUM = "13" # ── delivery scale ─────────────────────────────────────────────────────────── # The film is drawn on a 160x90 grid; W/H is only how big each of those pixels # is on screen. S scales the one non-pixel-art layer there is: the title type. W, H, FPS = 1920, 1080, 24 S = H / 720.0 def P(v): return int(round(v*S)) BPM = 132.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 bar map. All boundaries land on even bars so each section starts # on a chord change (PROG turns over every 2 bars). `waiting` is deliberately # NOT cut — it keeps its full 8 bars (14.5s) while everything around it loses # a third, so the held section grows from 16% to 21% of the runtime. SECTIONS = [ ("shallows", 0, 4), # was 6 ("open", 4, 10), # was 10 ("behind", 10, 16), # was 8 ("waiting", 16, 24), # was 8 — untouched ("together", 24, 30), # was 8 ("reef", 30, 36), # was 8 ] SEC_BAR = {nm: b0 for nm, b0, b1 in SECTIONS} N_BARS = SECTIONS[-1][2] DUR = N_BARS * BAR + 2.4 # shorter ring-out to match the shorter reef N_FRAMES = int(DUR * FPS) # absolute frame span of each section — animation beats key off these, not off # per-shot progress (see Sea.frame) SEC_FRAMES = {nm: (int(b0*BAR*FPS), int(b1*BAR*FPS)) for nm, b0, b1 in SECTIONS} def section_u(name, i): """Progress 0..1 through a *section* at absolute frame i.""" a, b = SEC_FRAMES[name] return min(1.0, max(0.0, (i - a) / max(1, b - a - 1))) MUSIC_DESC = f"bossa nova, {BPM:.0f}bpm, Am7, {N_BARS} bars, instrumental" ENGINE_DESC = "pixel art — 160x90 nearest-neighbour" 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) SW = 0.05 # Am9 - Dm7 - G7 - Cmaj7 : all diatonic to C major, nothing borrowed PROG = [(nf("A1"), [nf("C4"), nf("E4"), nf("G4"), nf("B4")]), (nf("D1"), [nf("C4"), nf("F4"), nf("A4"), nf("D5")]), (nf("G1"), [nf("B3"), nf("D4"), nf("F4"), nf("A4")]), (nf("C1"), [nf("B3"), nf("E4"), nf("G4"), nf("D5")])] # one fixed scale for every melodic voice in the piece SCALE = [nf("C4"), nf("D4"), nf("E4"), nf("F4"), nf("G4"), nf("A4"), nf("B4"), nf("C5"), nf("D5"), nf("E5"), nf("F5"), nf("G5"), nf("A5")] # which scale degrees are chord tones per bar of the loop (so the tune lands) CHORD_DEG = [[0, 2, 4, 6], [1, 3, 5, 7], [4, 6, 8, 10], [0, 2, 4, 6]] def build_song(): s = Song(DUR) R = np.random.RandomState(132) def sec_of(bar): for nm, a, b in SECTIONS: if a <= bar < b: return nm return "reef" for bar in range(N_BARS): sec = sec_of(bar) root, notes = PROG[(bar//2) % 4] quiet = sec == "waiting" loud = sec == "reef" # bossa clave on the rim, brushes on the snare if not quiet: for st in (0, 3, 6, 10, 12): s.put("drums", rimclick(), s.t(bar, st, SW), g=.30, pan=.26) for st in range(0, 16, 2): s.put("drums", brush2(), s.t(bar, st, SW), g=.24, pan=-.16) s.put("drums", kick(dur=.28, f0=120, f1=46, punch=22, click=.15), s.t(bar, 0, SW), g=.52) s.kick_t.append(s.t(bar, 0, SW)) s.put("drums", kick(dur=.26, f0=120, f1=46, punch=22, click=.15), s.t(bar, 11, SW), g=.40) else: for st in (0, 8): s.put("drums", brush2(), s.t(bar, st, SW), g=.16, pan=-.2) # upright bass — root/fifth, bossa placement if not quiet or bar % 2 == 0: for st, iv in ((0, 0), (6, 7)): s.put("bass", voice(root*2*2**(iv/12), BEAT*.9, kind="tri", nh=12, c0=640, c1=190, ck=7, a=.008, d=.16, s=.4, r=.16, seed=bar*3+st), s.t(bar, st, SW), g=.42 if not quiet else .26) # nylon guitar comp — the genre's engine if not quiet: for st in (2, 5, 9, 13): for k2, f2 in enumerate(notes): if (k2 + st) % 2: continue s.put("gtr", ks(f2, .55, damp=.9930, seed=bar*7+k2+st), s.t(bar, st, SW), g=.16, pan=-.30+.20*k2) else: for k2, f2 in enumerate(notes[:2]): s.put("gtr", ks(f2, 1.5, damp=.9962, seed=bar*11+k2), s.t(bar, 0, SW), g=.15, pan=-.2+.4*k2) # vibraphone melody if sec in ("open", "together", "reef"): # scale degrees, not chord-relative intervals MEL = [4, 5, 6, 7, 6, 4, 2, 4, 6, 7, 9, 7, 6, 4, 2, 1] deg = MEL[bar % 16] s.put("vibes", fm(SCALE[deg]*2, 1.9, ratio=4.01, index=3.0, idec=7.0, d=1.1, r=.7, seed=bar*13), s.t(bar, 4, SW), g=.13, pan=.30) if bar % 2 == 1: s.put("vibes", fm(SCALE[(deg+2) % len(SCALE)]*2, 1.3, ratio=4.01, index=2.4, idec=8.0, d=.8, r=.5, seed=bar*19), s.t(bar, 10, SW), g=.09, pan=-.28) if quiet: # one bell, occasionally # counted from the top of `waiting`, not from bar 0, so the shorter # bar map still puts three bells across the held section if (bar - SEC_BAR["waiting"]) % 3 == 0: deg = CHORD_DEG[bar % 4][2] s.put("vibes", fm(SCALE[deg]*2, 3.0, ratio=3.5, index=2.2, idec=4.0, d=1.8, r=1.2, seed=bar*17), s.t(bar, 2, SW), g=.14, pan=-.1) if loud: # the reef is busy cd = CHORD_DEG[bar % 4] for st in range(0, 16, 2): deg = cd[(st//2) % len(cd)] s.put("arp", ks(SCALE[deg]*2, .26, damp=.9900, seed=bar*19+st), s.t(bar, st, SW), g=.09, pan=-.5+R.rand()) for k2, f2 in enumerate(notes): s.put("horns", voice(f2, BEAT*1.1, kind="saw", nh=18, c0=2400, c1=1000, ck=4, res=.4, a=.03, d=.2, s=.55, r=.3, seed=bar*23+k2), s.t(bar, 8, SW), g=.10, pan=-.35+.24*k2) # water pad for k2, f2 in enumerate(notes[:3]): s.put("pad", voice(f2/2, BAR*1.2, kind="saw", nh=16, c0=900 + (900 if loud else 0), c1=500, ck=.7, detune=(-1.2, 0, 1.3), a=.8, d=.8, s=.7, r=1.0, seed=bar*29+k2), s.t(bar, 0), g=.08, pan=-.5+.5*k2) # cues follow the section map rather than hard-coded bar numbers s.put("fx", bubbles(3.0), SEC_BAR["behind"]*BAR, g=.22) s.put("fx", bubbles(4.0), SEC_BAR["waiting"]*BAR, g=.18) s.put("fx", crash(dur=2.0), SEC_BAR["reef"]*BAR, g=.22, pan=.1) s.bus("gtr", lambda x: reverb(x, rt=1.8, mix=.28, seed=503)) s.bus("vibes", lambda x: reverb(delay(x, BEAT*.75, .34, .26), rt=3.0, mix=.46, seed=509)) s.bus("pad", lambda x: reverb(x, rt=4.0, mix=.58, seed=521)) s.bus("arp", lambda x: reverb(x, rt=2.0, mix=.34, seed=523)) s.bus("horns", lambda x: reverb(x, rt=2.4, mix=.36, seed=541)) s.bus("fx", lambda x: reverb(x, rt=3.0, mix=.50, seed=547)) mix = s.mixdown(dict(drums=1.0, bass=1.0, gtr=1.0, vibes=1.0, pad=1.0, arp=1.0, horns=1.0, fx=1.0), pump_depth=.10, pump_rel=.16, levels=dict(shallows=.62, open=.84, behind=.74, waiting=.34, together=.88, reef=1.0)) wav = AUD/"final.wav"; s.write(wav, mix); return wav, mix def rimclick(dur=.08, seed=163): n = int(dur*SR); t = np.arange(n)/SR return (np.sin(2*np.pi*1650*t) + .5*np.sin(2*np.pi*2450*t))*np.exp(-t*95)*0.45 def brush2(dur=.18, seed=167): n = int(dur*SR); t = np.arange(n)/SR nz = bandshape(np.random.RandomState(seed).randn(n), lo=1400, hi=6400) return nz*np.exp(-t*15)*(0.35+0.65*np.sin(t*44)**2)*0.5 def bubbles(dur=3.0, seed=173): n = int(dur*SR); rng = np.random.RandomState(seed); out = np.zeros(n) for _ in range(90): i = rng.randint(0, n-4000); m = 3000 t = np.arange(m)/SR f = rng.uniform(400, 1800)*(1+2.5*t) out[i:i+m] += np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*24)*rng.uniform(.1, .5) return out*0.34 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 # ════════════════════════════════════════════════════════════════════════════ # ANIMATION HELPERS — motion is the point, so it gets its own primitives # ════════════════════════════════════════════════════════════════════════════ def ease_io(u): return u*u*(3-2*u) def ease_out(u): return 1-(1-u)**3 def ease_in(u): return u**3 def bounce(u, n=3): return abs(math.sin(u*math.pi*n))*(1-u) def lerp(a, b, u): return a + (b-a)*u def walk(t, speed=1.0): """Returns (leg_swing, body_bob, arm_swing) for a walk cycle at time t.""" p = t*speed*math.tau return math.sin(p), abs(math.sin(p))*-1.0, math.sin(p+math.pi) def spring(u, freq=3.0, damp=5.0): """Overshoot-and-settle, for things that arrive.""" if u <= 0: return 0.0 return 1 - math.exp(-damp*u)*math.cos(freq*math.tau*u) def arc(p0, p1, u, h=0.3): """Ballistic arc between two points.""" x = lerp(p0[0], p1[0], u) y = lerp(p0[1], p1[1], u) - math.sin(u*math.pi)*h*abs(p1[0]-p0[0]) return x, y # ════════════════════════════════════════════════════════════════════════════ # PIXEL ART — 160x90, nearest-neighbour # ════════════════════════════════════════════════════════════════════════════ NEUTRAL = {"rms": .5, "low": .4, "mid": .4, "high": .3, "kick": .2} PW, PH = 160, 90 SEA = [(10, 26, 54), (16, 40, 78), (24, 60, 104), (34, 84, 130), (52, 112, 156), (86, 152, 182), (132, 190, 204), (188, 226, 226)] CORAL = [(232, 106, 92), (240, 158, 78), (246, 208, 96), (150, 210, 130), (128, 118, 208), (236, 140, 186)] BIG = (246, 178, 66); BIGD = (198, 122, 40) SML = (120, 214, 214); SMLD = (66, 156, 168) DARK = (8, 14, 30); LIGHT = (226, 240, 240) BAYER = np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]])/16.0 def _px(): return Image.new("RGB", (PW, PH), SEA[1]) def grad(im, top, bot, y0=0, y1=PH): """Dithered vertical gradient across the sea ramp.""" a = np.asarray(im).astype(np.float32) yy = np.arange(PH)[:, None] u = np.clip((yy-y0)/max(1, (y1-y0)), 0, 1) idx = top + (bot-top)*u bay = np.tile(BAYER, (PH//4+1, PW//4+1))[:PH, :PW] ii = np.clip(np.round(idx + (bay-0.5)*0.9), 0, len(SEA)-1).astype(int) pal = np.array(SEA, np.float32) a[:] = pal[ii] return Image.fromarray(a.astype(np.uint8)) def fish(d, x, y, sc, body, dark, t, *, flip=False, tired=0.0, tail=1.0): """A pixel fish. sc 1 = small, 2 = big.""" s2 = sc sgn = -1 if flip else 1 wig = math.sin(t*(7.0-3.0*tired))*1.4*s2 y = y + math.sin(t*(2.4-1.2*tired))*1.0*s2 # body for i in range(-3*s2, 4*s2): hgt = int(math.sqrt(max(0.0, 1-(i/(4.0*s2))**2))*2.2*s2) d.line([x+i, y-hgt, x+i, y+hgt], fill=body) # belly shading for i in range(-3*s2, 4*s2): hgt = int(math.sqrt(max(0.0, 1-(i/(4.0*s2))**2))*2.2*s2) if hgt > 0: d.line([x+i, y+hgt-max(1, s2//2), x+i, y+hgt], fill=dark) # tail tx = x - sgn*4*s2 for i in range(0, 3*s2): hh = int(i*0.9)+1 d.line([tx - sgn*i, y-hh+wig*0.5, tx - sgn*i, y+hh+wig*0.5], fill=body) # fin d.line([x, y-2*s2, x+sgn*1, y-3*s2-int(tail*s2)], fill=dark) # eye d.point((x+sgn*2*s2, y-s2//2-1), fill=DARK) d.point((x+sgn*2*s2+sgn, y-s2//2-1), fill=LIGHT if s2 > 1 else DARK) def jellyfish(d, x, y, t, sc, phase): """A pulsing bell with trailing tentacles.""" p = 0.5 + 0.5*math.sin(t*1.6 + phase) bw = int(7*sc*(1.0+0.22*p)); bh = int(5*sc*(1.0-0.20*p)) for q in range(bh, 0, -1): d.line([x-bw*q//bh, y-q, x+bw*q//bh, y-q], fill=(196, 172, 226)) d.line([x-bw, y, x+bw, y], fill=(226, 208, 244)) for j in range(5): tx = x - bw + j*(2*bw//4) for q in range(int(9*sc)): d.point((tx + int(math.sin(t*2.4+j+q*0.5)*1.6), y+q+1), fill=(176, 154, 210)) def ray(d, x, y, t, sc, phase): """A manta gliding, wings flapping slowly.""" fl = math.sin(t*1.1 + phase) for i in range(-int(10*sc), int(10*sc)+1): w = abs(i)/(10.0*sc) yy = y + int(fl*w*w*5*sc) - int((1-w)*2*sc) h = max(1, int((1-w)*3*sc)) d.line([x+i, yy-h, x+i, yy+h], fill=(58, 74, 96)) d.line([x, y+int(3*sc), x, y+int(12*sc)], fill=(58, 74, 96)) def wreck(d, x, gy, t, sc): """A hull, half buried, with a mast.""" d.polygon([(x-34*sc, gy), (x-26*sc, gy-13*sc), (x+30*sc, gy-11*sc), (x+36*sc, gy)], fill=(52, 44, 42)) d.polygon([(x-24*sc, gy-11*sc), (x+26*sc, gy-9*sc), (x+22*sc, gy-14*sc), (x-20*sc, gy-15*sc)], fill=(38, 34, 34)) d.line([(x+6*sc, gy-13*sc), (x+2*sc, gy-40*sc)], fill=(46, 40, 38), width=max(1, int(2*sc))) for q in range(4): d.line([(x+2*sc, gy-24*sc-q*4*sc), (x+2*sc+int(math.sin(t*1.2+q)*5*sc), gy-22*sc-q*4*sc)], fill=(70, 96, 84)) def bubble_col(d, x, y0, t, n, seed): rng = np.random.RandomState(seed) for i in range(n): ph = (t*0.5 + i/n) % 1.0 yy = y0 - ph*PH*0.9 xx = x + math.sin(ph*8+i)*2 if yy < 0: continue r = 1 if ph < .5 else 2 d.ellipse([xx-r, yy-r, xx+r, yy+r], outline=SEA[6]) class Sea: """One continuous scene; the section decides who is in it and where.""" def __init__(self, shot, rng): self.rng = rng self.kelp = [(rng.random()*PW*2, rng.uniform(0.4, 1.0)) for _ in range(14)] self.dust = rng.random((60, 2)) self.mode = shot.engine self.reefbits = [(rng.random()*PW, rng.uniform(0.55, 1.0), int(rng.integers(0, len(CORAL)))) for _ in range(40)] self.school = rng.random((26, 3)) self.sec = shot.section self.jelly = rng.random((5, 3)) self.rays = rng.random((3, 3)) self.wreck_x = float(rng.uniform(0.2, 0.8)) self.crabs = rng.random((6, 2)) def frame(self, k, u, e, su=0.0): """`u` = progress through this shot, `su` = progress through the whole section. Story beats (falling behind, coming back) use `su` so they run once across the section instead of restarting inside every cut.""" t = k/FPS sec = self.sec im = _px() top, bot = (1, 6) if sec != "waiting" else (0, 4) if sec == "reef": top, bot = (2, 7) im = grad(im, top, bot) d = ImageDraw.Draw(im) # god rays — dithered diagonal shafts, not dotted lines bay = BAYER for q in range(4): x0 = int((q*41 + t*2.2) % (PW+60)) - 30 wdt = 5 + q % 3 for yy in range(0, int(PH*0.78)): xs = x0 + yy//3 for xx in range(xs, xs+wdt): if 0 <= xx < PW and bay[yy % 4][xx % 4] < 0.45 - yy/PH*0.30: d.point((xx, yy), fill=SEA[min(7, bot+1)]) # floor fy = int(PH*0.86) for x in range(PW): h = fy + int(math.sin(x*0.14 + 1.2)*2) d.line([x, h, x, PH], fill=SEA[1]) d.point((x, h), fill=SEA[3]) # kelp scroll = t*(6.0 if sec != "waiting" else 0.6) for (kx, kh) in self.kelp: x = int((kx - scroll) % (PW*2)) - PW//2 if x < -6 or x > PW+6: continue H2 = int(kh*PH*0.42) for j in range(H2): xx = x + int(math.sin(j*0.25 + t*1.6 + kx)*2) d.point((xx, fy-j), fill=(30, 92, 78) if j % 3 else (44, 122, 96)) # drifting motes for i in range(len(self.dust)): x = int((self.dust[i, 0]*PW - scroll*0.4) % PW) y = int((self.dust[i, 1]*PH + math.sin(t+i)*2) % PH) d.point((x, y), fill=SEA[min(7, bot+1)]) # ── props: something different happening in every section ── if sec in ("open", "behind"): for i in range(len(self.rays)): rx = int(((self.rays[i, 0] + t*0.020*(0.5+self.rays[i, 2])) % 1.3)*PW*1.3) - 30 ry = int(PH*(0.16 + 0.20*self.rays[i, 1])) ray(d, rx, ry, t, 1.0+self.rays[i, 2], i*2.1) if sec in ("behind", "waiting"): for i in range(len(self.jelly)): jx = int((self.jelly[i, 0]*PW + math.sin(t*0.3+i)*6) % PW) jy = int(PH*0.85 - ((self.jelly[i, 1] + t*0.030) % 1.0)*PH*0.75) jellyfish(d, jx, jy, t, 0.7+0.6*self.jelly[i, 2], i*1.7) if sec in ("waiting", "together"): wx = int(((self.wreck_x - t*0.004) % 1.4)*PW) if -60 < wx < PW+60: wreck(d, wx, fy, t, 1.0) if sec in ("shallows", "together"): for i in range(len(self.crabs)): cx2 = int(((self.crabs[i, 0] + t*0.012*(1 if i % 2 else -1)) % 1.0)*PW) cy2 = fy - 1 - int(self.crabs[i, 1]*3) d.line([cx2-2, cy2, cx2+2, cy2], fill=(196, 92, 78)) d.point((cx2-3, cy2-1), fill=(196, 92, 78)) d.point((cx2+3, cy2-1), fill=(196, 92, 78)) if sec == "reef": for (rx, rh, rc) in self.reefbits: x = int((rx - scroll*0.7) % PW) hh = int(rh*16) col = CORAL[rc] for j in range(hh): wdt = max(1, int((hh-j)*0.28)) d.line([x-wdt, fy-j, x+wdt, fy-j], fill=col if j % 4 else DARK) for i in range(len(self.school)): sx = int((self.school[i, 0]*PW + t*9*(0.5+self.school[i, 2])) % (PW+16))-8 sy = int(PH*0.25 + self.school[i, 1]*PH*0.4 + math.sin(t*3+i)*3) fish(d, sx, sy, 1, CORAL[i % len(CORAL)], DARK, t+i, flip=False, tail=0.5) # ── the two fish ── if sec == "shallows": fish(d, int(PW*0.42), int(PH*0.44), 2, BIG, BIGD, t) fish(d, int(PW*0.30), int(PH*0.50), 1, SML, SMLD, t*1.3) elif sec == "open": sway = math.sin(t*0.22)*PW*0.10 fish(d, int(PW*0.52+sway), int(PH*0.42+math.sin(t*0.4)*4), 2, BIG, BIGD, t) fish(d, int(PW*0.38+sway*1.3), int(PH*0.48+math.sin(t*0.55+1)*5), 1, SML, SMLD, t*1.3) elif sec == "behind": gap = lerp(14, 62, ease_io(su)) fish(d, int(PW*0.62), int(PH*0.40), 2, BIG, BIGD, t) fish(d, int(PW*0.62-gap), int(PH*0.54), 1, SML, SMLD, t*1.05, tired=su*0.9) bubble_col(d, int(PW*0.62-gap), int(PH*0.54), t, 5, 3) elif sec == "waiting": fish(d, int(PW*0.52), int(PH*0.44), 2, BIG, BIGD, t*0.35, flip=True) # the small one is gone for most of the section, then arrives if su > 0.66: x = lerp(-14, PW*0.34, ease_out((su-0.66)/0.34)) fish(d, int(x), int(PH*0.52), 1, SML, SMLD, t*0.9, tired=0.5) elif sec == "together": sway = math.sin(t*0.30)*PW*0.13 fish(d, int(PW*0.50+sway), int(PH*0.44+math.sin(t*0.5)*5), 2, BIG, BIGD, t) # the small one loops around the big one now, playing ang = t*1.1 fish(d, int(PW*0.50+sway+math.cos(ang)*22), int(PH*0.46+math.sin(ang)*11), 1, SML, SMLD, t*1.4, flip=(math.cos(ang) < 0)) else: fish(d, int(PW*0.44), int(PH*0.46), 2, BIG, BIGD, t) fish(d, int(PW*0.34), int(PH*0.52), 1, SML, SMLD, t*1.2) a = np.asarray(im).astype(np.float32) return a ENGINES = {"sea": Sea, "close": Sea, "wide": Sea} # shot-length menus, in beats. Scaled down with the sections so a 6-bar # section still gets 2–3 cuts — except `waiting`, whose menu stays long # (14–20 beats = 6.4–9.1s per shot) because the holding is the piece. PLAN = { "shallows": (["sea", "wide"], [8, 10, 14]), "open": (["sea", "close", "wide"], [10, 8, 14]), "behind": (["close", "sea"], [8, 10, 14]), "waiting": (["wide", "sea"], [20, 14, 16]), "together": (["sea", "close", "wide"], [10, 8, 14]), "reef": (["wide", "sea", "close"], [8, 10, 8]), } CARDS = {"shallows": "TWO FISH", "open": None, "behind": None, "waiting": None, "together": None, "reef": None} SYSTEM_NAMES = ["N", "NE", "E", "SE", "S", "SW"] 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 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] # each section opens on a free choice of angle — carrying `last` across # the boundary was locking the reef out of its establishing wide last = None t = b0*BAR; j = 0 while t < b1*BAR - 1e-6: step = menu[R.randint(len(menu))]*BEAT t2 = min(t+step, b1*BAR) # absorb anything shorter than a bar into the preceding shot — with # the shorter section map a 1.5-beat guard was still leaving 1.8s # slivers at section ends if (b1*BAR - t2) < BEAT*4.5: t2 = b1*BAR 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) 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, i, e, shot): """Nearest-neighbour upscale is the whole point — no smoothing anywhere.""" a = arr.astype(np.uint8) if isinstance(arr, np.ndarray) else np.asarray(arr, np.uint8) im = Image.fromarray(a) zoom = {"close": 1.55, "wide": 1.0, "sea": 1.22}.get(shot.engine, 1.0) if zoom > 1.0: cw, ch = int(PW/zoom), int(PH/zoom) x0 = (PW-cw)//2; y0 = int((PH-ch)*0.45) im = im.crop((x0, y0, x0+cw, y0+ch)) im = im.resize((W, H), Image.NEAREST) a = np.asarray(im, np.float32) a *= vignette()*0.9 + 0.1 out = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) d = ImageDraw.Draw(out) if shot.card: age = i - shot.i0 if age < FPS*2.6: al = min(1.0, age/8.0)*min(1.0, (FPS*2.6-age)/12.0) col = tuple(int(c*al) for c in LIGHT) d.text((P(44), P(44)), shot.card, font=font(30), fill=col) # the show name, set small under the title on the same left margin, # with a two-pixel-tall rule the width of the word above it sub = "PLAYER COMPUTER" f2 = font(13) d.rectangle([P(44), P(84), P(44) + int(d.textlength(shot.card, font=font(30))), P(84) + max(2, P(2))], fill=tuple(int(c*al*0.62) for c in LIGHT)) d.text((P(44), P(94)), sub, font=f2, fill=tuple(int(c*al*0.86) for c in LIGHT)) 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) # ALWAYS step the engine arr = eng.frame(k, u, e, section_u(shot.section, i)) 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, 169 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, section_u(sh.section, i)) 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()