the commune (play together via hub relay) + the stage (project to screens)

- hub.py: commune relay — {commune:{room,...}} messages are broadcast verbatim
  to other clients in the same room (allowed even on the read-only public hub;
  peer state never touches hub state). Room tag per ws, cleaned on disconnect.
- viz commune: join a room by word (panel via sky menu), claim voices and the
  godtime from right-click menus or the panel picker; owned sections (mute/
  level/trim, groove steps+on, arranger lane, bpm) diff-sync every 600ms;
  claims/take-overs announced in the ticker; late joiners get re-claims.
  Verified two-way through the live hub: peer state applied (level 1.70x),
  page claims relayed to peers. Bluetooth deliberately not used — a web page
  cannot be a BT peer; the hub is the meeting place, LAN or planet-wide.
- viz stage: window.open + canvas.captureStream(30) mirror window for
  projectors/AirPlay displays; double-click fullscreen; camera modes: whole
  view / follow one node / frame a station+satellites (smoothed glide),
  switchable live from the right-click menus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 22:39:51 +10:00
parent 390aa6e65a
commit 0a4c28b4c2
2 changed files with 279 additions and 4 deletions

16
hub.py
View File

@ -133,6 +133,7 @@ class Hub:
self._scan_patches()
self.midi = _open_midi(cfg.get("midi", {}))
self.clients: set = set()
self.rooms: dict = {} # ws -> commune room name
self.recent_events: list[dict] = []
self._last_notes: dict[str, int] = {}
@ -335,11 +336,24 @@ class Hub:
"patches": self.patches}))
async for msg in ws: # browser -> hub commands
try:
self._handle_cmd(json.loads(msg))
m = json.loads(msg)
c = m.get("commune")
if isinstance(c, dict) and c.get("room"):
# the commune: peer state relayed verbatim to roommates.
# Allowed even on the read-only public hub — it never
# touches hub state, only the other browsers in the room.
self.rooms[ws] = str(c.get("room"))[:40]
peers = [w for w in self.clients
if w is not ws and self.rooms.get(w) == self.rooms[ws]]
if peers:
websockets.broadcast(peers, msg if isinstance(msg, str) else msg.decode())
continue
self._handle_cmd(m)
except Exception: # noqa
pass
finally:
self.clients.discard(ws)
self.rooms.pop(ws, None)
# ---- named templates (patches) ------------------------------------
def _scan_patches(self):

View File

@ -76,13 +76,25 @@
#inspect .midibtn:disabled { opacity: 0.4; cursor: default; }
/* the holdings panel — your private ledger, localStorage only */
#holdings, #natal {
#holdings, #natal, #commune {
position: fixed; top: 96px; right: 14px; z-index: 12; width: 272px;
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
border-radius: 12px; padding: 12px 14px; display: none; color: #c7d4ea;
font-size: 12px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
#holdings.open, #natal.open { display: block; }
#holdings.open, #natal.open, #commune.open { display: block; }
#commune h3 { margin: 0 24px 4px 0; font-size: 13px; color: #8fd8e8; }
#commune .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
#commune .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
#commune .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
#commune input, #commune select {
flex: 1; min-width: 0; background: rgba(30,40,60,0.8); border: 1px solid rgba(120,150,200,0.3);
border-radius: 6px; color: #dbe6f5; padding: 4px 8px; font-size: 12px;
}
#commune .midibtn {
background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer;
}
#natal h3 { margin: 0 24px 4px 0; font-size: 13px; color: #cbb3ff; }
#natal .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
#natal .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
@ -884,7 +896,9 @@
<li><b>Skins — the same spine, another cosmology.</b> Right-click the sky and cycle <b>skin</b>. Beyond the seven <i>chakras</i> there is <b>the planes</b>: the Seven Planes &amp; forty-nine sub-planes of the Cosmic Physical Plane from the Alice Bailey / D.K. teachings — <b>I Divine</b> (white light) down through <b>Monadic</b> (violet), <b>Atmic</b>, <b>Buddhic</b>, <b>Mental</b> (where the egoic lotus sits), <b>Astral</b>, to <b>VII Physical</b> (red). The spine becomes the 49-sub-plane spectrum ladder; the side-brackets mark the <b>Monad</b>, the <b>Spiritual Triad</b> and the <b>Personality</b>; and every feed climbs to the plane that matches its rarefaction — the sun's own wind at the Divine, orbits and the sky's geometry at the Monadic, the great cyclic wheels at the Atmic, omen and aspect at the Buddhic, abstract thought and money-as-idea at the Mental, desire and fire and felt sensation at the Astral, and the dense body of the world — weather, quakes, the mortal rates, your own hands — at the Physical. Go inside any plane (mind mode) and its keys play a scale pitched to its altitude: airy whole-tone up at the Divine, grounded minor-pentatonic down at the Physical.</li>
<li><b>One Source, many Spirits, one Matter.</b> That is the whole architecture: the <b>Source</b> is the world's data, the same for everyone; the <b>Spirit</b> is the skin — the belief system through which you read it; the <b>Matter</b> is the sound. Choose your spirit, and the same planet plays through it. The shelf so far, each rendered from its own published lore, literally and without frills: the <b>Tree of Life</b> (Hermetic Qabalah, Golden Dawn attributions — ten sephirot on three pillars, all twenty-two canonical paths, Mercury's data at Hod, the Moon's at Yesod, the body of the world at Malkut); <b>the Lodge</b> (from published Masonic sources only — Worshipful Master in the East, Wardens West and South, the North dark, Boaz and Jachin at the porch where <i>your own hands</i> stand as the candidate, the Blazing Star, the Volume of the Sacred Law as humanity's open book, and music given to the South, for Beauty holds the arts); the <b>Hermetic ladder</b> of the seven planetary gates, lead to silver; <b>Yggdrasil</b> and its nine worlds, the dwarves keeping the markets in Nidavellir; the Sufi <b>lata'if</b>, where the wind data literally breathes at Ruh, spirit-as-breath; and the <b>Wu Xing</b>, five phases in their generating ring and controlling star, the metal birds of the air routed to Metal; and <b>the Spread</b> — the Celtic Cross of the tarot (Waite's published layout), dealt live and never finished: the heavens in <i>what crowns you</i>, the deep debts and quake-roots in <i>what is beneath you</i>, the retrogrades in <i>what is behind you</i>, births and seasons in <i>what is before you</i>, money and fire in <i>your hopes and fears</i> — and <b>your own hands in the Querent's place</b>, because you are the one asking. Right-click the sky → <b>skin</b> to turn the wheel of traditions. Each map stands as its true figure — the Tree as a tree, the Lodge as a lodge — and every station opens into mind mode with its own scale under your hands. Newest on the shelf: <b>the wheel</b> — the astrologer's own view, twelve signs in a circle with 0° Aries at the ascendant's place, every feed routed to its sign by traditional rulership (wildfires to Aries, your holdings to Taurus, the word to Gemini, the far-travelling planes to Sagittarius…), the <b>live planets riding the rim</b>, your natal chart on the inner ring, and the aspects drawn as <b>chords across the circle</b> — red the squares and oppositions, green the trines and sextiles, gold the conjunctions, dashed where the living sky strikes your natal points. And everywhere else, those same aspects now draw as faint <b>cables between the planets' own spheres</b> in any skin — the sky's geometry made visible in whatever cosmology you're standing in. And every Spirit now speaks <b>its own tongue</b>: the sephirot in Hebrew (כתר, חכמה, בינה…), the chakras in Devanagari (सहस्रार, अनाहत…), the lata'if in Arabic (قلب, روح, سر…), the nine worlds in Old Norse (ásgarðr, miðgarðr, helheimr…), the planetary gates in their glyphs (☽ ☿ ♀ ☉ ♂ ♃ ♄), the five phases in their characters (木 火 土 金 水). The lore, in the letters it was written in. Two more Spirits joined the shelf: <b>the runes</b> — the Elder Futhark, all twenty-four in the Kylver-stone row, ringed as a rune-band, every feed seated by its rune-poem meaning (wealth's strife at ᚠ fehu, Odin's word at ᚨ ansuz, the giant's blow at ᚦ thurisaz for the quakes, need's narrow band at ᚾ nauthiz for hunger and want, the lot-cup ᛈ perthro holding your natal fate, and <b>your raised hands at ᛉ algiz, the warding rune that is itself a figure with arms lifted</b>); and the <b>Wheel of the Year</b> — the eight Celtic festivals at their true solar longitudes (the astronomical reckoning: quarters at the equinoxes and solstices, cross-quarters at fifteen degrees of the fixed signs), deaths and the deep things at Samhain where the veil thins, births at Imbolc, the fires and the dance and the bond at Beltane, the great fair of commerce and chatter at Lughnasadh — <b>with the live sun walking the rim, showing exactly where the year stands today</b>. Honesty note kept from the scholarship: the unified eightfold wheel is a modern synthesis of the attested Irish fire festivals and older solstice customs — true lore, honestly labeled.</li>
<li><b>The groove grids — sequence the voices.</b> Open any voice's tab (shift-click it) and there's a <b>16-step grid on the godtime clock</b>. The note voices (<i>lead, bass</i>) get a <b>mini piano roll</b> — eight rows of minor pentatonic, click to place a note, click again to clear — and while your groove is on, <i>your melody replaces the data's</i>: bitcoin lets go of the lead and you take it. Every other voice gets an <b>808-style gate row</b> that rhythmically chops the world's signal — the reverb, the grit, the percussion breathing in your pattern while the planet still decides how hard. Grooves ride the godtime tempo (lock the dial, or let your heart drive it), and they <b>save with your patch</b>. And the <b>sources</b> get the grid too, with a different power: <b>sample &amp; hold</b> — while a source's groove is on, the world's number is only <i>sampled</i> on your lit steps and held between them, so a smooth drifting feed becomes stepped, rhythmic, quantized in time. Bitcoin arpeggiating in your pattern. The wind as a sequence. If an out port is chosen in ⚙, godtime also sends <b>MIDI clock</b> (24 ppqn), so a DAW can slave its tempo to the planet — or to your pulse. And the grids play <i>outward</i> too: with an out port chosen, <b>piano-roll notes go out as real MIDI</b> on the lead and bass channels (each note one sixteenth long), and the <b>mind-mode console plays on its own third channel</b> — so your grooves and your chakra-scale jams drive Ableton, Strudel, or hardware exactly as they drive the built-in synth. The three mouths drink at once: ♪ for the browser synth, the out port for MIDI, both or neither — there is no switch, only where you point the sound.</li>
<li><b>Press <kbd>T</kbd> — tracks, for making actual songs.</b> A conventional tracks-and-clips view for anyone who finds the cathedral overwhelming: every voice is a <b>track</b>, eight <b>bars</b> run across, and each cell cycles <b>● live</b> (the world plays that lane) → <b>A</b><b>B</b><b>C</b> (clip patterns on the groove grids — A and B come pre-seeded so it makes music immediately) → <b>silent</b>. Double-click any clip to edit its groove in the piano roll. Press <b></b> and the arrangement loops on the godtime clock — the planet still plays <i>through</i> your song, because live lanes stay wired to the world. Arrangements <b>save with your patch</b>, play out over MIDI like everything else, and <b>export .mid</b> hands you a Standard MIDI File of the note lanes to drag into Ableton, Logic, anything. Not a DAW — a sketchpad where the world sits in on your session. The panel earns its keep like one, too: a <b>🎛 tracks button</b> sits bottom-right always; <b>drag the panel's top edge to resize</b> (remembered); the top bar <b>tabs between clips and grooves</b> — the grooves tab is a full-size pattern editor (pick a track, pick a letter, big piano roll or 16 steps; double-clicking any clip jumps you straight there); and <b>right-click any track name for that voice's full routing menu</b> — every cable, every level, patch and unpatch without leaving the panel.</li></ul>
<li><b>Press <kbd>T</kbd> — tracks, for making actual songs.</b> A conventional tracks-and-clips view for anyone who finds the cathedral overwhelming: every voice is a <b>track</b>, eight <b>bars</b> run across, and each cell cycles <b>● live</b> (the world plays that lane) → <b>A</b><b>B</b><b>C</b> (clip patterns on the groove grids — A and B come pre-seeded so it makes music immediately) → <b>silent</b>. Double-click any clip to edit its groove in the piano roll. Press <b></b> and the arrangement loops on the godtime clock — the planet still plays <i>through</i> your song, because live lanes stay wired to the world. Arrangements <b>save with your patch</b>, play out over MIDI like everything else, and <b>export .mid</b> hands you a Standard MIDI File of the note lanes to drag into Ableton, Logic, anything. Not a DAW — a sketchpad where the world sits in on your session. The panel earns its keep like one, too: a <b>🎛 tracks button</b> sits bottom-right always; <b>drag the panel's top edge to resize</b> (remembered); the top bar <b>tabs between clips and grooves</b> — the grooves tab is a full-size pattern editor (pick a track, pick a letter, big piano roll or 16 steps; double-clicking any clip jumps you straight there); and <b>right-click any track name for that voice's full routing menu</b> — every cable, every level, patch and unpatch without leaving the panel.</li>
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
<li><b>🕸 The commune — play together.</b> Right-click the sky → <b>the commune</b>, agree on a room word with your friends (any word — "coven" will do), and join. No pairing, no Bluetooth, no setup: everyone on the site is already connected to the same hub, and the hub simply carries your hands to each other — same living room or opposite sides of the earth. Then <b>claim your sections</b>: right-click any voice → <i>claim this voice</i>, and from that moment your levels, mutes, grooves and arranger lanes for it follow your hands on everyone's screen. One of you takes the bass and the fires, another the pads and the moon, and someone <b>claims the godtime itself</b> and conducts. Claims are visible to all, take-overs are announced, and the world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
<div class="hr"></div>
@ -1486,6 +1500,228 @@
arrEl.classList.add("open");
}
// ---- the commune — play together through the hub --------------------------
// No Bluetooth needed (a web page can't be a BT peer): everyone is already
// joined to the same hub websocket, so the hub relays tiny state diffs
// between roommates. Form a room with a word, claim voices (and the godtime),
// and each player's owned sections follow their hands on every screen —
// same living room or opposite sides of the earth.
const commune = { room: null, me: null, peers: {}, owners: {}, sent: {} };
function communeSend(obj) {
if (!commune.room) return;
sendCmd({ commune: Object.assign({ room: commune.room, from: commune.me }, obj) });
}
function communeJoin(room, name) {
commune.room = (room || "").trim().toLowerCase();
commune.me = (name || "").trim() || ("guest" + Math.floor(Math.random() * 900 + 100));
if (!commune.room) return;
commune.peers = {}; commune.owners = {}; commune.sent = {};
communeSend({ join: 1 });
eventTicker.unshift({ text: "🕸 you joined the commune “" + commune.room + "” as " + commune.me, tAdded: now(), src: "sys" });
}
function communeLeave() {
if (commune.room) communeSend({ leave: 1 });
commune.room = null; commune.peers = {}; commune.owners = {}; commune.sent = {};
}
function communeClaim(section) {
commune.owners[section] = commune.me;
delete commune.sent[section]; // force a fresh broadcast
communeSend({ claim: section });
}
function communeRelease(section) {
if (commune.owners[section] === commune.me) delete commune.owners[section];
communeSend({ release: section });
}
function communeState(section) { // the shareable state of a section
if (section === "@godtime") return { bpm: Math.round(godtime.bpm * 2) / 2 };
const t2 = tweaks[section] || { mute: 0, gain: 1, offset: 0 };
const sq = seqs[section];
return {
tw: { m: t2.mute, g: Math.round(t2.gain * 100) / 100, o: Math.round(t2.offset * 100) / 100 },
on: sq ? !!sq.on : false,
steps: sq ? sq.steps.slice() : null,
lane: arr.grid[section] ? arr.grid[section].slice() : null,
};
}
setInterval(() => { // owned sections sync, diff-only
if (!commune.room) return;
for (const s2 in commune.owners) {
if (commune.owners[s2] !== commune.me) continue;
const st = communeState(s2), j2 = JSON.stringify(st);
if (commune.sent[s2] !== j2) { commune.sent[s2] = j2; communeSend({ voice: s2, state: st }); }
}
}, 600);
setInterval(() => { if (commune.room) communeSend({ join: 1 }); }, 5000); // heartbeat
function handleCommune(c) {
if (!commune.room || c.room !== commune.room || !c.from || c.from === commune.me) return;
if (c.join) {
const fresh = !commune.peers[c.from];
commune.peers[c.from] = now();
if (fresh) {
eventTicker.unshift({ text: "🕸 " + c.from + " is in the commune", tAdded: now(), src: "sys" });
for (const s2 in commune.owners) // tell the newcomer what you hold
if (commune.owners[s2] === commune.me) { delete commune.sent[s2]; communeSend({ claim: s2 }); }
}
return;
}
if (c.leave) { delete commune.peers[c.from]; return; }
if (c.claim) {
if (commune.owners[c.claim] === commune.me)
eventTicker.unshift({ text: "🕸 " + c.from + " took " + (c.claim === "@godtime" ? "the godtime" : prettyDest(c.claim)), tAdded: now(), src: "sys" });
commune.owners[c.claim] = c.from;
return;
}
if (c.release) { if (commune.owners[c.release] === c.from) delete commune.owners[c.release]; return; }
if (c.voice && c.state && commune.owners[c.voice] === c.from) {
const st = c.state;
if (c.voice === "@godtime") { if (st.bpm) godtime.bpm = clamp(st.bpm, 40, 180); return; }
const n = dests.get(c.voice);
if (!n) return;
if (st.tw) { const t2 = tw(c.voice); t2.mute = st.tw.m; t2.gain = st.tw.g; t2.offset = st.tw.o; }
if (st.steps && st.steps.length === 16) {
const sq = seqFor(c.voice, n.isNote);
sq.steps.length = 0; Array.prototype.push.apply(sq.steps, st.steps); // in place: banks stay wired
sq.on = !!st.on;
}
if (st.lane && st.lane.length === arr.bars) arr.grid[c.voice] = st.lane.slice();
if (arrPaintBar) arrPaintBar();
}
}
const communeEl = document.createElement("div");
communeEl.id = "commune";
document.body.appendChild(communeEl);
function closeCommunePanel() { communeEl.classList.remove("open"); }
function openCommune() {
communeEl.innerHTML = "";
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×";
x2.onclick = closeCommunePanel; communeEl.appendChild(x2);
const h3 = document.createElement("h3"); h3.textContent = "🕸 the commune"; communeEl.appendChild(h3);
const sub = document.createElement("div"); sub.className = "sub";
sub.textContent = "form a room with any word — everyone who joins it plays one instrument together. claim voices from their right-click menus or below; what you claim follows your hands on every screen.";
communeEl.appendChild(sub);
if (!commune.room) {
const roomI = document.createElement("input"); roomI.placeholder = "room word (e.g. coven)";
const nameI = document.createElement("input"); nameI.placeholder = "your name";
const chip2 = document.getElementById("authchip");
if (chip2 && /^@/.test(chip2.textContent)) nameI.value = chip2.textContent.slice(1);
const r1 = document.createElement("div"); r1.className = "hrow"; r1.appendChild(roomI);
const r2 = document.createElement("div"); r2.className = "hrow"; r2.appendChild(nameI);
const go = document.createElement("button"); go.className = "midibtn"; go.style.marginTop = "8px";
go.textContent = "join the commune";
go.onclick = () => { communeJoin(roomI.value, nameI.value); openCommune(); };
communeEl.appendChild(r1); communeEl.appendChild(r2); communeEl.appendChild(go);
} else {
const inRoom = document.createElement("div"); inRoom.className = "sub";
const names = [commune.me + " (you)"].concat(Object.keys(commune.peers));
inRoom.innerHTML = "room: <b style='color:#cfe0f5'>" + commune.room + "</b> · here: " + names.join(", ");
communeEl.appendChild(inRoom);
const claims = document.createElement("div"); claims.className = "sub";
const held = Object.keys(commune.owners);
claims.textContent = held.length
? "claims: " + held.map(s2 => (s2 === "@godtime" ? "godtime" : prettyDest(s2)) + " → " + commune.owners[s2]).join(" · ")
: "no voices claimed yet — right-click a voice, or use the picker below";
communeEl.appendChild(claims);
const pick = document.createElement("div"); pick.className = "hrow";
const sel = document.createElement("select"); sel.className = "asel"; sel.style.flex = "1";
const og = document.createElement("option"); og.value = "@godtime"; og.textContent = "the godtime (tempo)";
sel.appendChild(og);
for (const k2 of Array.from(dests.keys()).sort()) {
const o2 = document.createElement("option"); o2.value = k2; o2.textContent = prettyDest(k2);
sel.appendChild(o2);
}
const cb = document.createElement("button"); cb.className = "midibtn"; cb.textContent = "claim";
cb.onclick = () => { communeClaim(sel.value); openCommune(); };
pick.appendChild(sel); pick.appendChild(cb);
communeEl.appendChild(pick);
const lv = document.createElement("button"); lv.className = "midibtn"; lv.style.marginTop = "8px";
lv.textContent = "leave the room";
lv.onclick = () => { communeLeave(); openCommune(); };
communeEl.appendChild(lv);
}
communeEl.classList.add("open");
}
// ---- the stage — send the visual to another screen ------------------------
// window.open + canvas.captureStream: a live mirror window you drag to a
// projector or AirPlay display (macOS treats it like any other window), then
// double-click for fullscreen. The camera can hold the whole view, follow a
// single node through its orbits, or frame a station and its satellites.
let stageWin = null, stageWrap = null, stageVideo = null, stageCam = null, stagePoll = 0;
const stageRect = { x: 0, y: 0, w: 1, h: 1 };
function openStage(cam) {
stageCam = cam || null;
if (stageWin && !stageWin.closed) return; // window stays; only the camera moved
const w2 = window.open("", "gs_stage", "width=960,height=560");
if (!w2) {
eventTicker.unshift({ text: "the browser blocked the stage window — allow popups for this site", tAdded: now(), src: "sys" });
return;
}
stageWin = w2;
const d2 = w2.document;
d2.title = "godstrument · stage";
d2.body.style.cssText = "margin:0;background:#000;overflow:hidden";
stageWrap = d2.createElement("div");
stageWrap.style.cssText = "position:absolute;left:0;top:0;transform-origin:0 0";
stageVideo = d2.createElement("video");
stageVideo.muted = true; stageVideo.autoplay = true; stageVideo.playsInline = true;
try { stageVideo.srcObject = canvas.captureStream(30); } catch (e) {}
stageVideo.style.cssText = "display:block";
stageWrap.appendChild(stageVideo);
d2.body.appendChild(stageWrap);
const hint3 = d2.createElement("div");
hint3.textContent = "drag me to the projector · double-click for fullscreen";
hint3.style.cssText = "position:fixed;left:50%;bottom:24px;transform:translateX(-50%);color:rgba(200,215,240,0.7);font:12px 'Helvetica Neue',Arial,sans-serif;transition:opacity 1.5s";
d2.body.appendChild(hint3);
setTimeout(() => { try { hint3.style.opacity = "0"; } catch (e) {} }, 5000);
d2.body.addEventListener("dblclick", () => {
d2.fullscreenElement ? d2.exitFullscreen() : d2.body.requestFullscreen().catch(() => {});
});
stageRect.x = 0; stageRect.y = 0; stageRect.w = W; stageRect.h = H;
eventTicker.unshift({ text: "📽 the stage is open — drag it to your other screen, double-click for fullscreen", tAdded: now(), src: "sys" });
}
function closeStage() {
try { if (stageWin && !stageWin.closed) stageWin.close(); } catch (e) {}
stageWin = null; stageCam = null;
}
function stageTarget() { // what the camera wants to frame
if (stageCam && stageCam.type === "node") {
const n = sources.get(stageCam.key) || dests.get(stageCam.key);
if (n && n.x > -5000) return { x: n.x - 210, y: n.y - 210, w: 420, h: 420 };
}
if (stageCam && stageCam.type === "station" && chakraMode) {
const c = activeSkin.byKey[stageCam.key];
if (c && c.members && c.members.length) {
let x0 = c.sx != null ? c.sx : W / 2, y0 = c.sy != null ? c.sy : H / 2, x1 = x0, y1 = y0;
for (const m2 of c.members) {
if (m2.x < -5000) continue;
x0 = Math.min(x0, m2.x); x1 = Math.max(x1, m2.x);
y0 = Math.min(y0, m2.y); y1 = Math.max(y1, m2.y);
}
return { x: x0 - 90, y: y0 - 90, w: (x1 - x0) + 180, h: (y1 - y0) + 180 };
}
}
return { x: 0, y: 0, w: W, h: H };
}
function stageFrame(dt) {
if (!stageWin) return;
if (++stagePoll % 60 === 0 && stageWin.closed) { stageWin = null; stageCam = null; return; }
const t2 = stageTarget();
stageRect.x = approach(stageRect.x, t2.x, 0.14, dt);
stageRect.y = approach(stageRect.y, t2.y, 0.14, dt);
stageRect.w = approach(stageRect.w, t2.w, 0.14, dt);
stageRect.h = approach(stageRect.h, t2.h, 0.14, dt);
try {
stageVideo.style.width = W + "px"; stageVideo.style.height = H + "px";
const vw = stageWin.innerWidth, vh = stageWin.innerHeight;
const s2 = Math.min(vw / stageRect.w, vh / stageRect.h);
const tx = -stageRect.x * s2 + (vw - stageRect.w * s2) / 2;
const ty = -stageRect.y * s2 + (vh - stageRect.h * s2) / 2;
stageWrap.style.transform = "translate(" + tx + "px," + ty + "px) scale(" + s2 + ")";
} catch (e) {}
}
// the launcher — one visible button, always there for the casuals
const arrBtn = document.createElement("div");
arrBtn.id = "arrbtn";
@ -1798,6 +2034,7 @@
}
function handleMessage(msg) {
if (msg.commune) { handleCommune(msg.commune); return; } // a roommate speaking
if (msg.hello) {
helloReceived = true;
if (Array.isArray(msg.routes)) {
@ -2029,6 +2266,8 @@
}
}
if (stageWin) stageFrame(dt); // the projector camera glides after its subject
// MIDI clock out — 24 ppqn (0xF8) whenever an out port is chosen, so a DAW
// can slave to godtime. (rAF-timed: fine for lights & loose sync; a
// lookahead scheduler is the upgrade path if tight sync ever matters.)
@ -4073,6 +4312,16 @@
() => toggleSelect(node.key), "⌘-click");
ctxItem("open inspector tab", () => openInspector(node), "⇧-click");
ctxItem("reset to neutral", () => resetTweaks(node.key), "⌘⇧-click");
ctxItem("📽 project this node", () => openStage({ type: "node", key: node.key }));
if (isSrc && chakraMode)
ctxItem("📽 project its " + activeSkin.word,
() => openStage({ type: "station", key: node.station || activeSkin.stationOf(node.key) }));
if (!isSrc && commune.room) {
const owner = commune.owners[node.key];
if (owner === commune.me) ctxItem("🕸 release this voice", () => communeRelease(node.key));
else ctxItem(owner ? "🕸 take this voice from " + owner : "🕸 claim this voice (you play it)",
() => communeClaim(node.key));
}
const hint = document.createElement("div"); hint.className = "hint";
hint.textContent = "drag = patch a cable · ⇧-drag = ride the level";
ctxEl.appendChild(hint);
@ -4109,6 +4358,18 @@
() => openHoldings());
ctxItem("✨ your stars…" + (natalActive ? " (" + natalActive.name + ")" : ""),
() => openNatal());
ctxItem("🕸 the commune…" + (commune.room ? " (" + commune.room + ")" : ""),
() => openCommune());
if (commune.room) {
const gOwner = commune.owners["@godtime"];
ctxItem(gOwner === commune.me ? "🕸 release the godtime" :
(gOwner ? "🕸 take the godtime from " + gOwner : "🕸 claim the godtime (tempo)"),
() => { gOwner === commune.me ? communeRelease("@godtime") : communeClaim("@godtime"); });
}
ctxSep();
ctxItem(stageWin ? "📽 stage: whole view" : "📽 the stage — project this view",
() => openStage(null));
if (stageWin) ctxItem("📽 close the stage", () => closeStage());
}
ctxEl.classList.add("open");
ctxEl.style.left = Math.min(px, window.innerWidth - 252) + "px";