Waves Simulator

Waves Simulator

by @robynhub

A lava-lamp-like ocean animation for BUSY Bar, with colorful moving waves, smooth transitions from calm to stormy seas, realistic gradients, foam, reflections, sound and configurable speed.

zero-install weatherscreensaversoundinfo

How to run

This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:

python app.py

Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP with --host <address>.

No bar at hand? Run the BUSY Bar Emulator and point the app at it: python app.py --host 127.0.0.1:8080.

To keep it running without a terminal open, install it with busybar-manager, which handles the virtualenv, autostart and scheduling, and lets you switch between apps from a web dashboard.

Source code

app.py
#!/usr/bin/env python3
"""Ambient ocean waves for BUSY Bar.

A 1-D ocean surface rendered as a full 72x16 frame. Five sea states
progress from calm water to storm, with smooth timed transitions between them.
The left and right display edges behave like container walls: incoming waves are
partly reflected, the water piles up against the wall, and a slow slosh mode tilts
the surface like liquid moving inside a bottle.

    python3 app.py
    python3 app.py --host 127.0.0.1:8080
    python3 app.py --state auto
    python3 app.py --state moderate
    python3 app.py --state-seconds 24 --transition-seconds 10
    python3 app.py --animation-speed 0.65
    python3 app.py --cycle calm,breeze,moderate,rough,storm,rough,moderate,breeze
    python3 app.py --test

States: calm, breeze, moderate, rough, storm.

In auto mode the state machine moves through adjacent sea states instead of
jumping randomly. Each state is held for --state-seconds, then the physical wave
parameters are interpolated over --transition-seconds using a smoothstep curve.
"""
import argparse
import json
import math
import random
import struct
import wave
import io
import hashlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib

APP = "waves"
W, H = 72, 16
_RING = 4
_frame_no = 0

STATE_NAMES = ["calm", "breeze", "moderate", "rough", "storm"]

# Parameters are intentionally physical-ish rather than arbitrary animation
# knobs. Wave families deliberately travel in both directions and reflect from the
# side walls. Higher states increase amplitude, steepness, high-frequency energy
# and propagation speed together.
SEA_STATES = {
    "calm": {
        "level": 0.00, "base_y": 6.0, "amp": 0.55, "speed": 0.72,
        "steep": 0.08, "chop": 0.10, "foam": 0.00, "spray": 0.00,
    },
    "breeze": {
        "level": 0.25, "base_y": 6.0, "amp": 0.95, "speed": 0.86,
        "steep": 0.13, "chop": 0.20, "foam": 0.08, "spray": 0.00,
    },
    "moderate": {
        "level": 0.50, "base_y": 6.1, "amp": 1.55, "speed": 1.03,
        "steep": 0.20, "chop": 0.36, "foam": 0.28, "spray": 0.02,
    },
    "rough": {
        "level": 0.75, "base_y": 6.3, "amp": 2.25, "speed": 1.20,
        "steep": 0.28, "chop": 0.55, "foam": 0.58, "spray": 0.12,
    },
    "storm": {
        "level": 1.00, "base_y": 6.6, "amp": 3.00, "speed": 1.38,
        "steep": 0.36, "chop": 0.72, "foam": 0.90, "spray": 0.25,
    },
}


def parse_args():
    p = argparse.ArgumentParser(description="Ambient realistic ocean waves for BUSY Bar")
    p.add_argument("--host", default="10.0.4.20")
    p.add_argument("--fps", type=float, default=12.0, help="frames per second / smoothness (default: 12)")
    p.add_argument("--animation-speed", dest="animation_speed", type=float, default=2.0,
                   help="global animation speed multiplier; 0.5=half speed, 2=double (default: 2.0)")
    p.add_argument("--state", dest="state",
                   choices=["auto"] + STATE_NAMES, default="auto",
                   help="sea state (default: auto)")
    p.add_argument("--state-seconds", type=float, default=24.0,
                   help="time spent at each auto state before changing (default: 24)")
    p.add_argument("--transition-seconds", type=float, default=10.0,
                   help="duration of each smooth state transition (default: 10)")
    p.add_argument("--cycle", default=None,
                   help="comma-separated auto sequence, e.g. calm,breeze,moderate,rough,storm")
    p.add_argument("--wall-feedback", type=float, default=0.72,
                   help="edge reflection/pile-up strength, 0..1 (default: 0.72)")
    p.add_argument("--sloshing", type=float, default=0.70,
                   help="slow bottle-like left/right slosh strength, 0..1 (default: 0.70)")
    p.add_argument("--muted", action="store_true", help="disable thunder audio during lightning")
    p.add_argument("--thunder-test", action="store_true", help="upload and play one thunderclap immediately, then exit")
    p.add_argument("--test", action="store_true", help="draw one frame and exit")
    return p.parse_args()


THUNDER_ASSET = None  # resolved from the WAV content hash at runtime


def _thunder_wav():
    """Generate a thunderclap tuned for the BUSY Bar's tiny loudspeaker.

    A physically realistic thunder recording carries a lot of energy below
    100 Hz, which a very small speaker mostly cannot reproduce.  This version
    deliberately moves the perceived weight upward into roughly 140-1200 Hz,
    keeps several rolling bursts audible for seconds, and uses a short fade-in
    to avoid the click produced by an abrupt first sample.
    """
    rate = 44100
    duration = 1.25
    count = int(rate * duration)
    rng = random.Random(0xB05712)
    out = bytearray()

    # One-pole low-pass states. Differences between them form broad band-pass
    # signals without external DSP dependencies:
    #   presence: ~500-2200 Hz, body: ~150-650 Hz, weight: <~180 Hz.
    lp_fast = 0.0
    lp_mid = 0.0
    lp_low = 0.0

    # Irregular rolling thunder events.  The later events are intentionally
    # strong enough to remain audible on the BUSY Bar speaker instead of
    # disappearing into an inaudible sub-bass tail.
    rolls = (
        (0.05, 1.00, 3.2),
        (0.42, 0.88, 2.6),
        (0.92, 0.72, 2.2),
        (1.48, 0.52, 1.9),
    )

    for i in range(count):
        t = i / rate
        white = rng.random() * 2.0 - 1.0

        # Broad spectral bands with substantially more midrange than v11.
        lp_fast += 0.23 * (white - lp_fast)
        lp_mid += 0.060 * (white - lp_mid)
        lp_low += 0.016 * (white - lp_low)
        presence = lp_fast - lp_mid
        body_noise = lp_mid - lp_low
        weight_noise = lp_low

        # A thunder crack rather than a digital click: it rises over ~8 ms,
        # then decays quickly, with noisy midrange and a few resonances.
        crack_attack = 1.0 - math.exp(-t * 125.0)
        crack_decay = math.exp(-t * 8.5)
        crack_env = crack_attack * crack_decay
        crack = (
            1.00 * presence
            + 0.55 * body_noise
            + 0.14 * math.sin(2.0 * math.pi * 720.0 * t)
            + 0.12 * math.sin(2.0 * math.pi * 410.0 * t + 0.7)
        ) * crack_env

        # Each roll has a quick but non-instantaneous attack and a compact decay.
        # Slight amplitude modulation makes the tail breathe and break up.
        body_env = 0.0
        presence_env = 0.0
        for onset, strength, decay in rolls:
            dt = t - onset
            if dt > 0.0:
                rise = 1.0 - math.exp(-dt * 12.0)
                fall = math.exp(-dt * decay)
                e = strength * rise * fall
                body_env += e
                presence_env += e * math.exp(-dt * 0.55)

        flutter = (
            0.78
            + 0.11 * math.sin(2.0 * math.pi * 1.3 * t + 0.3)
            + 0.07 * math.sin(2.0 * math.pi * 3.1 * t + 1.2)
            + 0.04 * math.sin(2.0 * math.pi * 6.8 * t + 0.5)
        )

        # Speaker-friendly body: most energy lives above 120 Hz.  The tones are
        # intentionally not pure fundamentals; they reinforce perceived bass on
        # a speaker that cannot reproduce true 40-70 Hz thunder energy.
        resonances = (
            0.24 * math.sin(2.0 * math.pi * 148.0 * t + 0.2)
            + 0.17 * math.sin(2.0 * math.pi * 196.0 * t + 1.0)
            + 0.11 * math.sin(2.0 * math.pi * 286.0 * t + 2.1)
            + 0.055 * math.sin(2.0 * math.pi * 430.0 * t + 0.6)
        )
        rumble = (
            1.45 * body_noise
            + 0.72 * weight_noise
            + 0.38 * presence * presence_env
            + resonances
        ) * body_env * flutter

        # A quieter, grainy high-mid layer keeps the short tail perceptible.
        air = presence * (0.13 + 0.10 * math.sin(2.0 * math.pi * 0.73 * t)) * body_env

        # Fade the final ~0.45 second smoothly.  A tiny master fade-in eliminates any
        # discontinuity at sample zero even if the decoder starts immediately.
        fade_in = min(1.0, t / 0.012)
        fade_out = clamp((duration - t) / 0.25)
        sample = (0.62 * crack + 0.84 * rumble + 0.30 * air) * fade_in * fade_out

        # Strong soft compression is intentional: it keeps later rolls audible
        # while preventing the initial crack from clipping.
        sample = math.tanh(sample * 2.25) * 0.88
        out += struct.pack("<h", int(max(-1.0, min(1.0, sample)) * 32767))

    bio = io.BytesIO()
    with wave.open(bio, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(rate)
        wf.writeframes(bytes(out))
    return bio.getvalue()

def _upload_audio(host):
    """Upload thunder under a content-addressed filename.

    Reusing a fixed path such as thunder.wav can leave the device playing a
    previously decoded/cached asset even after new bytes were uploaded.  The
    SHA-256 suffix makes every materially different thunder waveform a new
    device path, while identical launches reuse the same stable name.
    """
    global THUNDER_ASSET
    wav = _thunder_wav()
    digest = hashlib.sha256(wav).hexdigest()[:12]
    THUNDER_ASSET = f"thunder_{digest}.wav"
    path = "/api/assets/upload?" + urllib.parse.urlencode({"application_name": APP, "file": THUNDER_ASSET})
    req = urllib.request.Request(_base(host) + path, data=wav, method="POST",
                                 headers={"Content-Type": "application/octet-stream"})
    with urllib.request.urlopen(req, timeout=15):
        pass
    return THUNDER_ASSET, len(wav)


def _play_thunder(host):
    if not THUNDER_ASSET:
        raise RuntimeError("thunder asset has not been uploaded")
    body = json.dumps({"application_name": APP, "path": THUNDER_ASSET}).encode()
    req = urllib.request.Request(_base(host) + "/api/audio/play", data=body, method="POST",
                                 headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except urllib.error.HTTPError as e:
        # Audio already busy should not interrupt the animation.
        if e.code not in (409, 410):
            raise


def _stop_audio(host):
    req = urllib.request.Request(_base(host) + "/api/audio/play", method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except urllib.error.HTTPError as e:
        if e.code not in (404, 410):
            raise


def _base(host):
    host = host.replace("http://", "").replace("https://", "").rstrip("/")
    return "http://" + host


def _png(pixels):
    raw = bytearray()
    for y in range(H):
        raw.append(0)
        base = y * W
        for x in range(W):
            r, g, b = pixels[base + x]
            raw += bytes((r, g, b, 255))

    def chunk(tag, data):
        c = tag + data
        return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)

    return (b"\x89PNG\r\n\x1a\n"
            + chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
            + chunk(b"IDAT", zlib.compress(bytes(raw), 6))
            + chunk(b"IEND", b""))


def _post(host, path, data, content_type):
    req = urllib.request.Request(_base(host) + path, data=data, method="POST",
                                 headers={"Content-Type": content_type})
    with urllib.request.urlopen(req, timeout=5) as r:
        return r.getcode()


def show(host, pixels):
    global _frame_no
    fn = "frame%d.png" % (_frame_no % _RING)
    _frame_no += 1
    try:
        _post(host, "/api/assets/upload?application_name=%s&file=%s" % (APP, fn),
              _png(pixels), "application/octet-stream")
        body = {
            "application_name": APP,
            "priority": 30,
            "elements": [{"id": "frame", "type": "image", "path": fn, "x": 0, "y": 0}],
        }
        return _post(host, "/api/display/draw", json.dumps(body).encode(), "application/json")
    except urllib.error.HTTPError as e:
        if e.code == 409:
            return 409
        raise


def clear(host):
    qs = urllib.parse.urlencode({"application_name": APP})
    req = urllib.request.Request(_base(host) + "/api/display/draw?" + qs, method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except Exception:
        pass


def clamp(v, lo=0.0, hi=1.0):
    return max(lo, min(hi, v))


def smoothstep(t):
    t = clamp(t)
    return t * t * (3.0 - 2.0 * t)


def mix_color(a, b, t):
    t = clamp(t)
    return tuple(int(a[i] + (b[i] - a[i]) * t + 0.5) for i in range(3))


def lerp_params(a, b, t):
    t = smoothstep(t)
    return {k: a[k] + (b[k] - a[k]) * t for k in a}


# Ocean palette tuned for the BUSY Bar's tiny, high-contrast LED matrix.
# The important change from v4 is that water is no longer a single linear
# mid->deep gradient. Real water loses red first, then green, while surface
# scattering introduces a brighter cyan/green band only in the top pixels.
SKY_CALM = (2, 9, 18)
SKY_STORM = (5, 8, 16)

# Calm water is slightly clearer/greener; rough water becomes darker and
# greyer as the apparent surface reflection increases.
DEEP_CALM = (0, 11, 30)
DEEP_STORM = (1, 7, 22)
LOW_CALM = (0, 30, 58)
LOW_STORM = (1, 22, 47)
MID_CALM = (0, 73, 105)
MID_STORM = (3, 48, 76)
UPPER_CALM = (5, 113, 132)
UPPER_STORM = (7, 78, 100)
SHALLOW_CALM = (22, 145, 151)
SHALLOW_STORM = (25, 107, 119)
CREST_CALM = (103, 205, 195)
CREST_STORM = (105, 177, 176)

# Foam is deliberately not pure white. A cold grey/cyan survives the LED
# display better and reads more like aerated seawater than a glowing stripe.
FOAM_CALM = (205, 232, 222)
FOAM_STORM = (188, 211, 208)
LIGHTNING = (226, 239, 244)


def water_color(depth, level):
    """Depth-aware ocean colour with non-linear optical attenuation.

    depth is measured in pixels below the instantaneous surface. The first
    ~2 px contain most of the turquoise surface scattering; below that the
    gradient compresses rapidly toward dark blue. This gives the 16px display
    much more perceived depth than a linear RGB interpolation.
    """
    deep = mix_color(DEEP_CALM, DEEP_STORM, level)
    low = mix_color(LOW_CALM, LOW_STORM, level)
    mid = mix_color(MID_CALM, MID_STORM, level)
    upper = mix_color(UPPER_CALM, UPPER_STORM, level)
    shallow = mix_color(SHALLOW_CALM, SHALLOW_STORM, level)
    crest = mix_color(CREST_CALM, CREST_STORM, level)

    d = max(0.0, depth)
    if d < 0.55:
        return mix_color(crest, shallow, smoothstep(d / 0.55))
    if d < 1.8:
        return mix_color(shallow, upper, smoothstep((d - 0.55) / 1.25))
    if d < 4.0:
        return mix_color(upper, mid, smoothstep((d - 1.8) / 2.2))
    if d < 7.5:
        return mix_color(mid, low, smoothstep((d - 4.0) / 3.5))
    return mix_color(low, deep, smoothstep((d - 7.5) / 5.5))


def _set(buf, x, y, c):
    if 0 <= x < W and 0 <= y < H:
        buf[y * W + x] = c


def _add(buf, x, y, c, alpha):
    if not (0 <= x < W and 0 <= y < H):
        return
    old = buf[y * W + x]
    a = clamp(alpha)
    buf[y * W + x] = tuple(
        min(255, int(old[i] * (1.0 - a) + c[i] * a + 0.5)) for i in range(3)
    )


class SeaStateMachine:
    """Timed state transitions with adjacent-state motion and smooth interpolation."""

    def __init__(self, state, state_seconds, transition_seconds, cycle=None):
        self.fixed = state != "auto"
        self.state_seconds = max(1.0, state_seconds)
        self.transition_seconds = max(0.1, transition_seconds)
        self.cycle = self._parse_cycle(cycle)
        self.cycle_pos = 0
        self.direction = 1

        initial = state if self.fixed else (self.cycle[0] if self.cycle else "calm")
        self.current_name = initial
        self.target_name = initial
        self.from_params = dict(SEA_STATES[initial])
        self.to_params = dict(SEA_STATES[initial])
        now = time.monotonic()
        self.transition_started = now
        self.transitioning = False
        self.next_change = now + self.state_seconds

        self.flash_until = 0.0
        self.next_flash = now + random.uniform(18.0, 35.0)

    @staticmethod
    def _parse_cycle(spec):
        if not spec:
            return None
        names = [s.strip().lower() for s in spec.split(",") if s.strip()]
        bad = [s for s in names if s not in SEA_STATES]
        if bad:
            raise ValueError("unknown --cycle state(s): " + ", ".join(bad))
        if not names:
            raise ValueError("--cycle cannot be empty")
        return names

    def _choose_next(self):
        if self.cycle:
            self.cycle_pos = (self.cycle_pos + 1) % len(self.cycle)
            return self.cycle[self.cycle_pos]

        # Natural random walk: move only one Beaufort-like step at a time.
        i = STATE_NAMES.index(self.current_name)
        if i == 0:
            self.direction = 1
        elif i == len(STATE_NAMES) - 1:
            self.direction = -1
        elif random.random() < 0.22:
            # Occasionally reverse the trend, but never jump across states.
            self.direction *= -1
        return STATE_NAMES[i + self.direction]

    def update(self, now):
        if self.fixed:
            params = SEA_STATES[self.current_name]
        else:
            if not self.transitioning and now >= self.next_change:
                self.target_name = self._choose_next()
                self.from_params = dict(SEA_STATES[self.current_name])
                self.to_params = dict(SEA_STATES[self.target_name])
                self.transition_started = now
                self.transitioning = True
                print(f"transition: {self.current_name} -> {self.target_name} "
                      f"({self.transition_seconds:g}s)")

            if self.transitioning:
                p = (now - self.transition_started) / self.transition_seconds
                params = lerp_params(self.from_params, self.to_params, p)
                if p >= 1.0:
                    self.current_name = self.target_name
                    self.transitioning = False
                    self.next_change = now + self.state_seconds
                    params = SEA_STATES[self.current_name]
                    print(f"state: {self.current_name} ({self.state_seconds:g}s)")
            else:
                params = SEA_STATES[self.current_name]

        level = params["level"]
        if level > 0.88 and now >= self.next_flash and random.random() < 0.035:
            self.flash_until = now + random.uniform(0.05, 0.10)
            self.next_flash = now + random.uniform(13.0, 30.0)

        return params, now < self.flash_until


class SloshDynamics:
    """Low-frequency liquid inertia with damped, irregular re-excitation.

    This is deliberately not a sine oscillator.  A velocity-like state carries
    the water toward one wall, loses energy through damping, reverses naturally,
    and receives small state-dependent impulses at irregular intervals.  The
    result is a bottle/tank motion whose period and amplitude slowly wander.
    """

    def __init__(self):
        self.angle = 0.0
        self.velocity = 0.0
        self.drive = 0.0
        self.last_t = None
        self.next_impulse = 0.0
        self.rng = random.Random(41723)

    def update(self, t, level, strength):
        if self.last_t is None:
            self.last_t = t
            self.next_impulse = t + self.rng.uniform(2.5, 5.5)
            return self.angle, self.velocity

        dt = min(0.10, max(0.0, t - self.last_t))
        self.last_t = t
        strength = clamp(strength)

        # Irregular external nudges emulate the bottle being disturbed. Stronger
        # sea states receive slightly larger and more frequent pushes.
        if t >= self.next_impulse:
            impulse = self.rng.uniform(-1.0, 1.0)
            self.velocity += impulse * (0.12 + 0.20 * level) * strength
            self.drive = self.rng.uniform(-1.0, 1.0) * (0.015 + 0.025 * level)
            self.next_impulse = t + self.rng.uniform(2.2, 5.8 - 1.4 * level)

        # Damped spring-like bulk liquid motion.  The nonlinear restoring term
        # keeps large excursions soft instead of perfectly harmonic.
        restoring = -0.62 * self.angle - 0.16 * self.angle * abs(self.angle)
        damping = -0.46 * self.velocity
        accel = restoring + damping + self.drive
        self.velocity += accel * dt
        self.angle += self.velocity * dt
        self.angle = max(-1.25, min(1.25, self.angle))
        self.drive *= math.exp(-dt * 0.55)
        return self.angle, self.velocity


_SLOSH = SloshDynamics()


def wave_surface(x, t, p, wall_feedback=0.72, sloshing=0.70, slosh_state=None):
    """Bidirectional bounded-liquid surface.

    The surface is the sum of independent right- and left-going wave families,
    their wall-reflected copies, a low-frequency inertial tank mode and local
    wind chop.  No component wraps around the display.  Because the two travelling
    families have different wavelengths and phase velocities, individual crests
    naturally overtake, cancel and reverse locally instead of the whole surface
    appearing to march in one direction.
    """
    amp = p["amp"]
    speed = p["speed"]
    steep = p["steep"]
    chop = p["chop"]
    level = p["level"]
    wall_feedback = clamp(wall_feedback)
    sloshing = clamp(sloshing)
    L = W - 1.0

    if slosh_state is None:
        slosh_pos, slosh_vel = _SLOSH.update(t, level, sloshing)
    else:
        slosh_pos, slosh_vel = slosh_state

    # Bulk liquid inertia.  cos(pi*x/L) puts opposite displacement at the two
    # walls and a node near the centre. Velocity adds a slight dynamic skew, so
    # the surface keeps moving through the neutral position instead of stopping.
    tank_shape = math.cos(math.pi * x / L)
    tank_shape2 = math.sin(2.0 * math.pi * x / L)
    slosh = amp * sloshing * (
        (0.28 + 0.20 * level) * slosh_pos * tank_shape
        + (0.055 + 0.050 * level) * slosh_vel * tank_shape2
    )

    # Reflection envelope: wall interaction is strong near the sides but the
    # reflected waves remain visible in the middle, which prevents a single
    # dominant propagation direction.
    edge_d = min(x, L - x)
    edge_env = math.exp(-edge_d / 13.0)
    refl = wall_feedback * (0.34 + 0.66 * edge_env)

    y = p["base_y"] + slosh

    # Long right-going swell and its left-going reflection.
    k1 = 2.0 * math.pi / 43.0
    f1 = k1 * x - speed * 0.90 * t + 0.10
    r1 = k1 * x + speed * 0.84 * t + 1.05
    y += amp * 0.66 * math.sin(f1)
    y += amp * 0.42 * refl * math.sin(r1)
    y += amp * steep * 0.34 * math.sin(2.0 * f1 + 0.35)
    y += amp * steep * 0.17 * refl * math.sin(2.0 * r1 - 0.20)

    # Independent left-going swell.  This is not merely the reflection of k1;
    # its different wavelength and phase speed create natural crossing patterns.
    k2 = 2.0 * math.pi / 61.0
    l2 = k2 * x + speed * 0.63 * t + 2.15
    rr2 = k2 * x - speed * 0.58 * t + 0.72
    y += amp * 0.31 * math.sin(l2)
    y += amp * 0.20 * refl * math.sin(rr2)
    y += amp * steep * 0.10 * math.sin(2.0 * l2 + 0.50)

    # Mid-scale crossing waves. Their amplitudes stay below the long swell so
    # storm mode remains recognisably fluid rather than random/noisy.
    k3 = 2.0 * math.pi / 25.0
    a3 = k3 * x - speed * 1.18 * t + 1.40
    b3 = k3 * x + speed * 0.96 * t + 3.00
    y += amp * chop * 0.18 * math.sin(a3)
    y += amp * chop * 0.14 * math.sin(b3)

    # Wall pile-up is coupled to actual inertial direction rather than a fixed
    # clock. Positive velocity drives water toward one side; negative toward the
    # other. The exponential shape makes the fluid climb the wall smoothly.
    push_r = clamp(max(0.0, slosh_vel) * 2.2)
    push_l = clamp(max(0.0, -slosh_vel) * 2.2)
    pile = amp * wall_feedback * (0.20 + 0.20 * level)
    y -= pile * (push_l * math.exp(-x / 5.0)
                 + push_r * math.exp(-(L - x) / 5.0))

    # Short capillary/chop components run in both directions.  Their amplitudes
    # are kept small, especially in calm states, to avoid the old random look.
    k4 = 2.0 * math.pi / 13.5
    y += amp * chop * 0.080 * math.sin(k4 * x - speed * 1.72 * t + 0.25)
    y += amp * chop * 0.060 * math.sin(k4 * x + speed * 1.49 * t + 2.30)

    # Very subtle vertical breathing avoids a perfectly fixed mean water volume.
    y += (0.025 + 0.045 * level) * math.sin(t * 0.19 + 0.7)
    return y

def render(t, p, flash=False, wall_feedback=0.72, sloshing=0.70):
    level = p["level"]
    sky = mix_color(SKY_CALM, SKY_STORM, level)
    crest_col = mix_color(CREST_CALM, CREST_STORM, level)
    foam_col = mix_color(FOAM_CALM, FOAM_STORM, level)
    shallow_col = mix_color(SHALLOW_CALM, SHALLOW_STORM, level)
    buf = [sky] * (W * H)

    # Update the bulk-liquid inertia exactly once per frame, then reuse that
    # state for every column.  Calling it per x would introduce artificial
    # phase/energy differences across the surface.
    slosh_state = _SLOSH.update(t, level, sloshing)
    surface = [wave_surface(x, t, p, wall_feedback, sloshing, slosh_state) for x in range(W)]

    # Water volume and subtle moving caustic texture.
    for x in range(W):
        sy = surface[x]
        for y in range(H):
            depth = y - sy
            if depth < 0:
                continue
            col = water_color(depth, level)

            # Moving caustics/specular modulation. Keep it subtle and strongest
            # near the surface; deeper water receives almost no brightening.
            shimmer_a = 0.5 + 0.5 * math.sin(x * 0.31 + y * 0.53 - t * (0.72 + level * 0.22))
            shimmer_b = 0.5 + 0.5 * math.sin(x * 0.13 - y * 0.37 + t * 0.41)
            shimmer = shimmer_a * shimmer_b
            near_surface = math.exp(-max(0.0, depth) / 3.4)
            col = mix_color(col, shallow_col, (0.035 + 0.025 * (1.0 - level)) * shimmer * near_surface)

            # Very mild blue absorption with depth. This darkens green before
            # blue and prevents the bottom rows from looking uniformly painted.
            absorb = clamp((depth - 4.0) / 10.0)
            if absorb > 0:
                col = (
                    int(col[0] * (1.0 - 0.34 * absorb)),
                    int(col[1] * (1.0 - 0.20 * absorb)),
                    int(col[2] * (1.0 - 0.07 * absorb)),
                )
            _set(buf, x, y, col)

    # Foam is based on crest height + negative curvature. This makes it gather
    # near physically plausible breaking crests instead of appearing on arbitrary
    # steep slopes all over the surface.
    foam_amount = p["foam"]
    for x in range(W):
        xm = max(0, x - 1)
        xp = min(W - 1, x + 1)
        s = surface[x]
        curvature = surface[xm] - 2.0 * s + surface[xp]
        mean_y = p["base_y"]
        crest_height = clamp((mean_y - s) / max(0.5, p["amp"] * 1.15))
        crest_curve = clamp((-curvature - 0.035) * 2.6)
        breaking = clamp(foam_amount * (0.62 * crest_height + 0.95 * crest_curve))

        yi = int(round(s))
        _add(buf, x, yi, crest_col, 0.58 + 0.17 * level)
        if breaking > 0.10:
            _add(buf, x, yi - 1, foam_col, 0.34 + breaking * 0.52)
        if breaking > 0.52:
            # A short lee-side streak reads better as foam than isolated speckles.
            if x + 1 < W:
                _add(buf, x + 1, yi, foam_col, breaking * 0.44)
            if x + 2 < W:
                _add(buf, x + 2, yi, crest_col, breaking * 0.28)

    # Only rough/storm seas produce detached spray. Particles are derived from
    # current breaking crests and advect consistently left/up for several frames.
    if p["spray"] > 0.0:
        tick = int(t * 8.0)
        rnd = random.Random(1709 + tick // 3)
        candidates = []
        for x in range(1, W - 1):
            s = surface[x]
            curvature = surface[x - 1] - 2.0 * s + surface[x + 1]
            if curvature < -0.20 and s < p["base_y"] - p["amp"] * 0.28:
                candidates.append(x)
        count = min(len(candidates), int(round(p["spray"] * 8)))
        if count:
            for x0 in rnd.sample(candidates, count):
                age = tick % 3
                x = max(0, x0 - age)
                y = int(round(surface[x0])) - 2 - age // 2
                _add(buf, x, y, foam_col, 0.46 + 0.22 * p["spray"])

    if flash:
        for y in range(H):
            a = 0.66 if y < 7 else 0.20
            for x in range(W):
                _add(buf, x, y, LIGHTNING, a)

    return buf


def main():
    args = parse_args()
    fps = max(2.0, min(20.0, args.fps))
    interval = 1.0 / fps

    try:
        sea = SeaStateMachine(args.state, args.state_seconds, args.transition_seconds, args.cycle)
    except ValueError as e:
        sys.exit(f"error: {e}")

    animation_speed = max(0.05, min(4.0, args.animation_speed))
    started = time.monotonic()
    thunder_due = None
    flash_was_on = False

    if not args.muted and not args.test:
        try:
            asset_name, asset_bytes = _upload_audio(args.host)
            print(f"thunder audio: ready ({asset_name}, {asset_bytes} bytes)")
        except Exception as e:
            print(f"warning: thunder audio unavailable ({e}); continuing silently")
            args.muted = True
    if args.thunder_test:
        if args.muted:
            sys.exit("error: --thunder-test cannot be used with --muted")
        try:
            print(f"playing thunder test from {THUNDER_ASSET}")
            _play_thunder(args.host)
            time.sleep(6.8)
        finally:
            try:
                _stop_audio(args.host)
            except Exception:
                pass
        return

    print(f"waves -> {_base(args.host)}  state={args.state} fps={fps:g} speed={animation_speed:g}x "
          f"hold={max(1.0,args.state_seconds):g}s transition={max(0.1,args.transition_seconds):g}s "
          f"walls={clamp(args.wall_feedback):.2f} slosh={clamp(args.sloshing):.2f} "
          "(Ctrl-C to stop)")

    try:
        if args.test:
            p = SEA_STATES["rough"]
            show(args.host, render(2.0, p, False, args.wall_feedback, args.sloshing))
            print("test: drew one rough-sea frame")
            return

        while True:
            frame_start = time.monotonic()
            p, flash = sea.update(frame_start)
            # Trigger one thunderclap per lightning event. A short random delay
            # gives the flash/sound pairing a little depth without blocking frames.
            if flash and not flash_was_on and not args.muted:
                thunder_due = frame_start + random.uniform(0.10, 0.38)
            flash_was_on = flash
            if thunder_due is not None and frame_start >= thunder_due:
                try:
                    _play_thunder(args.host)
                except Exception as e:
                    print(f"warning: thunder playback failed ({e})")
                thunder_due = None
            # Scale only the visual simulation clock. State hold/transition timers
            # intentionally remain in real seconds, so --animation-speed changes
            # how fast the water moves without changing the auto-state schedule.
            t = (frame_start - started) * animation_speed
            show(args.host, render(t, p, flash, args.wall_feedback, args.sloshing))
            elapsed = time.monotonic() - frame_start
            if elapsed < interval:
                time.sleep(interval - elapsed)
    except KeyboardInterrupt:
        print("\nstopped.")
    except urllib.error.HTTPError as e:
        sys.exit(f"error: HTTP {e.code} - {e.read().decode('utf-8', 'ignore')}")
    except urllib.error.URLError as e:
        sys.exit(f"error: cannot reach {_base(args.host)} - {e.reason}")
    finally:
        if not args.muted:
            try:
                _stop_audio(args.host)
            except Exception:
                pass
        clear(args.host)


if __name__ == "__main__":
    main()
View source on GitHub →