Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
211 changes: 164 additions & 47 deletions luminary/patterns/primitives.py

Large diffs are not rendered by default.

115 changes: 81 additions & 34 deletions luminary/patterns/repertoire.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
)
from luminary.patterns.primitives import (
AuroraVeils,
Blackout,
Candles,
Embers,
NoiseGlow,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -497,25 +518,28 @@ 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,
title="veils",
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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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."
),
),
]
Expand Down
18 changes: 13 additions & 5 deletions luminary/server/static/stage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions luminary/stage/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
Expand Down
16 changes: 10 additions & 6 deletions luminary/stage/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 20 additions & 11 deletions luminary/statedir.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
"""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

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
2 changes: 1 addition & 1 deletion patterns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading