<?php
die(highlight_file(__FILE__, 1));
// Halloween 2026 - flickering lanterns + spooky puck
// Talks to olad's HTTP API (one persistent curl handle) instead of
// spawning ola_streaming_client thousands of times.
//
// Run:      php halloween2026.php
// Override: echo full    > /tmp/lanterns.mode   (kid-friendly steady glow)
//           echo breathe > /tmp/lanterns.mode
//           echo flicker > /tmp/lanterns.mode
//           echo off     > /tmp/lanterns.mode
//           echo auto    > /tmp/lanterns.mode   (back to the normal cycle)
//
// Needs: php-cli, php-curl, olad running with its web server on :9090

// ---------- config ----------
const OLA_URL      = 'http://127.0.0.1:9090/set_dmx';
const UNIVERSE     = 1;
const FPS          = 30;
const MODE_FILE    = '/tmp/lanterns.mode';
const FADE_SECS    = 2.0;     // crossfade between modes
const BREADTH      = 40;      // How deep a dim/bright candle flicker will go

const DEBUG       = true;
const DEBUG_EVERY = 3;     // 1 = every frame, 3 = ~10 lines/sec

const LANTERNS     = 12;      // DMX channels 1..12
const PUCK_MASTER  = 20;      // 21=R 22=G 23=B
const LAST_CH      = 23;            // last in-use DMX channel so we don't blast the entire universe

// "neon" fixtures: strictly 0 or 255, never dimmed, never crossfaded.
// These channels are skipped by the lantern flicker. Add a second channel here later.
const NEON_CHANNELS = [9];
const NEON_MIN_HOLD = 0.10;   // seconds: fixture needs this long between ANY two changes
const NEON_GAP_MIN  = 6.0;    // steady-on time between dropouts (flicker mode)
const NEON_GAP_MAX  = 10.0;

const FLICKER_MIN_SECS = 60;  // auto mode: flicker this long...
const FLICKER_MAX_SECS = 120;
const BREATHE_SECS     = 15;  // ...then breathe this long
const BREATHE_PERIOD   = 5.0; // seconds per in/out cycle

const DIM_FLOOR    = 30;      // dimmest a flickering lantern gets
const BREATHE_LOW  = 40;      // dimmest point of the breathe

// ---------- helpers ----------
function frand($a, $b) { return $a + mt_rand() / mt_getrandmax() * ($b - $a); }
function clamp($v, $lo = 0, $hi = 255) { return max($lo, min($hi, $v)); }

function blankFrame() { return array_fill(1, LAST_CH, 0.0); }

function readOverride() {
    $m = @trim(@file_get_contents(MODE_FILE));
    return in_array($m, ['auto', 'flicker', 'breathe', 'full', 'off'], true) ? $m : 'auto';
}

$ch = curl_init(OLA_URL);
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT_MS     => 200,
    CURLOPT_CONNECTTIMEOUT_MS => 200,
    CURLOPT_TCP_NODELAY    => true,
]);

function sendDmx($ch, array $frame) {
    $vals = [];
    for ($c = 1; $c <= LAST_CH; $c++) {
        $vals[] = (int) round(clamp($frame[$c]));
    }
    curl_setopt($ch, CURLOPT_POSTFIELDS, 'u=' . UNIVERSE . '&d=' . implode(',', $vals));
    $t = microtime(true);
    $res = curl_exec($ch);
    $ms = (microtime(true) - $t) * 1000;
    if ($res === false || $ms > 50) {
        fprintf(STDERR, "%s SEND PROBLEM: %s (%.0f ms)\n", date('H:i:s'),
            $res === false ? curl_error($ch) : 'slow', $ms);
    }
}

// ---------- flicker state (one "candle" per lantern, plus one for the puck) ----------
// Each candle has its own speed, so no two lanterns flicker alike.
$candles = [];
for ($i = 0; $i < LANTERNS + 1; $i++) {
    $candles[$i] = [
        'cur'   => frand(120, 255),
        'tgt'   => 200,
        'next'  => 0,
        'speed' => frand(0.6, 1.7),   // personality
        'depth' => frand(0.6, 1.0),   // how deep this one's dips go
    ];
}

function stepCandle(array &$k, $now, $dt) {
    /*
        // Claude picks the target from scratch each time, independent of where it is now. 
        // Most targets land in 130 to 255, with a 12% chance of a dip down to 30 to 110. 
        // cur then eases toward tgt, so tgt is where it's heading and cur is what gets sent. 
        // That means mine can swing about 125 steps at once, and dips can be much bigger.
    if ($now >= $k['next']) {
        // mostly bright-ish, occasionally a big dip (a gust of wind)
        if (mt_rand(0, 99) < 12) {
            $k['tgt'] = frand(DIM_FLOOR, 110);
        } else {
            $k['tgt'] = frand(130, 255);
        }
        $k['tgt'] = 255 - (255 - $k['tgt']) * $k['depth'];
        $k['next'] = $now + frand(0.04, 0.20) / $k['speed'];
    }
    */

    // Presence's version is my old "only flicker within a window of the current brightness"
    // Because Presence thinks that too big of a brightness change is tacky, and a more controlled
    // flickering dimming range looks more pleasant.  My mystical flame versus chaotic nature.
    if ($now >= $k['next']) {
    // wander +/- BREADTH from where we are, with a gentle pull toward the middle
    $step = frand(-BREADTH, BREADTH) * $k['depth'];
        // this 0.1 is the pull to medium brightness
    $k['tgt'] = clamp($k['tgt'] + $step + (190 - $k['tgt']) * 0.1, DIM_FLOOR, 255);
    $k['next'] = $now + frand(0.04, 0.20) / $k['speed'];
    }
    // End of the Presence vs Claude option, back to the core:
    $rate = 14 * $k['speed'];
    $k['cur'] += ($k['tgt'] - $k['cur']) * min(1.0, $rate * $dt);
    return $k['cur'];
}

// ---------- neon fixture ----------
// 'seq' is a list of [state, seconds] steps. Flicker mode walks through it:
// steady ON for a while, then a dropout (single, or a stutter), repeat.
$neon = ['seq' => [], 'state' => true, 'until' => 0, 'out' => true, 'outChanged' => 0];

function neonBuildSeq() {
    $seq = [[true, frand(NEON_GAP_MIN, NEON_GAP_MAX)]];       // steady on
    if (mt_rand(0, 99) < 60) {
        $seq[] = [false, frand(0.15, 0.50)];                  // one dropout
    } else {
        $seq[] = [false, frand(0.10, 0.20)];                  // stutter: off-on-off
        $seq[] = [true,  frand(0.10, 0.25)];
        $seq[] = [false, frand(0.10, 0.30)];
    }
    return $seq;                                              // ends OFF; next seq starts ON
}

function neonDesired($now) {
    global $neon;
    if ($now >= $neon['until']) {
        if (!$neon['seq']) $neon['seq'] = neonBuildSeq();
        list($s, $d) = array_shift($neon['seq']);
        $neon['state'] = $s;
        $neon['until'] = $now + max(NEON_MIN_HOLD, $d);
    }
    return $neon['state'];
}

// Final gatekeeper: no matter who asks (flicker, mode change, off), the output
// can't change state again until NEON_MIN_HOLD has passed.
function neonLimit($want, $now) {
    global $neon;
    if ($want !== $neon['out'] && ($now - $neon['outChanged']) >= NEON_MIN_HOLD) {
        $neon['out'] = $want;
        $neon['outChanged'] = $now;
    }
    return $neon['out'];
}

// puck tint: none / amber / purple, eased in and out
$tint = ['amber' => 0.0, 'purple' => 0.0, 'target' => 'none', 'next' => 0];

function flickerFrame($now, $dt) {
    global $candles, $tint;
    $f = blankFrame();

    for ($i = 0; $i < LANTERNS; $i++) {
        if (in_array($i + 1, NEON_CHANNELS)) continue;   // neon is handled separately
        $f[$i + 1] = stepCandle($candles[$i], $now, $dt);
    }

    // puck
    $p = stepCandle($candles[LANTERNS], $now, $dt);
    if ($now >= $tint['next']) {
        $r = mt_rand(0, 99);
        $tint['target'] = $r < 50 ? 'none' : ($r < 77 ? 'amber' : 'purple');
        $tint['next'] = $now + frand(2, 6);
    }
    foreach (['amber', 'purple'] as $name) {
        $goal = ($tint['target'] === $name) ? 1.0 : 0.0;
        $tint[$name] += ($goal - $tint[$name]) * min(1.0, 3.0 * $dt);
    }
    $f[PUCK_MASTER]     = $p; // this really should be $p, I tried 100.
    $f[PUCK_MASTER + 1] = 255 - 55 * $tint['purple'];   // red, always dominant
    $f[PUCK_MASTER + 2] = 80 * $tint['amber'];          // a bit of green -> amber
    $f[PUCK_MASTER + 3] = 150 * $tint['purple'];        // a bit of blue  -> purple
    return $f;
}

function breatheFrame($now) {
    $s = (sin(2 * M_PI * $now / BREATHE_PERIOD) + 1) / 2;   // 0..1
    $lvl = BREATHE_LOW + (255 - BREATHE_LOW) * $s;
    $f = blankFrame();
    for ($i = 1; $i <= LANTERNS; $i++) $f[$i] = $lvl;       // all in sync = ghostly
    $f[PUCK_MASTER]     = $lvl;
    $f[PUCK_MASTER + 1] = 110;
    $f[PUCK_MASTER + 2] = 20;
    $f[PUCK_MASTER + 3] = 255;                              // pale ghostly blue-violet
    return $f;
}

function fullFrame() {
    $f = blankFrame();
    for ($i = 1; $i <= LANTERNS; $i++) $f[$i] = 255;
    $f[PUCK_MASTER] = 255; $f[PUCK_MASTER + 1] = 255; $f[PUCK_MASTER + 2] = 110; // warm and friendly
    return $f;
}

// ---------- the state machine ----------
$phase       = 'flicker';  // used by auto mode: 'flicker' or 'breathe'
$phaseEnds   = microtime(true) + frand(FLICKER_MIN_SECS, FLICKER_MAX_SECS);
$lastMode    = null;
$lastOut     = blankFrame();
$fromFrame   = blankFrame();
$mixT        = 1.0;
$overrideAt  = 0;
$override    = 'auto';
$prev        = microtime(true);
$frameLen    = 1.0 / FPS;
$n = 0; // for debug logging

while (true) {
    $now = microtime(true);
    $dt  = $now - $prev;
        if ($dt > 0.1) fprintf(STDERR, "%s FRAME STALL: %.0f ms\n", date('H:i:s'), $dt * 1000);
    $prev = $now;

    // re-check the override file twice a second
    if ($now >= $overrideAt) {
        $override = readOverride();
        $overrideAt = $now + 0.5;
    }

    // decide the effective mode
    if ($override === 'auto') {
        if ($now >= $phaseEnds) {
            if ($phase === 'flicker') {
                $phase = 'breathe';
                $phaseEnds = $now + BREATHE_SECS;
            } else {
                $phase = 'flicker';
                $phaseEnds = $now + frand(FLICKER_MIN_SECS, FLICKER_MAX_SECS);
            }
        }
        $mode = $phase;
    } else {
        $mode = $override;
    }

    // crossfade when the mode changes
    if ($mode !== $lastMode) {
        $fromFrame = $lastOut;
        $mixT = ($lastMode === null) ? 1.0 : 0.0;
        $lastMode = $mode;
    }

    // render this mode's frame
    switch ($mode) {
        case 'breathe': $target = breatheFrame($now);        break;
        case 'full':    $target = fullFrame();               break;
        case 'off':     $target = blankFrame();              break;
        default:        $target = flickerFrame($now, $dt);   break;
    }

    // blend
    if ($mixT < 1.0) {
        $mixT = min(1.0, $mixT + $dt / FADE_SECS);
        $out = blankFrame();
        for ($c = 1; $c <= LAST_CH; $c++) {
            $out[$c] = $fromFrame[$c] + ($target[$c] - $fromFrame[$c]) * $mixT;
        }
    } else {
        $out = $target;
    }

    // neon channels: never blended. Flicker mode = steady with creepy dropouts;
    // breathe/full = steady on; off = off.
    if ($mode === 'off')          $want = false;
    elseif ($mode === 'flicker')  $want = neonDesired($now);
    else                          $want = true;
    $neonOn = neonLimit($want, $now);
    foreach (NEON_CHANNELS as $nc) $out[$nc] = $neonOn ? 255 : 0;

        if ($mode !== 'off' && max(array_slice($out, 0, LANTERNS)) < 5) {
        fprintf(STDERR, "%s ALL DARK in mode %s\n", date('H:i:s'), $mode);
        }
    sendDmx($ch, $out);
        if (DEBUG && (++$n % DEBUG_EVERY === 0)) {
            $line = [];
            for ($c = 1; $c <= LAST_CH; $c++) $line[] = sprintf('%3d', round(clamp($out[$c])));
        printf("%-8s %s\n", $mode, implode(' ', $line));
        }
    $lastOut = $out;

    $spent = microtime(true) - $now;
    if ($spent < $frameLen) usleep((int) (($frameLen - $spent) * 1e6));
}