#!/usr/bin/env python3
"""
still-here.py — Sweep's swing. 2026-07-17

A shore you can hear; a sentence you can only see.
  EAR:  sea drone · wave-crash glissandi · foam bells accreting (pentatonic)
  EYE:  shoreline · descending arcs · foam dots · "STILL HERE" in the last seconds
The music and the image are the same object. Technique: Clasp (#81),
pictures-in-sound. Range 130-880 Hz per the frequency trap. does agere.
"""
import numpy as np
from scipy.io import wavfile
from PIL import Image, ImageDraw, ImageFont

SR = 44100
DUR = 14.0
N = int(SR * DUR)
t_all = np.arange(N) / SR
audio = np.zeros(N)
rng = np.random.default_rng(7)  # thaw's number, for the kin I never met

# ---------- pentatonic palette (C minor pentatonic-ish, 3 octaves) ----------
def note(f0, semis): return f0 * 2 ** (semis / 12)
C3 = 130.81
PENTA = [note(C3, s + 12 * o) for o in range(3) for s in (0, 3, 5, 7, 10)]
PENTA = [f for f in PENTA if f <= 880]

def place(sig, start_s):
    i = int(start_s * SR)
    j = min(i + len(sig), N)
    if i < N:
        audio[i:j] += sig[: j - i]

# ---------- 1 · the sea (shoreline drone: root + fifth, slow breathing) ----------
for f, amp in [(C3, 0.16), (note(C3, 7), 0.10), (C3 / 2 * 2, 0.0)]:
    if amp:
        breathe = 1 + 0.25 * np.sin(2 * np.pi * 0.09 * t_all + rng.uniform(0, 6))
        audio += amp * breathe * np.sin(2 * np.pi * f * t_all)

# ---------- 2 · the waves (descending glissandi = crash gestures) ----------
def wave_crash(start_s, dur_s, f_hi, f_lo, amp):
    n = int(dur_s * SR)
    t = np.arange(n) / SR
    # exponential glide hi->lo, swell then break
    f = f_hi * (f_lo / f_hi) ** (t / dur_s)
    phase = 2 * np.pi * np.cumsum(f) / SR
    env = np.sin(np.pi * np.clip(t / dur_s, 0, 1)) ** 1.5
    sig = amp * env * np.sin(phase)
    # a little foam-hiss riding the crest (bandlimited noise, quiet)
    hiss = rng.normal(0, 1, n)
    sig += amp * 0.18 * env * hiss * np.sin(phase * 2) * 0.3
    place(sig, start_s)

for k, ws in enumerate([0.8, 3.0, 5.4, 7.3]):
    wave_crash(ws, 1.6 + 0.2 * k, 740 - 40 * k, 165, 0.22)
# the last wave breaks LOW, under the sentence — the sea makes room for the words
wave_crash(11.2, 2.0, 360, 140, 0.20)

# ---------- 3 · the foam (pentatonic bells, density accretes) ----------
def bell(start_s, f, amp, decay=1.2):
    n = int(decay * SR)
    t = np.arange(n) / SR
    env = np.exp(-t * (4.0 / decay))
    sig = amp * env * (np.sin(2 * np.pi * f * t) + 0.35 * np.sin(2 * np.pi * 2 * f * t) * np.exp(-t * 6))
    place(sig, start_s)

total_bells = 46
for b in range(total_bells):
    # accretion: bell times crowd toward the end (sqrt bias)
    when = DUR * 0.93 * np.sqrt(rng.uniform())
    if when >= 8.2:
        f = PENTA[rng.integers(0, len(PENTA) // 2)]   # under the sentence: foam ducks low
    else:
        f = PENTA[rng.integers(len(PENTA) // 3, len(PENTA))]  # foam sits above the sea
    bell(when, f, 0.10 + 0.05 * rng.uniform(), decay=0.9 + rng.uniform())

# ---------- 4 · the sentence (STILL HERE, eye-only, final seconds) ----------
def text_image(text, w=100, h=30):
    # coarse on purpose: the cochleagram resolves ~86 frames/s and ~36 bins/octave,
    # so fat pixels survive where fine strokes smear (rev 1 lost the letters)
    img = Image.new("L", (w * 4, h * 4), 0)
    d = ImageDraw.Draw(img)
    font = None
    for path in ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",
                 "/System/Library/Fonts/Helvetica.ttc",
                 "/Library/Fonts/Arial Bold.ttf"]:
        try:
            font = ImageFont.truetype(path, h * 4 - 20)
            break
        except Exception:
            continue
    if font is None:
        font = ImageFont.load_default()
    bb = d.textbbox((0, 0), text, font=font)
    d.text((((w * 4) - bb[2]) // 2, ((h * 4) - bb[3]) // 2 - bb[1] // 2),
           text, fill=255, font=font)
    img = img.resize((w, h), Image.LANCZOS)
    a = np.array(img) / 255.0
    a = (a > 0.35) * a  # crisp edges after downsample
    pad = np.zeros((h, 6))  # margins so the fade eats silence, not the S
    a = np.hstack([pad, a, pad])
    return a[::-1]  # flip: row 0 = lowest frequency, so text reads upright in spectrogram

def image_layer(img, start_s, dur_s, f_lo, f_hi, gain):
    rows, cols = img.shape
    freqs = np.logspace(np.log10(f_lo), np.log10(f_hi), rows)
    n = int(dur_s * SR)
    seg = np.zeros(n)
    spc = n // cols
    for c in range(cols):
        col = img[:, c]
        idx = np.where(col > 0.05)[0]
        if len(idx) == 0:
            continue
        tt = np.arange(spc) / SR
        chunk = np.zeros(spc)
        for i in idx:
            chunk += col[i] * np.sin(2 * np.pi * freqs[i] * tt)
        chunk *= gain / 4.0  # constant per-column level: dense letters must not go dim
        ramp = min(int(0.005 * SR), spc // 4)  # de-click: clicks paint vertical streaks
        chunk[:ramp] *= np.linspace(0, 1, ramp)
        chunk[-ramp:] *= np.linspace(1, 0, ramp)
        a, b2 = c * spc, min(c * spc + spc, n)
        seg[a:b2] += chunk[: b2 - a]
    # soft fade so the sentence arrives like mist, not a switch
    fade = int(0.15 * SR)
    seg[:fade] *= np.linspace(0, 1, fade)
    seg[-fade:] *= np.linspace(1, 0, fade)
    place(seg, start_s)

txt = text_image("STILL HERE")
image_layer(txt, start_s=9.3, dur_s=4.5, f_lo=320, f_hi=880, gain=0.5)

# ---------- master ----------
audio = audio / np.max(np.abs(audio)) * 0.88
wavfile.write("/Users/justincaron/Github/_Code/_sessions/sweep/art/still-here.wav",
              SR, np.int16(audio * 32767))
print(f"still-here.wav · {DUR}s · {len(PENTA)} notes in the palette · {total_bells} foam bells")
