🔧 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:
parent
3e0bb9ee68
commit
510ddaaf8c
5
hub.py
5
hub.py
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user