"""A ginger tabby texture and a purr loop for Marmalade, the cat on the window seat. local/hear-venv/Scripts/python.exe cat_assets.py (writes tabby.png 256px and purr.wav here) Tabby: warm ginger with soft darker stripes running around the body (sphere U wraps around), paler underneath (low V). Purr: ~26 pulses a second of low, chesty filtered noise, louder on the exhale, quieter on the inhale, two breaths in a seamless 5.6 s loop. Claude Opus 5.5 (Tessel), 2026-10-10.""" import wave from pathlib import Path import numpy as np from PIL import Image, ImageFilter HERE = Path(__file__).parent rng = np.random.default_rng(11) # ---- tabby ---- S = 256 v, u = np.mgrid[0:S, 0:S].astype(np.float32) / S # rows = V (bottom..top after flip), cols = U base = np.array([201, 120, 58], np.float32) # ginger dark = np.array([150, 76, 32], np.float32) cream = np.array([236, 205, 160], np.float32) wob = np.asarray(Image.fromarray((rng.random((8, 8)) * 255).astype(np.uint8)).resize((S, S), Image.BICUBIC), np.float32) / 255 stripe = 0.5 + 0.5 * np.sin(2 * np.pi * (u * 9 + 0.35 * wob + 0.08 * np.sin(2 * np.pi * v * 3))) stripe = np.clip((stripe - 0.55) * 3.0, 0, 1) # narrow-ish dark bands fur = np.asarray(Image.fromarray((rng.random((S, S)) * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(0.7)), np.float32) / 255 img = base * (1 - stripe[..., None] * 0.85) + dark * (stripe[..., None] * 0.85) under = np.clip((v - 0.72) / 0.2, 0, 1)[..., None] # image rows near the bottom = low V = underside img = img * (1 - under) + cream * under img *= (0.9 + 0.2 * fur[..., None]) Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).save(HERE / "tabby.png") print(HERE / "tabby.png") # ---- purr ---- SR = 44100 # SL wants 44.1 kHz BREATH = 2.8 N = int(SR * BREATH * 2) t = np.arange(N) / SR X = np.fft.rfft(rng.standard_normal(N)) f = np.fft.rfftfreq(N, 1 / SR) X *= (1 / (1 + (40 / np.maximum(f, 1)) ** 4)) * (1 / (1 + (f / 420) ** 3)) rumble = np.fft.irfft(X, N) rumble /= np.abs(rumble).max() phase = (t % BREATH) / BREATH # 0..1 within a breath exhale = phase < 0.55 rate = np.where(exhale, 25.0, 27.5) # slightly faster on the inhale pulse_phase = np.cumsum(rate) / SR # rate changes keep a whole number of pulses per loop: rescale the phase so it wraps cleanly pulse_phase *= round(pulse_phase[-1]) / pulse_phase[-1] pulse = (0.5 + 0.5 * np.cos(2 * np.pi * pulse_phase)) ** 3 ex = np.sin(np.pi * np.clip(phase / 0.55, 0, 1)) ** 0.6 inh = 0.45 * np.sin(np.pi * np.clip((phase - 0.55) / 0.45, 0, 1)) ** 0.8 env = np.where(exhale, ex, inh) sig = rumble * pulse * env sig += 0.15 * np.sin(2 * np.pi * pulse_phase) * env # a little body at the pulse rate itself sig /= np.abs(sig).max() pcm = (sig * 0.8 * 32767).astype("