🔧 review fixes — the five-angle audit's whole findings list

Correctness (four were my own regressions from the REVELATION wave):
· ☸ WHEELS polymeter permanently detuned after any skipped frame —
  seqAbsStep advanced by ++ per transition, but one frame can cross two
  16ths (bpm ≳155, or the clamped dt on every tab refocus). Proven to
  drift 12 steps out at 174bpm; now advances by the true distance, so
  seqAbsStep % 16 === seqCurStep always holds.
· Sample & hold with exactly one lit step froze on its first sample
  forever (guard keyed on the wrapped step, which never changed) — now
  keyed on the absolute step.
· PSALM double-activation left the MICROPHONE HOT after "stop": the
  re-entrancy guard sat after the getUserMedia await, orphaning the
  first stream/interval/context. Guard is now synchronous, a stop
  during the prompt is honoured, and a second tap while starting
  cancels. Mic off means off — the promise the feature makes.
· Live-recording a drum tap into a short wheel wrote steps past wlen
  that never played; the tap now folds onto the lane's own wheel.
· Switching MIDI out port left notes droning on the old port (pending
  off-timers fired at the NEW one) — midiFlushOut() releases every held
  note + all-notes-off before the swap.
· world_quakes' seen-set grew unbounded on long-running rigs.

Security: stored XSS via the commune room name — the one untrusted
string reaching innerHTML unescaped (hub only lower-cased it). Escaped
client-side at both sinks, and the hub now strips markup chars too.

Robustness: world_ephemeris/world_almanac imported swisseph unguarded,
so a machine without pyswisseph crash-looped them every 10s forever
against the supervisor's exit-0 contract — both now retire quietly
(verified by simulating the missing dep), and pyswisseph is documented
in requirements.

Mobile: the top bar's five separately-pinned chips collided below
~500px — the first thing a phone visitor saw was a broken header. Now
a clean row with the account chip on its own line and the source
counter moved left.

Housekeeping: dead measureSpaced(), dead #account .code selector, and
an 888K stale worktree removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 19:43:20 +10:00
parent 3e0bb9ee68
commit 510ddaaf8c
6 changed files with 88 additions and 28 deletions

5
hub.py
View File

@ -25,6 +25,7 @@ import json
import math import math
import os import os
import queue import queue
import re
import threading import threading
import time import time
from functools import partial from functools import partial
@ -406,7 +407,9 @@ class Hub:
if not user: # local/trusted hub: take the claimed name if not user: # local/trusted hub: take the claimed name
user = str(c.get("from") or "guest")[:24] user = str(c.get("from") or "guest")[:24]
self.cnames[ws] = user 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 R = self.communes
def out(names, obj): def out(names, obj):

View File

@ -4,6 +4,8 @@ websockets>=12.0
# Satellites worker (workers/world_sats.py) — SGP4 propagation from Celestrak TLEs # Satellites worker (workers/world_sats.py) — SGP4 propagation from Celestrak TLEs
# pip install sgp4 # pure-python, no build; worker is import-safe without it # 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 # Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port
# pip install python-rtmidi # pip install python-rtmidi

View File

@ -522,7 +522,6 @@
#account .fbitem b { color: #9fd0ff; } #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); #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; } 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 .codes { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
#account .codechip { font-family: monospace; color: #7cffb2; font-size: 12px; cursor: pointer; #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; 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 { padding-left: 4px; margin: 6px 0; font-size: 13.5px; line-height: 1.65; }
#grimoire ol.steps li::before { content: none; } #grimoire ol.steps li::before { content: none; }
@media (max-width: 520px){ #grimoire .spec .row { grid-template-columns: 1fr; gap: 3px; } } @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.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 */ 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 */ /* 💤 the sleeper fold — quiet feeds tuck out of the field; this chip is the door back */
@ -2581,6 +2592,11 @@
communeEl.id = "commune"; communeEl.id = "commune";
document.body.appendChild(communeEl); document.body.appendChild(communeEl);
function closeCommunePanel() { communeEl.classList.remove("open"); } 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
function openCommune() { function openCommune() {
communeEl.innerHTML = ""; communeEl.innerHTML = "";
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×"; const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×";
@ -2605,7 +2621,7 @@
for (const rm of invRooms) { for (const rm of invRooms) {
const row = document.createElement("div"); row.className = "hrow"; const row = document.createElement("div"); row.className = "hrow";
const lbl = document.createElement("span"); lbl.style.flex = "1"; const lbl = document.createElement("span"); lbl.style.flex = "1";
lbl.innerHTML = "✉ <b style='color:#cfe0f5'>" + commune.invites[rm] + "</b> invites you to “" + rm + "”"; lbl.innerHTML = "✉ <b style='color:#cfe0f5'>" + cEsc(commune.invites[rm]) + "</b> invites you to “" + cEsc(rm) + "”";
const acc = document.createElement("button"); acc.className = "midibtn"; acc.textContent = "join"; const acc = document.createElement("button"); acc.className = "midibtn"; acc.textContent = "join";
acc.onclick = () => communeAccept(rm); acc.onclick = () => communeAccept(rm);
const dec = document.createElement("button"); dec.className = "midibtn"; dec.textContent = "decline"; const dec = document.createElement("button"); dec.className = "midibtn"; dec.textContent = "decline";
@ -2622,8 +2638,8 @@
} else { } else {
const isHost = commune.host === communeMe(); const isHost = commune.host === communeMe();
const inRoom = document.createElement("div"); inRoom.className = "sub"; const inRoom = document.createElement("div"); inRoom.className = "sub";
inRoom.innerHTML = "room: <b style='color:#cfe0f5'>" + commune.room + "</b> · host: <b style='color:#cfe0f5'>" + inRoom.innerHTML = "room: <b style='color:#cfe0f5'>" + cEsc(commune.room) + "</b> · host: <b style='color:#cfe0f5'>" +
commune.host + "</b><br>here: " + commune.members.map(m2 => m2 === communeMe() ? m2 + " (you)" : m2).join(", "); cEsc(commune.host) + "</b><br>here: " + commune.members.map(m2 => cEsc(m2) + (m2 === communeMe() ? " (you)" : "")).join(", ");
communeEl.appendChild(inRoom); communeEl.appendChild(inRoom);
const claims = document.createElement("div"); claims.className = "sub"; const claims = document.createElement("div"); claims.className = "sub";
const held = Object.keys(commune.alloc); const held = Object.keys(commune.alloc);
@ -3438,9 +3454,14 @@
psalmChip.textContent = "🕊 " + (psalm.on ? (psalm.curName || "…") : ""); psalmChip.textContent = "🕊 " + (psalm.on ? (psalm.curName || "…") : "");
} }
async function psalmSet(v) { 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 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.timer) { clearInterval(psalm.timer); psalm.timer = null; }
if (psalm.stream) { psalm.stream.getTracks().forEach(t => t.stop()); psalm.stream = 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; } if (psalm.ctx) { try { psalm.ctx.close(); } catch (e) {} psalm.ctx = null; }
@ -3453,7 +3474,11 @@
const src = actx.createMediaStreamSource(stream); const src = actx.createMediaStreamSource(stream);
const an = actx.createAnalyser(); an.fftSize = 2048; src.connect(an); const an = actx.createAnalyser(); an.fftSize = 2048; src.connect(an);
const buf = new Float32Array(an.fftSize), dtMs = 25; 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(); psalmChipEnsure(); psalmPaint();
psalm.timer = setInterval(() => { // setInterval, not rAF — a hidden tab shouldn't freeze the ear psalm.timer = setInterval(() => { // setInterval, not rAF — a hidden tab shouldn't freeze the ear
an.getFloatTimeDomainData(buf); an.getFloatTimeDomainData(buf);
@ -3465,12 +3490,12 @@
psalmPaint(); psalmPaint();
}, dtMs); }, dtMs);
} catch (e) { } 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" }); eventTicker.unshift({ text: "🕊 the instrument cannot hear you — allow the microphone", tAdded: now(), src: "sys" });
psalmPaint(); 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), // 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. // so the sequenced path and the played path can never drift: same guard, same GM MIDI.
@ -3580,7 +3605,10 @@
const sq = seqs[k]; const sq = seqs[k];
if (sq && sq.on) { // sample & hold: the world only speaks on your steps 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 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; if (sq.held != null) v = sq.held;
} }
sv[k] = v; sv[k] = v;
@ -5587,7 +5615,13 @@
} }
const frac = seqPhase - Math.floor(seqPhase); const frac = seqPhase - Math.floor(seqPhase);
const st16 = frac >= seqStepOnset ? rawStep : (rawStep + 15) % 16; // hold until the onset 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 // the arrangement advances one column per bar
const barAbs = Math.floor(seqPhase / 16); const barAbs = Math.floor(seqPhase / 16);
@ -7644,14 +7678,17 @@
// counts and the header line remain. // counts and the header line remain.
ctx.textBaseline = "alphabetic"; ctx.textBaseline = "alphabetic";
// counts (right, kept clear of the ⚙ gear button) // counts (right, kept clear of the ⚙ gear button). On a phone the top-right chips
ctx.textAlign = "right"; // 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.font = "500 11px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(130,150,180,0.6)"; 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) { if (lastServerT) {
ctx.fillStyle = "rgba(110,130,160,0.5)"; 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 // header underline glow
@ -7757,14 +7794,6 @@
ctx.restore(); ctx.restore();
} }
function measureSpaced(str, font) {
ctx.save();
ctx.font = font;
const w = ctx.measureText(str).width;
ctx.restore();
return w;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Footer ticker // Footer ticker
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -8130,8 +8159,9 @@
const name = DRUM_VOICES[di], vel = ev.shiftKey ? 0.5 : 0.9; // ⇧ = the ghost note 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); 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 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 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; ungodlyGesture("beat rec"); sq.steps[step] = 1; sq.vel[step] = vel;
if (beatRefresh) beatRefresh(); if (beatRefresh) beatRefresh();
} }
@ -9390,7 +9420,7 @@
function populatePorts() { function populatePorts() {
if (!midiAccess) return; 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) => { fillSelect(inSel, midiAccess.inputs, midiIn, (p) => {
if (midiIn) midiIn.onmidimessage = null; if (midiIn) midiIn.onmidimessage = null;
midiIn = p; midiIn = p;
@ -9453,6 +9483,18 @@
// mirror of the same-pitch clamp exportMid does). So retriggering a held pitch first // 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. // ends the earlier note cleanly, then re-strikes — no stale off cuts the new note.
const midiOffTimers = {}; 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) { function midiPluck(ch, note, vel, durMs) {
if (!midiOut || note == null || note < 0 || note > 127) return; if (!midiOut || note == null || note < 0 || note > 127) return;
const c = ch & 15, n = Math.round(note), k = (c << 8) | n; const c = ch & 15, n = Math.round(note), k = (c << 8) | n;

View File

@ -27,7 +27,12 @@ import datetime
import math import math
import time 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 from pythonosc.udp_client import SimpleUDPClient
FLG = swe.FLG_MOSEPH | swe.FLG_SPEED FLG = swe.FLG_MOSEPH | swe.FLG_SPEED

View File

@ -26,7 +26,12 @@ import itertools
import math import math
import time 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 from pythonosc.udp_client import SimpleUDPClient
FLG = swe.FLG_MOSEPH | swe.FLG_SPEED FLG = swe.FLG_MOSEPH | swe.FLG_SPEED

View File

@ -65,6 +65,9 @@ def main():
if fid is None or fid in seen: if fid is None or fid in seen:
continue continue
seen.add(fid) 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 {} props = feat.get("properties") or {}
mag = props.get("mag") mag = props.get("mag")