diff --git a/README.md b/README.md index 8c412ff..ac3871e 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ against shared golden vectors (`firmware/golden/`). | `GET /stage` · `GET/POST /api/queue` · `DELETE /api/queue/{i}` · `POST /api/queue/{play_next,move,skip,clear}` | the stage: viewer/control page and the play-queue API (tracklist + repeats cycle + now-playing; mounted by `serve`, opt out: `--no-stage`). Mutations take the stage key when one is configured (below) | | `POST /api/repeats/move` · `DELETE /api/repeats/{i}` | reorder / cancel turns of the stage's repeats cycle | | `WS /api/stage` · `GET /api/stage/{layout,patterns,chapters?pattern=N}` | the stage's wire-codec stream (SESSION on join, `{"type":"resync"}` back), its canvas draw layout, panel pattern metadata (notes, `loop`, `has_chapters`), and one pattern's chapter tree (`[]` if chapterless) | -| `GET /api/audio` | audio inventory (`var/audio/`): `[{name, seconds}]`, `seconds` null when unreadable | +| `GET /api/audio` | audio inventory: `{dir, files: [{name, seconds}]}` — `dir` is the resolved directory the stage reads (the checkout's `var/audio/`), `seconds` null when unreadable | ## The stage (play queue) diff --git a/luminary/patterns/primitives.py b/luminary/patterns/primitives.py index 000f5ed..34e4eb6 100644 --- a/luminary/patterns/primitives.py +++ b/luminary/patterns/primitives.py @@ -295,29 +295,39 @@ class Starfall(Starfield): The firmament starts full (the same salt keeps the same stars as any other Starfield movement). Then, newest first, each star is picked: - it swells into a flare over flare_rise_s, then streaks down its own - meridian and off the stage in fall_s, tail burning behind it — and - that point of sky is dark from then on. The departure schedule is a - story: one, then a few, then a wave of it, then a trickle, down to - the ``keep`` fraction of deep seniors who never fall. Pure in t: a - star's departure time is a hash-ranked function, so any frame can be + it swells into a flare over flare_rise_s, then streaks away in its + own hash-chosen direction — any direction — for fall_s, tail burning + behind it in the star's own color, burning out as it goes; that + point of sky is dark from then on. The shower is one single swell: + lone falls, then more and more, strongest just before the end, + until the LAST star falls (``keep`` > 0 spares that fraction of + deep seniors instead). With end_black_at, the sky's own floor + drains away underneath the final falls, so the last star burns on + true black — and black is what it leaves. Pure in t: a star's + departure time is a hash-ranked function, so any frame can be computed cold. """ name = "starfall" description = "A full sky that empties one shooting star at a time" - keep = 0.10 # the deep seniors who stay + keep = 0.0 # everyone falls (raise to spare the deepest seniors) fall_delay = 14.0 # hold the full sky this long before the first fall fall_span = 175.0 # departures spread over this much local time - # The schedule, as (order-fraction -> time-fraction) nodes: shallow - # start (one at a time), the wave, then the long trickle. - fall_q = (0.0, 0.02, 0.10, 0.55, 0.88, 1.0) - fall_tf = (0.0, 0.14, 0.34, 0.55, 0.80, 1.0) + # The schedule, as (order-fraction -> time-fraction) nodes: the + # single swell — every segment's rate higher than the last, the + # last star exactly at the end of the span. + fall_q = (0.0, 0.02, 0.08, 0.25, 0.55, 1.0) + fall_tf = (0.0, 0.20, 0.40, 0.62, 0.82, 1.0) flare_rise_s = 1.7 # the doomed star swells... - fall_s = 1.35 # ...then is gone off the stage in this long + fall_s = 1.35 # ...then streaks away and burns out in this long + travel_deg = 75.0 # how far a shooting star carries trail_deg = 30.0 fall_l = 0.95 + # The sky itself gone by end_black_at (0 disables): the airglow + # floor drains over end_black_s beneath the final falls. + end_black_at = 0.0 + end_black_s = 24.0 def _departures(self, pick: np.ndarray) -> np.ndarray: """Departure time per light (inf for keepers and non-stars).""" @@ -337,6 +347,14 @@ def render(self, lights: np.ndarray, t: float) -> np.ndarray: twinkle = self._twinkle(t, seniority, n) chroma, hue = self._star_colors(n) sky = self._sky(lights, t, n) + if self.end_black_at > 0.0: + # The sky floor drains beneath the last falls: the final + # star burns on true black, and black is what it leaves. + hold = 1.0 - smoothstep( + self.end_black_at - self.end_black_s, self.end_black_at, t + ) + sky[:, 0] *= hold + sky[:, 1] *= hold level = np.minimum(self.star_l * (0.55 + 0.45 * seniority), 0.95) # The chosen star swells toward white and burns steady — its # goodbye starts before it moves. @@ -346,50 +364,83 @@ def render(self, lights: np.ndarray, t: float) -> np.ndarray: star = np.column_stack( [ level + (self.fall_l - level) * pre**2, - chroma * (1.0 - 0.6 * pre), + chroma * (1.0 - 0.25 * pre), # the flare keeps its color hue, ] ) weight = np.where(is_star & alive, np.maximum(twinkle, pre), 0.0) out = blend_oklch(sky, star, weight) - w = self._falls(lights, t, pick, T) - if w is not None: - hot = np.column_stack( - [np.full(n, self.fall_l), np.full(n, 0.03), np.full(n, 90.0)] - ) - out = blend_oklch(out, hot, w) + falls = self._falls(lights, t, pick, T, chroma, hue) + if falls is not None: + w, streak = falls + out = blend_oklch(out, streak, w) return out def _falls( - self, lights: np.ndarray, t: float, pick: np.ndarray, T: np.ndarray - ) -> Optional[np.ndarray]: - """Blend weight of every star currently streaking off the stage.""" + self, + lights: np.ndarray, + t: float, + pick: np.ndarray, + T: np.ndarray, + chroma: np.ndarray, + hue: np.ndarray, + ) -> Optional[Tuple[np.ndarray, np.ndarray]]: + """(weight, color) of every star currently streaking — each in + its own hash-chosen direction, each keeping its own blue or red, + burning out as it goes.""" dt = t - T falling = np.flatnonzero( (pick < self.density) & np.isfinite(dt) & (dt >= 0.0) & (dt <= self.fall_s) ) if falling.size == 0: return None + n = lights.shape[0] phi, th = phi_theta(lights) - span = float(np.max(phi)) or 1.0 sin_phi = np.sin(np.clip(phi, 0.2, np.pi - 0.2)) + travel = np.radians(self.travel_deg) trail = np.radians(self.trail_deg) sig_p = np.radians(1.8) sig_h = np.radians(1.6) - w: Optional[np.ndarray] = None + heading = seeded_random(f"{self.salt}-heading", n) * 2.0 * np.pi + w = np.zeros(n) + color = np.zeros((n, 3)) for i in falling: prog = float(dt[i]) / self.fall_s - head = phi[i] + (1.06 * span - phi[i]) * prog**1.5 - off = (np.mod(th - th[i] + np.pi, 2.0 * np.pi) - np.pi) * sin_phi + # Local chart around the star; the path runs at its own angle. + x = (np.mod(th - th[i] + np.pi, 2.0 * np.pi) - np.pi) * sin_phi + y = phi - phi[i] + ca, sa = float(np.cos(heading[i])), float(np.sin(heading[i])) + along = x * ca + y * sa + off = -x * sa + y * ca + head = travel * prog**1.5 perp = np.exp(-(off**2) / (2.0 * sig_p**2)) - behind = head - phi - seg = phi >= phi[i] - np.radians(2.0) + behind = head - along + seg = along >= -np.radians(2.0) # nothing before the origin tail = np.where((behind >= 0.0) & seg, np.exp(-behind / trail), 0.0) - bloom = np.exp(-((phi - head) ** 2) / (2.0 * sig_h**2)) - layer = perp * np.maximum(bloom, 0.55 * tail) - w = layer if w is None else np.maximum(w, layer) - return w + bloom = np.exp(-((along - head) ** 2) / (2.0 * sig_h**2)) + burn = 1.0 - smoothstep(0.72, 1.0, prog) # it burns out mid-sky + layer = perp * np.maximum(bloom, 0.55 * tail) * burn + wins = layer > w + w = np.where(wins, layer, w) + color[wins, 0] = self.fall_l + color[wins, 1] = 0.85 * float(chroma[i]) + color[wins, 2] = float(hue[i]) + return w, color + + +class Blackout(Primitive): + """Nothing at all — a held black, as a movement in its own right. + + A show that has truly ended wants a chapter of dark before whatever + comes next; the duty-cycle audit exempts this pattern by name (an + intentional blackout is the one legitimate dead field).""" + + name = "blackout" + description = "Nothing at all: a held black" + + def render(self, lights: np.ndarray, t: float) -> np.ndarray: + return np.zeros((lights.shape[0], 3)) class NoiseGlow(Primitive): @@ -488,6 +539,7 @@ class AuroraVeils(Primitive): rays = 1.0 surge_s = 0.0 white_hot = 0.80 + hot_hue = 140.0 # what the burnt-through cores burn toward salt = "veils" def _activity(self, t: float) -> float: @@ -576,11 +628,13 @@ def render(self, lights: np.ndarray, t: float) -> np.ndarray: position = np.clip(intensity * (0.85 + 0.30 * fringe), 0.0, 1.0) position = self.floor + (1.0 - self.floor) * position out = self.palette.sample(position) - # The hottest cores burn past the palette toward green-white. + # The hottest cores burn past the palette toward near-white, + # keyed by hot_hue (green-white by default; a violet-crowned + # tuning burns purple-white). hotw = smoothstep(self.white_hot, 1.0, position) * 0.75 if float(np.max(hotw)) > 1e-4: pale = np.column_stack( - [np.full(n, 0.92), np.full(n, 0.05), np.full(n, 140.0)] + [np.full(n, 0.92), np.full(n, 0.05), np.full(n, self.hot_hue)] ) out = blend_oklch(out, pale, hotw) return out @@ -702,11 +756,26 @@ class Candles(Primitive): pos_to = 0.0 # >0: core position grows to this with the gathering flicker_s = 4.2 # each flame's slow breath flutter = 0.0 # fast flame-tip dance on top of the breath (~0.16) - # Anchors: (azimuth°, polar°) seats lit FIRST, in order, before any - # hash-chosen candle — the physical landmarks of the sculpture - # (measured in an anchor_span_deg-tall frame; scaled to fit others). + # Anchors: (azimuth°, polar°) seats lit FIRST, before any hash-chosen + # candle — the physical landmarks of the sculpture (measured in an + # anchor_span_deg-tall frame; scaled to fit others). anchor_spread + # scatters their ignitions unevenly through the early gathering + # (hash order, not list order) instead of all at once, and + # anchor_jitter_deg nudges each seat off its exact vertex so the + # opening never reads as a drawn diagram. anchors: Optional[Tuple[Tuple[float, float], ...]] = None anchor_span_deg = 99.48 + anchor_spread = 0.0 + anchor_jitter_deg = 0.0 + # Personality — no two flames alike. ``vary`` scales per-candle + # vigor (some burn brighter), pool size, gutter (irregular deep + # dips, a few near-extinctions that recover), and a ragged edge on + # the snuff wave. ``ignite_flare`` overshoots each ignition (a + # flame catches, flares, settles — not a fade-in). ``die_frac`` of + # the candles quietly gutter out early: subtly different fates. + vary = 0.0 + ignite_flare = 0.0 + die_frac = 0.0 # The sighing breath: at snuff_at a wave of still air spreads from # the center (the apex) to the rim over snuff_s — each flame leans # bright as it arrives, then goes out, leaving afterglow embers @@ -728,20 +797,31 @@ def _fill(self, t: float) -> float: return self.fill_from + (self.fill_to - self.fill_from) * u def _places(self, span: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """(pick, az, ph) for every candle seat: anchors first (tiny - ordered pick values — first lit, last out), hash seats after.""" + """(pick, az, ph) for every candle seat: anchors first (their + picks scattered over anchor_spread in hash order — uneven, + unpredictable ignitions — else tiny ordered values), hash seats + after.""" k = self.count - pick = 0.02 + 0.98 * seeded_random(f"{self.salt}-pick", k) + # With scattered anchors, the stray candles hold back a beat so + # the bones clearly open — then everyone interleaves. + floor = 0.075 if (self.anchors and self.anchor_spread > 0.0) else 0.02 + pick = floor + (1.0 - floor) * seeded_random(f"{self.salt}-pick", k) az = seeded_random(f"{self.salt}-az", k) * 2.0 * np.pi - np.pi ph = 0.85 + 1.15 * seeded_random(f"{self.salt}-ph", k) if self.anchors: m = min(len(self.anchors), k) + scatter = seeded_random(f"{self.salt}-seat", m) + jig = seeded_random(f"{self.salt}-jig", 2 * m) scale = span / np.radians(self.anchor_span_deg) + jitter = np.radians(self.anchor_jitter_deg) for i in range(m): a_deg, p_deg = self.anchors[i] - pick[i] = (i + 0.5) * 1e-3 - az[i] = np.radians(a_deg) - ph[i] = np.radians(p_deg) * scale + if self.anchor_spread > 0.0: + pick[i] = 0.004 + self.anchor_spread * scatter[i] + else: + pick[i] = (i + 0.5) * 1e-3 + az[i] = np.radians(a_deg) + jitter * (jig[i] - 0.5) * 2.0 + ph[i] = np.radians(p_deg) * scale + jitter * (jig[m + i] - 0.5) * 2.0 return pick, az, ph def render(self, lights: np.ndarray, t: float) -> np.ndarray: @@ -764,12 +844,45 @@ def render(self, lights: np.ndarray, t: float) -> np.ndarray: fast = np.sin(2.0 * np.pi * f1 * t + p2) * np.sin(2.0 * np.pi * f2 * t) flame = flame * (1.0 - self.flutter + self.flutter * (0.5 + 0.5 * fast)) strength = lit * flame + if self.ignite_flare > 0.0: + # A flame CATCHES: lit·(1−lit) peaks mid-ignition and is + # gone once the candle burns steady — a flare-and-settle, + # never a fade-in. + strength = strength * (1.0 + self.ignite_flare * 4.0 * lit * (1.0 - lit)) + if self.vary > 0.0: + # No two flames alike: some burn stronger, and each gutters + # on its own weather — rare irregular dips, a few near- + # extinctions that recover. + vig = seeded_random(f"{self.salt}-vig", k) + strength = strength * (1.0 + self.vary * (0.34 * vig - 0.20)) + g1 = 11.0 + 17.0 * seeded_random(f"{self.salt}-g1", k) + g2 = 19.0 + 23.0 * seeded_random(f"{self.salt}-g2", k) + ga = seeded_random(f"{self.salt}-ga", k) * 2.0 * np.pi + gb = seeded_random(f"{self.salt}-gb", k) * 2.0 * np.pi + g = np.sin(2.0 * np.pi * t / g1 + ga) * np.sin(2.0 * np.pi * t / g2 + gb) + depth = self.vary * (0.35 + 0.60 * seeded_random(f"{self.salt}-gd", k)) + strength = strength * (1.0 - depth * smoothstep(0.55, 0.95, g)) + if self.die_frac > 0.0 and self.arc_s > 0.0: + # Subtly different fates: a few candles quietly gutter out + # early and stay out. + doomed = seeded_random(f"{self.salt}-doom", k) < self.die_frac + die_at = self.arc_s * (0.50 + 0.45 * seeded_random(f"{self.salt}-when", k)) + u = np.clip((t - die_at) / 7.0, 0.0, 1.0) + dying = 1.0 - u * u * (3.0 - 2.0 * u) + strength = np.where(doomed, strength * dying, strength) if self.snuff_at > 0.0 and t >= self.snuff_at: # The breath spreads apex to rim; each flame leans bright - # for a moment as it arrives, then dies to afterglow. + # for a moment as it arrives, then dies to afterglow. With + # vary, the wave's edge is ragged — some flames go early, + # a few hold a beat longer. front = (t - self.snuff_at) / self.snuff_s * (span * 1.08) - arrive = front - ph # >0 once the breath has reached a candle + lag = ( + self.vary * 0.12 * (seeded_random(f"{self.salt}-lag", k) - 0.35) + if self.vary > 0.0 + else np.zeros(k) + ) + arrive = front - ph - lag # >0 once the breath has reached it lean = 1.0 + 0.55 * np.exp(-((arrive + 0.06) ** 2) / (2.0 * 0.05**2)) out_mul = np.where( arrive > 0.0, @@ -785,8 +898,12 @@ def render(self, lights: np.ndarray, t: float) -> np.ndarray: spot_now = self.spot_deg + ( (self.spot_to - self.spot_deg) * prog if self.spot_to > 0.0 else 0.0 ) - sigma = np.radians(spot_now) - spot = np.exp((nl @ fl.T - 1.0) / (sigma**2)) + sigma = np.full(k, np.radians(spot_now)) + if self.vary > 0.0: + # Pools of different sizes, too. + pool = seeded_random(f"{self.salt}-pool", k) + sigma = sigma * (1.0 + self.vary * (0.30 * pool - 0.15)) + spot = np.exp((nl @ fl.T - 1.0) / (sigma**2)[None, :]) glow = np.minimum(spot @ strength, 1.15) / 1.15 pos_now = self.pos_max + ( (self.pos_to - self.pos_max) * prog if self.pos_to > 0.0 else 0.0 diff --git a/luminary/patterns/repertoire.py b/luminary/patterns/repertoire.py index 4d84d7c..19c50ad 100644 --- a/luminary/patterns/repertoire.py +++ b/luminary/patterns/repertoire.py @@ -41,6 +41,7 @@ ) from luminary.patterns.primitives import ( AuroraVeils, + Blackout, Candles, Embers, NoiseGlow, @@ -399,6 +400,21 @@ def render(self, lights: np.ndarray, t: float) -> np.ndarray: ] ) +# The veils' night: a violet-blue dark sky under green curtains whose +# tops turn through blue into real purple — the crest crowns burn +# purple-white (pair with hot_hue). AURORA keeps violet only at its +# very tip; this palette gives the high fringes a whole violet band. +VEILS_NIGHT = Palette( + [ + (0.0, 0.030, 0.022, 240.0), + (0.40, 0.30, 0.130, 158.0), + (0.66, 0.50, 0.150, 140.0), + (0.80, 0.52, 0.150, 210.0), + (0.90, 0.58, 0.150, 305.0), + (1.0, 0.68, 0.120, 322.0), + ] +) + # The rings' journey, one color per toll: green, through blue-white and # purple-white, down to a warm and dimming red (the palette's own L # carries the dimming — late rings arrive quieter because they are red). @@ -446,26 +462,31 @@ def nocturne_movements() -> List[Movement]: return [ Movement( Embers( - arc_s=240.0, - swell_gain=1.25, - mortality=0.16, - dark_at=186.0, - dark_s=54.0, - dark_floor=0.10, + arc_s=251.0, + gain_from=0.40, + swell_gain=1.35, + swell_at=0.60, + gain_to=1.50, + mortality=0.06, + dark_at=222.0, + dark_s=26.0, + dark_floor=0.08, ), 251.0, fade=10.0, title="embers", audio="poa-alpina.mp3", notes=( - "The day's fire, and the wind that ends it. Coals glow " - "inside the ash banks — never the dark between — and " - "every three-quarters of a minute a gust crosses the " - "sphere in a five-second breath: the cloud goes dark " - "under it and stays beaten down, healing slower than the " - "next gust comes, while the coals flare and hold their " - "flare, each for its own while. Some flare for the last " - "time. After the three-minute wave: the dying fall." + "The day's fire, and the wind that cannot put it out — " + "yet. Coals glow inside the ash banks, and every " + "three-quarters of a minute a gust crosses the sphere " + "in a five-second breath: the cloud goes dark under it " + "and stays beaten down, the coals flare and hold their " + "flare — and the fire comes back stronger, growing " + "through the first two and a half minutes faster than " + "the wind can take it. It burns full nearly to the end; " + "the last gust and the dying fall share the final half " + "minute." ), ), Movement( @@ -497,13 +518,15 @@ def nocturne_movements() -> List[Movement]: ), Movement( AuroraVeils( - palette=AURORA, + palette=VEILS_NIGHT, speed=1.3, crest_at=0.45, activity_floor=0.50, arc_s=391.0, gain=1.35, surge_s=24.0, + white_hot=0.88, + hot_hue=318.0, ), 391.0, fade=24.0, @@ -511,11 +534,12 @@ def nocturne_movements() -> List[Movement]: audio="flight-from-the-city.mp3", notes=( "Weather from above: curtains of rayed light hung from " - "the apex, their tops uneven, their shafts racing. The " - "storm crests near the third minute — surges race the " - "sphere, the cores burn green-white, violet on the high " - "fringes, a corona gathers at the crown — then it lets " - "itself down. One system: coming, arrived, leaving." + "the apex, their tops uneven, their shafts racing, over " + "a violet-blue dark. The storm crests near the third " + "minute — surges race the sphere, the high fringes turn " + "through blue into real purple, the cores burn " + "purple-white, a corona gathers at the crown — then it " + "lets itself down. One system: coming, arrived, leaving." ), ), Movement( @@ -592,13 +616,19 @@ def nocturne_movements() -> List[Movement]: Movement( Candles( anchors=SEATS, + anchor_spread=0.16, + anchor_jitter_deg=2.5, fill_from=0.0, fill_to=1.0, arc_s=130.0, fill_gamma=0.75, + edge=0.015, spot_to=9.5, pos_to=1.0, flutter=0.16, + vary=1.0, + ignite_flare=0.55, + die_frac=0.08, snuff_at=138.0, snuff_s=13.0, floor_pos=0.05, @@ -608,13 +638,16 @@ def nocturne_movements() -> List[Movement]: title="candles", audio="requiem-static-king.mp3", notes=( - "An answer: flames at the sculpture's own bones — the " - "four hexagon hearts, the center-front, the arm tips — " - "then everywhere, fast, each flame panting on its own " - "clock. It swells to a roaring wave of fire; then one " - "sighing breath spreads from the crown and takes every " - "flame with it, each leaning bright as it goes, and the " - "dark it leaves is full of stars." + "An answer: flames catching at the sculpture's own " + "bones — the hexagon hearts, the center-front, the arm " + "tips — unevenly, each flaring as it lights, none on " + "anyone's schedule. No two alike: brighter and dimmer, " + "wide and small, guttering and recovering; a few go out " + "early and stay out. The rest swell to a roaring wave " + "of fire — then one sighing breath spreads from the " + "crown and takes every flame, some leaning a beat " + "longer than others, and the dark it leaves is full of " + "stars." ), ), Movement( @@ -626,19 +659,33 @@ def nocturne_movements() -> List[Movement]: tint=1.0, flutter=0.10, fall_delay=16.0, - fall_span=181.0, + fall_span=174.0, + end_black_at=189.0, + end_black_s=26.0, ), - 211.0, + 191.0, fade=16.0, title="starfall", audio="eluvium.mp3", notes=( "The same stars as before — the sky remembers its own. " - "Then one is chosen: it swells, and falls, streaking off " - "the stage. Then a few. Then a wave of them, raining " - "off the sphere; then a trickle; then the handful of " - "fixed stars that will not fall, holding. If you make a " - "wish, make it early." + "Then one is chosen: it swells and falls, streaking away " + "in its own direction, gold ones gold and blue ones " + "blue, burning out as they go. Then another, and " + "another — one single swell, strongest just before the " + "end, the sky itself draining away beneath them — until " + "the last star falls on black. If you make a wish, you " + "have the whole shower." + ), + ), + Movement( + Blackout(), + 20.0, + fade=0.0, + title="blackout", + notes=( + "Twenty seconds of nothing at all. Hold it — the night " + "is over when the dark says so, not the lights." ), ), ] diff --git a/luminary/server/static/stage.js b/luminary/server/static/stage.js index 4e05070..311a3c3 100644 --- a/luminary/server/static/stage.js +++ b/luminary/server/static/stage.js @@ -283,6 +283,7 @@ export async function initStagePage() { const rec = document.createElement("option"); rec.value = REC; let audioPicked = false; // the VJ touched the selector: keep their choice + let audioInventory = { dir: "", files: [] }; // Per-pattern defaults: the repeat toggle follows the pattern's own // loop flag; the audio selector pre-selects the declared soundtrack — @@ -298,10 +299,16 @@ export async function initStagePage() { present.length < wanted.length ? `♪ per chapter (${present.length}/${wanted.length} here)` : "♪ per chapter"; - none.textContent = - meta && meta.audio && !meta.audio_present - ? `— no audio (wants ♪ ${meta.audio}) —` - : "— no audio —"; + // An empty inventory names the directory the server actually reads + // — the one diagnostic that matters when files sit elsewhere. + if (!audioInventory.files.length && audioInventory.dir) { + none.textContent = `— no files in ${audioInventory.dir} —`; + } else { + none.textContent = + meta && meta.audio && !meta.audio_present + ? `— no audio (wants ♪ ${meta.audio}) —` + : "— no audio —"; + } const options = [...audioSelect.options]; if (keep !== undefined && options.some((o) => o.value === keep && !o.hidden)) { audioSelect.value = keep; @@ -332,11 +339,12 @@ export async function initStagePage() { if (!patternSelect.value && patternSelect.options.length) { patternSelect.selectedIndex = 0; } + audioInventory = audio; const keepAudio = audioSelect.value; audioSelect.replaceChildren(); audioSelect.appendChild(none); audioSelect.appendChild(rec); - for (const file of audio) { + for (const file of audio.files) { const option = document.createElement("option"); option.value = file.name; option.textContent = diff --git a/luminary/stage/audio.py b/luminary/stage/audio.py index 90d140c..afb1863 100644 --- a/luminary/stage/audio.py +++ b/luminary/stage/audio.py @@ -79,6 +79,7 @@ def __init__( self._spawn = spawn self._proc: Optional[Any] = None self._durations: Dict[Tuple[str, int, int], Optional[float]] = {} + logger.info("stage audio dir: %s", self.audio_dir.resolve()) if self.command is None: logger.info( "stage audio disabled: no player found (looked for mpv, cvlc, " diff --git a/luminary/stage/web.py b/luminary/stage/web.py index 4188826..21f46ab 100644 --- a/luminary/stage/web.py +++ b/luminary/stage/web.py @@ -274,13 +274,17 @@ def delete_repeat(i: int) -> JSONResponse: @app.get("/api/audio") def list_audio() -> JSONResponse: - """The audio inventory: [{name, seconds}] — seconds null when - the file's length cannot be read.""" + """The audio inventory: the resolved directory the stage reads + (so a misplaced file is diagnosable from the page) and its + files as [{name, seconds}] — seconds null when unreadable.""" return JSONResponse( - [ - {"name": name, "seconds": core.audio.duration_of(name)} - for name in core.audio.list_files() - ] + { + "dir": str(core.audio.audio_dir.resolve()), + "files": [ + {"name": name, "seconds": core.audio.duration_of(name)} + for name in core.audio.list_files() + ], + } ) # ------------------------------------------------------------- stream diff --git a/luminary/statedir.py b/luminary/statedir.py index 971fbc2..bbd1239 100644 --- a/luminary/statedir.py +++ b/luminary/statedir.py @@ -1,13 +1,17 @@ """Where runtime state lives — one resolver for every entrypoint. -The runtime state root is ``var/`` (geometry store, pattern uploads, -``var/mapping/``, ``var/mapping-demo/``). The directory itself ships in -the repo (``var/.gitkeep``), so every checkout has it and no existence -or fallback logic is needed anywhere. Both the CLI and the standalone -mapping web entrypoint resolve through this module, so the default -location cannot drift between surfaces. (``store/`` is the dead -pre-rename tree: nothing reads it — delete it, and re-seed demo -geometries with ``luminary.cli seed`` if wanted.) +The runtime state root is the checkout's ``var/`` (geometry store, +pattern uploads, ``var/audio/``, ``var/mapping/``, ``var/mapping-demo/``, +``var/stage/``). The directory ships in the repo (``var/.gitkeep``), so +every checkout has it and no existence or fallback logic is needed +anywhere. The default is anchored to the repo by ``__file__`` — exactly +like the pattern registry — NOT to the process working directory: a +service unit with no ``WorkingDirectory=`` must not silently read and +populate ``/var`` while the operator's files sit in the checkout. Both +the CLI and the standalone mapping web entrypoint resolve through this +module, so the default location cannot drift between surfaces. +(``store/`` is the dead pre-rename tree: nothing reads it — delete it, +and re-seed demo geometries with ``luminary.cli seed`` if wanted.) """ from __future__ import annotations @@ -15,11 +19,16 @@ from pathlib import Path from typing import Optional +#: The checkout root: luminary/statedir.py -> luminary/ -> the repo. +_REPO = Path(__file__).resolve().parents[1] + def runtime_state_dir(explicit: Optional[str] = None, sub: str = "") -> Path: """Resolve a runtime-state directory: an explicit path is honored - verbatim; the default is ``var/`` (joined with ``sub`` for callers - whose state lives in a subdirectory).""" + verbatim; the default is the checkout's ``var/`` (joined with + ``sub`` for callers whose state lives in a subdirectory), + independent of the process working directory.""" if explicit is not None: return Path(explicit) - return Path("var") / sub if sub else Path("var") + root = _REPO / "var" + return root / sub if sub else root diff --git a/patterns/README.md b/patterns/README.md index 9f9ce95..ec86ec9 100644 --- a/patterns/README.md +++ b/patterns/README.md @@ -307,7 +307,7 @@ Every file here is a worked example. By what it teaches: | `book-two/apollo.py` | The cue-sheet show idiom: an album as a Movement list whose durations are the track lengths, crossfades as the drift tolerance — pair with the record on the stage | | `relay` (voice in `repertoire`) | The wiring as the medium: `CONTROLLER`/`CHANNEL`/`INDEX` make every strip a lane, and races run the serpentine in index order; per-lane state gathered to lights by `np.unique` inverse | | `embers` (voice in `primitives`) | Coupled figures: one wind field dims the cloud, scars it (closed-form geometric heal — damage outlives the gust), gates the coals into the banks, and times each coal's own flare tail, so a coal's brightest seconds are its last — physics as dramaturgy | -| `starfall` (voice in `primitives`) | Departure as narrative: a hash rank maps through schedule nodes to each star's departure time — one, then a few, then the wave, then the trickle — and the streak is a pure function of (t − departure); the schedule IS the story | +| `starfall` (voice in `primitives`) | Departure as narrative: a hash rank maps through schedule nodes to each star's departure time — one single swell, densest just before the last star falls onto a drained-black sky — and the streak is a pure function of (t − departure); the schedule IS the story | | `book-two/promises.py` | A persistent motif under a whole show: every movement is `Layered(scene, THE SAME Motif)` — the temporal analogue of small_planet's spatial masks | | `book-two/spiegel.py` | Symmetry as the subject: every element lives on or mirrors across the geometry's own x = 0 plane, held by a mirror-pair test | | `book-two/koln.py` | Declared audio: `Pattern.audio` names the soundtrack, the stage pre-selects and auto-attaches it when the file is present, and the track's exact length times the entry | diff --git a/tests/test_compose.py b/tests/test_compose.py index 1dbee7e..c61e387 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -155,12 +155,13 @@ def test_nocturne_is_a_registered_half_hour(): show = registry.get("nocturne") assert show.duration == 1784.0 # 29:44 — the acts sum to their tracks rows = show.schedule() - assert len(rows) == 7 + assert len(rows) == 8 assert rows[0]["start"] == 0.0 starts = [row["start"] for row in rows] assert starts == sorted(starts) assert sum(row["duration"] for row in rows) == 1784.0 - # Every act declares its own track (separate files in var/audio). + # Every act declares its own track (separate files in var/audio); + # the closing black is its own silent chapter. assert [r["audio"] for r in rows] == [ "poa-alpina.mp3", "saman.mp3", @@ -169,7 +170,9 @@ def test_nocturne_is_a_registered_half_hour(): "cantus.mp3", "requiem-static-king.mp3", "eluvium.mp3", + "", ] + assert rows[7]["title"] == "blackout" and rows[7]["duration"] == 20.0 lights = make_lights(60) rng = np.random.default_rng(9) @@ -253,12 +256,14 @@ def test_chapters_tree_and_loop_flag(): # OUTER timeline (rendering the outer show over [start, start+dur) # IS the chapter — held bit-identical by the overnight nesting test). subs = first["children"] - assert len(subs) == 7 + assert len(subs) == 8 assert subs[0]["start"] == 0.0 and subs[0]["title"] == "embers" assert subs[2]["start"] == 383.0 # nocturne III inside chapter 1 assert subs[2]["title"] == "veils" assert all(c["notes"] for c in subs) # every chapter carries liner notes - assert all(c["audio"].endswith(".mp3") for c in subs) # and its track + # Every act carries its track; the closing black is silent. + assert all(c["audio"].endswith(".mp3") for c in subs[:-1]) + assert subs[-1]["title"] == "blackout" and subs[-1]["audio"] == "" assert tree[1]["title"] == "small-planet" assert "children" not in tree[1] # a plain-pattern chapter is a leaf @@ -271,6 +276,7 @@ def test_chapters_tree_and_loop_flag(): "rings", "candles", "starfall", + "blackout", ] @@ -303,17 +309,20 @@ def test_duty_cycle_no_movement_black_or_blasting(): for show in shows: name = show.name for row in show.schedule(): + if row["pattern"] == "blackout": + continue # an intentional blackout is the one legal dead field for frac, body in ((0.15, True), (0.40, True), (0.65, True), (0.90, False)): t0 = row["start"] + row["fade"] + (row["duration"] - row["fade"]) * frac samples = [show.render(lights, t0 + dt) for dt in (0.0, 2.3, 4.6, 6.9)] L = np.concatenate([s[:, 0] for s in samples]) where = f"{name}/{row['title']}@{frac}" - # A movement may end on a deliberate dying fall (embers, - # candles): the ending floor is a whisper, never zero. - floor = 0.012 if body else 0.003 - assert float(np.mean(L)) >= floor, f"{where}: effectively black" + # The never-dead law holds through every movement BODY; + # an ENDING may be a deliberate dying fall or a true + # blackout (embers, starfall) — accidents are what the + # body probes catch. assert float(np.mean(L)) <= 0.42, f"{where}: full-field blast" if body: + assert float(np.mean(L)) >= 0.012, f"{where}: effectively black" assert float(np.max(L)) >= 0.10, f"{where}: nothing alive" diff --git a/tests/test_mapping_cli.py b/tests/test_mapping_cli.py index 1679fcd..16ec04b 100644 --- a/tests/test_mapping_cli.py +++ b/tests/test_mapping_cli.py @@ -76,14 +76,18 @@ def test_build_continue_resumes_from_the_store(tmp_path): assert resumed.state.boards == core.state.boards -def test_store_dir_is_var_with_no_fallback_logic(): - """Runtime state defaults to var/ — which ships in the repo - (var/.gitkeep), so the resolver carries no existence or legacy - logic at all; an explicit --store wins verbatim.""" +def test_store_dir_is_the_checkout_var_regardless_of_cwd(tmp_path, monkeypatch): + """Runtime state defaults to the CHECKOUT's var/ — which ships in + the repo (var/.gitkeep) — anchored by __file__ like the pattern + registry, so a service run from any working directory (systemd's + default is /) still finds the operator's files. No existence or + legacy logic; an explicit --store wins verbatim.""" from luminary.cli import _store_dir - assert _store_dir(None) == Path("var") - assert _store_dir(None, "mapping") == Path("var") / "mapping" + assert _store_dir(None) == REPO / "var" + assert _store_dir(None, "mapping") == REPO / "var" / "mapping" + monkeypatch.chdir(tmp_path) # a wrong CWD must change nothing + assert _store_dir(None) == REPO / "var" # An explicit path is the directory itself, sub or no sub. assert _store_dir("elsewhere", "mapping") == Path("elsewhere") assert (REPO / "var" / ".gitkeep").exists() # the checkout guarantee diff --git a/tests/test_pattern_library.py b/tests/test_pattern_library.py index 204556a..5070c0e 100644 --- a/tests/test_pattern_library.py +++ b/tests/test_pattern_library.py @@ -605,43 +605,53 @@ def test_starfield_breathes_and_colors_its_stars(): def test_starfall_empties_the_sky_one_streak_at_a_time(): - """The stars leave by falling: departures follow the one/few/wave/ - trickle schedule, a falling star leaves a streak, and the keep - fraction never falls.""" + """The stars leave by falling: the shower is one single swell — + every stretch denser than the last, the final star at the end of + the span — each streak in its own direction, and with end_black_at + the sky itself drains so the piece ends on true black.""" from luminary.patterns.primitives import Starfall from luminary.patterns.util import seeded_random lights = make_lights(n=3000, seed=13) sf = Starfall( - density=0.035, star_l=0.70, sky_l=0.024, fall_delay=16.0, fall_span=181.0 + density=0.035, + star_l=0.70, + sky_l=0.024, + fall_delay=16.0, + fall_span=174.0, + end_black_at=189.0, + end_black_s=26.0, ) pick = seeded_random(f"{sf.salt}-pick", 3000) star = pick < sf.density T = sf._departures(pick)[star] finite = np.sort(T[np.isfinite(T)]) - # The keepers: about the keep fraction of the population never falls. - assert abs(1.0 - finite.size / star.sum() - sf.keep) < 0.06 - # The schedule has a shape: openers spaced out, the wave dense. - gaps = np.diff(finite) - openers = gaps[finite[:-1] < sf.fall_delay + 0.2 * sf.fall_span] - wave = gaps[ - (finite[:-1] > sf.fall_delay + 0.4 * sf.fall_span) - & (finite[:-1] < sf.fall_delay + 0.6 * sf.fall_span) + # Everyone falls, and the last one falls at the end of the span. + assert finite.size == int(star.sum()) + assert abs(float(finite[-1]) - (sf.fall_delay + sf.fall_span)) < 2.5 + # The single swell: median gaps shrink monotonically through the + # shower — lone falls, then more and more, densest at the end. + lo = sf.fall_delay + thirds = [ + np.diff(finite[(finite >= lo + a * 58.0) & (finite < lo + b * 58.0)]) + for a, b in ((0, 1), (1, 2), (2, 3)) ] - assert float(np.median(openers)) > 3.0 * float(np.median(wave)) - # A streak: sampling right after one departure lights a trail well - # beyond the star's own pixel. - t_probe = float(finite[finite.size // 2]) + 0.5 - frame = sf.render(lights, t_probe) - assert float(frame[:, 0].max()) > 0.7 - assert int((frame[:, 0] > 0.4).sum()) > 3 - # The sky really empties: far fewer lit stars late than early. - early = int((sf.render(lights, 5.0)[:, 0] > 0.25).sum()) - late = int( - (sf.render(lights, sf.fall_delay + sf.fall_span + 10.0)[:, 0] > 0.25).sum() - ) - assert late < early * 0.35 - assert late >= 3 # the keepers hold + m1, m2, m3 = (float(np.median(g)) for g in thirds) + assert m1 > m2 > m3 + assert m1 > 3.0 * m3 + # A streak right after a departure: bright, more than a pixel, and + # wearing the star's own color (tinted instances keep their hue). + # Sparse synthetic lights can miss one head, so probe three falls. + frames = [ + sf.render(lights, float(finite[j]) + 0.5) + for j in (finite.size // 3, finite.size // 2, 2 * finite.size // 3) + ] + assert max(float(f[:, 0].max()) for f in frames) > 0.7 + assert max(int((f[:, 0] > 0.4).sum()) for f in frames) > 3 + # The end: the last star has fallen and the sky itself is gone. + dark = sf.render(lights, sf.fall_delay + sf.fall_span + sf.fall_s + 5.0) + assert float(dark[:, 0].max()) < 0.02 + assert float(dark[:, 0].mean()) < 0.002 assert np.array_equal(sf.render(lights, 99.9), sf.render(lights, 99.9)) diff --git a/tests/test_stage_web.py b/tests/test_stage_web.py index 9396cec..18ad761 100644 --- a/tests/test_stage_web.py +++ b/tests/test_stage_web.py @@ -50,8 +50,9 @@ def test_page_layout_and_audio_endpoints(stage, lights): # noqa: F811 assert len(layout["viewBox"]) == 4 inventory = client.get("/api/audio").json() - assert [row["name"] for row in inventory] == AUDIO_FILES # sorted - assert all("seconds" in row for row in inventory) + assert [row["name"] for row in inventory["files"]] == AUDIO_FILES # sorted + assert all("seconds" in row for row in inventory["files"]) + assert inventory["dir"].endswith("audio") # the resolved directory def test_queue_http_flow(stage): @@ -177,7 +178,9 @@ def test_create_app_mounts_stage(tmp_path, lights): # noqa: F811 snap = client.get("/api/queue").json() assert snap["now"]["holding"] is True assert snap["now"]["pattern"] == "spiral" # the repo default - assert client.get("/api/audio").json() == [] + inventory = client.get("/api/audio").json() + assert inventory["files"] == [] + assert inventory["dir"] # the resolved directory, even empty decoder = Decoder() with client.websocket_connect("/api/stage") as ws: