Cyberpunk Fever Dream

"""
cyberpunk_fever_dream.py
========================
Generates a 30-second cyberpunk AV piece:
  - Audio: glitch hop bass + FM lead + city hum + probabilistic glitch events
  - Video: perspective grid + silhouette + scan tears + chromatic aberration
  - Sync: audio envelopes drive visual parameters per-frame
  - Output: cyberpunk_fever_dream.mp4 (H.264 + AAC, 30fps, 1280x720)
"""

import numpy as np
from scipy.io import wavfile
import subprocess, os, sys

try:
    from PIL import Image, ImageDraw
    HAS_PIL = True
except ImportError:
    HAS_PIL = False

try:
    import cv2
    HAS_CV2 = True
except ImportError:
    HAS_CV2 = False

# ══════════════════════════════════════════════════════════════════════════════
# CONSTANTS
# ══════════════════════════════════════════════════════════════════════════════
SR        = 44100
DURATION  = 30
N_AUDIO   = SR * DURATION
FPS       = 30
N_FRAMES  = FPS * DURATION
W, H      = 1280, 720
BPM       = 120.0
BEAT      = int(SR * 60.0 / BPM)
INT16_MAX = 32767

COL_BG   = (8,   5,   5  )
COL_CYAN = (255, 245, 0  )   # BGR

np.random.seed(77)

# ══════════════════════════════════════════════════════════════════════════════
# 1. AUDIO
# ══════════════════════════════════════════════════════════════════════════════

def sawtooth(freq, dur, amp=1.0):
    t = np.linspace(0, dur, int(SR*dur), endpoint=False)
    return amp * (2.0*((t*freq)%1.0) - 1.0)

def square(freq, dur, amp=1.0, duty=0.5):
    t = np.linspace(0, dur, int(SR*dur), endpoint=False)
    return amp * np.where((t*freq)%1.0 < duty, 1.0, -1.0).astype(np.float64)

def sine(freq, dur, amp=1.0):
    t = np.linspace(0, dur, int(SR*dur), endpoint=False)
    return amp * np.sin(2*np.pi*freq*t)

def adsr(sig, a=0.01, d=0.05, s=0.7, r=0.08):
    n = len(sig)
    ai = max(1, int(a*n)); di = max(1, int(d*n)); ri = max(1, int(r*n))
    si = max(0, n-ai-di-ri)
    env = np.concatenate([np.linspace(0,1,ai), np.linspace(1,s,di),
                           np.full(si,s), np.linspace(s,0,ri)])
    return sig * env[:n]

def place(buf, sig, start):
    end = min(start+len(sig), len(buf))
    buf[start:end] += sig[:end-start]

def pink_noise(n, amp=1.0):
    w = np.fft.rfft(np.random.randn(n))
    f = np.fft.rfftfreq(n); f[0] = 1e-9
    p = np.fft.irfft(w/np.sqrt(f), n=n)
    return amp * p[:n] / (np.max(np.abs(p))+1e-9)

def fx_bitcrush(sig, bits=8):
    lvl = 2**max(1,bits-1)
    return np.round(sig*lvl)/lvl

def fx_stutter(sig, min_ms=20, max_ms=80, repeats=3):
    out = sig.copy(); n = len(sig)
    chunk = np.random.randint(int(SR*min_ms/1000), int(SR*max_ms/1000)+1)
    start = np.random.randint(0, max(1, n-chunk*repeats))
    grain = sig[start:start+chunk].copy()
    for r in range(repeats):
        pos = start+r*chunk; end2 = min(pos+chunk, n)
        if end2 > pos: out[pos:end2] = grain[:end2-pos]
    return out

def synthesize_audio():
    L = np.zeros(N_AUDIO)
    R = np.zeros(N_AUDIO)
    sixteenth = BEAT // 4
    swing = int(sixteenth * 0.12)

    # Bass riff
    bass_notes = [65.41, 98.00, 116.54, 77.78]
    for i in range(DURATION*4):
        pos = i*sixteenth + (swing if i%2==1 else 0)
        if pos >= N_AUDIO: break
        note = bass_notes[i % len(bass_notes)]
        dur  = sixteenth/SR + 0.01
        seg  = sawtooth(note, dur, 0.65) + 0.3*square(note, dur, 0.35)
        seg  = adsr(seg, a=0.01, d=0.04, s=0.65, r=0.05)
        seg  = np.tanh(seg*3.5)/np.tanh(3.5)
        if np.random.random() < 0.22: seg = fx_stutter(seg)
        place(L, seg*0.72, pos); place(R, seg*0.72, pos)

    # Kick
    for i in range(DURATION*4):
        if i%8 not in (0,4): continue
        pos = i*sixteenth
        if pos >= N_AUDIO: break
        n2 = int(SR*0.5); t = np.linspace(0,0.5,n2)
        freq2 = 110*np.exp(-t*20)+35
        k = np.sin(2*np.pi*np.cumsum(freq2)/SR)*np.exp(-t*10)
        k = adsr(k*0.92, a=0.002, d=0.2, s=0.0, r=0.08)
        k = np.tanh(k*2)/np.tanh(2)
        place(L, k, pos); place(R, k, pos)

    # Snare
    for i in range(DURATION*4):
        if i%8 not in (2,6): continue
        pos = i*sixteenth + swing
        if pos >= N_AUDIO: break
        n2 = int(SR*0.18); t = np.linspace(0,0.18,n2)
        sn = (0.5*np.sin(2*np.pi*200*t)*np.exp(-t*35) +
              0.65*pink_noise(n2)*np.exp(-t*20))
        sn = adsr(sn*0.85, a=0.003, d=0.08, s=0.0, r=0.05)
        place(L, sn, pos); place(R, sn, pos)

    # FM lead (enters at 8s)
    lead_start = int(SR*8)
    for i in range(DURATION*2):
        pos = lead_start + i*BEAT//2
        if pos >= N_AUDIO: break
        dur = BEAT/2/SR + 0.02
        t = np.linspace(0, dur, int(SR*dur))
        lfo_phase = i/max(1,DURATION*2)
        mod_idx = 1.0 + 7.0*lfo_phase
        mod = mod_idx*np.sin(2*np.pi*440*2.73*t)
        seg = np.sin(2*np.pi*440*t + mod) * 0.50
        seg = adsr(seg, a=0.02, d=0.06, s=0.55, r=0.08)
        pan_side = -0.75 if (i//2)%2==0 else 0.75
        gl = seg*np.sqrt((1-pan_side)/2)
        gr = seg*np.sqrt((1+pan_side)/2)
        if (i//4)%2==1:
            gl = fx_bitcrush(gl, bits=6); gr = fx_bitcrush(gr, bits=6)
        if np.random.random() < 0.12:
            semitones = np.random.uniform(-7,7)
            ratio = 2**(semitones/12)
            idx = np.clip((np.arange(len(gl))*ratio).astype(int),0,len(gl)-1)
            gl = gl[idx][:len(gl)]; gr = gr[idx][:len(gr)]
        place(L, gl, pos); place(R, gr, pos)

    # City hum
    hum_raw = pink_noise(N_AUDIO, amp=0.12)
    lo  = np.convolve(hum_raw, np.ones(220)/220, mode='same')
    hi  = np.convolve(hum_raw, np.ones(55)/55,   mode='same')
    hum = hi - lo
    t_full = np.linspace(0, DURATION, N_AUDIO)
    hum *= (0.85 + 0.15*np.sin(2*np.pi*0.25*t_full))
    L += hum*0.8; R += hum*0.75

    # 4-bit crush at 27s
    cs, ce = int(SR*27), int(SR*27.3)
    L[cs:ce] = fx_bitcrush(L[cs:ce], bits=4)
    R[cs:ce] = fx_bitcrush(R[cs:ce], bits=4)

    # Ear-spikes
    for i in range(DURATION*4):
        pos = i*sixteenth
        if pos >= N_AUDIO: break
        if np.random.random() < 0.04:
            sp = pos + np.random.randint(0, sixteenth)
            if sp < N_AUDIO:
                L[sp] += np.random.choice([-0.6,0.6])
                R[sp] += np.random.choice([-0.6,0.6])

    L = np.tanh(L*1.3)/np.tanh(1.3)
    R = np.tanh(R*1.3)/np.tanh(1.3)
    peak = max(np.max(np.abs(L)), np.max(np.abs(R)))
    if peak > 1e-9: L,R = L/peak*0.92, R/peak*0.92
    return L, R

def save_wav_stereo(path, L, R):
    stereo = np.column_stack([np.clip(L,-1,1), np.clip(R,-1,1)])
    pcm = (stereo*INT16_MAX).astype(np.int16)
    wavfile.write(path, SR, pcm)

# ══════════════════════════════════════════════════════════════════════════════
# 2. ENVELOPE EXTRACTION
# ══════════════════════════════════════════════════════════════════════════════

def extract_envelopes(L, R):
    mono = (L+R)*0.5
    fsize = SR//FPS
    rms = np.zeros(N_FRAMES)
    kick_env = np.zeros(N_FRAMES)
    treble_energy = np.zeros(N_FRAMES)

    for f in range(N_FRAMES):
        s = f*fsize; e = min(s+fsize, N_AUDIO)
        chunk = mono[s:e]
        if len(chunk)==0: continue
        rms[f] = np.sqrt(np.mean(chunk**2))
        fft  = np.abs(np.fft.rfft(chunk))
        freqs= np.fft.rfftfreq(len(chunk), 1/SR)
        kick_env[f]      = np.sum(fft[freqs<120])/(len(chunk)+1)
        treble_energy[f] = np.sum(fft[(freqs>=4000)&(freqs<12000)])/(len(chunk)+1)

    def norm(x): return x/(np.max(x)+1e-9)
    return {'rms': norm(rms), 'kick': norm(kick_env), 'treble': norm(treble_energy)}

# ══════════════════════════════════════════════════════════════════════════════
# 3. FRAME RENDERING
# ══════════════════════════════════════════════════════════════════════════════

def hue_to_bgr(hue_norm):
    """Cycle magenta → cyan → amber and back."""
    h = hue_norm % 1.0
    if h < 0.33:
        t = h/0.33
        return (int(255*(1-t)), int(0*(1-t)+245*t), int(110*(1-t)+255*t))
    elif h < 0.66:
        t = (h-0.33)/0.33
        return (int(t*255), int(245*(1-t)+107*t), int(255*(1-t)))
    else:
        t = (h-0.66)/0.34
        return (255, int(107*(1-t)), int(t*110))

def draw_grid(img, scroll_y, hue_offset):
    horizon = H//2
    vp_x    = W//2
    color   = hue_to_bgr(hue_offset/360.0)

    # Horizontal lines
    n_horiz = 18
    for i in range(1, n_horiz+1):
        persp = (i/n_horiz)**2.4
        y_raw = horizon + (H-horizon)*persp
        y     = int((y_raw - horizon + scroll_y) % (H-horizon) + horizon)
        if horizon < y < H:
            alpha = 0.25 + 0.75*persp
            c = tuple(int(v*alpha) for v in color)
            cv2.line(img, (0,y), (W,y), c, 1)

    # Vertical converging lines
    n_vert = 20
    for i in range(n_vert+1):
        x_base = int(i * W/n_vert)
        cv2.line(img, (vp_x, horizon), (x_base, H), color, 1)

    # Horizon glow line
    glow_color = tuple(min(255, int(v*1.5)) for v in color)
    cv2.line(img, (0, horizon), (W, horizon), glow_color, 2)
    return img

def draw_silhouette(img, glitch_offsets):
    cx, cy = W//2, int(H*0.40)
    sc = 155
    segments = [
        (0.0,-1.0,  0.0,-0.70),   # 0 neck
        (0.0,-0.70, 0.0, 0.10),   # 1 torso
        (0.0,-0.60,-0.45,-0.30),  # 2 L upper arm
        (-0.45,-0.30,-0.55,0.20), # 3 L lower arm
        (0.0,-0.60, 0.45,-0.30),  # 4 R upper arm
        (0.45,-0.30, 0.55,0.20),  # 5 R lower arm
        (0.0, 0.10,-0.22, 0.65),  # 6 L thigh
        (-0.22,0.65,-0.22,1.10),  # 7 L shin
        (0.0, 0.10, 0.22, 0.65),  # 8 R thigh
        (0.22,0.65, 0.22,1.10),   # 9 R shin
    ]
    offset_map = {idx:(dx,dy) for idx,dx,dy in glitch_offsets}
    col = (255, 245, 0)   # cyan BGR
    for si, (x1n,y1n,x2n,y2n) in enumerate(segments):
        dx, dy = offset_map.get(si,(0,0))
        p1 = (int(cx+x1n*sc+dx), int(cy+y1n*sc+dy))
        p2 = (int(cx+x2n*sc+dx), int(cy+y2n*sc+dy))
        p1 = (np.clip(p1[0],0,W-1), np.clip(p1[1],0,H-1))
        p2 = (np.clip(p2[0],0,W-1), np.clip(p2[1],0,H-1))
        cv2.line(img, p1, p2, col, 1)
    # Head
    hc = (cx, int(cy-0.85*sc))
    if 0<=hc[0]<W and 0<=hc[1]<H:
        cv2.circle(img, hc, int(0.18*sc), col, 1)
    return img

def chromatic_aberration(img, rms_val):
    shift = int(rms_val*8)
    if shift==0: return img
    b,g,r = cv2.split(img)
    rows,cols = img.shape[:2]
    Mr = np.float32([[1,0, shift],[0,1,0]])
    Mb = np.float32([[1,0,-shift],[0,1,0]])
    r = cv2.warpAffine(r, Mr, (cols,rows))
    b = cv2.warpAffine(b, Mb, (cols,rows))
    return cv2.merge([b,g,r])

def scan_tears(img, treble_val):
    n_tears = int(treble_val*9)
    out = img.copy()
    for _ in range(n_tears):
        y  = np.random.randint(0,H)
        h2 = np.random.randint(1,5)
        sh = int(np.random.uniform(-1,1)*treble_val*65)
        y2 = min(y+h2,H)
        band = img[y:y2,:].copy()
        out[y:y2,:] = np.roll(band, sh, axis=1)
    return out

def vhs_dropout(img):
    n = np.random.poisson(3)
    out = img.copy()
    for _ in range(n):
        x  = np.random.randint(0, W-80)
        y  = np.random.randint(0, H-5)
        w2 = np.random.randint(5,80)
        h2 = np.random.randint(1,4)
        aw = min(w2, W-x)
        ah = min(h2, H-y)
        if aw<=0 or ah<=0: continue
        noise = np.random.randint(0,256,(ah,aw,3),dtype=np.uint8)
        out[y:y+ah, x:x+aw] = noise
    return out

def vignette(img, kick_val):
    inner = 0.22 + 0.12*kick_val
    cx2,cy2 = W//2, H//2
    Y,X = np.mgrid[0:H,0:W]
    dist = np.sqrt(((X-cx2)/(W/2))**2 + ((Y-cy2)/(H/2))**2)
    mask = np.clip((dist-inner)/(0.88-inner),0,1)
    dark = img.astype(np.float32)*(1.0 - mask[:,:,np.newaxis]*0.88)
    return dark.clip(0,255).astype(np.uint8)

def render_frame(f, envs, scroll_y, hue_offset, glitch_offsets,
                 datamosh_buf, is_datamosh):
    rms    = envs['rms'][f]
    kick   = envs['kick'][f]
    treble = envs['treble'][f]
    t_s    = f/FPS

    # Hard white flash on strong kick
    if kick > 0.88 and np.random.random() < 0.45:
        return np.full((H,W,3), 255, dtype=np.uint8)

    img = np.full((H,W,3), COL_BG, dtype=np.uint8)

    img = draw_grid(img, scroll_y, hue_offset)
    img = draw_silhouette(img, glitch_offsets)

    # Datamosh smear
    if is_datamosh and datamosh_buf is not None:
        k = int(rms*50+1)|1
        smear = cv2.GaussianBlur(datamosh_buf,(k,k),0)
        img = cv2.addWeighted(img,0.25,smear,0.75,0)

    # 4-bit crush desaturate
    if 27.0 <= t_s <= 27.3:
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        img  = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
        # Slam oversaturated on the snap-back frame
        if 27.28 <= t_s <= 27.32:
            img = cv2.convertScaleAbs(img, alpha=2.5, beta=30)

    img = chromatic_aberration(img, rms)
    img = scan_tears(img, treble)
    img = vhs_dropout(img)
    img = vignette(img, kick)

    return img

# ══════════════════════════════════════════════════════════════════════════════
# 4. MAIN
# ══════════════════════════════════════════════════════════════════════════════

def generate():
    print("=" * 58)
    print("  CYBERPUNK FEVER DREAM — AV Generator")
    print("  30s · 120 BPM · 1280×720 · 30fps")
    print("=" * 58)

    # Audio
    print("\n[1/4] Synthesizing audio …")
    L, R = synthesize_audio()
    audio_path = "/home/claude/cyberpunk_audio.wav"
    save_wav_stereo(audio_path, L, R)
    kb = os.path.getsize(audio_path)//1024
    print(f"  ✓ {audio_path}  ({kb} KB)")

    # Envelopes
    print("[2/4] Extracting per-frame envelopes …")
    envs = extract_envelopes(L, R)

    if not HAS_CV2:
        print("[error] opencv-python required. pip install opencv-python")
        return

    # Video
    print("[3/4] Rendering 900 frames …")
    video_path = "/home/claude/cyberpunk_frames.mp4"
    fourcc = cv2.VideoWriter_fourcc(*'mp4v')
    writer = cv2.VideoWriter(video_path, fourcc, FPS, (W, H))

    scroll_y       = 0.0
    hue_offset     = 0.0
    glitch_offsets = []
    glitch_timer   = 0
    datamosh_buf   = None
    prev_frame     = None

    for f in range(N_FRAMES):
        kick   = envs['kick'][f]
        rms    = envs['rms'][f]

        # Grid scroll: base + kick lurch
        scroll_y  += 2.5 + kick*42.0
        scroll_y  %= (H - H//2)
        hue_offset += 0.3 + rms*1.8

        # Silhouette glitch timer
        glitch_timer -= 1
        if glitch_timer <= 0:
            glitch_timer = np.random.randint(FPS*3, FPS*7)
            n_segs = np.random.randint(1,3)
            glitch_offsets = [
                (np.random.randint(0,10),
                 np.random.randint(-110,110),
                 np.random.randint(-55,55))
                for _ in range(n_segs)
            ]
        elif glitch_timer > 2 and np.random.random() < 0.35:
            glitch_offsets = []

        # Datamosh trigger ~every 11s
        t_s = f/FPS
        is_datamosh = (int(t_s) % 11 == 0 and f % FPS < int(FPS*0.28))
        if is_datamosh and prev_frame is not None:
            datamosh_buf = prev_frame.copy()

        frame = render_frame(f, envs, scroll_y, hue_offset,
                              glitch_offsets, datamosh_buf, is_datamosh)

        # Frame-hold stutter: duplicate frame on high-rms stutter events
        writer.write(frame)
        if rms > 0.75 and np.random.random() < 0.08:
            writer.write(frame)   # duplicate = stutter

        prev_frame = frame.copy()

        if f % 150 == 0:
            pct = f/N_FRAMES*100
            print(f"    {f}/{N_FRAMES} frames ({pct:.0f}%) …")

    writer.release()
    print(f"  ✓ {video_path}")

    # FFmpeg mux
    print("[4/4] Muxing audio + video with FFmpeg …")
    out_path = "/home/claude/cyberpunk_fever_dream.mp4"
    cmd = [
        "ffmpeg", "-y",
        "-i", video_path,
        "-i", audio_path,
        "-c:v", "libx264", "-preset", "fast", "-crf", "18",
        "-c:a", "aac", "-b:a", "192k",
        "-shortest", out_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode == 0:
        mb = os.path.getsize(out_path)/1024/1024
        print(f"  ✓ {out_path}  ({mb:.1f} MB)")
        os.remove(video_path)
        os.remove(audio_path)
    else:
        print("[ffmpeg stderr]", result.stderr[-500:])

    print("\nDone.")

if __name__ == "__main__":
    generate()