diff --git a/hub.py b/hub.py index c9a1691..40abe96 100644 --- a/hub.py +++ b/hub.py @@ -25,6 +25,7 @@ import json import math import os import queue +import re import threading import time from functools import partial @@ -406,7 +407,9 @@ class Hub: if not user: # local/trusted hub: take the claimed name user = str(c.get("from") or "guest")[:24] self.cnames[ws] = user - room = str(c.get("room") or "")[:40].strip().lower() + # room names are rendered by the clients โ€” strip anything markup-ish here too, + # so a hostile name can never reach a browser even if a client forgets to escape + room = re.sub(r"[<>&\"'`]", "", str(c.get("room") or ""))[:40].strip().lower() R = self.communes def out(names, obj): diff --git a/requirements.txt b/requirements.txt index 6100947..78c215c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ websockets>=12.0 # Satellites worker (workers/world_sats.py) โ€” SGP4 propagation from Celestrak TLEs # pip install sgp4 # pure-python, no build; worker is import-safe without it +# Sky workers (workers/world_ephemeris.py, world_almanac.py) โ€” Swiss Ephemeris planets +# pip install pyswisseph # both workers are import-safe without it (they retire quietly) # Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port # pip install python-rtmidi diff --git a/viz/index.html b/viz/index.html index fde35ba..6a39b62 100644 --- a/viz/index.html +++ b/viz/index.html @@ -522,7 +522,6 @@ #account .fbitem b { color: #9fd0ff; } #account .ghost { background: rgba(255,255,255,0.05); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.2); border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 11px; } - #account .code { font-family: monospace; color: #7cffb2; margin-left: 8px; font-size: 12px; } #account .codes { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; } #account .codechip { font-family: monospace; color: #7cffb2; font-size: 12px; cursor: pointer; background: rgba(124,255,178,0.08); border: 1px solid rgba(124,255,178,0.3); border-radius: 6px; @@ -662,6 +661,18 @@ #grimoire ol.steps li { padding-left: 4px; margin: 6px 0; font-size: 13.5px; line-height: 1.65; } #grimoire ol.steps li::before { content: none; } @media (max-width: 520px){ #grimoire .spec .row { grid-template-columns: 1fr; gap: 3px; } } + /* ---- narrow screens: the top bar is five separately-pinned chips (gear 14, โ™ช 52, + account 90, ๐Ÿ—ฃ 92, โบ 132) sized for a desktop. On a phone they collide into each + other and the canvas source-counter. Lay them out as one right-aligned strip and + drop the account chip to a second row so nothing overlaps. ---- */ + @media (max-width: 560px){ + #gear { right: 10px; } + #sound { right: 44px !important; } + #testament { right: 78px !important; } + #godspeakrec { right: 122px; } + #authchip { top: 48px; right: 10px; max-width: 46vw; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + } body.zen > :not(#stage):not(#ctxmenu) { display: none !important; } /* zen โ€” nothing but the sky */ body.perform > :not(#stage):not(#ctxmenu) { display: none !important; } /* performance โ€” pure visuals, no chrome */ /* ๐Ÿ’ค the sleeper fold โ€” quiet feeds tuck out of the field; this chip is the door back */ @@ -2581,6 +2592,11 @@ communeEl.id = "commune"; document.body.appendChild(communeEl); function closeCommunePanel() { communeEl.classList.remove("open"); } + // Room names are free text typed by another player and the hub only lower-cases + // them โ€” so they are UNTRUSTED and must be escaped before any innerHTML. (Usernames + // are already charset-validated server-side, but escape them too: one rule, no gaps.) + const cEsc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); function openCommune() { communeEl.innerHTML = ""; const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "ร—"; @@ -2605,7 +2621,7 @@ for (const rm of invRooms) { const row = document.createElement("div"); row.className = "hrow"; const lbl = document.createElement("span"); lbl.style.flex = "1"; - lbl.innerHTML = "โœ‰ " + commune.invites[rm] + " invites you to โ€œ" + rm + "โ€"; + lbl.innerHTML = "โœ‰ " + cEsc(commune.invites[rm]) + " invites you to โ€œ" + cEsc(rm) + "โ€"; const acc = document.createElement("button"); acc.className = "midibtn"; acc.textContent = "join"; acc.onclick = () => communeAccept(rm); const dec = document.createElement("button"); dec.className = "midibtn"; dec.textContent = "decline"; @@ -2622,8 +2638,8 @@ } else { const isHost = commune.host === communeMe(); const inRoom = document.createElement("div"); inRoom.className = "sub"; - inRoom.innerHTML = "room: " + commune.room + " ยท host: " + - commune.host + "
here: " + commune.members.map(m2 => m2 === communeMe() ? m2 + " (you)" : m2).join(", "); + inRoom.innerHTML = "room: " + cEsc(commune.room) + " ยท host: " + + cEsc(commune.host) + "
here: " + commune.members.map(m2 => cEsc(m2) + (m2 === communeMe() ? " (you)" : "")).join(", "); communeEl.appendChild(inRoom); const claims = document.createElement("div"); claims.className = "sub"; const held = Object.keys(commune.alloc); @@ -3438,9 +3454,14 @@ psalmChip.textContent = "๐Ÿ•Š " + (psalm.on ? (psalm.curName || "โ€ฆ") : ""); } async function psalmSet(v) { - if (v && psalm.on) return; + // `starting` is set SYNCHRONOUSLY before the getUserMedia await โ€” psalm.on only + // becomes true after it resolves, so without this a second activation during the + // permission prompt would orphan the first stream/interval/context and leave the + // microphone hot after the user "stops" listening. The promise is: mic off means off. + if (v && (psalm.on || psalm.starting)) return; + if (v) psalm.starting = true; if (!v) { // stop + fully release the mic - psalm.on = false; + psalm.on = false; psalm.starting = false; if (psalm.timer) { clearInterval(psalm.timer); psalm.timer = null; } if (psalm.stream) { psalm.stream.getTracks().forEach(t => t.stop()); psalm.stream = null; } if (psalm.ctx) { try { psalm.ctx.close(); } catch (e) {} psalm.ctx = null; } @@ -3453,7 +3474,11 @@ const src = actx.createMediaStreamSource(stream); const an = actx.createAnalyser(); an.fftSize = 2048; src.connect(an); const buf = new Float32Array(an.fftSize), dtMs = 25; - Object.assign(psalm, { on: true, stream, ctx: actx, an, lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0 }); + if (!psalm.starting) { // stopped while the permission prompt was up โ€” honour it, release everything + stream.getTracks().forEach(t => t.stop()); try { actx.close(); } catch (e) {} + return; + } + Object.assign(psalm, { on: true, starting: false, stream, ctx: actx, an, lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0 }); psalmChipEnsure(); psalmPaint(); psalm.timer = setInterval(() => { // setInterval, not rAF โ€” a hidden tab shouldn't freeze the ear an.getFloatTimeDomainData(buf); @@ -3465,12 +3490,12 @@ psalmPaint(); }, dtMs); } catch (e) { - psalm.on = false; + psalm.on = false; psalm.starting = false; eventTicker.unshift({ text: "๐Ÿ•Š the instrument cannot hear you โ€” allow the microphone", tAdded: now(), src: "sys" }); psalmPaint(); } } - function togglePsalm() { psalmSet(!psalm.on); } + function togglePsalm() { psalmSet(!(psalm.on || psalm.starting)); } // a second tap while starting = cancel // the one place a drum voice sounds โ€” shared by seqTick AND the finger-drum keys (E.1), // so the sequenced path and the played path can never drift: same guard, same GM MIDI. @@ -3580,7 +3605,10 @@ const sq = seqs[k]; if (sq && sq.on) { // sample & hold: the world only speaks on your steps const cur = wheelStep(sq); // โ˜ธ WHEELS โ€” a short source-wheel samples on its own turn - if (sq.steps[cur] && sq._sampled !== cur) { sq.held = v; sq._sampled = cur; } + // key the "already sampled" guard on the ABSOLUTE step, not the wrapped one: + // a lane with a single lit step returns to the same `cur` every pass, and a + // wrapped key would match forever โ€” freezing the held value on its first sample. + if (sq.steps[cur] && sq._sampled !== seqAbsStep) { sq.held = v; sq._sampled = seqAbsStep; } if (sq.held != null) v = sq.held; } sv[k] = v; @@ -5587,7 +5615,13 @@ } const frac = seqPhase - Math.floor(seqPhase); const st16 = frac >= seqStepOnset ? rawStep : (rawStep + 15) % 16; // hold until the onset - if (st16 !== seqCurStep) { seqCurStep = st16; seqAbsStep++; seqTick(); } // seqAbsStep drives WHEELS' per-lane wrap + if (st16 !== seqCurStep) { + // advance by the DISTANCE travelled, not by one: a frame can cross two 16ths + // (bpm โ‰ณ155, or the clamped dt on a tab refocus). ++ would let seqAbsStep fall + // behind seqCurStep forever and permanently detune every โ˜ธ WHEELS lane. + seqAbsStep += ((st16 - seqCurStep) % 16 + 16) % 16; + seqCurStep = st16; seqTick(); + } // the arrangement advances one column per bar const barAbs = Math.floor(seqPhase / 16); @@ -7644,14 +7678,17 @@ // counts and the header line remain. ctx.textBaseline = "alphabetic"; - // counts (right, kept clear of the โš™ gear button) - ctx.textAlign = "right"; + // counts (right, kept clear of the โš™ gear button). On a phone the top-right chips + // already fill that strip, so the counter moves to the left edge instead of colliding. + const tight = W < 560; + ctx.textAlign = tight ? "left" : "right"; ctx.font = "500 11px 'Helvetica Neue', Arial, sans-serif"; ctx.fillStyle = "rgba(130,150,180,0.6)"; - ctx.fillText(sources.size + " sources ยท " + routesArr.length + " cables ยท " + dests.size + " voices", W - 58, 34); + const cx0 = tight ? 20 : W - 58, cy0 = tight ? 84 : 34; + ctx.fillText(sources.size + " sources ยท " + routesArr.length + " cables ยท " + dests.size + " voices", cx0, cy0); if (lastServerT) { ctx.fillStyle = "rgba(110,130,160,0.5)"; - ctx.fillText("t+" + lastServerT.toFixed(1) + "s", W - 58, 50); + ctx.fillText("t+" + lastServerT.toFixed(1) + "s", cx0, cy0 + 16); } // header underline glow @@ -7757,14 +7794,6 @@ ctx.restore(); } - function measureSpaced(str, font) { - ctx.save(); - ctx.font = font; - const w = ctx.measureText(str).width; - ctx.restore(); - return w; - } - // --------------------------------------------------------------------------- // Footer ticker // --------------------------------------------------------------------------- @@ -8130,8 +8159,9 @@ const name = DRUM_VOICES[di], vel = ev.shiftKey ? 0.5 : 0.9; // โ‡ง = the ghost note fireDrum(name, vel, 60000 / Math.max(40, godtime.bpm) / 4 * 0.9); if (beat.rec) { // record: quantize the tap to the nearest 16th, write the step - const step = ((Math.round(seqPhase - (beat.recAnchor || 0)) % 16) + 16) % 16; // anchored at arm when stopped; raw grid, swing rides at playback const sq = drumSeqFor("drum." + name); + const wl = (sq.wlen && sq.wlen < 16) ? sq.wlen : 16; // โ˜ธ WHEELS โ€” fold the tap onto the lane's own wheel, + const step = ((Math.round(seqPhase - (beat.recAnchor || 0)) % wl) + wl) % wl; // else a tap past wlen writes a step that never plays ungodlyGesture("beat rec"); sq.steps[step] = 1; sq.vel[step] = vel; if (beatRefresh) beatRefresh(); } @@ -9390,7 +9420,7 @@ function populatePorts() { if (!midiAccess) return; - fillSelect(outSel, midiAccess.outputs, midiOut, (p) => { midiOut = p; }); + fillSelect(outSel, midiAccess.outputs, midiOut, (p) => { midiFlushOut(); midiOut = p; }); // hang up cleanly before switching fillSelect(inSel, midiAccess.inputs, midiIn, (p) => { if (midiIn) midiIn.onmidimessage = null; midiIn = p; @@ -9453,6 +9483,18 @@ // mirror of the same-pitch clamp exportMid does). So retriggering a held pitch first // ends the earlier note cleanly, then re-strikes โ€” no stale off cuts the new note. const midiOffTimers = {}; + // Leaving a port (or shutting up): release every note WE turned on, on the port + // that's still current โ€” otherwise the pending off-timers fire at the NEW port and + // the old one drones forever. Mirrors the input side, which already detaches cleanly. + function midiFlushOut() { + for (const key in midiOffTimers) { + clearTimeout(midiOffTimers[key]); delete midiOffTimers[key]; + const k = +key, c = (k >> 8) & 15, n = k & 255; // object keys are strings โ€” coerce before the shift + if (midiOut) { try { midiOut.send([0x80 | c, n, 0]); } catch (e) {} } + } + if (midiOut) for (let c = 0; c < 16; c++) // and a courtesy all-notes-off per channel + { try { midiOut.send([0xb0 | c, 123, 0]); } catch (e) {} } + } function midiPluck(ch, note, vel, durMs) { if (!midiOut || note == null || note < 0 || note > 127) return; const c = ch & 15, n = Math.round(note), k = (c << 8) | n; diff --git a/workers/world_almanac.py b/workers/world_almanac.py index f471dce..6cba0d6 100644 --- a/workers/world_almanac.py +++ b/workers/world_almanac.py @@ -27,7 +27,12 @@ import datetime import math import time -import swisseph as swe +try: # house rule: import-safe (see world_sats.py) โ€” a + import swisseph as swe # missing pyswisseph retires this worker quietly +except Exception: # rather than crash-looping under the supervisor. + import sys + print("world_almanac: pyswisseph not installed โ€” skipping (optional)") + sys.exit(0) from pythonosc.udp_client import SimpleUDPClient FLG = swe.FLG_MOSEPH | swe.FLG_SPEED diff --git a/workers/world_ephemeris.py b/workers/world_ephemeris.py index 81e9962..2f22fbe 100644 --- a/workers/world_ephemeris.py +++ b/workers/world_ephemeris.py @@ -26,7 +26,12 @@ import itertools import math import time -import swisseph as swe +try: # house rule: import-safe. Without pyswisseph this + import swisseph as swe # worker retires quietly (exit 0) instead of crash- +except Exception: # looping every 10s under run.py's supervisor. + import sys + print("world_ephemeris: pyswisseph not installed โ€” skipping (optional)") + sys.exit(0) from pythonosc.udp_client import SimpleUDPClient FLG = swe.FLG_MOSEPH | swe.FLG_SPEED diff --git a/workers/world_quakes.py b/workers/world_quakes.py index 29d7d9a..9ffb18e 100644 --- a/workers/world_quakes.py +++ b/workers/world_quakes.py @@ -65,6 +65,9 @@ def main(): if fid is None or fid in seen: continue seen.add(fid) + if len(seen) > 4000: # the all_hour feed ages ids out, so an unbounded + seen.clear() # set is a slow leak on a long-running rig; a reset + seen.add(fid) # can at worst re-fire a quake still in the window props = feat.get("properties") or {} mag = props.get("mag")