Two sessions of frontend work on the single-file console: - Grimoire: ~82k-char lore-and-spec manual grounded in the real routing - Chakra view (C): hand-rolled 3D spine of 7 wheels, sources orbit their chakra 360deg, drag to spin/tilt with flick momentum — no three.js - Mind mode: click a wheel to go POV inside it; a vector console plays that wheel's scale via click / QWERTY / MIDI, through the world's own FX bus - Right-click everything-menu + modifier set (solo, per-voice level, patch) - Performance tiers (auto/full/ultra/lite): cached bg, glow sprites, fps governor for weaker/ultrawide machines; ultra adds comet-trail spirals - Local-only dev backdoor (?dev=1, hostname-locked to localhost) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3502 lines
221 KiB
HTML
3502 lines
221 KiB
HTML
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||
<title>GODSTRUMENT</title>
|
||
<style>
|
||
html, body {
|
||
margin: 0; padding: 0; height: 100%; width: 100%;
|
||
background: #04060b; overflow: hidden;
|
||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
cursor: default; user-select: none;
|
||
}
|
||
#stage { display: block; position: fixed; inset: 0; }
|
||
#gear {
|
||
position: fixed; top: 12px; right: 14px; z-index: 10;
|
||
width: 30px; height: 30px; border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: rgba(20,28,44,0.7); color: #9fb4d8; cursor: pointer;
|
||
font-size: 16px; border: 1px solid rgba(120,150,200,0.25);
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
|
||
}
|
||
#gear:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.5); }
|
||
#panel {
|
||
position: fixed; top: 50px; right: 14px; z-index: 10;
|
||
width: 244px; max-height: 76vh; overflow-y: auto;
|
||
background: rgba(10,15,26,0.88); border: 1px solid rgba(120,150,200,0.22);
|
||
border-radius: 12px; padding: 12px 14px; display: none;
|
||
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
color: #c7d4ea; font-size: 12px;
|
||
}
|
||
#panel.open { display: block; }
|
||
#panel h3 { margin: 2px 0 8px; font-size: 12px; font-weight: 700; color: #9fd0ff; letter-spacing: .5px; }
|
||
#panel .hint { color: rgba(150,170,200,0.7); font-size: 11px; margin-bottom: 12px; line-height: 1.5; }
|
||
.grp { margin-bottom: 11px; padding-bottom: 9px; border-bottom: 1px solid rgba(120,150,200,0.12); }
|
||
.grp .row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||
.grp .nm { font-weight: 600; color: #dbe8ff; }
|
||
.grp .btns button {
|
||
background: rgba(40,52,74,0.8); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.2);
|
||
border-radius: 6px; padding: 2px 8px; font-size: 10px; cursor: pointer; margin-left: 4px;
|
||
}
|
||
.grp .btns button.on { background: rgba(255,90,90,0.25); color: #ffb3b3; border-color: rgba(255,120,120,0.5); }
|
||
.grp .btns button.frz.on { background: rgba(120,180,255,0.25); color: #bcd8ff; border-color: rgba(140,190,255,0.55); }
|
||
.grp input[type=range] { width: 100%; accent-color: #78a0ff; }
|
||
#panel .midibtn {
|
||
background: rgba(40,52,74,0.8); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-top: 2px;
|
||
}
|
||
#panel .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||
#panel .midisel {
|
||
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; font-size: 11px; max-width: 152px; padding: 2px;
|
||
}
|
||
#panel .tplname {
|
||
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; font-size: 11px; padding: 3px 6px; flex: 1; max-width: 120px; margin-right: 6px;
|
||
}
|
||
#inspect {
|
||
position: fixed; top: 96px; left: 14px; z-index: 11;
|
||
width: 262px; max-height: 74vh; overflow-y: auto;
|
||
background: rgba(10,15,26,0.92); border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 12px; padding: 12px 14px; display: none;
|
||
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
color: #c7d4ea; font-size: 12px;
|
||
}
|
||
#inspect.open { display: block; }
|
||
#inspect h3 { margin: 0 24px 2px 0; font-size: 13px; color: #9fd0ff; }
|
||
#inspect .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0; }
|
||
#inspect .rt { display: flex; align-items: center; gap: 6px; margin: 5px 0; }
|
||
#inspect .rt .lbl { flex: 1; font-size: 11px; color: #cfe0f5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#inspect .rt input[type=range] { width: 68px; accent-color: #78a0ff; }
|
||
#inspect .rt .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#inspect .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#inspect .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;
|
||
}
|
||
#inspect .midibtn:disabled { opacity: 0.4; cursor: default; }
|
||
|
||
/* right-click context menu — every setting, on everything */
|
||
#ctxmenu {
|
||
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
|
||
overflow-y: auto; background: rgba(10,15,26,0.95);
|
||
border: 1px solid rgba(120,150,200,0.35); border-radius: 10px; padding: 6px;
|
||
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
|
||
color: #c7d4ea; font-size: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.55);
|
||
}
|
||
#ctxmenu.open { display: block; }
|
||
#ctxmenu .hd { padding: 5px 8px 7px; border-bottom: 1px solid rgba(120,150,200,0.18); margin-bottom: 4px; }
|
||
#ctxmenu .hd b { color: #9fd0ff; font-size: 12px; display: block; }
|
||
#ctxmenu .hd span { color: rgba(150,170,200,0.7); font-size: 10px; }
|
||
#ctxmenu .it {
|
||
display: flex; align-items: center; gap: 8px; padding: 5px 8px;
|
||
border-radius: 6px; cursor: pointer; user-select: none;
|
||
}
|
||
#ctxmenu .it:hover { background: rgba(80,110,170,0.22); color: #eaf2ff; }
|
||
#ctxmenu .it .k { margin-left: auto; color: rgba(140,160,195,0.6); font-size: 10px; }
|
||
#ctxmenu .sl { display: flex; align-items: center; gap: 8px; padding: 4px 8px; }
|
||
#ctxmenu .sl label { font-size: 11px; color: #b9c8e2; width: 44px; }
|
||
#ctxmenu .sl input[type=range] { flex: 1; accent-color: #78a0ff; }
|
||
#ctxmenu .sl .v { width: 38px; text-align: right; color: #9fd0ff; font-size: 10px; }
|
||
#ctxmenu details { border-radius: 6px; }
|
||
#ctxmenu details[open] { background: rgba(30,42,66,0.35); }
|
||
#ctxmenu summary {
|
||
padding: 5px 8px; border-radius: 6px; cursor: pointer; list-style: none; user-select: none;
|
||
}
|
||
#ctxmenu summary:hover { background: rgba(80,110,170,0.22); color: #eaf2ff; }
|
||
#ctxmenu summary::after { content: "▸"; float: right; color: rgba(140,160,195,0.6); }
|
||
#ctxmenu details[open] summary::after { content: "▾"; }
|
||
#ctxmenu details .body { padding: 2px 4px 6px 12px; max-height: 200px; overflow-y: auto; }
|
||
#ctxmenu .rt { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
|
||
#ctxmenu .rt .lbl { flex: 1; font-size: 11px; color: #cfe0f5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#ctxmenu .rt input[type=range] { width: 62px; accent-color: #78a0ff; }
|
||
#ctxmenu .rt .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#ctxmenu .sep { height: 1px; background: rgba(120,150,200,0.16); margin: 4px 6px; }
|
||
#ctxmenu .hint { padding: 5px 8px 3px; color: rgba(130,150,180,0.55); font-size: 10px; line-height: 1.5; }
|
||
|
||
/* accounts — invite-only sign in / create */
|
||
#authchip {
|
||
position: fixed; top: 12px; right: 90px; z-index: 10; height: 30px;
|
||
display: flex; align-items: center; gap: 6px; padding: 0 12px;
|
||
border-radius: 8px; background: rgba(20,28,44,0.7); color: #9fb4d8;
|
||
border: 1px solid rgba(120,150,200,0.25); cursor: pointer; font-size: 12px;
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
|
||
}
|
||
#authchip:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.5); }
|
||
#auth {
|
||
position: fixed; inset: 0; z-index: 40; display: none;
|
||
align-items: center; justify-content: center;
|
||
background: rgba(4,7,16,0.72); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
|
||
}
|
||
#auth.open { display: flex; }
|
||
#auth .box {
|
||
position: relative; width: 340px; max-width: 90vw; padding: 26px 24px 22px;
|
||
border-radius: 16px; color: #dbe6f5;
|
||
background: linear-gradient(160deg, rgba(28,34,58,0.94), rgba(16,20,38,0.96));
|
||
border: 1px solid rgba(140,170,230,0.25);
|
||
box-shadow: 0 20px 80px rgba(0,0,0,0.6), 0 0 60px rgba(90,120,255,0.12) inset;
|
||
}
|
||
#auth .brand { font-weight: 800; font-size: 22px; letter-spacing: 2px; margin-bottom: 2px;
|
||
background: linear-gradient(90deg,#9fd0ff,#f6c8ff,#ffd98a); -webkit-background-clip: text;
|
||
background-clip: text; color: transparent; }
|
||
#auth .tag { color: rgba(160,180,210,0.7); font-size: 11px; margin-bottom: 16px; }
|
||
#auth .tabs { display: flex; gap: 4px; margin-bottom: 14px; }
|
||
#auth .tabs button { flex: 1; padding: 7px; border-radius: 8px; cursor: pointer; font-size: 12px;
|
||
background: rgba(255,255,255,0.04); color: #9fb4d8; border: 1px solid rgba(120,150,200,0.18); }
|
||
#auth .tabs button.on { background: rgba(120,150,255,0.18); color: #eaf1ff; border-color: rgba(150,180,255,0.5); }
|
||
#auth form { display: none; flex-direction: column; gap: 9px; }
|
||
#auth form.on { display: flex; }
|
||
#auth input { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 9px; font-size: 13px;
|
||
background: rgba(8,12,24,0.7); color: #eaf1ff; border: 1px solid rgba(120,150,200,0.25); outline: none; }
|
||
#auth input:focus { border-color: rgba(150,180,255,0.6); }
|
||
#auth .pwrow { position: relative; }
|
||
#auth .pwrow input { padding-right: 38px; }
|
||
#auth .eye { position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||
cursor: pointer; opacity: 0.6; font-size: 15px; user-select: none; }
|
||
#auth .eye:hover { opacity: 1; }
|
||
#auth .go { margin-top: 4px; padding: 11px; border-radius: 10px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||
background: linear-gradient(90deg, rgba(120,150,255,0.9), rgba(180,130,255,0.9)); color: #fff; border: none; }
|
||
#auth .go:hover { filter: brightness(1.1); }
|
||
#auth .err { color: #ff9ba0; font-size: 11px; min-height: 14px; }
|
||
#auth .close { position: absolute; top: 10px; right: 14px; cursor: pointer; color: #8fa4c8; font-size: 18px; }
|
||
|
||
/* account panel — feedback + (admin) stats */
|
||
#account { position: fixed; inset: 0; z-index: 41; display: none; align-items: center; justify-content: center;
|
||
background: rgba(4,7,16,0.72); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
|
||
#account.open { display: flex; }
|
||
#account .box { position: relative; width: 400px; max-width: 92vw; max-height: 86vh; overflow-y: auto;
|
||
padding: 24px; border-radius: 16px; color: #dbe6f5;
|
||
background: linear-gradient(160deg, rgba(28,34,58,0.96), rgba(16,20,38,0.97));
|
||
border: 1px solid rgba(140,170,230,0.25); box-shadow: 0 20px 80px rgba(0,0,0,0.6); }
|
||
#account h3 { margin: 0 0 6px; font-size: 15px; color: #cfe0f5; }
|
||
#account textarea { width: 100%; box-sizing: border-box; min-height: 70px; padding: 10px; border-radius: 9px;
|
||
background: rgba(8,12,24,0.7); color: #eaf1ff; border: 1px solid rgba(120,150,200,0.25); font: inherit; resize: vertical; }
|
||
#account .go { margin-top: 8px; padding: 9px 14px; border-radius: 9px; cursor: pointer; font-size: 12px; font-weight: 600;
|
||
background: linear-gradient(90deg, rgba(120,150,255,0.9), rgba(180,130,255,0.9)); color: #fff; border: none; }
|
||
#account .sub { color: rgba(160,180,210,0.7); font-size: 11px; margin: 6px 0; }
|
||
#account .close { position: absolute; top: 10px; right: 14px; cursor: pointer; color: #8fa4c8; font-size: 18px; }
|
||
#account .admin { margin-top: 16px; border-top: 1px solid rgba(140,170,230,0.18); padding-top: 12px; }
|
||
#account .fblist { max-height: 200px; overflow-y: auto; margin-top: 6px; }
|
||
#account .fbitem { font-size: 11px; padding: 6px 0; border-bottom: 1px solid rgba(120,150,200,0.1); line-height: 1.4; }
|
||
#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; }
|
||
|
||
/* ---- gated landing page (shown until you sign in) ---- */
|
||
#landing {
|
||
position: fixed; inset: 0; z-index: 30; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; overflow: hidden;
|
||
background: radial-gradient(ellipse at 50% 38%, #0c1533 0%, #070c1c 55%, #03050e 100%);
|
||
} /* below #auth (40) / #account (41) so sign-in shows on top, above console (10) */
|
||
#landing.hidden { display: none; }
|
||
#landing::before { /* starfield */
|
||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||
background-image:
|
||
radial-gradient(1.4px 1.4px at 12% 22%, #fff, transparent),
|
||
radial-gradient(1px 1px at 28% 66%, #cfe0ff, transparent),
|
||
radial-gradient(1.5px 1.5px at 44% 14%, #fff, transparent),
|
||
radial-gradient(1px 1px at 58% 82%, #dbe8ff, transparent),
|
||
radial-gradient(1.3px 1.3px at 72% 34%, #fff, transparent),
|
||
radial-gradient(1px 1px at 84% 72%, #cfe0ff, transparent),
|
||
radial-gradient(1.4px 1.4px at 92% 20%, #fff, transparent),
|
||
radial-gradient(1px 1px at 8% 84%, #fff, transparent),
|
||
radial-gradient(1px 1px at 36% 44%, #b9c8f0, transparent),
|
||
radial-gradient(1.2px 1.2px at 66% 58%, #fff, transparent);
|
||
animation: gsTwinkle 5.5s ease-in-out infinite alternate;
|
||
}
|
||
#landing .orrery { position: absolute; top: 50%; left: 50%; width: 520px; height: 520px;
|
||
transform: translate(-50%, -50%); opacity: 0.5; pointer-events: none; }
|
||
#landing .ring { position: absolute; inset: 0; margin: auto; border-radius: 50%;
|
||
border: 1px solid rgba(140, 170, 235, 0.14); }
|
||
#landing .r1 { width: 120px; height: 120px; animation: gsSpin 13s linear infinite; }
|
||
#landing .r2 { width: 205px; height: 205px; animation: gsSpin 21s linear infinite; }
|
||
#landing .r3 { width: 300px; height: 300px; animation: gsSpin 33s linear infinite; }
|
||
#landing .r4 { width: 400px; height: 400px; animation: gsSpin 50s linear infinite; }
|
||
#landing .r5 { width: 505px; height: 505px; animation: gsSpin 78s linear infinite; }
|
||
#landing .ring .planet { position: absolute; top: -8px; left: 50%; transform: translateX(-50%);
|
||
font-size: 14px; color: #cfe0ff; text-shadow: 0 0 10px rgba(150, 190, 255, 0.9); }
|
||
#landing .sun { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||
font-size: 24px; color: #ffe6a0; text-shadow: 0 0 34px rgba(255, 210, 120, 0.95); }
|
||
#landing .hero { position: relative; z-index: 2; text-align: center; padding: 0 24px; }
|
||
#landing .brand { font-weight: 800; font-size: clamp(42px, 9vw, 84px); letter-spacing: 6px;
|
||
background: linear-gradient(90deg, #9fd0ff, #f6c8ff, #ffd98a); -webkit-background-clip: text;
|
||
background-clip: text; color: transparent; filter: drop-shadow(0 0 30px rgba(150, 180, 255, 0.35));
|
||
animation: gsFloat 7s ease-in-out infinite; }
|
||
#landing .tagline { color: rgba(205, 218, 242, 0.9); font-size: 15px; letter-spacing: 2px; margin-top: 4px; }
|
||
#landing .poem { color: rgba(165, 185, 218, 0.72); font-size: 13px; margin: 16px auto 0; max-width: 460px;
|
||
line-height: 1.7; font-style: italic; }
|
||
#landing .cta { margin-top: 28px; display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||
#landing .cta button { padding: 12px 24px; border-radius: 11px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||
#landing .cta .primary { background: linear-gradient(90deg, rgba(120, 150, 255, 0.95), rgba(180, 130, 255, 0.95));
|
||
color: #fff; border: none; box-shadow: 0 6px 34px rgba(120, 140, 255, 0.4); }
|
||
#landing .cta .primary:hover { filter: brightness(1.1); }
|
||
#landing .cta .ghost { background: rgba(255, 255, 255, 0.06); color: #cfe0f5; border: 1px solid rgba(140, 170, 230, 0.3); }
|
||
#landing .cta .ghost:hover { border-color: rgba(160, 190, 255, 0.6); }
|
||
#landing .note { margin-top: 18px; color: rgba(150, 170, 205, 0.55); font-size: 11px; letter-spacing: 1px; }
|
||
@keyframes gsSpin { to { transform: rotate(360deg); } }
|
||
@keyframes gsFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
|
||
@keyframes gsTwinkle { from { opacity: 0.55; } to { opacity: 1; } }
|
||
|
||
/* ---- the grimoire (manual) ---- */
|
||
#grimtrigger { position: fixed; top: 76px; left: 36px; z-index: 11; cursor: pointer;
|
||
color: rgba(180,200,235,0.75); font-size: 12px; letter-spacing: 0.5px; user-select: none;
|
||
border-bottom: 1px dotted rgba(160,185,225,0.4); }
|
||
#grimtrigger:hover { color: #eaf1ff; }
|
||
#grimoire { position: fixed; inset: 0; z-index: 60; display: none; overflow-y: auto;
|
||
background: radial-gradient(ellipse at 50% 0%, #0c1533 0%, #070c1c 60%, #03050e 100%); }
|
||
#grimoire.open { display: block; }
|
||
#grimoire .wrap { max-width: 720px; margin: 0 auto; padding: 60px 28px 90px; color: #d6e2f4; }
|
||
#grimoire .x { position: fixed; top: 18px; right: 24px; font-size: 26px; color: #9fb4d8; cursor: pointer; z-index: 61; }
|
||
#grimoire .x:hover { color: #fff; }
|
||
#grimoire .title { font-weight: 800; font-size: clamp(34px,6vw,52px); letter-spacing: 4px;
|
||
background: linear-gradient(90deg,#9fd0ff,#f6c8ff,#ffd98a); -webkit-background-clip: text; background-clip: text;
|
||
color: transparent; text-align: center; }
|
||
#grimoire .subtitle { text-align: center; color: rgba(165,185,218,0.7); font-style: italic; margin: 6px 0 40px; letter-spacing: 1px; }
|
||
#grimoire h2 { color: #9fd0ff; font-size: 17px; letter-spacing: 1px; margin: 34px 0 10px; font-weight: 700; }
|
||
#grimoire p { line-height: 1.75; color: rgba(205,218,240,0.9); margin: 8px 0; font-size: 14px; }
|
||
#grimoire .lead { color: rgba(220,230,248,0.95); font-size: 15px; }
|
||
#grimoire ul { list-style: none; padding: 0; margin: 8px 0; }
|
||
#grimoire li { line-height: 1.7; margin: 7px 0; padding-left: 20px; position: relative; font-size: 14px;
|
||
color: rgba(205,218,240,0.9); }
|
||
#grimoire li::before { content: "✦"; position: absolute; left: 0; color: rgba(150,180,255,0.6); font-size: 11px; top: 3px; }
|
||
#grimoire b { color: #eaf1ff; font-weight: 600; }
|
||
#grimoire kbd { background: rgba(120,150,255,0.16); border: 1px solid rgba(150,180,255,0.35); border-radius: 5px;
|
||
padding: 1px 7px; font-size: 12px; font-family: inherit; color: #eaf1ff; }
|
||
#grimoire code { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: #b8e0c8; background: rgba(120,255,178,0.08); padding: 0 4px; border-radius: 4px; }
|
||
#grimoire .hr { height: 1px; background: linear-gradient(90deg, transparent, rgba(140,170,230,0.25), transparent); margin: 30px 0; }
|
||
#grimoire .foot { text-align: center; color: rgba(150,170,205,0.55); font-size: 12px; margin-top: 40px; font-style: italic; }
|
||
</style>
|
||
<canvas id="stage"></canvas>
|
||
<div id="gear" title="controls">⚙</div>
|
||
<div id="panel"></div>
|
||
<div id="inspect"></div>
|
||
<div id="authchip">sign in</div>
|
||
<div id="auth">
|
||
<div class="box">
|
||
<span class="close">×</span>
|
||
<div class="brand">GODSTRUMENT</div>
|
||
<div class="tag">an instrument played by the world</div>
|
||
<div class="tabs"><button data-tab="login" class="on">log in</button><button data-tab="signup">create account</button></div>
|
||
<form id="loginform" class="on" autocomplete="on">
|
||
<input name="identifier" placeholder="email or username" autocomplete="username">
|
||
<div class="pwrow"><input name="password" type="password" placeholder="password" autocomplete="current-password"><span class="eye">👁</span></div>
|
||
<div class="err"></div>
|
||
<button type="submit" class="go">enter</button>
|
||
</form>
|
||
<form id="signupform" autocomplete="on">
|
||
<input name="username" placeholder="username" autocomplete="nickname">
|
||
<input name="email" type="email" placeholder="email" autocomplete="email">
|
||
<div class="pwrow"><input name="password" type="password" placeholder="password (8+ chars)" autocomplete="new-password"><span class="eye">👁</span></div>
|
||
<input name="confirm" type="password" placeholder="confirm password" autocomplete="new-password">
|
||
<input name="invite" placeholder="invite code" autocomplete="off">
|
||
<div class="err"></div>
|
||
<button type="submit" class="go">create account</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div id="account"><div class="box"></div></div>
|
||
<div id="landing">
|
||
<div class="orrery">
|
||
<div class="ring r1"><span class="planet">☿</span></div>
|
||
<div class="ring r2"><span class="planet">♀</span></div>
|
||
<div class="ring r3"><span class="planet">♁</span></div>
|
||
<div class="ring r4"><span class="planet">♂</span></div>
|
||
<div class="ring r5"><span class="planet">♃</span></div>
|
||
<div class="sun">☉</div>
|
||
</div>
|
||
<div class="hero">
|
||
<div class="brand">GODSTRUMENT</div>
|
||
<div class="tagline">an instrument played by the world</div>
|
||
<div class="poem">the sun opens the filter · bitcoin plays the lead · an earthquake swells the reverb.
|
||
one instrument, played by the planet — and now, by you.</div>
|
||
<div class="cta">
|
||
<button class="primary" data-tab="signup">create account</button>
|
||
<button class="ghost" data-tab="login">log in</button>
|
||
</div>
|
||
<div class="note">✦ invite-only ✦ ask a friend for a code</div>
|
||
<div class="note" id="landgrim" style="cursor:pointer;text-decoration:underline;text-underline-offset:3px;">read the grimoire</div>
|
||
</div>
|
||
</div>
|
||
<div id="grimtrigger">✦ the grimoire</div>
|
||
<div id="grimoire">
|
||
<span class="x">×</span>
|
||
<div class="wrap">
|
||
<div class="title">THE GRIMOIRE</div>
|
||
<div class="subtitle">how to play the world</div>
|
||
|
||
<p class="lead">Godstrument is an instrument whose player is the living planet. Roughly forty real feeds — the solar wind streaming off the Sun, the price of bitcoin ticking sub-second on Binance, an earthquake off the coast of Japan, every edit to Wikipedia on Earth, the phase of the Moon, the churn of the U.S. national debt, a wildfire catching somewhere in the dark — arrive as raw numbers, are cleaned into control signals, and are fired at one central hub. There the hub smooths them, learns their ranges, and pours them through a <b>modulation matrix</b> — a patchbay where every feed can be wired to a sound. The world already moves; here it moves <i>something</i>. You don't play the notes. You decide what drives what. Imagine, but also know: the sound you hear is the sky doing exactly what the sky is doing, routed through choices you made.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Getting in</h2>
|
||
<p>Godstrument is <b>invite-only</b>. There is one shared planet — the same Sun, the same quakes, the same Moon reach every account, because there is only one world and everyone is standing on it. But your <b>instrument is your own</b>: the routing, the groups, the curves, the mix, the spells you save — that patch is yours.</p>
|
||
<ul>
|
||
<li>Your instrument is <b>computed in your browser</b>. The feeds flow in, the matrix evaluates, the voices sound — the whole live console runs on your machine, watching the planet in real time.</li>
|
||
<li>Your patch is <b>saved to your account</b>, so the wiring you compose — which quake opens which reverb, which market plays which lead — persists and travels with you.</li>
|
||
<li><b>Shared planet, personal instrument.</b> We all hear the same M4.2 off the same fault line; what it <i>does</i> — a swell of void, a chosen bass note, nothing at all — is your composition.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>How a feed becomes sound</h2>
|
||
<p>A feed does not arrive as music. It arrives as a number in its own stubborn units — <i>solar wind speed</i> in kilometers per second (<kbd>250</kbd>–<kbd>800</kbd>), <i>BTC price</i> in dollars, <i>quake depth</i> in kilometers, <i>PM2.5</i> in micrograms over Delhi, <i>debt.total</i> in the tens of trillions. Between that raw honesty and a usable control signal sits a chain — five refinements, each earning its place — after which the signal enters the matrix and finds its voice.</p>
|
||
|
||
<ul>
|
||
<li><b>The raw number, in its own units.</b> Each source is one small worker process that does nothing but read the world and emit it: USGS GeoJSON for quakes, Open-Meteo for Tokyo's weather, a Binance websocket for crypto, the Wikimedia SSE firehose for edits, the Swiss Ephemeris <i>computed on demand</i> for the sky. Honest, but mechanical, and wildly mismatched in scale and speed.</li>
|
||
|
||
<li><b>The One-Euro filter — jitter without lag.</b> Real signals shiver. A hand sensor trembles, a price feed flickers with noise. The One-Euro filter removes the shiver <i>without</i> the sluggish lag a normal smoother would add: it filters hard when the signal is still and opens up the instant the signal genuinely moves. In <kbd>config.json</kbd> each signal tunes it with <kbd>min_cutoff</kbd> (how much resting jitter to erase) and <kbd>beta</kbd> (how eagerly to let real motion through) — the Sun set gentle and slow (<kbd>min_cutoff 0.4</kbd>), the ToF hand fast and responsive (<kbd>1.5</kbd>), so the planet's slow feeds glide while your hand snaps.</li>
|
||
|
||
<li><b>Adaptive normalization — auto-scaling an unknown range.</b> A feed's range is often unknown or drifting: today's BTC volatility means nothing without knowing yesterday's. So the hub auto-scales everything to <kbd>0..1</kbd>. Signals with a known span declare it (<i>weather.temp</i> maps <kbd>-10..45°C</kbd>); the restless ones learn their own — <kbd>minmax</kbd> tracks the highs and lows over a rolling window (BTC volatility over the last 120 samples), <kbd>zscore</kbd> centers on the recent mean (BTC price over 400). A drifting, boundless feed becomes a clean <kbd>0..1</kbd> that always uses its full musical range.</li>
|
||
|
||
<li><b>Event envelopes — a rupture becomes a decaying pulse.</b> Some things don't have a value; they <i>happen</i>. A quake, a Wikipedia edit, a camera flash, a new wildfire — a discrete event with no duration. The hub gives each a shape: a fast <kbd>attack</kbd> and a chosen <kbd>decay</kbd>, turning the instant into a pulse that swells and fades. A wiki edit is a sharp spark (<kbd>attack 0.005</kbd>, <kbd>decay 0.25</kbd> — thousands per second, each a glitch); a quake is a slow bloom (<kbd>decay 3.5</kbd>, scaled by magnitude <kbd>0..7</kbd>) that opens the void and takes seconds to close. The moment gets a body.</li>
|
||
|
||
<li><b>The modulation matrix — where each cable is a decision.</b> This is the patchbay, and where you compose. A <kbd>route</kbd> is one cable from a source to a voice, and every cable carries four choices:
|
||
<ul>
|
||
<li><b>an amount</b> (<kbd>-1..1</kbd>) — how hard the feed leans, and which way. Negative inverts it: a feed that <i>closes</i> a filter as it rises.</li>
|
||
<li><b>a response curve</b> — the <i>feel</i> of the lean. <kbd>lin</kbd> is honest and even, one-to-one. <kbd>exp</kbd> and <kbd>exp3</kbd> stay low and then surge at the top, so the effect hides until the source is strong (the Sun opening the master filter, worse air erupting into glitch). <kbd>log</kbd> is the mirror — it leaps early then flattens, generous with small values. <kbd>scurve</kbd> eases in and out with a firm middle, the most musical for pads and swells (warmth brightening the pads, the Moon setting the tide of space). <kbd>inv</kbd> flips the sense entirely.</li>
|
||
<li><b>an optional gate</b> (a threshold) — the cable stays silent until the source crosses it. Quakes open the reverb only past <kbd>gate 0.35</kbd>, so the small tremors of the everyday Earth pass unheard and only a real rupture opens the void.</li>
|
||
<li><b>an optional quantize</b> — snap the signal to a scale so a data stream picks <i>notes in a key</i> instead of wobbling formlessly. You name a <kbd>scale</kbd>, a <kbd>root</kbd> (MIDI note), and a span of <kbd>octaves</kbd>. Available scales: <kbd>chromatic</kbd>, <kbd>major</kbd>, <kbd>minor</kbd>, <kbd>dorian</kbd>, <kbd>phrygian</kbd>, <kbd>lydian</kbd>, <kbd>mixolydian</kbd>, <kbd>pentatonic</kbd>, <kbd>minor_pent</kbd>, <kbd>wholetone</kbd>, <kbd>hirajoshi</kbd>. Bitcoin's price plays the lead in a minor pentatonic; deep quakes choose the bass note in a low minor — the Earth kept in key.</li>
|
||
</ul>
|
||
</li>
|
||
</ul>
|
||
|
||
<p>Every cable pointed at the same voice is <b>summed</b> — so a destination is a chord of the world. <i>pad.brightness</i> alone is fed by Tokyo's warmth, the local daylight, the room's own brightness through the mic, aircraft altitude, a harmonious sky, fertile days on the biodynamic calendar, the birth rate, longer human lives, and the northern summer — nine feeds folded into one glow. The sum becomes a voice; the voice streams out as MIDI CC and notes, as OSC to Ableton or TouchDesigner, and to the live visualizer, where you watch the planet play the instrument you wired.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The sources — the world, listening</h2>
|
||
|
||
<p class="lead">Forty-odd feeds, and not one of them was invented for you. Every source in this instrument is a real measurement of a real thing happening right now — a satellite over the Pacific, a trade clearing on Binance, a tremor under Honshu, the Moon at some true longitude computed to the arc-second. The hub does not synthesize the world; it <i>listens</i> to it, normalizes what it hears into a clean 0..1 stream, and hands that stream to the matrix as a modulator. What follows is the whole choir, grouped by where in the world each voice lives. For each: what it truly measures, the tempo it naturally moves at, and why <i>this</i> — of all things — deserved to be a voice.</p>
|
||
|
||
<p>Read every source through three lenses at once. <b>The feed</b>: the actual API or computation behind it. <b>The character</b>: how fast it moves, because tempo is destiny in a modulation matrix — a sub-second feed is a rhythm engine, a decade-scale feed is ballast that barely breathes. <b>The lore</b>: what it <i>means</i> that this signal has a seat in the ensemble. A signal's normalization range (its <kbd>lo</kbd>/<kbd>hi</kbd> in <b>config.json</b>) is its tuning: it tells the hub what counts as "silent" and what counts as "full open" for that voice.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Sky & space — the feeds that fall from above</h2>
|
||
|
||
<p><b>sun.speed — solar wind speed.</b> The literal velocity of the solar wind, in km/s, pulled live from NOAA's Space Weather Prediction Center (SWPC). Normalized <kbd>250→800</kbd> km/s: quiet solar wind sits near the floor, a coronal-hole stream or the leading edge of a CME shoves it toward the ceiling. Its filter is heavily damped (<kbd>min_cutoff 0.4, β 0.01</kbd>) so it drifts rather than jumps — a slow LFO that changes over minutes to hours as the Sun's weather washes over the Earth. In the starter patch <i>the Sun opens the master filter</i> (amt 0.85, exp): the single largest hand on the brightness of the entire instrument belongs to a star 150 million km away. When the heliosphere gusts, the whole record opens up. It is the oldest possible reason for a sound to get brighter — the Sun did it.</p>
|
||
|
||
<p><b>iss.vel — ISS ground speed.</b> The ground-track velocity of the International Space Station, derived from its live position (open-notify / orbital elements). The station laps the Earth every ~92 minutes at roughly 7.66 km/s, so raw speed is nearly constant — which is why it's normalized <kbd>minmax</kbd> over a 60-sample window, letting the hub auto-scale the tiny real variations into usable motion. Character: a smooth, patient drift. It <i>morphs the wavetable</i> (amt 0.4) — the timbre slowly bends as the one permanently-crewed outpost above us circles. A human hand is up there right now; here it turns a knob on the timbre.</p>
|
||
|
||
<p><b>planes.count / planes.avgalt / planes.avgspeed — aircraft aloft.</b> A live census of aircraft from OpenSky: how many are in the air in a region, their average altitude (<kbd>0→13000</kbd> m) and airspeed (<kbd>0→300</kbd> m/s). Count is <kbd>minmax</kbd>-normalized over 100 samples because "how many planes" swings between night and rush hour. Character: a swarm — dozens to hundreds of tracked objects, breathing on the diurnal cycle of human travel. <i>Aircraft aloft = drone density</i> (amt 0.5): the more of humanity is airborne, the thicker the drone. <i>Altitude tilts the pad</i> (amt 0.3): a sky full of cruising jets brightens the pads, a sky of low departures dims them. The drone is literally the sound of everyone who is flying.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Weather & air — the local troposphere</h2>
|
||
|
||
<p><b>weather.temp / wind / pressure / precip / humidity — Tokyo's weather.</b> Open-Meteo, keyless, at a fixed venue (Tokyo in the starter patch). Temperature <kbd>-10→45</kbd>°C, wind <kbd>0→30</kbd> m/s, pressure <kbd>970→1040</kbd> hPa, precip <kbd>0→10</kbd> mm, humidity <kbd>0→100</kbd>%. Weather is the archetypal <i>slow / mood</i> feed — it moves over hours, so temp carries a gentle damped filter (<kbd>min_cutoff 0.3, β 0.005</kbd>). The mappings are almost synaesthetic: <i>warmth = brighter pads</i> (scurve, amt 0.6), <i>wind speed = LFO rate</i> (amt 0.55, so a gale literally modulates faster), <i>barometric pressure = drone weight</i> (amt 0.4, the heaviness of the air becoming the heaviness of the drone). This is the weather you'd feel if you stepped outside in Tokyo, transposed into how the room feels.</p>
|
||
|
||
<p><b>air.pm25 / pm10 / aqi — Delhi's air.</b> Open-Meteo's air-quality endpoint, pointed at Delhi: PM2.5 (<kbd>0→250</kbd> µg/m³), PM10 (<kbd>0→400</kbd>), US AQI (<kbd>0→300</kbd>). Slow, like weather, but pointed at one of Earth's most polluted skies on purpose. <i>Delhi's air becomes grit</i> (amt 0.75, exp) — particulate matter drives the saturation/distortion, so the dirtier the air a fifth of humanity breathes, the more the sound corrodes. <i>Worse air, more glitch</i> (aqi → glitch, amt 0.3, exp3 — a steep curve that stays clean until the air turns genuinely toxic, then breaks). This is the instrument's conscience: pollution you can't see, made into a texture you can't ignore.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Markets — money in motion</h2>
|
||
|
||
<p><b>crypto.price / crypto.vel — Bitcoin.</b> Live trades off the <b>Binance websocket firehose</b>. Price is <kbd>zscore</kbd>-normalized over a 400-sample window (so it's "how unusual is this price vs the recent run," not an absolute dollar figure); volatility (<kbd>crypto.vel</kbd>) is <kbd>minmax</kbd> over 120 samples with an aggressive, snappy filter (<kbd>min_cutoff 2.0, β 0.15</kbd>) so it reacts in near-real-time. This is the fastest feed in the instrument: <i>sub-second chaos</i>, a genuine rhythm engine. <i>BTC volatility drives the hats</i> (amt 0.8, scurve) and <i>nudges the tempo</i> (amt 0.25) — when the market convulses, the percussion densifies and the track leans forward. And <i>Bitcoin plays the lead melody</i> (crypto.price → lead.note, quantized to <b>minor pentatonic</b>, root 60 / C, 2 octaves) — the price doesn't wobble formlessly, it picks notes in a key. The market's fear and greed, rendered as a melody line that's always in tune.</p>
|
||
|
||
<p><b>market.velocity / market.turnover — your dealgod market.</b> Your own deal-flow, read from the basegod warehouse (<kbd>dealgod.price_events</kbd>, ~4M rows) over the tailnet — a single read-only aggregate query, no writes. Binned into an <i>activity</i> curve (velocity) and an <i>average-price</i> curve (turnover), replayed compressed and jog-scrubbable. Both are pre-normalized <kbd>0→1</kbd>. Character: a replay with a playhead, so it's scratchable like a record. <i>Your market drives the hats</i> (amt 0.7) and <i>nudges tempo</i> (amt 0.2), mirroring BTC; <i>pricier gear moving = more grit</i> (turnover → saturation, amt 0.5, exp); and <i>your market plays the lead</i> (turnover → lead.note, <b>G dorian</b>, root 55, 2 octaves). This is the one voice that is <i>yours</i> — your commerce, given a melody in a mode with a little melancholy built in.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Humanity — the species, in aggregate</h2>
|
||
|
||
<p><b>wiki.rate / wiki.edit.event — the human pulse.</b> The <b>Wikimedia SSE stream</b> — the real-time firehose of every edit to every Wikipedia, worldwide. Two signals fall out of it: <kbd>wiki.rate</kbd>, the smoothed global edit rate (<kbd>minmax</kbd>/200-window, a texture), and <kbd>wiki.edit.event</kbd>, a discrete impulse per edit (an <i>event</i> type with a fast attack of 0.005 and a 0.25s decay — a spike, not a level). Character: a relentless, chattering pulse, thousands of edits an hour. <i>The internet's pulse = texture</i> (wiki.rate → granular density, amt 0.6) is the ambient hum of collective attention; <i>every human edit = a glitch burst</i> (amt 0.9) means that somewhere on Earth, a person just fixed a typo or added a citation, and you heard it as a click. This is the loudest data source by volume — the README notes it's most of the recording footprint. It is humanity thinking out loud, made audible.</p>
|
||
|
||
<p><b>clock.pop / births / deaths / popvel — the vital rates.</b> Modelled world vital statistics: population (<kbd>8.0→8.3</kbd> billion), births/sec (<kbd>0→8</kbd>), deaths/sec (<kbd>0→5</kbd>), net growth/sec (<kbd>popvel</kbd>, <kbd>0→4</kbd>). Not a live census — no such feed exists per-second — but a steady demographic model, so these are glacially smooth. <i>Humanity's growth thickens the drone</i> (popvel → drone.voices, amt 0.4); <i>the birth rate lifts the pads</i> (amt 0.2, brighter); <i>the death rate adds weight</i> (deaths → saturation, amt 0.2, grittier, heavier). The two ends of every human life are wired to opposite ends of the timbre — birth to light, death to grain — and both are always sounding, because both are always happening.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Earth & fire — the planet's violence</h2>
|
||
|
||
<p><b>quake.event / quake.depth — earthquakes.</b> The <b>USGS all-hour GeoJSON</b> feed — every earthquake on Earth in the last 60 minutes, refreshed live. <kbd>quake.event</kbd> is an <i>event</i> impulse scaled by magnitude (<kbd>0→7</kbd>) with a long 3.5s decay — a slow swell, not a click; <kbd>quake.depth</kbd> maps hypocenter depth (<kbd>0→300</kbd> km). Character: rare, seismic, unpredictable — the definitive <i>event-burst</i> source. <i>Earthquakes open the void</i> (quake.event → reverb.size, amt 1.0, with a <kbd>gate=0.35</kbd> so only real quakes trigger it) and <i>swell the whole field</i> (→ master.space, amt 0.9). <i>Deep quakes choose the bass note</i> (depth → bass.note, <b>minor scale</b>, root 28 / low E, 2 octaves) — a shallow tremor and a deep one pick different notes, so the Earth's fault lines are, quite literally, a bass sequencer you cannot predict. When the ground moves anywhere on the planet, the reverb blooms and the low end speaks.</p>
|
||
|
||
<p><b>fire.count / fire.event — wildfires.</b> Active wildfires worldwide (<kbd>fire.count</kbd>, <kbd>0→600</kbd>, damped filter) plus an impulse per newly-detected fire (<kbd>fire.event</kbd>, attack 0.01, 0.5s decay). Character: a slow-burning level with occasional flares. <i>The world's wildfires burn the saturation</i> (count → saturation, amt 0.4, exp — the more of the world is on fire, the more the sound scorches); <i>each new fire flares a glitch</i> (amt 0.8). Fire is elemental here in the plainest way: the planet's combustion becomes the instrument's distortion.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The economy — the ledger of the age</h2>
|
||
|
||
<p><b>debt.total / debt.vel — the US national debt.</b> Pulled keyless from the <b>US Treasury</b>: the total debt (normalized <kbd>38→42</kbd> trillion dollars, with the most damped filter in the config, <kbd>min_cutoff 0.08</kbd>) and the daily churn (<kbd>debt.vel</kbd>, <kbd>0→50</kbd> billion/day). Character: the slowest ballast in the instrument — the total barely moves, by design. <i>The weight of the debt as a drone</i> (total → drone.voices, amt 0.3): a number so large it's abstract, made into a low, ever-present mass in the mix. <i>The debt churn grinds the saturation</i> (vel → saturation, amt 0.4, exp): the daily motion of that number as grit. The debt is the drone you can't turn off — which is the point.</p>
|
||
|
||
<p><b>econ.inflation / econ.cpi_mom — inflation.</b> US CPI from the <b>BLS</b>, keyless: year-over-year inflation (<kbd>0→10</kbd>%) and month-over-month CPI change (<kbd>-1→2</kbd>%). Slow, monthly-updated, damped. <i>Inflation quietly presses the tempo</i> (→ tempo.nudge, amt 0.2, scurve) and <i>erodes the space</i> (→ master.space, amt 0.3) — the reverb tightens and the track creeps faster as money loses value. It is a grinding pressure you feel before you name it, in the economy and in the mix alike.</p>
|
||
|
||
<p><b>world.poverty / hunger / fertility / lifeexp — the human condition.</b> <b>World Bank</b> indicators, keyless: extreme poverty (<kbd>0→20</kbd>% of the world), undernourishment (<kbd>0→15</kbd>%), fertility rate (<kbd>0→4</kbd>), life expectancy (<kbd>50→85</kbd> years). These change over <i>years</i> — the most glacial, almost-static feeds here, the deep tuning of the whole instrument's mood. <i>Poverty weights the drone</i> (amt 0.35), <i>hunger grinds under everything</i> (saturation, amt 0.25), <i>longer lives, brighter pads</i> (lifeexp, amt 0.2). The state of the species sets the baseline the fast feeds dance on top of.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The heavens — the sky, computed live</h2>
|
||
|
||
<p><b>astro.moon / moon_lon / sun_lon / mars / venus / jupiter / saturn / mercury.</b> Not fetched — <b>computed</b>, live, with the <b>Swiss Ephemeris</b> in Moshier mode (no data files, valid 3000 BC–3000 AD). The sky is generated on demand like the sun worker. Moon phase (<kbd>0→1</kbd>, most-damped filter <kbd>min_cutoff 0.1</kbd>); every planet's ecliptic longitude (<kbd>0→360°</kbd>). These are the purest LFOs in existence — real orbital motion, spanning the Moon's monthly wheel to Saturn's 29-year one. <i>The Moon phase sets the tide of space</i> (astro.moon → master.space, amt 0.6, scurve); <i>the Moon's wheel drives the LFO</i> (moon_lon → lfo.rate, amt 0.4); <i>Saturn's slow wheel weights the drone</i> (amt 0.4 — Saturn's longitude has its own ultra-damped filter, <kbd>min_cutoff 0.08</kbd>); <i>Mars morphs the timbre</i> (amt 0.3). The oldest instruments humans ever built were clocks for these bodies; here they're oscillators again.</p>
|
||
|
||
<p><b>astro.tension / harmony — the aspects.</b> Computed from the same ephemeris: a count of hard aspects (squares, oppositions — <kbd>tension</kbd>, <kbd>0→8</kbd>) and soft ones (trines, sextiles — <kbd>harmony</kbd>, <kbd>0→8</kbd>). This is real geometry — the angular relationships between the planets right now — read through the old astrological grammar. <i>Hard aspects = dissonant grit</i> (tension → saturation, amt 0.45, exp); <i>a harmonious sky brightens the pads</i> (harmony → pad.brightness, amt 0.4, scurve). The literal shape of the solar system becomes consonance or friction in the sound.</p>
|
||
|
||
<p><b>astro.retro / mercury_retro — retrogrades.</b> Boolean/count flags for planets in apparent retrograde motion, computed from velocity sign. <i>Mercury retrograde smears the delay</i> (mercury_retro → delay.feedback, amt 0.6 — the planet of communication turns backward and the echoes run long); <i>the more retrogrades, the more glitch</i> (astro.retro → glitch, amt 0.3, exp). Whatever you believe about the lore, the astronomy is exact — and the instrument leans into the myth on purpose.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The almanac — sowing by the moon</h2>
|
||
|
||
<p><b>almanac.element / waxing / dec / fertile.</b> The old plant-by-the-moon calendar, computed from the same sky by <b>world_almanac.py</b> — no data, no downloads. The zodiac's four elements cycle fire→earth→air→water, and biodynamic tradition maps them to crop types, so the Moon walks a slow four-step sequencer changing every ~2.25 days: <b>earth=root, water=leaf, air=flower, fire=fruit/seed</b> (<kbd>almanac.element</kbd>, damped). Also: waxing/waning (<kbd>waxing</kbd> — sow above vs below ground), the Moon's ascending/descending declination cycle (<kbd>dec</kbd>, ~27.3 days), and a fertility score (<kbd>fertile</kbd>). <i>The Moon's element steps the timbre</i> (element → wavetable.morph, amt 0.4 — a four-step timbral sequencer keyed to the garden); <i>fertile days brighten the pads</i> (amt 0.3, scurve); <i>the Moon rising/falling sets the LFO</i> (dec → lfo.rate, amt 0.25). A farmer's calendar older than the telescope, quietly conducting the instrument's character.</p>
|
||
|
||
<p><b>zodiac.year / element / hour — the Chinese zodiac.</b> The twelve-animal cycle: the year's animal and element, and the <i>hour</i> animal (<kbd>zodiac.hour</kbd>), which turns every ~2 hours through the traditional double-hour cycle (<kbd>0→1</kbd>, damped). <i>The hour animal turns the timbre</i> (zodiac.hour → wavetable.morph, amt 0.3): across a day the timbre steps twelve times, through the Rat, Ox, Tiger and the rest. Time-of-day as a slow, mythic sequencer.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Your hand & room — the sensors in the space with you</h2>
|
||
|
||
<p><b>tof.cx / cy / near — the hand sensor.</b> A time-of-flight sensor (or its simulator) reading your hand's position: X and Y in frame (<kbd>0→1</kbd>) and proximity (<kbd>near</kbd>). All three carry snappy, responsive One-Euro filters (<kbd>min_cutoff 1.5, β 0.1</kbd>) so gesture tracks with low lag. Character: immediate, gestural, <i>yours</i> — the fastest human-scale input. <i>Your hand X morphs the wavetable</i> (amt 0.9 — the strongest single hand on the timbre), <i>hand Y feeds the delay</i> (cy → delay.feedback, amt 0.7, scurve), <i>your hand pushes the filter further</i> (near → filter.cutoff, amt 0.5, adding to the Sun's push). And <kbd>tof.cx</kbd> doubles as the warp-scrub control: it sets the playhead speed on the quake and dealgod replay loops. Your hand and a star both open the same filter — that's the whole thesis.</p>
|
||
|
||
<p><b>hand.open — hand openness (camera).</b> From the vision worker: how open your palm is (<kbd>0→1</kbd>). <i>Open palm opens the filter</i> (amt 0.6) — and in <b>config.json</b>'s controls it's also a macro: an open hand swells the entire <kbd>@planet</kbd> group's gain (<kbd>0.2→1.4</kbd>). One gesture leans on the whole local world at once.</p>
|
||
|
||
<p><b>light.lux / light.flash.event — ambient light.</b> A light sensor: room brightness (<kbd>0→1000</kbd> lux) and an impulse on sudden flashes (<kbd>light.flash.event</kbd>, attack 0.002, 0.15s decay — the sharpest, fastest envelope in the instrument). <i>Room light sizes the reverb</i> (lux → reverb.size, amt 0.5 — a bright room, a bigger space); <i>a flash triggers a glitch</i> (amt 1.0). The literal light in the room becomes the size of the imagined room in the sound.</p>
|
||
|
||
<p><b>audio.rms / audio.centroid — the room mic.</b> From the audio worker: loudness (<kbd>rms</kbd>, <kbd>minmax</kbd>/120-window) and spectral centroid (<kbd>centroid</kbd>, <kbd>200→6000</kbd> Hz — "how bright the room sounds"). <i>Room loudness feeds the space</i> (rms → master.space, amt 0.4); <i>room brightness → pad brightness</i> (centroid → pad.brightness, amt 0.35). The room the instrument plays in is itself a source — clap, talk, or fall silent, and the mix answers. The space listens to its own space.</p>
|
||
|
||
<p><b>sky.day / sky.elev — the local sun.</b> Computed from NOAA solar-position math for your venue: day/night state (<kbd>sky.day</kbd>, <kbd>0→1</kbd>) and the Sun's elevation angle (<kbd>sky.elev</kbd>, <kbd>-30→75°</kbd>). Both ultra-damped (<kbd>min_cutoff 0.15, β 0.002</kbd>) — the slowest, most inevitable LFO there is: sunrise and sunset. <i>Local daylight raises the pads</i> (sky.day → pad.brightness, amt 0.7, scurve — the single strongest pull on the pads); <i>the Sun's height sets the space</i> (sky.elev → master.space, amt 0.3). Play the instrument at dawn and at midnight and it is a different instrument, because it knows where your Sun is. The room, the hand, and the planet — the same kind of thing, all the way up.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>season.north / season.south — the seasons.</b> Computed from the Sun's declination: how deep into summer each hemisphere is (<kbd>0→1</kbd> each, damped). <i>Northern summer brightens</i> (season.north → pad.brightness, amt 0.3, scurve). The year's longest, slowest breath — a brightness that swells across months, opposite in the two halves of the world, so the instrument's high summer and deep winter are always both present, pulling against each other.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p class="lead">That is the choir. A star, a market, a fault line, the Moon, a species' birth and death rate, and your own open hand — each one a real number, each one wired to a real change in the sound, each one always sounding whether you attend to it or not. You do not add these voices. They are already singing. The instrument only asks: which of them are you listening for?</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The living patch — what drives what</h2>
|
||
|
||
<p class="lead">This is the factory routing: every cable in <kbd>config.json</kbd>, source by source, voice by voice. Each cable is a real feed normalized to <b>0..1</b>, bent by a <i>curve</i>, scaled by an <i>amount</i>, and poured into one destination of the modulation matrix. <b>lin</b> is honest and linear; <b>scurve</b> eases in and out (a soft, musical S); <b>exp</b> and <b>exp3</b> hug the floor and only bloom near the top (so grit and glitch stay clean until the world genuinely spikes). A <b>gate</b> silences the cable until the source crosses a threshold. <b>quantize</b> snaps a wandering number onto the notes of a scale, so a data stream sings in a key instead of wobbling formlessly. What follows is the whole instrument, wire by wire — imagine, but also know.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE FILTER — the sky's hand on the brightness.</b> The master low-pass filter is the instrument's eyelid: closed, everything is muffled and near; open, the whole field turns bright and present. Four cables reach for it, and every one is a thing that <i>opens</i>.</p>
|
||
<ul>
|
||
<li><b>sun.speed → filter.cutoff</b> · amt <b>0.85</b> · <i>exp</i> — "<i>the sun opens the master filter.</i>" The dominant cable in the whole patch. This is real <b>solar wind speed</b> from NOAA SWPC (250–800 km/s), the plasma actually streaming off the Sun past Earth right now. The <i>exp</i> curve keeps the filter half-shut through the quiet solar breeze and only throws it wide when a genuine gust arrives — so the Sun literally decides how bright the instrument sounds. It is the loudest voice because it is the largest object.</li>
|
||
<li><b>tof.near → filter.cutoff</b> · amt <b>0.5</b> · <i>lin</i> — "<i>your hand pushes it further.</i>" <b>Hand proximity</b> from the time-of-flight sensor (or its simulator). Where the Sun sets the ceiling, your hand leans in linearly and pushes past it — the one place where a human body and a star pull the same lever, which is the entire thesis of the instrument.</li>
|
||
<li><b>hand.open → filter.cutoff</b> · amt <b>0.6</b> · <i>lin</i> — "<i>open palm opens the filter (cam).</i>" The webcam's read of <b>hand openness</b>. An open palm is an open sound: spreading your fingers spreads the spectrum. A second, independent human hand on the same eyelid.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE LEAD & THE BASS — the market sings the top, the deep Earth sings the bottom.</b> Only three cables carry pitch, and all three are quantized so the planet plays <i>in key</i> rather than sliding between notes.</p>
|
||
<ul>
|
||
<li><b>crypto.price → lead.note</b> · amt <b>1.0</b> · <i>quantize: minor_pent, root 60 (middle C), 2 octaves</i> — "<i>bitcoin plays the lead melody.</i>" The live <b>BTC price</b> from Binance, z-scored over a 400-tick window so only its <i>motion</i> matters, snapped onto a two-octave <b>C minor pentatonic</b> from middle C. The minor pentatonic is the scale that cannot hit a wrong note — so the most volatile number on Earth becomes a melody that is always, defiantly, musical. The market's mania, rendered singable.</li>
|
||
<li><b>market.turnover → lead.note</b> · amt <b>1.0</b> · <i>quantize: dorian, root 55 (G3), 2 octaves</i> — "<i>your market plays the lead.</i>" The dealgod feed's <b>average deal price</b>, snapped to <b>G dorian</b>. Dorian is minor-but-hopeful — a minor scale with a raised sixth, the mode of folk laments that still feel like they're walking uphill. Your own deal-flow gets its own lead line in its own key, a second melodist sharing the top of the mix with bitcoin.</li>
|
||
<li><b>quake.depth → bass.note</b> · amt <b>1.0</b> · <i>quantize: minor, root 28 (E1), 2 octaves</i> — "<i>deep quakes choose the bass note.</i>" The <b>focal depth</b> of the last earthquake (0–300 km) snapped to a two-octave <b>E natural minor</b> down in the sub-bass at E1. Literal and perfect: the deeper the rupture inside the planet, the deeper the note. The Earth's fracture depth <i>is</i> the pitch of the foundation. Natural minor because a quake is not hopeful.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE RHYTHM — volatility is the pulse.</b> Nothing here is a metronome; the hats and the tempo are driven by how <i>agitated</i> two markets are. Fast money makes fast rhythm.</p>
|
||
<ul>
|
||
<li><b>crypto.vel → perc.density</b> · amt <b>0.8</b> · <i>scurve</i> — "<i>BTC volatility drives the hats.</i>" <b>BTC volatility</b> (min-max over a 120-tick window, heavily smoothed) mapped through a soft S onto percussion density. When bitcoin thrashes, the hi-hats multiply; when it flatlines, they thin to almost nothing. The chaos of the order book <i>is</i> the busyness of the groove.</li>
|
||
<li><b>crypto.vel → tempo.nudge</b> · amt <b>0.25</b> · <i>scurve</i> — "<i>the market nudges the tempo.</i>" The same volatility, at a quarter strength, leans on the tempo itself — never seizing control, just pressing the track slightly faster when the market runs hot.</li>
|
||
<li><b>market.velocity → perc.density</b> · amt <b>0.7</b> · <i>scurve</i> — "<i>your dealgod market drives the hats.</i>" Your own <b>market activity</b> from the dealgod warehouse, running the hats in parallel with bitcoin — two independent economies co-authoring the rhythm.</li>
|
||
<li><b>market.velocity → tempo.nudge</b> · amt <b>0.2</b> · <i>scurve</i> — "<i>market activity nudges the tempo.</i>" And your market's busyness, too, presses gently on the clock.</li>
|
||
<li><b>econ.inflation → tempo.nudge</b> · amt <b>0.2</b> · <i>scurve</i> — "<i>inflation quietly presses the tempo.</i>" US <b>inflation, year-over-year</b> from BLS CPI (0–10%). Inflation is time speeding up on your money, so here it speeds up the track — a slow, structural pressure on the pulse rather than a jolt, exactly as it feels in a life.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE PADS — daylight, warmth, life, and harmony make it bright.</b> The pads are the instrument's emotional weather: their <b>brightness</b> is a chord of everything that means <i>day, warmth, growth, and grace</i>. Eight cables converge here, each a small vote for light.</p>
|
||
<ul>
|
||
<li><b>sky.day → pad.brightness</b> · amt <b>0.7</b> · <i>scurve</i> — "<i>local daylight raises the pads.</i>" A computed <b>day/night</b> value for your venue (NOAA solar position). The single strongest brightener: when the real sun is up where you're standing, the pads are luminous; at night they go dim and inward. The instrument knows what time it is outside.</li>
|
||
<li><b>weather.temp → pad.brightness</b> · amt <b>0.6</b> · <i>scurve</i> — "<i>warmth in Tokyo = brighter pads.</i>" Live <b>temperature in Tokyo</b> (Open-Meteo, −10 to 45°C). Warmth is warmth — a hot day in Tokyo literally warms the pad's tone, wherever you are.</li>
|
||
<li><b>astro.harmony → pad.brightness</b> · amt <b>0.4</b> · <i>scurve</i> — "<i>a harmonious sky brightens the pads.</i>" The count of <b>soft aspects</b> (trines and sextiles) among the real planets, computed from the Swiss Ephemeris (0–8). When the sky is geometrically agreeable, the pads glow. Astrology rendered as consonance — a flattering angle overhead becomes a brighter chord.</li>
|
||
<li><b>audio.centroid → pad.brightness</b> · amt <b>0.35</b> · <i>lin</i> — "<i>room brightness → pad brightness.</i>" The <b>spectral centroid</b> of the room mic (200–6000 Hz) — how <i>trebly</i> the room actually sounds. A bright room makes bright pads; the instrument listens to its own acoustic and answers in kind.</li>
|
||
<li><b>planes.avgalt → pad.brightness</b> · amt <b>0.3</b> · <i>lin</i> — "<i>altitude tilts the pad.</i>" The <b>average altitude</b> of aircraft overhead (OpenSky, 0–13,000 m). The higher the planes are flying, the higher the pad is tilted — thinner air, thinner and brighter tone.</li>
|
||
<li><b>almanac.fertile → pad.brightness</b> · amt <b>0.3</b> · <i>scurve</i> — "<i>fertile days brighten the pads.</i>" The <b>biodynamic fertility score</b> from the planting almanac. On a fertile day in the old sow-by-the-moon calendar, the pads open up — the garden's good days become good tone.</li>
|
||
<li><b>season.north → pad.brightness</b> · amt <b>0.3</b> · <i>scurve</i> — "<i>northern summer brightens.</i>" A computed <b>northern-summer</b> value. As the northern hemisphere tilts into summer across the year, the pads slowly brighten — the largest, slowest brightener in the room, a wheel that turns once annually.</li>
|
||
<li><b>clock.births → pad.brightness</b> · amt <b>0.2</b> · <i>lin</i> — "<i>the birth rate lifts the pads.</i>" Humanity's modelled <b>births per second</b> (~0–8). Every new life on Earth adds a sliver of light to the chord.</li>
|
||
<li><b>world.lifeexp → pad.brightness</b> · amt <b>0.2</b> · <i>lin</i> — "<i>longer lives, brighter pads.</i>" World <b>life expectancy</b> from the World Bank (50–85 years). The slow civilizational good news — that people live longer than they used to — is a quiet, persistent brightness under everything.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE DRONE — weight, mass, and the slow bad news.</b> The drone's thickness is set by <b>drone.voices</b>: how many oscillators are stacked into the sustained bed. Everything that routes here is something <i>heavy</i> — pressure, crowds, debt, poverty, the slowest planet. The more of it there is in the world, the denser and darker the floor.</p>
|
||
<ul>
|
||
<li><b>planes.count → drone.voices</b> · amt <b>0.5</b> · <i>lin</i> — "<i>aircraft aloft = drone density.</i>" The raw <b>count of aircraft</b> currently airborne (OpenSky). A swarm is a drone: every plane in the sky adds a voice to the bed. The world's air traffic becomes a literal choir of hums.</li>
|
||
<li><b>weather.pressure → drone.voices</b> · amt <b>0.4</b> · <i>lin</i> — "<i>barometric pressure = drone weight.</i>" <b>Barometric pressure</b> (970–1040 hPa). High pressure is heavy air, heavy air is a heavy drone — you can feel a weather front in the density of the bass.</li>
|
||
<li><b>clock.popvel → drone.voices</b> · amt <b>0.4</b> · <i>lin</i> — "<i>humanity's growth thickens the drone.</i>" <b>Population growth per second</b> — net humans added to Earth every tick. The species getting larger makes the floor thicker.</li>
|
||
<li><b>astro.saturn → drone.voices</b> · amt <b>0.4</b> · <i>lin</i> — "<i>Saturn's slow wheel weights the drone.</i>" <b>Saturn's ecliptic longitude</b> (0–360°), filtered nearly to a standstill (min_cutoff 0.08). Saturn is the planet of weight, limit, and time — its 29-year march around the zodiac becomes the slowest-turning voice in the drone, a gravity you'll never hear move but always feel.</li>
|
||
<li><b>world.poverty → drone.voices</b> · amt <b>0.35</b> · <i>lin</i> — "<i>the world's poverty weights the drone.</i>" The share of humanity in <b>extreme poverty</b> (World Bank, 0–20%). A structural sorrow held in the bass — the more of it, the heavier the world sounds.</li>
|
||
<li><b>debt.total → drone.voices</b> · amt <b>0.3</b> · <i>lin</i> — "<i>the weight of the debt as a drone.</i>" The <b>US national debt</b> itself (38–42 trillion), filtered to a crawl. Not its churn — its sheer <i>mass</i>, sitting in the floor as ballast. A number too large to feel, made audible as weight.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE GRIT — saturation is where the world's suffering and combustion live.</b> <b>saturation</b> is the distortion/drive stage — the instrument's grit and grind. Almost everything routed here is pollution, dissonance, death, fire, hunger, or debt, and most of it goes through <i>exp</i> curves so the grit stays clean until the world genuinely worsens, then bites hard.</p>
|
||
<ul>
|
||
<li><b>air.pm25 → saturation</b> · amt <b>0.75</b> · <i>exp</i> — "<i>Delhi's air becomes grit.</i>" Real <b>PM2.5 in Delhi</b> (Open-Meteo, 0–250 µg/m³) — one of the most polluted airsheds on Earth. The dominant grit source. The <i>exp</i> curve means clean-ish air passes clean, but as Delhi's air thickens with particulates the sound literally cakes with distortion. You are hearing the smog.</li>
|
||
<li><b>market.turnover → saturation</b> · amt <b>0.5</b> · <i>exp</i> — "<i>pricier gear moving = more grit.</i>" Your dealgod <b>average price</b>. When expensive things change hands, the mix grinds harder — high-stakes trade as texture.</li>
|
||
<li><b>astro.tension → saturation</b> · amt <b>0.45</b> · <i>exp</i> — "<i>hard aspects = dissonant grit.</i>" The count of <b>hard aspects</b> (squares and oppositions) in the real sky (0–8). Astrology's stressful angles become literal audible stress — the more the planets are at war overhead, the more the sound distorts. Dissonance in the heavens, dissonance in the drive.</li>
|
||
<li><b>debt.vel → saturation</b> · amt <b>0.4</b> · <i>exp</i> — "<i>the debt churn grinds the saturation.</i>" The <b>daily churn</b> of the national debt (up to 50 billion/day) — its rate, not its mass. The debt growing is a grinding sound; the faster it churns, the harder it grinds.</li>
|
||
<li><b>fire.count → saturation</b> · amt <b>0.4</b> · <i>exp</i> — "<i>the world's wildfires burn the saturation.</i>" The count of <b>active wildfires worldwide</b> (0–600). Combustion becomes distortion — the more the planet is literally on fire, the more the sound burns. The most direct metaphor in the patch: fire is drive.</li>
|
||
<li><b>world.hunger → saturation</b> · amt <b>0.25</b> · <i>lin</i> — "<i>hunger grinds under everything.</i>" World <b>undernourishment</b> (0–15%). A low, linear grind that never quite leaves — hunger as the grit beneath the whole mix.</li>
|
||
<li><b>clock.deaths → saturation</b> · amt <b>0.2</b> · <i>lin</i> — "<i>the death rate adds weight.</i>" Humanity's modelled <b>deaths per second</b> (~0–5). Where births lift the pads into light, deaths add grit to the drive — the two demographic rates pull the instrument in opposite directions, exactly as they pull the world.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE GLITCH — every discrete shock in the world is a burst.</b> <b>glitch</b> is momentary digital rupture — stutters, buffer-shatter, transient chaos. Its sources are almost all <i>events</i>: impulse feeds that spike to 1.0 the instant something happens, then decay. This is where the world <i>interrupts</i> the music.</p>
|
||
<ul>
|
||
<li><b>light.flash.event → glitch</b> · amt <b>1.0</b> · fast attack (2 ms), 150 ms decay — "<i>a flash triggers a glitch.</i>" A sudden <b>flash of light</b> in the room (sensor or sim). Full-strength, near-instant: a camera flash or a lightning strike shatters the audio for a blink. Light becoming a stutter.</li>
|
||
<li><b>wiki.edit.event → glitch</b> · amt <b>0.9</b> · attack 5 ms, decay 250 ms — "<i>every human edit = a glitch burst.</i>" The <b>Wikipedia edit firehose</b> (Wikimedia SSE stream) — every single time any human anywhere edits Wikipedia, a glitch fires. This is the busiest event source in the instrument: a relentless, chattering sparkle of collective human attention rewriting itself in real time. The pulse of the species, rendered as texture.</li>
|
||
<li><b>fire.event → glitch</b> · amt <b>0.8</b> · attack 10 ms, decay 500 ms — "<i>each new fire flares a glitch.</i>" Distinct from the slow <b>fire.count</b> grit: the moment a <i>new</i> wildfire is detected, a glitch flares and rings out over half a second. Ignition as an audible spark.</li>
|
||
<li><b>air.aqi → glitch</b> · amt <b>0.3</b> · <i>exp3</i> — "<i>worse air, more glitch.</i>" Delhi's <b>US AQI</b> (0–300) through a steep cubic curve — so it contributes essentially nothing at moderate air and then erupts into instability only when the air becomes genuinely hazardous. Bad air doesn't just grind (that's PM2.5 → saturation); at its worst it makes the whole signal <i>unstable</i>.</li>
|
||
<li><b>astro.retro → glitch</b> · amt <b>0.3</b> · <i>exp</i> — "<i>the more retrogrades, the more glitch.</i>" The <b>count of planets currently retrograde</b> in the real sky. Retrograde motion is the illusion of a planet moving backward — apparent malfunction of the cosmos — so more retrogrades mean more glitch. When the whole solar system seems to stutter, so does the instrument.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE SPACE & THE REVERB — the size of the void.</b> Two related destinations sculpt depth: <b>reverb.size</b> is the room the sound sits in; <b>master.space</b> is the overall sense of scale and field. Earthquakes, moonlight, sunlight, and the room itself decide how vast the instrument feels.</p>
|
||
<ul>
|
||
<li><b>quake.event → reverb.size</b> · amt <b>1.0</b> · <i>gate 0.35</i> · decay 3.5 s — "<i>earthquakes open the void.</i>" A live <b>earthquake</b> from USGS, normalized against magnitude (0–7). The <b>gate</b> is the crucial detail: nothing happens until the quake crosses 0.35 — small tremors are ignored — and then a real quake throws the reverb wide open and lets it ring out for three and a half seconds. The Earth cracks and the void yawns. Only serious quakes are allowed to move the room.</li>
|
||
<li><b>quake.event → master.space</b> · amt <b>0.9</b> · "<i>and swell the whole field.</i>" The same seismic event, ungated, also swells the total sense of space — so a big quake doesn't just add reverb, it makes the entire instrument feel momentarily enormous. The planet's rupture, felt as scale.</li>
|
||
<li><b>astro.moon → master.space</b> · amt <b>0.6</b> · <i>scurve</i> — "<i>the moon phase sets the tide of space.</i>" The real <b>lunar phase</b> (0 = new, 1 = full), filtered glacially (min_cutoff 0.1). The moon pulls tides on Earth; here it pulls the tide of <i>space</i> — the fuller the moon, the wider the field. A month-long swell you feel more than hear.</li>
|
||
<li><b>light.lux → reverb.size</b> · amt <b>0.5</b> · <i>lin</i> — "<i>room light sizes the reverb.</i>" <b>Ambient light</b> in the room (0–1000 lux). A brighter room reads as a bigger room — the reverb grows with the light you're actually standing in.</li>
|
||
<li><b>audio.rms → master.space</b> · amt <b>0.4</b> · <i>lin</i> — "<i>room loudness feeds the space.</i>" The mic's <b>loudness</b> (RMS). The louder the room actually is, the larger the field opens — the instrument breathes with the noise around it.</li>
|
||
<li><b>sky.elev → master.space</b> · amt <b>0.3</b> · <i>lin</i> — "<i>the sun's height sets the space.</i>" The <b>sun's elevation</b> above your local horizon (−30° to 75°), filtered nearly still. High noon feels expansive; a low sun draws the space in. The arc of the day, as depth.</li>
|
||
<li><b>econ.inflation → master.space</b> · amt <b>0.3</b> · <i>lin</i> — "<i>inflation erodes the space.</i>" Inflation's second cable: beyond pressing the tempo, it feeds the field — a slow structural pressure that inflation, fittingly, is everywhere in.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE TEXTURE & TIMBRE — the internet's pulse and the drifting waveform.</b> Two destinations shape the <i>grain</i> and <i>shape</i> of the sound. <b>granular.density</b> controls the cloud of tiny grains that make up the pad's texture; <b>wavetable.morph</b> sweeps the oscillator through its bank of waveforms, continuously changing the fundamental character of the tone.</p>
|
||
<ul>
|
||
<li><b>wiki.rate → granular.density</b> · amt <b>0.6</b> · <i>lin</i> — "<i>the internet's pulse = texture.</i>" The <b>global rate of Wikipedia edits</b> (min-max over a 200-sample window) — not each edit (that's the glitch) but the overall <i>tempo</i> of human editing, right now. When the world is busy writing itself down, the texture thickens into a dense granular cloud; in the quiet hours it thins. The collective attention of the species as grain.</li>
|
||
<li><b>tof.cx → wavetable.morph</b> · amt <b>0.9</b> · <i>lin</i> — "<i>your hand X morphs the wavetable.</i>" The strongest morph cable: your <b>hand's X position</b> over the ToF sensor sweeps the waveform bank directly. This is the most immediate, physical, "instrument" gesture in the whole patch — move your hand sideways and the tone visibly transforms under your fingers.</li>
|
||
<li><b>almanac.element → wavetable.morph</b> · amt <b>0.4</b> · <i>lin</i> — "<i>the moon's element steps the timbre.</i>" The <b>planting element</b> of the current moon sign, which cycles <b>fire → earth → air → water</b> (fruit → root → flower → leaf) every ~2.25 days. So the timbre walks a slow four-step sequencer keyed to the old biodynamic garden calendar — the moon literally steps the waveform through four characters as it crosses the zodiac.</li>
|
||
<li><b>iss.vel → wavetable.morph</b> · amt <b>0.4</b> · <i>lin</i> — "<i>the ISS drifts the timbre.</i>" The <b>ground speed of the International Space Station</b> as it orbits (min-max, 60-sample window). A gentle, orbital drift in the tone — the fastest human-crewed object, felt as a slow wander in the waveform.</li>
|
||
<li><b>astro.mars → wavetable.morph</b> · amt <b>0.3</b> · <i>lin</i> — "<i>Mars morphs the timbre.</i>" <b>Mars's longitude</b> (0–360°). Mars's ~2-year circuit adds another slow hand on the morph — the red planet's wheel, turning the tone.</li>
|
||
<li><b>zodiac.hour → wavetable.morph</b> · amt <b>0.3</b> · <i>lin</i> — "<i>the hour animal turns the timbre.</i>" The <b>Chinese hour-animal</b>, which changes every two hours through the twelve-animal cycle. Every two hours a new animal takes the shift and nudges the waveform — the day divided into twelve timbral moods.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE LFO — the wind and the moon set the wobble.</b> <b>lfo.rate</b> is the speed of the instrument's low-frequency modulation — the rate of its breathing, its wobble, its slow sweep. Three cables set it, and all three are things that <i>cycle</i>.</p>
|
||
<ul>
|
||
<li><b>weather.wind → lfo.rate</b> · amt <b>0.55</b> · <i>lin</i> — "<i>wind speed = modulation rate.</i>" <b>Wind speed in Tokyo</b> (0–30 m/s). The dominant LFO driver, and the most intuitive one: the harder the wind actually blows, the faster the instrument sways. You can hear a gale as a quickening wobble.</li>
|
||
<li><b>astro.moon_lon → lfo.rate</b> · amt <b>0.4</b> · <i>lin</i> — "<i>the Moon's wheel drives the LFO.</i>" The <b>Moon's longitude</b> through the zodiac (0–360°), a full turn every ~27 days. Not the moon's phase but its <i>position</i> — the fastest of the celestial wheels, driving the slow breath of the modulation.</li>
|
||
<li><b>almanac.dec → lfo.rate</b> · amt <b>0.25</b> · <i>lin</i> — "<i>the moon rising/falling sets the LFO.</i>" The Moon's <b>ascending/descending declination cycle</b> (~27.3 days) — whether it's climbing or sinking in the sky. A second lunar wheel on the LFO, subtly out of phase with the first.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>THE DELAY — the echoes smear when Mercury turns and your hand descends.</b> <b>delay.feedback</b> controls how much of the echo feeds back into itself — low is a single slap, high is a long, smearing, self-devouring tail.</p>
|
||
<ul>
|
||
<li><b>tof.cy → delay.feedback</b> · amt <b>0.7</b> · <i>scurve</i> — "<i>your hand Y feeds the delay.</i>" Your <b>hand's height</b> over the ToF sensor. Raise and lower your hand and the echoes lengthen and shorten under a soft S-curve — the vertical partner to the horizontal wavetable morph, so both axes of your hand shape the sound at once.</li>
|
||
<li><b>astro.mercury_retro → delay.feedback</b> · amt <b>0.6</b> · <i>lin</i> — "<i>Mercury retrograde smears the delay.</i>" Whether <b>Mercury is retrograde</b> right now (0 or 1), from the real ephemeris. Mercury rules communication and messages; retrograde Mercury is the folklore of things echoing back, repeating, garbling. So when Mercury actually turns retrograde in the sky, the delay's feedback climbs and every echo smears into the next. The lore made literally audible — for the weeks it's true, and only then.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>How it all sums.</b> Every cable that shares a destination <i>adds</i> — the pads are the sum of daylight and Tokyo's warmth and a harmonious sky and the room's own brightness and the birth rate; the grit is Delhi's air plus the wildfire count plus the death rate plus hard aspects plus hunger, all at once. Nothing here is a solo. At any given second the instrument is playing the <b>whole state of the world</b>: the Sun holding the filter open, bitcoin singing the lead while the deep Earth answers in the bass, the Wikipedia firehose glittering, an earthquake ready to blow the void wide, the moon setting the tide of space, the debt sitting in the floor, your hand morphing the waveform and feeding the delay. This is the factory patch — and <kbd>config.json</kbd> is where you rewire any of it.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The vibes — grouping by feeling</h2>
|
||
|
||
<p class="lead">A vibe is not a folder. It is a mood you can grab. Godstrument's ~40 real feeds are already sorted once by <i>what they are</i> — the modulation matrix, source to voice. The vibes sort them a second time by <i>how they feel</i>, and that second sorting is where the instrument becomes playable by hand.</p>
|
||
|
||
<p>The rule that makes vibes powerful is the rule that makes them strange: <b>a single source lives in several vibes at once.</b> BTC volatility (<code>crypto.vel</code>) is a member of <b>cosmos</b>, of <b>market</b>, and of <b>fire</b> — three feelings, one feed. The moon phase (<code>astro.moon</code>) belongs to <b>heavens</b>, to <b>light</b>, and to <b>water</b>. So when you lean on one vibe, the source it shares with another vibe ripples sideways: press <b>fire</b> and the market glows hotter too, because they are wired to the same volatility. Nothing here is a clean partition — it is a web of overlapping intents, exactly the way a real feeling is never only one thing.</p>
|
||
|
||
<p>Each vibe carries three macros you can seize at any moment:</p>
|
||
<ul>
|
||
<li><b>mute</b> — silence every member of the vibe at once. Cut <b>dark</b> and the whole shadow ballast drops out; the field lifts.</li>
|
||
<li><b>freeze</b> — hold every member at its current value, a photograph of the world. The frozen sources stop breathing while everything outside the vibe keeps moving. (This is the same freeze exposed on <kbd>pad 20</kbd> for <b>@planet</b> — "freezes the planet in place.")</li>
|
||
<li><b>gain</b> — swell or fade the vibe's total contribution. In the factory patch an <i>open palm</i> rides <b>@planet</b>'s gain from 0.2 to 1.4, and <i>hand proximity</i> leans on <b>@cosmos</b> — you conduct a whole feeling with one hand.</li>
|
||
</ul>
|
||
|
||
<p><b>The orbits.</b> Some vibes are bound to a planet, and when a vibe is on a planetary orbit its gain <i>waxes and wanes on its own</i> — a slow tremolo at that planet's <b>real orbital rate</b>, scaled from <code>orbit_base_seconds: 60</code>. Mercury whips around in a blink; Mars breathes at roughly <b>113 seconds</b>; Saturn takes about <b>29 minutes</b> to complete one swell; Neptune drifts across <b>hours</b>. You are not hearing a metaphor for the planets — you are hearing the planets keep time. Six vibes are pinned to a sky; the other eight are elemental and archetypal, waiting for your hand or a MIDI knob to move them.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>The six orbiting vibes</b> — the ones that move themselves, each turning at its planet's true period.</p>
|
||
|
||
<ul>
|
||
<li><b>planet — "the local world"</b> · <i>orbit: earth.</i> The feeling of <i>right here, right now, outside your window.</i> Members: <b>temperature (Tokyo)</b>, <b>wind speed (Tokyo)</b>, <b>air pressure</b>, <b>PM2.5 (Delhi)</b>, <b>local day/night</b>, <b>local sun elevation</b>. This is the weather-body of the instrument — warmth brightening the pads, wind driving the LFO, pressure weighting the drone, daylight lifting everything, the local grit of unbreathable air. It rides Earth's own year, and it is the vibe your palm conducts directly: open your hand and the local world swells.</li>
|
||
|
||
<li><b>cosmos — "the shared sky"</b> · <i>orbit: saturn (~29 min swell).</i> The feeling of <i>the vast machinery we all sit under.</i> Members: <b>solar wind speed</b>, <b>ISS ground speed</b>, <b>aircraft aloft</b>, <b>BTC volatility</b>. The sun opening the master filter, the station drifting the timbre, ten thousand planes thickening the drone, the market's fever — the great shared traffic of the heavens and the atmosphere, all breathing on Saturn's patient 29-minute tide. Reaching your hand toward the sensor "leans on the cosmic layer."</li>
|
||
|
||
<li><b>human — "humanity"</b> · <i>orbit: mars (~113 s).</i> The feeling of <i>the species, alive and busy, in aggregate.</i> Members: <b>global edit rate</b> (the pulse of everyone editing the encyclopedia at once), <b>population growth / sec</b>, <b>births / sec</b>. The internet's texture, humanity's growth thickening the drone, the birth rate lifting the pads. It swells and fades every ~113 seconds on the war-planet's wheel — Mars, because we are a restless, multiplying, world-shaking animal.</li>
|
||
|
||
<li><b>market — "your market"</b> · <i>orbit: mercury (a whip).</i> The feeling of <i>commerce at speed — money changing hands faster than thought.</i> Members: <b>BTC volatility</b>, <b>dealgod market activity</b>, <b>dealgod avg price</b>. It drives the hats, nudges the tempo, grinds the saturation when pricier gear moves. Bound to Mercury — messenger, trader, quicksilver — so this vibe <i>whips</i>, waxing and waning almost too fast to track, the way a market does. Fittingly, <kbd>knob 2</kbd> "liquifies the market" by smoothing its volatility.</li>
|
||
|
||
<li><b>heavens — "the sky"</b> · <i>orbit: neptune (hours).</i> The feeling of <i>fate, aspect, the slow turning of the actual planets overhead.</i> Members: <b>moon phase</b>, <b>sky tension (hard aspects)</b>, <b>sky harmony (soft aspects)</b>, <b>Saturn longitude</b>, <b>Mars longitude</b>. The moon setting the tide of the master space, hard aspects grinding dissonance, soft aspects brightening the pads, Saturn's slow wheel weighting the drone. This is the astrological body — and it drifts on Neptune's oceanic, hours-long swell, the dreamiest and least hurried gain in the instrument.</li>
|
||
|
||
<li><b>summer — "☀ summer"</b> · <i>no orbit.</i> The feeling of <i>the year at its warm apex — light and heat as a single glow.</i> Members: <b>northern summer</b>, <b>southern summer</b>, <b>temperature (Tokyo)</b>, <b>local day/night</b>. Two hemispheres' summers plus warmth plus daylight, all of them pushing <b>pad brightness</b> — so this vibe is almost pure luminance. Because <code>season.north</code> and <code>season.south</code> are always in opposition, one hemisphere's summer is always answered by the other's winter: the vibe never fully sleeps, it just migrates across the equator with the calendar.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>The shadow and the bright</b> — the central polarity of the whole instrument. These two vibes are the ballast and the buoyancy; play them against each other and you set the emotional water-line of everything.</p>
|
||
|
||
<ul>
|
||
<li><b>dark — "the shadow"</b> · <i>no orbit.</i> The feeling of <i>the world's weight — everything grinding, eroding, ending.</i> Members: <b>debt daily churn</b>, <b>US inflation</b>, <b>PM2.5 (Delhi)</b>, <b>earthquake</b>, <b>deaths / sec</b>, <b>sky tension</b>, <b>extreme poverty</b>, <b>undernourishment</b>. Look at where these route and the darkness is <i>literal grit</i>: debt, deaths, dirty air, hunger, hard aspects all pile into <b>saturation</b> — distortion as suffering. Quakes tear open the reverb and swell the whole field; inflation erodes the master space; poverty weights the drone. This is the low, grinding, dissonant undertow, the ballast that keeps the instrument from floating away into prettiness. Mute it and the sun comes out.</li>
|
||
|
||
<li><b>light — "the bright"</b> · <i>no orbit.</i> The feeling of <i>the world answering back — life, tenderness, growth.</i> Members: <b>births / sec</b>, <b>moon phase</b>, <b>sky harmony</b>, <b>biodynamic fertility</b>, <b>local day/night</b>, <b>world life expectancy</b>, <b>world fertility rate</b>. Where dark grinds the saturation, light lifts the <b>pad brightness</b>: births, harmony, fertile days, daylight, longer lives all raise the glow. The moon phase sits in both vibes at once — it is the hinge between shadow and bright, the one body that is both night and light. Play <b>light</b> up against <b>dark</b> down and the instrument becomes hopeful; invert them and it becomes a dirge. This single opposition is the loudest lever in the Grimoire.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>The four elements</b> — fire, earth, air, water. The oldest way of sorting the world, wired straight into the sound. Each gathers the feeds that <i>belong</i> to its element, whatever category they came from.</p>
|
||
|
||
<ul>
|
||
<li><b>fire — "🔥 fire"</b> · <i>no orbit.</i> The feeling of <i>heat, ignition, the thing that burns.</i> Members: <b>wildfires worldwide</b>, <b>new wildfire</b> (event), <b>Mars longitude</b>, <b>solar wind speed</b>, <b>BTC volatility</b>. Literal fire (the world's live wildfire count burning the saturation, each new blaze flaring a glitch) joined to the fire-planet Mars, to the sun's own wind, and to the market's volatility — because a hot market <i>is</i> fire. This is the most aggressive element: grit and glitch and heat.</li>
|
||
|
||
<li><b>earth — "⛰ earth"</b> · <i>no orbit.</i> The feeling of <i>mass, depth, the immovable and the crushing.</i> Members: <b>quake depth</b>, <b>US national debt</b>, <b>extreme poverty</b>, <b>Saturn longitude</b>. Deep quakes choosing the bass note from far underground, the sheer 40-trillion-dollar weight of the debt as a drone, poverty as ballast, and Saturn — lord of stone, limit, and gravity — weighting the low end. Earth is the vibe of everything heavy and buried and slow.</li>
|
||
|
||
<li><b>air — "💨 air"</b> · <i>no orbit.</i> The feeling of <i>the moving atmosphere and everything carried on it.</i> Members: <b>wind speed (Tokyo)</b>, <b>PM2.5 (Delhi)</b>, <b>aircraft aloft</b>, <b>global edit rate</b>. Real wind driving the LFO, real particulates hanging in real air, thousands of planes literally aloft, and the internet's edit-rate as the invisible signal on the wind. Air is motion, breath, and transmission — the medium things travel through.</li>
|
||
|
||
<li><b>water — "💧 water"</b> · <i>no orbit.</i> The feeling of <i>flow, tide, moisture, the pull of the moon.</i> Members: <b>precipitation</b>, <b>humidity</b>, <b>moon phase</b>, <b>southern summer</b>. Falling rain, hanging humidity, the moon that moves the tides, and the southern hemisphere's warm wet season. Water is the softest, most fluid element — and it shares the moon with both <b>heavens</b> and <b>light</b>, so the tide is never far from the sky.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>The archetypes</b> — two last vibes that answer each other the way dark answers light: one of pure meaning, one of pure money.</p>
|
||
|
||
<ul>
|
||
<li><b>spirit — "✨ spirit"</b> · <i>no orbit.</i> The feeling of <i>the sacred, the fertile, the omen-in-the-hour.</i> Members: <b>sky harmony</b>, <b>biodynamic fertility</b>, <b>hour animal (2h cycle)</b>, <b>births / sec</b>. A harmonious sky, the biodynamic almanac's fertile days, the Chinese zodiac's hour-animal turning the timbre every two hours, and the raw fact of new life arriving. Spirit is the vibe of grace and meaning — soft, bright, generative. It is the near-opposite of wealth: value without price.</li>
|
||
|
||
<li><b>wealth — "💰 wealth"</b> · <i>no orbit.</i> The feeling of <i>money as a thing with weight and melody.</i> Members: <b>BTC price</b>, <b>dealgod avg price</b>, <b>dealgod market activity</b>, <b>US national debt</b>. Bitcoin literally playing the lead melody (quantized to a minor pentatonic), your own market's turnover playing its own lead in dorian, activity driving the hats, and the national debt droning underneath as the shadow-side of all wealth. Where <b>spirit</b> is value-without-price, <b>wealth</b> is price-made-audible — and the debt sitting inside it is the reminder that every fortune casts a shadow.</li>
|
||
</ul>
|
||
|
||
<p>Fourteen feelings, ~40 feeds, endless overlap. Grab one and the whole planet tilts toward it.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The heavens</h2>
|
||
|
||
<p class="lead">Above the instrument turns the oldest sequencer there is. <code>world_ephemeris.py</code> does not fetch the sky — it <i>computes</i> it, with the Swiss Ephemeris running in Moshier mode: no data files, no downloads, no network, good from <b>3000 BC to 3000 AD</b>. The planets are generated on demand from the current Julian day, exactly the way the sun worker generates the dawn. This is the opposite of big data — a whole cosmos conjured from a single timestamp, refreshed every <b>20 seconds</b>. As above, so below; as computed, so sounded.</p>
|
||
|
||
<p>Astrology is perfect fuel for an instrument for one honest reason: it is inherently cyclic at <i>wildly</i> different rates. The Moon wheels through all twelve signs in a month; Saturn takes twenty-nine years to close its ring. Feed those angles in and you have a bank of natural LFOs spanning seconds to a lifetime — oscillators no software could make you wait long enough to hear, except that the real sky has already been running them since before the room existed.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>The moon phase — the tide of space.</b> The worker takes the Moon's ecliptic longitude minus the Sun's, the <i>phase angle</i>, and turns it into illumination with <code>illum = (1 − cos(angle)) / 2</code> — a clean cosine swell from <b>0 at new moon</b> to <b>1 at full</b>. That single number rides <code>astro.moon</code> into <b>master.space</b> at amount 0.6 on an s-curve: <i>the moon phase sets the tide of space</i>. When the disc fills, the whole field floods and the reverb grows oceanic; at the dark of the moon the space contracts to a held breath. It is the slowest breath the instrument takes — one full inhale-and-exhale every 29.5 days — and everything else plays inside it.</p>
|
||
|
||
<p><b>The seven wheels.</b> The worker emits ecliptic longitude (0–360°) for Moon, Sun, Mercury, Venus, Mars, Jupiter and Saturn — each a sawtooth ramp that resets once per orbit as the body crosses 0° Aries. These are the LFOs:</p>
|
||
|
||
<ul>
|
||
<li>✦ <b>astro.moon_lon</b> — the fast wheel, ~13° a day, a full turn a month → <b>lfo.rate</b> (amount 0.4). The Moon's position in the zodiac literally sets the modulation rate; the wheel drives the wobble.</li>
|
||
<li>✦ <b>astro.mars</b> — a red two-year cycle → <b>wavetable.morph</b> (0.3). <i>Mars morphs the timbre</i> — a slow drift of the waveform's shape, keyed to the war-planet's march.</li>
|
||
<li>✦ <b>astro.saturn</b> — the 29-year ring, heavily smoothed (<code>min_cutoff 0.08</code>, the most glacial filter in the config) → <b>drone.voices</b> (0.4). <i>Saturn's slow wheel weights the drone.</i> Saturn is time, limit, gravity, the lord of heaviness — so it is the one hand on the drone's mass, thickening it across a span most players will never live to hear complete.</li>
|
||
</ul>
|
||
|
||
<p>Venus and Jupiter are computed too but held in reserve — they feed the aspect engine rather than a cable, present in the sky's math even when they aren't singing.</p>
|
||
|
||
<p><b>Aspects — the angles between the wanderers.</b> Every 20 seconds the worker measures the angular distance between all pairs of bodies and asks how close each pair sits to a named aspect, weighting by orb (nearer = stronger). It sorts them into two moods:</p>
|
||
|
||
<ul>
|
||
<li>✦ <b>HARD</b> — conjunction (0°, orb 8°), opposition (180°, orb 8°), square (90°, orb 7°): the aspects of tension, friction, the grind of forces pulling against each other. Their summed closeness rides <b>astro.tension</b> into <b>saturation</b> on an exponential curve (0.45): <i>hard aspects = dissonant grit.</i> When Mars squares Saturn the sky itself distorts the signal.</li>
|
||
<li>✦ <b>SOFT</b> — trine (120°, orb 7°), sextile (60°, orb 5°): the aspects of ease and flow. Their closeness rides <b>astro.harmony</b> into <b>pad.brightness</b> on an s-curve (0.4): <i>a harmonious sky brightens the pads.</i> A well-aspected heaven opens the light.</li>
|
||
</ul>
|
||
|
||
<p>So the same instant of sky is read twice — once for what strains and once for what agrees — and grit and glow are dealt out in exact proportion to the geometry overhead. Both signals are gently smoothed (<code>min_cutoff 0.2</code>) so an aspect arrives as a swell, not a switch.</p>
|
||
|
||
<p><b>Retrogrades — the backward planets.</b> The ephemeris runs with <code>FLG_SPEED</code>, so it knows not just where each body is but which way it's moving. Any planet (never the Moon) with negative longitude speed is retrograde — apparently walking backward against the stars. Two signals fall out:</p>
|
||
|
||
<ul>
|
||
<li>✦ <b>astro.mercury_retro</b> — 1 when Mercury is retrograde, else 0 → <b>delay.feedback</b> (0.6). <i>Mercury retrograde smears the delay.</i> Mercury is the messenger, the ruler of signal and communication; when it turns backward the folklore says messages garble and echo — and so the delay's feedback climbs, the past keeps returning, the signal will not resolve. Three or four times a year, for weeks, the instrument's echoes literally get muddier.</li>
|
||
<li>✦ <b>astro.retro</b> — the <i>fraction</i> of all bodies currently retrograde → <b>glitch</b> on an exp curve (0.3). <i>The more retrogrades, the more glitch.</i> A sky where many wanderers reverse at once is a sky out of joint, and the instrument stutters in sympathy.</li>
|
||
</ul>
|
||
|
||
<p>In the <b>heavens</b> concept group these live together (<code>astro.moon, tension, harmony, saturn, mars</code>), and can be assigned <b>Neptune's orbit</b> — the whole sky-layer then waxing and waning on Neptune's ~2.7-hour relative pulse, a cosmos breathing inside the cosmos.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The almanac</h2>
|
||
|
||
<p class="lead">From the very same computed sky, <code>world_almanac.py</code> reconstructs the oldest working calendar humans ever kept: the biodynamic planting almanac, sowing by the moon. Nothing is fetched — the Moon's sign, phase and declination all fall out of the same Swiss-Ephemeris call, refreshed every <b>30 seconds</b>. The Moon becomes a gardener walking the beds, and the instrument follows her from row to row.</p>
|
||
|
||
<p>For millennia the rule was simple: the sign the Moon is passing through tells you <i>what</i> to plant, and her phase tells you <i>whether</i> to plant it. The zodiac's twelve signs cycle through four elements in a fixed order — fire, earth, air, water — and biodynamic tradition binds each element to a part of the plant. As the Moon crosses roughly one sign every ~2.25 days, she steps through a slow four-part sequence of crop-types:</p>
|
||
|
||
<ul>
|
||
<li>✦ <b>EARTH signs → ROOT days</b> — Taurus, Virgo, Capricorn. Tend what grows below: carrots, potatoes, the hidden weight.</li>
|
||
<li>✦ <b>WATER signs → LEAF days</b> — Cancer, Scorpio, Pisces. Tend the green: lettuce, the parts that drink.</li>
|
||
<li>✦ <b>AIR signs → FLOWER days</b> — Gemini, Libra, Aquarius. Tend the bloom, the reaching-outward.</li>
|
||
<li>✦ <b>FIRE signs → FRUIT / SEED days</b> — Aries, Leo, Sagittarius. Tend the ripening: tomatoes, grain, the seed that carries on.</li>
|
||
</ul>
|
||
|
||
<p>The worker computes this with <code>sign = moon_longitude // 30</code>, then <code>elem = sign % 4</code> (0 fire, 1 earth, 2 air, 3 water), and maps it to the crop <code>["fruit", "root", "flower", "leaf"]</code>. It emits <b>almanac.element</b> as a quantized staircase — 0, ⅓, ⅔, 1 — heavily smoothed (<code>min_cutoff 0.1</code>) so each step is a soft glide. That staircase drives <b>wavetable.morph</b> (amount 0.4): <i>the moon's element steps the timbre.</i> This is the heart of the almanac's sound — <b>the Moon walks the garden as a four-step sequencer of timbre</b>. Root days give one waveform, leaf days another, flower and fruit their own; every ~2¼ days the instrument's fundamental character clicks over to the next bed, and there is no way to rush it, because the Moon sets the pace.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p><b>Waxing and waning — above ground or below.</b> The same phase angle the ephemeris uses splits the month in two: <code>waxing = 1 when the angle is under 180°</code>. The old rule — <i>sow above-ground crops as the moon fills, root crops as it empties</i> — the sap thought to rise toward a growing moon and sink toward a dwindling one. The instrument carries <b>almanac.waxing</b> as this clean above/below flag, the calendar's simplest bit.</p>
|
||
|
||
<p><b>Ascending and descending — the second month.</b> Beyond phase, the Moon has a whole other cycle the almanac watches: her <b>declination</b>, how high or low she rides against the celestial equator, swinging north and south over <b>~27.3 days</b> — a rhythm distinct from the 29.5-day phase, so the two beat slowly against each other. The worker pulls it from an equatorial-coordinate call and normalizes declination into <b>almanac.dec</b> (0..1, <code>min_cutoff 0.1</code>), which drives <b>lfo.rate</b> (0.25): <i>the moon rising and falling sets the LFO.</i> In the tradition an ascending Moon is the season of harvest and grafting, a descending Moon the time to plant and transplant — and here it becomes a second, slower hand on the modulation's speed, gently offset from the phase-tide so nothing in the sky ever lines up twice.</p>
|
||
|
||
<p><b>The fertility score.</b> The worker blends three of these into one favourability number — <code>fertile = 0.45·illumination + 0.35·ascending + 0.20·(root-or-leaf day)</code>: fullest and brightest when the Moon is bright, climbing north, and standing in a root or leaf sign — the classic good-sowing conjunction. It rides <b>almanac.fertile</b> into <b>pad.brightness</b> on an s-curve (0.3): <i>fertile days brighten the pads.</i> On the almanac's most propitious days the instrument's pads open of their own accord, as if the sound itself were readier to grow.</p>
|
||
|
||
<p>These four — element, waxing, declination, fertility — gather in the <b>spirit</b> and <b>light</b> concept groups (<code>almanac.fertile</code> beside <code>astro.harmony</code>, <code>clock.births</code>, <code>zodiac.hour</code>), the bright and living half of the world, where the Moon's slow tour of the garden lends its patience to everything it touches.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Playing it — the gestures</h2>
|
||
|
||
<p class="lead">The console is not a picture of the instrument. It <i>is</i> the instrument. Every sphere, every cable, every dial answers to the hand. Nothing here writes to <kbd>config.json</kbd> — the edits ride the same websocket the data rides (<i>add_route · remove_route · set_route · make_group</i>), so the modulation matrix rewires beneath your fingers in real time, live, on stage, mid-note.</p>
|
||
|
||
<p>Learn these seven gestures and the whole planet is under your hands:</p>
|
||
|
||
<ul>
|
||
<li><b>Click any node — toggle it on or off.</b> A source sphere (<i>the sun, an earthquake, Delhi's air, bitcoin's volatility</i>) or a voice (<i>lead, bass, pad, drone, perc</i>): one click mutes it, and the node takes a red slash. Silence the sun and the master filter stops breathing with the solar wind; silence <i>perc</i> and BTC's chaos stops driving the hats. Click again — it returns.</li>
|
||
<li><b>Drag a source onto a voice — lay a new cable.</b> This is composition by hand. Drag <i>quake.depth</i> onto <i>lead.note</i> and deep earthquakes start choosing the melody; drag <i>weather.wind</i> onto <i>drone.voices</i> and Tokyo's gusts thicken the choir. The cable is born live at a default amount, ready to bend.</li>
|
||
<li><b>Shift-click any node — open its tab.</b> The inspector tells you what the thing <i>is</i> (its real feed and label — <i>solar wind speed</i>, <i>PM2.5 over Delhi</i>, <i>the dealgod market's average price</i>), its <i>vibe</i> (which groups claim it), its <b>live value</b> ticking in real time, and every cable in and out — each with an <b>amount slider</b> to lean harder or lighter and a <b>×</b> to unpatch it entirely. This is where a rough idea becomes a mix.</li>
|
||
<li><b>⌘/Ctrl-click several sources, then group them — mint your own vibe.</b> Multi-select spheres by feeling, not category — a <i>shadow</i> of debt and inflation and quakes and the death rate, a <i>bright</i> of births and moonlight and fertility — and group them into a new named macro you can then mute, freeze, or gain as one hand-sized thing. The factory ships fourteen such vibes (<i>planet, cosmos, human, market, heavens, dark, light, and the seven elemental + seasonal blends</i>); yours join them.</li>
|
||
<li><b>♪ (top-right) — wake the sound.</b> Browsers hold audio until a human asks. This is the ask. One click and the in-browser synth spins up, and the planet — already flowing as data — becomes audible.</li>
|
||
<li><b>The godtime dial (top) — set the pulse.</b> Drag it to choose a tempo from <b>40 to 180 BPM</b>, with the real planets of tonight's sky wheeling around it as you turn. Click the dial to <b>lock</b> the tempo where you left it — otherwise the data itself keeps nudging it (bitcoin's velocity, the dealgod market, inflation, each with a small hand on the clock).</li>
|
||
<li><b>Press <kbd>P</kbd> — performance mode.</b> The editing chrome vanishes, the readouts declutter, and a rotating line of dispatches from the world (<i>bitcoin at $62,902 · the moon is 81% lit · mercury is retrograde · M4.2 earthquake</i>) rises for the audience. Put the console on the projector, hit <kbd>P</kbd>, and conduct.</li>
|
||
<li><b>Press <kbd>C</kbd> — chakra view.</b> The sources leave their column and take up their stations on the subtle body: seven wheels on a spine of light, crown to root, each feed orbiting the chakra it belongs to — the sky's machinery at the crown, the omens at the third eye, the species talking at the throat, weather and air at the heart, fire and money at the solar plexus, birth and water at the sacral, quakes and debt and the mortal weight at the root. Each wheel glows with the live average of its members and spins its traditional petals, counter-rotating wheel to wheel. <b>Drag empty space to spin the whole body 360°</b>, drag up/down to tilt, flick and let momentum carry it. Everything still works in there: click a node to mute it, drag it onto a voice to patch, shift-click for its tab. <kbd>C</kbd> again and the sources glide home.</li>
|
||
<li><b>Right-click anything — the everything menu.</b> Every node carries its full settings under the right button: mute, <b>solo</b>, a <b>level</b> fader (0–2×), a <b>trim</b> offset, every cable it carries with its amount slider and an unpatch ×, and a nested <i>patch to →</i> list for laying new cables without dragging. Right-click the empty sky for the view menu. And the modifiers, for the fast hands: <b>click</b> mutes · <b>⇧-click</b> opens the node's tab · <b>⇧-drag</b> rides the level up and down · <b>⌥-click</b> solos a source · <b>⌘-click</b> multi-selects for grouping · <b>⌘⇧-click</b> resets a node to neutral. People play differently; the instrument shouldn't care which hand you reach with.</li>
|
||
<li><b>Click a wheel — go inside it (mind mode).</b> In chakra view, click any chakra's core and the camera steps <i>into</i> the wheel: its mandala of petals turns around your whole field of view, and that chakra's feeds circle <i>you</i> — drag to look around the full 360°. A <b>vector console appears under your hands</b>: 12 keys of that wheel's own scale (C minor pentatonic at the root, rising key by key to B hirajoshi at the crown), played by clicking, by the <kbd>Q</kbd>–<kbd>]</kbd> row, or by any connected <b>MIDI keyboard</b> — every note runs through the world's own reverb and grit, so you are jamming <i>inside</i> the instrument, in that chakra's mode, while the planet plays around you. <kbd>↑</kbd> <kbd>↓</kbd> travel the spine wheel to wheel; <kbd>esc</kbd> steps back out. And for powerful machines, right-click the sky and set graphics to <b>ultra ✦</b>: every orbiting feed grows a glowing comet trail, and the orbits breathe so the trails weave slow spirals.</li>
|
||
</ul>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>The godtime dial</h2>
|
||
|
||
<p class="lead">At the top of the console turns a small orrery — a real map of tonight's sky, the Moon and planets in their true longitudes, computed live by the Swiss Ephemeris (Moshier mode, no downloads, valid 3000 BC to 3000 AD). It is beautiful, and it is also the transport. The clock of the whole instrument is a picture of the heavens.</p>
|
||
|
||
<p>This is deliberate. In Godstrument time itself is planetary. The dial is the <b>godtime</b> — drag it and you set the tempo anywhere from <b>40 to 180 BPM</b>, the orrery wheeling to follow your hand; click it to <b>lock</b> that tempo in place. Leave it unlocked and the world conducts: several feeds carry a small hand on the clock — <i>crypto.vel</i> nudges tempo at amount 0.25, the <i>dealgod market's velocity</i> at 0.2, US <i>inflation</i> quietly presses it at 0.2. Lock the dial and you take the baton back; the data still plays, but no longer on the beat.</p>
|
||
|
||
<p>Because the orrery is rendered client-side, in each listener's own browser, <b>every listener conducts their own godtime</b>. Two people on the same hub can run the same planetary data at different tempos, each with their own locked or drifting clock. The sky is shared; the pulse is yours.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Making it audible — three ways</h2>
|
||
|
||
<p class="lead">The matrix pours out clean control values whether or not anyone is listening. Choosing <i>how</i> to hear it is choosing which instrument the planet plays.</p>
|
||
|
||
<p><b>One — the built-in browser synth.</b> Click <b>♪</b> and a full voice rack wakes inside the page: <b>lead</b> (bitcoin's price and your market's average price both quantize to it — minor pentatonic and G dorian), <b>bass</b> (quake depth chooses its notes in a low minor), <b>pad</b> (brightened by Tokyo's warmth, daylight, harmony, the birth rate, longer lives), <b>drone</b> (thickened by aircraft aloft, barometric pressure, Saturn's slow wheel, the weight of the national debt), and <b>perc</b> (BTC volatility and market activity drive the hats). Over them sit the effects the world modulates: <b>reverb</b> (earthquakes open the void, room light sizes it), <b>delay</b> (your hand's Y feeds it, Mercury retrograde smears it), <b>saturation</b> (Delhi's air becomes grit, hard aspects add dissonance, wildfires burn it, hunger grinds beneath), and <b>glitch</b> (every Wikipedia edit on Earth, every light flash, every new wildfire fires a burst). Nothing to install; the planet plays a synth in your tab.</p>
|
||
|
||
<p><b>Two — Web MIDI, out into a DAW or hardware.</b> In Chrome or Edge, open the ⚙ panel's WEB MIDI section, <i>enable MIDI</i>, and pick an <b>out</b> port. Every destination now streams as CC or notes straight to your rig — no Python, no IAC bus. The map is fixed and MIDI-learnable at the far end:</p>
|
||
|
||
<ul>
|
||
<li><b>Filter cutoff → CC 74</b> — the sun and your hand open it</li>
|
||
<li><b>Pad brightness → CC 71</b> · <b>Wavetable morph → CC 70</b> — timbre and light</li>
|
||
<li><b>Delay → CC 93</b> · <b>Reverb → CC 91</b> · <b>Saturation → CC 75</b> · <b>Granular → CC 76</b> · <b>Glitch → CC 73</b> — the space and the grit</li>
|
||
<li><b>Perc density → CC 77</b> · <b>Drone voices → CC 79</b> · <b>Tempo nudge → CC 78</b> · <b>LFO rate → CC 72</b> · <b>Master space → CC 94</b> — the body and the pulse</li>
|
||
<li><b>Lead note → channel 0</b> · <b>Bass note → channel 1</b> — the two melodic voices, as real note-on/note-off</li>
|
||
</ul>
|
||
|
||
<p>MIDI-learn any of these at your synth and the planet drives your favorite hardware. You can also pick an <b>in</b> port and hit <b>MIDI learn</b> here: click a slider, mute, or sphere in the console, then twist a physical knob, and that knob is bound to that control (saved to your browser). A knob over your open palm becomes a macro over four data feeds at once. Web MIDI needs a secure context — <kbd>localhost</kbd> works as-is; a hosted console needs HTTPS. The browser runs the MIDI, so even a remote page drives the MIDI on <i>your</i> laptop.</p>
|
||
|
||
<p><b>Three — Strudel, TouchDesigner, OSC.</b> The hub streams <i>/out/<destination></i> floats to <kbd>127.0.0.1:9001</kbd> — point TouchDesigner's OSC In or a Max-for-Live device at that port and drive visuals or a modular from the same matrix. And <kbd>strudel/godstrument.md</kbd> is a starter kit that plays a live Strudel track (free, open, no Ableton) straight off the CC map, with a no-MIDI websocket path for a custom web-audio synth. One matrix, many mouths.</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Saving your instrument</h2>
|
||
|
||
<p class="lead">A patch you have played into being — the cables you dragged, the amounts you leaned, the mutes and freezes, the tempo you locked — is a whole instrument, and it deserves to survive the tab closing.</p>
|
||
|
||
<p>Open <b>⚙ → MY PATCHES</b> and <b>save</b>. The entire live state is written to your account, keyed to your login (<i>monsterrobotparty@gmail.com</i>): every route with its amount and curve, every group and orbit, the running mix of gains and mutes and freezes, and the godtime tempo. From any device, <b>load</b> it back and the exact instrument returns — the same planet, wired the same way, pulsing at the same locked BPM. (Locally, spells live as <kbd>patches/*.json</kbd> and cast the same full state from the ⚙ panel's SPELLS section; MY PATCHES is that power, carried to the cloud and back.)</p>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<p class="foot">So: the sun holds the filter, the earthquake opens the void, bitcoin sings the lead and Delhi's air turns it to grit, every human edit on Earth flickers as a glitch, and the Moon walks slowly through the garden weighting the drone. You did not build these forces — they were already playing. What you built is the <i>listening</i>: a cable dragged here, an amount leaned there, a tempo locked to tonight's true sky. Wake the sound with <b>♪</b>, conduct with the godtime dial, hide the chrome with <kbd>P</kbd>, and save what you've made to carry it anywhere. The instrument was always going to play. Now it plays <i>you back</i>.</p>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
"use strict";
|
||
(function () {
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Canvas / DPR setup
|
||
// ---------------------------------------------------------------------------
|
||
const canvas = document.getElementById("stage");
|
||
const ctx = canvas.getContext("2d");
|
||
let W = 0, H = 0, DPR = 1;
|
||
|
||
// ---- graphics level ----------------------------------------------------
|
||
// "auto" starts full and drops to lite if the frame rate drowns (ultrawides,
|
||
// older boxes). lite = no shadow blur, flat node cores, DPR capped at 1 —
|
||
// the expensive 90% of the look for 10% of the cost. Right-click the sky to
|
||
// override; the choice is remembered.
|
||
let fxMode = "auto"; // "auto" | "full" | "ultra" | "lite"
|
||
try { fxMode = localStorage.getItem("gs_fx") || "auto"; } catch (e) {}
|
||
let fxLevel = fxMode === "lite" ? 1 : fxMode === "ultra" ? 3 : 2;
|
||
let fpsEma = 60;
|
||
const bootT = performance.now() / 1000;
|
||
function setFxMode(m) {
|
||
fxMode = m;
|
||
try { localStorage.setItem("gs_fx", m); } catch (e) {}
|
||
fxLevel = m === "lite" ? 1 : m === "ultra" ? 3 : 2; // auto re-drops on its own
|
||
resize();
|
||
}
|
||
|
||
let bgCanvas = null; // background painted once per resize
|
||
function buildBg() {
|
||
bgCanvas = document.createElement("canvas");
|
||
bgCanvas.width = canvas.width; bgCanvas.height = canvas.height;
|
||
const c = bgCanvas.getContext("2d");
|
||
c.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||
const g = c.createRadialGradient(W * 0.5, H * 0.42, 40, W * 0.5, H * 0.5, Math.max(W, H) * 0.75);
|
||
g.addColorStop(0, "#0a1120");
|
||
g.addColorStop(0.55, "#060a13");
|
||
g.addColorStop(1, "#020306");
|
||
c.fillStyle = g;
|
||
c.fillRect(0, 0, W, H);
|
||
const vg = c.createRadialGradient(W * 0.5, H * 0.5, Math.min(W, H) * 0.35, W * 0.5, H * 0.5, Math.max(W, H) * 0.72);
|
||
vg.addColorStop(0, "rgba(0,0,0,0)");
|
||
vg.addColorStop(1, "rgba(0,0,0,0.72)");
|
||
c.fillStyle = vg;
|
||
c.fillRect(0, 0, W, H);
|
||
}
|
||
|
||
function resize() {
|
||
DPR = Math.min(window.devicePixelRatio || 1, 2.5);
|
||
if (fxLevel < 2) DPR = Math.min(DPR, 1);
|
||
W = window.innerWidth;
|
||
H = window.innerHeight;
|
||
// monster displays drown canvas2d in pixels — cap the backing store and let
|
||
// CSS scale it up; at that size nobody can tell.
|
||
const px = W * H * DPR * DPR;
|
||
const MAXPX = 5200000;
|
||
if (px > MAXPX) DPR = Math.max(0.7, Math.sqrt(MAXPX / (W * H)));
|
||
canvas.width = Math.floor(W * DPR);
|
||
canvas.height = Math.floor(H * DPR);
|
||
canvas.style.width = W + "px";
|
||
canvas.style.height = H + "px";
|
||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||
buildBg();
|
||
layoutDirty = true;
|
||
}
|
||
window.addEventListener("resize", resize);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utility
|
||
// ---------------------------------------------------------------------------
|
||
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
|
||
const lerp = (a, b, t) => a + (b - a) * t;
|
||
function approach(cur, target, rate, dt) {
|
||
// frame-rate independent lerp
|
||
const t = 1 - Math.pow(1 - rate, dt * 60);
|
||
return cur + (target - cur) * t;
|
||
}
|
||
const easeOut = t => 1 - Math.pow(1 - t, 3);
|
||
const now = () => performance.now() / 1000;
|
||
|
||
// MIDI note number -> name
|
||
const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
|
||
function midiToName(n) {
|
||
n = Math.round(n);
|
||
if (!isFinite(n)) return "--";
|
||
const octave = Math.floor(n / 12) - 1;
|
||
return NOTE_NAMES[((n % 12) + 12) % 12] + octave;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Families & palette
|
||
// ---------------------------------------------------------------------------
|
||
// body/room = warm amber; planet/weather = teal/green; sky/space = blue/violet;
|
||
// markets = gold; human-net = magenta.
|
||
const FAMILIES = {
|
||
body: { name: "body & room", hue: 34, sat: 90, col: [255, 176, 74] },
|
||
planet: { name: "planet", hue: 165, sat: 75, col: [64, 224, 178] },
|
||
sky: { name: "sky & space", hue: 226, sat: 82, col: [120, 150, 255] },
|
||
markets:{ name: "markets", hue: 45, sat: 92, col: [255, 214, 92] },
|
||
human: { name: "human net", hue: 312, sat: 82, col: [246, 108, 236] },
|
||
other: { name: "signals", hue: 200, sat: 40, col: [150, 180, 210] }
|
||
};
|
||
|
||
function familyOf(key) {
|
||
const p = (key || "").split(".")[0].toLowerCase();
|
||
if (["tof","light","hand","audio","mic","touch","room","cam"].includes(p)) return "body";
|
||
if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet";
|
||
if (["sun","iss","planes","plane","sky","aurora","moon","space"].includes(p)) return "sky";
|
||
if (["crypto","market","stock","btc","eth","fx"].includes(p)) return "markets";
|
||
if (["wiki","net","news","social","edit"].includes(p)) return "human";
|
||
return "other";
|
||
}
|
||
|
||
function rgba(col, a) {
|
||
return "rgba(" + col[0] + "," + col[1] + "," + col[2] + "," + a + ")";
|
||
}
|
||
function rgbMix(c, amt) {
|
||
// mix toward white by amt
|
||
return [
|
||
Math.round(lerp(c[0], 255, amt)),
|
||
Math.round(lerp(c[1], 255, amt)),
|
||
Math.round(lerp(c[2], 255, amt))
|
||
];
|
||
}
|
||
|
||
// pre-rendered glow sprites — one drawImage beats a per-frame radial gradient
|
||
const glowSprites = {};
|
||
function drawHalo(x, y, r, col, alpha) {
|
||
const key = col[0] + "," + col[1] + "," + col[2];
|
||
let s = glowSprites[key];
|
||
if (!s) {
|
||
s = document.createElement("canvas");
|
||
s.width = s.height = 64;
|
||
const c = s.getContext("2d");
|
||
const g = c.createRadialGradient(32, 32, 1, 32, 32, 32);
|
||
g.addColorStop(0, rgba(col, 1));
|
||
g.addColorStop(1, rgba(col, 0));
|
||
c.fillStyle = g;
|
||
c.fillRect(0, 0, 64, 64);
|
||
glowSprites[key] = s;
|
||
}
|
||
const ga = ctx.globalAlpha; // chakra view draws nodes pre-faded
|
||
ctx.globalAlpha = ga * clamp(alpha, 0, 1);
|
||
ctx.drawImage(s, x - r, y - r, r * 2, r * 2);
|
||
ctx.globalAlpha = ga;
|
||
}
|
||
const starSprite = (() => {
|
||
const s = document.createElement("canvas");
|
||
s.width = s.height = 16;
|
||
const c = s.getContext("2d");
|
||
const g = c.createRadialGradient(8, 8, 0.5, 8, 8, 8);
|
||
g.addColorStop(0, "rgba(190,205,255,1)");
|
||
g.addColorStop(1, "rgba(190,205,255,0)");
|
||
c.fillStyle = g;
|
||
c.fillRect(0, 0, 16, 16);
|
||
return s;
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Model
|
||
// ---------------------------------------------------------------------------
|
||
const sources = new Map(); // key -> node
|
||
const dests = new Map(); // key -> node
|
||
const routesArr = []; // route objects
|
||
const routeIndex = new Map();// "src|dest" -> route
|
||
let helloReceived = false;
|
||
let layoutDirty = true;
|
||
let lastServerT = 0;
|
||
let groupsInfo = {};
|
||
let readonly = false; // hub read-only (public): show info, hide edit controls
|
||
let inspectedNode = null; // node whose tab is open (for live value updates)
|
||
|
||
// godtime — the client-side global transport. Drag the celestial dial to set
|
||
// the tempo; it drives the synth and (later) MIDI clock out. Client-side, so
|
||
// it works on the read-only public site and each listener conducts their own.
|
||
const godtime = { bpm: 96, locked: false, beat: 0, beatFlash: 0 };
|
||
const GT_PLANETS = [["astro.sun_lon", "☉"], ["astro.moon_lon", "☽"], ["astro.mercury", "☿"],
|
||
["astro.venus", "♀"], ["astro.mars", "♂"], ["astro.jupiter", "♃"], ["astro.saturn", "♄"]];
|
||
const GT_ZODIAC = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"];
|
||
|
||
// short "what it is" for the output voices/params (sources use their label)
|
||
const DESC = {
|
||
"filter.cutoff": "master brightness — opens & closes the whole sound",
|
||
"reverb.size": "the size of the space everything sits in",
|
||
"delay.feedback": "echo — how much the sound repeats and smears",
|
||
"saturation": "grit & warmth — drive / distortion",
|
||
"granular.density": "fine textural shimmer",
|
||
"perc.density": "how busy the percussion is",
|
||
"tempo.nudge": "pushes & pulls the tempo",
|
||
"drone.voices": "weight — thickness of the low drone",
|
||
"pad.brightness": "the glow of the pads",
|
||
"wavetable.morph": "timbre morph — character of the lead",
|
||
"lfo.rate": "movement — how fast things wobble",
|
||
"glitch": "digital glitch bursts",
|
||
"master.space": "overall sense of space & air",
|
||
"lead.note": "the lead melody note, quantized to a key",
|
||
"bass.note": "the bass note, quantized to a key",
|
||
};
|
||
|
||
function vibesOf(key) { // which concept-groups ("vibes") this node belongs to
|
||
const out = [];
|
||
for (const g in groupsInfo) {
|
||
const gi = groupsInfo[g];
|
||
if (gi && Array.isArray(gi.members) && gi.members.indexOf(key) >= 0)
|
||
out.push(gi.label || g);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Personal instrument — a browser port of matrix.py + the mute/gain tweaks.
|
||
// Once the house patch arrives, YOUR edits (routesArr + tweaks) are computed
|
||
// here, client-side, so they take effect + persist without touching the shared
|
||
// hub. Each logged-in user plays their own instrument off the same planet feed.
|
||
// ---------------------------------------------------------------------------
|
||
const SCALES = {
|
||
chromatic: [0,1,2,3,4,5,6,7,8,9,10,11], major: [0,2,4,5,7,9,11], minor: [0,2,3,5,7,8,10],
|
||
dorian: [0,2,3,5,7,9,10], phrygian: [0,1,3,5,7,8,10], lydian: [0,2,4,6,7,9,11],
|
||
mixolydian: [0,2,4,5,7,9,10], pentatonic: [0,2,4,7,9], minor_pent: [0,3,5,7,10],
|
||
wholetone: [0,2,4,6,8,10], hirajoshi: [0,2,3,7,8],
|
||
};
|
||
function mxCurve(x, c) {
|
||
x = clamp(x, 0, 1);
|
||
if (c === "exp") return x * x;
|
||
if (c === "exp3") return x * x * x;
|
||
if (c === "log") return Math.sqrt(x);
|
||
if (c === "scurve") return x * x * (3 - 2 * x);
|
||
if (c === "inv") return 1 - x;
|
||
return x;
|
||
}
|
||
function mxQuantize(x01, scale, root, octaves) {
|
||
const steps = SCALES[scale] || SCALES.chromatic;
|
||
const n = steps.length * (octaves || 3);
|
||
const idx = Math.floor(clamp(x01, 0, 1) * (n - 1) + 0.5);
|
||
const oct = Math.floor(idx / steps.length), deg = idx % steps.length;
|
||
return (root || 48) + 12 * oct + steps[deg];
|
||
}
|
||
|
||
const tweaks = {}; // key or "@group" -> {mute,gain,offset}
|
||
const tw = (key) => tweaks[key] || (tweaks[key] = { mute: 0, gain: 1, offset: 0 });
|
||
let soloKey = null; // ⌥-click a source to hear it alone
|
||
function groupsOf(key) {
|
||
const out = [];
|
||
for (const g in groupsInfo) {
|
||
const gi = groupsInfo[g];
|
||
if (gi && Array.isArray(gi.members) && gi.members.indexOf(key) >= 0) out.push(g);
|
||
}
|
||
return out;
|
||
}
|
||
function isMutedLocal(key) {
|
||
if (tweaks[key] && tweaks[key].mute >= 0.5) return true;
|
||
return groupsOf(key).some(g => tweaks["@" + g] && tweaks["@" + g].mute >= 0.5);
|
||
}
|
||
function tweakNorm(key, norm) {
|
||
let v = norm;
|
||
for (const g of groupsOf(key)) {
|
||
const t = tweaks["@" + g];
|
||
if (t) { if (t.mute >= 0.5) return 0; v = clamp(v * t.gain + t.offset, 0, 1); }
|
||
}
|
||
const t = tweaks[key];
|
||
if (t) { if (t.mute >= 0.5) return 0; v = clamp(v * t.gain + t.offset, 0, 1); }
|
||
return v;
|
||
}
|
||
|
||
let clientMatrix = false; // true once the house patch has been cloned in
|
||
function computeDests() {
|
||
if (!clientMatrix) return;
|
||
const sv = {};
|
||
for (const [k, n] of sources) {
|
||
sv[k] = (soloKey && k !== soloKey) ? 0 : tweakNorm(k, n.norm || 0);
|
||
n.muted = isMutedLocal(k) || !!(soloKey && k !== soloKey);
|
||
}
|
||
const destVals = {}, notes = {};
|
||
for (const r of routesArr) {
|
||
const raw = sv[r.source];
|
||
if (raw == null) { r.active = 0; continue; }
|
||
if (r.gate != null && raw < r.gate) { r.active = 0; continue; }
|
||
const shaped = mxCurve(raw, r.curve || "lin");
|
||
const amt = (typeof r.amount === "number") ? r.amount : 0.5;
|
||
if (r.quantize) {
|
||
notes[r.dest] = mxQuantize(shaped, r.quantize.scale, r.quantize.root, r.quantize.octaves);
|
||
r.active = Math.abs(amt) * shaped; continue;
|
||
}
|
||
destVals[r.dest] = (destVals[r.dest] || 0) + amt * shaped;
|
||
r.active = Math.abs(amt * shaped);
|
||
}
|
||
for (const k in destVals) destVals[k] = clamp(destVals[k], 0, 1);
|
||
for (const k in notes) destVals[k] = notes[k];
|
||
for (const k in destVals) {
|
||
const tk = tweaks[k];
|
||
const muted = tk && tk.mute >= 0.5;
|
||
let v = muted ? 0 : destVals[k];
|
||
const n = ensureDest(k);
|
||
if (tk && !muted && !n.isNote) // per-voice output level (⇧-drag / menu)
|
||
v = clamp(v * tk.gain + tk.offset, 0, 1);
|
||
destVals[k] = v;
|
||
n.raw = v; n.muted = muted;
|
||
n.norm = n.isNote ? clamp((v - 24) / (96 - 24), 0, 1) : clamp(v, 0, 1);
|
||
}
|
||
Synth.update(destVals);
|
||
sendDestsToMidi(destVals);
|
||
}
|
||
|
||
// ---- local edits (mutate your patch instead of the read-only hub) ----------
|
||
function toggleMuteLocal(key) { const t = tw(key); t.mute = t.mute >= 0.5 ? 0 : 1; }
|
||
function setParamLocal(target, param, value) { tw(target)[param] = value; }
|
||
function addRouteLocal(source, dest, amount) {
|
||
if (!source || !dest) return;
|
||
const id = source + "|" + dest;
|
||
if (routeIndex.has(id)) return;
|
||
registerRoute({ source, dest, amount: amount != null ? amount : 0.5, curve: "lin" });
|
||
}
|
||
function removeRouteLocal(source, dest) {
|
||
const id = source + "|" + dest;
|
||
routeIndex.delete(id);
|
||
for (let i = routesArr.length - 1; i >= 0; i--)
|
||
if (routesArr[i].source === source && routesArr[i].dest === dest) routesArr.splice(i, 1);
|
||
layoutDirty = true;
|
||
}
|
||
function setRouteAmountLocal(source, dest, amount) {
|
||
const r = routeIndex.get(source + "|" + dest);
|
||
if (r) r.amount = amount;
|
||
}
|
||
function serializePatch() {
|
||
return {
|
||
routes: routesArr.map(r => ({ source: r.source, dest: r.dest, amount: r.amount,
|
||
curve: r.curve || "lin", gate: r.gate, quantize: r.quantize, label: r.label })),
|
||
tweaks: JSON.parse(JSON.stringify(tweaks)),
|
||
bpm: godtime.bpm,
|
||
};
|
||
}
|
||
function loadPatch(p) {
|
||
if (!p) return;
|
||
if (Array.isArray(p.routes)) {
|
||
routesArr.length = 0; routeIndex.clear();
|
||
for (const r of p.routes) registerRoute(r);
|
||
}
|
||
for (const k in tweaks) delete tweaks[k];
|
||
if (p.tweaks) for (const k in p.tweaks) tweaks[k] = Object.assign({ mute: 0, gain: 1, offset: 0 }, p.tweaks[k]);
|
||
if (typeof p.bpm === "number") godtime.bpm = clamp(p.bpm, 40, 180);
|
||
layoutDirty = true;
|
||
}
|
||
function makeGroupLocal(name, members) {
|
||
if (!name || !members || !members.length) return;
|
||
groupsInfo[name] = { label: name, members: members.slice() };
|
||
buildPanel();
|
||
}
|
||
async function gsFetch(path, method, body) {
|
||
const opt = { method: method || "GET", headers: { "Content-Type": "application/json" } };
|
||
if (body) opt.body = JSON.stringify(body);
|
||
try {
|
||
const r = await fetch(path, opt);
|
||
let d = {}; try { d = await r.json(); } catch (e) { }
|
||
return { ok: r.ok, status: r.status, data: d };
|
||
} catch (e) { return { ok: false, status: 0, data: {} }; }
|
||
}
|
||
|
||
const eventTicker = []; // {text, tAdded, src, mag}
|
||
const shockwaves = []; // active bursts
|
||
const particles = [];
|
||
|
||
function ensureSource(key, info) {
|
||
let n = sources.get(key);
|
||
if (!n) {
|
||
const fam = familyOf(key);
|
||
n = {
|
||
key, kind: "source", family: fam,
|
||
col: FAMILIES[fam].col,
|
||
label: (info && info.label) || key,
|
||
raw: 0, rawDisp: 0,
|
||
norm: 0, normDisp: 0,
|
||
isEvent: !!(info && info.event),
|
||
firePulse: 0, muted: false,
|
||
x: 0, y: 0, seed: Math.random() * 1000
|
||
};
|
||
sources.set(key, n);
|
||
layoutDirty = true;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function ensureDest(key) {
|
||
let n = dests.get(key);
|
||
if (!n) {
|
||
const isNote = /\.note$/.test(key) || /note/.test(key.split(".").pop() || "");
|
||
n = {
|
||
key, kind: "dest",
|
||
label: prettyDest(key),
|
||
isNote,
|
||
raw: 0, rawDisp: 0, // for notes this is midi number
|
||
norm: 0, normDisp: 0,
|
||
x: 0, y: 0, seed: Math.random() * 1000,
|
||
family: destFamily(key)
|
||
};
|
||
dests.set(key, n);
|
||
layoutDirty = true;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function destFamily(key) {
|
||
const p = key.split(".")[0].toLowerCase();
|
||
if (p.includes("lead") || p.includes("bass") || p.includes("pad") || p.includes("arp")) return "sky";
|
||
if (p.includes("filter") || p.includes("cutoff")) return "planet";
|
||
if (p.includes("drum") || p.includes("perc") || p.includes("kick")) return "body";
|
||
return "other";
|
||
}
|
||
|
||
function prettyDest(key) {
|
||
return key.replace(/\./g, " ").replace(/_/g, " ");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Layout — sources on left column, dests on right column
|
||
// ---------------------------------------------------------------------------
|
||
const HEADER_H = 96;
|
||
const FOOTER_H = 46;
|
||
|
||
function doLayout() {
|
||
layoutDirty = false;
|
||
const topPad = HEADER_H + 24;
|
||
const botPad = FOOTER_H + 20;
|
||
const usableH = Math.max(120, H - topPad - botPad);
|
||
|
||
// group sources by family for nicer grouping order
|
||
const srcList = Array.from(sources.values());
|
||
const famOrder = ["body","planet","sky","markets","human","other"];
|
||
srcList.sort((a, b) => {
|
||
const fa = famOrder.indexOf(a.family), fb = famOrder.indexOf(b.family);
|
||
if (fa !== fb) return fa - fb;
|
||
return a.key < b.key ? -1 : 1;
|
||
});
|
||
|
||
const dstList = Array.from(dests.values());
|
||
dstList.sort((a, b) => a.key < b.key ? -1 : 1);
|
||
|
||
const leftX = clamp(W * 0.20, 150, 320);
|
||
const rightX = W - clamp(W * 0.20, 150, 320);
|
||
|
||
placeColumn(srcList, leftX, topPad, usableH);
|
||
placeColumn(dstList, rightX, topPad, usableH);
|
||
}
|
||
|
||
function placeColumn(list, x, top, usableH) {
|
||
const n = list.length;
|
||
if (n === 0) return;
|
||
const gap = usableH / n;
|
||
for (let i = 0; i < n; i++) {
|
||
list[i].x = x;
|
||
list[i].targetY = top + gap * (i + 0.5);
|
||
if (list[i].y === 0) list[i].y = list[i].targetY;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WebSocket
|
||
// ---------------------------------------------------------------------------
|
||
let ws = null;
|
||
let connected = false;
|
||
let reconnectTimer = null;
|
||
let connectingSince = now();
|
||
|
||
function connect() {
|
||
try {
|
||
// Derive the hub URL from where the page is served so a remotely-hosted
|
||
// console (godstrument.pro) reaches the hub, not the visitor's localhost.
|
||
// https pages must use wss (mixed-content rule). Override with ?ws=... to
|
||
// point a remote page back at a local hub (e.g. ?ws=ws://localhost:8765).
|
||
const override = new URLSearchParams(location.search).get("ws");
|
||
const h = location.hostname;
|
||
const isLocal = h === "localhost" || h === "127.0.0.1" || h === "";
|
||
let url;
|
||
if (override) url = override; // ?ws=... wins
|
||
else if (isLocal) // local dev: hub directly
|
||
url = "ws://" + (h || "localhost") + ":8765";
|
||
else // hosted: same-origin /ws,
|
||
url = (location.protocol === "https:" ? "wss://" : "ws://") +
|
||
location.host + "/ws"; // matches page protocol
|
||
ws = new WebSocket(url);
|
||
} catch (e) {
|
||
scheduleReconnect();
|
||
return;
|
||
}
|
||
ws.onopen = () => {
|
||
connected = true;
|
||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||
};
|
||
ws.onclose = () => { connected = false; scheduleReconnect(); };
|
||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||
ws.onmessage = (ev) => {
|
||
let msg;
|
||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||
handleMessage(msg);
|
||
};
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
connected = false;
|
||
connectingSince = now();
|
||
if (reconnectTimer) return;
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connect();
|
||
}, 1000);
|
||
}
|
||
|
||
function handleMessage(msg) {
|
||
if (msg.hello) {
|
||
helloReceived = true;
|
||
if (Array.isArray(msg.routes)) {
|
||
preLayoutRoutes(msg.routes);
|
||
clientMatrix = true; // from here YOUR patch is computed locally
|
||
}
|
||
if (Array.isArray(msg.patches)) patchList = msg.patches;
|
||
if (msg.groups) { groupsInfo = msg.groups; buildPanel(); }
|
||
if (typeof msg.readonly === "boolean") readonly = msg.readonly;
|
||
if (msg.midi) midiMap = msg.midi;
|
||
return;
|
||
}
|
||
if (typeof msg.t === "number") lastServerT = msg.t;
|
||
|
||
if (msg.sources) {
|
||
for (const k in msg.sources) {
|
||
const s = msg.sources[k];
|
||
const n = ensureSource(k, s);
|
||
if (s.label) n.label = s.label;
|
||
if (typeof s.raw === "number") n.raw = s.raw;
|
||
if (typeof s.norm === "number") n.norm = clamp(s.norm, 0, 1);
|
||
if (s.event) n.isEvent = true;
|
||
n.muted = !!s.muted;
|
||
}
|
||
}
|
||
if (msg.dests && !clientMatrix) { // else computeDests() drives the dests locally
|
||
for (const k in msg.dests) {
|
||
const n = ensureDest(k);
|
||
const v = msg.dests[k];
|
||
n.raw = v;
|
||
if (n.isNote) {
|
||
// normalize midi-ish 0..127 into 0..1 for meter fill (centered on 36..96)
|
||
n.norm = clamp((v - 24) / (96 - 24), 0, 1);
|
||
} else {
|
||
n.norm = clamp(v, 0, 1);
|
||
}
|
||
}
|
||
sendDestsToMidi(msg.dests);
|
||
Synth.update(msg.dests);
|
||
}
|
||
if (msg.routes && !clientMatrix) { // the client owns routes once editing locally
|
||
const present = new Set();
|
||
for (const r of msg.routes) { registerRoute(r); present.add(r.source + "|" + r.dest); }
|
||
// drop cables the hub no longer has (live unpatch)
|
||
for (let i = routesArr.length - 1; i >= 0; i--) {
|
||
const id = routesArr[i].source + "|" + routesArr[i].dest;
|
||
if (!present.has(id)) { routeIndex.delete(id); routesArr.splice(i, 1); }
|
||
}
|
||
}
|
||
if (msg.macros) {
|
||
let fresh = false;
|
||
for (const g in msg.macros) if (!(g in groupsInfo)) { groupsInfo[g] = { label: g }; fresh = true; }
|
||
if (fresh) { buildPanel(); if (typeof midiAccess !== "undefined" && midiAccess) populatePorts(); }
|
||
}
|
||
if (msg.patches) { patchList = msg.patches; refreshTemplates(); }
|
||
if (msg.events && Array.isArray(msg.events)) {
|
||
for (const e of msg.events) {
|
||
ingestEvent(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
function preLayoutRoutes(rlist) {
|
||
for (const r of rlist) {
|
||
if (r.source) ensureSource(r.source, null);
|
||
if (r.dest) ensureDest(r.dest);
|
||
registerRoute(r, true);
|
||
}
|
||
layoutDirty = true;
|
||
}
|
||
|
||
function registerRoute(r, silent) {
|
||
if (!r.source || !r.dest) return;
|
||
ensureSource(r.source, null);
|
||
ensureDest(r.dest);
|
||
const id = r.source + "|" + r.dest;
|
||
let route = routeIndex.get(id);
|
||
if (!route) {
|
||
route = {
|
||
source: r.source, dest: r.dest,
|
||
active: 0, activeDisp: 0,
|
||
amount: (typeof r.amount === "number" ? r.amount : 0.5),
|
||
curve: r.curve || "lin",
|
||
gate: (r.gate != null ? r.gate : undefined),
|
||
quantize: r.quantize || undefined,
|
||
label: r.label || "",
|
||
dashPhase: Math.random() * 40
|
||
};
|
||
routeIndex.set(id, route);
|
||
routesArr.push(route);
|
||
layoutDirty = true;
|
||
}
|
||
if (typeof r.active === "number") route.active = clamp(r.active, 0, 1);
|
||
if (typeof r.amount === "number") route.amount = r.amount;
|
||
if (r.curve) route.curve = r.curve;
|
||
if (r.gate != null) route.gate = r.gate;
|
||
if (r.quantize) route.quantize = r.quantize;
|
||
if (r.label) route.label = r.label;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Events -> ticker + shockwaves
|
||
// ---------------------------------------------------------------------------
|
||
const seenEventKeys = new Set();
|
||
|
||
function ingestEvent(e) {
|
||
const key = (e.src || "?") + "@" + (e.t != null ? e.t : Math.random());
|
||
if (seenEventKeys.has(key)) return;
|
||
seenEventKeys.add(key);
|
||
if (seenEventKeys.size > 400) {
|
||
// trim
|
||
const arr = Array.from(seenEventKeys);
|
||
for (let i = 0; i < 200; i++) seenEventKeys.delete(arr[i]);
|
||
}
|
||
|
||
const text = eventText(e);
|
||
eventTicker.unshift({ text, tAdded: now(), src: e.src, mag: e.mag, serverT: e.t });
|
||
if (eventTicker.length > 40) eventTicker.pop();
|
||
|
||
// trigger a fire pulse + shockwave at the matching source node
|
||
const node = findSourceForEvent(e.src);
|
||
if (node) {
|
||
node.firePulse = 1;
|
||
spawnShockwave(node, e.mag);
|
||
}
|
||
}
|
||
|
||
function eventText(e) {
|
||
const src = (e.src || "event");
|
||
if (/quake/i.test(src)) {
|
||
return "M" + (e.mag != null ? (+e.mag).toFixed(1) : "?") + " earthquake";
|
||
}
|
||
if (/wiki/i.test(src)) {
|
||
return "wiki edit " + (e.mag != null ? "+" + Math.round(e.mag) + "b" : "");
|
||
}
|
||
if (/plane|iss/i.test(src)) {
|
||
return src + " " + (e.mag != null ? "· " + e.mag : "");
|
||
}
|
||
return src + (e.mag != null ? " · " + e.mag : "");
|
||
}
|
||
|
||
function findSourceForEvent(src) {
|
||
if (!src) return null;
|
||
// exact key
|
||
if (sources.has(src)) return sources.get(src);
|
||
// prefix match: event src "quake" -> source "quake.event"
|
||
for (const [k, n] of sources) {
|
||
if (k.split(".")[0] === src) return n;
|
||
if (k.startsWith(src)) return n;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function spawnShockwave(node, mag) {
|
||
const strength = clamp((mag != null ? Math.log10(Math.abs(mag) + 1) / 3 : 0.5), 0.25, 1.2);
|
||
shockwaves.push({
|
||
x: node.x, y: node.y, r: 8, maxR: 60 + strength * 90,
|
||
life: 1, col: node.col, born: now()
|
||
});
|
||
const count = Math.floor(10 + strength * 26);
|
||
for (let i = 0; i < count; i++) {
|
||
const ang = Math.random() * Math.PI * 2;
|
||
const spd = 40 + Math.random() * (80 + strength * 160);
|
||
particles.push({
|
||
x: node.x, y: node.y,
|
||
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
|
||
life: 1, decay: 0.6 + Math.random() * 0.9,
|
||
size: 1 + Math.random() * 2.4, col: node.col
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Starfield
|
||
// ---------------------------------------------------------------------------
|
||
const stars = [];
|
||
function initStars() {
|
||
stars.length = 0;
|
||
const count = 170;
|
||
for (let i = 0; i < count; i++) {
|
||
stars.push({
|
||
x: Math.random(), y: Math.random(),
|
||
z: Math.random() * 0.9 + 0.1,
|
||
tw: Math.random() * Math.PI * 2,
|
||
drift: (Math.random() - 0.5) * 0.004
|
||
});
|
||
}
|
||
}
|
||
initStars();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render loop
|
||
// ---------------------------------------------------------------------------
|
||
let lastFrame = now();
|
||
let pulseClock = 0;
|
||
|
||
function frame() {
|
||
const t = now();
|
||
let dt = t - lastFrame;
|
||
if (dt > 0.1) dt = 0.1;
|
||
lastFrame = t;
|
||
pulseClock += dt;
|
||
|
||
// fps governor — a machine that can't hold the frame rate sheds eye-candy
|
||
fpsEma += (1 / Math.max(dt, 1 / 240) - fpsEma) * 0.05;
|
||
if (fxMode === "auto" && fxLevel === 2 && t - bootT > 6 && fpsEma < 42) {
|
||
fxLevel = 1;
|
||
resize();
|
||
eventTicker.unshift({ text: "graphics switched to lite — right-click the sky to change", tAdded: now(), src: "sys" });
|
||
}
|
||
|
||
// godtime beat clock — drives the dial pulse even before ♪ is pressed
|
||
godtime.beat += dt * godtime.bpm / 60;
|
||
if (godtime.beat >= 1) { godtime.beat -= 1; godtime.beatFlash = 1; }
|
||
godtime.beatFlash = Math.max(0, godtime.beatFlash - dt * 3);
|
||
|
||
if (layoutDirty) doLayout();
|
||
computeDests(); // YOUR patch -> dests (synth + viz + midi)
|
||
|
||
// advance smoothing
|
||
for (const n of sources.values()) {
|
||
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
|
||
n.rawDisp = approach(n.rawDisp, n.raw, 0.16, dt);
|
||
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
|
||
if (n.firePulse > 0) n.firePulse = Math.max(0, n.firePulse - dt * 1.8);
|
||
}
|
||
for (const n of dests.values()) {
|
||
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
|
||
n.rawDisp = approach(n.rawDisp, n.raw, 0.22, dt);
|
||
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
|
||
}
|
||
for (const r of routesArr) {
|
||
r.activeDisp = approach(r.activeDisp, r.active, 0.14, dt);
|
||
r.dashPhase -= dt * (18 + r.activeDisp * 90);
|
||
}
|
||
|
||
if (chakraMode) updateChakra(t, dt); // positions sources on the subtle body
|
||
|
||
drawBackground(t);
|
||
drawCables(t);
|
||
drawShockwaves(t, dt);
|
||
drawParticles(dt);
|
||
if (chakraMode) drawChakraView(t);
|
||
else for (const n of sources.values()) drawSourceNode(n, t);
|
||
for (const n of dests.values()) drawDestNode(n, t);
|
||
drawEditing(t);
|
||
if (performMode) drawPerform(t, dt);
|
||
drawHeader(t);
|
||
if (connected) drawGodtime(t);
|
||
drawFooter(t);
|
||
if (!connected) drawConnecting(t);
|
||
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Background
|
||
// ---------------------------------------------------------------------------
|
||
function drawBackground(t) {
|
||
// gradient + vignette are pre-painted once per resize; sprites for the stars
|
||
ctx.drawImage(bgCanvas, 0, 0, W, H);
|
||
for (const s of stars) {
|
||
s.y += s.drift * 0.06;
|
||
if (s.y > 1.02) s.y -= 1.04; else if (s.y < -0.02) s.y += 1.04;
|
||
const tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * (0.6 + s.z) + s.tw));
|
||
ctx.globalAlpha = tw * s.z * 0.55;
|
||
const r = s.z * 3.2;
|
||
ctx.drawImage(starSprite, s.x * W - r, s.y * H - r, r * 2, r * 2);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cables
|
||
// ---------------------------------------------------------------------------
|
||
function drawCables(t) {
|
||
ctx.lineCap = "round";
|
||
for (const r of routesArr) {
|
||
const s = sources.get(r.source);
|
||
const d = dests.get(r.dest);
|
||
if (!s || !d) continue;
|
||
const a = r.activeDisp;
|
||
|
||
const x1 = s.x + NODE_R + 6, y1 = s.y;
|
||
const x2 = d.x - NODE_R - 6, y2 = d.y;
|
||
const midx = (x1 + x2) / 2;
|
||
const cx1 = lerp(x1, midx, 0.85) + 40;
|
||
const cx2 = lerp(midx, x2, 0.15) - 40;
|
||
|
||
const col = s.col;
|
||
const glow = rgbMix(col, 0.25);
|
||
|
||
// idle base line
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.bezierCurveTo(cx1, y1, cx2, y2, x2, y2);
|
||
ctx.lineWidth = 1 + a * 3.5;
|
||
ctx.strokeStyle = rgba(col, 0.08 + a * 0.30);
|
||
ctx.setLineDash([]);
|
||
ctx.stroke();
|
||
|
||
if (a > 0.02) {
|
||
// glowing flowing dashes
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.bezierCurveTo(cx1, y1, cx2, y2, x2, y2);
|
||
ctx.lineWidth = 1.4 + a * 4.5;
|
||
ctx.strokeStyle = rgba(glow, 0.35 + a * 0.5);
|
||
ctx.setLineDash([2 + a * 8, 10 - a * 4]);
|
||
ctx.lineDashOffset = r.dashPhase;
|
||
if (fxLevel > 1) { // shadow blur on 50+ beziers is the #1 cost
|
||
ctx.shadowColor = rgba(col, 0.6);
|
||
ctx.shadowBlur = 6 + a * 14;
|
||
}
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// travelling pulse dot
|
||
const pt = ((t * (0.15 + a * 0.6) + r.dashPhase * 0.002) % 1 + 1) % 1;
|
||
const bp = bezierPoint(x1, y1, cx1, y1, cx2, y2, x2, y2, pt);
|
||
ctx.beginPath();
|
||
ctx.arc(bp.x, bp.y, 1.5 + a * 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.5), 0.6 + a * 0.4);
|
||
if (fxLevel > 1) {
|
||
ctx.shadowColor = rgba(col, 0.9);
|
||
ctx.shadowBlur = 8 + a * 10;
|
||
}
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
function bezierPoint(x1,y1,cx1,cy1,cx2,cy2,x2,y2,t) {
|
||
const mt = 1 - t;
|
||
const a = mt*mt*mt, b = 3*mt*mt*t, c = 3*mt*t*t, d = t*t*t;
|
||
return {
|
||
x: a*x1 + b*cx1 + c*cx2 + d*x2,
|
||
y: a*y1 + b*cy1 + c*cy2 + d*y2
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Nodes
|
||
// ---------------------------------------------------------------------------
|
||
const NODE_R = 16;
|
||
|
||
function drawSourceNode(n, t) {
|
||
const col = n.col;
|
||
const x = n.x, y = n.y;
|
||
const pulse = n.firePulse;
|
||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1 + n.seed);
|
||
|
||
// outer halo (sprite — no per-frame gradient)
|
||
drawHalo(x, y, NODE_R + 26 + pulse * 20, col, 0.10 + n.normDisp * 0.28 + pulse * 0.5);
|
||
|
||
// radial meter arc (norm)
|
||
const startA = Math.PI * 0.75;
|
||
const endA = Math.PI * 0.75 + Math.PI * 1.5;
|
||
// track
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, endA);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(col, 0.14);
|
||
ctx.stroke();
|
||
// fill
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, startA + (endA - startA) * n.normDisp);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.25), 0.85);
|
||
if (fxLevel > 1) {
|
||
ctx.shadowColor = rgba(col, 0.7);
|
||
ctx.shadowBlur = 8;
|
||
}
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// core disc (lite: flat fill, no gradient)
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
|
||
if (fxLevel > 1) {
|
||
const cg = ctx.createRadialGradient(x - 4, y - 4, 1, x, y, NODE_R);
|
||
cg.addColorStop(0, rgba(rgbMix(col, 0.55 + pulse * 0.4), 0.95));
|
||
cg.addColorStop(1, rgba(col, 0.35 + breathe * 0.12));
|
||
ctx.fillStyle = cg;
|
||
} else {
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.35 + pulse * 0.3), 0.8);
|
||
}
|
||
ctx.fill();
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.4), 0.5);
|
||
ctx.stroke();
|
||
|
||
// event indicator ring
|
||
if (n.isEvent) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R - 5, 0, Math.PI * 2);
|
||
ctx.setLineDash([2, 3]);
|
||
ctx.lineDashOffset = -t * 8;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.4), 0.6 + pulse * 0.4);
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// label (to the LEFT of node)
|
||
ctx.textAlign = "right";
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.55), 0.95);
|
||
ctx.fillText(truncate(n.label, 22), x - NODE_R - 14, y - 1);
|
||
if (!performMode) {
|
||
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,165,190,0.7)";
|
||
ctx.fillText(fmtRaw(n.rawDisp), x - NODE_R - 14, y + 13);
|
||
}
|
||
|
||
// multi-select highlight ring
|
||
if (selection.has(n.key)) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 9, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(255,238,150,0.9)";
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([3, 3]); ctx.lineDashOffset = -t * 6;
|
||
ctx.stroke(); ctx.setLineDash([]);
|
||
}
|
||
|
||
// muted: dark veil + red slash
|
||
if (n.muted) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(4,6,11,0.62)";
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - NODE_R + 2, y - NODE_R + 2);
|
||
ctx.lineTo(x + NODE_R - 2, y + NODE_R - 2);
|
||
ctx.lineWidth = 2.5;
|
||
ctx.strokeStyle = "rgba(255,95,95,0.85)";
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawDestNode(n, t) {
|
||
const fam = FAMILIES[n.family] || FAMILIES.other;
|
||
const col = fam.col;
|
||
const x = n.x, y = n.y;
|
||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.3 + n.seed);
|
||
|
||
drawHalo(x, y, NODE_R + 22, col, 0.08 + n.normDisp * 0.22);
|
||
|
||
// meter arc
|
||
const startA = Math.PI * 0.75;
|
||
const endA = Math.PI * 0.75 + Math.PI * 1.5;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, endA);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(col, 0.14);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R + 5, startA, startA + (endA - startA) * n.normDisp);
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.3), 0.85);
|
||
if (fxLevel > 1) {
|
||
ctx.shadowColor = rgba(col, 0.6);
|
||
ctx.shadowBlur = 7;
|
||
}
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// core (lite: flat fill)
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
|
||
if (fxLevel > 1) {
|
||
const cg = ctx.createRadialGradient(x - 4, y - 4, 1, x, y, NODE_R);
|
||
cg.addColorStop(0, rgba(rgbMix(col, 0.5), 0.92));
|
||
cg.addColorStop(1, rgba(col, 0.3 + breathe * 0.1));
|
||
ctx.fillStyle = cg;
|
||
} else {
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.32), 0.78);
|
||
}
|
||
ctx.fill();
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = rgba(rgbMix(col, 0.35), 0.5);
|
||
ctx.stroke();
|
||
|
||
// center value for notes
|
||
if (n.isNote) {
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.font = "700 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(10,14,22,0.9)";
|
||
ctx.fillText(midiToName(n.raw), x, y + 0.5);
|
||
}
|
||
|
||
// label (to the RIGHT of node)
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(col, 0.5), 0.95);
|
||
ctx.fillText(truncate(n.label, 22), x + NODE_R + 14, y - 1);
|
||
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,165,190,0.7)";
|
||
const sub = n.isNote ? ("note " + Math.round(n.raw)) : fmtRaw(n.rawDisp);
|
||
ctx.fillText(sub, x + NODE_R + 14, y + 13);
|
||
}
|
||
|
||
function fmtRaw(v) {
|
||
if (!isFinite(v)) return "--";
|
||
const a = Math.abs(v);
|
||
if (a >= 10000) return (v / 1000).toFixed(1) + "k";
|
||
if (a >= 100) return v.toFixed(0);
|
||
if (a >= 10) return v.toFixed(1);
|
||
return v.toFixed(2);
|
||
}
|
||
function truncate(s, n) {
|
||
s = String(s || "");
|
||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chakra view — the subtle body (press C). Seven wheels on a spine of light;
|
||
// every source takes its station and orbits the chakra it belongs to, full
|
||
// 360°. Drag empty space to spin the body, drag up/down to tilt, flick for
|
||
// momentum. The 3D is hand-rolled: rotation around the spine axis + a
|
||
// perspective divide — no library, the same 2D canvas.
|
||
// ---------------------------------------------------------------------------
|
||
let chakraMode = false;
|
||
let chakraDrag = null; // {x0,y0,rot0,tilt0,lastX}
|
||
const chakraSpin = { rot: 0, vel: 0, tilt: 0.35 };
|
||
|
||
const CHAKRAS = [ // top -> bottom, traditional petal counts
|
||
{ key: "crown", label: "crown · sahasrara", petals: 20, col: [186, 122, 255] },
|
||
{ key: "eye", label: "third eye · ajna", petals: 2, col: [122, 110, 255] },
|
||
{ key: "throat", label: "throat · vishuddha", petals: 16, col: [86, 178, 255] },
|
||
{ key: "heart", label: "heart · anahata", petals: 12, col: [96, 230, 140] },
|
||
{ key: "solar", label: "solar plexus · manipura", petals: 10, col: [255, 214, 74] },
|
||
{ key: "sacral", label: "sacral · svadhisthana", petals: 6, col: [255, 150, 64] },
|
||
{ key: "root", label: "root · muladhara", petals: 4, col: [255, 88, 88] },
|
||
];
|
||
const CHAKRA_BY_KEY = {};
|
||
for (const c of CHAKRAS) CHAKRA_BY_KEY[c.key] = c;
|
||
|
||
// each wheel is a key & a scale — C at the root rising to B at the crown
|
||
const CHAKRA_SCALES = {
|
||
root: { root: 48, scale: "minor_pent" },
|
||
sacral: { root: 50, scale: "dorian" },
|
||
solar: { root: 52, scale: "mixolydian" },
|
||
heart: { root: 53, scale: "major" },
|
||
throat: { root: 55, scale: "lydian" },
|
||
eye: { root: 57, scale: "wholetone" },
|
||
crown: { root: 59, scale: "hirajoshi" },
|
||
};
|
||
|
||
// ---- mind mode: click a wheel and go INSIDE it — POV from the data, a
|
||
// vector console of that wheel's scale under your hands ----
|
||
let mindChakra = null; // the CHAKRAS entry we're inside
|
||
const mindKeys = { notes: [], flash: new Array(12).fill(0) };
|
||
const MIND_KEYROW = "qwertyuiop[]"; // 12 notes of the wheel's scale
|
||
|
||
function enterMind(c) {
|
||
mindChakra = c;
|
||
const cs = CHAKRA_SCALES[c.key], steps = SCALES[cs.scale];
|
||
mindKeys.notes = [];
|
||
for (let i = 0; i < 12; i++)
|
||
mindKeys.notes.push(cs.root + 12 * Math.floor(i / steps.length) + steps[i % steps.length]);
|
||
mindKeys.flash.fill(0);
|
||
clearTrails();
|
||
}
|
||
function exitMind() {
|
||
mindChakra = null;
|
||
clearTrails();
|
||
for (const n of sources.values()) if (n.y < -5000) n.y = 0; // parked nodes snap home
|
||
}
|
||
function clearTrails() { for (const n of sources.values()) n.trail = null; }
|
||
|
||
function mindKeyGeom() { // the floating vector console
|
||
const total = Math.min(W * 0.8, 860), kw = total / 12;
|
||
return { x0: W / 2 - total / 2, yb: H - FOOTER_H - 26, kw, kh: 74 };
|
||
}
|
||
function mindStrike(i, vel) {
|
||
if (i < 0 || i > 11 || !mindChakra) return;
|
||
mindKeys.flash[i] = 1;
|
||
Synth.pluck(mindKeys.notes[i], vel == null ? 0.85 : vel);
|
||
}
|
||
|
||
function chakraOf(key) {
|
||
const k = (key || "").toLowerCase();
|
||
const p = k.split(".")[0];
|
||
// crown — cosmos & consciousness: the actual machinery of the sky
|
||
if (p === "iss" || p === "space" || /_lon|mercury|venus|jupiter|saturn/.test(k)) return "crown";
|
||
if (p === "sun" && !/elev/.test(k)) return "crown"; // solar wind
|
||
// third eye — light, omen, perception
|
||
if (p === "astro" || p === "almanac" || p === "aurora" || /elev|daynight|zodiac|hour/.test(k)) return "eye";
|
||
// throat — the species talking to itself
|
||
if (["wiki","net","news","social","edit"].includes(p)) return "throat";
|
||
// solar plexus — fire, will, money
|
||
if (["crypto","market","stock","btc","eth","fx","dealgod","fire"].includes(p) || /inflation/.test(k)) return "solar";
|
||
// sacral — water, creation, birth
|
||
if (/birth|fertil|precip|humid|rain|tide|moon|pop/.test(k)) return "sacral";
|
||
// root — ground, survival, the heavy and the mortal
|
||
if (["quake","seismic","debt","econ"].includes(p) || /poverty|death|hunger|undernour/.test(k)) return "root";
|
||
// heart — air, weather, connection, life
|
||
if (["weather","air","planes","plane","season"].includes(p) || /life|temp|wind|pressure/.test(k)) return "heart";
|
||
// the player's own body grounds the instrument; anything unplaced sits at the heart
|
||
return familyOf(key) === "body" ? "root" : "heart";
|
||
}
|
||
|
||
function chakraGeom() {
|
||
const top = HEADER_H + 74;
|
||
const bot = H - FOOTER_H - 60;
|
||
return { cx: W * 0.42, top, bot, gap: (bot - top) / (CHAKRAS.length - 1) };
|
||
}
|
||
|
||
const CHAKRA_FOV = 900;
|
||
function updateChakra(t, dt) {
|
||
if (!chakraDrag) { // inertia glide + slow idle spin
|
||
chakraSpin.vel *= Math.pow(0.02, dt);
|
||
chakraSpin.rot += chakraSpin.vel * dt + dt * 0.12;
|
||
}
|
||
const g = chakraGeom();
|
||
for (const c of CHAKRAS) { c.members = []; c.energy = 0; }
|
||
for (const n of sources.values()) {
|
||
if (!n.chakra) n.chakra = chakraOf(n.key);
|
||
const c = CHAKRA_BY_KEY[n.chakra];
|
||
c.members.push(n); c.energy += n.normDisp;
|
||
}
|
||
for (const c of CHAKRAS) {
|
||
c.energy = c.members.length ? c.energy / c.members.length : 0;
|
||
c.members.sort((a, b) => a.key < b.key ? -1 : 1); // stable seats
|
||
}
|
||
if (mindChakra) { updateMind(t, dt); return; }
|
||
CHAKRAS.forEach((c, i) => {
|
||
c.cy = g.top + g.gap * i;
|
||
const m = c.members.length;
|
||
const dir = i % 2 ? -1 : 1; // adjacent wheels counter-rotate
|
||
for (let j = 0; j < m; j++) {
|
||
const n = c.members[j];
|
||
let R = Math.min(300, W * 0.24, 130 + m * 12);
|
||
if (fxLevel > 2) R *= 1 + 0.06 * Math.sin(t * 0.7 + j * 1.7); // ultra: orbits breathe -> spirals
|
||
const ang = chakraSpin.rot * dir + (j / m) * Math.PI * 2;
|
||
const x3 = Math.cos(ang) * R, z3 = Math.sin(ang) * R; // z>0 is far
|
||
const s = CHAKRA_FOV / (CHAKRA_FOV + z3);
|
||
n.x = g.cx + x3 * s;
|
||
n.y = c.cy + z3 * Math.sin(chakraSpin.tilt) * s;
|
||
n.targetY = n.y; // keep the smoother from fighting us
|
||
n._z = z3; n._s = s;
|
||
pushTrail(n);
|
||
}
|
||
});
|
||
}
|
||
|
||
function pushTrail(n) { // ultra only: comet tails behind every orbit
|
||
if (fxLevel < 3) { n.trail = null; return; }
|
||
const tr = n.trail || (n.trail = []);
|
||
const L = tr.length;
|
||
if (L >= 2) {
|
||
const dx = n.x - tr[L - 2], dy = n.y - tr[L - 1];
|
||
if (dx * dx + dy * dy < 16) return; // sample by distance — slow spins still leave arcs
|
||
}
|
||
tr.push(n.x, n.y);
|
||
if (tr.length > 56) tr.splice(0, tr.length - 56);
|
||
}
|
||
|
||
// inside a wheel: you are the axis. The members circle you; drag to look.
|
||
function updateMind(t, dt) {
|
||
const c = mindChakra;
|
||
const yaw = chakraSpin.rot;
|
||
const horizon = H * 0.46 + (chakraSpin.tilt - 0.4) * H * 0.5;
|
||
for (const n of sources.values()) // everything not of this wheel waits outside
|
||
if (n.chakra !== c.key) { n.x = -9999; n.y = -9999; n._z = 0; n._s = 1; n.targetY = n.y; }
|
||
const dir = CHAKRAS.indexOf(c) % 2 ? -1 : 1;
|
||
const m = c.members.length;
|
||
for (let j = 0; j < m; j++) {
|
||
const n = c.members[j];
|
||
const ang = t * 0.07 * dir + (j / m) * Math.PI * 2; // the world turns around you
|
||
const phi = ang - yaw;
|
||
const d = (1 + Math.cos(phi)) / 2; // 1 = dead ahead, 0 = behind you
|
||
if (d < 0.15) { // behind your head — parked
|
||
n.x = -9999; n.y = -9999; n._z = 0; n._s = 1; n.targetY = n.y; n.trail = null;
|
||
continue;
|
||
}
|
||
const s = 0.45 + d * 1.05;
|
||
n.x = W / 2 + Math.sin(phi) * W * 0.42;
|
||
n.y = horizon + (((j * 37) % 5) - 2) * 52 * s * 0.5 + (1 - d) * 30;
|
||
n.targetY = n.y;
|
||
n._z = -d; // painter: far (small d) first
|
||
n._s = s;
|
||
pushTrail(n);
|
||
}
|
||
for (let i = 0; i < 12; i++)
|
||
if (mindKeys.flash[i] > 0) mindKeys.flash[i] = Math.max(0, mindKeys.flash[i] - dt * 3.2);
|
||
}
|
||
|
||
function drawChakraView(t) {
|
||
if (mindChakra) { drawMindView(t); return; }
|
||
const g = chakraGeom();
|
||
drawTrails();
|
||
// painter's order: far satellites, then the spine, then near satellites
|
||
const list = Array.from(sources.values()).sort((a, b) => (b._z || 0) - (a._z || 0));
|
||
let i = 0;
|
||
for (; i < list.length && (list[i]._z || 0) > 0; i++) drawChakraNode(list[i], t);
|
||
drawChakraSpine(t, g);
|
||
for (; i < list.length; i++) drawChakraNode(list[i], t);
|
||
}
|
||
|
||
function drawTrails() { // ultra: three fading chunks per comet tail
|
||
if (fxLevel < 3) return;
|
||
ctx.lineCap = "round";
|
||
for (const n of sources.values()) {
|
||
const tr = n.trail;
|
||
if (!tr || tr.length < 8) continue;
|
||
const pts = tr.length / 2;
|
||
for (let k = 0; k < 3; k++) {
|
||
const a0 = Math.floor(pts * k / 3), a1 = Math.min(pts - 1, Math.floor(pts * (k + 1) / 3));
|
||
if (a1 - a0 < 1) continue;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tr[a0 * 2], tr[a0 * 2 + 1]);
|
||
for (let i = a0 + 1; i <= a1; i++) ctx.lineTo(tr[i * 2], tr[i * 2 + 1]);
|
||
const f = (k + 1) / 3;
|
||
ctx.lineWidth = 1 + f * 3;
|
||
ctx.strokeStyle = rgba(n.col, 0.07 + f * 0.28);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawMindView(t) {
|
||
const c = mindChakra;
|
||
// the wheel's inner light, breathing with its members' data
|
||
drawHalo(W / 2, H * 0.46, Math.max(W, H) * 0.75, c.col, 0.10 + (c.energy || 0) * 0.22);
|
||
// the mandala — this wheel's petals, wheeling around your whole field of view
|
||
const pr = Math.min(W, H) * 0.55;
|
||
for (let p = 0; p < c.petals; p++) {
|
||
const a = t * 0.05 + (p / c.petals) * Math.PI * 2;
|
||
drawHalo(W / 2 + Math.cos(a) * pr, H * 0.46 + Math.sin(a) * pr * 0.72, 26, c.col, 0.22 + (c.energy || 0) * 0.3);
|
||
}
|
||
drawTrails();
|
||
const list = c.members.filter(n => n.x > -9000).sort((a, b) => (b._z || 0) - (a._z || 0));
|
||
for (const n of list) drawChakraNode(n, t);
|
||
drawMindKeys(t);
|
||
const cs = CHAKRA_SCALES[c.key];
|
||
ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(c.col, 0.6), 0.9);
|
||
ctx.fillText("inside " + c.label + " · " + cs.scale.replace("_", " ") + " on " + midiToName(cs.root), W / 2, HEADER_H + 26);
|
||
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,170,200,0.6)";
|
||
ctx.fillText("drag to look around · click the console or Q–] to play · MIDI notes play too · ↑ ↓ travel the spine · esc steps out", W / 2, HEADER_H + 44);
|
||
}
|
||
|
||
function drawMindKeys(t) { // the vector console — 12 notes of this wheel's scale
|
||
const gk = mindKeyGeom(), c = mindChakra;
|
||
for (let i = 0; i < 12; i++) {
|
||
const x = gk.x0 + i * gk.kw, f = mindKeys.flash[i];
|
||
const lean = (i - 5.5) * 4; // fan toward the vanishing point
|
||
const x0 = x + 3, x1 = x + gk.kw - 3;
|
||
const tx0 = x0 + gk.kw * 0.14 + lean, tx1 = x1 - gk.kw * 0.14 + lean;
|
||
const yt = gk.yb - gk.kh + Math.abs(i - 5.5) * 3.2; // slight rise at the edges
|
||
ctx.beginPath();
|
||
ctx.moveTo(x0, gk.yb); ctx.lineTo(x1, gk.yb); ctx.lineTo(tx1, yt); ctx.lineTo(tx0, yt);
|
||
ctx.closePath();
|
||
ctx.fillStyle = rgba(c.col, 0.05 + f * 0.30);
|
||
ctx.fill();
|
||
ctx.lineWidth = f > 0.02 ? 1.6 : 1;
|
||
ctx.strokeStyle = rgba(rgbMix(c.col, 0.35 + f * 0.5), 0.5 + f * 0.5);
|
||
ctx.stroke();
|
||
for (let r2 = 1; r2 <= 2; r2++) { // vector grid rungs
|
||
const yy = gk.yb + (yt - gk.yb) * r2 / 3;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x0 + (tx0 - x0) * r2 / 3, yy);
|
||
ctx.lineTo(x1 + (tx1 - x1) * r2 / 3, yy);
|
||
ctx.lineWidth = 0.6;
|
||
ctx.strokeStyle = rgba(c.col, 0.18 + f * 0.3);
|
||
ctx.stroke();
|
||
}
|
||
if (f > 0.02) drawHalo((x0 + x1) / 2, (gk.yb + yt) / 2, gk.kw, c.col, f * 0.5);
|
||
ctx.textAlign = "center";
|
||
ctx.font = "600 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(c.col, 0.5), 0.55 + f * 0.45);
|
||
ctx.fillText(midiToName(mindKeys.notes[i]), x + gk.kw / 2, gk.yb - 8);
|
||
ctx.font = "500 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,170,200,0.45)";
|
||
ctx.fillText(MIND_KEYROW[i].toUpperCase(), x + gk.kw / 2, gk.yb + 12);
|
||
}
|
||
}
|
||
|
||
function drawChakraNode(n, t) {
|
||
const s = n._s || 1;
|
||
ctx.save();
|
||
ctx.globalAlpha = clamp(0.30 + (s - 0.75) * 1.6, 0.28, 1); // depth cueing
|
||
ctx.translate(n.x, n.y); ctx.scale(s, s); ctx.translate(-n.x, -n.y);
|
||
drawSourceNode(n, t);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawChakraSpine(t, g) {
|
||
// the sushumna — a column of light, pulsing on the godtime beat
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(215,228,255,0.30)";
|
||
ctx.lineWidth = 3 + godtime.beatFlash * 2.5;
|
||
if (fxLevel > 1) {
|
||
ctx.shadowColor = "rgba(170,195,255,0.8)";
|
||
ctx.shadowBlur = 18;
|
||
}
|
||
ctx.beginPath(); ctx.moveTo(g.cx, g.top - 46); ctx.lineTo(g.cx, g.bot + 36); ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
// rising prana
|
||
for (let k = 0; k < 7; k++) {
|
||
const p = (t * 0.11 + k / 7) % 1;
|
||
ctx.beginPath();
|
||
ctx.arc(g.cx, lerp(g.bot + 30, g.top - 40, p), 2.2, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(230,240,255," + (0.55 * Math.sin(p * Math.PI)).toFixed(3) + ")";
|
||
ctx.fill();
|
||
}
|
||
|
||
CHAKRAS.forEach((c, i) => {
|
||
const e = c.energy || 0;
|
||
const r = 15 + e * 10; // the wheel breathes with its members' data
|
||
|
||
drawHalo(g.cx, c.cy, 66 + e * 60, c.col, 0.18 + e * 0.35);
|
||
|
||
// spinning petals, alternating direction wheel to wheel
|
||
const dir = i % 2 ? -1 : 1;
|
||
for (let p = 0; p < c.petals; p++) {
|
||
const a = chakraSpin.rot * dir * 1.4 + (p / c.petals) * Math.PI * 2;
|
||
ctx.beginPath();
|
||
ctx.arc(g.cx + Math.cos(a) * (r + 9), c.cy + Math.sin(a) * (r + 9), 1.8, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(rgbMix(c.col, 0.35), 0.55 + e * 0.4);
|
||
ctx.fill();
|
||
}
|
||
|
||
const cg = ctx.createRadialGradient(g.cx - 3, c.cy - 3, 1, g.cx, c.cy, r);
|
||
cg.addColorStop(0, rgba(rgbMix(c.col, 0.6), 0.95));
|
||
cg.addColorStop(1, rgba(c.col, 0.45));
|
||
ctx.beginPath(); ctx.arc(g.cx, c.cy, r, 0, Math.PI * 2);
|
||
ctx.fillStyle = cg; ctx.fill();
|
||
ctx.lineWidth = 1.2;
|
||
ctx.strokeStyle = rgba(rgbMix(c.col, 0.5), 0.8);
|
||
ctx.stroke();
|
||
|
||
ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(c.col, 0.55), 0.8);
|
||
ctx.fillText(c.label + (c.members.length ? " · " + c.members.length : ""), g.cx, c.cy + r + 16);
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shockwaves & particles
|
||
// ---------------------------------------------------------------------------
|
||
function drawShockwaves(t, dt) {
|
||
for (let i = shockwaves.length - 1; i >= 0; i--) {
|
||
const s = shockwaves[i];
|
||
const age = t - s.born;
|
||
const p = clamp(age / 1.1, 0, 1);
|
||
s.r = lerp(8, s.maxR, easeOut(p));
|
||
const a = (1 - p) * 0.6;
|
||
if (p >= 1) { shockwaves.splice(i, 1); continue; }
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||
ctx.lineWidth = 2 * (1 - p) + 0.5;
|
||
ctx.strokeStyle = rgba(rgbMix(s.col, 0.3), a);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawParticles(dt) {
|
||
for (let i = particles.length - 1; i >= 0; i--) {
|
||
const p = particles[i];
|
||
p.life -= p.decay * dt;
|
||
if (p.life <= 0) { particles.splice(i, 1); continue; }
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.vx *= (1 - 2.2 * dt);
|
||
p.vy *= (1 - 2.2 * dt);
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(rgbMix(p.col, 0.4), clamp(p.life, 0, 1) * 0.8);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Editing overlay — rubber-band cable while dragging a patch
|
||
// ---------------------------------------------------------------------------
|
||
function drawEditing(t) {
|
||
if (gainDrag && gainDrag.moved) { // ⇧-drag level readout
|
||
const n = gainDrag.node;
|
||
ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(255,238,150,0.92)";
|
||
ctx.fillText("level " + tw(n.key).gain.toFixed(2) + "×", n.x, n.y - NODE_R - 16);
|
||
}
|
||
if (!drag || !drag.moved) return;
|
||
const s = drag.node;
|
||
ctx.beginPath();
|
||
ctx.moveTo(s.x, s.y);
|
||
ctx.lineTo(drag.cx, drag.cy);
|
||
ctx.setLineDash([4, 5]); ctx.lineDashOffset = -t * 22;
|
||
ctx.strokeStyle = "rgba(180,212,255,0.85)"; ctx.lineWidth = 2;
|
||
ctx.stroke(); ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.arc(drag.cx, drag.cy, 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(205,228,255,0.95)"; ctx.fill();
|
||
if (hoverDest) {
|
||
ctx.beginPath();
|
||
ctx.arc(hoverDest.x, hoverDest.y, NODE_R + 8, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(140,255,180,0.95)"; ctx.lineWidth = 2.5; ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Header
|
||
// ---------------------------------------------------------------------------
|
||
function drawHeader(t) {
|
||
// wordmark
|
||
ctx.textAlign = "left";
|
||
ctx.textBaseline = "alphabetic";
|
||
const wm = "GODSTRUMENT";
|
||
ctx.font = "800 30px 'Helvetica Neue', Arial, sans-serif";
|
||
const grad = ctx.createLinearGradient(34, 0, 34 + 360, 0);
|
||
grad.addColorStop(0, "#9fd0ff");
|
||
grad.addColorStop(0.5, "#f6c8ff");
|
||
grad.addColorStop(1, "#ffd98a");
|
||
ctx.fillStyle = grad;
|
||
// subtle letter spacing
|
||
drawSpacedText(wm, 34, 50, 4.5, ctx.font);
|
||
|
||
ctx.font = "500 12px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,170,200,0.65)";
|
||
ctx.fillText("an instrument played by the world", 36, 70);
|
||
|
||
// (the connected status phrase now lives under the godtime dial; the
|
||
// disconnected state is drawn by drawConnecting)
|
||
|
||
// counts (right, kept clear of the ⚙ gear button)
|
||
ctx.textAlign = "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);
|
||
if (lastServerT) {
|
||
ctx.fillStyle = "rgba(110,130,160,0.5)";
|
||
ctx.fillText("t+" + lastServerT.toFixed(1) + "s", W - 58, 50);
|
||
}
|
||
|
||
// header underline glow
|
||
const lg = ctx.createLinearGradient(0, 0, W, 0);
|
||
lg.addColorStop(0, "rgba(90,140,255,0)");
|
||
lg.addColorStop(0.5, "rgba(140,180,255,0.25)");
|
||
lg.addColorStop(1, "rgba(255,180,120,0)");
|
||
ctx.fillStyle = lg;
|
||
ctx.fillRect(0, HEADER_H - 1, W, 1);
|
||
}
|
||
|
||
function statusPhrase() {
|
||
// pick liveliest source family
|
||
let best = null, bestV = -1;
|
||
for (const n of sources.values()) {
|
||
if (n.normDisp > bestV) { bestV = n.normDisp; best = n; }
|
||
}
|
||
if (best && bestV > 0.5) {
|
||
const fam = FAMILIES[best.family];
|
||
return "the " + fam.name + " is playing";
|
||
}
|
||
return "the planet is playing";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// godtime dial — a wide celestial arc across the top. The real planets ride
|
||
// it at their live zodiac longitudes; drag left/right to slow/speed the world.
|
||
// ---------------------------------------------------------------------------
|
||
function godBand() {
|
||
const w = Math.min(W * 0.46, 520);
|
||
const cx = W / 2, apexY = 28, depth = 20;
|
||
return { cx, w, left: cx - w / 2, right: cx + w / 2, apexY, depth, hitTop: 6, hitBot: 94 };
|
||
}
|
||
function godArcY(x, b) {
|
||
const nx = clamp((x - b.left) / b.w, 0, 1);
|
||
return b.apexY + b.depth * (1 - Math.sin(nx * Math.PI)); // dome: high at centre
|
||
}
|
||
function inGodBand(mx, my) {
|
||
const b = godBand();
|
||
return mx >= b.left - 6 && mx <= b.right + 6 && my >= b.hitTop && my <= b.hitBot;
|
||
}
|
||
|
||
function drawGodtime(t) {
|
||
const b = godBand();
|
||
ctx.save();
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
// the arc
|
||
ctx.beginPath();
|
||
for (let i = 0; i <= 64; i++) {
|
||
const x = b.left + b.w * i / 64, y = godArcY(x, b);
|
||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||
}
|
||
ctx.strokeStyle = "rgba(150,180,235,0.22)"; ctx.lineWidth = 1; ctx.stroke();
|
||
// zodiac runes as ticks
|
||
ctx.font = "11px 'Helvetica Neue', Arial, sans-serif";
|
||
for (let i = 0; i < 12; i++) {
|
||
const x = b.left + b.w * (i + 0.5) / 12, y = godArcY(x, b);
|
||
ctx.fillStyle = "rgba(150,170,205,0.28)";
|
||
ctx.fillText(GT_ZODIAC[i], x, y - 11);
|
||
}
|
||
// real planets, placed by their live longitude
|
||
for (const [k, g] of GT_PLANETS) {
|
||
const n = sources.get(k); if (!n) continue;
|
||
const raw = n.rawDisp != null ? n.rawDisp : n.raw;
|
||
const lon = ((raw % 360) + 360) % 360;
|
||
const x = b.left + b.w * (lon / 360), y = godArcY(x, b);
|
||
ctx.font = "14px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,190,255,0.35)";
|
||
ctx.fillText(g, x, y + 0.5); // soft halo
|
||
ctx.fillStyle = "rgba(224,234,255,0.95)";
|
||
ctx.fillText(g, x, y);
|
||
}
|
||
// beat pulse at the apex
|
||
const px = b.cx, py = b.apexY - 2;
|
||
if (godtime.beatFlash > 0) {
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, 8 + (1 - godtime.beatFlash) * 22, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(150,210,255," + (godtime.beatFlash * 0.5).toFixed(3) + ")";
|
||
ctx.lineWidth = 2; ctx.stroke();
|
||
}
|
||
// BPM readout + label + status
|
||
ctx.fillStyle = "rgba(205,222,255,0.96)";
|
||
ctx.font = "700 22px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText(Math.round(godtime.bpm), b.cx, b.apexY + b.depth + 26);
|
||
ctx.font = "600 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,170,205,0.6)";
|
||
ctx.fillText("GODTIME · BPM" + (godtime.locked ? " · LOCKED" : " · drag"), b.cx, b.apexY + b.depth + 42);
|
||
ctx.font = "italic 500 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(120,200,175,0.7)";
|
||
ctx.fillText(statusPhrase(), b.cx, b.apexY + b.depth + 58);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawSpacedText(str, x, y, spacing, font) {
|
||
ctx.save();
|
||
ctx.font = font;
|
||
let cx = x;
|
||
for (const ch of str) {
|
||
ctx.fillText(ch, cx, y);
|
||
cx += ctx.measureText(ch).width + spacing;
|
||
}
|
||
ctx.restore();
|
||
}
|
||
function measureSpaced(str, font) {
|
||
ctx.save();
|
||
ctx.font = font;
|
||
const w = ctx.measureText(str).width;
|
||
ctx.restore();
|
||
return w;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Footer ticker
|
||
// ---------------------------------------------------------------------------
|
||
let tickerOffset = 0;
|
||
function drawFooter(t) {
|
||
const y = H - FOOTER_H / 2;
|
||
// bar
|
||
ctx.fillStyle = "rgba(6,10,18,0.72)";
|
||
ctx.fillRect(0, H - FOOTER_H, W, FOOTER_H);
|
||
const tg = ctx.createLinearGradient(0, H - FOOTER_H, 0, H);
|
||
tg.addColorStop(0, "rgba(120,160,255,0.12)");
|
||
tg.addColorStop(1, "rgba(120,160,255,0)");
|
||
ctx.fillStyle = tg;
|
||
ctx.fillRect(0, H - FOOTER_H, W, 2);
|
||
|
||
ctx.textBaseline = "middle";
|
||
ctx.textAlign = "left";
|
||
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(140,160,190,0.55)";
|
||
ctx.fillText("RECENT EVENTS", 20, y);
|
||
|
||
const startX = 150;
|
||
// build scrolling string entries
|
||
ctx.font = "500 12px 'Helvetica Neue', Arial, sans-serif";
|
||
let x = startX - tickerOffset;
|
||
const spacing = 46;
|
||
let totalW = 0;
|
||
const segs = [];
|
||
for (const e of eventTicker) {
|
||
const ago = Math.max(0, Math.floor(now() - e.tAdded));
|
||
const label = e.text + " · " + ago + "s ago";
|
||
const w = ctx.measureText(label).width;
|
||
segs.push({ label, w, col: colForEventSrc(e.src) });
|
||
totalW += w + spacing;
|
||
}
|
||
if (totalW < 10) totalW = W; // avoid div issues
|
||
|
||
for (const seg of segs) {
|
||
if (x + seg.w > startX - 40 && x < W + 40) {
|
||
// fade near left edge
|
||
let alpha = 1;
|
||
if (x < startX + 30) alpha = clamp((x - startX + 10) / 40, 0, 1);
|
||
ctx.fillStyle = rgba(seg.col, 0.85 * alpha);
|
||
ctx.beginPath();
|
||
ctx.arc(x - 8, y, 2.2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = "rgba(200,215,240," + (0.85 * alpha).toFixed(3) + ")";
|
||
ctx.fillText(seg.label, x, y);
|
||
}
|
||
x += seg.w + spacing;
|
||
}
|
||
|
||
// advance scroll; loop
|
||
tickerOffset += 0.7;
|
||
const loopW = totalW;
|
||
if (tickerOffset > loopW && segs.length) tickerOffset -= loopW;
|
||
if (!segs.length) {
|
||
tickerOffset = 0;
|
||
ctx.fillStyle = "rgba(120,140,170,0.4)";
|
||
ctx.fillText("listening for the world…", startX, y);
|
||
}
|
||
|
||
// clip mask left of label
|
||
ctx.fillStyle = "rgba(6,10,18,0.9)";
|
||
ctx.fillRect(0, H - FOOTER_H, startX - 6, FOOTER_H);
|
||
ctx.fillStyle = "rgba(140,160,190,0.55)";
|
||
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText("RECENT EVENTS", 20, y);
|
||
}
|
||
|
||
function colForEventSrc(src) {
|
||
const node = findSourceForEvent(src);
|
||
if (node) return node.col;
|
||
const fam = familyOf(src || "");
|
||
return FAMILIES[fam].col;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Connecting overlay
|
||
// ---------------------------------------------------------------------------
|
||
function drawConnecting(t) {
|
||
if (sources.size > 0) return; // only full overlay before any data
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.font = "500 15px 'Helvetica Neue', Arial, sans-serif";
|
||
const a = 0.4 + 0.4 * Math.sin(t * 2.2);
|
||
ctx.fillStyle = "rgba(160,190,230," + a.toFixed(3) + ")";
|
||
ctx.fillText("connecting to the hub…", W / 2, H / 2);
|
||
// spinner
|
||
const r = 22;
|
||
ctx.beginPath();
|
||
ctx.arc(W / 2, H / 2 - 44, r, t * 2, t * 2 + Math.PI * 1.4);
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeStyle = "rgba(150,200,255,0.7)";
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interaction + Web MIDI — click-to-mute, ⚙ panel, MIDI out + learn
|
||
// ---------------------------------------------------------------------------
|
||
const panelEl = document.getElementById("panel");
|
||
const gearEl = document.getElementById("gear");
|
||
gearEl.addEventListener("click", () => panelEl.classList.toggle("open"));
|
||
|
||
function sendCmd(obj) {
|
||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
|
||
}
|
||
|
||
function nodeAt(mx, my) {
|
||
let hit = null, best = (NODE_R + 12) * (NODE_R + 12);
|
||
for (const n of sources.values()) {
|
||
const dx = mx - n.x, dy = my - n.y, d2 = dx * dx + dy * dy;
|
||
if (d2 < best) { best = d2; hit = n; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
// ---- Web MIDI state ----
|
||
let midiAccess = null, midiOut = null, midiIn = null;
|
||
let midiMap = {}; // dest -> {cc|note, channel}, from the hub hello payload
|
||
let learnMode = false, learnTarget = null;
|
||
let patchList = [], templateSelect = null;
|
||
let performMode = false, dispatchIdx = 0, dispatchT = 0;
|
||
const midiBindings = loadBindings(); // "cc:17" / "note:36" -> {target,param,mode}
|
||
const lastCCSent = {}, lastNoteFor = {};
|
||
let outSel = null, inSel = null, midiStatusEl = null, learnBtn = null, bindCountEl = null;
|
||
|
||
function loadBindings() {
|
||
try { return JSON.parse(localStorage.getItem("gs_midi_bindings") || "{}"); }
|
||
catch (e) { return {}; }
|
||
}
|
||
function saveBindings() {
|
||
try { localStorage.setItem("gs_midi_bindings", JSON.stringify(midiBindings)); } catch (e) {}
|
||
if (bindCountEl) bindCountEl.textContent = Object.keys(midiBindings).length + " mapped";
|
||
}
|
||
function armLearn(target) {
|
||
if (!learnMode) return false;
|
||
learnTarget = target;
|
||
if (midiStatusEl) midiStatusEl.textContent = "learning " + target.target + " · " + target.param + " — now move a knob…";
|
||
return true;
|
||
}
|
||
|
||
// ---- editing: drag-to-patch, shift-inspect, cmd-select, click-mute ----
|
||
const inspectEl = document.getElementById("inspect");
|
||
const selection = new Set();
|
||
let drag = null; // {node, x0,y0, cx,cy, moved}
|
||
let godDrag = null; // {x0, bpm0, moved} — dragging the godtime dial
|
||
let gainDrag = null; // {node, y0, gain0, moved} — ⇧-drag rides a level
|
||
let hoverDest = null;
|
||
|
||
function destAt(mx, my) {
|
||
let hit = null, best = (NODE_R + 12) * (NODE_R + 12);
|
||
for (const n of dests.values()) {
|
||
const dx = mx - n.x, dy = my - n.y, d2 = dx * dx + dy * dy;
|
||
if (d2 < best) { best = d2; hit = n; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
canvas.addEventListener("mousedown", (ev) => {
|
||
if (ev.button !== 0) return; // right button belongs to the menu
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
if (inGodBand(mx, my)) { godDrag = { x0: mx, bpm0: godtime.bpm, moved: false }; return; }
|
||
const s = nodeAt(mx, my) || destAt(mx, my); // sources AND dests are clickable
|
||
if (s && ev.shiftKey) { // ⇧-drag = ride this node's level
|
||
gainDrag = { node: s, y0: my, gain0: tw(s.key).gain, moved: false };
|
||
} else if (s) {
|
||
drag = { node: s, x0: mx, y0: my, cx: mx, cy: my, moved: false };
|
||
} else if (chakraMode) {
|
||
if (mindChakra) { // strike a key on the vector console
|
||
const gk = mindKeyGeom();
|
||
if (my > gk.yb - gk.kh - 6 && my < gk.yb + 4 && mx >= gk.x0 && mx < gk.x0 + gk.kw * 12) {
|
||
mindStrike(Math.floor((mx - gk.x0) / gk.kw));
|
||
return;
|
||
}
|
||
} else { // click a wheel = step inside it
|
||
const g = chakraGeom();
|
||
for (const c of CHAKRAS)
|
||
if (Math.hypot(mx - g.cx, my - c.cy) < 24) { enterMind(c); return; }
|
||
}
|
||
chakraSpin.vel = 0; // empty space = grab / look around
|
||
chakraDrag = { x0: mx, y0: my, rot0: chakraSpin.rot, tilt0: chakraSpin.tilt, lastX: mx };
|
||
}
|
||
});
|
||
window.addEventListener("mousemove", (ev) => {
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
if (chakraDrag) { // spin / tilt the subtle body
|
||
chakraSpin.rot = chakraDrag.rot0 + (mx - chakraDrag.x0) * 0.008;
|
||
chakraSpin.tilt = clamp(chakraDrag.tilt0 + (my - chakraDrag.y0) * 0.004, 0.06, 0.95);
|
||
chakraSpin.vel = (mx - chakraDrag.lastX) * 0.48; // flick momentum, rad/s-ish
|
||
chakraDrag.lastX = mx;
|
||
canvas.style.cursor = "grabbing";
|
||
} else if (gainDrag) { // ⇧-drag: up louder, down quieter
|
||
const dy = gainDrag.y0 - my;
|
||
if (Math.abs(dy) > 3) gainDrag.moved = true;
|
||
tw(gainDrag.node.key).gain = clamp(gainDrag.gain0 + dy * 0.01, 0, 2);
|
||
canvas.style.cursor = "ns-resize";
|
||
} else if (godDrag) {
|
||
const dx = mx - godDrag.x0;
|
||
if (Math.abs(dx) > 3) godDrag.moved = true;
|
||
godtime.bpm = clamp(godDrag.bpm0 + dx * 0.22, 40, 180);
|
||
canvas.style.cursor = "ew-resize";
|
||
} else if (drag) {
|
||
drag.cx = mx; drag.cy = my;
|
||
if (Math.hypot(mx - drag.x0, my - drag.y0) > 6) drag.moved = true;
|
||
hoverDest = drag.moved ? destAt(mx, my) : null;
|
||
} else {
|
||
canvas.style.cursor = inGodBand(mx, my) ? "ew-resize"
|
||
: (nodeAt(mx, my) || destAt(mx, my)) ? "pointer"
|
||
: chakraMode ? "grab" : "default";
|
||
}
|
||
});
|
||
window.addEventListener("mouseup", (ev) => {
|
||
if (chakraDrag) { chakraDrag = null; return; } // let go — momentum carries it
|
||
if (gainDrag) { // release the level ride
|
||
const gd = gainDrag; gainDrag = null;
|
||
if (!gd.moved) {
|
||
if (ev.metaKey || ev.ctrlKey) resetTweaks(gd.node.key); // ⌘⇧-click = back to neutral
|
||
else openInspector(gd.node); // plain ⇧-click = the tab
|
||
}
|
||
return;
|
||
}
|
||
if (godDrag) { // release the tempo dial
|
||
const gd = godDrag; godDrag = null;
|
||
if (!gd.moved) godtime.locked = !godtime.locked; // a click toggles lock
|
||
return;
|
||
}
|
||
if (!drag) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
const d = drag; drag = null; hoverDest = null;
|
||
const isSrc = d.node.kind === "source";
|
||
if (d.moved) { // drag source -> dest = new cable
|
||
if (isSrc) { const dest = destAt(mx, my); if (dest) addRouteLocal(d.node.key, dest.key, 0.5); }
|
||
} else if (ev.shiftKey) { // shift-click -> the node's tab
|
||
openInspector(d.node);
|
||
} else if (isSrc && (ev.metaKey || ev.ctrlKey)) { // cmd/ctrl-click -> multi-select (group)
|
||
toggleSelect(d.node.key);
|
||
} else if (ev.altKey && isSrc) { // ⌥-click -> solo this source
|
||
soloKey = soloKey === d.node.key ? null : d.node.key;
|
||
} else if (isSrc && armLearn({ target: d.node.key, param: "mute", mode: "toggle" })) {
|
||
/* armed for MIDI learn */
|
||
} else { // plain click -> toggle on/off
|
||
toggleMuteLocal(d.node.key);
|
||
}
|
||
});
|
||
|
||
function toggleSelect(key) {
|
||
if (selection.has(key)) selection.delete(key); else selection.add(key);
|
||
updateGroupBtn();
|
||
}
|
||
|
||
function resetTweaks(key) { // back to neutral: level 1×, no trim, unmuted
|
||
delete tweaks[key];
|
||
if (soloKey === key) soloKey = null;
|
||
}
|
||
|
||
// performance mode — clean projection view for the audience (press P)
|
||
window.addEventListener("keydown", (ev) => {
|
||
const tag = (ev.target && ev.target.tagName) || "";
|
||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||
if (mindChakra) { // inside a wheel, the row plays
|
||
const ki = MIND_KEYROW.indexOf(ev.key.toLowerCase());
|
||
if (ki >= 0 && !ev.metaKey && !ev.ctrlKey && !ev.altKey) {
|
||
if (!ev.repeat) mindStrike(ki);
|
||
return;
|
||
}
|
||
if (ev.key === "Escape") { exitMind(); return; }
|
||
if (ev.key === "ArrowUp" || ev.key === "ArrowDown") {
|
||
const i2 = clamp(CHAKRAS.indexOf(mindChakra) + (ev.key === "ArrowUp" ? -1 : 1),
|
||
0, CHAKRAS.length - 1);
|
||
enterMind(CHAKRAS[i2]);
|
||
return;
|
||
}
|
||
}
|
||
if (ev.key === "p" || ev.key === "P") {
|
||
performMode = !performMode;
|
||
if (performMode) {
|
||
gearEl.style.display = "none"; panelEl.style.display = "none";
|
||
inspectEl.style.display = "none";
|
||
} else {
|
||
gearEl.style.display = ""; panelEl.style.display = ""; inspectEl.style.display = "";
|
||
}
|
||
}
|
||
if (ev.key === "c" || ev.key === "C") {
|
||
chakraMode = !chakraMode;
|
||
if (!chakraMode) { layoutDirty = true; exitMind(); } // sources glide home
|
||
else clearTrails();
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Right-click — the everything menu. Every node (and the empty sky) carries
|
||
// its full settings: mute/solo, level & trim, every cable with its amount,
|
||
// patch-to, inspect, reset. People use things differently; right-click is
|
||
// being nice.
|
||
// ---------------------------------------------------------------------------
|
||
const ctxEl = document.createElement("div");
|
||
ctxEl.id = "ctxmenu";
|
||
document.body.appendChild(ctxEl);
|
||
function closeCtx() { ctxEl.classList.remove("open"); ctxEl.innerHTML = ""; }
|
||
window.addEventListener("mousedown", (e) => { if (!ctxEl.contains(e.target)) closeCtx(); });
|
||
window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeCtx(); });
|
||
|
||
function ctxItem(label, fn, kbd) {
|
||
const it = document.createElement("div"); it.className = "it";
|
||
it.textContent = label;
|
||
if (kbd) { const k = document.createElement("span"); k.className = "k"; k.textContent = kbd; it.appendChild(k); }
|
||
it.onclick = () => { fn(); closeCtx(); };
|
||
ctxEl.appendChild(it);
|
||
}
|
||
function ctxSlider(label, val, min, max, oninput, fmt) {
|
||
fmt = fmt || (x => x.toFixed(2));
|
||
const row = document.createElement("div"); row.className = "sl";
|
||
const l = document.createElement("label"); l.textContent = label;
|
||
const s = document.createElement("input");
|
||
s.type = "range"; s.min = min; s.max = max; s.step = "0.01"; s.value = val;
|
||
const v = document.createElement("span"); v.className = "v"; v.textContent = fmt(parseFloat(s.value));
|
||
s.oninput = () => { const x = parseFloat(s.value); oninput(x); v.textContent = fmt(x); };
|
||
row.appendChild(l); row.appendChild(s); row.appendChild(v);
|
||
ctxEl.appendChild(row);
|
||
}
|
||
function ctxSub(label) {
|
||
const d = document.createElement("details");
|
||
const su = document.createElement("summary"); su.textContent = label;
|
||
const body = document.createElement("div"); body.className = "body";
|
||
d.appendChild(su); d.appendChild(body);
|
||
ctxEl.appendChild(d);
|
||
return body;
|
||
}
|
||
function ctxSep() { const s = document.createElement("div"); s.className = "sep"; ctxEl.appendChild(s); }
|
||
|
||
function openCtxMenu(node, px, py) {
|
||
ctxEl.innerHTML = "";
|
||
if (node) {
|
||
const isSrc = node.kind === "source";
|
||
const t0 = tw(node.key);
|
||
const hd = document.createElement("div"); hd.className = "hd";
|
||
const b = document.createElement("b"); b.textContent = node.label || node.key;
|
||
const sp = document.createElement("span");
|
||
sp.textContent = node.key + (isSrc ? " · " + chakraOf(node.key) + " chakra" : " · voice");
|
||
hd.appendChild(b); hd.appendChild(sp); ctxEl.appendChild(hd);
|
||
|
||
ctxItem(node.muted ? "unmute" : "mute", () => toggleMuteLocal(node.key), "click");
|
||
if (isSrc) ctxItem(soloKey === node.key ? "unsolo" : "solo",
|
||
() => { soloKey = soloKey === node.key ? null : node.key; }, "⌥-click");
|
||
ctxSlider("level", t0.gain, 0, 2, x => { t0.gain = x; }, x => x.toFixed(2) + "×");
|
||
ctxSlider("trim", t0.offset, -1, 1, x => { t0.offset = x; });
|
||
ctxSep();
|
||
|
||
// every cable this node carries, each with its amount + unpatch
|
||
const rel = routesArr.filter(rt => isSrc ? rt.source === node.key : rt.dest === node.key);
|
||
const cb = ctxSub((isSrc ? "cables out" : "driven by") + " (" + rel.length + ")");
|
||
if (!rel.length) {
|
||
const e = document.createElement("div"); e.className = "hint";
|
||
e.textContent = "no cables yet"; cb.appendChild(e);
|
||
}
|
||
for (const rt of rel) {
|
||
const row = document.createElement("div"); row.className = "rt";
|
||
const lbl = document.createElement("span"); lbl.className = "lbl";
|
||
lbl.textContent = isSrc ? ("→ " + prettyDest(rt.dest)) : ("← " + rt.source);
|
||
const sl = document.createElement("input");
|
||
sl.type = "range"; sl.min = "0"; sl.max = "1"; sl.step = "0.01";
|
||
sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount";
|
||
sl.oninput = () => setRouteAmountLocal(rt.source, rt.dest, parseFloat(sl.value));
|
||
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch";
|
||
x.onclick = () => { removeRouteLocal(rt.source, rt.dest); row.remove(); };
|
||
row.appendChild(lbl); row.appendChild(sl); row.appendChild(x);
|
||
cb.appendChild(row);
|
||
}
|
||
|
||
// lay a new cable from here
|
||
const pb = ctxSub(isSrc ? "patch to →" : "add driver ←");
|
||
const pool = Array.from((isSrc ? dests : sources).values()).sort((a, b) => a.key < b.key ? -1 : 1);
|
||
for (const other of pool) {
|
||
if (routeIndex.has(isSrc ? node.key + "|" + other.key : other.key + "|" + node.key)) continue;
|
||
const it = document.createElement("div"); it.className = "it";
|
||
it.textContent = other.label || other.key;
|
||
it.onclick = () => {
|
||
if (isSrc) addRouteLocal(node.key, other.key, 0.5);
|
||
else addRouteLocal(other.key, node.key, 0.5);
|
||
closeCtx();
|
||
};
|
||
pb.appendChild(it);
|
||
}
|
||
ctxSep();
|
||
if (isSrc) ctxItem(selection.has(node.key) ? "deselect (group)" : "select (group)",
|
||
() => toggleSelect(node.key), "⌘-click");
|
||
ctxItem("open inspector tab", () => openInspector(node), "⇧-click");
|
||
ctxItem("reset to neutral", () => resetTweaks(node.key), "⌘⇧-click");
|
||
const hint = document.createElement("div"); hint.className = "hint";
|
||
hint.textContent = "drag = patch a cable · ⇧-drag = ride the level";
|
||
ctxEl.appendChild(hint);
|
||
} else { // empty sky -> view menu
|
||
const hd = document.createElement("div"); hd.className = "hd";
|
||
const b = document.createElement("b"); b.textContent = "godstrument";
|
||
const sp = document.createElement("span"); sp.textContent = "view";
|
||
hd.appendChild(b); hd.appendChild(sp); ctxEl.appendChild(hd);
|
||
ctxItem(chakraMode ? "leave chakra view" : "chakra view",
|
||
() => { chakraMode = !chakraMode; if (!chakraMode) layoutDirty = true; }, "C");
|
||
ctxItem(performMode ? "leave performance mode" : "performance mode",
|
||
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "p" })), "P");
|
||
if (mindChakra) ctxItem("step out of " + mindChakra.label, () => exitMind(), "esc");
|
||
const fxLabel = fxMode === "auto" ? "auto (" + (fxLevel > 1 ? "full" : "lite") + ")"
|
||
: fxMode === "ultra" ? "ultra ✦ trails" : fxMode;
|
||
ctxItem("graphics: " + fxLabel,
|
||
() => setFxMode(fxMode === "auto" ? "full" : fxMode === "full" ? "ultra"
|
||
: fxMode === "ultra" ? "lite" : "auto"));
|
||
if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { soloKey = null; });
|
||
}
|
||
ctxEl.classList.add("open");
|
||
ctxEl.style.left = Math.min(px, window.innerWidth - 252) + "px";
|
||
ctxEl.style.top = Math.min(py, Math.max(12, window.innerHeight - ctxEl.offsetHeight - 12)) + "px";
|
||
}
|
||
|
||
canvas.addEventListener("contextmenu", (ev) => {
|
||
ev.preventDefault();
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
||
openCtxMenu(nodeAt(mx, my) || destAt(mx, my), ev.clientX, ev.clientY);
|
||
});
|
||
|
||
// rotating "dispatches from the world" — data as readable poetry
|
||
function worldPhrases() {
|
||
const p = [], get = (k) => sources.get(k);
|
||
const moon = get("astro.moon"); if (moon) p.push("the moon is " + Math.round(moon.raw * 100) + "% lit");
|
||
const merc = get("astro.mercury_retro"); if (merc && merc.raw > 0.5) p.push("mercury is retrograde");
|
||
const btc = get("crypto.price"); if (btc && btc.raw > 0) p.push("bitcoin at $" + Math.round(btc.raw).toLocaleString());
|
||
const inf = get("econ.inflation"); if (inf && inf.raw > 0) p.push("inflation runs at " + inf.raw.toFixed(1) + "%");
|
||
const debt = get("debt.total"); if (debt && debt.raw > 0) p.push("$" + (debt.raw / 1e12).toFixed(1) + " trillion owed");
|
||
const pov = get("world.poverty"); if (pov && pov.raw > 0) p.push(pov.raw.toFixed(1) + "% of the world in extreme poverty");
|
||
const ev = eventTicker[0]; if (ev) p.push(ev.text.toLowerCase());
|
||
if (!p.length) p.push("the world is playing");
|
||
return p;
|
||
}
|
||
|
||
function drawPerform(t, dt) {
|
||
dispatchT += dt;
|
||
const phrases = worldPhrases();
|
||
if (dispatchT > 5.0) { dispatchT = 0; dispatchIdx++; }
|
||
const phrase = phrases[dispatchIdx % phrases.length];
|
||
let a = 1;
|
||
if (dispatchT < 0.6) a = dispatchT / 0.6;
|
||
else if (dispatchT > 4.4) a = (5.0 - dispatchT) / 0.6;
|
||
a = clamp(a, 0, 1);
|
||
ctx.save();
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
ctx.font = "300 30px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(224,234,255," + (0.12 + a * 0.72).toFixed(3) + ")";
|
||
ctx.fillText(phrase, W / 2, H - 118);
|
||
ctx.restore();
|
||
}
|
||
|
||
let inspectValEl = null;
|
||
|
||
function liveValText(n) {
|
||
if (!n) return "";
|
||
if (n.kind === "source")
|
||
return "now: " + fmtRaw(n.rawDisp != null ? n.rawDisp : n.raw) +
|
||
" (" + Math.round((n.norm || 0) * 100) + "%)";
|
||
if (n.isNote) return "now: note " + Math.round(n.raw || 0);
|
||
return "now: " + Math.round((n.norm || 0) * 100) + "%";
|
||
}
|
||
|
||
function openInspector(node) {
|
||
inspectedNode = node;
|
||
inspectEl.innerHTML = "";
|
||
const isSource = node.kind === "source";
|
||
|
||
const close = document.createElement("span");
|
||
close.className = "close"; close.textContent = "×";
|
||
close.onclick = () => { inspectEl.classList.remove("open"); inspectedNode = null; };
|
||
inspectEl.appendChild(close);
|
||
|
||
const h = document.createElement("h3");
|
||
h.textContent = node.label || (isSource ? node.key : prettyDest(node.key));
|
||
inspectEl.appendChild(h);
|
||
const fam = FAMILIES[node.family] ? FAMILIES[node.family].name : "";
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.textContent = node.key + (fam ? " · " + fam : "") + (isSource ? " · source" : " · voice")
|
||
+ (isSource ? " · " + chakraOf(node.key) + " chakra" : "");
|
||
inspectEl.appendChild(sub);
|
||
|
||
// what it is
|
||
if (DESC[node.key]) {
|
||
const dl = document.createElement("div"); dl.className = "sub";
|
||
dl.style.color = "rgba(185,205,235,0.92)"; dl.textContent = DESC[node.key];
|
||
inspectEl.appendChild(dl);
|
||
}
|
||
// its vibe(s)
|
||
const vibes = vibesOf(node.key);
|
||
if (vibes.length) {
|
||
const vl = document.createElement("div"); vl.className = "sub";
|
||
vl.innerHTML = "<span style='color:rgba(150,170,200,0.72)'>vibe:</span> " +
|
||
vibes.map(v => "<b style='color:#cfe0f5'>" + v + "</b>").join(" · ");
|
||
inspectEl.appendChild(vl);
|
||
}
|
||
// live value (updated by the interval below)
|
||
inspectValEl = document.createElement("div"); inspectValEl.className = "sub";
|
||
inspectValEl.style.color = "#9fd0ff"; inspectValEl.style.marginTop = "4px";
|
||
inspectValEl.textContent = liveValText(node);
|
||
inspectEl.appendChild(inspectValEl);
|
||
|
||
// connections
|
||
const rel = routesArr.filter(rt => isSource ? rt.source === node.key : rt.dest === node.key);
|
||
const title = document.createElement("div"); title.className = "sub"; title.style.marginTop = "6px";
|
||
title.textContent = rel.length ? (isSource ? "connects to:" : "driven by:") : "no connections yet";
|
||
inspectEl.appendChild(title);
|
||
for (const rt of rel) {
|
||
const row = document.createElement("div"); row.className = "rt";
|
||
const lbl = document.createElement("span"); lbl.className = "lbl";
|
||
lbl.textContent = isSource ? ("→ " + prettyDest(rt.dest)) : ("← " + rt.source);
|
||
row.appendChild(lbl);
|
||
const sl = document.createElement("input");
|
||
sl.type = "range"; sl.min = "0"; sl.max = "1"; sl.step = "0.01";
|
||
sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount";
|
||
sl.oninput = () => setRouteAmountLocal(rt.source, rt.dest, parseFloat(sl.value));
|
||
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch";
|
||
x.onclick = () => { removeRouteLocal(rt.source, rt.dest); setTimeout(() => openInspector(node), 120); };
|
||
row.appendChild(sl); row.appendChild(x);
|
||
inspectEl.appendChild(row);
|
||
}
|
||
|
||
const mb = document.createElement("button"); mb.className = "midibtn"; mb.style.marginTop = "8px";
|
||
mb.textContent = node.muted ? "unmute" : "mute";
|
||
mb.onclick = () => { toggleMuteLocal(node.key); node.muted = isMutedLocal(node.key); mb.textContent = node.muted ? "unmute" : "mute"; };
|
||
inspectEl.appendChild(mb);
|
||
if (isSource) {
|
||
const tip = document.createElement("div"); tip.className = "sub"; tip.style.marginTop = "8px";
|
||
tip.textContent = "drag onto a destination to patch · shift-click any node for its tab";
|
||
inspectEl.appendChild(tip);
|
||
}
|
||
const grp = document.createElement("div"); grp.id = "grpbtn"; grp.style.marginTop = "8px";
|
||
inspectEl.appendChild(grp);
|
||
updateGroupBtn();
|
||
inspectEl.classList.add("open");
|
||
}
|
||
|
||
setInterval(() => {
|
||
if (inspectedNode && inspectValEl && inspectEl.classList.contains("open"))
|
||
inspectValEl.textContent = liveValText(inspectedNode);
|
||
}, 150);
|
||
|
||
function updateGroupBtn() {
|
||
const grp = document.getElementById("grpbtn");
|
||
if (!grp) return;
|
||
grp.innerHTML = "";
|
||
const btn = document.createElement("button"); btn.className = "midibtn";
|
||
btn.textContent = "group selected (" + selection.size + ")";
|
||
btn.disabled = selection.size < 2;
|
||
btn.onclick = () => {
|
||
if (selection.size < 2) return;
|
||
const name = "group" + (Object.keys(groupsInfo).length + 1);
|
||
makeGroupLocal(name, Array.from(selection));
|
||
selection.clear(); updateGroupBtn();
|
||
};
|
||
grp.appendChild(btn);
|
||
const hint = document.createElement("div"); hint.className = "sub"; hint.style.marginTop = "4px";
|
||
hint.textContent = "⌘/ctrl-click spheres to select, then group.";
|
||
grp.appendChild(hint);
|
||
}
|
||
|
||
// ---- Web MIDI ----
|
||
async function enableMidi() {
|
||
if (!navigator.requestMIDIAccess) {
|
||
if (midiStatusEl) midiStatusEl.textContent = "Web MIDI not supported — use Chrome/Edge.";
|
||
return;
|
||
}
|
||
try {
|
||
midiAccess = await navigator.requestMIDIAccess({ sysex: false });
|
||
} catch (e) {
|
||
if (midiStatusEl) midiStatusEl.textContent = "MIDI blocked (needs https or localhost, and permission).";
|
||
return;
|
||
}
|
||
midiAccess.onstatechange = populatePorts;
|
||
populatePorts();
|
||
if (midiStatusEl) midiStatusEl.textContent = "MIDI ready — pick your ports.";
|
||
}
|
||
|
||
function populatePorts() {
|
||
if (!midiAccess) return;
|
||
fillSelect(outSel, midiAccess.outputs, midiOut, (p) => { midiOut = p; });
|
||
fillSelect(inSel, midiAccess.inputs, midiIn, (p) => {
|
||
if (midiIn) midiIn.onmidimessage = null;
|
||
midiIn = p;
|
||
if (midiIn) midiIn.onmidimessage = onMidiMessage;
|
||
});
|
||
}
|
||
|
||
function fillSelect(sel, portMap, current, setter) {
|
||
if (!sel) return;
|
||
const ports = Array.from(portMap.values());
|
||
sel.innerHTML = "";
|
||
const none = document.createElement("option");
|
||
none.value = ""; none.textContent = "— none —"; sel.appendChild(none);
|
||
let chosen = null;
|
||
for (const p of ports) {
|
||
const o = document.createElement("option");
|
||
o.value = p.id; o.textContent = p.name; sel.appendChild(o);
|
||
if (current && p.id === current.id) { o.selected = true; chosen = p; }
|
||
}
|
||
if (!chosen && ports.length) { chosen = ports[0]; sel.options[1].selected = true; }
|
||
sel.onchange = () => setter(ports.find((q) => q.id === sel.value) || null);
|
||
setter(chosen);
|
||
}
|
||
|
||
function onMidiMessage(ev) {
|
||
const status = ev.data[0], d1 = ev.data[1], d2 = ev.data[2];
|
||
const type = status & 0xf0;
|
||
let key = null, value = 0;
|
||
if (type === 0xB0) { key = "cc:" + d1; value = d2 / 127; }
|
||
else if (type === 0x90 && d2 > 0) { key = "note:" + d1; value = 1; }
|
||
else if (type === 0x80 || (type === 0x90 && d2 === 0)) { key = "note:" + d1; value = 0; }
|
||
else return;
|
||
|
||
if (learnMode && learnTarget) {
|
||
midiBindings[key] = { target: learnTarget.target, param: learnTarget.param, mode: learnTarget.mode };
|
||
saveBindings();
|
||
learnTarget = null; learnMode = false;
|
||
if (learnBtn) learnBtn.classList.remove("on");
|
||
if (midiStatusEl) midiStatusEl.textContent = "bound " + key + " ✓";
|
||
return;
|
||
}
|
||
if (mindChakra && type === 0x90 && d2 > 0) { // inside a wheel: notes play its scale
|
||
mindStrike(d1 % 12, d2 / 127);
|
||
return;
|
||
}
|
||
const b = midiBindings[key];
|
||
if (!b) return;
|
||
if (b.mode === "toggle") {
|
||
if (value >= 0.5) { const t = tw(b.target); t[b.param] = t[b.param] >= 0.5 ? 0 : 1; }
|
||
} else {
|
||
const max = b.param === "gain" ? 1.5 : 1.0;
|
||
setParamLocal(b.target, b.param, value * max);
|
||
}
|
||
}
|
||
|
||
function sendDestsToMidi(destObj) {
|
||
if (!midiOut) return;
|
||
try {
|
||
const notesLive = {};
|
||
for (const dest in destObj) {
|
||
const m = midiMap[dest];
|
||
if (!m) continue;
|
||
const ch = (m.channel || 0) & 0x0f;
|
||
const v = destObj[dest];
|
||
if (m.note) {
|
||
const note = Math.round(v);
|
||
if (note >= 24 && note <= 127) { // Web MIDI send() throws on a byte > 127
|
||
notesLive[dest] = true;
|
||
if (lastNoteFor[dest] !== note) {
|
||
if (lastNoteFor[dest] != null) midiOut.send([0x80 | ch, lastNoteFor[dest], 0]);
|
||
midiOut.send([0x90 | ch, note, 100]);
|
||
lastNoteFor[dest] = note;
|
||
}
|
||
}
|
||
} else if (m.cc != null) {
|
||
const cc = clamp(Math.round(v * 127), 0, 127);
|
||
if (lastCCSent[dest] !== cc) { midiOut.send([0xB0 | ch, m.cc, cc]); lastCCSent[dest] = cc; }
|
||
}
|
||
}
|
||
// release any held note whose source went silent (muted/gated) this frame
|
||
for (const dest in lastNoteFor) {
|
||
if (lastNoteFor[dest] != null && !notesLive[dest]) {
|
||
const m = midiMap[dest] || {};
|
||
midiOut.send([0x80 | ((m.channel || 0) & 0x0f), lastNoteFor[dest], 0]);
|
||
lastNoteFor[dest] = null;
|
||
}
|
||
}
|
||
} catch (e) { /* never let a MIDI error starve route/event processing */ }
|
||
}
|
||
|
||
// ---- panel ----
|
||
function mkSelect(labelText) {
|
||
const row = document.createElement("div"); row.className = "row"; row.style.margin = "6px 0";
|
||
const lab = document.createElement("span"); lab.className = "hint"; lab.textContent = labelText;
|
||
const sel = document.createElement("select"); sel.className = "midisel";
|
||
row.appendChild(lab); row.appendChild(sel); panelEl.appendChild(row);
|
||
return sel;
|
||
}
|
||
|
||
function buildPanel() {
|
||
panelEl.innerHTML = "";
|
||
const h = document.createElement("h3"); h.textContent = "CONTROLS";
|
||
panelEl.appendChild(h);
|
||
const hint = document.createElement("div"); hint.className = "hint";
|
||
hint.innerHTML = "Click any <b>sphere</b> to mute / unmute it.<br>Group macros:";
|
||
panelEl.appendChild(hint);
|
||
|
||
for (const gname in groupsInfo) {
|
||
const g = groupsInfo[gname] || {};
|
||
const wrap = document.createElement("div"); wrap.className = "grp";
|
||
const row = document.createElement("div"); row.className = "row";
|
||
const nm = document.createElement("span"); nm.className = "nm"; nm.textContent = g.label || gname;
|
||
const btns = document.createElement("span"); btns.className = "btns";
|
||
const mute = document.createElement("button"); mute.textContent = "mute";
|
||
mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; const t = tw("@" + gname); t.mute = t.mute >= 0.5 ? 0 : 1; mute.classList.toggle("on"); };
|
||
const frz = document.createElement("button"); frz.textContent = "freeze"; frz.className = "frz";
|
||
frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; const t = tw("@" + gname); t.freeze = t.freeze >= 0.5 ? 0 : 1; frz.classList.toggle("on"); };
|
||
btns.appendChild(mute); btns.appendChild(frz);
|
||
row.appendChild(nm); row.appendChild(btns); wrap.appendChild(row);
|
||
const slider = document.createElement("input");
|
||
slider.type = "range"; slider.min = "0"; slider.max = "1.5"; slider.step = "0.01"; slider.value = "1"; slider.title = "gain";
|
||
slider.addEventListener("pointerdown", () => armLearn({ target: "@" + gname, param: "gain", mode: "cc" }));
|
||
slider.oninput = () => { if (!learnMode) setParamLocal("@" + gname, "gain", parseFloat(slider.value)); };
|
||
wrap.appendChild(slider);
|
||
panelEl.appendChild(wrap);
|
||
}
|
||
|
||
// Web MIDI section
|
||
const mh = document.createElement("h3"); mh.textContent = "WEB MIDI"; mh.style.marginTop = "4px";
|
||
panelEl.appendChild(mh);
|
||
const enable = document.createElement("button"); enable.textContent = "enable MIDI"; enable.className = "midibtn";
|
||
enable.onclick = enableMidi; panelEl.appendChild(enable);
|
||
midiStatusEl = document.createElement("div"); midiStatusEl.className = "hint"; midiStatusEl.style.margin = "6px 0";
|
||
midiStatusEl.textContent = "off — enable to drive your DAW (Chrome/Edge; https or localhost).";
|
||
panelEl.appendChild(midiStatusEl);
|
||
outSel = mkSelect("out →");
|
||
inSel = mkSelect("in ←");
|
||
learnBtn = document.createElement("button"); learnBtn.textContent = "MIDI learn"; learnBtn.className = "midibtn";
|
||
learnBtn.onclick = () => {
|
||
learnMode = !learnMode; learnTarget = null;
|
||
learnBtn.classList.toggle("on", learnMode);
|
||
midiStatusEl.textContent = learnMode
|
||
? "LEARN on — click a slider / mute / sphere, then move a knob."
|
||
: "learn off.";
|
||
};
|
||
panelEl.appendChild(learnBtn);
|
||
bindCountEl = document.createElement("span"); bindCountEl.className = "hint"; bindCountEl.style.marginLeft = "8px";
|
||
bindCountEl.textContent = Object.keys(midiBindings).length + " mapped";
|
||
panelEl.appendChild(bindCountEl);
|
||
const clr = document.createElement("button"); clr.textContent = "clear"; clr.className = "midibtn"; clr.style.marginLeft = "8px";
|
||
clr.onclick = () => { for (const k in midiBindings) delete midiBindings[k]; saveBindings(); midiStatusEl.textContent = "mappings cleared."; };
|
||
panelEl.appendChild(clr);
|
||
|
||
// My patches — personal presets saved to your account (the full patch:
|
||
// routes, tweaks, tempo). Sign in to save; loads restore your instrument.
|
||
const th = document.createElement("h3"); th.textContent = "MY PATCHES"; th.style.marginTop = "8px";
|
||
panelEl.appendChild(th);
|
||
const pMsg = document.createElement("div"); pMsg.className = "hint"; pMsg.style.margin = "2px 0 4px";
|
||
pMsg.textContent = "your own routing, saved to your account.";
|
||
panelEl.appendChild(pMsg);
|
||
const nameRow = document.createElement("div"); nameRow.className = "row"; nameRow.style.margin = "4px 0";
|
||
const nameIn = document.createElement("input"); nameIn.type = "text"; nameIn.placeholder = "name this patch…"; nameIn.className = "tplname";
|
||
const saveBtn = document.createElement("button"); saveBtn.textContent = "save"; saveBtn.className = "midibtn";
|
||
saveBtn.onclick = async () => {
|
||
const n = nameIn.value.trim(); if (!n) return;
|
||
if (!(Accounts && Accounts.user)) { pMsg.textContent = "sign in (top-right) to save."; return; }
|
||
const r = await gsFetch("/api/presets/" + encodeURIComponent(n), "PUT", { data: serializePatch() });
|
||
pMsg.textContent = r.ok ? ("saved “" + n + "” ✓") : (r.data.error || "save failed");
|
||
if (r.ok) { nameIn.value = ""; refreshTemplates(); }
|
||
};
|
||
nameRow.appendChild(nameIn); nameRow.appendChild(saveBtn); panelEl.appendChild(nameRow);
|
||
const loadRow = document.createElement("div"); loadRow.className = "row"; loadRow.style.margin = "4px 0";
|
||
templateSelect = document.createElement("select"); templateSelect.className = "midisel";
|
||
const loadBtn = document.createElement("button"); loadBtn.textContent = "load"; loadBtn.className = "midibtn"; loadBtn.style.marginLeft = "6px";
|
||
loadBtn.onclick = async () => {
|
||
const n = templateSelect.value; if (!n) return;
|
||
const r = await gsFetch("/api/presets/" + encodeURIComponent(n), "GET");
|
||
if (r.ok && r.data.data) { loadPatch(r.data.data); pMsg.textContent = "loaded “" + n + "”"; }
|
||
else pMsg.textContent = "load failed";
|
||
};
|
||
loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);
|
||
refreshTemplates();
|
||
}
|
||
|
||
async function refreshTemplates() {
|
||
if (!templateSelect) return;
|
||
templateSelect.innerHTML = "";
|
||
const opt = (v, t) => { const o = document.createElement("option"); o.value = v; o.textContent = t; templateSelect.appendChild(o); };
|
||
if (!(typeof Accounts !== "undefined" && Accounts && Accounts.user)) { opt("", "— sign in to load —"); return; }
|
||
const r = await gsFetch("/api/presets", "GET");
|
||
const names = (r.ok && r.data.presets) ? r.data.presets.map(p => p.name) : [];
|
||
if (!names.length) opt("", "— none saved —");
|
||
else names.forEach(nm => opt(nm, nm));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// In-browser synth — the planet, made audible. Reads the same `dests` the
|
||
// console shows and turns them into a small ensemble (lead · bass · pad ·
|
||
// drone · perc) through a shared FX chain. Click ♪ to start (browsers need a
|
||
// gesture before audio). Pure Web Audio; works on the read-only public site.
|
||
// ---------------------------------------------------------------------------
|
||
const Synth = (function () {
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
let ctx = null, on = false, N = null, sched = null, lfo = null;
|
||
const D = {}; // latest destination values
|
||
let leadNote = 0, bassNote = 0, step = 0, nextAt = 0;
|
||
|
||
const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);
|
||
const c01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
||
const d = (k, def) => { const v = D[k]; return typeof v === "number" ? v : (def || 0); };
|
||
|
||
function impulse(sec, decay) {
|
||
const n = Math.floor(ctx.sampleRate * sec), b = ctx.createBuffer(2, n, ctx.sampleRate);
|
||
for (let c = 0; c < 2; c++) { const ch = b.getChannelData(c);
|
||
for (let i = 0; i < n; i++) ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, decay); }
|
||
return b;
|
||
}
|
||
function tanhCurve() {
|
||
const n = 1024, cur = new Float32Array(n);
|
||
for (let i = 0; i < n; i++) cur[i] = Math.tanh(((i / (n - 1)) * 2 - 1) * 2.5);
|
||
return cur;
|
||
}
|
||
|
||
function build() {
|
||
const out = ctx.createGain(); out.gain.value = 0; // fades in on start
|
||
const lim = ctx.createDynamicsCompressor(); // safety limiter
|
||
lim.threshold.value = -8; lim.ratio.value = 12; lim.attack.value = 0.003; lim.release.value = 0.25;
|
||
out.connect(lim); lim.connect(ctx.destination);
|
||
|
||
const conv = ctx.createConvolver(); conv.buffer = impulse(2.4, 2.4);
|
||
const revSend = ctx.createGain(); revSend.gain.value = 0;
|
||
const revWet = ctx.createGain(); revWet.gain.value = 0.9;
|
||
revSend.connect(conv); conv.connect(revWet); revWet.connect(out);
|
||
|
||
const delay = ctx.createDelay(1.0); delay.delayTime.value = 0.38;
|
||
const fb = ctx.createGain(); fb.gain.value = 0.3; delay.connect(fb); fb.connect(delay);
|
||
const delSend = ctx.createGain(); delSend.gain.value = 0.16;
|
||
const delWet = ctx.createGain(); delWet.gain.value = 0.5;
|
||
delSend.connect(delay); delay.connect(delWet); delWet.connect(out);
|
||
|
||
const preSat = ctx.createGain(); // all voices sum here
|
||
const shaper = ctx.createWaveShaper(); shaper.curve = tanhCurve();
|
||
const satDry = ctx.createGain(); satDry.gain.value = 1;
|
||
const satWet = ctx.createGain(); satWet.gain.value = 0;
|
||
const post = ctx.createGain();
|
||
preSat.connect(satDry); satDry.connect(post);
|
||
preSat.connect(shaper); shaper.connect(satWet); satWet.connect(post);
|
||
const glitch = ctx.createGain(); glitch.gain.value = 1; // stutter target
|
||
post.connect(glitch); glitch.connect(out); post.connect(delSend); post.connect(revSend);
|
||
|
||
lfo = ctx.createOscillator(); lfo.frequency.value = 0.2;
|
||
const lfoAmt = ctx.createGain(); lfoAmt.gain.value = 260; lfo.connect(lfoAmt);
|
||
|
||
const leadOsc = ctx.createOscillator(); leadOsc.type = "sawtooth";
|
||
const leadFilt = ctx.createBiquadFilter(); leadFilt.type = "lowpass"; leadFilt.frequency.value = 1200;
|
||
const leadAmp = ctx.createGain(); leadAmp.gain.value = 0;
|
||
leadOsc.connect(leadFilt); leadFilt.connect(leadAmp); leadAmp.connect(preSat);
|
||
lfoAmt.connect(leadFilt.frequency); leadOsc.start();
|
||
|
||
const bassOsc = ctx.createOscillator(); bassOsc.type = "square";
|
||
const bassFilt = ctx.createBiquadFilter(); bassFilt.type = "lowpass"; bassFilt.frequency.value = 500;
|
||
const bassAmp = ctx.createGain(); bassAmp.gain.value = 0;
|
||
bassOsc.connect(bassFilt); bassFilt.connect(bassAmp); bassAmp.connect(preSat);
|
||
bassOsc.frequency.value = mtof(38); bassOsc.start();
|
||
|
||
const padFilt = ctx.createBiquadFilter(); padFilt.type = "lowpass"; padFilt.frequency.value = 800;
|
||
const padAmp = ctx.createGain(); padAmp.gain.value = 0;
|
||
padFilt.connect(padAmp); padAmp.connect(preSat); lfoAmt.connect(padFilt.frequency);
|
||
const padOscs = [0, 7, 16].map((iv, i) => {
|
||
const o = ctx.createOscillator(); o.type = "sawtooth"; o.detune.value = (i - 1) * 6;
|
||
o.frequency.value = mtof(50 + iv); o.connect(padFilt); o.start(); return o;
|
||
});
|
||
|
||
const droneFilt = ctx.createBiquadFilter(); droneFilt.type = "lowpass"; droneFilt.frequency.value = 380;
|
||
const droneAmp = ctx.createGain(); droneAmp.gain.value = 0;
|
||
droneFilt.connect(droneAmp); droneAmp.connect(preSat);
|
||
const droneOscs = [0, 7].map((iv) => {
|
||
const o = ctx.createOscillator(); o.type = "sawtooth"; o.frequency.value = mtof(26 + iv);
|
||
o.connect(droneFilt); o.start(); return o;
|
||
});
|
||
|
||
lfo.start();
|
||
return { out, preSat, glitch, revSend, fb, satDry, satWet, leadOsc, leadFilt, leadAmp,
|
||
bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp };
|
||
}
|
||
|
||
function hit(node, peak, dec) { // pluck envelope
|
||
const t = ctx.currentTime, g = node.gain;
|
||
g.cancelScheduledValues(t);
|
||
g.setValueAtTime(Math.max(g.value, 0.0001), t);
|
||
g.linearRampToValueAtTime(peak, t + 0.008);
|
||
g.setTargetAtTime(peak * 0.28, t + 0.01, dec);
|
||
}
|
||
function noiseHit(freq, dur, vol) {
|
||
const n = Math.floor(ctx.sampleRate * dur), b = ctx.createBuffer(1, n, ctx.sampleRate), ch = b.getChannelData(0);
|
||
for (let i = 0; i < n; i++) ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3);
|
||
const s = ctx.createBufferSource(); s.buffer = b;
|
||
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = freq;
|
||
const g = ctx.createGain(); g.gain.value = vol;
|
||
s.connect(hp); hp.connect(g); g.connect(N.preSat); s.start();
|
||
}
|
||
|
||
function params() {
|
||
const t = ctx.currentTime, S = (p, v, tc) => p.setTargetAtTime(v, t, tc || 0.1);
|
||
S(N.leadFilt.frequency, 250 + d("filter.cutoff", 0.3) * 5000, 0.05);
|
||
N.leadFilt.Q.setTargetAtTime(1 + d("wavetable.morph", 0) * 8, t, 0.1);
|
||
S(N.padFilt.frequency, 220 + d("pad.brightness", 0) * 4200);
|
||
S(N.padAmp.gain, d("pad.brightness", 0) * 0.15, 0.3);
|
||
S(N.droneAmp.gain, d("drone.voices", 0) * 0.12, 0.3);
|
||
S(N.revSend.gain, (d("reverb.size", 0) * 0.5 + d("master.space", 0) * 0.5) * 0.45, 0.3);
|
||
S(N.fb.gain, Math.min(0.82, d("delay.feedback", 0) * 0.85), 0.2);
|
||
const sat = d("saturation", 0);
|
||
S(N.satWet.gain, sat * 0.9, 0.2); S(N.satDry.gain, 1 - sat * 0.4, 0.2);
|
||
lfo.frequency.setTargetAtTime(0.1 + d("lfo.rate", 0) * 6, t, 0.3);
|
||
const ln = Math.round(d("lead.note", 0));
|
||
if (ln >= 24 && ln !== leadNote) { leadNote = ln; N.leadOsc.frequency.setTargetAtTime(mtof(ln), t, 0.02); hit(N.leadAmp, 0.15, 0.35); }
|
||
const bn = Math.round(d("bass.note", 0));
|
||
if (bn >= 20 && bn !== bassNote) {
|
||
bassNote = bn; N.bassOsc.frequency.setTargetAtTime(mtof(bn), t, 0.03); hit(N.bassAmp, 0.24, 0.6);
|
||
N.padOscs.forEach((o, i) => o.frequency.setTargetAtTime(mtof(bn + 12 + [0, 7, 16][i]), t, 0.5));
|
||
N.droneOscs.forEach((o, i) => o.frequency.setTargetAtTime(mtof(bn - 12 + [0, 7][i]), t, 0.5));
|
||
}
|
||
}
|
||
|
||
function tick() {
|
||
if (!on) return;
|
||
const now = ctx.currentTime;
|
||
const nudge = godtime.locked ? 0 : (d("tempo.nudge", 0.5) - 0.5) * 28; // data pushes the tempo
|
||
const bpm = Math.max(30, godtime.bpm + nudge), six = 60 / bpm / 4;
|
||
while (nextAt < now + 0.1) {
|
||
const t = Math.max(nextAt, now), dens = d("perc.density", 0);
|
||
if (Math.random() < dens * 0.9) noiseHit(7000, 0.05, 0.09 + dens * 0.09);
|
||
if (step % 4 === 0 && Math.random() < 0.4 + d("granular.density", 0) * 0.4) noiseHit(2800, 0.03, 0.05);
|
||
if (Math.random() < d("glitch", 0) * 0.18) {
|
||
const g = N.glitch.gain; g.setValueAtTime(1, t); g.setValueAtTime(0, t + 0.02); g.setValueAtTime(1, t + 0.055);
|
||
}
|
||
step = (step + 1) % 16; nextAt += six;
|
||
}
|
||
params();
|
||
}
|
||
|
||
function start() {
|
||
if (!AC) return;
|
||
if (!ctx) { ctx = new AC(); N = build(); }
|
||
ctx.resume();
|
||
on = true; nextAt = ctx.currentTime;
|
||
N.out.gain.setTargetAtTime(0.85, ctx.currentTime, 0.5);
|
||
if (!sched) sched = setInterval(tick, 25);
|
||
}
|
||
function stop() { on = false; if (N) N.out.gain.setTargetAtTime(0, ctx.currentTime, 0.3); }
|
||
|
||
// a hand-played note (the chakra keyboard) — a short pluck routed through
|
||
// the full FX chain, so it sits inside the world's reverb & grit. Wakes
|
||
// the audio graph on first strike if ♪ hasn't been pressed.
|
||
function pluck(midi, vel) {
|
||
if (!AC) return;
|
||
if (!ctx) { ctx = new AC(); N = build(); }
|
||
ctx.resume();
|
||
const t = ctx.currentTime;
|
||
if (!on) N.out.gain.setTargetAtTime(0.85, t, 0.1); // master up; data voices stay silent
|
||
const v = vel == null ? 0.8 : vel;
|
||
const o = ctx.createOscillator(); o.type = "triangle"; o.frequency.value = mtof(midi);
|
||
const f = ctx.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = 700 + v * 2800;
|
||
const g = ctx.createGain(); g.gain.value = 0;
|
||
o.connect(f); f.connect(g); g.connect(N.preSat);
|
||
g.gain.setValueAtTime(0, t);
|
||
g.gain.linearRampToValueAtTime(0.22 * v, t + 0.006);
|
||
g.gain.setTargetAtTime(0, t + 0.02, 0.38);
|
||
o.start(t); o.stop(t + 2.4);
|
||
}
|
||
|
||
const btn = document.createElement("div");
|
||
btn.id = "sound"; btn.textContent = "♪"; btn.title = "play the sound of the world";
|
||
Object.assign(btn.style, { position: "fixed", top: "12px", right: "52px", zIndex: 10,
|
||
width: "30px", height: "30px", borderRadius: "8px", display: "flex", alignItems: "center",
|
||
justifyContent: "center", background: "rgba(20,28,44,0.7)", color: "#9fb4d8", cursor: "pointer",
|
||
fontSize: "15px", border: "1px solid rgba(120,150,200,0.25)",
|
||
backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" });
|
||
document.body.appendChild(btn);
|
||
function paint() { btn.style.color = on ? "#7cffb2" : "#9fb4d8";
|
||
btn.style.borderColor = on ? "rgba(124,255,178,0.5)" : "rgba(120,150,200,0.25)"; }
|
||
if (!AC) { btn.style.opacity = "0.3"; btn.title = "Web Audio not supported"; }
|
||
else btn.addEventListener("click", () => { on ? stop() : start(); paint(); });
|
||
|
||
return { update(dests) { if (dests) Object.assign(D, dests); }, pluck };
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Accounts — invite-only sign in / create, per-user presets come next.
|
||
// ---------------------------------------------------------------------------
|
||
const Accounts = (function () {
|
||
const chip = document.getElementById("authchip");
|
||
const modal = document.getElementById("auth");
|
||
const loginForm = document.getElementById("loginform");
|
||
const signupForm = document.getElementById("signupform");
|
||
let user = null;
|
||
|
||
async function api(path, method, body) {
|
||
const opt = { method, headers: { "Content-Type": "application/json" } };
|
||
if (body) opt.body = JSON.stringify(body);
|
||
let data = {};
|
||
try {
|
||
const r = await fetch(path, opt);
|
||
try { data = await r.json(); } catch (e) { }
|
||
return { ok: r.ok, status: r.status, data };
|
||
} catch (e) { return { ok: false, status: 0, data: { error: "no connection" } }; }
|
||
}
|
||
function setUser(u) {
|
||
user = u;
|
||
chip.textContent = u ? ("@" + u.username) : "sign in";
|
||
chip.title = u ? "signed in — click to sign out" : "sign in / create account";
|
||
if (typeof refreshTemplates === "function") refreshTemplates(); // update My Patches
|
||
const landing = document.getElementById("landing");
|
||
if (u) { // signed in -> open the instrument
|
||
if (landing) landing.classList.add("hidden");
|
||
if (typeof connect === "function" && (!ws || ws.readyState > 1)) connect();
|
||
} else { // signed out -> gate at the landing
|
||
if (landing) landing.classList.remove("hidden");
|
||
if (ws) { try { ws.close(); } catch (e) { } }
|
||
connected = false;
|
||
}
|
||
}
|
||
const clearErr = () => modal.querySelectorAll(".err").forEach(e => e.textContent = "");
|
||
const showErr = (f, m) => { f.querySelector(".err").textContent = m || "something went wrong"; };
|
||
|
||
modal.querySelectorAll(".tabs button").forEach(b => b.addEventListener("click", () => {
|
||
const t = b.dataset.tab;
|
||
modal.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x === b));
|
||
loginForm.classList.toggle("on", t === "login");
|
||
signupForm.classList.toggle("on", t === "signup");
|
||
clearErr();
|
||
}));
|
||
// the landing page's CTAs open the auth modal on the chosen tab
|
||
document.querySelectorAll("#landing button[data-tab]").forEach(b => b.addEventListener("click", () => {
|
||
const t = b.dataset.tab;
|
||
modal.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x.dataset.tab === t));
|
||
loginForm.classList.toggle("on", t === "login");
|
||
signupForm.classList.toggle("on", t === "signup");
|
||
clearErr();
|
||
modal.classList.add("open");
|
||
}));
|
||
modal.querySelectorAll(".eye").forEach(eye => eye.addEventListener("click", () => {
|
||
const inp = eye.previousElementSibling;
|
||
inp.type = inp.type === "password" ? "text" : "password";
|
||
eye.style.opacity = inp.type === "text" ? "1" : "0.6";
|
||
}));
|
||
modal.querySelector(".close").addEventListener("click", () => { modal.classList.remove("open"); clearErr(); });
|
||
modal.addEventListener("click", (e) => { if (e.target === modal) { modal.classList.remove("open"); clearErr(); } });
|
||
chip.addEventListener("click", () => { user ? openAccount() : modal.classList.add("open"); });
|
||
|
||
// ---- account panel: feedback + (admin) stats ----
|
||
const acct = document.getElementById("account");
|
||
const acctBox = acct.querySelector(".box");
|
||
const esc = (s) => (s || "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
const closeAccount = () => acct.classList.remove("open");
|
||
acct.addEventListener("click", (e) => { if (e.target === acct) closeAccount(); });
|
||
|
||
function openAccount() {
|
||
acctBox.innerHTML =
|
||
'<span class="close">×</span>' +
|
||
'<h3>signed in as @' + esc(user.username) + '</h3>' +
|
||
'<div class="sub">ideas · bugs · comments — goes straight to the team</div>' +
|
||
'<textarea id="fbtext" placeholder="what\'s on your mind?"></textarea>' +
|
||
'<button class="go" id="fbsend">send feedback</button> <span class="sub" id="fbmsg"></span>' +
|
||
'<div class="admin" id="adminbox" style="display:none"></div>' +
|
||
'<div style="margin-top:16px"><button class="ghost" id="signout">sign out</button></div>';
|
||
acctBox.querySelector(".close").onclick = closeAccount;
|
||
acctBox.querySelector("#signout").onclick = async () => { await api("/api/logout", "POST"); setUser(null); closeAccount(); };
|
||
acctBox.querySelector("#fbsend").onclick = async () => {
|
||
const t = acctBox.querySelector("#fbtext").value.trim();
|
||
if (!t) return;
|
||
const r = await api("/api/feedback", "POST", { text: t });
|
||
acctBox.querySelector("#fbmsg").textContent = r.ok ? "thank you! sent 💫" : (r.data.error || "failed");
|
||
if (r.ok) acctBox.querySelector("#fbtext").value = "";
|
||
};
|
||
acct.classList.add("open");
|
||
if (user.admin) loadAdmin();
|
||
}
|
||
|
||
async function loadAdmin() {
|
||
const r = await api("/api/admin/stats", "GET");
|
||
if (!r.ok) return;
|
||
const s = r.data, box = acctBox.querySelector("#adminbox");
|
||
const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s");
|
||
box.style.display = "block";
|
||
box.innerHTML =
|
||
"<h3>admin</h3>" +
|
||
'<div class="sub">' + plural(s.users, "user") + " · " + plural(s.unused_codes, "code") + " left · " + plural(s.presets, "preset") + "</div>" +
|
||
'<button class="ghost" id="newcode">issue new code</button><span class="code" id="codeout"></span>' +
|
||
'<div class="sub" style="margin-top:12px">feedback</div>' +
|
||
'<div class="fblist">' + (s.feedback.length
|
||
? s.feedback.map(f => '<div class="fbitem"><b>@' + esc(f.username) + '</b> <span style="opacity:.55">' + esc(f.when) + "</span><br>" + esc(f.text) + "</div>").join("")
|
||
: '<div class="sub">none yet</div>') + "</div>";
|
||
box.querySelector("#newcode").onclick = async () => {
|
||
const cr = await api("/api/admin/invite", "POST");
|
||
box.querySelector("#codeout").textContent = cr.ok ? cr.data.code : "failed";
|
||
};
|
||
}
|
||
|
||
loginForm.addEventListener("submit", async (e) => {
|
||
e.preventDefault(); clearErr();
|
||
const r = await api("/api/login", "POST",
|
||
{ identifier: loginForm.identifier.value, password: loginForm.password.value });
|
||
if (r.ok) { setUser(r.data); modal.classList.remove("open"); loginForm.reset(); }
|
||
else showErr(loginForm, r.data.error);
|
||
});
|
||
signupForm.addEventListener("submit", async (e) => {
|
||
e.preventDefault(); clearErr();
|
||
if (signupForm.password.value !== signupForm.confirm.value)
|
||
return showErr(signupForm, "passwords don't match");
|
||
const r = await api("/api/signup", "POST", {
|
||
username: signupForm.username.value, email: signupForm.email.value,
|
||
password: signupForm.password.value, invite: signupForm.invite.value.trim()
|
||
});
|
||
if (r.ok) { setUser(r.data); modal.classList.remove("open"); signupForm.reset(); }
|
||
else showErr(signupForm, r.data.error);
|
||
});
|
||
|
||
// dev backdoor — ?dev=1 skips the gate for LOCAL testing only. The public
|
||
// site never honours it: the hostname must literally be localhost.
|
||
const devH = location.hostname;
|
||
if (new URLSearchParams(location.search).get("dev") === "1" &&
|
||
(devH === "localhost" || devH === "127.0.0.1" || devH === ""))
|
||
setUser({ username: "dev", email: "dev@localhost" });
|
||
else
|
||
api("/api/me", "GET").then(r => { if (r.ok) setUser(r.data); }); // already logged in?
|
||
return { get user() { return user; } };
|
||
})();
|
||
|
||
// ---- the grimoire (manual) — opens from the landing and the console ----
|
||
(function () {
|
||
const g = document.getElementById("grimoire");
|
||
if (!g) return;
|
||
const open = () => g.classList.add("open");
|
||
const close = () => g.classList.remove("open");
|
||
g.querySelector(".x").addEventListener("click", close);
|
||
g.addEventListener("click", (e) => { if (e.target === g) close(); });
|
||
["grimtrigger", "landgrim"].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener("click", open);
|
||
});
|
||
window.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Boot — the ws connects only once you're signed in (Accounts.setUser drives
|
||
// it). Until then the landing page is the gate; the live feed never streams
|
||
// to a signed-out visitor. Returning users auto-connect via /api/me on load.
|
||
// ---------------------------------------------------------------------------
|
||
resize();
|
||
requestAnimationFrame(frame);
|
||
|
||
})();
|
||
</script>
|