Four grimoire entries in the house voice, fact-checked against what was
verified: 📼 TESTAMENT ("it is written"), ☸ WHEELS (Ezekiel 1:16 — "a
wheel in the middle of a wheel"), 🕊 PSALM (the local-listening privacy
line — nothing leaves the machine), ✦ CANON (the read-only shelf of
worked examples). Manual regenerated (27 sections).
Cross-feature QA: all four surfaces coexist with the pantheon and the
crossover — TESTAMENT ⏺ button, WHEELS chips in all 5 beat lanes,
PSALM/TESTAMENT/GODSPEED ctxItems — zero console errors, groove
self-check green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10151 lines
714 KiB
HTML
10151 lines
714 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; }
|
||
|
||
/* the holdings panel — your private ledger, localStorage only */
|
||
#cvpanel { display: none; }
|
||
#cvpanel.open { display: block; }
|
||
#cvpanel .cvrow { display: flex; gap: 5px; align-items: center; margin: 4px 0; }
|
||
#cvpanel .cvrow select { min-width: 0; }
|
||
#cvpanel .cvrow .asel:first-child { flex: 1; }
|
||
#cvpanel .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#keys.open { display: block; }
|
||
#keys h3 { margin: 0 24px 4px 0; font-size: 13px; color: #a8d8ff; }
|
||
#keys .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
|
||
#keys .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#keys .hrow { display: flex; gap: 6px; align-items: center; margin: 3px 0; }
|
||
#keys .keycap { min-width: 30px; text-align: center; font-family: monospace; font-size: 11px; color: #cfe0f5;
|
||
background: rgba(120,150,200,0.15); border: 1px solid rgba(120,150,200,0.3); border-radius: 5px; padding: 2px 6px; }
|
||
#keys .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#keys select { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3); border-radius: 6px; padding: 3px 6px; font-size: 11px; }
|
||
#keys .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; }
|
||
#holdings, #natal, #commune, #cvpanel, #keys {
|
||
position: fixed; top: 96px; right: 14px; z-index: 12; width: 292px; display: none;
|
||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||
border-radius: 12px; padding: 12px 14px; display: none; color: #c7d4ea;
|
||
font-size: 12px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
}
|
||
#holdings.open, #natal.open, #commune.open, #cvpanel.open { display: block; }
|
||
#cvpanel h3 { margin: 0 24px 4px 0; font-size: 13px; color: #ffd98a; }
|
||
#cvpanel .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
|
||
#cvpanel .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#cvpanel .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
|
||
#cvpanel select { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3); border-radius: 6px; padding: 3px 6px; font-size: 11px; }
|
||
#cvpanel .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; }
|
||
#cvpanel .midibtn.on { background: rgba(255,200,90,0.22); color: #ffe6a8; border-color: rgba(255,200,90,0.5); }
|
||
#commune h3 { margin: 0 24px 4px 0; font-size: 13px; color: #8fd8e8; }
|
||
#commune .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
|
||
#commune .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#commune .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
|
||
#commune input, #commune select {
|
||
flex: 1; min-width: 0; background: rgba(30,40,60,0.8); border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 6px; color: #dbe6f5; padding: 4px 8px; font-size: 12px;
|
||
}
|
||
#commune .midibtn {
|
||
background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer;
|
||
}
|
||
#natal h3 { margin: 0 24px 4px 0; font-size: 13px; color: #cbb3ff; }
|
||
#natal .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
|
||
#natal .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#natal .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
|
||
#natal input { flex: 1; min-width: 0; background: rgba(30,40,60,0.8); border: 1px solid rgba(120,150,200,0.3); border-radius: 6px; color: #dbe6f5; padding: 4px 8px; font-size: 12px; }
|
||
#natal .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#natal .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; }
|
||
#holdings h3 { margin: 0 24px 4px 0; font-size: 13px; color: #ffd98a; }
|
||
#holdings .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0 8px; line-height: 1.5; }
|
||
#holdings .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
|
||
#holdings .hrow { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
|
||
#holdings input {
|
||
flex: 1; min-width: 0; background: rgba(30,40,60,0.8);
|
||
border: 1px solid rgba(120,150,200,0.3); border-radius: 6px;
|
||
color: #dbe6f5; padding: 4px 8px; font-size: 12px;
|
||
}
|
||
#holdings .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#holdings .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;
|
||
}
|
||
|
||
/* the arranger — tracks & clips for the casuals */
|
||
#arranger {
|
||
position: fixed; left: 12px; right: 12px; bottom: 54px; z-index: 13;
|
||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
max-height: 44vh;
|
||
}
|
||
#arranger.open { display: flex; flex-direction: column; }
|
||
#arranger .agrip {
|
||
height: 8px; margin: -8px -10px 2px; cursor: ns-resize; border-radius: 12px 12px 0 0;
|
||
}
|
||
#arranger .agrip:hover { background: rgba(150,180,230,0.25); }
|
||
#arranger .atop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||
#arranger .asel {
|
||
background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 6px; padding: 3px 8px; font-size: 11px; max-width: 180px;
|
||
}
|
||
#arranger .seqgrid.big { grid-template-columns: repeat(16, 26px); gap: 3px; margin: 10px auto; }
|
||
#arranger .seqgrid.big .seqcell { width: 26px; height: 22px; border-radius: 4px; }
|
||
#arrbtn {
|
||
position: fixed; right: 14px; bottom: 56px; z-index: 12; padding: 6px 12px;
|
||
border-radius: 9px; background: rgba(20,28,44,0.85); color: #9fb4d8;
|
||
border: 1px solid rgba(120,150,200,0.3); cursor: pointer; font-size: 12px;
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
|
||
}
|
||
#arrbtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
|
||
#arrbtn.hidden { display: none; }
|
||
#omnibtn {
|
||
position: fixed; right: 14px; bottom: 92px; z-index: 12; padding: 6px 12px;
|
||
border-radius: 9px; background: rgba(20,28,44,0.85); color: #9fb4d8;
|
||
border: 1px solid rgba(120,150,200,0.3); cursor: pointer; font-size: 12px;
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
|
||
}
|
||
#omnibtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
|
||
#omnibtn.on { color: #cfe0ff; border-color: rgba(150,200,255,0.7); background: rgba(30,42,66,0.9); }
|
||
#omnibtn.hidden { display: none; }
|
||
#arranger .albl { color: rgba(150,170,200,0.65); font-size: 10px; flex: 1; }
|
||
#arranger .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||
#arranger .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;
|
||
}
|
||
#arranger .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||
#arranger .abody { flex: 1; min-height: 0; overflow-y: auto; }
|
||
#arranger .arow { display: flex; align-items: center; gap: 3px; margin: 2px 0; }
|
||
#arranger .aname {
|
||
width: 118px; min-width: 118px; overflow: hidden; text-overflow: ellipsis;
|
||
white-space: nowrap; color: #aebfda; font-size: 10px;
|
||
}
|
||
#arranger .acell {
|
||
flex: 1; height: 17px; border-radius: 3px; cursor: pointer; text-align: center;
|
||
line-height: 17px; font-size: 9px; font-weight: 700; user-select: none;
|
||
}
|
||
#arranger .acell.alive { background: rgba(100,180,140,0.18); color: rgba(140,220,170,0.7); }
|
||
#arranger .acell.aclip { background: rgba(90,130,230,0.4); color: #dbe6ff; }
|
||
#arranger .acell.amute { background: rgba(30,36,54,0.7); }
|
||
#arranger .acell:hover { outline: 1px solid rgba(150,180,230,0.5); }
|
||
#arranger .acell.acur { outline: 1px solid rgba(255,238,150,0.9); }
|
||
/* the Groove — feel controls + per-step expression lane */
|
||
#arranger .grow { gap: 12px; flex-wrap: wrap; align-items: center; margin: 4px 0 2px; }
|
||
#arranger .gctl { display: inline-flex; align-items: center; gap: 5px; }
|
||
#arranger .gctl .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 48px; text-align: right; }
|
||
#arranger .gctl input[type=range] { width: 74px; accent-color: #7fa0e6; height: 3px; }
|
||
#arranger .gctl .gval { color: #aebbdc; font-size: 10px; min-width: 30px; }
|
||
#arranger .gpreset { font-size: 10px; padding: 2px 7px; opacity: 0.85; }
|
||
#arranger .eunum { width: 44px; background: rgba(20,26,40,0.8); color: #cfe0f5;
|
||
border: 1px solid rgba(120,150,220,0.3); border-radius: 4px; font-size: 11px; padding: 1px 4px; }
|
||
#arranger .emrow { gap: 6px; align-items: center; margin-top: 4px; }
|
||
#arranger .exprlane { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px; margin: 4px auto 8px;
|
||
height: 52px; align-items: end; }
|
||
#arranger .exprcell { width: 26px; height: 52px; display: flex; align-items: flex-end;
|
||
background: rgba(30,36,54,0.6); border-radius: 4px; cursor: ns-resize; }
|
||
#arranger .exprcell.bar { background: rgba(40,48,72,0.7); }
|
||
#arranger .exprbar { width: 100%; background: rgba(120,150,230,0.35); border-radius: 4px; min-height: 2px; }
|
||
#arranger .exprbar.lit { background: rgba(140,175,255,0.85); }
|
||
#arranger .exprbar.locked { background: rgba(255,190,90,0.6); } /* a p-lock (amber) */
|
||
#arranger .exprbar.locked.lit { background: rgba(255,200,90,0.95); } /* p-lock on a lit step */
|
||
#arranger .acell.hasfollow { position: relative; }
|
||
#arranger .acell.hasfollow::after { content: "⤳"; position: absolute; top: -2px; right: 2px;
|
||
font-size: 9px; color: rgba(255,225,150,0.9); }
|
||
.seqcell.playone { background: rgba(90,150,255,0.9) !important; /* Play-One stack — Ableton blue */
|
||
box-shadow: inset 0 0 0 1px rgba(175,205,255,0.95); }
|
||
|
||
/* the groove grid — piano roll / 808 steps inside a voice's tab */
|
||
.seqgrid { display: grid; grid-template-columns: repeat(16, 12px); gap: 2px; margin-top: 6px; }
|
||
.seqcell {
|
||
width: 12px; height: 12px; border-radius: 2px; cursor: pointer;
|
||
background: rgba(120,150,200,0.14);
|
||
}
|
||
.seqcell.bar { background: rgba(120,150,200,0.24); }
|
||
.seqcell:hover { background: rgba(140,170,220,0.45); }
|
||
.seqcell.on { background: #78a0ff; box-shadow: 0 0 6px rgba(120,160,255,0.6); }
|
||
.seqcell.cur { outline: 1px solid rgba(255,238,150,0.85); }
|
||
.seqcell.off { opacity: 0.3; pointer-events: none; } /* ☸ WHEELS — a step past the lane's length is outside the wheel */
|
||
/* note length — a placed note is an overlay block spanning `len` steps, drag its
|
||
right edge (the ew-resize handle) to lengthen. Sits over the lit cells; only the
|
||
handle takes the mouse, so the grid underneath still toggles notes. */
|
||
.seqgrid { position: relative; }
|
||
.seqnote { border-radius: 2px; background: #78a0ff; box-shadow: 0 0 6px rgba(120,160,255,0.5);
|
||
pointer-events: none; position: relative; z-index: 2; }
|
||
.seqgrid.big .seqnote { border-radius: 4px; }
|
||
.seqnote .rsz { position: absolute; right: 0; top: 0; bottom: 0; width: 3px; touch-action: none;
|
||
cursor: ew-resize; pointer-events: auto; border-radius: 0 2px 2px 0;
|
||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4)); }
|
||
.seqgrid.big .seqnote .rsz { width: 7px; }
|
||
|
||
/* 🥁 GODRUM — the beat panel: a sibling of the arranger, sharing its groove & clock */
|
||
#beat {
|
||
position: fixed; left: 12px; right: 12px; bottom: 54px; z-index: 13;
|
||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
max-height: 46vh;
|
||
}
|
||
#beat.open { display: flex; flex-direction: column; }
|
||
#beat .agrip { height: 8px; margin: -8px -10px 2px; cursor: ns-resize; border-radius: 12px 12px 0 0; }
|
||
#beat .agrip:hover { background: rgba(150,180,230,0.25); }
|
||
#beat .atop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||
#beat .abody { flex: 1; min-height: 0; overflow-y: auto; }
|
||
#beat .arow { display: flex; align-items: center; gap: 3px; margin: 2px 0; }
|
||
#beat .albl { color: rgba(150,170,200,0.65); font-size: 10px; flex: 1; }
|
||
#beat .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||
#beat .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 6px; padding: 3px 8px; font-size: 11px; }
|
||
#beat .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 0 0 auto; }
|
||
#beat .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; }
|
||
#beat .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||
#beat .midibtn.rec-on { background: rgba(230,90,90,0.28); color: #ffd2d2; border-color: rgba(240,110,110,0.6); }
|
||
#beat .bmini { padding: 2px 7px; font-size: 11px; line-height: 1.1; }
|
||
#beat .grow { gap: 12px; flex-wrap: wrap; align-items: center; margin: 4px 0 2px; }
|
||
#beat .gctl { display: inline-flex; align-items: center; gap: 5px; }
|
||
#beat .gctl .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 48px; text-align: right; }
|
||
#beat .gctl input[type=range] { width: 74px; accent-color: #7fa0e6; height: 3px; }
|
||
#beat .gctl .gval { color: #aebbdc; font-size: 10px; min-width: 30px; }
|
||
#beat .glbl { color: rgba(160,180,215,0.8); font-size: 10px; }
|
||
#beat .gpreset { font-size: 10px; padding: 2px 7px; opacity: 0.85; }
|
||
#beat .eunum { width: 52px; background: rgba(20,26,40,0.8); color: #cfe0f5;
|
||
border: 1px solid rgba(120,150,220,0.3); border-radius: 4px; font-size: 11px; padding: 1px 4px; }
|
||
#beat .emrow { gap: 6px; align-items: center; margin-top: 6px; }
|
||
#beat .blane { display: flex; align-items: center; gap: 4px; margin: 3px 0; }
|
||
#beat .blead { display: flex; align-items: center; gap: 4px; width: 104px; min-width: 104px; }
|
||
#beat .bname { flex: 1; color: #aebfda; font-size: 10px; text-align: right; overflow: hidden;
|
||
white-space: nowrap; text-transform: uppercase; letter-spacing: 0.4px; }
|
||
#beat .bsteps { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px; }
|
||
#beat .seqcell { width: 26px; height: 20px; border-radius: 4px; }
|
||
#beat .exprlane { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px;
|
||
height: 46px; align-items: end; margin: 4px 0 8px 108px; }
|
||
#beat .exprcell { width: 26px; height: 46px; display: flex; align-items: flex-end;
|
||
background: rgba(30,36,54,0.6); border-radius: 4px; cursor: ns-resize; }
|
||
#beat .exprcell.bar { background: rgba(40,48,72,0.7); }
|
||
#beat .exprbar { width: 100%; background: rgba(120,150,230,0.35); border-radius: 4px; min-height: 2px; }
|
||
#beat .exprbar.lit { background: rgba(140,175,255,0.85); }
|
||
#beat .bkit { gap: 8px; align-items: flex-start; }
|
||
#beat .bvoice { display: inline-flex; flex-direction: column; gap: 2px; padding: 4px 6px;
|
||
background: rgba(20,26,40,0.5); border-radius: 6px; }
|
||
#beat .bvn { color: #9fb6dc; font-size: 10px; text-align: center; margin-bottom: 1px;
|
||
text-transform: uppercase; letter-spacing: 0.4px; }
|
||
#beatbtn {
|
||
position: fixed; right: 14px; bottom: 128px; z-index: 12; padding: 6px 12px;
|
||
border-radius: 9px; background: rgba(20,28,44,0.85); color: #9fb4d8;
|
||
border: 1px solid rgba(120,150,200,0.3); cursor: pointer; font-size: 12px;
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
|
||
}
|
||
#beatbtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
|
||
#beatbtn.on { color: #cfe0ff; border-color: rgba(150,200,255,0.7); background: rgba(30,42,66,0.9); }
|
||
#beatbtn.hidden { display: none; }
|
||
|
||
/* 🌾 AMPLER — right-side sample-slicing panel (the bottom is the drawers' turf) */
|
||
#ampler {
|
||
position: fixed; right: 12px; top: 64px; width: 300px; z-index: 13;
|
||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
max-height: 82vh; overflow-y: auto;
|
||
}
|
||
#ampler.open { display: flex; flex-direction: column; gap: 6px; }
|
||
#ampler .atop { display: flex; align-items: center; gap: 8px; }
|
||
#ampler .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||
#ampler .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||
#ampler .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
#ampler .albl { color: rgba(150,170,200,0.7); font-size: 10px; }
|
||
#ampler .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||
#ampler .midibtn.on { background: rgba(230,120,120,0.22); color: #ffc9c9; border-color: rgba(230,130,130,0.55); }
|
||
#ampler .midibtn:disabled { opacity: 0.6; cursor: default; }
|
||
#ampler .bmini { padding: 2px 7px; }
|
||
#ampler .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
|
||
#ampler canvas { width: 100%; height: 54px; background: rgba(20,26,40,0.7); border-radius: 6px; display: block; }
|
||
#ampler .sgrid { display: grid; gap: 3px; }
|
||
#ampler .scell { position: relative; height: 30px; border-radius: 4px; background: rgba(40,52,74,0.7);
|
||
border: 1px solid rgba(120,150,200,0.25); cursor: pointer; font-size: 9px; color: #9fb6dc;
|
||
display: flex; align-items: center; justify-content: center; }
|
||
#ampler .scell:hover { border-color: rgba(150,180,230,0.6); }
|
||
#ampler .scell.sel { border-color: rgba(150,200,255,0.85); background: rgba(50,66,96,0.85); }
|
||
#ampler .scell.rev { background: rgba(120,150,230,0.32); }
|
||
#ampler .scell .rv { position: absolute; top: 0; right: 2px; font-size: 9px; color: #bcd; }
|
||
#ampler .scell .rv:hover { color: #eaf2ff; }
|
||
#ampler .amap { display: flex; flex-direction: column; gap: 3px; }
|
||
#ampler .maprow { display: flex; align-items: center; gap: 5px; }
|
||
#ampler .mvn { width: 40px; color: #aebfda; font-size: 10px; }
|
||
#ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||
#ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||
|
||
/* ⚡ GODSPEED — the arpeggiator (compact, left side) */
|
||
#godspeed {
|
||
position: fixed; left: 12px; top: 64px; width: 244px; z-index: 13;
|
||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||
}
|
||
#godspeed.open { display: flex; flex-direction: column; gap: 6px; }
|
||
#godspeed .atop { display: flex; align-items: center; gap: 8px; }
|
||
#godspeed .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||
#godspeed .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||
#godspeed .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
#godspeed .grow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
#godspeed .gctl { display: inline-flex; align-items: center; gap: 4px; }
|
||
#godspeed .glbl { color: rgba(160,180,215,0.8); font-size: 10px; }
|
||
#godspeed .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||
#godspeed .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||
#godspeed .bmini { padding: 2px 7px; }
|
||
#godspeed .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
|
||
#godspeed input[type=range] { width: 88px; accent-color: #7fa0e6; height: 3px; }
|
||
#godspeed .held { color: #bcd; font-size: 11px; letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#godspeed .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||
|
||
/* 🗣 GODSPEAK — a small "listening" chip near the ♪ button, pulses while the mic is held */
|
||
#godspeakrec { position: fixed; top: 12px; right: 92px; z-index: 40; width: 30px; height: 30px;
|
||
border-radius: 8px; display: none; align-items: center; justify-content: center; font-size: 15px;
|
||
background: rgba(230,90,90,0.18); color: #ffd2d2; border: 1px solid rgba(240,110,110,0.5);
|
||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||
#godspeakrec.on { display: flex; animation: gspk-pulse 1.1s ease-in-out infinite; }
|
||
@keyframes gspk-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||
|
||
/* 📼 TESTAMENT — the record button; red + gently pulsing while it bears witness */
|
||
#testament.on { color: #ff8a8a !important; border-color: rgba(240,110,110,0.6) !important;
|
||
background: rgba(230,90,90,0.2) !important; animation: gspk-pulse 1.5s ease-in-out infinite; font-variant-numeric: tabular-nums; }
|
||
|
||
/* 🕊 PSALM — the listening chip; shows the note you're singing, brightens on voice */
|
||
#psalmchip { position: fixed; bottom: 164px; right: 14px; z-index: 12; display: none;
|
||
align-items: center; gap: 4px; min-width: 46px; padding: 6px 12px; border-radius: 8px; font-size: 12px;
|
||
background: rgba(20,28,44,0.7); color: #8fa4c8; border: 1px solid rgba(120,150,200,0.25);
|
||
cursor: pointer; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||
#psalmchip.open { display: inline-flex; }
|
||
#psalmchip.voiced { color: #cfe4ff; border-color: rgba(150,200,255,0.6); background: rgba(60,95,150,0.5); }
|
||
|
||
/* 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; }
|
||
#account .codes { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
|
||
#account .codechip { font-family: monospace; color: #7cffb2; font-size: 12px; cursor: pointer;
|
||
background: rgba(124,255,178,0.08); border: 1px solid rgba(124,255,178,0.3); border-radius: 6px;
|
||
padding: 4px 9px; user-select: text; -webkit-user-select: text; transition: background 0.15s; }
|
||
#account .codechip:hover { background: rgba(124,255,178,0.18); }
|
||
#account .codechip.copied { color: #fff; border-color: #7cffb2; background: rgba(124,255,178,0.3); }
|
||
/* admin — user management */
|
||
#account.wide .box { width: 520px; }
|
||
#account .userlist { margin-top: 6px; }
|
||
#account .urow { padding: 8px 0; border-bottom: 1px solid rgba(120,150,200,0.12); }
|
||
#account .urow .uhead { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||
#account .urow .uname { color: #eaf1ff; font-size: 13px; font-weight: 600; }
|
||
#account .urow .umeta { color: rgba(160,180,210,0.6); font-size: 10.5px; }
|
||
#account .urow .ubadge { font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; padding: 1px 6px; border-radius: 20px;
|
||
background: rgba(255,214,92,0.14); color: #ffd65c; border: 1px solid rgba(255,214,92,0.35); }
|
||
#account .urow .uact { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||
#account .urow .ubtn { background: rgba(255,255,255,0.05); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.22);
|
||
border-radius: 7px; padding: 4px 9px; cursor: pointer; font-size: 10.5px; }
|
||
#account .urow .ubtn:hover { border-color: rgba(150,180,255,0.55); color: #eaf1ff; }
|
||
#account .urow .ubtn.danger:hover { border-color: rgba(255,120,120,0.6); color: #ffb3b3; }
|
||
#account .urow .uedit { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||
#account .urow .uedit input { flex: 1 1 130px; min-width: 0; padding: 5px 8px; border-radius: 7px; font: inherit; font-size: 11px;
|
||
background: rgba(8,12,24,0.7); color: #eaf1ff; border: 1px solid rgba(120,150,200,0.25); }
|
||
#account .urow .umsg { font-size: 10.5px; margin-top: 4px; min-height: 12px; }
|
||
#account .urow .umsg .tmp { font-family: monospace; color: #7cffb2; user-select: text; }
|
||
|
||
/* ---- 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: 24px; 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; }
|
||
/* quick-start card — the fast on-ramp */
|
||
#grimoire .quickstart { max-width: 720px; margin: 6px auto 34px; padding: 18px 22px; border-radius: 14px;
|
||
background: linear-gradient(135deg, rgba(90,120,220,0.13), rgba(150,110,230,0.1)); border: 1px solid rgba(140,170,255,0.32); }
|
||
#grimoire .quickstart .qshead { font-weight: 800; color: #d3e2ff; font-size: 16px; letter-spacing: 1px; margin-bottom: 10px; }
|
||
#grimoire .quickstart ol { margin: 0; padding-left: 22px; color: rgba(210,222,244,0.92); line-height: 1.7; font-size: 13.5px; }
|
||
#grimoire .quickstart li { margin: 5px 0; }
|
||
#grimoire .quickstart .qsfoot { margin-top: 12px; font-size: 12px; color: rgba(165,185,218,0.72); font-style: italic; }
|
||
/* table of contents — the grimoire got big */
|
||
#grimtocbtn { position: fixed; top: 18px; right: 62px; z-index: 61; cursor: pointer; font-size: 13px; color: #9fb4d8;
|
||
background: rgba(20,26,44,0.85); border: 1px solid rgba(120,150,220,0.35); border-radius: 9px; padding: 5px 11px; }
|
||
#grimtocbtn:hover { color: #fff; border-color: rgba(150,180,255,0.6); }
|
||
#grimtoc { position: fixed; top: 52px; right: 24px; z-index: 62; width: 290px; max-height: 74vh; overflow-y: auto;
|
||
background: rgba(16,20,36,0.97); border: 1px solid rgba(120,150,220,0.4); border-radius: 12px; padding: 8px;
|
||
display: none; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
|
||
#grimtoc.open { display: block; }
|
||
#grimtoc .grimtoc-head { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: rgba(150,175,220,0.7); padding: 4px 8px 8px; }
|
||
#grimtoc .grimtoc-item { padding: 6px 9px; border-radius: 7px; cursor: pointer; color: #c6d5ee; font-size: 13px; }
|
||
#grimtoc .grimtoc-item:hover { background: rgba(110,145,235,0.22); color: #fff; }
|
||
#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; }
|
||
/* tech-manual reference blocks — scannable control tables */
|
||
#grimoire .spec { margin: 12px 0 6px; border: 1px solid rgba(140,170,230,0.18); border-radius: 12px; overflow: hidden; background: rgba(30,40,64,0.35); }
|
||
#grimoire .spec .row { display: grid; grid-template-columns: 156px 1fr; gap: 14px; padding: 9px 15px; border-top: 1px solid rgba(140,170,230,0.10); font-size: 13px; align-items: baseline; }
|
||
#grimoire .spec .row:first-child { border-top: none; }
|
||
#grimoire .spec .k { color: #9fd0ff; font-family: ui-monospace, Menlo, monospace; font-size: 12px; font-weight: 600; word-break: break-word; }
|
||
#grimoire .spec .v { color: rgba(205,218,240,0.9); line-height: 1.6; }
|
||
#grimoire .spec .v b { color: #eaf1ff; }
|
||
#grimoire .spec .v em { color: rgba(159,208,255,0.85); font-style: normal; }
|
||
#grimoire ol.steps { margin: 8px 0 12px; padding-left: 24px; list-style: decimal; color: rgba(205,218,240,0.9); }
|
||
#grimoire ol.steps li { padding-left: 4px; margin: 6px 0; font-size: 13.5px; line-height: 1.65; }
|
||
#grimoire ol.steps li::before { content: none; }
|
||
@media (max-width: 520px){ #grimoire .spec .row { grid-template-columns: 1fr; gap: 3px; } }
|
||
body.zen > :not(#stage):not(#ctxmenu) { display: none !important; } /* zen — nothing but the sky */
|
||
body.perform > :not(#stage):not(#ctxmenu) { display: none !important; } /* performance — pure visuals, no chrome */
|
||
</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="quickstart">
|
||
<div class="qshead">⚡ Quick-start — make it sing in a minute</div>
|
||
<ol>
|
||
<li>Press <kbd>T</kbd> — the <b>tracks & grooves</b> panel opens.</li>
|
||
<li>Pick a <b>♪ scale</b> up top — or just switch <b>skins</b> (each cosmology is its own key).</li>
|
||
<li>On a note voice (<i>lead</i>/<i>bass</i>): hit <b>⬢ euclid</b> — pulses <b>11</b>, steps <b>16</b> — for a busy, even rhythm.</li>
|
||
<li>Click 2–3 notes in one column so they turn blue: <b>⬛ Play One</b> picks one at random each pass.</li>
|
||
<li>Open the <b>ratchet</b> lane and drag a couple of steps to <b>3–4</b> for instant rolls.</li>
|
||
<li>Switch to a texture voice (<i>filter</i>, <i>glitch</i>), gate a few steps, and <b>⬗ p-lock</b> them to blocky values.</li>
|
||
<li>Press <b>▶</b>. In-key, never-repeating, self-glitching — the planet playing your machine.</li>
|
||
</ol>
|
||
<div class="qsfoot">Everything below is the full lore. Tap <b>☰ contents</b> (top-right) to jump anywhere.</div>
|
||
</div>
|
||
|
||
<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 eight shapes — every number the world sends has a temperament</h2>
|
||
|
||
<p class="lead">Beneath the hundred sources there are only <b>eight shapes of data</b>. Name the shape and the whole question of "how should this become sound?" answers itself. This is the instrument's deepest grammar — every feed, present or future, is one of these eight temperaments, and each temperament has a canonical way of being cleaned, bounded, and spoken.</p>
|
||
|
||
<p><b>1 · Drift</b> — smooth, slow, bounded. <i>The moon's phase, the seasons, life expectancy, the sun's height.</i> The world's breathing. It wants gentle smoothing and a known range, and it sounds like <b>modulation</b>: filters opening, space swelling, brightness turning. Drift is the tide under everything.</p>
|
||
|
||
<p><b>2 · Flux</b> — continuous but restless. <i>Wind, market velocity, bitcoin's fever, the room's loudness.</i> It jitters, so it wants the One-Euro filter (calm when idle, quick when it truly moves) and an adaptive window that learns its recent range. Flux sounds like <b>movement</b>: LFO rate, grit, the busyness of the percussion.</p>
|
||
|
||
<p><b>3 · Events</b> — silence, then a spike that carries a magnitude. <i>An earthquake, a wiki edit, a new wildfire, a flash of light.</i> Not a level but an <b>occurrence</b>. Events want envelopes — the quake's slow 3.5-second bloom, the edit's sharp spark — and they sound like <b>triggers</b>: hits, swells, gates thrown open, magnitude as velocity.</p>
|
||
|
||
<p><b>4 · Counts</b> — small integers stepping up and down. <i>Aircraft aloft, wildfires burning.</i> Discrete by nature; smoothing them into a continuum wastes their character. Counts sound like <b>steps</b>: how many drone voices, how many hats, a census turned to rhythm.</p>
|
||
|
||
<p><b>5 · Accumulators</b> — numbers that only ever grow. <i>The national debt, the human population.</i> As a level they are almost mute (normalized, they read "1.0, forever"), so the music lives in their <b>derivative</b> — which this instrument discovered by instinct when <kbd>debt.vel</kbd> and <kbd>clock.popvel</kbd> were built as their own feeds. The mass becomes a drone's weight; the velocity becomes its life.</p>
|
||
|
||
<p><b>6 · Angles</b> — degrees on a wheel, 0 to 360, and then the wrap. <i>Every planetary longitude, the zodiac hour.</i> An angle is not a quantity but a <b>position on a circle</b>: it wants sawtooth treatment (an orbit completing is a downbeat — Saturn crossing 0° Aries should be felt), circular mappings (pan, phase), and quantizing — twelve signs are twelve notes waiting.</p>
|
||
|
||
<p><b>7 · Enums</b> — a named phase from a small set. <i>The almanac's earth/leaf/flower/fruit element, the hour's zodiac animal.</i> Not quantities at all — <b>categories</b> — and treating them as sliders makes a fruit-day sound "33% louder" than a leaf-day, which is nonsense. An enum wants to <b>switch</b> things: the scale, the timbre, the sample bank. A categorical change is a mode change: the world modulating to a new key.</p>
|
||
|
||
<p><b>8 · Booleans</b> — yes or no. <i>Mercury retrograde, day or night, fertile or not.</i> They want to <b>toggle</b>: mute a layer, flip major to minor, throw the delay in. When retrograde switches on, the state of the world has changed, and the instrument should change state with it.</p>
|
||
|
||
<p>The whole pipeline, then, in four verbs: <b>SHAPE → CLEAN → BOUND → SPEAK.</b> Know the shape; clean it by its temperament (One-Euro for flux, envelope for events, derivative for accumulators, wrap-awareness for angles, hands-off for enums and booleans); bound it by its temperament (fixed range for drift, rolling window for flux, log for magnitudes); and let it speak in its native register — drift modulates, flux moves, events strike, counts step, angles cycle, enums switch, booleans toggle. Eight temperaments of number. The planet has no other kinds.</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 to flatter you. <b>Nearly every source is a real measurement of a real thing happening right now</b> — 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. A few, honestly, are <b>models</b> rather than live gauges: the human birth-and-death tide is a demographic model, and the room sensors (your hand, the light) fall back to a gentle simulator until a real rig is plugged in. Where the world truly is measured the hub does not synthesize it; it <i>listens</i>, 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>sats.* — the sky's schedule joins the orchestra.</b> The <kbd>world_sats</kbd> worker brings <b>real satellites</b> in as a source, but by a different trick than the ISS feed: instead of begging a web API for each position, it fetches the <b>two-line element sets</b> (TLEs) from Celestrak <i>once</i> — the space stations, plus the named recon birds <b>GAOFEN</b> and <b>COSMOS</b> — caches them, and then propagates every one locally with <b>SGP4</b>, the same orbital model NORAD uses. From that it computes each satellite's live latitude, longitude and altitude, and — for the venue city — its <b>elevation above the horizon</b>. <kbd>sats.best_elev</kbd> is the highest elevation among all tracked craft (the continuous "<i>something is passing overhead</i>" signal, <kbd>-30→90°</kbd>); <kbd>sats.overhead_count</kbd> is how many are above 10° right now; <kbd>sats.iss_lat</kbd> / <kbd>sats.iss_lon</kbd> keep the station's sweep available even when <kbd>world_iss</kbd> isn't running; <kbd>sats.recon_elev</kbd> is the best elevation among <i>only the watchers</i> — the surveillance satellites' own channel; and <kbd>sats.pass.event</kbd> fires a single impulse the moment any craft <b>crosses rising through 30°</b>, a pass beginning. In the starter patch, <i>something overhead brightens the pads</i> (best_elev → pad.brightness, amt 0.4), <i>a satellite pass throws the delay</i> (pass → delay.feedback, amt 0.7), and <i>the sky watching back sets the LFO</i> (recon_elev → lfo.rate) — so a recon bird cresting the sky above the room is a rhythm you can hear arrive. (Needs <kbd>pip install sgp4</kbd>; the worker is import-safe and simply sits out if it's absent.)</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>The trading desk — more markets, and the market's mood as signals.</b> The same Binance tap now carries <b>ETH</b> and <b>SOL</b> (<kbd>crypto.eth</kbd>, <kbd>crypto.sol</kbd>, each with its own velocity) and — because Binance quotes the euro itself — <b>EUR/USD live</b> (<kbd>fx.eurusd</kbd>, on a steady one-second pulse). The slower currencies arrive as <b>ECB daily reference rates</b>: <kbd>fx.gbpusd</kbd>, <kbd>fx.usdjpy</kbd>, <kbd>fx.audusd</kbd> — glacial, tectonic feeds like the national debt; drones, not melodies. And Bitcoin now speaks its own <b>technical analysis</b>, computed on five-second closes so it is stable no matter how fast the trades storm in: <kbd>crypto.macd</kbd> is the <b>MACD line</b> (fast EMA minus slow — momentum, z-scored so above-half means a bullish push), <kbd>crypto.rsi</kbd> is <b>Wilder's RSI(14)</b> (a natural mean-reverting wave: near 1 overbought, near 0 oversold — practically a market-driven LFO), and the <b>cross bells</b>: <kbd>crypto.cross_up</kbd> fires a single impulse the moment the fast average climbs over the slow (a golden cross in miniature) and <kbd>crypto.cross_down</kbd> when it falls through — patch them to a bell, a chord, the reverb throwing open, and the market's turning points become <i>events you hear happen</i>. Every one of these routes through the Spirits like any money: to the solar plexus, to Hod, to the dwarves in Nidavellir, to ᚠ fehu — <i>wealth is strife among kinsmen</i>.</p>
|
||
|
||
<p><b>The stadium — the world at play (opt-in).</b> Start the hub with <kbd>--sports</kbd> and TheSportsDB's free tier joins the orchestra. It stays off until invited because the free key is shared and rate-limited (30 calls a minute) — so the worker is polite by design: the day's fixtures arrive in a few calls per hour, and everything else is computed at home. <kbd>sport.events_today</kbd> counts the planet's fixtures; <kbd>sport.inplay</kbd> is how many matches are being played <i>right now</i> — the world's stadium lights as a stepped curve climbing through the evenings; and <kbd>sport.kickoff</kbd> fires the instant any match begins, swelling when many begin together — three o'clock on an English Saturday rings like a carillon. Somewhere, a whistle blows; patch it to a bell and hear the world step onto the pitch. It routes where play belongs: <b>Netzach — Victory —</b> on the Tree, the fifth house <b>Leo</b> on the wheel (the house of games), the funeral games of <b>Lughnasadh's</b> great fair, <b>ᚹ wunjo</b> the joy-rune, <b>☱ duì</b> the joyous lake. (Live scores are the paid tier; the schedule is free, and the kickoff was always the musical part.)</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>Your own sources — the Source is also you</h2>
|
||
|
||
<p class="lead">The sacred is not only the sky. It is also the body, the wallet, the pulse — exercise and money and food and joy. The world's choir is the same for everyone; <b>your own sources</b> are the verse only you can sing. Everything in this layer is <b>opt-in, yours, and private</b>: nothing is read without your say-so, and your body's data goes to your browser and nowhere else.</p>
|
||
|
||
<p><b>♥ Your heart — live now.</b> If you have a Bluetooth heart-rate device (a chest strap, many watches and fitbits in broadcast mode), right-click the sky and choose <b>♥ connect your heart</b>. The browser itself asks your permission — that is the opt-in — and from that moment <kbd>body.heart</kbd> is a source like any other: your pulse in beats per minute, normalized 40→180, ready to be patched into anything. It routes where a heart belongs in every tradition: the <b>anahata</b> chakra, <b>Tiferet</b> at the Tree's solar centre, <b>Qalb</b> among the lata'if, the <b>Fire</b> phase (the heart is the fire-organ in Chinese medicine), the <b>Sun gate</b> on the Hermetic ladder, the <b>Querent</b> in the spread. And there is a second, deeper wiring: choose <b>godtime follows your heart</b> and the instrument's tempo becomes your actual pulse — the whole planet playing at the speed of your blood. Dance, and the world keeps up. Rest, and it rests. (Web Bluetooth needs Chrome or Edge; your pulse never touches the server.)</p>
|
||
|
||
<p><b>💰 Your holdings — live now.</b> Right-click the sky → <b>💰 your holdings</b>. Declare what you hold — coins, and metals through their tokens (<kbd>PAXG</kbd> is an ounce of gold) — and <i>how much of each</i>, and the amounts become <b>weights</b>: a bigger bag sings louder, each holding's influence auto-scaled by its share of the total. Prices poll straight from the public market to <i>your browser</i> — the server never learns your bag, and the ledger lives only on your machine. Each holding becomes <kbd>me.btc</kbd>, <kbd>me.paxg</kbd>…, the whole bag breathes as <kbd>me.portfolio</kbd> (drift) with <kbd>me.portfolio.vel</kbd> as its churn (flux) — and it routes where money lives in every tradition: Hod's quicksilver, the Wardens' wages in the West, the dwarves' gold in Nidavellir, the Metal phase, your hopes and fears in the spread. Stock tickers come later (they need a keyed API); your coins and your gold can sing today. Your finances were already an instrument; we only added the speakers.</p>
|
||
|
||
<p><b>✨ Your stars — live now.</b> Right-click the sky → <b>✨ your stars</b>: birth date, time to the ten-minute, the UTC offset where you were born — and the instrument <b>casts your chart in the browser</b>, with a real ephemeris (truncated Meeus for the lights, JPL orbital elements for the planets, precessed to the tropical zodiac; verified against eclipse timings and JPL positions to a fraction of a degree). Computed here, kept here: the server never sees a chart. Then the sky's living conversation with <i>your</i> sky becomes seven sources: <kbd>natal.harmony</kbd> (transiting trines & sextiles to your points — grace), <kbd>natal.tension</kbd> (squares & <b>oppositions</b> — friction), <kbd>natal.hits</kbd> (conjunctions — emphasis), <kbd>natal.moon</kbd> (the fast hand: the Moon crossing your chart, fresh every few hours), <kbd>natal.saturn</kbd> (the slow teacher, a months-long pressure), <kbd>natal.birthday</kbd> (the solar return, swelling as your day approaches) — and the deep one, <kbd>natal.web</kbd>: your chart's own tight aspects form a <b>resonance network</b>, and a transit striking one end of your natal opposition rings the <i>whole figure</i>, exactly as astrology reads it. Your natal planets also appear as <b>gold ghosts under the godtime dial</b>, so you can watch Saturn creep toward your natal Sun for weeks before you hear it arrive. Transit orbs are kept tight (aspects peak sharply at exactness, as transit practice demands) so these sources spike and breathe rather than drone. Load anyone's chart — yours, a lover's, a friend's; the instrument plays relationships too. Give it your <b>birthplace</b> (lat/lon) and the chart gains its <b>angles</b>: the ascendant and midheaven, computed from the sidereal time of your birth (the formula is verified against the sunrise itself — the Sun stands on the ascendant as it rises). Your <b>rising sign</b> is announced when the chart is cast, transits striking the angles become <kbd>natal.angles</kbd> (hard contacts to the ASC and MC — among the heaviest events in transit work), and the <b>wheel skin</b> draws your <b>whole-sign houses</b>, the oldest house system there is, with the ASC and MC marked in gold. One honest caveat from the mathematics: the angles move a degree every four minutes, so they are only as true as your birth time. And then, <b>synastry</b>: save two charts and press <b>⚭</b> on the second — the instrument computes every aspect <i>between</i> the two charts (the inter-aspect web) and the relationship itself becomes sound: <kbd>syn.harmony</kbd> and <kbd>syn.tension</kbd> as the bond's standing grace and friction, <kbd>syn.hits</kbd> its meetings, <kbd>syn.web</kbd> ringing live whenever the moving sky strikes either person's end of any bond-aspect, and <kbd>syn.moon</kbd> — the bond's daily weather, freshened every few hours by the Moon. On the zodiac wheel the second chart appears as a <b>silver ring inside the gold one</b>, the bond's own chords strung between the two charts. Your oppositions to another person's planets, audible.</p>
|
||
|
||
<p class="foot">One Source, many Spirits, one Matter — and the Source was never only "out there." The instrument's ambition is simple: <b>everything that varies can sing</b>, and the first citizen of that everything is you.</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><b>Zoom in.</b> Scroll (or pinch on a trackpad) to zoom the whole sky toward your cursor — get right up into the orbits, the cables, the chakra wheels. Once you're in, <b>drag empty space to pan</b> around, and <b>double-click</b> to snap back out to the full view.</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>Hover a crowded column — the zoom finds the one you want.</b> The left column can hold dozens of sources packed a few pixels apart, too tight to click just one. Rest the cursor near it and a <b>magnified strip</b> unfolds beside it — the nine feeds nearest your cursor, spaced out and fully named with their live values, the one under you highlighted. Every row is the real thing: click to mute, ⇧-click for its tab, ⌘-click to group, or drag it straight onto a voice to patch. Reach across into the strip and it holds still for you. (Right-click the sky → <i>hover zoom</i> to turn it off.)</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> Everything that is not the world vanishes: the wordmark, the header, the counts, the godtime readout, the ticker, every button and chip — <i>pure visuals</i>, with only 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>) rising for the audience. Put the console on the projector, hit <kbd>P</kbd>, and conduct. (Want even the labels and dispatches gone? <kbd>Z</kbd> is zen — nothing but the sky.)</li>
|
||
<li><b>Press <kbd>W</kbd> — the sky wheel.</b> The zodiac rises behind the whole instrument, huge and faint, drawn the way a natal chart is drawn: a <b>sign band</b> with the twelve glyphs in their element colours and degree ticks running the rim, the <b>living planets</b> just inside it exactly where they stand tonight, the sky's <b>aspects strung as chords</b> across the wheel — red for the hard angles, green for the soft, gold for the meetings — and <b>the sign the Sun is walking right now lit in its own colour</b> (Cancer in early July), so one glance says where in the year you are. Cast a chart (✨ your stars) and the layer deepens: your <b>whole-sign houses</b> divide the wheel, the ASC and MC stand in gold, your natal planets sit as gold ghosts on the inner ring, and transit chords strike them as the sky moves. The same wheel the zodiac skin draws, now living under the main view. <kbd>W</kbd> again to put it away; it remembers.</li>
|
||
<li><b>Press <kbd>Z</kbd> — zen.</b> Every word vanishes: labels, readouts, panels, the header, the ticker, the dial — all of it — and only the visuals remain, full screen. The spheres, the cables, the shockwaves, the sky wheel if it's up, still moving to the world; nothing left to read, everything left to watch. <kbd>Z</kbd> or <kbd>esc</kbd> brings the words back.</li>
|
||
<li><b>🌱 Lifestrument — the gentle door.</b> The same hub, a second instrument: <a href="/life.html" style="color:#9fd0ff">godstrument.pro/life.html</a> plays only the sources about <i>living</i> — your pulse, the weather, the sun going down, a baby born somewhere — as eight one-tap <b>Vibes</b> with two knobs and no cables at all. Made for a phone in one hand and a friend who doesn't want a manual. This console is where you build; that one is where you feel. (Right-click the sky → 🌱 lifestrument.)</li>
|
||
<li><b>The keyboard is yours — <kbd>F1</kbd>–<kbd>F10</kbd> and beyond.</b> A default set of function keys is ready to fly: <kbd>F1</kbd> the grimoire, <kbd>F2</kbd> play/stop the synth, <kbd>F3</kbd> the skin view, <kbd>F4</kbd> next skin, <kbd>F5</kbd> cycle mood, <kbd>F6</kbd> tracks, <kbd>F7</kbd> performance mode, <kbd>F8</kbd> lock the godtime, <kbd>F9</kbd> the earth echo, <kbd>F10</kbd> the stage. But every key is remappable: right-click the sky → <b>⌨ keys</b> to bind any key to any action, or <b>map a key</b> from a voice's own right-click menu. Want <kbd>a</kbd> and <kbd>s</kbd> to raise and lower a drone's level like a fader? Right-click that voice → <i>map a key → raise its level</i>, press <kbd>a</kbd>; again for <i>lower</i>, press <kbd>s</kbd> — now they nudge it live, hold to sweep. Your whole flow, under your fingers.</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>
|
||
<li><b>Skins — the same spine, another cosmology.</b> Right-click the sky and cycle <b>skin</b>. Beyond the seven <i>chakras</i> there is <b>the planes</b>: the Seven Planes & forty-nine sub-planes of the Cosmic Physical Plane from the Alice Bailey / D.K. teachings — <b>I Divine</b> (white light) down through <b>Monadic</b> (violet), <b>Atmic</b>, <b>Buddhic</b>, <b>Mental</b> (where the egoic lotus sits), <b>Astral</b>, to <b>VII Physical</b> (red). The spine becomes the 49-sub-plane spectrum ladder; the side-brackets mark the <b>Monad</b>, the <b>Spiritual Triad</b> and the <b>Personality</b>; and every feed climbs to the plane that matches its rarefaction — the sun's own wind at the Divine, orbits and the sky's geometry at the Monadic, the great cyclic wheels at the Atmic, omen and aspect at the Buddhic, abstract thought and money-as-idea at the Mental, desire and fire and felt sensation at the Astral, and the dense body of the world — weather, quakes, the mortal rates, your own hands — at the Physical. Go inside any plane (mind mode) and its keys play a scale pitched to its altitude: airy whole-tone up at the Divine, grounded minor-pentatonic down at the Physical.</li>
|
||
<li><b>One Source, many Spirits, one Matter.</b> That is the whole architecture: the <b>Source</b> is the world's data, the same for everyone; the <b>Spirit</b> is the skin — the belief system through which you read it; the <b>Matter</b> is the sound. Choose your spirit, and the same planet plays through it — <b>literally re-tuned</b>: each tradition carries its own scale, so when you turn the wheel of skins the whole world re-harmonises into that cosmology's key (your grooves <i>and</i> the data's own melodies alike). The chakras sing one way, the Wu Xing another, the runes another still — one planet, many keys. The shelf so far, each rendered from its own published lore, literally and without frills: the <b>Tree of Life</b> (Hermetic Qabalah, Golden Dawn attributions — ten sephirot on three pillars, all twenty-two canonical paths, Mercury's data at Hod, the Moon's at Yesod, the body of the world at Malkut); <b>the Lodge</b> (from published Masonic sources only — Worshipful Master in the East, Wardens West and South, the North dark, Boaz and Jachin at the porch where <i>your own hands</i> stand as the candidate, the Blazing Star, the Volume of the Sacred Law as humanity's open book, and music given to the South, for Beauty holds the arts); the <b>Hermetic ladder</b> of the seven planetary gates, lead to silver; <b>Yggdrasil</b> and its nine worlds, the dwarves keeping the markets in Nidavellir; the Sufi <b>lata'if</b>, where the wind data literally breathes at Ruh, spirit-as-breath; and the <b>Wu Xing</b>, five phases in their generating ring and controlling star, the metal birds of the air routed to Metal; and <b>the Spread</b> — the Celtic Cross of the tarot (Waite's published layout), dealt live and never finished: the heavens in <i>what crowns you</i>, the deep debts and quake-roots in <i>what is beneath you</i>, the retrogrades in <i>what is behind you</i>, births and seasons in <i>what is before you</i>, money and fire in <i>your hopes and fears</i> — and <b>your own hands in the Querent's place</b>, because you are the one asking. Right-click the sky → <b>skin</b> to turn the wheel of traditions. Each map stands as its true figure — the Tree as a tree, the Lodge as a lodge — and every station opens into mind mode with its own scale under your hands. Newest on the shelf: <b>the wheel</b> — the astrologer's own view, twelve signs in a circle with 0° Aries at the ascendant's place, every feed routed to its sign by traditional rulership (wildfires to Aries, your holdings to Taurus, the word to Gemini, the far-travelling planes to Sagittarius…), the <b>live planets riding the rim</b>, your natal chart on the inner ring, and the aspects drawn as <b>chords across the circle</b> — red the squares and oppositions, green the trines and sextiles, gold the conjunctions, dashed where the living sky strikes your natal points. And everywhere else, those same aspects now draw as faint <b>cables between the planets' own spheres</b> in any skin — the sky's geometry made visible in whatever cosmology you're standing in. And every Spirit now speaks <b>its own tongue</b>: the sephirot in Hebrew (כתר, חכמה, בינה…), the chakras in Devanagari (सहस्रार, अनाहत…), the lata'if in Arabic (قلب, روح, سر…), the nine worlds in Old Norse (ásgarðr, miðgarðr, helheimr…), the planetary gates in their glyphs (☽ ☿ ♀ ☉ ♂ ♃ ♄), the five phases in their characters (木 火 土 金 水). The lore, in the letters it was written in. Two more Spirits joined the shelf: <b>the runes</b> — the Elder Futhark, all twenty-four in the Kylver-stone row, ringed as a rune-band, every feed seated by its rune-poem meaning (wealth's strife at ᚠ fehu, Odin's word at ᚨ ansuz, the giant's blow at ᚦ thurisaz for the quakes, need's narrow band at ᚾ nauthiz for hunger and want, the lot-cup ᛈ perthro holding your natal fate, and <b>your raised hands at ᛉ algiz, the warding rune that is itself a figure with arms lifted</b>); and the <b>Wheel of the Year</b> — the eight Celtic festivals at their true solar longitudes (the astronomical reckoning: quarters at the equinoxes and solstices, cross-quarters at fifteen degrees of the fixed signs), deaths and the deep things at Samhain where the veil thins, births at Imbolc, the fires and the dance and the bond at Beltane, the great fair of commerce and chatter at Lughnasadh — <b>with the live sun walking the rim, showing exactly where the year stands today</b>. Honesty note kept from the scholarship: the unified eightfold wheel is a modern synthesis of the attested Irish fire festivals and older solstice customs — true lore, honestly labeled. And newest of all, the first Spirit with no temple: <b>the artist</b> — Kandinsky's <i>Concerning the Spiritual in Art</i> (1911) taken at its word, his colour-sound table rendered as a wireframe easel. Six pigments squeezed round the palette — <b>gelb</b> the trumpet blown louder and louder (the news, the markets' mania), <b>orange</b> the Angelus bell (the clocks, the hours), <b>rot</b> the drumbeat (your heartbeat, the quakes, the flame), <b>grün</b> the violin's placid middle strings (the weather at rest), <b>blau</b> deepening from flute toward the organ (the whole heavenly circle), <b>violett</b> the bassoon (the retrogrades, Saturn, the sorrows) — with <b>weiß</b>, the great silence pregnant with possibility, waiting as the canvas itself (your unpainted chart lives there, and <i>your own hands hold the brush</i>), and <b>schwarz</b>, the pause after which nothing follows, at the palette's edge with the deaths and the debt. His four great contrasts strung dashed between the drops, and as each colour's data sings, its paint <b>strokes itself up onto the canvas</b> — the world painting, live. Labels in German, the tongue he wrote it in. And beside the easel now hangs <b>the bagua</b> — the I Ching's eight trigrams in the King Wen "Later Heaven" ring, south at the top as the old compasses have it, every feed seated by the Shuogua's own attributions: the earthquakes at <b>☳ zhèn, the trigram that literally means thunder-shock</b>; the word at ☱ duì, for "Dui is the mouth and tongue"; the moon's data at ☵ kǎn, the sun's at ☲ lí; and <b>your own hands at ☶ gèn, for the Shuogua says plainly: Gen is the hand</b>. And at the centre, the oracle never rests: the two loudest trigrams of the moment stack into a <b>live hexagram</b> — the strongest below as the ground of the moment, the second above — drawn line by line in their own colours and named from all sixty-four by the King Wen table, re-cast as the world's weather shifts. When the planet falls silent it settles at hexagram 11, <i>Peace</i>: heaven below, earth above. The book that reads itself, forever. And the newest Spirit is the one with no lore at all — <b>the god's eye</b> — the world's own sensor picture read as the cosmology: a minimal equirectangular <b>Earth</b> with a 30° graticule, and every feed seated at its <i>literal coordinates</i>. The earthquakes flash where the ground actually moved; the ISS and the recon birds sweep as a moving point trailing light; the aircraft are a shimmer band across the busy mid-latitudes; the markets pulse at their financial capitals — <b>New York, London, Tokyo</b>; the solar wind washes the poles as an <b>aurora</b>; the word sparks at random civilisational coordinates; and <b>your own hands are the ring at the venue</b>, wherever on the map you happen to be. A cold watch-floor palette — near-black, cyan, warning amber — and a Bb minor-pentatonic retune, so stepping inside <i>sounds</i> like a surveillance room. Not a belief system but its opposite: the map with nothing hidden. <b>The eye that hears.</b></li>
|
||
<li><b>The groove grids — sequence the voices.</b> Open any voice's tab (shift-click it) and there's a <b>16-step grid on the godtime clock</b>. The note voices (<i>lead, bass</i>) get a <b>mini piano roll</b> — eight rows of the scale, click to place a note, click again to clear, and <b>drag a note's right edge to stretch it across the steps — real note length</b>, from a staccato sixteenth to a bar-long hold (the built-in synth holds it on the standard pluck voice — 🎸 string and 🎛 GODSONIQ keep their own natural ring — and MIDI out always honors the drawn length) — and while your groove is on, <i>your melody replaces the data's</i>: bitcoin lets go of the lead and you take it. Every other voice gets an <b>808-style gate row</b> that rhythmically chops the world's signal — the reverb, the grit, the percussion breathing in your pattern while the planet still decides how hard. Grooves ride the godtime tempo (lock the dial, or let your heart drive it), and they <b>save with your patch</b>. And the <b>sources</b> get the grid too, with a different power: <b>sample & hold</b> — while a source's groove is on, the world's number is only <i>sampled</i> on your lit steps and held between them, so a smooth drifting feed becomes stepped, rhythmic, quantized in time. Bitcoin arpeggiating in your pattern. The wind as a sequence. If an out port is chosen in ⚙, godtime also sends <b>MIDI clock</b> (24 ppqn), so a DAW can slave its tempo to the planet — or to your pulse. And the grids play <i>outward</i> too: with an out port chosen, <b>piano-roll notes go out as real MIDI</b> on the lead and bass channels (each note as long as you drew it), and the <b>mind-mode console plays on its own third channel</b> — so your grooves and your chakra-scale jams drive Ableton, Strudel, or hardware exactly as they drive the built-in synth. The three mouths drink at once: ♪ for the browser synth, the out port for MIDI, both or neither — there is no switch, only where you point the sound.</li>
|
||
<li><b>Press <kbd>T</kbd> — tracks, for making actual songs.</b> A conventional tracks-and-clips view for anyone who finds the cathedral overwhelming: every voice is a <b>track</b>, eight <b>bars</b> run across, and each cell cycles <b>● live</b> (the world plays that lane) → <b>A</b> → <b>B</b> → <b>C</b> (clip patterns on the groove grids — A and B come pre-seeded so it makes music immediately) → <b>silent</b>. Double-click any clip to edit its groove in the piano roll. Press <b>▶</b> and the arrangement loops on the godtime clock — the planet still plays <i>through</i> your song, because live lanes stay wired to the world. Arrangements <b>save with your patch</b>, play out over MIDI like everything else, and <b>export .mid</b> hands you a Standard MIDI File of the note lanes to drag into Ableton, Logic, anything. Not a DAW — a sketchpad where the world sits in on your session. The panel earns its keep like one, too: a <b>🎛 tracks button</b> sits bottom-right always; <b>drag the panel's top edge to resize</b> (remembered); the top bar <b>tabs between clips and grooves</b> — the grooves tab is a full-size pattern editor (pick a track, pick a letter, big piano roll or 16 steps; double-clicking any clip jumps you straight there); and <b>right-click any track name for that voice's full routing menu</b> — every cable, every level, patch and unpatch without leaving the panel.</li>
|
||
<li><b>The Groove — one feel for the whole pattern.</b> The grooves tab carries Ableton's Groove Pool, reduced to the 16-step grid: <b>swing</b> lays the off-beats back, <b>humanize</b> is a touch of random timing so nothing is machine-stiff, <b>velocity</b> lets each hit breathe in loudness, and an <b>amount</b> master scales the lot — with one-click presets (<i>straight, swing 16, MPC 8-4, drunk</i>). Under the roll sit three expression lanes: <b>velocity</b> (how hard each step hits), <b>chance</b> (how likely each step fires at all — drop a step to 40% and it plays four times in ten, so the loop varies itself), and <b>ratchet</b> (how many times a step re-fires, 1–4 — an instant roll or 32nd-note glitch from a single step, and if that step is a Play-One stack each sub-hit re-rolls the note). The feel and the per-step shaping <b>save with your patch</b>. And the <b>texture voices</b> (the filter, the glitch, the reverb, the drive) get their own lane: a <b>⬗ p-lock</b>, the Pocket-Operator move — pin that voice to a blocky value on any step, so the filter jumps to a different cutoff on every hit, disjointed and alive; drag a step below the lane to hand it back to the world. And any lane takes a <b>⬢ Euclidean fill</b> — say how many <i>pulses</i> to spread across the steps and they distribute themselves as evenly as possible (E(3,8) is the tresillo, E(5,8) the cinquillo, E(4,16) four-on-the-floor), with <i>steps</i> and <i>rotate</i> to taste: the oldest rhythms of the world fall straight out of the arithmetic.</li>
|
||
<li><b>♪ One scale for the whole planet, and ⬛ Play One for the glitch.</b> Set a <b>root and scale</b> at the top of the grooves tab and every melody you draw is in that key — the piano roll is only ever the scale's own notes, so nothing lands wrong; change the scale and your patterns <i>re-harmonise</i> in place. And a column isn't limited to one note: <b>stack several</b> and it turns blue — a <b>Play One</b> group, from which the sequencer picks a single note at random each time it fires. Stack a handful of in-key notes under a fast Euclidean fill and the line glitches itself into something new on every pass, always in tune. (Adapted from Ableton's <i>Group Notes → Play One</i>.)</li>
|
||
<li><b>⤳ Follow actions — the song composes itself.</b> Give any clip (A/B/C) a <b>follow action</b> in the grooves tab — <i>play again, next, any, any other, stop</i> — with a <b>chance</b>, and once the arrangement is playing that clip can hand off to another at the bar line instead of just repeating. Set two clips to "any other" and they trade the lane back and forth; sprinkle a low-chance divert and the piece drifts. As Ableton's manual puts it, "structures that repeat but can also be surprising… never exactly repeat." A ⤳ marks any clip that can divert. Nothing changes for arrangements without follow actions — they loop exactly as written.</li>
|
||
<li><b>🥁 The beat — five drums on the godtime clock.</b> Press <kbd>B</kbd> (or the <b>🥁 button</b>) and the beat drawer opens: five lanes — <i>kick, snare, clap, closed hat, open hat</i> — synthesised from oscillators and noise like every other voice, so they ride the world's saturation, crush and reverb too. Click a step to draw; <b>▶</b> starts all five at once. They live on the same godtime clock as the grooves, and the feel — <b>swing, humanize, velocity, amount</b> — is <i>the very same</i> the tracks use (shared, not copied). The same three expression lanes shape each step (<b>velocity</b>, <b>chance</b>, <b>ratchet</b>), and <b>⬢ euclid</b> spreads N pulses across the sixteen. Open the <b>🎛 kit</b> drawer to voice each drum with its own knobs — <i>tune or tone, punch or snap, decay, level, pan</i>, as fits the voice — and on the kick, <b>duck</b>: every kick <i>parts the sea</i>, dipping the pads and drone out from under itself so the thump lands in its own air. The beat <b>saves with your patch</b>; a <b>mood never silences it</b> — the world's hands change, but the beat belongs to you; and a beat drawn in <b>zero</b> counts as a build, so leaving zero offers to keep it.</li>
|
||
<li><b>Keys <kbd>1</kbd>–<kbd>5</kbd> & ⏺ rec — finger-drum the kit.</b> With the beat open the number row <i>is</i> the kit — <kbd>1</kbd> kick, <kbd>2</kbd> snare, <kbd>3</kbd> clap, <kbd>4</kbd> hat, <kbd>5</kbd> open hat — played by hand, live, straight into the world's FX. Hold <b>⇧</b> for the ghost note, softer. Arm <b>⏺ rec</b> and every tap writes itself into the grid, <b>quantized to the nearest sixteenth</b> — tap a hi-hat pattern into a quiet pocket and it's a lane — so you play the beat in rather than clicking it out.</li>
|
||
<li><b>🌾 AMPLER — there is ample, for god is abundant.</b> The SP-404 move, made of the planet. Right-click the sky → <b>🌾 AMPLER</b>: <b>⏺ capture</b> a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab <i>is</i> the world, that YouTube loop counts), then <b>✂ chop</b> it into 8 or 16 slices. Tap a slice to audition it, <b>⇄</b> to play it backwards, then <b>seat a slice onto a drum voice</b> — and that lane stops synthesising and starts <i>playing the world</i>. The slice rides the voice's own <i>level</i> and <i>pan</i> and, on the kick, its <b>duck</b>; a slice on the open hat still chokes under a closed one — the machine's rules don't care whether a hit is oscillator or sample. Sample anything playing on the machine, chop it, and it's a kit. Session-only for now: the slices don't travel with your patch (yet).</li>
|
||
<li><b>The beat leaves the building.</b> The drum lanes ride <b>MIDI out on channel 10</b> — General MIDI percussion, <i>kick 36, snare 38, clap 39, closed hat 42, open hat 46</i> — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in <b>export .mid</b>: draw a beat, hit export, and the Standard MIDI File carries a channel-10 drum track (looped across the arrangement's bars) alongside your lead and bass — drag it into Ableton and the planet's rhythm is already programmed.</li>
|
||
<li><b>⚡ GODSPEED — hold a chord, the machine runs.</b> Press <kbd>G</kbd>. Enter a chakra (<kbd>C</kbd>), turn GODSPEED <b>on</b>, and <b>strike keys to latch a chord</b> — each note stays held until you strike it again. Then the machine runs with it, one note at a time, <i>on the godtime clock</i> — so it <b>swings with the groove</b>, laying its off-beats back exactly as the drums do, because it plays from the same swung tick. Set the <b>rate</b> (1/4 → 1/32), the <b>direction</b> (up, down, up/down, random), the <b>octaves</b> (×1–3) and the <b>gate</b> (how long each note rings). A <b>MIDI keyboard latches too</b> — its notes route through the same hands — and the arp plays <i>outward</i> on channel 2, the mind console's channel, so it drives your rig as it drives the synth.</li>
|
||
<li><b>🫀 GODSQUASH — the master squeeze the world can pump.</b> In <b>OMNI</b>'s fx rack sits one new knob: <b>squash</b>, a compressor across the whole master bus. Turn it up and the <b>threshold dives while the ratio climbs together</b>, a matched gain riding underneath so the <i>loudness stays level while the peaks kneel</i> — the mix gets denser, not louder. And because it's a destination like any other, <b>route a feed onto it</b> — the quakes, your heartbeat, the market's velocity — and the planet <b>pumps the mix</b>, breathing the whole instrument in time with the world.</li>
|
||
<li><b>🗣 GODSPEAK — the voice of god.</b> On your own machine (<i>Apple-Silicon only</i>), run <code>run.py --godspeak</code>, then <b>hold Right ⌥ and speak</b>. Your words are heard <b>locally</b> — your own Whisper, nothing leaving the machine — and become commands: <i>"tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen"</i>, and — the voice of god undoing its own works — <i>"take it back"</i>. A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply <b>arrives in the world</b>, printed to the ticker — the word is never an error, only another feed.</li>
|
||
<li><b>⏪ UNGODLY — take it back.</b> <kbd>⌘Z</kbd> undoes; <kbd>⌘⇧Z</kbd> redoes. The instrument remembers your last <b>thirty-two acts</b> — every cable patched, mood applied, groove and scale changed, kit knob turned, beat step drawn, level and mute — and steps back through them, un-doing what god (or you) did. A held drag counts as one act, not fifty. It is <b>memory-only</b>: a reload forgives everything and starts the history clean.</li>
|
||
<li><b>📼 TESTAMENT — record what god does.</b> Beside the <kbd>♪</kbd> sits a <b>⏺</b>. Press it and the instrument records <i>itself</i> — the true final mix, taken after the squash and the limiter, exactly what you hear. The button turns red and counts the minutes; press it again and a file drops to your disk (<kbd>godstrument-testament-…webm</kbd>), and the ticker says <i>it is written</i>. Start it before the beat and it captures the session from its first breath; it needs no setup and no other app — the planet, at last, can bear witness to itself. (Also on the sky menu, near GODSONIQ.)</li>
|
||
<li><b>☸ WHEELS — a wheel within a wheel.</b> <i>"…their work was as it were a wheel in the middle of a wheel"</i> (Ezekiel 1:16). Each lane keeps its own length: set the <b>☸ wheel</b> chip in a beat lane (or the arranger's euclid row) to anything from 1 to 16, and a lane shorter than the full sixteen <b>wraps on its own turn</b>. A hat of twelve over a kick of sixteen drift apart and only realign every forty-eight steps — <b>polymeter</b>, the oldest trick for making a loop that never quite repeats. Steps past the wheel dim out; the playhead rides each lane's own length; and the pattern <b>exports to MIDI exactly as it plays</b>, twelve-wheel and all. A full wheel of sixteen behaves precisely as it always did.</li>
|
||
<li><b>🕊 PSALM — sing, and the instrument answers.</b> Right-click the sky → <b>🕊 PSALM</b>, allow the microphone, and <b>sing</b>. The pitch is heard, <b>folded to the scale you're in</b> (so you're never out of key), and played as notes — a <b>🕊</b> chip shows the note leaving your throat. And the duet with <b>⚡ GODSPEED</b>: with the arp running, each note you sing <b>latches into the held chord</b>, one at a time — hum four notes and the machine arpeggiates the chord you sang. <b>It listens locally</b>: the analysis happens in the page, in its own silence, and <i>nothing ever leaves the machine</i> — no recording, no upload, just your voice becoming the instrument's.</li>
|
||
<li><b>✦ CANON — the shelf of worked examples.</b> Open <b>⚙ → DIMENSIONS</b> and at the top of the list, above your own saved worlds, sit four <b>✦ factory dimensions</b> that ship with the instrument — <i>the heartbeat</i> (a boom-bap kit with the swing and the kick's duck), <i>the wheel within the wheel</i> (WHEELS, made audible), <i>the squeeze</i> (the market pumping GODSQUASH), and <i>quiet arrival</i> (the earth echo, slow and sparse). Load one to hear a feature in its element, then save your own version under a new name — the ✦ ones are <b>read-only</b>, the same for everyone, a place to start rather than a thing to overwrite.</li>
|
||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</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>The built-in voice, up close.</b> Press <kbd>♪</kbd> and <b>OMNI</b> — the built-in synth — wakes. Its plucked notes ring with a <b>DX7-style FM tine</b> — brighter the harder they're struck; its pads bloom into a <b>detuned supersaw</b>; a <b>Juno chorus</b> warms the whole thing. Prefer strings? Right-click the sky → <b>🎸 string voice</b> and every pluck becomes a real <b>Karplus-Strong</b> plucked string. And route anything to <kbd>crush</kbd> for a <b>12-bit-DAC bitcrusher</b> — the planet degrading its own converter into lo-fi grit. (Every one of these was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)</p>
|
||
|
||
<p><b>🎛 GODSONIQ — resample the world onto the keys.</b> A cheeky nod to the Ensoniq ASR-10: right-click the sky → <b>🎛 GODSONIQ</b> and it records ~3 seconds of the <em>live</em> master mix — whatever the world is playing right now, this exact never-repeating take — into a buffer. From then on every pluck <b>replays that capture, pitched across the keyboard</b> (middle C plays it at speed, an octave up plays it twice as fast). It runs through the full FX chain, so route <kbd>crush</kbd> at it and you get the classic 12-bit sampler grit. Right-click → 🎛 again to grab a fresh slice; <b>🎸 string voice</b> (or toggling it off) drops you back to the FM tine. The browser-native bounce: freeze a moment of the planet, then play the planet like an instrument.</p>
|
||
|
||
<p><b>🌐 Feed a tab into the world.</b> Right-click the sky → <b>🌐 feed a tab</b> and the browser's share-picker opens — choose another tab (a YouTube video, a live radio stream, a Bandcamp page), tick <b>“Share tab audio,”</b> and that sound pours straight into Godstrument's FX chain. It's <em>in the world</em> now: route <kbd>crush</kbd> to grit it, drench it in the world's reverb, or hit <b>🎛 GODSONIQ</b> to resample it and play it pitched across the keys. Click 🌐 again (or the browser's <i>Stop sharing</i>) to cut it. <b>The fine print:</b> this is a <b>Chrome / Edge</b> trick (Safari and Firefox can't capture tab audio at all); on a Mac it grabs one <em>tab's</em> sound, not the whole system (for that you'd route audio through a virtual device like BlackHole); and DRM'd services (Netflix, Spotify, Apple Music) usually come through silent — ordinary YouTube is fine.</p>
|
||
|
||
<p><b>Zero — the moods, the gentle front door.</b> On your very first visit the instrument greets you with <b>🌅 first light</b> instead of the full flood: a bass line, soft hats, a small melody, modest space — a thing shaped like a song, with the world still playing underneath it. Right-click the sky → <b>🌗 mood</b> to move between <i>first light</i>, <i>ambient drift</i> (no drums, all atmosphere, the planet as weather), and <b>the full storm</b> — the factory instrument with every one of the world's hands on it at once. Ease in, then open the door.</p>
|
||
|
||
<p><b>One — OMNI, 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>🌀 The Earth Echo — a Space Echo whose tape is the planet.</b> Right-click the sky → <b>🌀 earth echo</b>, press <b>♪</b>, and the whole mix runs onto a virtual Roland tape delay — but its knobs are wired to the living world. Its <b>repeat rate</b> is the wind (Tokyo's gusts whip the tape speed, and because a tape motor has inertia the pitch <i>glides</i> as it changes — that dubby seasick bend); its <b>intensity</b> is volatility (bitcoin's fever climbs the feedback toward a screaming, self-oscillating howl); its <b>tone</b> is the air (Delhi's PM2.5 rolls the treble off until the echoes melt into a dark, suffocating soup); its <b>wow & flutter</b> is the Moon (the lunar cycle warbles the pitch with an eerie, drifting vibrato); its <b>tape wear</b> is the world's fire (every wildfire adds hiss, the machine growing more fragile as the planet burns); and a <b>quake</b> throws the whole echo forward. Best of all, these six knobs are ordinary <b>voices in the matrix</b> — <kbd>echo.time</kbd>, <kbd>echo.feedback</kbd>, <kbd>echo.tone</kbd>, <kbd>echo.flutter</kbd>, <kbd>echo.wear</kbd>, <kbd>echo.wet</kbd> — so you can drag <i>any</i> feed onto any of them, groove them on the 16-step grid, or send them out as CV. Turn it on and the world doesn't just play the instrument; it plays the <i>space</i> the instrument lives in. And underneath it all, always, the <b>Schumann resonance</b>: the cavity between the Earth's surface and the ionosphere rings at <b>7.83 Hz</b> (predicted by Winfried Otto Schumann, 1952), pumped by every lightning strike on the planet — the Earth's electromagnetic heartbeat. It sits below human hearing, so it plays the <i>machine</i> instead of the ear: a 7.83 Hz pulse forever breathing the echo's tone and laying a second, planetary wow under the moon's — and, octaved up ×4 (two octaves) to <b>31.32 Hz</b>, it hums as a true sub beneath the drone voice. It is the one dial the world never turns, because it is the size of the Earth itself — and the Earth's size does not change.</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>The Ableton recipe — same Mac, three minutes, your whole plugin collection becomes the instrument.</b> Native VST/AU plugins can't run <i>inside</i> a browser page — the sandbox forbids it, by design. But they don't need to: Ableton is the plugin host, and the Godstrument drives it. <b>1)</b> Open <i>Audio MIDI Setup → Window → Show MIDI Studio</i>, double-click <b>IAC Driver</b>, tick <i>Device is online</i> — that's a virtual MIDI cable inside your Mac. <b>2)</b> Here: ⚙ → WEB MIDI → enable → out port = <b>IAC Bus 1</b>. <b>3)</b> In Ableton: <i>Settings → Link/Tempo/MIDI</i> → the IAC input: <b>Track</b> and <b>Remote</b> on (and <b>Sync</b> on, if you want godtime — or your heartbeat — to be Ableton's tempo: then press EXT in the transport). <b>4)</b> Drop any instrument on a MIDI track, monitor <i>In</i>, and the <b>lead</b> (ch 1) and <b>bass</b> (ch 2) play it live; hit ⌘M and MIDI-map the CCs above to any macro, any plugin knob, any send. Bitcoin playing your Serum, earthquakes riding your favorite reverb's decay, Delhi's air on a distortion — every plugin you own, played by the planet. The reverse works too: MIDI-learn here binds your hardware knobs to the console's own controls.</p>
|
||
|
||
<p><b>⚡ Control voltage — the world as raw electricity, into modular gear.</b> Two ways to make the planet move a Eurorack. <b>The easy way, already live:</b> the note voices <i>are</i> 1V/oct pitch + gate and the CCs <i>are</i> modulation, so route them out (⚙ → Web MIDI, or the Ableton/IAC recipe above) into any <b>MIDI-to-CV module</b> — a Befaco CV Thing, a Behringer CM1A, an Expert Sleepers FH-2, a Mutable Yarns — and BTC's price is bending an oscillator, the earthquake is opening a filter, the clock is your analog sync. Nothing to install; it works today. <b>The pure way:</b> right-click the sky → <b>⚡ control voltage</b> and the browser emits <i>true DC voltage</i> straight from Web Audio into a <b>DC-coupled interface</b> (an Expert Sleepers ES-8 or ES-9 in your case). Map any feed to any output channel, choose <b>unipolar</b> (0→+V), <b>bipolar</b> (−V→+V) or <b>1V/oct</b> pitch, and leave the calibration knob where your oscillator actually tracks — because a real interface and a real VCO never agree with the maths. Stock price to a drone's pitch, volatility to its filter, volume to its drive: sit back and listen to the global economy breathe and crash through an analog voice. (DC out needs a DC-coupled interface; a normal soundcard blocks it — that's what the MIDI path is for.)</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>OMNI — the softsynth, in full</h2>
|
||
|
||
<p class="lead"><b>OMNI</b> is the instrument's built-in voice: not one synth but a rack of classic machines in a single browser engine — a DX7-style FM tine, a JP-8000 supersaw, a Karplus-Strong string, a Juno-106 chorus, a morphing wavetable lead, a granular cloud, a 12-bit-DAC crusher, and the planet-driven tape echo below. Nothing to install; the world plays all of it at once. (Every voice was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)</p>
|
||
|
||
<ol class="steps">
|
||
<li>Press <kbd>♪</kbd> (top-right, or <kbd>F2</kbd>) to wake OMNI — the whole audio graph builds on the first press.</li>
|
||
<li>Your first visit opens gently in <b>🌅 first light</b>. Right-click the sky → <b>🌗 mood — how much world</b> to move between <i>first light</i>, <i>ambient drift</i> (all atmosphere, no drums) and <i>the full storm</i> (every hand at once).</li>
|
||
<li>Play notes three ways: enter <b>chakra view</b> (<kbd>C</kbd>) and click a wheel's core for a 12-key console (play it with the <kbd>Q…P</kbd> row or a MIDI keyboard); open <b>🎛 tracks</b> (<kbd>T</kbd>) to sequence a song; or just wire the world's data to play it hands-free.</li>
|
||
<li>Shape the character: swap the pluck voice (<b>🎸 string voice</b> / <b>🎛 GODSONIQ</b>), or drag any feed onto <kbd>crush</kbd>, <kbd>wavetable.morph</kbd> or <kbd>granular.density</kbd>.</li>
|
||
</ol>
|
||
|
||
<div class="spec">
|
||
<div class="row"><span class="k">lead</span><span class="v">Morphing <b>wavetable</b> melody (mellow ↔ bright saw, swept by <kbd>wavetable.morph</kbd>). <em>World:</em> bitcoin's price & your market's average both sing the note (minor pentatonic / G dorian).</span></div>
|
||
<div class="row"><span class="k">bass</span><span class="v">Square <b>sub</b>. <em>World:</em> earthquake depth chooses the note (low minor) — and every bass note also conducts the pad & drone pitch.</span></div>
|
||
<div class="row"><span class="k">pad</span><span class="v">Nine-oscillator detuned <b>supersaw</b> (JP-8000, ±14¢). <em>World:</em> daylight, warmth, harmony, the birth rate & longer lives brighten it.</span></div>
|
||
<div class="row"><span class="k">drone</span><span class="v">Sawtooth bed + a 31.32 Hz Schumann <b>sub</b>. <em>World:</em> aircraft aloft, air pressure, Saturn's wheel, poverty & the national debt thicken it.</span></div>
|
||
<div class="row"><span class="k">perc</span><span class="v">Filtered-noise <b>hats</b> + the granular grain cloud. <em>World:</em> bitcoin volatility & market activity drive the density.</span></div>
|
||
<div class="row"><span class="k">pluck</span><span class="v">Hand-played notes ring as a <b>DX7 FM tine</b> by default (harder = brighter) — or a <b>🎸 Karplus string</b>, or a <b>🎛 GODSONIQ</b> sample. All keys, MIDI, mind-mode & sequencer notes flow through it.</span></div>
|
||
<div class="row"><span class="k">chorus · crush</span><span class="v">An always-on <b>Juno-106 chorus</b> widens the dry oscillators; <kbd>crush</kbd> is a <b>12-bit-DAC bitcrusher</b> on the master — route any feed at it to degrade the whole mix into lo-fi grit.</span></div>
|
||
<div class="row"><span class="k">world FX</span><span class="v"><b>reverb</b> (quakes open the void, room light sizes it), <b>delay</b> (your hand's Y, Mercury retrograde smears it), <b>saturation</b> (Delhi's air, wildfires, hunger), <b>glitch</b> (every Wikipedia edit, light-flash & new fire fires a burst).</span></div>
|
||
</div>
|
||
|
||
<h2>The Earth Echo — the planet on tape</h2>
|
||
|
||
<p class="lead">A virtual <b>Roland Space Echo</b> the entire mix runs onto — except its six knobs aren't yours, they're the living world's. And under all of it rings the <b>Schumann resonance</b>: the cavity between the Earth's surface and the ionosphere, pumped by every lightning strike on the planet, humming at <b>7.83 Hz</b> — the Earth's electromagnetic heartbeat, the one dial the world never turns.</p>
|
||
|
||
<ol class="steps">
|
||
<li>Right-click the sky → <b>🌀 earth echo — the planet on tape</b> (or press <kbd>F9</kbd>). It reads <b>🌀 earth echo: on</b> once live.</li>
|
||
<li>Press <kbd>♪</kbd> — the tape only sings while OMNI is running.</li>
|
||
<li>The six knobs are ordinary <b>matrix voices</b> — drag any feed onto them, sequence them on the 16-step grid, or send them out as CV. The world's default cables are already patched:</li>
|
||
</ol>
|
||
|
||
<div class="spec">
|
||
<div class="row"><span class="k">echo.time</span><span class="v">Repeat rate / tape speed — and because a tape motor has inertia, changes <em>glide</em> and pitch-bend the tail (that dubby seasick bend). <em>World:</em> the wind (Tokyo's gusts).</span></div>
|
||
<div class="row"><span class="k">echo.feedback</span><span class="v">Intensity — climbs toward, but never past, a screaming self-oscillation. <em>World:</em> bitcoin volatility.</span></div>
|
||
<div class="row"><span class="k">echo.tone</span><span class="v">Treble roll-off — darkens the repeats into a suffocating dub soup. <em>World:</em> air PM2.5 (Delhi).</span></div>
|
||
<div class="row"><span class="k">echo.flutter</span><span class="v">Wow & flutter — an eerie, drifting pitch vibrato. <em>World:</em> the Moon (the lunar cycle).</span></div>
|
||
<div class="row"><span class="k">echo.wear</span><span class="v">Tape hiss — the machine growing more fragile as the planet burns. <em>World:</em> the world's wildfire count.</span></div>
|
||
<div class="row"><span class="k">echo.wet</span><span class="v">Wet/dry mix — also the true on/off gate (forced to silence when the echo is off). <em>World:</em> a quake throws the whole echo forward.</span></div>
|
||
<div class="row"><span class="k">Schumann 7.83 Hz</span><span class="v">A fixed sub-audio pulse forever breathing the echo's <em>tone</em>, and laying a second planetary <em>wow</em> under the Moon's. Below hearing, so it plays the machine, not the ear.</span></div>
|
||
<div class="row"><span class="k">Schumann sub 31.32 Hz</span><span class="v">That same heartbeat ×4 (two octaves up) — an audible sub hum beneath the <b>drone</b> voice.</span></div>
|
||
</div>
|
||
|
||
<h2>GODSONIQ — resampling the world</h2>
|
||
|
||
<p class="lead">The browser-native <b>bounce</b> — a cheeky nod to the Ensoniq ASR-10. Capture a few seconds of the <em>live</em> world (this exact never-repeating take of Euclid fills, ratchets and drift), then play that moment back <b>pitched across the keys</b>. Freeze a moment of the planet, then play the planet like an instrument. Its sibling <b>🌐 feed a tab</b> pours any other browser tab's audio into the world so you can resample <i>that</i> too.</p>
|
||
|
||
<ol class="steps">
|
||
<li>Press <kbd>♪</kbd> first — GODSONIQ needs the audio worklet awake.</li>
|
||
<li>Right-click the sky → <b>🎛 GODSONIQ — resample the world onto the keys</b>. It records ~3 seconds of the live master mix.</li>
|
||
<li>Play the keys — every pluck now <b>replays your capture, pitched</b> (middle C = original speed, an octave up = twice as fast). Route <kbd>crush</kbd> at it for the classic 12-bit sampler grit.</li>
|
||
<li>Right-click → 🎛 again for a fresh slice. Exit back to the FM tine with <b>🎸 string voice</b>.</li>
|
||
</ol>
|
||
|
||
<div class="spec">
|
||
<div class="row"><span class="k">🎛 GODSONIQ</span><span class="v">Records ~3s of the full master (post-FX, post-crush) into a buffer; every pluck then replays it. Tap again to re-sample.</span></div>
|
||
<div class="row"><span class="k">playback pitch</span><span class="v">Note-spread ASR-style — <code>playbackRate = 2^((midi−60)/12)</code>, so middle C plays it at speed and each octave doubles/halves it.</span></div>
|
||
<div class="row"><span class="k">🌐 feed a tab</span><span class="v">Right-click → <b>feed a tab</b>, pick another tab in the share-picker and tick <b>“Share tab audio.”</b> That sound (a YouTube video, a live stream) is now <em>in the world</em> — crush it, reverb it, or GODSONIQ-resample it.</span></div>
|
||
<div class="row"><span class="k">the fine print</span><span class="v"><b>Chrome / Edge only</b> (Safari & Firefox can't capture tab audio). On a Mac it grabs one <em>tab's</em> sound, not all-system (for that, route through a virtual device like BlackHole). DRM'd services (Netflix, Spotify, Apple Music) come through silent — ordinary YouTube is fine.</span></div>
|
||
</div>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Building from zero — the empty instrument</h2>
|
||
|
||
<p class="lead">The full instrument is a sensory flood by design. <b>Zero</b> is the other door: an empty field with nothing wired, where you build the whole thing by hand, one cable at a time. Same matrix underneath — a calmer face on it. On your first visit the three-door card offers it (<b>🌱 start from zero</b>); after that, press <kbd>F11</kbd>, or ⚙ → <b>DIMENSIONS → ✦ dimension zero</b>, or the 🌗 mood menu → <b>0 zero</b>.</p>
|
||
|
||
<ol class="steps">
|
||
<li>Down each side sit <b>seven vibe-rings</b> — the world grouped into the seven elements (left) and seven domains (right). Each ring <b>breathes</b> with its group's live average. Click one to open its drawer.</li>
|
||
<li>In the drawer, <b>⊕ pick</b> the feeds you want. Every picked group becomes a <b>spinning orb</b> on the stage — its petals are the feeds, its core glows with the group's pulse. Gather more and the orbs multiply.</li>
|
||
<li><b>Wire an orb to a voice</b>: drag it onto a socket in the rack (<i>lead, bass, pad…</i> / <i>filter, reverb, crush…</i>), or — on a touch screen — <b>tap the orb to arm it</b>, then tap a voice. A lit green socket means it's carrying cable.</li>
|
||
<li><b>Right-click a socket</b> to assign the "synth roll" — a scale, root & octaves for a note voice, or amount & curve for an effect. Press <kbd>♪</kbd> and the thing you built plays.</li>
|
||
<li><b>Right-click an orb</b> for its menu — drop a feed (× takes its cables with it), unpatch the orb, or <b>💾 save it as a vibe</b>. When you leave (<b>⛈ enter the storm</b>, or a mood), zero asks whether to <b>keep your build</b>, bring the old world back, or <b>weave them together</b> — your work is never thrown away silently, and it survives a reload.</li>
|
||
</ol>
|
||
|
||
<div class="spec">
|
||
<div class="row"><span class="k">the rings</span><span class="v">The 14 config groups, split 7 (elements: light, dark, fire, water, earth, air, spirit) + 7 (domains). Hues are the chakra palette. A ring shows its live feed-average as a breathing glow.</span></div>
|
||
<div class="row"><span class="k">the orbs</span><span class="v">One spinning orb per picked group. Petals = the chosen feeds (opacity rides each feed's value); the core pulses with the group average. Drag or tap-arm to route; right-click for feeds / unpatch / save-as-vibe.</span></div>
|
||
<div class="row"><span class="k">the rack</span><span class="v">The player-facing outputs. A socket lights green when a cable lands on it; right-click it to shape what's patched (scale/root/octaves for <i>lead·bass</i>, amount/curve for the rest).</span></div>
|
||
<div class="row"><span class="k">leaving zero</span><span class="v"><b>keep my build</b> (it becomes the whole instrument) · <b>bring the old world back</b> (restore what you had before zero) · <b>weave them together</b> (your build layered on the old world). Esc stays in zero.</span></div>
|
||
<div class="row"><span class="k">persistence</span><span class="v">Your picks + cables persist across a reload; a returning zero-builder lands back in exactly the field they left.</span></div>
|
||
</div>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>OMNI — the synth panel</h2>
|
||
|
||
<p class="lead">The planet's voice, laid out like a synth. Press <kbd>O</kbd> or the <b>🎹 OMNI</b> button (bottom-right, above 🎛 tracks). It is a <em>view</em> over the same voices and effects the field already drives — no new sound, just a instrument-panel face on it. Every knob does two things at once: its <b>arc</b> shows the voice's <em>live world-driven value</em> (it breathes with the planet), and its <b>dot</b> marks <em>your hand</em>.</p>
|
||
|
||
<ol class="steps">
|
||
<li><b>Drag a knob</b> up or down to shape that voice — turn <kbd>reverb</kbd> up for more space, <kbd>crush</kbd> up for grit. A dot appears to mark your setting; it rides the voice's <b>trim</b>, so it works even before the world is wired to it, and it <b>saves with your dimension</b>.</li>
|
||
<li><b>Double-click</b> a knob to hand it back to the world (neutral). <b>Right-click</b> any knob for that voice's full node menu — every cable, level and patch.</li>
|
||
<li>Pick the <b>pluck's character</b> up top: <b>FM tine</b> (default), <b>🎸 string</b> (Karplus), or <b>🎛 GODSONIQ</b> (resample the world). Beside it: <kbd>♪</kbd> start/stop, the live <b>BPM</b>, and the godtime <b>🔒 lock</b>.</li>
|
||
<li><b>🌀 Earth echo</b> has its own strip — flip it on and its six tape knobs (<i>time, feedback, tone, flutter, wear, wet</i>) appear.</li>
|
||
</ol>
|
||
|
||
<div class="spec">
|
||
<div class="row"><span class="k">the arc</span><span class="v">The voice's current value, live — the same number the field's meter shows. Note voices (<i>lead·bass</i>) show their pitch here; shape those with scale, not a knob.</span></div>
|
||
<div class="row"><span class="k">the dot · drag</span><span class="v">Your trim on that voice. Drag rides it up (a floor / more) or down (less); a positive trim gives a voice a level even with no cable — so OMNI plays like a real synth. Double-click resets. Saved in the dimension.</span></div>
|
||
<div class="row"><span class="k">VOICES</span><span class="v">lead · bass · pad · drone · perc — the ensemble.</span></div>
|
||
<div class="row"><span class="k">FX RACK</span><span class="v">filter · reverb · delay · saturation · glitch · granular · crush · morph · lfo · space.</span></div>
|
||
<div class="row"><span class="k">engine · transport</span><span class="v">FM tine / 🎸 string / 🎛 GODSONIQ for the pluck; ♪ audio, live BPM, 🔒 tempo lock.</span></div>
|
||
</div>
|
||
|
||
<div class="hr"></div>
|
||
|
||
<h2>Saving your instrument — dimensions & vibes</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. There are two sizes of keepsake: a <b>dimension</b> is <i>everything</i>; a <b>vibe</b> is a feeling you can hand a friend.</p>
|
||
|
||
<p><b>Dimensions — the whole world, saved.</b> Open <b>⚙ → DIMENSIONS</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. The top of the list is always <b>✦ dimension zero</b> — the empty field, the place you build from nothing. (Locally, spells live as <kbd>patches/*.json</kbd> on the host; DIMENSIONS is that same power for signed-in players, carried to your account and back.)</p>
|
||
|
||
<p><b>Vibes — a feeling, shareable.</b> A dimension is a hard scene save of the whole instrument; a <b>vibe</b> is smaller and lighter — a little bundle of specific source→voice cables, the exact combo that made a moment sing. Right-click a spinning orb in zero (or any node in the full field) → <b>💾 save as vibe</b>, or open <b>✨ vibes</b> (the zero pill, or ⚙). Applying a vibe is <i>additive</i> — it lays its cables on top of whatever you already have and can be toggled straight back off, and it never touches a cable you built by hand. Then <b>⧉ copy</b> the vibe's code (a short <kbd>GSV1…</kbd> string) and paste it to a friend, or share a <kbd>godstrument.pro/#vibe=…</kbd> link — they see <i>“someone shared a vibe”</i> and choose whether to add it. Build a vibe, save a vibe, share a vibe.</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;
|
||
|
||
// ---- pinch / scroll zoom of the whole visual (CSS transform on the canvas) ----
|
||
const view = { z: 1, x: 0, y: 0 }; // zoom + pan (screen px); #stage is fixed at 0,0
|
||
let panDrag = null;
|
||
canvas.style.transformOrigin = "0 0";
|
||
function clampView() {
|
||
view.x = clamp(view.x, window.innerWidth * (1 - view.z), 0); // keep the canvas covering the viewport
|
||
view.y = clamp(view.y, window.innerHeight * (1 - view.z), 0);
|
||
}
|
||
function applyView() {
|
||
if (view.z <= 1.002) { view.z = 1; view.x = 0; view.y = 0; } // snap home when fully out
|
||
clampView();
|
||
canvas.style.transform = (view.z === 1 && !view.x && !view.y) ? "none"
|
||
: "translate(" + view.x.toFixed(1) + "px," + view.y.toFixed(1) + "px) scale(" + view.z.toFixed(4) + ")";
|
||
}
|
||
|
||
// ---- 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","body"].includes(p)) return "body";
|
||
if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet";
|
||
if (["sun","iss","planes","plane","sky","aurora","moon","space","natal"].includes(p)) return "sky";
|
||
if (["crypto","market","stock","btc","eth","fx","me"].includes(p)) return "markets";
|
||
if (["wiki","net","news","social","edit","syn"].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 = {
|
||
"crypto.eth": "ETH price, live trade ticks from Binance",
|
||
"crypto.eth_vel": "ETH tick-to-tick velocity — ether's jitter",
|
||
"crypto.sol": "SOL price, live trade ticks from Binance",
|
||
"crypto.sol_vel": "SOL tick-to-tick velocity",
|
||
"crypto.macd": "BTC momentum — fast EMA minus slow (the MACD line) on 5s closes; above the middle = bullish push",
|
||
"crypto.rsi": "BTC RSI(14) on 5s closes — near 1 overbought, near 0 oversold; a natural mean-reverting wave",
|
||
"crypto.cross_up": "golden cross — BTC's fast average just crossed above the slow. A bell rings for the bulls.",
|
||
"crypto.cross_down": "death cross — the fast average just fell through the slow. The bears' bell.",
|
||
"fx.eurusd": "EUR/USD, live from Binance's EURUSDT tap — the euro breathing in real time",
|
||
"fx.eurusd_vel": "EUR/USD tick-to-tick velocity",
|
||
"fx.gbpusd": "GBP/USD — cable, from the ECB daily fix (glacial: a drone, not a melody)",
|
||
"fx.usdjpy": "USD/JPY — the yen, from the ECB daily fix",
|
||
"fx.audusd": "AUD/USD — the aussie, from the ECB daily fix",
|
||
"sport.events_today": "how many matches the planet plays today (TheSportsDB — opt-in: run with --sports)",
|
||
"sport.inplay": "matches being played right now, worldwide — the world's stadium lights as a curve",
|
||
"sport.kickoff": "somewhere, a whistle blows — fires at every kickoff, louder when many start at once",
|
||
"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 = Array.isArray(SCALES[scale]) ? SCALES[scale] : SCALES.chromatic; // never an inherited prototype name → no NaN
|
||
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
|
||
|
||
// ---- the groove grids: per-voice 16-step sequencers on godtime -----------
|
||
// Note voices get a mini piano roll (8 rows of minor pentatonic); every other
|
||
// voice gets an 808-style gate row that rhythmically chops the world's data.
|
||
// "Quantize in time" — the rhythmic sibling of the matrix's quantize-to-scale.
|
||
const seqs = {}; // destKey -> {on, steps[16]} (notes: -1|row, CC: 0|1)
|
||
let seqPhase = 0, seqCurStep = 0, seqAbsStep = 0, seqGridRefresh = null; // seqAbsStep: monotonic 16th counter for ☸ WHEELS polymeter
|
||
// The Groove — Ableton's Groove Pool (manual §14.1), reduced to a fixed 1/16
|
||
// grid: swing delays the off-beats, humanize is the manual's "Random" timing
|
||
// jitter, velo is per-note Velocity Deviation, amount is the Global Amount
|
||
// master (0..1.3). One "feel" applied to every playing groove, not four sliders.
|
||
const GROOVE_PRESETS = {
|
||
straight: { swing: 0, humanize: 0, velo: 0 },
|
||
"swing 16": { swing: 0.55, humanize: 0.05, velo: 0.10 },
|
||
"mpc 8-4": { swing: 0.62, humanize: 0.12, velo: 0.18 },
|
||
drunk: { swing: 0.30, humanize: 0.45, velo: 0.30 },
|
||
};
|
||
const groove = Object.assign({ amount: 1, preset: "straight" }, GROOVE_PRESETS.straight);
|
||
let arrExprMode = "none"; // grooves-tab expression lane: none|velocity|chance
|
||
let seqRawStep = -1, seqStepOnset = 0; // swing/humanize onset within the current 16th
|
||
// Euclidean rhythms (Live 12 MIDI Generators §11.3.5): spread `pulses` hits as
|
||
// evenly as possible over `len` steps, then tile + rotate into the 16-step grid.
|
||
// E(3,8) tresillo, E(5,8) cinquillo, E(4,16) four-on-floor — cosmic math as beat.
|
||
const euclidP = { pulses: 5, len: 16, rot: 0 };
|
||
function euclidHit(i, pulses, len) { // Bresenham even-distribution (maximally-even, = Euclidean up to rotation)
|
||
return Math.floor(i * pulses / len) !== Math.floor((i - 1) * pulses / len);
|
||
}
|
||
function euclidFill(dst, pulses, len, rot, isNote) {
|
||
pulses = clamp(Math.round(pulses), 0, len);
|
||
for (let i = 0; i < 16; i++) {
|
||
const j = (((i - rot) % len) + len) % len;
|
||
const hit = pulses > 0 && euclidHit(j, pulses, len);
|
||
dst[i] = hit ? (isNote ? 0 : 1) : (isNote ? -1 : 0); // note lanes fill the root; gates fill 1
|
||
}
|
||
}
|
||
let midiClockPhase = 0; // 24-ppqn accumulator for MIDI clock out
|
||
// The global Scale (Live 12 Scale Awareness §3.13): one root + scale the whole
|
||
// groove editor sings in. The piano roll's 8 rows ARE the scale degrees (always
|
||
// "folded to scale"), so every melody you draw is in key; change the scale and
|
||
// your patterns re-harmonise. Factory data-melodies keep their own tuned scales.
|
||
const gScale = { root: 0, name: "minor_pent" }; // NOTE_NAMES already defined above (midiToName)
|
||
function scaleRows(name) { // 8 piano-roll rows from a scale's intervals
|
||
const s = SCALES[name] || SCALES.minor_pent, rows = [];
|
||
for (let o = 0; rows.length < 8; o++) for (const iv of s) if (rows.length < 8) rows.push(iv + 12 * o);
|
||
return rows;
|
||
}
|
||
let SEQ_ROWS = scaleRows(gScale.name);
|
||
const seqRootFor = (key) => (/bass/.test(key) ? 36 : 60) + gScale.root;
|
||
// "Play One" (adapted from Ableton's Group Notes → Play One): a column may hold
|
||
// SEVERAL candidate degrees — on each fire, one is chosen at random. Stack a
|
||
// handful of in-scale notes and the sequence glitches itself, always in key.
|
||
function stepHas(v, rw) { return Array.isArray(v) ? v.includes(rw) : v === rw; }
|
||
function stepStack(v) { return Array.isArray(v) && v.length >= 2; }
|
||
function stepPick(v) { return Array.isArray(v) ? (v.length ? v[Math.floor(Math.random() * v.length)] : -1) : v; }
|
||
function stepFirst(v) { return Array.isArray(v) ? (v.length ? v[0] : -1) : v; }
|
||
function stepToggle(steps, stp, rw) { // click a note lane: build/undo a Play-One stack
|
||
const v = steps[stp];
|
||
if (Array.isArray(v)) {
|
||
const i = v.indexOf(rw); if (i >= 0) v.splice(i, 1); else v.push(rw);
|
||
steps[stp] = v.length === 0 ? -1 : v.length === 1 ? v[0] : v;
|
||
} else if (v === rw) steps[stp] = -1; // click a lit single → clear
|
||
else if (v >= 0) steps[stp] = [v, rw]; // a second note in the column → a stack
|
||
else steps[stp] = rw; // first note
|
||
}
|
||
// ---- note length — the piano-roll "key-roll" -----------------------------
|
||
// A placed note draws as an overlay block spanning len[stp] steps; drag its right
|
||
// edge (the ew-resize handle) to lengthen. Length lives on the VOICE seq (seqFor.len),
|
||
// shared across A/B/C clips exactly as vel/chance/rat are — main's per-voice expression
|
||
// model. len === 1 (the default and the back-fill) is one 16th: byte-for-byte the old
|
||
// behaviour. `cells` are the {el, rw, stp} the grid already built; `stepsArr` is what
|
||
// the grid edits — passed as a getter `getSteps()` so overlays always read the CURRENT
|
||
// array (the arranger reassigns a voice's sq.steps per bar when a clip plays); `lenArr`
|
||
// is the voice seq's len[16]. Returns renderNotes() — call it after every steps edit (paint()).
|
||
function attachNoteLengths(grid, cells, getSteps, lenArr, rows, onDrop) {
|
||
for (const c of cells) { // explicit placement so overlays can span columns
|
||
c.el.style.gridColumn = String(c.stp + 1);
|
||
c.el.style.gridRow = String(rows - c.rw);
|
||
}
|
||
const pitch = () => { // column stride in px (cell width + gap), measured live
|
||
const a = cells[0] && cells[0].el; if (!a) return 15;
|
||
let b = null;
|
||
for (const c of cells) if (c.rw === cells[0].rw && c.stp === cells[0].stp + 1) { b = c.el; break; }
|
||
return (b ? b.offsetLeft - a.offsetLeft : 0) || 15;
|
||
};
|
||
const overlays = [];
|
||
function startDrag(handle, stp) {
|
||
// pointer events (mouse AND touch); listeners live on window because
|
||
// renderNotes() rebuilds the overlay mid-drag — element capture would die
|
||
handle.addEventListener("pointerdown", (e) => {
|
||
e.preventDefault(); e.stopPropagation(); // the handle never toggles the cell underneath
|
||
const p = pitch(), startX = e.clientX, start = clamp(Math.round(lenArr[stp] || 1), 1, 16 - stp);
|
||
const move = (e2) => { lenArr[stp] = clamp(start + Math.round((e2.clientX - startX) / p), 1, 16 - stp); renderNotes(); };
|
||
const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up);
|
||
window.removeEventListener("pointercancel", up); if (onDrop) onDrop(); };
|
||
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
|
||
window.addEventListener("pointercancel", up);
|
||
});
|
||
}
|
||
function renderNotes() {
|
||
for (const el of overlays) el.remove(); overlays.length = 0;
|
||
const stepsArr = getSteps(); // read fresh: the arranger may have swapped the array
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = stepsArr[stp];
|
||
const rws = Array.isArray(cell) ? cell : (cell >= 0 ? [cell] : []); // Play-One stack or single note
|
||
if (!rws.length) continue;
|
||
const span = clamp(Math.round(lenArr[stp] || 1), 1, 16 - stp);
|
||
for (const raw of rws) {
|
||
const rw = clamp(raw, 0, rows - 1);
|
||
const el = document.createElement("div"); el.className = "seqnote";
|
||
el.style.gridColumn = (stp + 1) + " / span " + span;
|
||
el.style.gridRow = String(rows - rw);
|
||
const h = document.createElement("div"); h.className = "rsz"; h.title = "drag to set note length";
|
||
startDrag(h, stp); el.appendChild(h);
|
||
grid.appendChild(el); overlays.push(el);
|
||
}
|
||
}
|
||
}
|
||
renderNotes();
|
||
return renderNotes;
|
||
}
|
||
// Parameter locks (PO / Elektron style): a CC/texture voice's groove gates it in
|
||
// time; a per-step LOCK pins that voice's value to a fixed level on that step
|
||
// (blocky, disjointed — the groovebox move). lock[s] = -1 → pass the world; >=0 → override.
|
||
function applyGroove(sq, step, worldV) {
|
||
if (!sq || !sq.on) return worldV;
|
||
if (!sq.steps[step]) return 0; // dark step: silence the voice
|
||
if (sq.lock && sq.lock[step] >= 0) return sq.lock[step]; // p-lock: absolute per-step value
|
||
return worldV; // lit, unlocked: pass the world through
|
||
}
|
||
function seqFor(key, isNote) {
|
||
return seqs[key] || (seqs[key] = {
|
||
on: false,
|
||
steps: isNote ? new Array(16).fill(-1) : new Array(16).fill(1),
|
||
vel: new Array(16).fill(0.8), // per-step Velocity Editor (§10.5.11)
|
||
chance: new Array(16).fill(1), // per-step Chance Editor (§10.5.13)
|
||
rat: new Array(16).fill(1), // per-step ratchet count (1 = single hit)
|
||
lock: new Array(16).fill(-1), // per-step parameter lock (-1 = unlocked)
|
||
len: new Array(16).fill(1), // per-step note length, in 16ths (1 = one step) — the piano-roll "key-roll"
|
||
wlen: 16, // ☸ WHEELS — the lane's own pattern length (1..16); 16 = a full wheel (default, unchanged)
|
||
});
|
||
}
|
||
// GODRUM drum lanes — same seq shape as seqFor, but steps are 0/1 gates that fill
|
||
// EMPTY (a fresh drum lane makes no sound; seqFor's all-1s default would be an
|
||
// instant four-on-everything). Fired straight from seqTick, so swing/humanize ride free.
|
||
const DRUM_VOICES = ["kick", "snare", "clap", "hat", "ohat"];
|
||
// the canonical first-open seed — ONE source of truth for seedBeat() and zeroHasBuild() (they must not drift)
|
||
const DRUM_SEED = { kick: [0, 7, 10], snare: [4, 12], clap: [],
|
||
hat: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], ohat: [14] };
|
||
const DRUM_GM = { kick: 36, snare: 38, clap: 39, hat: 42, ohat: 46 }; // General MIDI drum notes (channel 9)
|
||
function drumSeqFor(key) {
|
||
return seqs[key] || (seqs[key] = {
|
||
on: false,
|
||
steps: new Array(16).fill(0), // 0/1 gate — empty until you draw a beat
|
||
vel: new Array(16).fill(0.8),
|
||
chance: new Array(16).fill(1),
|
||
rat: new Array(16).fill(1),
|
||
lock: new Array(16).fill(-1), // unused by drums, kept for seq-shape parity (migrateSeq)
|
||
len: new Array(16).fill(1), // unused by drums (they're one-shots), kept for seq-shape parity
|
||
wlen: 16, // ☸ WHEELS — per-lane pattern length; polymeter for the drums
|
||
});
|
||
}
|
||
// ☸ WHEELS — "a wheel within a wheel". A lane shorter than 16 wraps on its own
|
||
// length against the absolute clock, so kick-16 over hat-12 drift into polymeter.
|
||
// A full-length lane returns seqCurStep — bit-identical to pre-WHEELS behaviour.
|
||
const wheelStep = (sq) => (sq && sq.wlen && sq.wlen < 16) ? ((seqAbsStep % sq.wlen) + sq.wlen) % sq.wlen : seqCurStep;
|
||
// ---- the arranger — tracks & clips for the casuals -----------------------
|
||
// Ableton-shaped, not Ableton-sized: tracks are the voices, a clip is a bar
|
||
// of groove (A/B/C patterns on the existing grids), "live" means the world
|
||
// plays that lane, dark means silence. Eight bars, looping on godtime. The
|
||
// Source stays live; you arrange when the Matter speaks.
|
||
const arr = { open: false, playing: false, bars: 8, bar: -1, grid: {}, banks: {}, prevMute: {},
|
||
follow: {}, now: {} }; // follow: per-clip-letter action+chance · now: the live diverted letter per track
|
||
let arrPaintBar = null; // the panel's playhead painter, when open
|
||
let arrBarAbs = -1; // absolute bar counter off the godtime clock
|
||
function arrBank(key, isNote) {
|
||
if (!arr.banks[key]) {
|
||
const mk2 = (fill) => new Array(16).fill(fill);
|
||
const A = mk2(isNote ? -1 : 1), B = mk2(isNote ? -1 : 0), C = mk2(isNote ? -1 : 1);
|
||
if (isNote) { [0, 4, 8, 12].forEach(s2 => A[s2] = 0); [0, 3, 6, 8, 11, 14].forEach(s2 => B[s2] = (s2 % 2) ? 3 : 0); }
|
||
else { for (let s2 = 0; s2 < 16; s2 += 2) B[s2] = 1; [3, 7, 11, 15].forEach(s2 => C[s2] = 0); }
|
||
arr.banks[key] = { A, B, C };
|
||
}
|
||
return arr.banks[key];
|
||
}
|
||
function arrLane(key) {
|
||
return arr.grid[key] || (arr.grid[key] = new Array(arr.bars).fill("live"));
|
||
}
|
||
// Follow Actions (manual §16.7): a clip can hand off to another by chance, so
|
||
// an 8-bar loop stops being static — "structures that repeat but can surprise."
|
||
// Returns the letter/"mute" to divert to, or null = obey the grid as written.
|
||
function arrFollowNext(key, cur) {
|
||
const fa = arr.follow[key] && arr.follow[key][cur];
|
||
if (!fa || !fa.act || !(Math.random() < (fa.chance != null ? fa.chance : 1))) return null;
|
||
const used = ["A", "B", "C"].filter(L => (arr.grid[key] || []).includes(L));
|
||
if (!used.length) return null;
|
||
switch (fa.act) {
|
||
case "stop": return "mute";
|
||
case "again": return cur;
|
||
case "next": { const i = used.indexOf(cur); return used[(i + 1) % used.length]; }
|
||
case "any": return used[Math.floor(Math.random() * used.length)];
|
||
case "other": { const o = used.filter(L => L !== cur); return o.length ? o[Math.floor(Math.random() * o.length)] : cur; }
|
||
}
|
||
return null;
|
||
}
|
||
function applyArrColumn() {
|
||
for (const key in arr.grid) {
|
||
const n = dests.get(key);
|
||
if (!n) continue;
|
||
const st = (arr.now[key] != null ? arr.now[key] : (arr.grid[key][arr.bar] || "live"));
|
||
const t2 = tw(key);
|
||
if (st === "mute") {
|
||
t2.mute = 1;
|
||
const sq = seqs[key]; if (sq) sq.on = false;
|
||
} else if (st === "live") {
|
||
t2.mute = 0;
|
||
const sq = seqs[key]; if (sq) sq.on = false;
|
||
} else { // a clip letter — play that pattern
|
||
t2.mute = 0;
|
||
const sq = seqFor(key, n.isNote);
|
||
sq.steps = arrBank(key, n.isNote)[st]; // shared reference: grid edits write through
|
||
sq.on = true;
|
||
}
|
||
}
|
||
if (arrPaintBar) arrPaintBar();
|
||
}
|
||
function arrPlay() {
|
||
if (arr.playing) return;
|
||
arr.playing = true;
|
||
arr.prevMute = {};
|
||
arr.now = {}; // start clean — no diversions yet
|
||
for (const key in arr.grid) arr.prevMute[key] = tw(key).mute;
|
||
arr.bar = -1; // next bar boundary starts the song
|
||
}
|
||
function arrStop() {
|
||
if (!arr.playing) return;
|
||
arr.playing = false; arr.bar = -1; arr.now = {};
|
||
for (const key in arr.grid) { // hand the voices back to the world
|
||
tw(key).mute = arr.prevMute[key] || 0;
|
||
const sq = seqs[key]; if (sq) sq.on = false;
|
||
}
|
||
if (arrPaintBar) arrPaintBar();
|
||
}
|
||
|
||
// ---- export the arrangement as a Standard MIDI File (type 1, 96 ppq) -----
|
||
function midVarlen(n) {
|
||
const out = [n & 0x7f];
|
||
while ((n >>= 7)) out.unshift((n & 0x7f) | 0x80);
|
||
return out;
|
||
}
|
||
function exportMid() {
|
||
const PPQ = 96, TICKS16 = PPQ / 4;
|
||
const trks = [];
|
||
// tempo track
|
||
const uspq = Math.round(60000000 / clamp(godtime.bpm, 40, 180));
|
||
trks.push([[0, 0xff, 0x51, 0x03, (uspq >> 16) & 255, (uspq >> 8) & 255, uspq & 255],
|
||
[0, 0xff, 0x2f, 0x00]]);
|
||
// one track per note lane that has any clip in the grid
|
||
for (const key in arr.grid) {
|
||
const n = dests.get(key);
|
||
if (!n || !n.isNote) continue;
|
||
const ch = /bass/.test(key) ? 1 : 0, root = seqRootFor(key);
|
||
const sq = seqs[key]; // per-step velocity + ratchet + length ride here
|
||
const notes = []; // {on, off, note, vel} — collected, then overlap-clamped
|
||
for (let b = 0; b < arr.bars; b++) {
|
||
const st = arr.grid[key][b];
|
||
if (st !== "A" && st !== "B" && st !== "C") continue;
|
||
const steps = arrBank(key, true)[st];
|
||
for (let s2 = 0; s2 < 16; s2++) {
|
||
const d = stepFirst(steps[s2]); // Play-One → first candidate (a file is deterministic)
|
||
if (d < 0) continue;
|
||
const note = root + SEQ_ROWS[d];
|
||
const vel = Math.round(clamp(sq && sq.vel ? sq.vel[s2] : 0.8, 0.05, 1) * 127);
|
||
const rat = clamp(Math.round(sq && sq.rat ? sq.rat[s2] : 1), 1, 4);
|
||
const base = (b * 16 + s2) * TICKS16;
|
||
if (rat > 1) { // ratchets → real sub-notes (one 16th, subdivided)
|
||
const subLen = TICKS16 / rat;
|
||
for (let h = 0; h < rat; h++) {
|
||
const on = base + Math.round(h * subLen);
|
||
notes.push({ on, off: Math.max(on + 1, base + Math.round((h + 1) * subLen) - 2), note, vel });
|
||
}
|
||
} else { // un-ratcheted → the note sustains its length (key-roll)
|
||
const len16 = clamp(Math.round(sq && sq.len ? sq.len[s2] : 1), 1, 16);
|
||
notes.push({ on: base, off: base + len16 * TICKS16 - 2, note, vel });
|
||
}
|
||
}
|
||
}
|
||
if (!notes.length) continue;
|
||
// clamp a note-off to just before the next onset of the SAME pitch, so a long
|
||
// note running into a repeat of itself never emits an overlapping / stuck pair
|
||
notes.sort((a, b2) => a.note - b2.note || a.on - b2.on);
|
||
for (let i = 1; i < notes.length; i++)
|
||
if (notes[i].note === notes[i - 1].note && notes[i - 1].off >= notes[i].on)
|
||
notes[i - 1].off = Math.max(notes[i - 1].on + 1, notes[i].on - 1);
|
||
const ev = [];
|
||
for (const nt of notes) ev.push([nt.on, 0x90 | ch, nt.note, nt.vel], [nt.off, 0x80 | ch, nt.note, 0]);
|
||
ev.sort((a, b2) => a[0] - b2[0] || (a[1] & 0xf0) - (b2[1] & 0xf0)); // note-off before note-on at a tie
|
||
const bytes = [];
|
||
let last = 0;
|
||
for (const e of ev) {
|
||
bytes.push(...midVarlen(e[0] - last), ...e.slice(1));
|
||
last = e[0];
|
||
}
|
||
bytes.push(0, 0xff, 0x2f, 0x00);
|
||
trks.push([bytes]);
|
||
}
|
||
// channel-9 GM drum track — the beat looped across the arrangement's bars.
|
||
// Only lanes the user OWNS export: playing (on), or edited off the factory
|
||
// seed — merely opening the 🥁 panel (which seeds a demo pattern) must not
|
||
// smuggle a phantom groove into the .mid. Same test zeroHasBuild uses.
|
||
{
|
||
const dev = [];
|
||
const owned = (v, sq) => {
|
||
if (sq.on) return true;
|
||
const seed = DRUM_SEED[v];
|
||
for (let i = 0; i < 16; i++) if ((sq.steps[i] ? 1 : 0) !== (seed.indexOf(i) >= 0 ? 1 : 0)) return true;
|
||
return false;
|
||
};
|
||
for (const v of DRUM_VOICES) {
|
||
const sq = seqs["drum." + v];
|
||
if (!sq || !owned(v, sq)) continue;
|
||
const gm = DRUM_GM[v];
|
||
for (let sa = 0; sa < arr.bars * 16; sa++) { // ☸ WHEELS — one absolute loop
|
||
const s2 = (sq.wlen && sq.wlen < 16) ? sa % sq.wlen : sa % 16; // a 12-wheel exports exactly as it plays
|
||
if (!sq.steps[s2]) continue;
|
||
const dvel = Math.round(clamp(sq.vel ? sq.vel[s2] : 0.8, 0.05, 1) * 127);
|
||
const t0 = sa * TICKS16, t1 = Math.max(t0 + 1, t0 + TICKS16 - 2); // one 16th long
|
||
dev.push([t0, 0x99, gm, dvel], [t1, 0x89, gm, 0]); // ratchet sub-hits omitted in v1
|
||
}
|
||
}
|
||
if (dev.length) {
|
||
dev.sort((a, b2) => a[0] - b2[0]);
|
||
const bytes = [];
|
||
let last = 0;
|
||
for (const e of dev) { bytes.push(...midVarlen(e[0] - last), ...e.slice(1)); last = e[0]; }
|
||
bytes.push(0, 0xff, 0x2f, 0x00);
|
||
trks.push([bytes]);
|
||
}
|
||
}
|
||
if (trks.length === 1) { // only the tempo track — nothing to hear yet
|
||
eventTicker.unshift({ text: "nothing to export yet — place A/B/C clips on a note lane, or draw a beat in 🥁 first", tAdded: now(), src: "sys" });
|
||
return;
|
||
}
|
||
const flat = (t2) => t2.length === 1 ? t2[0] : t2.reduce((a, e) => a.concat(e), []);
|
||
const chunks = [];
|
||
const push32 = (a, v) => a.push((v >> 24) & 255, (v >> 16) & 255, (v >> 8) & 255, v & 255);
|
||
const head = [0x4d, 0x54, 0x68, 0x64]; push32(head, 6);
|
||
head.push(0, 1, (trks.length >> 8) & 255, trks.length & 255, (PPQ >> 8) & 255, PPQ & 255);
|
||
chunks.push(head);
|
||
for (const t2 of trks) {
|
||
const body = flat(t2);
|
||
const h2 = [0x4d, 0x54, 0x72, 0x6b]; push32(h2, body.length);
|
||
chunks.push(h2, body);
|
||
}
|
||
const all = chunks.reduce((a, c) => a.concat(c), []);
|
||
const blob = new Blob([new Uint8Array(all)], { type: "audio/midi" });
|
||
const a2 = document.createElement("a");
|
||
a2.href = URL.createObjectURL(blob);
|
||
a2.download = "godstrument-track.mid";
|
||
a2.click();
|
||
setTimeout(() => URL.revokeObjectURL(a2.href), 5000);
|
||
eventTicker.unshift({ text: "🎛 arrangement exported — drag the .mid into any DAW", tAdded: now(), src: "sys" });
|
||
}
|
||
|
||
// the arranger panel — tracks down the left, eight bars across
|
||
const arrEl = document.createElement("div");
|
||
arrEl.id = "arranger";
|
||
document.body.appendChild(arrEl);
|
||
|
||
// the 🥁 beat panel — GODRUM's drum lanes, a sibling of the arranger
|
||
const beatEl = document.createElement("div");
|
||
beatEl.id = "beat";
|
||
document.body.appendChild(beatEl);
|
||
let arrGrooveRefresh = null; // grooves-tab playhead painter
|
||
let beatRefresh = null; // 🥁 beat-panel playhead painter (mirrors arrGrooveRefresh)
|
||
const beat = { open: false, sel: "kick", exprMode: "velocity", kitOpen: false, rec: false };
|
||
let arrTab = "clips", arrSelKey = null, arrSelLetter = "A";
|
||
function closeArranger() {
|
||
arr.open = false; arrEl.classList.remove("open");
|
||
arrPaintBar = null; arrGrooveRefresh = null;
|
||
arrBtn.classList.remove("hidden");
|
||
}
|
||
function openArranger() {
|
||
arr.open = true;
|
||
if (beat.open) closeBeat(); // the two bottom drawers share the screen — only one at a time
|
||
arrBtn.classList.add("hidden");
|
||
arrEl.innerHTML = "";
|
||
|
||
// grab-handle — drag to resize, remembered
|
||
const grip = document.createElement("div"); grip.className = "agrip"; grip.title = "drag to resize";
|
||
grip.addEventListener("mousedown", (e0) => {
|
||
e0.preventDefault();
|
||
const startY = e0.clientY, startH = arrEl.getBoundingClientRect().height;
|
||
const move = (e2) => {
|
||
const h2 = clamp(startH + (startY - e2.clientY), 140, window.innerHeight * 0.8);
|
||
arrEl.style.height = h2 + "px";
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up);
|
||
try { localStorage.setItem("gs_arrh", parseInt(arrEl.style.height) || ""); } catch (e) {}
|
||
};
|
||
window.addEventListener("mousemove", move); window.addEventListener("mouseup", up);
|
||
});
|
||
arrEl.appendChild(grip);
|
||
try { const h0 = parseInt(localStorage.getItem("gs_arrh")); if (h0) arrEl.style.height = h0 + "px"; } catch (e) {}
|
||
|
||
const bar0 = document.createElement("div"); bar0.className = "atop";
|
||
const play = document.createElement("button"); play.className = "midibtn";
|
||
const paintPlay = () => { play.textContent = arr.playing ? "■ stop" : "▶ play"; play.classList.toggle("on", arr.playing); };
|
||
paintPlay();
|
||
play.onclick = () => { arr.playing ? arrStop() : arrPlay(); paintPlay(); };
|
||
const tabC = document.createElement("button"); tabC.className = "midibtn";
|
||
const tabG = document.createElement("button"); tabG.className = "midibtn";
|
||
const paintTabs = () => {
|
||
tabC.textContent = "clips"; tabG.textContent = "grooves";
|
||
tabC.classList.toggle("on", arrTab === "clips");
|
||
tabG.classList.toggle("on", arrTab === "grooves");
|
||
};
|
||
paintTabs();
|
||
tabC.onclick = () => { arrTab = "clips"; openArranger(); };
|
||
tabG.onclick = () => { arrTab = "grooves"; openArranger(); };
|
||
const exp = document.createElement("button"); exp.className = "midibtn"; exp.textContent = "export .mid";
|
||
exp.title = "a Standard MIDI File of the note lanes — drag it into any DAW";
|
||
exp.onclick = exportMid;
|
||
const lbl = document.createElement("span"); lbl.className = "albl";
|
||
lbl.textContent = arrTab === "clips"
|
||
? "click a cell: live → A → B → C → silent · double-click a clip to edit · right-click a track name for its routing · loops 8 bars"
|
||
: "pick a track and a clip letter — the big grid edits the pattern the clips play";
|
||
const x2 = document.createElement("span"); x2.className = "aclose"; x2.textContent = "×";
|
||
x2.onclick = closeArranger;
|
||
bar0.appendChild(play); bar0.appendChild(tabC); bar0.appendChild(tabG);
|
||
bar0.appendChild(exp); bar0.appendChild(lbl); bar0.appendChild(x2);
|
||
arrEl.appendChild(bar0);
|
||
|
||
const body = document.createElement("div"); body.className = "abody";
|
||
arrEl.appendChild(body);
|
||
|
||
if (arrTab === "grooves") { // ---- the big pattern editor ----
|
||
const keys2 = Array.from(dests.keys()).sort();
|
||
if (!arrSelKey || !dests.has(arrSelKey)) arrSelKey = keys2[0];
|
||
const pick = document.createElement("div"); pick.className = "arow";
|
||
const sel = document.createElement("select"); sel.className = "asel";
|
||
for (const k2 of keys2) {
|
||
const o2 = document.createElement("option");
|
||
o2.value = k2; o2.textContent = prettyDest(k2);
|
||
if (k2 === arrSelKey) o2.selected = true;
|
||
sel.appendChild(o2);
|
||
}
|
||
sel.onchange = () => { arrSelKey = sel.value; openArranger(); };
|
||
pick.appendChild(sel);
|
||
for (const L2 of ["A", "B", "C"]) {
|
||
const lb = document.createElement("button"); lb.className = "midibtn";
|
||
lb.textContent = L2;
|
||
lb.classList.toggle("on", arrSelLetter === L2);
|
||
lb.onclick = () => { arrSelLetter = L2; openArranger(); };
|
||
pick.appendChild(lb);
|
||
}
|
||
const hint2 = document.createElement("span"); hint2.className = "albl";
|
||
const nSel = dests.get(arrSelKey) || { isNote: false }; // dests can be empty in the cold-connect window (pre-hello)
|
||
hint2.textContent = nSel.isNote
|
||
? "piano roll · " + NOTE_NAMES[gScale.root] + " " + gScale.name.replace("_", " ") + " · click to place · stack 2+ in a column → plays one at random"
|
||
: "16-step gate · lit steps let the world's signal through";
|
||
pick.appendChild(hint2);
|
||
body.appendChild(pick);
|
||
|
||
// --- the global Scale (§3.13): root + scale every groove you draw sings in ---
|
||
const scRow = document.createElement("div"); scRow.className = "arow grow";
|
||
const scL = document.createElement("span"); scL.className = "glbl"; scL.style.minWidth = "auto"; scL.textContent = "♪ scale";
|
||
scRow.appendChild(scL);
|
||
const rootSel = document.createElement("select"); rootSel.className = "asel";
|
||
NOTE_NAMES.forEach((nm, i) => { const o = document.createElement("option"); o.value = String(i); o.textContent = nm; if (i === gScale.root) o.selected = true; rootSel.appendChild(o); });
|
||
rootSel.onchange = () => { ungodlyMark("scale"); gScale.root = +rootSel.value; openArranger(); };
|
||
scRow.appendChild(rootSel);
|
||
const scaleSel = document.createElement("select"); scaleSel.className = "asel";
|
||
Object.keys(SCALES).forEach(nm => { const o = document.createElement("option"); o.value = nm; o.textContent = nm.replace("_", " "); if (nm === gScale.name) o.selected = true; scaleSel.appendChild(o); });
|
||
scaleSel.onchange = () => { ungodlyMark("scale"); gScale.name = scaleSel.value; SEQ_ROWS = scaleRows(gScale.name); openArranger(); };
|
||
scRow.appendChild(scaleSel);
|
||
const scHint = document.createElement("span"); scHint.className = "albl";
|
||
scHint.textContent = "every groove is in this key — change it and your patterns re-harmonise (the roll is always folded to the scale)";
|
||
scRow.appendChild(scHint);
|
||
body.appendChild(scRow);
|
||
|
||
// --- the Groove controls: one feel for every playing pattern (§14.1) ---
|
||
const gr = document.createElement("div"); gr.className = "arow grow";
|
||
const mkGSlider = (label, get, set, max) => {
|
||
const w = document.createElement("span"); w.className = "gctl";
|
||
const lb = document.createElement("span"); lb.className = "glbl"; lb.textContent = label;
|
||
const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000";
|
||
const vv = document.createElement("span"); vv.className = "gval";
|
||
const show = () => vv.textContent = Math.round(get() / max * 100) + "%";
|
||
sl.value = String(Math.round(get() / max * 1000)); show();
|
||
sl.oninput = () => { ungodlyGesture("groove"); set(clamp(+sl.value / 1000 * max, 0, max)); show(); };
|
||
w.appendChild(lb); w.appendChild(sl); w.appendChild(vv);
|
||
return w;
|
||
};
|
||
gr.appendChild(mkGSlider("swing", () => groove.swing, v => { groove.swing = v; groove.preset = "custom"; }, 1));
|
||
gr.appendChild(mkGSlider("humanize", () => groove.humanize, v => { groove.humanize = v; groove.preset = "custom"; }, 1));
|
||
gr.appendChild(mkGSlider("velocity", () => groove.velo, v => { groove.velo = v; groove.preset = "custom"; }, 1));
|
||
gr.appendChild(mkGSlider("amount", () => groove.amount, v => { groove.amount = v; }, 1.3));
|
||
for (const pn of Object.keys(GROOVE_PRESETS)) {
|
||
const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn;
|
||
pb.classList.toggle("on", groove.preset === pn);
|
||
pb.onclick = () => { ungodlyMark("groove preset"); Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openArranger(); };
|
||
gr.appendChild(pb);
|
||
}
|
||
body.appendChild(gr);
|
||
|
||
// --- Follow Action for the selected clip letter (§16.7): the song composes itself ---
|
||
const fa = (arr.follow[arrSelKey] && arr.follow[arrSelKey][arrSelLetter]) || { act: "", chance: 1 };
|
||
const setFA = (patch) => { const fol = arr.follow[arrSelKey] || (arr.follow[arrSelKey] = {});
|
||
Object.assign(fol[arrSelLetter] || (fol[arrSelLetter] = { act: "", chance: 1 }), patch); };
|
||
const fr = document.createElement("div"); fr.className = "arow grow";
|
||
const flbl = document.createElement("span"); flbl.className = "glbl"; flbl.style.minWidth = "auto";
|
||
flbl.textContent = "after " + arrSelLetter + " →";
|
||
const fsel = document.createElement("select"); fsel.className = "asel";
|
||
[["", "— stay"], ["again", "play again"], ["next", "next clip"], ["any", "any clip"], ["other", "any other"], ["stop", "stop"]]
|
||
.forEach(([v, t]) => { const o = document.createElement("option"); o.value = v; o.textContent = t; if (v === fa.act) o.selected = true; fsel.appendChild(o); });
|
||
fsel.onchange = () => { ungodlyMark("follow"); setFA({ act: fsel.value }); openArranger(); };
|
||
fr.appendChild(flbl); fr.appendChild(fsel);
|
||
if (fa.act) {
|
||
const cwrap = document.createElement("span"); cwrap.className = "gctl";
|
||
const clab = document.createElement("span"); clab.className = "glbl"; clab.textContent = "chance";
|
||
const csl = document.createElement("input"); csl.type = "range"; csl.min = "0"; csl.max = "100";
|
||
csl.value = String(Math.round((fa.chance != null ? fa.chance : 1) * 100));
|
||
const cval = document.createElement("span"); cval.className = "gval"; cval.textContent = csl.value + "%";
|
||
csl.oninput = () => { ungodlyGesture("follow"); setFA({ chance: clamp(+csl.value / 100, 0, 1) }); cval.textContent = csl.value + "%"; };
|
||
cwrap.appendChild(clab); cwrap.appendChild(csl); cwrap.appendChild(cval);
|
||
fr.appendChild(cwrap);
|
||
}
|
||
const fhint = document.createElement("span"); fhint.className = "albl";
|
||
fhint.textContent = fa.act ? "▶ playing, the arrangement diverts here by chance — never quite the same twice"
|
||
: "give a clip a follow action and the song writes its own variations";
|
||
fr.appendChild(fhint);
|
||
body.appendChild(fr);
|
||
|
||
// --- Euclidean fill (§11.3.5): spread N pulses evenly across the pattern ---
|
||
const euRow = document.createElement("div"); euRow.className = "arow grow";
|
||
const euL = document.createElement("span"); euL.className = "glbl"; euL.style.minWidth = "auto"; euL.textContent = "⬢ euclid";
|
||
euRow.appendChild(euL);
|
||
const mkEuNum = (label, get, set, min, max) => {
|
||
const w = document.createElement("span"); w.className = "gctl";
|
||
const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "auto"; l.textContent = label;
|
||
const n = document.createElement("input"); n.type = "number"; n.min = String(min); n.max = String(max);
|
||
n.value = String(get()); n.className = "eunum";
|
||
n.oninput = () => { ungodlyGesture("euclid"); set(clamp(Math.round(+n.value || 0), min, max));
|
||
euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); };
|
||
w.appendChild(l); w.appendChild(n);
|
||
return w;
|
||
};
|
||
euRow.appendChild(mkEuNum("pulses", () => euclidP.pulses, v => euclidP.pulses = v, 0, 16));
|
||
euRow.appendChild(mkEuNum("steps", () => euclidP.len, v => euclidP.len = v, 2, 16));
|
||
euRow.appendChild(mkEuNum("rotate", () => euclidP.rot, v => euclidP.rot = v, 0, 15));
|
||
{ // ☸ WHEELS — the lane's own pattern length (1..16); shorter than 16 → polymeter
|
||
const wsqA = seqFor(arrSelKey, nSel.isNote);
|
||
const w = document.createElement("span"); w.className = "gctl";
|
||
const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "auto"; l.textContent = "☸ wheel";
|
||
const n = document.createElement("input"); n.type = "number"; n.min = "1"; n.max = "16"; n.className = "eunum";
|
||
n.value = String(wsqA.wlen || 16); n.title = "the lane's own length — 12 over a 16 kick is polymeter";
|
||
n.oninput = () => { ungodlyGesture("wheel"); wsqA.wlen = clamp(Math.round(+n.value || 16), 1, 16);
|
||
euclidP.len = wsqA.wlen; paintG(); }; // a fill now spreads within the wheel (steps still overrides)
|
||
w.appendChild(l); w.appendChild(n); euRow.appendChild(w);
|
||
}
|
||
const euBtn = document.createElement("button"); euBtn.className = "midibtn"; euBtn.textContent = "fill";
|
||
euBtn.onclick = () => { ungodlyMark("euclid"); euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); };
|
||
euRow.appendChild(euBtn);
|
||
const euHint = document.createElement("span"); euHint.className = "albl";
|
||
euHint.textContent = "spread the pulses evenly — E(3,8) tresillo · E(5,8) cinquillo · E(4,16) four-on-floor";
|
||
euRow.appendChild(euHint);
|
||
body.appendChild(euRow);
|
||
|
||
const steps = arrBank(arrSelKey, nSel.isNote)[arrSelLetter];
|
||
const rows = nSel.isNote ? SEQ_ROWS.length : 1;
|
||
const grid = document.createElement("div");
|
||
grid.className = "seqgrid big";
|
||
const cells = [];
|
||
for (let rw = rows - 1; rw >= 0; rw--) {
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = document.createElement("div");
|
||
cell.className = "seqcell";
|
||
if (stp % 4 === 0) cell.classList.add("bar");
|
||
if (nSel.isNote) {
|
||
cell.title = midiToName(seqRootFor(arrSelKey) + SEQ_ROWS[rw]) + " · step " + (stp + 1);
|
||
cell.onclick = () => { ungodlyGesture("groove edit"); stepToggle(steps, stp, rw); paintG(); };
|
||
} else {
|
||
cell.title = "step " + (stp + 1);
|
||
cell.onclick = () => { ungodlyGesture("groove edit"); steps[stp] = steps[stp] ? 0 : 1; paintG(); };
|
||
}
|
||
cells.push({ el: cell, rw, stp });
|
||
grid.appendChild(cell);
|
||
}
|
||
}
|
||
let renderNotesG = null; // note-length overlays (note lanes only; len is per-voice)
|
||
if (nSel.isNote) { const vsq = seqFor(arrSelKey, true); if (!vsq.len) vsq.len = new Array(16).fill(1);
|
||
renderNotesG = attachNoteLengths(grid, cells, () => steps, vsq.len, rows, null); }
|
||
const paintG = () => {
|
||
const wsq = seqFor(arrSelKey, nSel.isNote), wl = wsq.wlen || 16, ph = wheelStep(wsq); // ☸ WHEELS
|
||
for (const c2 of cells) {
|
||
c2.el.classList.toggle("on", nSel.isNote ? stepHas(steps[c2.stp], c2.rw) : !!steps[c2.stp]);
|
||
c2.el.classList.toggle("playone", nSel.isNote && stepStack(steps[c2.stp]) && stepHas(steps[c2.stp], c2.rw));
|
||
c2.el.classList.toggle("cur", arr.playing && c2.stp === ph);
|
||
c2.el.classList.toggle("off", c2.stp >= wl); // dim the steps past the wheel
|
||
}
|
||
if (renderNotesG) renderNotesG();
|
||
};
|
||
paintG();
|
||
arrGrooveRefresh = paintG;
|
||
body.appendChild(grid);
|
||
|
||
// --- per-step expression: velocity / chance / ratchet, bound to the voice ---
|
||
if (nSel.isNote) {
|
||
const sqx = seqFor(arrSelKey, true);
|
||
if (!sqx.vel) sqx.vel = new Array(16).fill(0.8);
|
||
if (!sqx.chance) sqx.chance = new Array(16).fill(1);
|
||
if (!sqx.rat) sqx.rat = new Array(16).fill(1);
|
||
const em = document.createElement("div"); em.className = "arow emrow";
|
||
for (const mode of ["velocity", "chance", "ratchet"]) {
|
||
const b = document.createElement("button"); b.className = "midibtn"; b.textContent = mode;
|
||
b.classList.toggle("on", arrExprMode === mode);
|
||
b.onclick = () => { arrExprMode = arrExprMode === mode ? "none" : mode; openArranger(); };
|
||
em.appendChild(b);
|
||
}
|
||
const eh = document.createElement("span"); eh.className = "albl";
|
||
eh.textContent = arrExprMode === "none" ? "velocity / chance / ratchet — how hard each step hits, how often, and how many times"
|
||
: arrExprMode === "velocity" ? "drag a bar — how hard each step hits"
|
||
: arrExprMode === "chance" ? "drag a bar — how likely each step fires (100% = always)"
|
||
: "drag a bar — how many times each step re-fires (1–4): instant rolls & 32nd glitch";
|
||
em.appendChild(eh);
|
||
body.appendChild(em);
|
||
if (arrExprMode !== "none") {
|
||
const isRat = arrExprMode === "ratchet";
|
||
const arrv = isRat ? sqx.rat : (arrExprMode === "velocity" ? sqx.vel : sqx.chance);
|
||
const lane = document.createElement("div"); lane.className = "exprlane";
|
||
const bars = [];
|
||
const paintExpr = () => { for (const b of bars) {
|
||
const h = isRat ? (clamp(arrv[b.stp], 1, 4) - 1) / 3 : clamp(arrv[b.stp], 0, 1);
|
||
b.fill.style.height = Math.round(h * 100) + "%";
|
||
b.fill.classList.toggle("lit", stepFirst(steps[b.stp]) >= 0);
|
||
} };
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = document.createElement("div"); cell.className = "exprcell";
|
||
if (stp % 4 === 0) cell.classList.add("bar");
|
||
const fill = document.createElement("div"); fill.className = "exprbar";
|
||
cell.appendChild(fill);
|
||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||
const frac = clamp(1 - (cy - r.top) / r.height, 0, 1);
|
||
ungodlyGesture("groove expr"); arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||
const mv = (e2) => setY(e2.clientY);
|
||
const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); };
|
||
window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); });
|
||
bars.push({ fill, stp });
|
||
lane.appendChild(cell);
|
||
}
|
||
paintExpr();
|
||
body.appendChild(lane);
|
||
}
|
||
} else {
|
||
// --- p-lock lane for a CC/texture voice: pin its value per step (PO/Elektron) ---
|
||
const sqx = seqFor(arrSelKey, false);
|
||
if (!sqx.lock) sqx.lock = new Array(16).fill(-1);
|
||
const em = document.createElement("div"); em.className = "arow emrow";
|
||
const eh = document.createElement("span"); eh.className = "albl";
|
||
eh.textContent = "⬗ p-lock — drag a step to pin " + prettyDest(arrSelKey) + " to a value there; drag below the lane to unlock";
|
||
em.appendChild(eh);
|
||
body.appendChild(em);
|
||
const lane = document.createElement("div"); lane.className = "exprlane";
|
||
const bars = [];
|
||
const paintLock = () => { for (const b of bars) {
|
||
const lk = sqx.lock[b.stp];
|
||
b.fill.style.height = lk >= 0 ? Math.round(lk * 100) + "%" : "0%";
|
||
b.fill.classList.toggle("locked", lk >= 0);
|
||
b.fill.classList.toggle("lit", !!steps[b.stp]); // steps = this voice's gate row
|
||
} };
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = document.createElement("div"); cell.className = "exprcell";
|
||
if (stp % 4 === 0) cell.classList.add("bar");
|
||
const fill = document.createElement("div"); fill.className = "exprbar";
|
||
cell.appendChild(fill);
|
||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||
const val = 1 - (cy - r.top) / r.height;
|
||
ungodlyGesture("groove expr"); sqx.lock[stp] = val < 0 ? -1 : clamp(val, 0, 1); // dragged below the lane = unlock
|
||
paintLock(); };
|
||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||
const mv = (e2) => setY(e2.clientY);
|
||
const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); };
|
||
window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); });
|
||
bars.push({ fill, stp });
|
||
lane.appendChild(cell);
|
||
}
|
||
paintLock();
|
||
body.appendChild(lane);
|
||
}
|
||
|
||
arrPaintBar = () => paintPlay();
|
||
arrEl.classList.add("open");
|
||
return;
|
||
}
|
||
|
||
// ---- the clips grid ----
|
||
arrGrooveRefresh = null;
|
||
const CYCLE = ["live", "A", "B", "C", "mute"];
|
||
const cellRefs = [];
|
||
const keys2 = Array.from(dests.keys()).sort();
|
||
for (const key of keys2) {
|
||
const n = dests.get(key);
|
||
const row = document.createElement("div"); row.className = "arow";
|
||
const name = document.createElement("span"); name.className = "aname";
|
||
name.textContent = prettyDest(key);
|
||
name.title = key + (n.isNote ? " · note lane (exports to .mid)" : " · texture lane (gates the data)") +
|
||
" — right-click for routing, click to open its tab";
|
||
name.onclick = () => openInspector(n);
|
||
name.oncontextmenu = (e2) => { // right-click a track = its full routing
|
||
e2.preventDefault();
|
||
openCtxMenu(n, e2.clientX, e2.clientY);
|
||
};
|
||
row.appendChild(name);
|
||
const lane = arrLane(key);
|
||
for (let b = 0; b < arr.bars; b++) {
|
||
const cell = document.createElement("div"); cell.className = "acell";
|
||
const paint = () => {
|
||
const st = lane[b];
|
||
cell.textContent = st === "live" ? "●" : st === "mute" ? "" : st;
|
||
cell.className = "acell " + (st === "live" ? "alive" : st === "mute" ? "amute" : "aclip");
|
||
if ((st === "A" || st === "B" || st === "C") && arr.follow[key] && arr.follow[key][st] && arr.follow[key][st].act)
|
||
cell.classList.add("hasfollow"); // this clip can divert the song (§16.7)
|
||
if (arr.playing && b === arr.bar) cell.classList.add("acur");
|
||
};
|
||
cell.onclick = () => {
|
||
ungodlyGesture("clip"); lane[b] = CYCLE[(CYCLE.indexOf(lane[b]) + 1) % CYCLE.length];
|
||
if (arr.playing && b === arr.bar) applyArrColumn();
|
||
paint();
|
||
};
|
||
cell.ondblclick = () => {
|
||
const st = lane[b];
|
||
if (st !== "A" && st !== "B" && st !== "C") return;
|
||
arrSelKey = key; arrSelLetter = st; arrTab = "grooves"; // jump to the big editor
|
||
openArranger();
|
||
};
|
||
paint();
|
||
cellRefs.push(paint);
|
||
row.appendChild(cell);
|
||
}
|
||
body.appendChild(row);
|
||
}
|
||
arrPaintBar = () => { for (const p2 of cellRefs) p2(); paintPlay(); };
|
||
arrEl.classList.add("open");
|
||
}
|
||
|
||
// ---- the commune — play together through the hub --------------------------
|
||
// No Bluetooth needed (a web page can't be a BT peer): everyone is already
|
||
// joined to the same hub websocket, so the hub relays tiny state diffs
|
||
// between roommates. Form a room with a word, claim voices (and the godtime),
|
||
// and each player's owned sections follow their hands on every screen —
|
||
// same living room or opposite sides of the earth.
|
||
const commune = { room: null, host: null, members: [], alloc: {}, invites: {}, sent: {} };
|
||
function communeMe() {
|
||
const u = (typeof Accounts !== "undefined" && Accounts.user) ? Accounts.user.username : null;
|
||
if (u) return u;
|
||
const chip2 = document.getElementById("authchip");
|
||
return chip2 && /^@/.test(chip2.textContent) ? chip2.textContent.slice(1) : "guest";
|
||
}
|
||
function communeSend(obj) {
|
||
sendCmd({ commune: Object.assign({ room: obj.room != null ? obj.room : commune.room, from: communeMe() }, obj) });
|
||
}
|
||
function communeCreate(name) {
|
||
const room = (name || "").trim().toLowerCase();
|
||
if (room) communeSend({ create: 1, room });
|
||
}
|
||
function communeInvite(who) { communeSend({ invite: (who || "").trim() }); }
|
||
function communeAccept(room) { communeSend({ accept: 1, room }); }
|
||
function communeDecline(room) { communeSend({ decline: 1, room }); delete commune.invites[room]; }
|
||
function communeLeave() {
|
||
if (commune.room) communeSend({ leave: 1 });
|
||
commune.room = null; commune.host = null; commune.members = []; commune.alloc = {}; commune.sent = {};
|
||
}
|
||
function communeAllocate(section, to) { communeSend({ allocate: { section, to } }); }
|
||
function communeClaim(section) { delete commune.sent[section]; communeSend({ claim: section }); }
|
||
function communeRelease(section) { communeSend({ release: section }); }
|
||
setInterval(() => { if (connected) communeSend({ hello: 1, room: "~" }); }, 15000); // presence tag survives reconnects
|
||
function communeState(section) { // the shareable state of a section
|
||
if (section === "@godtime") return { bpm: Math.round(godtime.bpm * 2) / 2 };
|
||
const t2 = tweaks[section] || { mute: 0, gain: 1, offset: 0 };
|
||
const sq = seqs[section];
|
||
return {
|
||
tw: { m: t2.mute, g: Math.round(t2.gain * 100) / 100, o: Math.round(t2.offset * 100) / 100 },
|
||
on: sq ? !!sq.on : false,
|
||
steps: sq ? sq.steps.slice() : null,
|
||
lane: arr.grid[section] ? arr.grid[section].slice() : null,
|
||
};
|
||
}
|
||
setInterval(() => { // your sections sync, diff-only
|
||
if (!commune.room) return;
|
||
for (const s2 in commune.alloc) {
|
||
if (commune.alloc[s2] !== communeMe()) continue;
|
||
const st = communeState(s2), j2 = JSON.stringify(st);
|
||
if (commune.sent[s2] !== j2) { commune.sent[s2] = j2; communeSend({ voice: s2, state: st }); }
|
||
}
|
||
}, 600);
|
||
|
||
function handleCommune(c) {
|
||
if (c.error) { eventTicker.unshift({ text: "🕸 " + c.error, tAdded: now(), src: "sys" }); return; }
|
||
if (c.ok) { eventTicker.unshift({ text: "🕸 " + c.ok, tAdded: now(), src: "sys" }); return; }
|
||
if (c.invite) { // someone asks you in
|
||
commune.invites[c.invite.room] = c.invite.host;
|
||
eventTicker.unshift({ text: "✉ " + c.invite.host + " invites you to the commune “" + c.invite.room + "” — right-click the sky → the commune", tAdded: now(), src: "sys" });
|
||
if (communeEl.classList.contains("open")) openCommune();
|
||
return;
|
||
}
|
||
if (c.declined) { eventTicker.unshift({ text: "🕸 " + c.declined + " declined the invitation", tAdded: now(), src: "sys" }); return; }
|
||
if (c.disbanded) {
|
||
if (commune.room === c.disbanded) {
|
||
commune.room = null; commune.host = null; commune.members = []; commune.alloc = {}; commune.sent = {};
|
||
eventTicker.unshift({ text: "🕸 the commune “" + c.disbanded + "” has closed", tAdded: now(), src: "sys" });
|
||
if (communeEl.classList.contains("open")) openCommune();
|
||
}
|
||
return;
|
||
}
|
||
if (c.roominfo) { // the hub is the truth of the room
|
||
const ri = c.roominfo, before = commune.members;
|
||
const joining = !commune.room;
|
||
commune.room = ri.room; commune.host = ri.host;
|
||
commune.members = ri.members || []; commune.alloc = ri.alloc || {};
|
||
delete commune.invites[ri.room];
|
||
if (!joining) for (const m2 of commune.members)
|
||
if (before.indexOf(m2) < 0)
|
||
eventTicker.unshift({ text: "🕸 " + m2 + " joined the commune", tAdded: now(), src: "sys" });
|
||
if (joining)
|
||
eventTicker.unshift({ text: "🕸 you are in the commune “" + ri.room + "” · host: " + ri.host, tAdded: now(), src: "sys" });
|
||
if (communeEl.classList.contains("open")) openCommune();
|
||
return;
|
||
}
|
||
if (c.voice && c.state && c.room === commune.room && commune.alloc[c.voice] === c.from && c.from !== communeMe()) {
|
||
const st = c.state;
|
||
if (c.voice === "@godtime") { if (st.bpm) godtime.bpm = clamp(st.bpm, 40, 180); return; }
|
||
const n = dests.get(c.voice);
|
||
if (!n) return;
|
||
if (st.tw) { const t2 = tw(c.voice); t2.mute = st.tw.m; t2.gain = st.tw.g; t2.offset = st.tw.o; }
|
||
if (st.steps && st.steps.length === 16) {
|
||
const sq = seqFor(c.voice, n.isNote);
|
||
sq.steps = st.steps.slice(); // replace the reference — never let a peer
|
||
sq.on = !!st.on; // overwrite one of YOUR arranger banks
|
||
}
|
||
if (st.lane && st.lane.length === arr.bars) arr.grid[c.voice] = st.lane.slice();
|
||
if (arrPaintBar) arrPaintBar();
|
||
}
|
||
}
|
||
|
||
const communeEl = document.createElement("div");
|
||
communeEl.id = "commune";
|
||
document.body.appendChild(communeEl);
|
||
function closeCommunePanel() { communeEl.classList.remove("open"); }
|
||
function openCommune() {
|
||
communeEl.innerHTML = "";
|
||
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×";
|
||
x2.onclick = closeCommunePanel; communeEl.appendChild(x2);
|
||
const h3 = document.createElement("h3"); h3.textContent = "🕸 the commune"; communeEl.appendChild(h3);
|
||
const mkVoiceSel = () => {
|
||
const sel = document.createElement("select"); sel.className = "asel"; sel.style.flex = "1";
|
||
const og = document.createElement("option"); og.value = "@godtime"; og.textContent = "the godtime (tempo)";
|
||
sel.appendChild(og);
|
||
for (const k2 of Array.from(dests.keys()).sort()) {
|
||
const o2 = document.createElement("option"); o2.value = k2; o2.textContent = prettyDest(k2);
|
||
sel.appendChild(o2);
|
||
}
|
||
return sel;
|
||
};
|
||
if (!commune.room) {
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.textContent = "host a room and invite the others (they must be signed in and on the site), or accept an invitation below. the host names the room and can allocate sections; anyone may leave at any time.";
|
||
communeEl.appendChild(sub);
|
||
// pending invitations
|
||
const invRooms = Object.keys(commune.invites);
|
||
for (const rm of invRooms) {
|
||
const row = document.createElement("div"); row.className = "hrow";
|
||
const lbl = document.createElement("span"); lbl.style.flex = "1";
|
||
lbl.innerHTML = "✉ <b style='color:#cfe0f5'>" + commune.invites[rm] + "</b> invites you to “" + rm + "”";
|
||
const acc = document.createElement("button"); acc.className = "midibtn"; acc.textContent = "join";
|
||
acc.onclick = () => communeAccept(rm);
|
||
const dec = document.createElement("button"); dec.className = "midibtn"; dec.textContent = "decline";
|
||
dec.onclick = () => { communeDecline(rm); openCommune(); };
|
||
row.appendChild(lbl); row.appendChild(acc); row.appendChild(dec);
|
||
communeEl.appendChild(row);
|
||
}
|
||
const roomI = document.createElement("input"); roomI.placeholder = "name your room (e.g. the lodge)";
|
||
const r1 = document.createElement("div"); r1.className = "hrow"; r1.appendChild(roomI);
|
||
const go = document.createElement("button"); go.className = "midibtn"; go.style.marginTop = "8px";
|
||
go.textContent = "host a commune";
|
||
go.onclick = () => communeCreate(roomI.value);
|
||
communeEl.appendChild(r1); communeEl.appendChild(go);
|
||
} else {
|
||
const isHost = commune.host === communeMe();
|
||
const inRoom = document.createElement("div"); inRoom.className = "sub";
|
||
inRoom.innerHTML = "room: <b style='color:#cfe0f5'>" + commune.room + "</b> · host: <b style='color:#cfe0f5'>" +
|
||
commune.host + "</b><br>here: " + commune.members.map(m2 => m2 === communeMe() ? m2 + " (you)" : m2).join(", ");
|
||
communeEl.appendChild(inRoom);
|
||
const claims = document.createElement("div"); claims.className = "sub";
|
||
const held = Object.keys(commune.alloc);
|
||
claims.textContent = held.length
|
||
? "sections: " + held.map(s2 => (s2 === "@godtime" ? "godtime" : prettyDest(s2)) + " → " + commune.alloc[s2]).join(" · ")
|
||
: "no sections held yet — claim below, or the host can allocate";
|
||
communeEl.appendChild(claims);
|
||
// claim (anyone)
|
||
const pick = document.createElement("div"); pick.className = "hrow";
|
||
const sel = mkVoiceSel();
|
||
const cb = document.createElement("button"); cb.className = "midibtn"; cb.textContent = "claim";
|
||
cb.onclick = () => communeClaim(sel.value);
|
||
pick.appendChild(sel); pick.appendChild(cb);
|
||
communeEl.appendChild(pick);
|
||
if (isHost) {
|
||
// invite (host)
|
||
const invRow = document.createElement("div"); invRow.className = "hrow";
|
||
const invI = document.createElement("input"); invI.placeholder = "invite a signed-in user…";
|
||
const ib = document.createElement("button"); ib.className = "midibtn"; ib.textContent = "✉ invite";
|
||
ib.onclick = () => { communeInvite(invI.value); invI.value = ""; };
|
||
invRow.appendChild(invI); invRow.appendChild(ib);
|
||
communeEl.appendChild(invRow);
|
||
// allocate (host)
|
||
const alRow = document.createElement("div"); alRow.className = "hrow";
|
||
const alSel = mkVoiceSel();
|
||
const whoSel = document.createElement("select"); whoSel.className = "asel";
|
||
for (const m2 of commune.members) {
|
||
const o2 = document.createElement("option"); o2.value = m2; o2.textContent = m2;
|
||
whoSel.appendChild(o2);
|
||
}
|
||
const ab = document.createElement("button"); ab.className = "midibtn"; ab.textContent = "allocate";
|
||
ab.onclick = () => communeAllocate(alSel.value, whoSel.value);
|
||
alRow.appendChild(alSel); alRow.appendChild(whoSel); alRow.appendChild(ab);
|
||
communeEl.appendChild(alRow);
|
||
}
|
||
const lv = document.createElement("button"); lv.className = "midibtn"; lv.style.marginTop = "8px";
|
||
lv.textContent = isHost ? "disband the commune" : "leave the room";
|
||
lv.onclick = () => { communeLeave(); openCommune(); };
|
||
communeEl.appendChild(lv);
|
||
}
|
||
communeEl.classList.add("open");
|
||
}
|
||
|
||
// ---- the stage — send the visual to another screen ------------------------
|
||
// window.open + canvas.captureStream: a live mirror window you drag to a
|
||
// projector or AirPlay display (macOS treats it like any other window), then
|
||
// double-click for fullscreen. The camera can hold the whole view, follow a
|
||
// single node through its orbits, or frame a station and its satellites.
|
||
let stageWin = null, stageWrap = null, stageVideo = null, stageCam = null, stagePoll = 0;
|
||
const stageRect = { x: 0, y: 0, w: 1, h: 1 };
|
||
function openStage(cam) {
|
||
stageCam = cam || null;
|
||
if (stageWin && !stageWin.closed) return; // window stays; only the camera moved
|
||
const w2 = window.open("", "gs_stage", "width=960,height=560");
|
||
if (!w2) {
|
||
eventTicker.unshift({ text: "the browser blocked the stage window — allow popups for this site", tAdded: now(), src: "sys" });
|
||
return;
|
||
}
|
||
stageWin = w2;
|
||
const d2 = w2.document;
|
||
d2.title = "godstrument · stage";
|
||
d2.body.style.cssText = "margin:0;background:#000;overflow:hidden";
|
||
stageWrap = d2.createElement("div");
|
||
stageWrap.style.cssText = "position:absolute;left:0;top:0;transform-origin:0 0";
|
||
stageVideo = d2.createElement("video");
|
||
stageVideo.muted = true; stageVideo.autoplay = true; stageVideo.playsInline = true;
|
||
try { stageVideo.srcObject = canvas.captureStream(30); } catch (e) {}
|
||
stageVideo.style.cssText = "display:block";
|
||
stageWrap.appendChild(stageVideo);
|
||
d2.body.appendChild(stageWrap);
|
||
const hint3 = d2.createElement("div");
|
||
hint3.textContent = "drag me to the projector · double-click for fullscreen";
|
||
hint3.style.cssText = "position:fixed;left:50%;bottom:24px;transform:translateX(-50%);color:rgba(200,215,240,0.7);font:12px 'Helvetica Neue',Arial,sans-serif;transition:opacity 1.5s";
|
||
d2.body.appendChild(hint3);
|
||
setTimeout(() => { try { hint3.style.opacity = "0"; } catch (e) {} }, 5000);
|
||
d2.body.addEventListener("dblclick", () => {
|
||
d2.fullscreenElement ? d2.exitFullscreen() : d2.body.requestFullscreen().catch(() => {});
|
||
});
|
||
stageRect.x = 0; stageRect.y = 0; stageRect.w = W; stageRect.h = H;
|
||
eventTicker.unshift({ text: "📽 the stage is open — drag it to your other screen, double-click for fullscreen", tAdded: now(), src: "sys" });
|
||
}
|
||
function closeStage() {
|
||
try { if (stageWin && !stageWin.closed) stageWin.close(); } catch (e) {}
|
||
stageWin = null; stageCam = null;
|
||
}
|
||
function stageTarget() { // what the camera wants to frame
|
||
if (stageCam && stageCam.type === "node") {
|
||
const n = sources.get(stageCam.key) || dests.get(stageCam.key);
|
||
if (n && n.x > -5000) return { x: n.x - 210, y: n.y - 210, w: 420, h: 420 };
|
||
}
|
||
if (stageCam && stageCam.type === "station" && chakraMode) {
|
||
const c = activeSkin.byKey[stageCam.key];
|
||
if (c && c.members && c.members.length) {
|
||
let x0 = c.sx != null ? c.sx : W / 2, y0 = c.sy != null ? c.sy : H / 2, x1 = x0, y1 = y0;
|
||
for (const m2 of c.members) {
|
||
if (m2.x < -5000) continue;
|
||
x0 = Math.min(x0, m2.x); x1 = Math.max(x1, m2.x);
|
||
y0 = Math.min(y0, m2.y); y1 = Math.max(y1, m2.y);
|
||
}
|
||
return { x: x0 - 90, y: y0 - 90, w: (x1 - x0) + 180, h: (y1 - y0) + 180 };
|
||
}
|
||
}
|
||
return { x: 0, y: 0, w: W, h: H };
|
||
}
|
||
function stageFrame(dt) {
|
||
if (!stageWin) return;
|
||
if (++stagePoll % 60 === 0 && stageWin.closed) { stageWin = null; stageCam = null; return; }
|
||
const t2 = stageTarget();
|
||
stageRect.x = approach(stageRect.x, t2.x, 0.14, dt);
|
||
stageRect.y = approach(stageRect.y, t2.y, 0.14, dt);
|
||
stageRect.w = approach(stageRect.w, t2.w, 0.14, dt);
|
||
stageRect.h = approach(stageRect.h, t2.h, 0.14, dt);
|
||
try {
|
||
stageVideo.style.width = W + "px"; stageVideo.style.height = H + "px";
|
||
const vw = stageWin.innerWidth, vh = stageWin.innerHeight;
|
||
const s2 = Math.min(vw / stageRect.w, vh / stageRect.h);
|
||
const tx = -stageRect.x * s2 + (vw - stageRect.w * s2) / 2;
|
||
const ty = -stageRect.y * s2 + (vh - stageRect.h * s2) / 2;
|
||
stageWrap.style.transform = "translate(" + tx + "px," + ty + "px) scale(" + s2 + ")";
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ---- moods — song-shaped presets, so first load greets instead of floods --
|
||
// "first light" is the front door: a bass line, hats, a small melody, soft
|
||
// space — the world still plays underneath, just fewer of its hands at once.
|
||
const MOODS = {
|
||
"first light": {
|
||
bpm: 92, lock: true,
|
||
mutes: { "glitch": 1, "granular.density": 1, "saturation": 1, "tempo.nudge": 1 },
|
||
gains: { "reverb.size": 0.7, "delay.feedback": 0.5, "drone.voices": 0.5,
|
||
"pad.brightness": 0.7, "perc.density": 1.3, "master.space": 0.7 },
|
||
grooves: {
|
||
"bass.note": [0,-1,-1,-1, 0,-1,-1,-1, 1,-1,-1,-1, 2,-1, 1,-1],
|
||
"lead.note": [0,-1, 2,-1, 3,-1, 2,-1, 4,-1, 3, 2, 1,-1, 2,-1],
|
||
"perc.density": [1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1],
|
||
},
|
||
},
|
||
"ambient drift": {
|
||
bpm: 70, lock: true, clearGrooves: true,
|
||
mutes: { "glitch": 1, "granular.density": 1, "saturation": 1, "tempo.nudge": 1,
|
||
"perc.density": 1, "lead.note": 1, "bass.note": 1 },
|
||
gains: { "reverb.size": 1.2, "master.space": 1.2, "pad.brightness": 1.0,
|
||
"drone.voices": 0.9, "delay.feedback": 0.8 },
|
||
},
|
||
"the full storm": { storm: true }, // the factory instrument, every hand at once
|
||
};
|
||
const MOOD_KEYS = ["glitch", "granular.density", "saturation", "tempo.nudge", "wavetable.morph",
|
||
"lfo.rate", "reverb.size", "delay.feedback", "drone.voices", "pad.brightness",
|
||
"perc.density", "master.space", "lead.note", "bass.note"];
|
||
let moodAppliedThisLoad = false; // guard: apply a mood once per page load, not per reconnect
|
||
function applyMood(name) {
|
||
const m = MOODS[name];
|
||
if (!m) return;
|
||
ungodlyMark("mood: " + name); // ⏪ before a mood rewires the mix
|
||
try { localStorage.setItem("gs_mood", name); } catch (e) {} // your vibe survives a reload
|
||
for (const k of MOOD_KEYS) { const t2 = tw(k); t2.mute = 0; t2.gain = 1; t2.offset = 0; }
|
||
if (m.storm) {
|
||
for (const k in seqs) { if (k.slice(0, 5) === "drum.") continue; seqs[k].on = false; } // the beat belongs to the player — a mood never silences it
|
||
godtime.locked = false;
|
||
} else {
|
||
for (const k in (m.mutes || {})) tw(k).mute = m.mutes[k];
|
||
for (const k in (m.gains || {})) tw(k).gain = m.gains[k];
|
||
if (m.clearGrooves) for (const k in seqs) { if (k.slice(0, 5) === "drum.") continue; seqs[k].on = false; } // drum lanes survive a mood change
|
||
for (const k in (m.grooves || {})) {
|
||
const n = dests.get(k);
|
||
const sq = seqFor(k, n ? n.isNote : /note/.test(k));
|
||
sq.steps = m.grooves[k].slice(); // a fresh array — never overwrite an arranger bank
|
||
sq.on = true;
|
||
}
|
||
if (m.bpm) godtime.bpm = m.bpm;
|
||
godtime.locked = !!m.lock;
|
||
}
|
||
eventTicker.unshift({ text: "🌗 mood: " + name, tAdded: now(), src: "sys" });
|
||
}
|
||
|
||
// the launcher — one visible button, always there for the casuals
|
||
const arrBtn = document.createElement("div");
|
||
arrBtn.id = "arrbtn";
|
||
arrBtn.textContent = "🎛 tracks";
|
||
arrBtn.title = "tracks & clips — make a song (T)";
|
||
arrBtn.onclick = () => openArranger();
|
||
document.body.appendChild(arrBtn);
|
||
|
||
const omniBtn = document.createElement("div"); // 🎹 OMNI — the synth panel (Z5), sibling of 🎛 tracks
|
||
omniBtn.id = "omnibtn";
|
||
omniBtn.textContent = "🎹 OMNI";
|
||
omniBtn.title = "OMNI — the synth panel: shape every voice & effect (O)";
|
||
omniBtn.onclick = () => OMNI.toggle();
|
||
document.body.appendChild(omniBtn);
|
||
|
||
const beatBtn = document.createElement("div"); // 🥁 beat — the GODRUM panel (B), sibling of 🎹 OMNI
|
||
beatBtn.id = "beatbtn";
|
||
beatBtn.textContent = "🥁 beat";
|
||
beatBtn.title = "GODRUM — make a beat: drum lanes, groove & kit (B)";
|
||
beatBtn.onclick = () => { beat.open ? closeBeat() : openBeat(); };
|
||
document.body.appendChild(beatBtn);
|
||
|
||
// ---- 🥁 GODRUM — the beat panel ------------------------------------------
|
||
// Drum lanes live in `seqs` under "drum.*" keys and fire from seqTick, so they
|
||
// inherit swing / humanize / chance / ratchet for free. The panel is modelled on
|
||
// the arranger's grooves tab; the feel (`groove`) is the SAME shared object the
|
||
// arranger edits — one groove for the whole machine.
|
||
function seedBeat() { // first open: a pattern so ▶ makes music at once
|
||
if (DRUM_VOICES.some(v => seqs["drum." + v])) return;
|
||
for (const v of DRUM_VOICES) { const sq = drumSeqFor("drum." + v); for (const i of DRUM_SEED[v]) sq.steps[i] = 1; }
|
||
const H = seqs["drum.hat"]; for (let i = 0; i < 16; i++) H.vel[i] = (i % 2) ? 0.4 : 0.8; // seed hat dynamics (step gates come from DRUM_SEED — hat 0–13, open hat rings into the downbeat)
|
||
// all lanes stay on:false — the user's first ▶ starts the beat
|
||
}
|
||
function closeBeat() {
|
||
beat.open = false; beatEl.classList.remove("open");
|
||
beatRefresh = null;
|
||
beatBtn.classList.remove("hidden");
|
||
}
|
||
function openBeat() {
|
||
beat.open = true;
|
||
if (arr.open) closeArranger(); // the two bottom drawers share the screen — only one at a time
|
||
beatBtn.classList.add("hidden");
|
||
seedBeat();
|
||
beatEl.innerHTML = "";
|
||
|
||
// grab-handle — drag to resize, remembered (mirrors the arranger)
|
||
const grip = document.createElement("div"); grip.className = "agrip"; grip.title = "drag to resize";
|
||
grip.addEventListener("mousedown", (e0) => {
|
||
e0.preventDefault();
|
||
const startY = e0.clientY, startH = beatEl.getBoundingClientRect().height;
|
||
const move = (e2) => { beatEl.style.height = clamp(startH + (startY - e2.clientY), 140, window.innerHeight * 0.8) + "px"; };
|
||
const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up);
|
||
try { localStorage.setItem("gs_beath", parseInt(beatEl.style.height) || ""); } catch (e) {} };
|
||
window.addEventListener("mousemove", move); window.addEventListener("mouseup", up);
|
||
});
|
||
beatEl.appendChild(grip);
|
||
try { const h0 = parseInt(localStorage.getItem("gs_beath")); if (h0) beatEl.style.height = h0 + "px"; } catch (e) {}
|
||
|
||
// ---- header: title · master ▶/■ · bpm · close ----
|
||
const top = document.createElement("div"); top.className = "atop";
|
||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "🥁 beat";
|
||
const anyOn = () => DRUM_VOICES.some(v => seqs["drum." + v] && seqs["drum." + v].on);
|
||
const play = document.createElement("button"); play.className = "midibtn";
|
||
const paintPlay = () => { const p = anyOn(); play.textContent = p ? "■ stop" : "▶ play"; play.classList.toggle("on", p); };
|
||
paintPlay();
|
||
const rec = document.createElement("button"); rec.className = "midibtn"; rec.textContent = "⏺ rec";
|
||
rec.title = "record — finger-drum hits (keys 1–5) tap into the grid, quantized to the nearest 16th";
|
||
const paintRec = () => rec.classList.toggle("rec-on", beat.rec);
|
||
paintRec();
|
||
rec.onclick = () => { beat.rec = !beat.rec;
|
||
// stopped-beat recording anchors step 0 to the arm moment — otherwise taps
|
||
// quantize against the free-running (and invisible) clock and land rotated
|
||
beat.recAnchor = (beat.rec && !anyOn()) ? seqPhase : 0;
|
||
paintRec(); };
|
||
const bpmL = document.createElement("span"); bpmL.className = "glbl"; bpmL.style.minWidth = "auto"; bpmL.textContent = "bpm";
|
||
const bpm = document.createElement("input"); bpm.type = "number"; bpm.className = "eunum"; bpm.min = "40"; bpm.max = "180";
|
||
bpm.value = String(Math.round(godtime.bpm));
|
||
// always editable — this mirrors the dial's write path. 🔒 lock means "the
|
||
// world's hands (heart, tempo.nudge) can't drive tempo", never "the user can't".
|
||
bpm.title = "beats per minute (40–180)";
|
||
bpm.onchange = () => { const nv = clamp(Math.round(+bpm.value || godtime.bpm), 40, 180);
|
||
if (nv !== godtime.bpm) { ungodlyMark("tempo"); godtime.bpm = nv; } // ⏪ a tempo change is an act
|
||
bpm.value = String(Math.round(godtime.bpm)); };
|
||
const x2 = document.createElement("span"); x2.className = "aclose"; x2.textContent = "×"; x2.onclick = closeBeat;
|
||
const hint = document.createElement("span"); hint.className = "albl";
|
||
hint.textContent = "keys 1–5 play the kit · ⏺ rec taps them into the grid · the feel is shared with tracks";
|
||
top.appendChild(title); top.appendChild(play); top.appendChild(rec); top.appendChild(bpmL); top.appendChild(bpm);
|
||
top.appendChild(hint); top.appendChild(x2);
|
||
beatEl.appendChild(top);
|
||
|
||
const body = document.createElement("div"); body.className = "abody";
|
||
beatEl.appendChild(body);
|
||
|
||
// ---- the feel: GROOVE presets + swing / humanize / amount (the shared `groove`) ----
|
||
const gr = document.createElement("div"); gr.className = "arow grow";
|
||
const gLbl = document.createElement("span"); gLbl.className = "glbl"; gLbl.style.minWidth = "auto";
|
||
gLbl.textContent = "the feel (shared with tracks)";
|
||
gr.appendChild(gLbl);
|
||
const presetBtns = [];
|
||
for (const pn of Object.keys(GROOVE_PRESETS)) {
|
||
const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn;
|
||
pb.classList.toggle("on", groove.preset === pn);
|
||
pb.onclick = () => { ungodlyMark("groove preset"); Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openBeat(); };
|
||
gr.appendChild(pb); presetBtns.push({ pb, pn });
|
||
}
|
||
const paintPresets = () => presetBtns.forEach(({ pb, pn }) => pb.classList.toggle("on", groove.preset === pn));
|
||
const mkGS = (label, get, set, max) => {
|
||
const w = document.createElement("span"); w.className = "gctl";
|
||
const l = document.createElement("span"); l.className = "glbl"; l.textContent = label;
|
||
const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000";
|
||
const vv = document.createElement("span"); vv.className = "gval";
|
||
const show = () => vv.textContent = Math.round(get() / max * 100) + "%";
|
||
sl.value = String(Math.round(get() / max * 1000)); show();
|
||
sl.oninput = () => { ungodlyGesture("groove"); set(clamp(+sl.value / 1000 * max, 0, max)); show(); paintPresets(); }; // a drag drops the preset to "custom" — relight the buttons to match
|
||
w.appendChild(l); w.appendChild(sl); w.appendChild(vv); return w;
|
||
};
|
||
gr.appendChild(mkGS("swing", () => groove.swing, v => { groove.swing = v; groove.preset = "custom"; }, 1));
|
||
gr.appendChild(mkGS("humanize", () => groove.humanize, v => { groove.humanize = v; groove.preset = "custom"; }, 1));
|
||
gr.appendChild(mkGS("velocity", () => groove.velo, v => { groove.velo = v; groove.preset = "custom"; }, 1)); // the fourth feel dial — velocity deviation, which drum lanes obey
|
||
gr.appendChild(mkGS("amount", () => groove.amount, v => { groove.amount = v; }, 1.3));
|
||
body.appendChild(gr);
|
||
|
||
// ---- the grid: five lanes × 16 steps ----
|
||
let paintGrid, paintExpr; // both defined below; paintGrid also drives paintExpr so lit-bars track the steps
|
||
const euclidPrompt = (sq, v) => {
|
||
const ans = prompt("⬢ euclid " + v + " — pulses across 16 steps (or pulses,rotate ):", "4");
|
||
if (ans == null) return;
|
||
const parts = String(ans).trim().split(/[\s,]+/).filter(x => x !== "").map(Number); // drop leading empties from " 4" / ",4"
|
||
if (!parts.length || !Number.isFinite(parts[0])) return; // junk / empty confirm → no-op; an explicit "0" still clears
|
||
const pulses = clamp(Math.round(parts[0]), 0, 16),
|
||
rot = clamp(Math.round(Number.isFinite(parts[1]) ? parts[1] : 0), 0, 15);
|
||
ungodlyMark("euclid");
|
||
euclidFill(sq.steps, pulses, 16, rot, false);
|
||
};
|
||
const lanes = [];
|
||
for (const v of DRUM_VOICES) {
|
||
const sq = drumSeqFor("drum." + v);
|
||
const lane = document.createElement("div"); lane.className = "blane";
|
||
const lead = document.createElement("div"); lead.className = "blead";
|
||
const name = document.createElement("span"); name.className = "bname"; name.textContent = v;
|
||
const mute = document.createElement("button"); mute.className = "midibtn bmini";
|
||
const paintMute = () => { const on = sq.on; if (mute._on === on) return;
|
||
mute._on = on; mute.classList.toggle("on", on); mute.textContent = on ? "●" : "○"; };
|
||
mute._on = null; paintMute();
|
||
mute.title = "mute / unmute this lane";
|
||
mute.onclick = () => { ungodlyMark("beat mute"); sq.on = !sq.on; paintMute(); paintPlay(); };
|
||
const eu = document.createElement("button"); eu.className = "midibtn bmini"; eu.textContent = "⬢";
|
||
eu.title = "euclid — spread N pulses evenly across the 16 steps";
|
||
eu.onclick = () => { euclidPrompt(sq, v); paintGrid(); };
|
||
const wl = document.createElement("input"); wl.type = "number"; wl.min = "1"; wl.max = "16";
|
||
wl.className = "eunum"; wl.style.width = "36px"; wl.value = String(sq.wlen || 16); // ☸ WHEELS — this lane's length
|
||
wl.title = "☸ wheel — this lane's length (a 12 over a 16 kick is polymeter)";
|
||
wl.oninput = () => { ungodlyGesture("wheel"); sq.wlen = clamp(Math.round(+wl.value || 16), 1, 16); paintGrid(); };
|
||
lead.appendChild(name); lead.appendChild(mute); lead.appendChild(eu); lead.appendChild(wl);
|
||
lane.appendChild(lead);
|
||
const steps = document.createElement("div"); steps.className = "bsteps";
|
||
const cells = [];
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = document.createElement("div"); cell.className = "seqcell";
|
||
if (stp % 4 === 0) cell.classList.add("bar"); // beats 1 / 5 / 9 / 13
|
||
cell.title = v + " · step " + (stp + 1);
|
||
cell.onclick = () => { ungodlyGesture("beat edit"); sq.steps[stp] = sq.steps[stp] ? 0 : 1; paintGrid(); };
|
||
cells.push(cell); steps.appendChild(cell);
|
||
}
|
||
lane.appendChild(steps); body.appendChild(lane);
|
||
lanes.push({ v, sq, cells, paintMute });
|
||
}
|
||
paintGrid = () => {
|
||
const playing = anyOn();
|
||
paintPlay();
|
||
const bv = String(Math.round(godtime.bpm));
|
||
if (document.activeElement !== bpm && bpm.value !== bv) bpm.value = bv;
|
||
for (const L of lanes) { L.paintMute();
|
||
const wl2 = L.sq.wlen || 16, ph = wheelStep(L.sq); // ☸ WHEELS — this lane's wrap
|
||
for (let s = 0; s < 16; s++) {
|
||
L.cells[s].classList.toggle("on", !!L.sq.steps[s]);
|
||
L.cells[s].classList.toggle("cur", playing && s === ph);
|
||
L.cells[s].classList.toggle("off", s >= wl2); // dim steps past the wheel
|
||
}
|
||
}
|
||
if (paintExpr) paintExpr(); // keep the expression lane's lit-bars in step with the grid
|
||
};
|
||
paintGrid();
|
||
beatRefresh = paintGrid;
|
||
|
||
play.onclick = () => { // master ▶/■ — remembers per-lane mutes
|
||
ungodlyMark("beat play"); // ⏪ transport is an act
|
||
if (anyOn()) { // ■ — note who was playing, then stop all
|
||
beat.wasOn = lanes.filter(L => L.sq.on).map(L => L.sq);
|
||
lanes.forEach(L => { L.sq.on = false; L.paintMute(); });
|
||
} else { // ▶ — restore exactly that set (muted lanes stay muted)
|
||
const keep = (beat.wasOn && beat.wasOn.length) ? new Set(beat.wasOn) : null;
|
||
lanes.forEach(L => { L.sq.on = keep ? keep.has(L.sq) : true; L.paintMute(); });
|
||
}
|
||
paintGrid();
|
||
};
|
||
|
||
// ---- expression: velocity / chance / ratchet for one selected lane (arranger-style drag) ----
|
||
const em = document.createElement("div"); em.className = "arow emrow";
|
||
const emL = document.createElement("span"); emL.className = "glbl"; emL.style.minWidth = "auto"; emL.textContent = "shape";
|
||
em.appendChild(emL);
|
||
const laneSel = document.createElement("select"); laneSel.className = "asel";
|
||
DRUM_VOICES.forEach(v => { const o = document.createElement("option"); o.value = v; o.textContent = v;
|
||
if (v === beat.sel) o.selected = true; laneSel.appendChild(o); });
|
||
laneSel.onchange = () => { beat.sel = laneSel.value; openBeat(); };
|
||
em.appendChild(laneSel);
|
||
for (const mode of ["velocity", "chance", "ratchet"]) {
|
||
const b = document.createElement("button"); b.className = "midibtn"; b.textContent = mode;
|
||
b.classList.toggle("on", beat.exprMode === mode);
|
||
b.onclick = () => { beat.exprMode = mode; openBeat(); };
|
||
em.appendChild(b);
|
||
}
|
||
const eh = document.createElement("span"); eh.className = "albl";
|
||
eh.textContent = beat.exprMode === "chance" ? "drag a bar — how likely each " + beat.sel + " step fires"
|
||
: beat.exprMode === "ratchet" ? "drag a bar — how many times each " + beat.sel + " step re-fires (1–4): rolls"
|
||
: "drag a bar — how hard each " + beat.sel + " step hits";
|
||
em.appendChild(eh);
|
||
body.appendChild(em);
|
||
|
||
const selSq = drumSeqFor("drum." + beat.sel);
|
||
const isRat = beat.exprMode === "ratchet";
|
||
const arrv = isRat ? selSq.rat : (beat.exprMode === "chance" ? selSq.chance : selSq.vel);
|
||
const exlane = document.createElement("div"); exlane.className = "exprlane";
|
||
const bars = [];
|
||
paintExpr = () => { for (const b of bars) {
|
||
const h = isRat ? (clamp(arrv[b.stp], 1, 4) - 1) / 3 : clamp(arrv[b.stp], 0, 1);
|
||
b.fill.style.height = Math.round(h * 100) + "%";
|
||
b.fill.classList.toggle("lit", !!selSq.steps[b.stp]);
|
||
} };
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = document.createElement("div"); cell.className = "exprcell";
|
||
if (stp % 4 === 0) cell.classList.add("bar");
|
||
const fill = document.createElement("div"); fill.className = "exprbar"; cell.appendChild(fill);
|
||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||
const frac = clamp(1 - (cy - r.top) / r.height, 0, 1);
|
||
ungodlyGesture("beat expr"); arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||
const mv = (e2) => setY(e2.clientY);
|
||
const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); };
|
||
window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); });
|
||
bars.push({ fill, stp }); exlane.appendChild(cell);
|
||
}
|
||
paintExpr();
|
||
body.appendChild(exlane);
|
||
|
||
// ---- kit drawer: knobs rendered from Synth.drumParamList (guarded — nothing pre-merge) ----
|
||
const kitRow = document.createElement("div"); kitRow.className = "arow bkit";
|
||
const kitTgl = document.createElement("button"); kitTgl.className = "midibtn";
|
||
kitTgl.textContent = "🎛 kit " + (beat.kitOpen ? "▾" : "▸");
|
||
kitTgl.onclick = () => { beat.kitOpen = !beat.kitOpen; openBeat(); };
|
||
kitRow.appendChild(kitTgl);
|
||
if (beat.kitOpen) {
|
||
{
|
||
for (const v of DRUM_VOICES) {
|
||
const box = document.createElement("div"); box.className = "bvoice";
|
||
const vn = document.createElement("div"); vn.className = "bvn"; vn.textContent = v; box.appendChild(vn);
|
||
for (const p of (Synth.drumParamList(v) || [])) {
|
||
const w = document.createElement("span"); w.className = "gctl";
|
||
const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "44px"; l.textContent = p.label;
|
||
const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000";
|
||
sl.value = String(Math.round(clamp(Synth.drumParam(v, p.key), 0, 1) * 1000));
|
||
sl.oninput = () => { ungodlyGesture("kit"); Synth.drumParam(v, p.key, clamp(+sl.value / 1000, 0, 1)); };
|
||
w.appendChild(l); w.appendChild(sl); box.appendChild(w);
|
||
}
|
||
const au = document.createElement("button"); au.className = "midibtn bmini"; au.textContent = "▷ hear";
|
||
au.onclick = () => { if (Synth.drum) Synth.drum(v, 0.9); };
|
||
box.appendChild(au);
|
||
kitRow.appendChild(box);
|
||
}
|
||
}
|
||
}
|
||
body.appendChild(kitRow);
|
||
|
||
beatEl.classList.add("open");
|
||
}
|
||
|
||
// ---- 🌾 AMPLER — sample the world into the kit ---------------------------
|
||
// GODSONIQ captures ~3s of the live master; AMPLER chops that capture into
|
||
// slices and seats them onto the drum voices, so the beat's lanes play the
|
||
// world instead of synthesis (the SP-404 move). A right-side panel — the
|
||
// bottom belongs to the beat/arranger drawers. All AMPLER state is session-only.
|
||
let amplerEl = null;
|
||
const ampler = { open: false, capLen: 3, chop: 16, sel: -1, capturing: false, rev: [] };
|
||
function amplerEnsure() {
|
||
if (!amplerEl) { amplerEl = document.createElement("div"); amplerEl.id = "ampler"; document.body.appendChild(amplerEl); }
|
||
return amplerEl;
|
||
}
|
||
function closeAmpler() { ampler.open = false; if (amplerEl) amplerEl.classList.remove("open"); }
|
||
function openAmpler() { ampler.open = true; amplerEnsure().classList.add("open"); amplerRender(); }
|
||
function amplerCapture() {
|
||
const ok = Synth.amplerArm ? Synth.amplerArm(ampler.capLen) : false;
|
||
if (!ok) { eventTicker.unshift({ text: "🌾 AMPLER needs the audio worklet — press ♪ first, then ⏺ capture", tAdded: now(), src: "sys" }); return; }
|
||
ampler.capturing = true; ampler.sel = -1; ampler.rev = [];
|
||
eventTicker.unshift({ text: "🌾 AMPLER — sampling " + ampler.capLen + "s of the world… then ✂ chop it into a kit", tAdded: now(), src: "sys" });
|
||
amplerRender();
|
||
setTimeout(() => { ampler.capturing = false; if (ampler.open) amplerRender(); }, ampler.capLen * 1000 + 250); // capture runs real-time; re-render once it has landed
|
||
}
|
||
function amplerDrawWave(cv) {
|
||
const g = cv.getContext("2d"); if (!g) return;
|
||
g.clearRect(0, 0, cv.width, cv.height);
|
||
const peaks = Synth.amplerPeaks ? Synth.amplerPeaks(cv.width) : [], mid = cv.height / 2;
|
||
g.strokeStyle = "rgba(140,175,255,0.75)"; g.beginPath();
|
||
for (let px = 0; px < peaks.length; px++) { const p = peaks[px]; g.moveTo(px + 0.5, mid - p[1] * mid); g.lineTo(px + 0.5, mid - p[0] * mid); }
|
||
g.stroke();
|
||
const st = Synth.amplerStatus ? Synth.amplerStatus() : { slices: 0 };
|
||
if (st.slices) { g.strokeStyle = "rgba(150,200,255,0.35)";
|
||
for (let i = 1; i < st.slices; i++) { const xx = Math.floor(cv.width * i / st.slices) + 0.5; g.beginPath(); g.moveTo(xx, 0); g.lineTo(xx, cv.height); g.stroke(); } }
|
||
}
|
||
function amplerRender() {
|
||
if (!amplerEl) return;
|
||
const st = Synth.amplerStatus ? Synth.amplerStatus() : { has: false, secs: 0, slices: 0 };
|
||
amplerEl.innerHTML = "";
|
||
const top = document.createElement("div"); top.className = "atop";
|
||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "🌾 AMPLER";
|
||
const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeAmpler;
|
||
top.append(title, x); amplerEl.appendChild(top);
|
||
|
||
const cap = document.createElement("div"); cap.className = "row";
|
||
const rec = document.createElement("button"); rec.className = "midibtn"; rec.textContent = ampler.capturing ? "…capturing" : "⏺ capture";
|
||
rec.classList.toggle("on", ampler.capturing); rec.disabled = ampler.capturing; rec.onclick = amplerCapture;
|
||
const lenSel = document.createElement("select"); lenSel.className = "asel"; lenSel.title = "capture length";
|
||
for (const s of [1.5, 3, 6]) { const o = document.createElement("option"); o.value = String(s); o.textContent = s + "s"; if (s === ampler.capLen) o.selected = true; lenSel.appendChild(o); }
|
||
lenSel.onchange = () => { ampler.capLen = parseFloat(lenSel.value) || 3; };
|
||
cap.append(rec, lenSel); amplerEl.appendChild(cap);
|
||
|
||
if (!st.has) {
|
||
const h = document.createElement("div"); h.className = "hint";
|
||
h.textContent = ampler.capturing ? "listening to the world…" : "press ♪, let the world play, then ⏺ capture — you'll chop it into a kit.";
|
||
amplerEl.appendChild(h); return;
|
||
}
|
||
|
||
const cv = document.createElement("canvas"); cv.width = 280; cv.height = 54; amplerEl.appendChild(cv); amplerDrawWave(cv);
|
||
|
||
const chopRow = document.createElement("div"); chopRow.className = "row";
|
||
const chopL = document.createElement("span"); chopL.className = "albl"; chopL.textContent = "chop";
|
||
const chopSel = document.createElement("select"); chopSel.className = "asel";
|
||
for (const n of [8, 16]) { const o = document.createElement("option"); o.value = String(n); o.textContent = n + " slices"; if (n === ampler.chop) o.selected = true; chopSel.appendChild(o); }
|
||
chopSel.onchange = () => { ampler.chop = parseInt(chopSel.value) || 16; };
|
||
const chopBtn = document.createElement("button"); chopBtn.className = "midibtn bmini"; chopBtn.textContent = "✂ chop";
|
||
chopBtn.onclick = () => { if (Synth.amplerChop(ampler.chop)) { ampler.sel = -1; ampler.rev = []; amplerRender(); } };
|
||
chopRow.append(chopL, chopSel, chopBtn); amplerEl.appendChild(chopRow);
|
||
|
||
if (!st.slices) { const h = document.createElement("div"); h.className = "hint"; h.textContent = "✂ chop the capture into slices, then tap to audition and assign."; amplerEl.appendChild(h); return; }
|
||
|
||
const grid = document.createElement("div"); grid.className = "sgrid";
|
||
grid.style.gridTemplateColumns = "repeat(" + (st.slices > 8 ? 8 : st.slices) + ", 1fr)";
|
||
for (let i = 0; i < st.slices; i++) {
|
||
const cell = document.createElement("div"); cell.className = "scell"; cell.textContent = String(i);
|
||
if (i === ampler.sel) cell.classList.add("sel");
|
||
if (ampler.rev[i]) cell.classList.add("rev");
|
||
cell.title = "tap to audition slice " + i;
|
||
cell.onclick = () => { ampler.sel = i; Synth.amplerPlay(i, 0.95); amplerRender(); };
|
||
const rv = document.createElement("span"); rv.className = "rv"; rv.textContent = "⇄"; rv.title = "reverse this slice";
|
||
rv.onclick = (ev) => { ev.stopPropagation(); ampler.rev[i] = Synth.amplerReverse(i); amplerRender(); };
|
||
cell.appendChild(rv); grid.appendChild(cell);
|
||
}
|
||
amplerEl.appendChild(grid);
|
||
|
||
const map = Synth.amplerMap ? Synth.amplerMap() : {};
|
||
const strip = document.createElement("div"); strip.className = "amap";
|
||
for (const v of DRUM_VOICES) {
|
||
const row = document.createElement("div"); row.className = "maprow";
|
||
const nm = document.createElement("span"); nm.className = "mvn"; nm.textContent = v;
|
||
const sel = document.createElement("select"); sel.className = "asel"; sel.title = "assign a slice to " + v;
|
||
const syn = document.createElement("option"); syn.value = "-1"; syn.textContent = "synth"; sel.appendChild(syn);
|
||
for (let i = 0; i < st.slices; i++) { const o = document.createElement("option"); o.value = String(i); o.textContent = "s" + i + (ampler.rev[i] ? "ʳ" : ""); sel.appendChild(o); }
|
||
sel.value = map[v] ? String(map[v].slice) : "-1";
|
||
sel.onchange = () => { const idx = parseInt(sel.value); if (idx < 0) Synth.amplerClear(v); else Synth.amplerAssign(v, idx); amplerRender(); };
|
||
const cur = document.createElement("span"); cur.className = "albl"; cur.textContent = map[v] ? ("← s" + map[v].slice + (map[v].reverse ? "ʳ" : "")) : "← synth";
|
||
const clr = document.createElement("span"); clr.className = "x"; clr.textContent = "✕"; clr.title = "back to synth"; clr.onclick = () => { Synth.amplerClear(v); amplerRender(); };
|
||
row.append(nm, sel, cur, clr); strip.appendChild(row);
|
||
}
|
||
amplerEl.appendChild(strip);
|
||
const h = document.createElement("div"); h.className = "hint"; h.textContent = "tap a slice to audition · ⇄ reverses it · assign a slice to a voice and the beat plays the world";
|
||
amplerEl.appendChild(h);
|
||
}
|
||
|
||
// ---- ⚡ GODSPEED — the arpeggiator ---------------------------------------
|
||
// Hold a chord in mind mode; the machine runs with it, on godtime. Because it
|
||
// fires from seqTick — at the SWUNG onset — the arp inherits the groove's feel
|
||
// for free. Notes are latched at mindStrike (so a MIDI keyboard, which already
|
||
// routes through there, captures for free too). App-scope only; never touches
|
||
// the Synth engine — it just plays through the same pluck() every note uses.
|
||
const arp = { on: false, held: [], rate: 1, dir: "up", oct: 1, gate: 0.8, idx: 0 };
|
||
// rate 4=1/4 · 2=1/8 · 1=1/16 · 0.5=1/32 dir up|down|updown|random oct 1..3 held [{note,vel}] cap 8
|
||
function arpLatch(note, vel) { // toggle a note in/out of the held chord (latch)
|
||
const i = arp.held.findIndex(h => h.note === note);
|
||
if (i >= 0) arp.held.splice(i, 1); // strike again → unlatch
|
||
else if (arp.held.length < 8) arp.held.push({ note, vel }); // cap 8
|
||
arpPanelRefresh();
|
||
}
|
||
function arpSetOn(v) { arp.on = !!v; if (!arp.on) { arp.held = []; arp.idx = 0; } } // off → drop the latch
|
||
function arpPool() { // held, ascending, expanded across oct octaves
|
||
const base = arp.held.slice().sort((a, b) => a.note - b.note), pool = [];
|
||
for (let o = 0; o < arp.oct; o++) for (const h of base) pool.push({ note: h.note + 12 * o, vel: h.vel });
|
||
return pool;
|
||
}
|
||
function arpIndexFor(step, len) { // map a monotonic step counter → pool index, per direction
|
||
if (len <= 1) return 0;
|
||
if (arp.dir === "down") return (len - 1) - (step % len);
|
||
if (arp.dir === "random") return Math.floor(Math.random() * len);
|
||
if (arp.dir === "updown") { const period = 2 * (len - 1), p = step % period; return p < len ? p : period - p; } // ping-pong, no doubled turnaround
|
||
return step % len; // up
|
||
}
|
||
function arpNext(pool) { const note = pool[arpIndexFor(arp.idx, pool.length)]; arp.idx++; return note; }
|
||
function arpFire(sub) {
|
||
const pool = arpPool(); if (!pool.length) return;
|
||
const pick = arpNext(pool); if (!pick) return;
|
||
const durMs = Math.max(30, sub * arp.rate * arp.gate); // gate = fraction of the step length
|
||
let vel = pick.vel;
|
||
if (groove.velo) // breathe like the lanes — shared Velocity Deviation
|
||
vel = clamp(vel + (Math.random() * 2 - 1) * groove.velo * groove.amount * 0.5, 0.05, 1);
|
||
Synth.pluck(pick.note, vel);
|
||
midiPluck(2, pick.note, vel, durMs); // channel 2 — the mind console's channel, exactly like mindStrike
|
||
}
|
||
function arpTick() { // called at seqTick's tail — one 16th has passed
|
||
if (!arp.on || !arp.held.length) return;
|
||
if (arp.rate >= 1 && (seqCurStep % arp.rate) !== 0) return; // rate gate for 1/4·1/8·1/16
|
||
const sub = 60000 / Math.max(40, godtime.bpm) / 4; // one 16th in ms (matches the drum branch)
|
||
arpFire(sub);
|
||
if (arp.rate < 1) setTimeout(() => { if (arp.on && arp.held.length) arpFire(sub); }, sub / 2); // 1/32 — a second hit mid-16th
|
||
}
|
||
|
||
// the ⚡ GODSPEED panel — compact, left side (the bottom & right are other panels' turf)
|
||
let godspeedEl = null;
|
||
function godspeedEnsure() { if (!godspeedEl) { godspeedEl = document.createElement("div"); godspeedEl.id = "godspeed"; document.body.appendChild(godspeedEl); } return godspeedEl; }
|
||
function godspeedOpen() { return !!(godspeedEl && godspeedEl.classList.contains("open")); }
|
||
function closeGodspeed() { if (godspeedEl) godspeedEl.classList.remove("open"); }
|
||
function openGodspeed() { godspeedEnsure().classList.add("open"); godspeedRender(); }
|
||
function toggleGodspeed() { godspeedOpen() ? closeGodspeed() : openGodspeed(); }
|
||
function arpPanelRefresh() { if (godspeedOpen()) godspeedRender(); }
|
||
function gsSel(opts, cur, onChange) {
|
||
const s = document.createElement("select"); s.className = "asel";
|
||
for (const [label, val] of opts) { const o = document.createElement("option"); o.value = String(val); o.textContent = label; if (val === cur) o.selected = true; s.appendChild(o); }
|
||
s.onchange = () => { const hit = opts.find(o => String(o[1]) === s.value); onChange(hit ? hit[1] : s.value); };
|
||
return s;
|
||
}
|
||
function gsLbl(text, el) { const w = document.createElement("span"); w.className = "gctl"; const l = document.createElement("span"); l.className = "glbl"; l.textContent = text; w.append(l, el); return w; }
|
||
function godspeedRender() {
|
||
if (!godspeedEl) return;
|
||
godspeedEl.innerHTML = "";
|
||
const top = document.createElement("div"); top.className = "atop";
|
||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "⚡ GODSPEED";
|
||
const onBtn = document.createElement("button"); onBtn.className = "midibtn"; onBtn.textContent = arp.on ? "■ on" : "▶ off";
|
||
onBtn.classList.toggle("on", arp.on); onBtn.title = "arpeggiate the held chord on the clock";
|
||
onBtn.onclick = () => { arpSetOn(!arp.on); godspeedRender(); };
|
||
const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeGodspeed;
|
||
top.append(title, onBtn, x); godspeedEl.appendChild(top);
|
||
|
||
const grow = document.createElement("div"); grow.className = "grow";
|
||
grow.append(
|
||
gsLbl("rate", gsSel([["1/4", 4], ["1/8", 2], ["1/16", 1], ["1/32", 0.5]], arp.rate, v => arp.rate = v)),
|
||
gsLbl("dir", gsSel([["up", "up"], ["down", "down"], ["up/down", "updown"], ["rand", "random"]], arp.dir, v => arp.dir = v)),
|
||
gsLbl("oct", gsSel([["×1", 1], ["×2", 2], ["×3", 3]], arp.oct, v => arp.oct = v))
|
||
);
|
||
godspeedEl.appendChild(grow);
|
||
|
||
const gr2 = document.createElement("div"); gr2.className = "row";
|
||
const gate = document.createElement("input"); gate.type = "range"; gate.min = "0.1"; gate.max = "1"; gate.step = "0.05"; gate.value = String(arp.gate);
|
||
gate.oninput = () => { arp.gate = parseFloat(gate.value) || 0.8; };
|
||
gr2.append(gsLbl("gate", gate)); godspeedEl.appendChild(gr2);
|
||
|
||
const heldRow = document.createElement("div"); heldRow.className = "row";
|
||
const held = document.createElement("span"); held.className = "held"; held.style.flex = "1";
|
||
held.textContent = arp.held.length ? arp.held.slice().sort((a, b) => a.note - b.note).map(h => midiToName(h.note)).join(" ") : "— strike keys to latch —";
|
||
const clr = document.createElement("button"); clr.className = "midibtn bmini"; clr.textContent = "clear"; clr.title = "unlatch all";
|
||
clr.onclick = () => { arp.held = []; arp.idx = 0; godspeedRender(); };
|
||
heldRow.append(held, clr); godspeedEl.appendChild(heldRow);
|
||
|
||
const hint = document.createElement("div"); hint.className = "hint";
|
||
hint.textContent = "enter a chakra (C), turn on, strike keys to latch a chord — it runs on godtime with the groove's swing.";
|
||
godspeedEl.appendChild(hint);
|
||
}
|
||
|
||
// ---- 🕊 PSALM — sing, and the instrument answers ------------------------
|
||
// Mic pitch, folded to the global scale, played as notes — or, when ⚡ GODSPEED
|
||
// is on, latched into the arp one note at a time (sing your chord in). PRIVACY:
|
||
// analysis is entirely local, in a THROWAWAY AudioContext the music graph never
|
||
// touches — zero feedback risk, and nothing ever leaves the page. This block
|
||
// only consumes Synth.pluck / arpLatch; it reaches into no engine.
|
||
const PSALM_SENS = 0.012; // RMS gate — quieter than this is "silence"
|
||
const PSALM_CLARITY = 0.6; // autocorrelation confidence floor
|
||
// Pure pitch detector over a Float32Array — unit-testable without a mic.
|
||
function psalmDetect(buf, sampleRate) {
|
||
const n = buf.length;
|
||
let rms = 0; for (let i = 0; i < n; i++) rms += buf[i] * buf[i];
|
||
rms = Math.sqrt(rms / n);
|
||
if (rms < 1e-4) return { freq: 0, clarity: 0, rms };
|
||
const minLag = Math.floor(sampleRate / 1000), maxLag = Math.floor(sampleRate / 80); // 80–1000 Hz
|
||
let c0 = 0; for (let i = 0; i < n; i++) c0 += buf[i] * buf[i];
|
||
const acAt = (L) => { let a = 0; for (let i = 0; i + L < n; i++) a += buf[i] * buf[i + L]; return a; };
|
||
let bestLag = -1, bestVal = 0;
|
||
for (let lag = minLag; lag <= maxLag && lag < n; lag++) { const ac = acAt(lag); if (ac > bestVal) { bestVal = ac; bestLag = lag; } }
|
||
if (bestLag < 0) return { freq: 0, clarity: 0, rms };
|
||
const clarity = c0 > 0 ? bestVal / c0 : 0;
|
||
let lag = bestLag; // parabolic interpolation for sub-sample accuracy
|
||
if (bestLag > minLag && bestLag < maxLag) {
|
||
const y0 = acAt(bestLag - 1), y1 = bestVal, y2 = acAt(bestLag + 1), den = (y0 - 2 * y1 + y2);
|
||
if (den !== 0) lag = bestLag + 0.5 * (y0 - y2) / den;
|
||
}
|
||
return { freq: sampleRate / lag, clarity, rms };
|
||
}
|
||
const freqToMidi = (f) => 69 + 12 * Math.log2(f / 440);
|
||
function psalmFold(midiFloat) { // fold to the nearest note of the global scale
|
||
const iv = SCALES[gScale.name] || SCALES.minor_pent;
|
||
const pcs = iv.map(i => (((gScale.root + i) % 12) + 12) % 12);
|
||
const c = Math.round(midiFloat); let best = c, bestD = 1e9;
|
||
for (let k = c - 7; k <= c + 7; k++) {
|
||
if (pcs.indexOf(((k % 12) + 12) % 12) < 0) continue;
|
||
const d = Math.abs(k - midiFloat); if (d < bestD) { bestD = d; best = k; }
|
||
}
|
||
return best;
|
||
}
|
||
const psalm = { on: false, ctx: null, an: null, stream: null, timer: null,
|
||
lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0, curName: "" };
|
||
// Pure note-logic state machine (testable): a NEW note fires once after 2 stable
|
||
// frames; legato re-fires on a note change; after ~150ms below the gate it closes
|
||
// and the same note may re-attack. Velocity from RMS.
|
||
function psalmStep(d, dtMs) {
|
||
const out = { fire: false, note: -1, vel: 0 };
|
||
const voiced = d.rms >= PSALM_SENS && d.clarity >= PSALM_CLARITY && d.freq > 0;
|
||
if (!voiced) {
|
||
psalm.silentMs += dtMs;
|
||
if (psalm.silentMs >= 150) { psalm.gateOpen = false; psalm.lastNote = -1; psalm.stableNote = -1; psalm.stableCount = 0; }
|
||
return out;
|
||
}
|
||
psalm.silentMs = 0;
|
||
const note = psalmFold(freqToMidi(d.freq));
|
||
if (note === psalm.stableNote) psalm.stableCount++;
|
||
else { psalm.stableNote = note; psalm.stableCount = 1; }
|
||
if (psalm.stableCount < 2) return out; // hold two frames before committing
|
||
if (note !== psalm.lastNote) { // a new note, or a legato change
|
||
psalm.lastNote = note; psalm.gateOpen = true;
|
||
out.fire = true; out.note = note; out.vel = clamp(0.3 + d.rms * 4, 0.3, 1);
|
||
}
|
||
return out;
|
||
}
|
||
let psalmChip = null;
|
||
function psalmChipEnsure() {
|
||
if (psalmChip) return psalmChip;
|
||
psalmChip = document.createElement("div"); psalmChip.id = "psalmchip";
|
||
psalmChip.title = "🕊 PSALM — singing; click to stop listening";
|
||
psalmChip.onclick = () => psalmSet(false);
|
||
document.body.appendChild(psalmChip);
|
||
return psalmChip;
|
||
}
|
||
function psalmPaint() {
|
||
if (!psalmChip) return;
|
||
psalmChip.classList.toggle("open", psalm.on);
|
||
psalmChip.classList.toggle("voiced", psalm.gateOpen);
|
||
psalmChip.textContent = "🕊 " + (psalm.on ? (psalm.curName || "…") : "");
|
||
}
|
||
async function psalmSet(v) {
|
||
if (v && psalm.on) return;
|
||
if (!v) { // stop + fully release the mic
|
||
psalm.on = false;
|
||
if (psalm.timer) { clearInterval(psalm.timer); psalm.timer = null; }
|
||
if (psalm.stream) { psalm.stream.getTracks().forEach(t => t.stop()); psalm.stream = null; }
|
||
if (psalm.ctx) { try { psalm.ctx.close(); } catch (e) {} psalm.ctx = null; }
|
||
psalm.gateOpen = false; psalm.curName = ""; psalmPaint(); return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } });
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
const actx = new AC(); // OWN context — mic is analysed only, never routed to the music
|
||
const src = actx.createMediaStreamSource(stream);
|
||
const an = actx.createAnalyser(); an.fftSize = 2048; src.connect(an);
|
||
const buf = new Float32Array(an.fftSize), dtMs = 25;
|
||
Object.assign(psalm, { on: true, stream, ctx: actx, an, lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0 });
|
||
psalmChipEnsure(); psalmPaint();
|
||
psalm.timer = setInterval(() => { // setInterval, not rAF — a hidden tab shouldn't freeze the ear
|
||
an.getFloatTimeDomainData(buf);
|
||
const r = psalmStep(psalmDetect(buf, actx.sampleRate), dtMs);
|
||
if (r.fire && r.note >= 0) {
|
||
psalm.curName = midiToName(r.note);
|
||
if (arp.on) arpLatch(r.note, r.vel); else Synth.pluck(r.note, r.vel);
|
||
}
|
||
psalmPaint();
|
||
}, dtMs);
|
||
} catch (e) {
|
||
psalm.on = false;
|
||
eventTicker.unshift({ text: "🕊 the instrument cannot hear you — allow the microphone", tAdded: now(), src: "sys" });
|
||
psalmPaint();
|
||
}
|
||
}
|
||
function togglePsalm() { psalmSet(!psalm.on); }
|
||
|
||
// the one place a drum voice sounds — shared by seqTick AND the finger-drum keys (E.1),
|
||
// so the sequenced path and the played path can never drift: same guard, same GM MIDI.
|
||
function fireDrum(name, vel, durMs) {
|
||
if (Synth.drum) Synth.drum(name, vel); // guarded — silent if the engine isn't present
|
||
const gm = DRUM_GM[name];
|
||
if (gm != null) midiPluck(9, gm, vel, durMs); // GM drums out on channel 9 (0x99)
|
||
}
|
||
function seqTick() {
|
||
for (const k in seqs) {
|
||
const sq = seqs[k];
|
||
if (!sq.on) continue;
|
||
if (k.slice(0, 5) === "drum.") { // a GODRUM lane — fire a synth drum
|
||
const cur = wheelStep(sq); // ☸ WHEELS — the lane's own wrap (16 → seqCurStep)
|
||
if (sq.steps[cur]) { // 0/1 gate
|
||
const chc = sq.chance ? sq.chance[cur] : 1; // chance: does this step fire?
|
||
if (chc >= 1 || Math.random() < chc) {
|
||
const name = k.slice(5);
|
||
const rat = clamp(Math.round((sq.rat && sq.rat[cur]) || 1), 1, 4); // ratchet: sub-hits per step
|
||
const sub = (60000 / Math.max(40, godtime.bpm) / 4) / rat; // ms between sub-hits
|
||
const fireHit = () => {
|
||
let vel = sq.vel ? sq.vel[cur] : 0.8;
|
||
if (groove.velo) // shared Velocity Deviation (§10.5.11)
|
||
vel = clamp(vel + (Math.random() * 2 - 1) * groove.velo * groove.amount * 0.5, 0.05, 1);
|
||
fireDrum(name, vel, sub * 0.9); // the shared fire path (finger-keys use it too)
|
||
};
|
||
fireHit();
|
||
for (let h = 1; h < rat; h++) setTimeout(fireHit, h * sub);
|
||
}
|
||
}
|
||
continue; // swing came free from the clock
|
||
}
|
||
const n = dests.get(k);
|
||
if (n && n.isNote) {
|
||
const cur = wheelStep(sq), sv = sq.steps[cur]; // ☸ WHEELS — one cur for both the gate and len[] (must not diverge)
|
||
const hasNote = Array.isArray(sv) ? sv.length > 0 : sv >= 0;
|
||
const ch = sq.chance ? sq.chance[cur] : 1; // chance: does this step fire?
|
||
if (hasNote && (ch >= 1 || Math.random() < ch)) {
|
||
const rat = clamp(Math.round((sq.rat && sq.rat[cur]) || 1), 1, 4); // ratchet: sub-hits per step
|
||
const sixteenth = 60000 / Math.max(40, godtime.bpm) / 4; // ms per 16th at this tempo
|
||
const sub = sixteenth / rat; // ms between ratchet sub-hits
|
||
const single = rat === 1; // no ratchet → the note sustains its length
|
||
const len16 = clamp(Math.round(sq.len ? sq.len[cur] : 1), 1, 16); // note length in 16ths (key-roll)
|
||
const durMs = single ? len16 * sixteenth : sub * 0.9; // ratchets keep their sub-hit length
|
||
const ch1 = /bass/.test(k) ? 1 : 0;
|
||
const fireHit = () => {
|
||
const deg = stepPick(sv); // Play One: re-pick per sub-hit
|
||
if (deg < 0) return;
|
||
const note = seqRootFor(k) + SEQ_ROWS[deg];
|
||
let vel = sq.vel ? sq.vel[cur] : (ch1 ? 0.9 : 0.7);
|
||
if (groove.velo) // Velocity Deviation (§10.5.11)
|
||
vel = clamp(vel + (Math.random() * 2 - 1) * groove.velo * groove.amount * 0.5, 0.05, 1);
|
||
Synth.pluck(note, vel, single ? durMs : undefined); // sustained only when un-ratcheted; ratchets keep the natural pluck
|
||
midiPluck(ch1, note, vel, durMs);
|
||
};
|
||
fireHit();
|
||
for (let h = 1; h < rat; h++) setTimeout(fireHit, h * sub);
|
||
}
|
||
}
|
||
}
|
||
if (seqGridRefresh) seqGridRefresh();
|
||
if (arrGrooveRefresh) arrGrooveRefresh();
|
||
if (beatRefresh) beatRefresh();
|
||
arpTick(); // ⚡ GODSPEED rides the swung clock
|
||
}
|
||
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);
|
||
}
|
||
const frozenVals = {}; // freeze: a held photograph per source
|
||
function isFrozenLocal(key) {
|
||
return groupsOf(key).some(g => tweaks["@" + g] && tweaks["@" + g].freeze >= 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;
|
||
}
|
||
|
||
// Synth-side idle for the two dests whose "no value" default isn't 0 (see the
|
||
// d("filter.cutoff",0.3) / d("tempo.nudge",0.5) fallbacks). The cableless-tweak
|
||
// floor seeds THIS, so a neutral/reset knob returns a voice to its idle, not to 0.
|
||
const DEST_IDLE = { "filter.cutoff": 0.3, "tempo.nudge": 0.5 };
|
||
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) {
|
||
let v = (soloKey && k !== soloKey) ? 0 : tweakNorm(k, n.norm || 0);
|
||
if (isFrozenLocal(k)) { // freeze: hold the value it had when the group froze
|
||
if (frozenVals[k] == null) frozenVals[k] = v;
|
||
v = frozenVals[k];
|
||
} else if (frozenVals[k] != null) delete frozenVals[k];
|
||
const sq = seqs[k];
|
||
if (sq && sq.on) { // sample & hold: the world only speaks on your steps
|
||
const cur = wheelStep(sq); // ☸ WHEELS — a short source-wheel samples on its own turn
|
||
if (sq.steps[cur] && sq._sampled !== cur) { sq.held = v; sq._sampled = cur; }
|
||
if (sq.held != null) v = sq.held;
|
||
}
|
||
sv[k] = v;
|
||
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) {
|
||
// a sequenced note-voice takes its melody from YOUR grid, not the data
|
||
if (!(seqs[r.dest] && seqs[r.dest].on)) {
|
||
let sc = r.quantize.scale, rt = r.quantize.root;
|
||
if (skinTunesWorld) { // the Spirit re-tunes the ambient melody to its scale
|
||
sc = gScale.name;
|
||
rt = Math.floor((r.quantize.root != null ? r.quantize.root : 60) / 12) * 12 + gScale.root;
|
||
}
|
||
notes[r.dest] = mxQuantize(shaped, sc, rt, 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, n] of dests) { // any hand-tweaked cableless voice: emit its ACTUAL value —
|
||
if (n.isNote || (k in destVals)) continue; // an offset floor gives it a level from its idle, and a
|
||
if (tweaks[k]) destVals[k] = DEST_IDLE[k] || 0; // reset/mute/lower re-emit too — seed the synth idle (not a blank 0), so a neutral filter/tempo doesn't pin dark
|
||
}
|
||
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);
|
||
const sq = seqs[k]; // groove grid: gate + per-step p-lock in time
|
||
if (!n.isNote) v = applyGroove(sq, wheelStep(sq), v); // ☸ WHEELS — the gate wraps on the lane's length
|
||
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) { ungodlyMark("mute"); 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;
|
||
}
|
||
// rebuild a saved seq, back-filling vel/chance for patches saved before the
|
||
// Groove landed (old patches only had {on, steps}). Returns null if unusable.
|
||
function migrateSeq(s) {
|
||
if (!s || !Array.isArray(s.steps) || s.steps.length !== 16) return null;
|
||
return { on: !!s.on, steps: s.steps.slice(),
|
||
vel: Array.isArray(s.vel) && s.vel.length === 16 ? s.vel.slice() : new Array(16).fill(0.8),
|
||
chance: Array.isArray(s.chance) && s.chance.length === 16 ? s.chance.slice() : new Array(16).fill(1),
|
||
rat: Array.isArray(s.rat) && s.rat.length === 16 ? s.rat.slice() : new Array(16).fill(1),
|
||
lock: Array.isArray(s.lock) && s.lock.length === 16 ? s.lock.slice() : new Array(16).fill(-1),
|
||
len: Array.isArray(s.len) && s.len.length === 16 ? s.len.slice() : new Array(16).fill(1),
|
||
wlen: Number.isFinite(s.wlen) ? clamp(Math.round(s.wlen), 1, 16) : 16 }; // ☸ WHEELS — per-lane length (legacy patches → a full wheel)
|
||
}
|
||
function gsGrooveSelfCheck() { // ponytail: one runnable guard on the risky bits
|
||
const f = [];
|
||
const old = migrateSeq({ on: 1, steps: new Array(16).fill(-1) }); // pre-Groove patch
|
||
if (!old || old.vel[0] !== 0.8 || old.chance[0] !== 1) f.push("migrate-default");
|
||
const neu = migrateSeq({ on: 0, steps: new Array(16).fill(0), vel: new Array(16).fill(0.3), chance: new Array(16).fill(0.5) });
|
||
if (!neu || neu.vel[5] !== 0.3 || neu.chance[5] !== 0.5) f.push("migrate-preserve");
|
||
if (migrateSeq({ steps: [1, 2, 3] }) !== null) f.push("migrate-reject-badlen");
|
||
if (clamp(0.8 + 1 * 1 * 0.5, 0.05, 1) !== 1) f.push("vel-clamp"); // velo deviation stays in range
|
||
// follow-action decisions (arrFollowNext) — save/restore arr + Math.random
|
||
const _rnd = Math.random, _hadF = "__t" in arr.follow, _hadG = "__t" in arr.grid, _f = arr.follow["__t"], _g = arr.grid["__t"];
|
||
arr.grid["__t"] = ["A", "B", "C", "A"];
|
||
arr.follow["__t"] = { A: { act: "next", chance: 1 }, B: { act: "stop", chance: 1 } };
|
||
Math.random = () => 0;
|
||
if (arrFollowNext("__t", "A") !== "B") f.push("follow-next"); // next after A = B
|
||
if (arrFollowNext("__t", "B") !== "mute") f.push("follow-stop"); // stop = mute
|
||
arr.follow["__t"].A.chance = 0; Math.random = () => 0.5;
|
||
if (arrFollowNext("__t", "A") !== null) f.push("follow-chance0"); // chance 0 never diverts
|
||
Math.random = _rnd;
|
||
if (_hadF) arr.follow["__t"] = _f; else delete arr.follow["__t"];
|
||
if (_hadG) arr.grid["__t"] = _g; else delete arr.grid["__t"];
|
||
// euclidean generator — canonical patterns
|
||
const eu = new Array(16).fill(9);
|
||
euclidFill(eu, 3, 8, 0, false);
|
||
if (eu.slice(0, 8).join("") !== "10010010") f.push("euclid-tresillo"); // E(3,8)
|
||
euclidFill(eu, 4, 16, 0, false);
|
||
if (eu.join("") !== "1000100010001000") f.push("euclid-4onfloor"); // E(4,16)
|
||
euclidFill(eu, 0, 16, 0, false);
|
||
if (eu.some(x => x !== 0)) f.push("euclid-empty"); // no pulses = silent
|
||
const er0 = new Array(16), er1 = new Array(16), erN = new Array(16);
|
||
euclidFill(er0, 3, 8, 0, false); euclidFill(er1, 3, 8, 1, false); euclidFill(erN, 3, 8, 8, false);
|
||
if (er0.join("") === er1.join("")) f.push("euclid-rot-shift"); // rot 1 changes it
|
||
if (er0.join("") !== erN.join("")) f.push("euclid-rot-cycle"); // rot=len is identity
|
||
// global scale — 8 folded rows per scale
|
||
if (scaleRows("major").join() !== "0,2,4,5,7,9,11,12") f.push("scale-major");
|
||
if (scaleRows("minor_pent").join() !== "0,3,5,7,10,12,15,17") f.push("scale-minorpent");
|
||
// play-one stacks
|
||
const ps = new Array(16).fill(-1);
|
||
stepToggle(ps, 0, 3); if (ps[0] !== 3) f.push("po-single"); // first note = plain
|
||
stepToggle(ps, 0, 5); if (!Array.isArray(ps[0]) || ps[0].join() !== "3,5") f.push("po-stack"); // 2nd = stack
|
||
stepToggle(ps, 0, 3); if (ps[0] !== 5) f.push("po-unstack"); // remove one → back to plain
|
||
if (!stepHas([3, 5], 5) || stepHas([3, 5], 7)) f.push("po-has");
|
||
if (stepPick([4]) !== 4 || stepFirst([9, 2]) !== 9) f.push("po-pickfirst");
|
||
if (migrateSeq({ steps: new Array(16).fill(0) }).rat[0] !== 1) f.push("ratchet-default"); // rat back-fills to 1
|
||
if (migrateSeq({ steps: new Array(16).fill(0) }).len[0] !== 1) f.push("len-default"); // len back-fills to 1 (one 16th)
|
||
if (migrateSeq({ steps: new Array(16).fill(0), len: new Array(4).fill(3) }).len[0] !== 1) f.push("len-badlen"); // wrong-length len → reset
|
||
if (migrateSeq({ steps: new Array(16).fill(0) }).wlen !== 16) f.push("wlen-default"); // ☸ legacy seq → full wheel
|
||
if (migrateSeq({ steps: new Array(16).fill(0), wlen: 12 }).wlen !== 12) f.push("wlen-preserve"); // ☸ a 12-wheel round-trips
|
||
if (migrateSeq({ steps: new Array(16).fill(0), wlen: 0 }).wlen !== 1) f.push("wlen-clamp-lo"); // ☸ clamps into 1..16
|
||
if (migrateSeq({ steps: new Array(16).fill(0), wlen: 99 }).wlen !== 16) f.push("wlen-clamp-hi");
|
||
if (wheelStep({ wlen: 16 }) !== seqCurStep) f.push("wheel-full-identity"); // ☸ full wheel == pre-WHEELS
|
||
// parameter locks (applyGroove)
|
||
if (applyGroove(null, 0, 0.7) !== 0.7) f.push("plock-nogroove"); // no groove → world passes
|
||
const lsq = { on: true, steps: [1, 0, 1, ...new Array(13).fill(0)], lock: [-1, -1, 0.25, ...new Array(13).fill(-1)] };
|
||
if (applyGroove(lsq, 0, 0.7) !== 0.7) f.push("plock-lit-unlocked"); // lit + unlocked → world
|
||
if (applyGroove(lsq, 1, 0.7) !== 0) f.push("plock-dark"); // dark step → 0
|
||
if (applyGroove(lsq, 2, 0.7) !== 0.25) f.push("plock-locked"); // lit + locked → the lock value
|
||
// GODRUM — a fresh drum lane (0-filled gates) round-trips through migrateSeq unchanged
|
||
const drm = migrateSeq({ on: false, steps: new Array(16).fill(0) });
|
||
if (!drm || drm.on !== false || drm.steps.length !== 16 || drm.steps.some(x => x !== 0)) f.push("drum-lane-shape");
|
||
if (f.length) console.error("⚠ groove self-check FAILED:", f); else console.log("✓ groove self-check passed");
|
||
return f.length === 0;
|
||
}
|
||
gsGrooveSelfCheck();
|
||
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)),
|
||
seqs: JSON.parse(JSON.stringify(seqs)), // your grooves travel with the patch
|
||
arr: { grid: JSON.parse(JSON.stringify(arr.grid)), banks: JSON.parse(JSON.stringify(arr.banks)),
|
||
follow: JSON.parse(JSON.stringify(arr.follow)) },
|
||
bpm: godtime.bpm,
|
||
locked: godtime.locked, // the tempo-lock travels too, so ⏪ can restore a mood's lock (see loadPatch)
|
||
groove: Object.assign({}, groove), // the feel travels with the patch
|
||
scale: Object.assign({}, gScale), // the key travels too
|
||
kit: (Synth.drumKit ? Synth.drumKit() : undefined), // the GODRUM kit travels too (undefined pre-merge)
|
||
};
|
||
}
|
||
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]);
|
||
for (const k in seqs) delete seqs[k];
|
||
if (p.seqs) for (const k in p.seqs) {
|
||
const s = migrateSeq(p.seqs[k]);
|
||
if (s) seqs[k] = s;
|
||
}
|
||
if (p.groove) Object.assign(groove, p.groove);
|
||
if (p.scale) { Object.assign(gScale, p.scale); SEQ_ROWS = scaleRows(gScale.name); }
|
||
if (p.kit && Synth.drumKitLoad) Synth.drumKitLoad(p.kit); // the GODRUM kit travels with the patch
|
||
if (p.arr && p.arr.grid && p.arr.banks) { // the arrangement travels too
|
||
arr.grid = JSON.parse(JSON.stringify(p.arr.grid));
|
||
arr.banks = JSON.parse(JSON.stringify(p.arr.banks));
|
||
arr.follow = p.arr.follow ? JSON.parse(JSON.stringify(p.arr.follow)) : {};
|
||
if (arr.open) openArranger(); // rebuild the panel on the new song
|
||
}
|
||
if (typeof p.bpm === "number") godtime.bpm = clamp(p.bpm, 40, 180);
|
||
if (typeof p.locked === "boolean") godtime.locked = p.locked; // restore the lock (old patches omit it → left as-is)
|
||
if (beat.open) openBeat(); // rebuild the beat panel on the new song
|
||
layoutDirty = true;
|
||
}
|
||
|
||
// ---- ⏪ UNGODLY — undo as time-travel over the patch (un-doing what god did) ----
|
||
// loadPatch() already restores everything (routes, tweaks, seqs incl. drums, arr, bpm,
|
||
// groove, scale, kit) AND rebuilds open panels — so undo is just a ring of
|
||
// serializePatch() snapshots. Memory-only in v1: a reload starts history fresh.
|
||
const ungodly = { past: [], future: [], muted: false, MAX: 32, lastT: 0, lastLabel: "" };
|
||
function ungodlyMark(label) { // call BEFORE a mutation applies — the snapshot is the world as it WAS
|
||
if (ungodly.muted) return;
|
||
const s = JSON.stringify(serializePatch());
|
||
if (ungodly.past.length && ungodly.past[ungodly.past.length - 1].s === s) return; // no empty entries
|
||
ungodly.past.push({ s, label });
|
||
if (ungodly.past.length > ungodly.MAX) ungodly.past.shift(); // ring of 32
|
||
ungodly.future.length = 0; // a fresh act forks the timeline — no redo across it
|
||
}
|
||
function ungodlyGesture(label) { // continuous drags → ONE mark per burst (same label within 800ms coalesces)
|
||
const tNow = performance.now();
|
||
if (label === ungodly.lastLabel && tNow - ungodly.lastT < 800) { ungodly.lastT = tNow; return; }
|
||
ungodly.lastLabel = label; ungodly.lastT = tNow; ungodlyMark(label);
|
||
}
|
||
function ungodlyApply(s) { // restore a snapshot without recording it as a new act
|
||
ungodly.muted = true; loadPatch(JSON.parse(s)); ungodly.muted = false;
|
||
ungodly.lastLabel = ""; ungodly.lastT = 0; // a drag after undo mustn't coalesce with one before it
|
||
}
|
||
function ungodlyUndo() {
|
||
if (!ungodly.past.length) { eventTicker.unshift({ text: "⏪ nothing to take back — god has done nothing yet", tAdded: now(), src: "sys" }); return; }
|
||
const p = ungodly.past.pop();
|
||
ungodly.future.push({ s: JSON.stringify(serializePatch()), label: p.label }); // current state → redo stack
|
||
ungodlyApply(p.s);
|
||
eventTicker.unshift({ text: "⏪ ungodly: " + p.label, tAdded: now(), src: "sys" });
|
||
}
|
||
function ungodlyRedo() {
|
||
if (!ungodly.future.length) { eventTicker.unshift({ text: "⏩ nothing to redo", tAdded: now(), src: "sys" }); return; }
|
||
const f = ungodly.future.pop();
|
||
ungodly.past.push({ s: JSON.stringify(serializePatch()), label: f.label });
|
||
ungodlyApply(f.s);
|
||
eventTicker.unshift({ text: "⏩ ungodly: " + f.label, tAdded: now(), src: "sys" });
|
||
}
|
||
|
||
// ---- zero mode — dimension zero -------------------------------------------
|
||
// The calm front door: not a muted world (moods do that) but an EMPTY one — no
|
||
// cables, nothing sounding — that you build by hand. It is the SAME matrix
|
||
// (routesArr/tweaks/synth), just started from nothing; leaving zero restores
|
||
// the world you had. Client-local, like moods — the hub/other users untouched.
|
||
let zeroMode = false;
|
||
function enterZero(restore) { // restore=true only on a reload back into an existing zero build
|
||
if (!restore) ungodlyMark("enter zero"); // ⏪ (a reload-restore is not a user act — don't record it)
|
||
if (!zeroMode && !restore) { // fresh enter: snapshot what you had, so exiting can restore it
|
||
try { localStorage.setItem("gs_prezero", JSON.stringify(serializePatch())); } catch (e) {}
|
||
}
|
||
clientMatrix = true; // own the matrix so the hub can't re-populate the cables
|
||
if (restore) { // reload — bring the zero build's cables back (C2)
|
||
let build = null; try { build = JSON.parse(localStorage.getItem("gs_zerobuild") || "null"); } catch (e) {}
|
||
loadPatch(build && build.routes ? build : { routes: [], tweaks: {}, seqs: {} });
|
||
zeroUI.restoreVibeState(); // re-tag vibe cables so they show ON and stay removable (#7)
|
||
} else { // fresh — empty the field and forget any old build
|
||
loadPatch({ routes: [], tweaks: {}, seqs: {} });
|
||
zeroUI.nodes = {}; activeVibes.clear();
|
||
try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); localStorage.removeItem("gs_zerovibes"); } catch (e) {}
|
||
}
|
||
zeroMode = true;
|
||
try { localStorage.setItem("gs_mood", "0 zero"); } catch (e) {}
|
||
eventTicker.unshift({ text: "🌱 zero — the field is empty. pick a feed from a ring, or right-click a source → patch to → a voice, then press ♪", tAdded: now(), src: "sys" });
|
||
zeroUI.open();
|
||
layoutDirty = true;
|
||
}
|
||
function exitZeroToMood(name) { // leave zero back into the full world, then a mood
|
||
if (!zeroMode) { applyMood(name); return; }
|
||
if (!zeroHasBuild()) { finishZeroExit("old", name); return; } // truly nothing built — quiet restore (C1)
|
||
zeroExitChoice(name); // you built something — ask what to keep with it
|
||
}
|
||
function zeroHasBuild() { // a zero build can be cables, picked orbs, OR OMNI/trim shapes (no cables)
|
||
if (routesArr.length) return true;
|
||
if (zeroUI.nodes && Object.keys(zeroUI.nodes).length) return true;
|
||
for (const k in tweaks) { const t = tweaks[k];
|
||
if (t && ((t.offset && Math.abs(t.offset) > 0.0015) || (t.gain != null && Math.abs(t.gain - 1) > 0.01) || t.mute >= 0.5 || t.freeze >= 0.5)) return true; }
|
||
for (const v of DRUM_VOICES) { // a beat is a build too (R11): any lane playing, or its gates edited off the seed
|
||
const sq = seqs["drum." + v]; if (!sq) continue;
|
||
if (sq.on) return true;
|
||
const seed = DRUM_SEED[v];
|
||
for (let i = 0; i < 16; i++) if ((sq.steps[i] ? 1 : 0) !== (seed.indexOf(i) >= 0 ? 1 : 0)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
function finishZeroExit(mode, name) { // mode: keep | old | weave (C1)
|
||
ungodlyMark("exit zero"); // ⏪ the zero build, before we leave it (the inner applyMood marks the mood step)
|
||
const build = serializePatch(); // the zero-built patch (routes + tweaks + seqs), captured before we touch anything
|
||
zeroMode = false; zeroUI.close();
|
||
try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); localStorage.removeItem("gs_zerovibes"); } catch (e) {}
|
||
if (mode === "old" || mode === "weave") {
|
||
try { const pz = localStorage.getItem("gs_prezero"); if (pz) loadPatch(JSON.parse(pz)); } catch (e) {}
|
||
}
|
||
if (mode === "weave") for (const r of (build.routes || [])) registerRoute(r); // graft build's cables atop the old world
|
||
applyMood(name); // the mood rides on top: its groove + tempo (+ its own mutes/gains)…
|
||
if (mode === "keep" || mode === "weave") { // …but the kept build's own mix wins — applyMood resets MOOD_KEYS, so re-lay it
|
||
for (const k in (build.tweaks || {})) tweaks[k] = Object.assign({ mute: 0, gain: 1, offset: 0 }, build.tweaks[k]);
|
||
}
|
||
layoutDirty = true;
|
||
}
|
||
function zeroExitChoice(name) { // compact three-way card (same language as the door card)
|
||
if (document.getElementById("zexit")) return;
|
||
const n = routesArr.length;
|
||
const wrap = document.createElement("div"); wrap.id = "zexit";
|
||
Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "90", display: "flex",
|
||
alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,0.72)",
|
||
backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)" });
|
||
const card = document.createElement("div");
|
||
Object.assign(card.style, { maxWidth: "600px", width: "88%", textAlign: "center", color: "#d6e2f4" });
|
||
const h = document.createElement("div"); h.textContent = "leaving zero";
|
||
Object.assign(h.style, { fontWeight: "800", fontSize: "clamp(20px,3.5vw,28px)", letterSpacing: "2px", marginBottom: "6px" });
|
||
const sub = document.createElement("div");
|
||
sub.textContent = n ? ("you built " + n + " cable" + (n === 1 ? "" : "s") + " here. keep it?")
|
||
: "you shaped this instrument by hand. keep it?"; // OMNI/trim-only builds have no cables
|
||
Object.assign(sub.style, { color: "rgba(165,185,218,0.75)", fontStyle: "italic", marginBottom: "24px", fontSize: "14px" });
|
||
card.appendChild(h); card.appendChild(sub);
|
||
const row = document.createElement("div");
|
||
Object.assign(row.style, { display: "flex", gap: "12px", flexWrap: "wrap", justifyContent: "center" });
|
||
const doors = [
|
||
["🌱", "keep my build", "this becomes the whole instrument. the world you left stays gone.", "keep"],
|
||
["🌌", "bring the old world back", "drop what you built, return to the field you had before zero.", "old"],
|
||
["🕸", "weave them together", "your build layered on top of the old world — both at once.", "weave"],
|
||
];
|
||
for (const [emoji, title, desc, mode] of doors) {
|
||
const b = document.createElement("div");
|
||
Object.assign(b.style, { flex: "1 1 160px", minWidth: "150px", maxWidth: "190px", cursor: "pointer",
|
||
padding: "18px 14px", borderRadius: "16px", background: "rgba(20,28,44,0.7)",
|
||
border: "1px solid rgba(120,150,200,0.28)", transition: "transform .12s, border-color .12s" });
|
||
b.onmouseenter = () => { b.style.transform = "translateY(-3px)"; b.style.borderColor = "rgba(150,200,255,0.7)"; };
|
||
b.onmouseleave = () => { b.style.transform = ""; b.style.borderColor = "rgba(120,150,200,0.28)"; };
|
||
b.innerHTML = '<div style="font-size:30px;margin-bottom:8px">' + emoji + '</div>'
|
||
+ '<div style="font-weight:700;color:#eaf1ff;margin-bottom:6px">' + title + '</div>'
|
||
+ '<div style="font-size:12px;color:rgba(180,195,225,0.75);line-height:1.5">' + desc + '</div>';
|
||
b.onclick = () => { wrap.remove(); document.removeEventListener("keydown", onEsc, true); finishZeroExit(mode, name); };
|
||
row.appendChild(b);
|
||
}
|
||
card.appendChild(row);
|
||
const foot = document.createElement("div"); foot.textContent = "esc — stay in zero";
|
||
Object.assign(foot.style, { marginTop: "20px", fontSize: "11px", color: "rgba(150,170,205,0.55)" });
|
||
card.appendChild(foot);
|
||
wrap.appendChild(card);
|
||
const onEsc = (e) => { if (e.key === "Escape") { wrap.remove(); document.removeEventListener("keydown", onEsc, true); } }; // stay in zero
|
||
wrap.onclick = (e) => { if (e.target === wrap) { wrap.remove(); document.removeEventListener("keydown", onEsc, true); } };
|
||
document.addEventListener("keydown", onEsc, true);
|
||
document.body.appendChild(wrap);
|
||
}
|
||
|
||
// The three doors, shown once on a first-ever visit (replaces the silent drop
|
||
// into first light). Backdrop/esc = first light, the gentle default.
|
||
function showFirstVisitCard() {
|
||
if (document.getElementById("firstcard")) return;
|
||
const wrap = document.createElement("div");
|
||
wrap.id = "firstcard";
|
||
Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "70", display: "flex",
|
||
alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,0.72)",
|
||
backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)" });
|
||
const card = document.createElement("div");
|
||
Object.assign(card.style, { maxWidth: "640px", width: "88%", textAlign: "center",
|
||
color: "#d6e2f4", fontFamily: "inherit" });
|
||
const h = document.createElement("div");
|
||
h.textContent = "one world, three doors";
|
||
Object.assign(h.style, { fontWeight: "800", fontSize: "clamp(22px,4vw,32px)", letterSpacing: "2px", marginBottom: "6px" });
|
||
const sub = document.createElement("div");
|
||
sub.textContent = "the planet is already playing. choose how you meet it.";
|
||
Object.assign(sub.style, { color: "rgba(165,185,218,0.75)", fontStyle: "italic", marginBottom: "26px", fontSize: "14px" });
|
||
card.appendChild(h); card.appendChild(sub);
|
||
const row = document.createElement("div");
|
||
Object.assign(row.style, { display: "flex", gap: "14px", flexWrap: "wrap", justifyContent: "center" });
|
||
const doors = [
|
||
["🌱", "start from zero", "an empty field. build the instrument yourself, one cable at a time.", () => enterZero()],
|
||
["🌅", "first light", "a gentle arrangement — a bass line, soft hats, a small melody. ease in.", () => applyMood("first light")],
|
||
["⛈", "the full storm", "every one of the world's hands on it at once. the factory instrument.", () => applyMood("the full storm")],
|
||
];
|
||
for (const [emoji, title, desc, act] of doors) {
|
||
const b = document.createElement("div");
|
||
Object.assign(b.style, { flex: "1 1 170px", minWidth: "160px", maxWidth: "200px", cursor: "pointer",
|
||
padding: "20px 16px", borderRadius: "16px", background: "rgba(20,28,44,0.7)",
|
||
border: "1px solid rgba(120,150,200,0.28)", transition: "transform .12s, border-color .12s" });
|
||
b.onmouseenter = () => { b.style.transform = "translateY(-3px)"; b.style.borderColor = "rgba(150,200,255,0.7)"; };
|
||
b.onmouseleave = () => { b.style.transform = ""; b.style.borderColor = "rgba(120,150,200,0.28)"; };
|
||
b.innerHTML = '<div style="font-size:34px;margin-bottom:8px">' + emoji + '</div>'
|
||
+ '<div style="font-weight:700;color:#eaf1ff;margin-bottom:6px">' + title + '</div>'
|
||
+ '<div style="font-size:12px;color:rgba(180,195,225,0.75);line-height:1.5">' + desc + '</div>';
|
||
b.onclick = () => { wrap.remove(); document.removeEventListener("keydown", onEsc, true); act(); };
|
||
row.appendChild(b);
|
||
}
|
||
card.appendChild(row);
|
||
const foot = document.createElement("div");
|
||
foot.textContent = "you can switch any time — right-click the sky → 🌗 mood";
|
||
Object.assign(foot.style, { marginTop: "22px", fontSize: "11px", color: "rgba(150,170,205,0.55)" });
|
||
card.appendChild(foot);
|
||
wrap.appendChild(card);
|
||
const onEsc = (e) => { if (e.key === "Escape") { wrap.remove(); document.removeEventListener("keydown", onEsc, true); applyMood("first light"); } };
|
||
wrap.onclick = (e) => { if (e.target === wrap) { wrap.remove(); document.removeEventListener("keydown", onEsc, true); applyMood("first light"); } };
|
||
document.addEventListener("keydown", onEsc, true);
|
||
document.body.appendChild(wrap);
|
||
}
|
||
|
||
// ---- ZERO UI (Z1) — the calm 7+7 field --------------------------------------
|
||
// A DOM overlay shown only in zero mode: 7 source-vibe rings down each side (the
|
||
// 14 config groups), a center stage for the nodes you build, an output rack of the
|
||
// voices/FX. Same matrix underneath — this is a calmer face on it. Hues/side/order
|
||
// are TASTE CONSTS (retune freely). Members come live from groupsInfo (the hub's config).
|
||
const GROUP_META = {
|
||
// left column — the elements & feelings
|
||
light: { emoji: "☀", hue: "#ffd67a", side: "L", ord: 0 },
|
||
dark: { emoji: "🌑", hue: "#7a6cff", side: "L", ord: 1 },
|
||
fire: { emoji: "🔥", hue: "#ff5a4d", side: "L", ord: 2 },
|
||
water: { emoji: "💧", hue: "#4db8ff", side: "L", ord: 3 },
|
||
earth: { emoji: "⛰", hue: "#b0854a", side: "L", ord: 4 },
|
||
air: { emoji: "🌬", hue: "#bfe6ff", side: "L", ord: 5 },
|
||
spirit: { emoji: "✦", hue: "#d18aff", side: "L", ord: 6 },
|
||
// right column — the domains
|
||
planet: { emoji: "🌍", hue: "#57c98c", side: "R", ord: 0 },
|
||
cosmos: { emoji: "🛰", hue: "#8090ff", side: "R", ord: 1 },
|
||
heavens:{ emoji: "🪐", hue: "#ffd24d", side: "R", ord: 2 },
|
||
human: { emoji: "👤", hue: "#ff9060", side: "R", ord: 3 },
|
||
market: { emoji: "📈", hue: "#4dd6c2", side: "R", ord: 4 },
|
||
wealth: { emoji: "💰", hue: "#ffcf3a", side: "R", ord: 5 },
|
||
summer: { emoji: "🌻", hue: "#ff9e3d", side: "R", ord: 6 },
|
||
};
|
||
const ZERO_RACK = [ // the outputs, plain-language (routing is Z2)
|
||
{ grp: "voices", items: [["lead.note", "lead — sings"], ["bass.note", "bass — roots"],
|
||
["pad.brightness", "pad — glows"], ["drone.voices", "drone — hums"], ["perc.density", "perc — taps"]] },
|
||
{ grp: "space & grit", items: [["filter.cutoff", "filter"], ["reverb.size", "reverb"],
|
||
["delay.feedback", "delay"], ["saturation", "saturation"], ["glitch", "glitch"],
|
||
["granular.density", "granular"], ["crush", "crush"], ["squash", "squash"], ["wavetable.morph", "morph"],
|
||
["lfo.rate", "lfo"], ["master.space", "space"]] },
|
||
];
|
||
function _grpMembers(key) { const gi = groupsInfo[key]; return (gi && gi.members) || []; }
|
||
function _grpAvg(key) {
|
||
const m = _grpMembers(key); if (!m.length) return 0;
|
||
let s = 0, c = 0; for (const k of m) { const n = sources.get(k); if (n) { s += n.normDisp || n.norm || 0; c++; } }
|
||
return c ? s / c : 0;
|
||
}
|
||
const zeroUI = {
|
||
root: null, drawer: null, stage: null, rings: {}, nodes: {}, openGroup: null,
|
||
build() {
|
||
if (this.root) return;
|
||
const st = document.createElement("style");
|
||
st.textContent = "#zerowrap{position:fixed;inset:0;z-index:8;display:none;pointer-events:auto;font-family:inherit}"
|
||
+ "#zerowrap.on{display:block;background:radial-gradient(ellipse at 50% 45%,rgba(8,12,24,.35),rgba(4,6,14,.62))}"
|
||
+ ".zcol{position:absolute;top:76px;bottom:20px;width:168px;display:flex;flex-direction:column;justify-content:space-between;pointer-events:auto}"
|
||
+ ".zcol.left{left:22px}.zcol.right{right:22px}"
|
||
+ ".zring{display:flex;align-items:center;gap:11px;cursor:pointer;opacity:.86;transition:opacity .15s}"
|
||
+ ".zcol.right .zring{flex-direction:row-reverse;text-align:right}.zring:hover{opacity:1}"
|
||
+ ".zring.sel .zlbl{color:#fff}"
|
||
+ ".zdot{width:40px;height:40px;border-radius:50%;flex:0 0 auto;position:relative}"
|
||
+ ".zlbl{font-size:12.5px;color:#c6d4ec;letter-spacing:.3px;line-height:1.2}"
|
||
+ ".zlbl small{display:block;color:rgba(150,170,205,.6);font-size:10px}"
|
||
+ ".zstage{position:absolute;left:210px;right:210px;top:84px;bottom:150px;pointer-events:auto;display:flex;flex-wrap:wrap;align-content:center;justify-content:center;gap:22px;padding:14px;overflow:auto}"
|
||
+ ".zstage .zhint{margin:auto;color:rgba(150,170,205,.5);font-size:13px;font-style:italic;text-align:center;max-width:280px}"
|
||
+ ".zorb{position:relative;width:132px;height:150px;flex:0 0 auto;cursor:grab}"
|
||
+ ".zorb .core{position:absolute;left:50%;top:44%;width:44px;height:44px;margin:-22px;border-radius:50%;animation:zpulse 3.2s ease-in-out infinite}"
|
||
+ ".zorb .orbit{position:absolute;left:0;right:0;top:0;height:132px;animation:zspin 15s linear infinite}"
|
||
+ ".zorb .pet{position:absolute;left:50%;top:50%;width:13px;height:13px;margin:-6.5px;border-radius:50%;box-shadow:0 0 6px currentColor}"
|
||
+ ".zorb .cap{position:absolute;left:0;right:0;bottom:0;text-align:center;font-size:11px;color:#dbe6f8}"
|
||
+ ".zorb .cap small{color:rgba(170,190,220,.6)}"
|
||
+ "@keyframes zspin{to{transform:rotate(360deg)}}"
|
||
+ "@keyframes zpulse{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.12);opacity:1}}"
|
||
+ ".zrack{position:absolute;left:210px;right:210px;bottom:20px;height:120px;pointer-events:auto;overflow:auto}"
|
||
+ ".zrack .grp{font-size:10px;letter-spacing:1px;color:rgba(150,170,205,.55);text-transform:uppercase;margin:2px 0 4px}"
|
||
+ ".zsock{display:inline-block;margin:0 7px 7px 0;padding:6px 10px;border-radius:9px;background:rgba(18,26,42,.7);border:1px solid rgba(120,150,200,.2);font-size:11.5px;color:#aab8d4;transition:border-color .12s,background .12s}"
|
||
+ ".zsock.lit{border-color:rgba(120,255,178,.6);color:#dff5e8;background:rgba(28,56,46,.55)}"
|
||
+ ".zsock.hot{border-color:rgba(150,200,255,.95);background:rgba(44,64,96,.7);color:#eaf1ff}"
|
||
+ ".zdrawer{position:absolute;top:76px;bottom:20px;left:0;width:288px;background:rgba(12,18,32,.95);border-radius:0 16px 16px 0;border:1px solid rgba(120,150,200,.25);box-shadow:0 8px 40px rgba(0,0,0,.5);opacity:0;transform:translateX(-16px);transition:opacity .18s,transform .18s;pointer-events:none;overflow:auto;padding:16px}"
|
||
+ ".zdrawer.right{left:auto;right:0;border-radius:16px 0 0 16px;transform:translateX(16px)}"
|
||
+ ".zdrawer.open{opacity:1;transform:translateX(0);pointer-events:auto}"
|
||
+ ".zdrawer h4{margin:0 0 4px;color:#eaf1ff;font-size:15px;letter-spacing:.5px}"
|
||
+ ".zdrawer .dsub{color:rgba(150,170,205,.6);font-size:11px;margin-bottom:12px}"
|
||
+ ".zfeed{display:flex;align-items:center;gap:8px;padding:7px 4px;border-top:1px solid rgba(140,170,230,.08)}"
|
||
+ ".zfeed .fn{flex:1;font-size:12px;color:#cdd9f0}.zfeed .fn small{display:block;color:rgba(150,170,205,.55);font-size:10px}"
|
||
+ ".zfeed .bar{width:44px;height:4px;border-radius:2px;background:rgba(120,150,200,.15);overflow:hidden}"
|
||
+ ".zfeed .bar i{display:block;height:100%;border-radius:2px}"
|
||
+ ".zadd{width:22px;height:22px;border-radius:6px;border:1px solid rgba(150,180,255,.4);color:#9fd0ff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;background:rgba(120,150,255,.12)}"
|
||
+ ".zadd.on{background:rgba(120,255,178,.18);border-color:rgba(120,255,178,.5);color:#7cffb2}"
|
||
+ ".zorb.armed{outline:2px solid rgba(150,200,255,.85);outline-offset:5px;border-radius:16px}"
|
||
+ ".zrack.aiming .zsock{border-color:rgba(150,200,255,.5);cursor:pointer}"
|
||
+ ".zstorm{position:absolute;right:216px;bottom:150px;padding:8px 15px;border-radius:20px;background:rgba(30,26,52,.8);border:1px solid rgba(150,130,220,.4);color:#d8cbff;font-size:12px;cursor:pointer;pointer-events:auto;z-index:9;transition:transform .12s,border-color .12s}"
|
||
+ ".zstorm:hover{transform:translateY(-2px);border-color:rgba(180,160,255,.85)}"
|
||
+ ".zvibes{position:absolute;left:216px;bottom:150px;padding:8px 15px;border-radius:20px;background:rgba(18,32,44,.8);border:1px solid rgba(120,190,220,.4);color:#c8ecf5;font-size:12px;cursor:pointer;pointer-events:auto;z-index:9;transition:transform .12s,border-color .12s}"
|
||
+ ".zvibes:hover{transform:translateY(-2px);border-color:rgba(150,220,255,.85)}";
|
||
document.head.appendChild(st);
|
||
const w = document.createElement("div"); w.id = "zerowrap"; this.root = w;
|
||
const colL = document.createElement("div"); colL.className = "zcol left";
|
||
const colR = document.createElement("div"); colR.className = "zcol right";
|
||
const order = Object.keys(GROUP_META).sort((a, b) => GROUP_META[a].ord - GROUP_META[b].ord);
|
||
for (const key of order) {
|
||
const meta = GROUP_META[key];
|
||
const ring = document.createElement("div"); ring.className = "zring";
|
||
const dot = document.createElement("div"); dot.className = "zdot"; dot.style.background = meta.hue;
|
||
const lbl = document.createElement("div"); lbl.className = "zlbl";
|
||
lbl.innerHTML = meta.emoji + " " + key + "<small></small>";
|
||
ring.appendChild(dot); ring.appendChild(lbl);
|
||
ring.onclick = () => this.openDrawer(key);
|
||
(meta.side === "L" ? colL : colR).appendChild(ring);
|
||
this.rings[key] = { ring, dot, lbl };
|
||
}
|
||
const stage = document.createElement("div"); stage.className = "zstage"; this.stage = stage;
|
||
const rack = document.createElement("div"); rack.className = "zrack"; this.socks = {}; this.rack = rack;
|
||
for (const sect of ZERO_RACK) {
|
||
const g = document.createElement("div"); g.className = "grp"; g.textContent = sect.grp; rack.appendChild(g);
|
||
const box = document.createElement("div");
|
||
for (const [key, label] of sect.items) {
|
||
const s = document.createElement("span"); s.className = "zsock"; s.textContent = label; s.dataset.dest = key;
|
||
s.ondragover = (e) => { e.preventDefault(); s.classList.add("hot"); };
|
||
s.ondragleave = () => s.classList.remove("hot");
|
||
s.ondrop = (e) => { e.preventDefault(); s.classList.remove("hot"); this.routeGroup(e.dataTransfer.getData("text/plain"), key); };
|
||
s.oncontextmenu = (e) => { e.preventDefault(); this.assignMenu(e, key); };
|
||
s.onclick = () => { if (this.armed) { this.routeGroup(this.armed, key); this.disarm(); } }; // tap-to-patch (C3)
|
||
this.socks[key] = s; box.appendChild(s);
|
||
}
|
||
rack.appendChild(box);
|
||
}
|
||
const drawer = document.createElement("div"); drawer.className = "zdrawer"; this.drawer = drawer;
|
||
const storm = document.createElement("div"); storm.className = "zstorm"; storm.textContent = "⛈ enter the storm";
|
||
storm.title = "leave zero — step into the full world";
|
||
storm.onclick = () => exitZeroToMood("the full storm");
|
||
const vibes = document.createElement("div"); vibes.className = "zvibes"; vibes.textContent = "✨ vibes";
|
||
vibes.title = "save & share small route bundles";
|
||
vibes.onclick = () => openVibesPanel();
|
||
w.appendChild(colL); w.appendChild(colR); w.appendChild(stage); w.appendChild(rack); w.appendChild(drawer); w.appendChild(storm); w.appendChild(vibes);
|
||
w.addEventListener("click", (e) => { // tap empty space → disarm (C3)
|
||
if (this.armed && e.target && !e.target.closest(".zorb") && !e.target.closest(".zsock")) this.disarm();
|
||
});
|
||
document.body.appendChild(w);
|
||
this.renderStage();
|
||
},
|
||
open() { this.build(); this.restoreNodes(); this.root.classList.add("on"); this.renderStage(); this.refreshSockets(); },
|
||
close() { if (this.root) { this.root.classList.remove("on"); this.closeDrawer(); this.disarm(); } },
|
||
persist() { // zero build survives a reload: picks + cables + vibe ownership (C2)
|
||
try {
|
||
const nd = {}; for (const gk in this.nodes) nd[gk] = [...this.nodes[gk].members];
|
||
localStorage.setItem("gs_zeronodes", JSON.stringify(nd));
|
||
localStorage.setItem("gs_zerobuild", JSON.stringify(serializePatch()));
|
||
const own = {}; for (const r of routesArr) if (r._vibes && r._vibes.size) own[r.source + "|" + r.dest] = { v: [...r._vibes], b: !!r._base };
|
||
localStorage.setItem("gs_zerovibes", JSON.stringify({ active: [...activeVibes], own })); // so reloaded vibes stay live + removable (Z3/#7)
|
||
} catch (e) {}
|
||
},
|
||
restoreVibeState() { // re-tag routes so a reloaded vibe shows ON and its off-toggle works
|
||
try {
|
||
const zv = JSON.parse(localStorage.getItem("gs_zerovibes") || "null");
|
||
if (!zv) return;
|
||
activeVibes.clear(); for (const nm of (zv.active || [])) activeVibes.add(nm);
|
||
for (const id in (zv.own || {})) { const rt = routeIndex.get(id); if (rt) { rt._vibes = new Set(zv.own[id].v); rt._base = !!zv.own[id].b; } }
|
||
} catch (e) {}
|
||
},
|
||
restoreNodes() { // rebuild the picked orbs from storage (reload path)
|
||
try {
|
||
const nd = JSON.parse(localStorage.getItem("gs_zeronodes") || "null");
|
||
if (nd) { this.nodes = {}; for (const gk in nd) if (GROUP_META[gk]) this.nodes[gk] = { members: new Set(nd[gk]) }; }
|
||
} catch (e) {}
|
||
},
|
||
_dropSource(src) { for (const r of routesArr.filter(x => x.source === src)) removeRouteLocal(r.source, r.dest); },
|
||
armOrb(gk) { // tap-to-patch (touch fallback for drag) (C3)
|
||
this.armed = (this.armed === gk) ? null : gk;
|
||
for (const orb of (this.orbs || [])) orb.classList.toggle("armed", orb._group === this.armed);
|
||
if (this.rack) this.rack.classList.toggle("aiming", !!this.armed);
|
||
if (this.armed) eventTicker.unshift({ text: "🌱 armed " + GROUP_META[gk].emoji + " " + gk + " — tap a voice in the rack to patch it (or tap the orb again to cancel)", tAdded: now(), src: "sys" });
|
||
},
|
||
disarm() { if (!this.armed) return; this.armed = null;
|
||
for (const orb of (this.orbs || [])) orb.classList.remove("armed");
|
||
if (this.rack) this.rack.classList.remove("aiming"); },
|
||
orbMenu(e, gk) { // right-click an orb → members / unpatch / save-as-vibe (C3)
|
||
const old = document.getElementById("zorbmenu"); if (old) old.remove();
|
||
const node = this.nodes[gk]; if (!node) return;
|
||
const meta = GROUP_META[gk];
|
||
const m = document.createElement("div"); m.id = "zorbmenu";
|
||
Object.assign(m.style, { position: "fixed", left: Math.min(e.clientX, innerWidth - 250) + "px",
|
||
top: Math.min(e.clientY, innerHeight - 120 - node.members.size * 24) + "px",
|
||
zIndex: "80", background: "rgba(14,20,34,.97)", border: "1px solid rgba(120,150,200,.35)", borderRadius: "12px",
|
||
padding: "12px 14px", minWidth: "220px", color: "#dbe6f8", fontSize: "12px", boxShadow: "0 8px 30px rgba(0,0,0,.5)" });
|
||
const title = document.createElement("div"); title.textContent = meta.emoji + " " + gk; title.style.cssText = "font-weight:700;color:#eaf1ff;margin-bottom:8px";
|
||
m.appendChild(title);
|
||
for (const src of [...node.members]) { // each feed, with × to drop it (and its cables)
|
||
const r = document.createElement("div"); r.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;margin:5px 0";
|
||
const l = document.createElement("span"); l.textContent = (sources.get(src) || {}).label || src; l.style.cssText = "color:rgba(190,205,235,.85);overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
|
||
const x = document.createElement("span"); x.textContent = "×"; x.style.cssText = "color:#ff9a9a;cursor:pointer;flex:0 0 auto;font-size:15px";
|
||
x.onclick = () => { m.remove(); this.removeMember(gk, src); if (this.nodes[gk]) this.orbMenu(e, gk); };
|
||
r.appendChild(l); r.appendChild(x); m.appendChild(r);
|
||
}
|
||
const sep = document.createElement("div"); sep.style.cssText = "border-top:1px solid rgba(140,170,230,.14);margin:8px 0 6px"; m.appendChild(sep);
|
||
const unp = document.createElement("div"); unp.textContent = "⌫ unpatch this orb (keep the feeds)"; unp.style.cssText = "color:#ffbf87;cursor:pointer;margin:5px 0";
|
||
unp.onclick = () => { for (const src of node.members) this._dropSource(src); this.refreshSockets(); this.persist(); m.remove(); };
|
||
m.appendChild(unp);
|
||
const vibe = document.createElement("div"); vibe.textContent = "💾 save as vibe"; vibe.style.cssText = "color:#9fd0ff;cursor:pointer;margin:5px 0";
|
||
vibe.onclick = () => { m.remove(); this.saveVibe(gk); };
|
||
m.appendChild(vibe);
|
||
document.body.appendChild(m);
|
||
const off = (ev) => { if (!m.contains(ev.target)) { m.remove(); document.removeEventListener("mousedown", off, true); } };
|
||
setTimeout(() => document.addEventListener("mousedown", off, true), 0);
|
||
},
|
||
removeMember(gk, src) {
|
||
const node = this.nodes[gk]; if (!node) return;
|
||
node.members.delete(src); this._dropSource(src); // the feed and its cables leave together
|
||
if (!node.members.size) delete this.nodes[gk];
|
||
if (this.armed === gk && !this.nodes[gk]) this.disarm();
|
||
this.renderStage(); this.refreshSockets(); this.persist();
|
||
},
|
||
saveVibe(gk) { // one orb's routes → a shareable vibe (Z3)
|
||
const node = this.nodes[gk]; if (!node) return;
|
||
const members = [...node.members];
|
||
const routes = routesArr.filter(r => members.includes(r.source));
|
||
if (!routes.length) { eventTicker.unshift({ text: "💾 patch this orb to a voice first, then save it as a vibe", tAdded: now(), src: "sys" }); return; }
|
||
nameVibeCard(GROUP_META[gk].emoji + " " + gk, (name) => {
|
||
const vibe = vibeFromRoutes(routes, name, GROUP_META[gk].emoji);
|
||
if (!vibe) { eventTicker.unshift({ text: "💾 couldn't save that vibe", tAdded: now(), src: "sys" }); return; }
|
||
vibeStore.save(vibe); applyVibe(vibe); // snapshot + mark live (its cables are already on)
|
||
eventTicker.unshift({ text: "💾 saved vibe “" + vibe.name + "” · " + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + " — open ✨ vibes to share the code", tAdded: now(), src: "sys" });
|
||
});
|
||
},
|
||
openDrawer(key) {
|
||
this.build();
|
||
const meta = GROUP_META[key], d = this.drawer;
|
||
this.openGroup = key;
|
||
d.className = "zdrawer" + (meta.side === "R" ? " right" : "");
|
||
const gi = groupsInfo[key] || {};
|
||
d.innerHTML = "";
|
||
const h = document.createElement("h4"); h.textContent = meta.emoji + " " + key; h.style.color = meta.hue;
|
||
const sub = document.createElement("div"); sub.className = "dsub"; sub.textContent = (gi.label || "") + " — pick the feeds you want on the stage";
|
||
d.appendChild(h); d.appendChild(sub);
|
||
const picked = this.nodes[key] ? this.nodes[key].members : new Set();
|
||
for (const k of _grpMembers(key)) {
|
||
const n = sources.get(k);
|
||
const row = document.createElement("div"); row.className = "zfeed";
|
||
const fn = document.createElement("div"); fn.className = "fn";
|
||
fn.innerHTML = (n ? n.label : k).replace(/</g, "<") + "<small>" + k + "</small>";
|
||
const bar = document.createElement("div"); bar.className = "bar";
|
||
const fill = document.createElement("i"); fill.style.background = meta.hue; fill.style.width = "0%"; bar.appendChild(fill);
|
||
const add = document.createElement("div"); add.className = "zadd" + (picked.has(k) ? " on" : "");
|
||
add.textContent = picked.has(k) ? "✓" : "+";
|
||
add.onclick = (e) => { e.stopPropagation(); this.pick(key, k); // toggle in place (no drawer re-flicker)
|
||
const on = !!(this.nodes[key] && this.nodes[key].members.has(k));
|
||
add.className = "zadd" + (on ? " on" : ""); add.textContent = on ? "✓" : "+"; };
|
||
row.appendChild(fn); row.appendChild(bar); row.appendChild(add);
|
||
row._srcKey = k; row._fill = fill;
|
||
d.appendChild(row);
|
||
}
|
||
void d.offsetWidth; // force reflow, then transition in reliably
|
||
d.classList.add("open");
|
||
for (const r of this.rings ? Object.entries(this.rings) : []) r[1].ring.classList.toggle("sel", r[0] === key);
|
||
},
|
||
closeDrawer() { if (this.drawer) this.drawer.classList.remove("open"); this.openGroup = null;
|
||
for (const k in this.rings) this.rings[k].ring.classList.remove("sel"); },
|
||
pick(groupKey, srcKey) {
|
||
let node = this.nodes[groupKey];
|
||
if (!node) node = this.nodes[groupKey] = { members: new Set() };
|
||
if (node.members.has(srcKey)) node.members.delete(srcKey); else node.members.add(srcKey);
|
||
if (!node.members.size) delete this.nodes[groupKey];
|
||
this.renderStage(); this.persist();
|
||
},
|
||
renderStage() {
|
||
if (!this.stage) return;
|
||
this.stage.innerHTML = ""; this.orbs = [];
|
||
const keys = Object.keys(this.nodes);
|
||
if (!keys.length) {
|
||
const hint = document.createElement("div"); hint.className = "zhint";
|
||
hint.textContent = "click a ring on either side → pick a feed → it becomes a spinning orb here. gather more and the orbs multiply. then drag an orb onto a voice (or tap it, then tap a voice) to wire it up.";
|
||
this.stage.appendChild(hint); return;
|
||
}
|
||
for (const gk of keys) { // each built group → a spinning orb; they accumulate
|
||
const meta = GROUP_META[gk], mem = [...this.nodes[gk].members];
|
||
const orb = document.createElement("div"); orb.className = "zorb"; orb.title = "drag me onto a voice → · " + mem.map(k => (sources.get(k) || {}).label || k).join(" · ");
|
||
orb.draggable = true;
|
||
orb.ondragstart = (e) => { e.dataTransfer.setData("text/plain", gk); e.dataTransfer.effectAllowed = "copy"; };
|
||
orb.onclick = (e) => { e.stopPropagation(); this.armOrb(gk); }; // tap-to-patch arm (C3)
|
||
orb.oncontextmenu = (e) => { e.preventDefault(); this.orbMenu(e, gk); }; // members / unpatch / vibe (C3)
|
||
const orbit = document.createElement("div"); orbit.className = "orbit";
|
||
const R = 44 + Math.min(mem.length, 8) * 1.5; // more feeds → a slightly wider ring
|
||
mem.forEach((k, i) => {
|
||
const a = (i / mem.length) * Math.PI * 2;
|
||
const pet = document.createElement("div"); pet.className = "pet";
|
||
pet.style.color = meta.hue; pet.style.background = meta.hue;
|
||
pet.style.transform = "translate(" + (Math.cos(a) * R).toFixed(1) + "px," + (Math.sin(a) * R).toFixed(1) + "px)";
|
||
pet._src = k; orbit.appendChild(pet);
|
||
});
|
||
const core = document.createElement("div"); core.className = "core"; core.style.background = meta.hue;
|
||
const cap = document.createElement("div"); cap.className = "cap";
|
||
cap.innerHTML = meta.emoji + " " + gk + " <small>· " + mem.length + "</small>";
|
||
orb.appendChild(orbit); orb.appendChild(core); orb.appendChild(cap);
|
||
orb._group = gk; orb._core = core;
|
||
if (this.armed === gk) orb.classList.add("armed"); // keep the arm visible across a rebuild
|
||
this.stage.appendChild(orb); this.orbs.push(orb);
|
||
}
|
||
},
|
||
// ---- routing (Z2): an orb's feeds → an output socket ------------------
|
||
routeGroup(gk, destKey) {
|
||
const node = this.nodes[gk]; if (!node || !destKey) return;
|
||
const isNote = /note/.test(destKey.split(".").pop());
|
||
let n = 0;
|
||
for (const src of node.members) {
|
||
const r = { source: src, dest: destKey, amount: 0.5, curve: "lin" };
|
||
if (isNote) r.quantize = { scale: gScale.name || "minor_pent", root: /bass/.test(destKey) ? 36 : 60, octaves: 2 };
|
||
registerRoute(r); n++;
|
||
}
|
||
this.refreshSockets(); this.persist();
|
||
eventTicker.unshift({ text: "🌱 patched " + GROUP_META[gk].emoji + " " + gk + " → " + prettyDest(destKey)
|
||
+ " (" + n + " cable" + (n > 1 ? "s" : "") + "). press ♪ to hear it" + (isNote ? " · right-click the voice to pick a scale" : ""), tAdded: now(), src: "sys" });
|
||
},
|
||
refreshSockets() {
|
||
if (!this.socks) return;
|
||
for (const dest in this.socks) {
|
||
const s = this.socks[dest], into = routesArr.filter(r => r.dest === dest);
|
||
s.classList.toggle("lit", into.length > 0);
|
||
s.dataset.n = into.length || "";
|
||
}
|
||
},
|
||
assignMenu(e, destKey) { // right-click a socket → assign the "synth roll"
|
||
const old = document.getElementById("zassign"); if (old) old.remove();
|
||
const into = routesArr.filter(r => r.dest === destKey);
|
||
if (!into.length) { eventTicker.unshift({ text: "🌱 nothing patched to " + prettyDest(destKey) + " yet — drag an orb onto it first", tAdded: now(), src: "sys" }); return; }
|
||
const isNote = /note/.test(destKey.split(".").pop());
|
||
const m = document.createElement("div"); m.id = "zassign";
|
||
Object.assign(m.style, { position: "fixed", left: Math.min(e.clientX, innerWidth - 230) + "px", top: Math.min(e.clientY, innerHeight - 200) + "px",
|
||
zIndex: "80", background: "rgba(14,20,34,.97)", border: "1px solid rgba(120,150,200,.35)", borderRadius: "12px",
|
||
padding: "12px 14px", minWidth: "210px", color: "#dbe6f8", fontSize: "12px", boxShadow: "0 8px 30px rgba(0,0,0,.5)" });
|
||
const title = document.createElement("div"); title.textContent = prettyDest(destKey); title.style.cssText = "font-weight:700;color:#eaf1ff;margin-bottom:8px";
|
||
m.appendChild(title);
|
||
const row = (label) => { const r = document.createElement("div"); r.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;margin:6px 0";
|
||
const l = document.createElement("span"); l.textContent = label; l.style.color = "rgba(180,195,225,.8)"; r.appendChild(l); return r; };
|
||
if (isNote) {
|
||
const q = into.find(r => r.quantize) && into.find(r => r.quantize).quantize || { scale: "minor_pent", root: 60, octaves: 2 };
|
||
const scRow = row("scale"); const sc = document.createElement("select");
|
||
for (const name in SCALES) { const o = document.createElement("option"); o.value = name; o.textContent = name; if (name === q.scale) o.selected = true; sc.appendChild(o); }
|
||
scRow.appendChild(sc); m.appendChild(scRow);
|
||
const rtRow = row("root"); const rt = document.createElement("input"); rt.type = "number"; rt.min = "24"; rt.max = "96"; rt.value = q.root; rt.style.width = "56px"; rtRow.appendChild(rt); m.appendChild(rtRow);
|
||
const ocRow = row("octaves"); const oc = document.createElement("input"); oc.type = "number"; oc.min = "1"; oc.max = "3"; oc.value = q.octaves; oc.style.width = "56px"; ocRow.appendChild(oc); m.appendChild(ocRow);
|
||
const apply = () => { const nq = { scale: sc.value, root: parseInt(rt.value) || 60, octaves: parseInt(oc.value) || 2 };
|
||
for (const r of into) r.quantize = nq; this.persist(); };
|
||
sc.onchange = apply; rt.onchange = apply; oc.onchange = apply;
|
||
} else {
|
||
const amRow = row("amount"); const am = document.createElement("input"); am.type = "range"; am.min = "0"; am.max = "1"; am.step = "0.01"; am.value = into[0].amount != null ? into[0].amount : 0.5; amRow.appendChild(am); m.appendChild(amRow);
|
||
const cvRow = row("curve"); const cv = document.createElement("select");
|
||
for (const c of ["lin", "exp", "exp3", "log", "scurve", "inv"]) { const o = document.createElement("option"); o.value = c; o.textContent = c; if (c === (into[0].curve || "lin")) o.selected = true; cv.appendChild(o); }
|
||
cvRow.appendChild(cv); m.appendChild(cvRow);
|
||
am.oninput = () => { for (const r of into) r.amount = parseFloat(am.value); };
|
||
am.onchange = () => this.persist();
|
||
cv.onchange = () => { for (const r of into) r.curve = cv.value; this.persist(); };
|
||
}
|
||
const unp = document.createElement("div"); unp.textContent = "× unpatch all"; unp.style.cssText = "margin-top:10px;color:#ff9a9a;cursor:pointer;font-size:11px";
|
||
unp.onclick = () => { for (const r of [...into]) removeRouteLocal(r.source, r.dest); this.refreshSockets(); this.persist(); m.remove(); };
|
||
m.appendChild(unp);
|
||
document.body.appendChild(m);
|
||
const off = (ev) => { if (!m.contains(ev.target)) { m.remove(); document.removeEventListener("mousedown", off, true); } };
|
||
setTimeout(() => document.addEventListener("mousedown", off, true), 0);
|
||
},
|
||
update() {
|
||
if (!this.root || !this.root.classList.contains("on")) return;
|
||
for (const key in this.rings) { // breathing glow = the group's live average
|
||
const v = _grpAvg(key), meta = GROUP_META[key];
|
||
const dot = this.rings[key].dot;
|
||
dot.style.boxShadow = "0 0 " + (6 + v * 26).toFixed(0) + "px " + (1 + v * 4).toFixed(1) + "px " + meta.hue;
|
||
const sm = this.rings[key].lbl.querySelector("small");
|
||
if (sm) sm.textContent = _grpMembers(key).length + " feeds";
|
||
}
|
||
if (this.openGroup && this.drawer.classList.contains("open")) { // live bars in the open drawer
|
||
for (const row of this.drawer.querySelectorAll(".zfeed")) {
|
||
const n = sources.get(row._srcKey);
|
||
row._fill.style.width = Math.round((n ? (n.normDisp || n.norm || 0) : 0) * 100) + "%";
|
||
}
|
||
}
|
||
if (this.orbs) for (const orb of this.orbs) { // orbs breathe: core glows by group avg, petals by feed
|
||
const v = _grpAvg(orb._group);
|
||
orb._core.style.boxShadow = "0 0 " + (8 + v * 24).toFixed(0) + "px " + (1 + v * 3).toFixed(1) + "px " + GROUP_META[orb._group].hue;
|
||
for (const pet of orb.querySelectorAll(".pet")) {
|
||
const n = sources.get(pet._src);
|
||
pet.style.opacity = (0.32 + (n ? (n.normDisp || n.norm || 0) : 0) * 0.68).toFixed(2);
|
||
}
|
||
}
|
||
},
|
||
};
|
||
|
||
// ---- VIBES (Z3) — small, shareable route bundles ----------------------------
|
||
// A vibe = { v:1, name, emoji, routes:[{source,dest,amount,curve,quantize?}] }.
|
||
// Applying is ADDITIVE and reversible: routes a vibe creates are tagged
|
||
// (route._vibes) so the vibe can be toggled off cleanly; a route two vibes share
|
||
// survives until both are off; a hand-built route (route._base) a vibe rides is
|
||
// NEVER deleted by toggling. Stored in localStorage; shared as a GSV1.<b64url>
|
||
// code. Per-account cloud storage is deferred — the code + local library already
|
||
// deliver build/save/share across devices and friends.
|
||
// ponytail: add a vibes table (additive auth.py) only if users ask for a cloud library.
|
||
const VIBE_CURVES = new Set(["lin", "exp", "exp3", "log", "scurve", "inv"]);
|
||
function vibeKnownSource(k) {
|
||
if (sources.has(k)) return true;
|
||
for (const g in groupsInfo) if ((groupsInfo[g].members || []).includes(k)) return true;
|
||
return false;
|
||
}
|
||
const vibeKnownDest = (k) => dests.has(k);
|
||
function sanitizeVibe(raw) { // trust boundary: user-pasted / URL data
|
||
if (!raw || typeof raw !== "object") return null;
|
||
const noMarkup = (s) => String(s).replace(/[<>&"'`]/g, ""); // strip markup chars — these strings hit innerHTML
|
||
const nm = (typeof raw.name === "string") ? noMarkup(raw.name).trim() : "";
|
||
const name = nm ? nm.slice(0, 48) : "shared vibe";
|
||
const em = (typeof raw.emoji === "string") ? noMarkup(raw.emoji) : "";
|
||
const emoji = em ? [...em].slice(0, 2).join("") : "🎁";
|
||
if (!Array.isArray(raw.routes)) return null;
|
||
const routes = [];
|
||
for (const r of raw.routes.slice(0, 32)) { // hard cap
|
||
if (!r || typeof r.source !== "string" || typeof r.dest !== "string") continue;
|
||
if (!vibeKnownSource(r.source) || !vibeKnownDest(r.dest)) continue; // skip unknown, don't throw
|
||
const route = { source: r.source, dest: r.dest,
|
||
amount: (typeof r.amount === "number" && isFinite(r.amount)) ? clamp(r.amount, -4, 4) : 0.5,
|
||
curve: VIBE_CURVES.has(r.curve) ? r.curve : "lin" };
|
||
// quantize only ever belongs on a NOTE dest, and the scale must be an own
|
||
// array entry of SCALES — never an inherited name ("constructor" etc.), which
|
||
// would smuggle a NaN into a Web Audio param and throw every audio tick.
|
||
if (r.quantize && typeof r.quantize === "object" && /note/.test(r.dest.split(".").pop())) {
|
||
const q = r.quantize;
|
||
route.quantize = {
|
||
scale: (typeof q.scale === "string" && Array.isArray(SCALES[q.scale])) ? q.scale : "minor_pent",
|
||
root: (typeof q.root === "number" && isFinite(q.root)) ? clamp(Math.round(q.root), 0, 127) : 60,
|
||
octaves: (typeof q.octaves === "number" && isFinite(q.octaves)) ? clamp(Math.round(q.octaves), 1, 4) : 2,
|
||
};
|
||
}
|
||
routes.push(route);
|
||
}
|
||
return routes.length ? { v: 1, name, emoji, routes } : null;
|
||
}
|
||
function b64urlEncode(str) {
|
||
return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
}
|
||
function b64urlDecode(s) {
|
||
s = String(s).replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "=";
|
||
return decodeURIComponent(escape(atob(s)));
|
||
}
|
||
const vibeToCode = (vibe) => "GSV1." + b64urlEncode(JSON.stringify({ v: 1, name: vibe.name, emoji: vibe.emoji, routes: vibe.routes }));
|
||
function codeToVibe(code) {
|
||
if (typeof code !== "string") return null;
|
||
const m = /GSV1\.([A-Za-z0-9_\-]+)/.exec(code.trim());
|
||
if (!m) return null;
|
||
try { return sanitizeVibe(JSON.parse(b64urlDecode(m[1]))); } catch (e) { return null; }
|
||
}
|
||
const vibeStore = {
|
||
KEY: "gs_vibes",
|
||
all() { try { const a = JSON.parse(localStorage.getItem(this.KEY) || "[]"); return Array.isArray(a) ? a : []; } catch (e) { return []; } },
|
||
save(vibe) { const a = this.all().filter(x => x.name !== vibe.name); a.push(vibe); this._write(a); },
|
||
remove(name) { this._write(this.all().filter(x => x.name !== name)); },
|
||
_write(a) { try { localStorage.setItem(this.KEY, JSON.stringify(a.slice(-64))); } catch (e) {} },
|
||
};
|
||
const activeVibes = new Set(); // names currently applied — drives the panel toggles
|
||
function applyVibe(vibe) {
|
||
const v = sanitizeVibe(vibe); if (!v) return 0;
|
||
ungodlyMark("loaded " + (v.name || "vibe")); // ⏪ before a vibe lays its cables on
|
||
let n = 0;
|
||
for (const r of v.routes) {
|
||
const id = r.source + "|" + r.dest;
|
||
let route = routeIndex.get(id);
|
||
if (!route) { registerRoute(r); route = routeIndex.get(id); if (!route) continue; route._base = false; }
|
||
if (!route._vibes) { route._vibes = new Set(); if (route._base === undefined) route._base = true; } // pre-existing = a hand-built base route
|
||
route._vibes.add(v.name); n++;
|
||
}
|
||
activeVibes.add(v.name); layoutDirty = true;
|
||
if (zeroMode && zeroUI.root) { zeroUI.refreshSockets(); zeroUI.persist(); }
|
||
return n;
|
||
}
|
||
function unapplyVibe(name) {
|
||
for (const route of [...routesArr]) {
|
||
if (route._vibes && route._vibes.has(name)) {
|
||
route._vibes.delete(name);
|
||
if (route._vibes.size === 0 && !route._base) removeRouteLocal(route.source, route.dest); // never removes a hand-built cable
|
||
}
|
||
}
|
||
activeVibes.delete(name); layoutDirty = true;
|
||
if (zeroMode && zeroUI.root) { zeroUI.refreshSockets(); zeroUI.persist(); }
|
||
}
|
||
function vibeFromRoutes(routes, name, emoji) {
|
||
const rs = routes.slice(0, 32).map(r => {
|
||
const o = { source: r.source, dest: r.dest, amount: r.amount, curve: r.curve || "lin" };
|
||
if (r.quantize) o.quantize = { scale: r.quantize.scale, root: r.quantize.root, octaves: r.quantize.octaves };
|
||
return o;
|
||
});
|
||
return sanitizeVibe({ v: 1, name, emoji, routes: rs });
|
||
}
|
||
function nameVibeCard(defName, onName) { // small centered naming card (matches the app's dark cards)
|
||
const old = document.getElementById("znamecard"); if (old) old.remove();
|
||
const wrap = document.createElement("div"); wrap.id = "znamecard";
|
||
Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "95", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,.66)", backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" });
|
||
const card = document.createElement("div");
|
||
Object.assign(card.style, { background: "rgba(14,20,34,.98)", border: "1px solid rgba(120,150,200,.3)", borderRadius: "14px", padding: "20px 22px", width: "320px", maxWidth: "88%", textAlign: "center", color: "#dbe6f8", boxShadow: "0 12px 44px rgba(0,0,0,.5)" });
|
||
card.innerHTML = '<div style="font-weight:700;font-size:15px;margin-bottom:10px;color:#eaf1ff">💾 name this vibe</div>';
|
||
const inp = document.createElement("input"); inp.type = "text"; inp.value = defName || ""; inp.maxLength = 48;
|
||
Object.assign(inp.style, { width: "100%", padding: "9px 11px", borderRadius: "9px", background: "rgba(8,12,22,.8)", border: "1px solid rgba(120,150,200,.3)", color: "#eaf1ff", fontSize: "13px", fontFamily: "inherit", boxSizing: "border-box" });
|
||
card.appendChild(inp);
|
||
const row = document.createElement("div"); row.style.cssText = "display:flex;gap:10px;justify-content:center;margin-top:14px";
|
||
const ok = document.createElement("button"); ok.textContent = "save"; ok.className = "midibtn";
|
||
const no = document.createElement("button"); no.textContent = "cancel"; no.className = "midibtn"; no.style.opacity = ".7";
|
||
row.appendChild(ok); row.appendChild(no); card.appendChild(row);
|
||
wrap.appendChild(card); document.body.appendChild(wrap);
|
||
inp.focus(); inp.select();
|
||
const done = (val) => { wrap.remove(); if (val && val.trim()) onName(val.trim().slice(0, 48)); };
|
||
ok.onclick = () => done(inp.value);
|
||
no.onclick = () => done(null);
|
||
inp.onkeydown = (e) => { if (e.key === "Enter") done(inp.value); if (e.key === "Escape") done(null); };
|
||
wrap.onclick = (e) => { if (e.target === wrap) done(null); };
|
||
}
|
||
function saveCurrentAsVibe() { // "save current wiring" — small builds only
|
||
if (!routesArr.length) { eventTicker.unshift({ text: "✨ nothing wired yet — build a cable or two first", tAdded: now(), src: "sys" }); return; }
|
||
if (routesArr.length > 32) { eventTicker.unshift({ text: "✨ that's " + routesArr.length + " cables — too big for a vibe (max 32). save the whole thing as a dimension instead (⚙ → DIMENSIONS)", tAdded: now(), src: "sys" }); return; }
|
||
nameVibeCard("my vibe", (name) => {
|
||
const vibe = vibeFromRoutes(routesArr, name, "✨");
|
||
if (!vibe) return;
|
||
vibeStore.save(vibe); applyVibe(vibe);
|
||
eventTicker.unshift({ text: "💾 saved vibe “" + vibe.name + "” · " + vibe.routes.length + " cables", tAdded: now(), src: "sys" });
|
||
if (document.getElementById("vibepanel")) openVibesPanel();
|
||
});
|
||
}
|
||
function copyVibeCode(vibe) {
|
||
const code = vibeToCode(vibe);
|
||
const say = (ok) => eventTicker.unshift({ text: ok ? ("⧉ copied “" + vibe.name + "” — paste the code to a friend, or into ✨ vibes → import") : "⧉ copy failed — the code is in the console", tAdded: now(), src: "sys" });
|
||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(() => say(true), () => { console.log("vibe code:", code); say(false); });
|
||
else { console.log("vibe code:", code); say(false); }
|
||
}
|
||
function importVibeCode(code) { // returns the sanitized vibe (or null) — caller messages
|
||
const vibe = codeToVibe(code); if (!vibe) return null;
|
||
vibeStore.save(vibe); return vibe;
|
||
}
|
||
function openVibesPanel() {
|
||
const old = document.getElementById("vibepanel"); if (old) old.remove();
|
||
const wrap = document.createElement("div"); wrap.id = "vibepanel";
|
||
Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "92", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,.6)", backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" });
|
||
const card = document.createElement("div");
|
||
Object.assign(card.style, { background: "rgba(12,18,32,.98)", border: "1px solid rgba(120,150,200,.28)", borderRadius: "16px", padding: "20px 22px", width: "440px", maxWidth: "92%", maxHeight: "82vh", overflow: "auto", color: "#dbe6f8", boxShadow: "0 14px 50px rgba(0,0,0,.55)" });
|
||
card.innerHTML = '<div style="font-weight:800;font-size:17px;letter-spacing:.5px;color:#eaf1ff">✨ vibes</div>'
|
||
+ '<div style="color:rgba(165,185,218,.7);font-size:12px;font-style:italic;margin:2px 0 14px">build a feeling, save it, hand it to a friend. a vibe is a small bundle of cables — toggling one adds or removes its cables; anything you built by hand stays.</div>';
|
||
// import row
|
||
const imp = document.createElement("div"); imp.style.cssText = "display:flex;gap:8px;margin-bottom:6px";
|
||
const impIn = document.createElement("input"); impIn.type = "text"; impIn.placeholder = "paste a GSV1… vibe code";
|
||
Object.assign(impIn.style, { flex: "1", padding: "8px 10px", borderRadius: "8px", background: "rgba(8,12,22,.8)", border: "1px solid rgba(120,150,200,.3)", color: "#eaf1ff", fontSize: "12px", fontFamily: "inherit", boxSizing: "border-box" });
|
||
const impBtn = document.createElement("button"); impBtn.textContent = "import"; impBtn.className = "midibtn";
|
||
const impMsg = document.createElement("div"); impMsg.style.cssText = "font-size:11px;color:rgba(150,170,205,.7);min-height:14px;margin-bottom:10px";
|
||
impBtn.onclick = () => { const v = importVibeCode(impIn.value); if (v) { impIn.value = ""; impMsg.textContent = "added “" + v.name + "” · " + v.routes.length + " cables — toggle it on below"; render(); } else impMsg.textContent = "that's not a valid vibe code."; };
|
||
imp.appendChild(impIn); imp.appendChild(impBtn); card.appendChild(imp); card.appendChild(impMsg);
|
||
// save-current
|
||
const saveCur = document.createElement("button"); saveCur.textContent = "+ save current wiring as a vibe"; saveCur.className = "midibtn"; saveCur.style.cssText += "margin-bottom:12px";
|
||
saveCur.onclick = () => saveCurrentAsVibe();
|
||
card.appendChild(saveCur);
|
||
// list
|
||
const list = document.createElement("div"); card.appendChild(list);
|
||
const render = () => {
|
||
list.innerHTML = "";
|
||
const vibes = vibeStore.all();
|
||
if (!vibes.length) { const e = document.createElement("div"); e.style.cssText = "color:rgba(150,170,205,.55);font-size:12px;font-style:italic;padding:10px 0"; e.textContent = "no vibes yet — save an orb in zero (right-click it), or your current wiring above."; list.appendChild(e); return; }
|
||
for (const vibe of vibes.slice().reverse()) {
|
||
const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:10px;padding:9px 4px;border-top:1px solid rgba(140,170,230,.1)";
|
||
const on = activeVibes.has(vibe.name);
|
||
const tog = document.createElement("div"); tog.textContent = on ? "◉" : "○"; tog.title = on ? "applied — click to remove its cables" : "click to add its cables";
|
||
tog.style.cssText = "cursor:pointer;font-size:18px;color:" + (on ? "#7cffb2" : "rgba(150,180,255,.6)");
|
||
tog.onclick = () => { if (activeVibes.has(vibe.name)) unapplyVibe(vibe.name); else applyVibe(vibe); render(); };
|
||
const nm = document.createElement("div"); nm.style.cssText = "flex:1;min-width:0";
|
||
nm.innerHTML = '<div style="color:#eaf1ff;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (vibe.emoji || "🎁") + " " + (vibe.name || "vibe").replace(/</g, "<") + '</div>'
|
||
+ '<div style="color:rgba(150,170,205,.55);font-size:10.5px">' + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + '</div>';
|
||
const cp = document.createElement("div"); cp.textContent = "⧉"; cp.title = "copy share code"; cp.style.cssText = "cursor:pointer;font-size:15px;color:#9fd0ff;padding:2px 6px";
|
||
cp.onclick = () => copyVibeCode(vibe);
|
||
const del = document.createElement("div"); del.textContent = "×"; del.title = "delete"; del.style.cssText = "cursor:pointer;font-size:16px;color:#ff9a9a;padding:2px 6px";
|
||
del.onclick = () => { if (activeVibes.has(vibe.name)) unapplyVibe(vibe.name); vibeStore.remove(vibe.name); render(); };
|
||
row.appendChild(tog); row.appendChild(nm); row.appendChild(cp); row.appendChild(del); list.appendChild(row);
|
||
}
|
||
};
|
||
render();
|
||
const foot = document.createElement("div"); foot.style.cssText = "text-align:right;margin-top:14px";
|
||
const close = document.createElement("button"); close.textContent = "done"; close.className = "midibtn"; close.onclick = () => wrap.remove();
|
||
foot.appendChild(close); card.appendChild(foot);
|
||
wrap.appendChild(card); document.body.appendChild(wrap);
|
||
wrap.onclick = (e) => { if (e.target === wrap) wrap.remove(); };
|
||
}
|
||
function offerVibeImport(vibe) { // #vibe= fragment / shared code — never auto-applies
|
||
const wrap = document.createElement("div"); wrap.id = "vibeoffer";
|
||
Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "96", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,.7)", backdropFilter: "blur(7px)", WebkitBackdropFilter: "blur(7px)" });
|
||
const card = document.createElement("div");
|
||
Object.assign(card.style, { background: "rgba(14,20,34,.98)", border: "1px solid rgba(120,150,200,.3)", borderRadius: "16px", padding: "22px 24px", width: "360px", maxWidth: "90%", textAlign: "center", color: "#dbe6f8", boxShadow: "0 12px 44px rgba(0,0,0,.5)" });
|
||
card.innerHTML = '<div style="font-size:30px;margin-bottom:6px">' + (vibe.emoji || "🎁") + '</div>'
|
||
+ '<div style="font-weight:700;color:#eaf1ff;font-size:15px">someone shared a vibe</div>'
|
||
+ '<div style="color:#cdd9f0;font-size:13px;margin-top:4px">“' + (vibe.name || "vibe").replace(/</g, "<") + '” · ' + vibe.routes.length + ' cable' + (vibe.routes.length > 1 ? "s" : "") + '</div>'
|
||
+ '<div style="color:rgba(165,185,218,.6);font-size:11px;font-style:italic;margin-top:8px">saved to your ✨ vibes. add its cables now?</div>';
|
||
const row = document.createElement("div"); row.style.cssText = "display:flex;gap:10px;justify-content:center;margin-top:16px";
|
||
const apply = document.createElement("button"); apply.textContent = "add it"; apply.className = "midibtn";
|
||
const later = document.createElement("button"); later.textContent = "just save"; later.className = "midibtn"; later.style.opacity = ".7";
|
||
apply.onclick = () => { applyVibe(vibe); wrap.remove(); eventTicker.unshift({ text: "✨ added “" + vibe.name + "” — press ♪ to hear it", tAdded: now(), src: "sys" }); };
|
||
later.onclick = () => wrap.remove();
|
||
row.appendChild(apply); row.appendChild(later); card.appendChild(row);
|
||
wrap.appendChild(card); document.body.appendChild(wrap);
|
||
wrap.onclick = (e) => { if (e.target === wrap) wrap.remove(); };
|
||
vibeStore.save(vibe); // saved either way; applying is opt-in
|
||
}
|
||
|
||
// ---- OMNI (Z5) — the synth panel: a NICE view over dests + tweaks -----------
|
||
// NO new audio paths. Each knob's ARC is the dest's live value (n.normDisp — the
|
||
// same number the field meters); dragging rides that dest's tweak OFFSET
|
||
// (setParamLocal → the matrix's v = v*gain + offset), shown as a dot; double-
|
||
// click resets to neutral; right-click opens the node's full context menu. The
|
||
// engine selector, earth echo and transport are the existing synth controls,
|
||
// surfaced. Note voices (lead/bass) show a live pitch arc — their character is
|
||
// the engine selector, not an offset, so those knobs are display-only.
|
||
const OMNI = {
|
||
root: null, head: null, body: null, knobs: [], _open: false,
|
||
MODULES: [
|
||
{ title: "voices", accent: [124, 255, 178], keys: ["lead.note", "bass.note", "pad.brightness", "drone.voices", "perc.density"] },
|
||
{ title: "fx rack", accent: [120, 170, 255], keys: ["filter.cutoff", "reverb.size", "delay.feedback", "saturation", "glitch", "granular.density", "crush", "squash", "wavetable.morph", "lfo.rate", "master.space"] },
|
||
],
|
||
ECHO: { title: "🌀 earth echo", accent: [184, 150, 255], keys: ["echo.time", "echo.feedback", "echo.tone", "echo.flutter", "echo.wear", "echo.wet"] },
|
||
LABELS: { "lead.note": "lead", "bass.note": "bass", "pad.brightness": "pad", "drone.voices": "drone", "perc.density": "perc",
|
||
"filter.cutoff": "filter", "reverb.size": "reverb", "delay.feedback": "delay", "saturation": "saturation", "glitch": "glitch",
|
||
"granular.density": "granular", "crush": "crush", "squash": "squash", "wavetable.morph": "morph", "lfo.rate": "lfo", "master.space": "space",
|
||
"echo.time": "time", "echo.feedback": "feedback", "echo.tone": "tone", "echo.flutter": "flutter", "echo.wear": "wear", "echo.wet": "wet" },
|
||
ARC: "M25.96 74.04 A34 34 0 1 1 74.04 74.04", // 270° gauge, opening at the bottom
|
||
isOpen() { return this._open; },
|
||
toggle() { this._open ? this.close() : this.open(); },
|
||
build() {
|
||
if (this.root) return;
|
||
const st = document.createElement("style");
|
||
st.textContent =
|
||
"#omni{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);z-index:25;width:min(760px,94vw);max-height:86vh;display:none;opacity:0;transition:opacity .16s,transform .16s;background:rgba(10,15,27,.97);border:1px solid rgba(120,150,200,.28);border-radius:18px;box-shadow:0 20px 70px rgba(0,0,0,.6);color:#dbe6f8;font-family:inherit;overflow:hidden}"
|
||
+ "#omni.on{display:flex;flex-direction:column;opacity:1;transform:translate(-50%,-50%) scale(1)}"
|
||
+ "#omni .ohead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:15px 18px;border-bottom:1px solid rgba(120,150,200,.16)}"
|
||
+ "#omni .otitle{font-weight:800;font-size:17px;letter-spacing:2px;color:#eaf1ff}"
|
||
+ "#omni .ochip{padding:5px 11px;border-radius:16px;background:rgba(20,28,44,.8);border:1px solid rgba(120,150,200,.28);color:#aebbd6;font-size:12px;cursor:pointer;transition:.12s;user-select:none}"
|
||
+ "#omni .ochip:hover{border-color:rgba(150,200,255,.6);color:#eaf1ff}"
|
||
+ "#omni .ochip.on{background:rgba(30,46,70,.9);border-color:rgba(150,200,255,.7);color:#eaf1ff}"
|
||
+ "#omni .ospacer{flex:1}"
|
||
+ "#omni .obody{padding:6px 18px 18px;overflow:auto}"
|
||
+ "#omni .omod{margin-top:14px}"
|
||
+ "#omni .omodh{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:rgba(150,170,205,.6);margin:0 0 6px;display:flex;align-items:center;gap:9px}"
|
||
+ "#omni .oknobs{display:flex;flex-wrap:wrap;gap:6px 2px}"
|
||
+ "#omni .okn{width:84px;display:flex;flex-direction:column;align-items:center;cursor:ns-resize;user-select:none;padding:4px 0;touch-action:none}"
|
||
+ "#omni .okn.note{cursor:default}"
|
||
+ "#omni .okn svg{width:64px;height:64px;display:block;overflow:visible}"
|
||
+ "#omni .okn .trk{fill:none;stroke:rgba(120,150,200,.18);stroke-width:6;stroke-linecap:round}"
|
||
+ "#omni .okn .val{fill:none;stroke-width:6;stroke-linecap:round;transition:stroke-dashoffset .09s linear}"
|
||
+ "#omni .okn .dot{opacity:0;transition:opacity .12s}"
|
||
+ "#omni .okn .dot.show{opacity:1}"
|
||
+ "#omni .okn .olbl{font-size:11px;color:#cdd9f0;margin-top:1px;max-width:82px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center}"
|
||
+ "#omni .oecho.off .oknobs{display:none}"
|
||
+ "#omni .oclose{cursor:pointer;color:#9fb4d8;font-size:19px;line-height:1;padding:0 2px}"
|
||
+ "#omni .ohint{font-size:11px;color:rgba(150,170,205,.5);font-style:italic;text-transform:none;letter-spacing:0}";
|
||
document.head.appendChild(st);
|
||
const w = document.createElement("div"); w.id = "omni"; this.root = w;
|
||
const head = document.createElement("div"); head.className = "ohead"; this.head = head;
|
||
const body = document.createElement("div"); body.className = "obody"; this.body = body;
|
||
w.appendChild(head); w.appendChild(body);
|
||
document.body.appendChild(w);
|
||
},
|
||
open() {
|
||
this.build(); this.renderHead(); this.render();
|
||
this.root.classList.add("on"); this._open = true;
|
||
const b = document.getElementById("omnibtn"); if (b) b.classList.add("on");
|
||
},
|
||
close() {
|
||
if (this.root) this.root.classList.remove("on");
|
||
this._open = false;
|
||
const b = document.getElementById("omnibtn"); if (b) b.classList.remove("on");
|
||
},
|
||
renderHead() {
|
||
const h = this.head; h.innerHTML = "";
|
||
const title = document.createElement("div"); title.className = "otitle"; title.textContent = "🎹 OMNI"; h.appendChild(title);
|
||
const engines = [ // the pluck's character (FM tine / string / GODSONIQ)
|
||
["FM tine", () => !Synth.stringVoice() && !Synth.sampling(), () => Synth.stringVoice(false)],
|
||
["🎸 string", () => Synth.stringVoice(), () => Synth.stringVoice(true)],
|
||
["🎛 GODSONIQ", () => Synth.sampling(), () => { const ok = Synth.godsoniq(3);
|
||
eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling 3s of the world… every pluck now replays it" : "🎛 press ♪ first, then GODSONIQ", tAdded: now(), src: "sys" }); }],
|
||
];
|
||
this._chips = []; // remembered so update() can re-light them (GODSONIQ lands ~3s async) (#8)
|
||
for (const [label, isOn, act] of engines) {
|
||
const c = document.createElement("div"); c.className = "ochip" + (isOn() ? " on" : ""); c.textContent = label;
|
||
c.onclick = () => { try { act(); } catch (e) {} setTimeout(() => this.renderHead(), 40); };
|
||
h.appendChild(c); this._chips.push([c, isOn]);
|
||
}
|
||
const sp = document.createElement("div"); sp.className = "ospacer"; h.appendChild(sp);
|
||
const snd = document.createElement("div"); snd.className = "ochip" + (Synth.playing && Synth.playing() ? " on" : ""); snd.textContent = "♪";
|
||
snd.title = "start / stop the synth"; snd.onclick = () => { Synth.toggle(); setTimeout(() => this.renderHead(), 60); }; h.appendChild(snd);
|
||
this._chips.push([snd, () => Synth.playing && Synth.playing()]);
|
||
const bpm = document.createElement("div"); bpm.className = "ochip"; bpm.id = "omnibpm"; bpm.style.cursor = "default"; bpm.textContent = Math.round(godtime.bpm) + " bpm"; h.appendChild(bpm);
|
||
const lock = document.createElement("div"); lock.className = "ochip" + (godtime.locked ? " on" : ""); lock.textContent = godtime.locked ? "🔒" : "🔓";
|
||
lock.title = "lock the godtime tempo"; lock.onclick = () => { godtime.locked = !godtime.locked; this.renderHead(); }; h.appendChild(lock);
|
||
const x = document.createElement("div"); x.className = "oclose"; x.textContent = "✕"; x.title = "close (O)"; x.onclick = () => this.close(); h.appendChild(x);
|
||
},
|
||
render() {
|
||
const b = this.body; b.innerHTML = ""; this.knobs = [];
|
||
for (const mod of this.MODULES) {
|
||
for (const key of mod.keys) if (!/note/.test(key.split(".").pop())) ensureDest(key); // register voices/FX (e.g. crush) so their arcs live + floors work
|
||
b.appendChild(this.moduleEl(mod, false));
|
||
}
|
||
const em = this.moduleEl(this.ECHO, true); // earth echo — knobs hidden until it's on
|
||
em.classList.add("oecho"); if (!earthEchoOn) em.classList.add("off");
|
||
b.appendChild(em);
|
||
},
|
||
moduleEl(mod, isEcho) {
|
||
const box = document.createElement("div"); box.className = "omod";
|
||
const hd = document.createElement("div"); hd.className = "omodh";
|
||
const t = document.createElement("span"); t.textContent = mod.title; hd.appendChild(t);
|
||
if (isEcho) {
|
||
const tog = document.createElement("div"); tog.className = "ochip" + (earthEchoOn ? " on" : ""); tog.textContent = earthEchoOn ? "on" : "off";
|
||
tog.style.cssText = "padding:2px 9px;font-size:10px";
|
||
tog.onclick = () => { earthEchoSet(!earthEchoOn); this.render(); };
|
||
hd.appendChild(tog);
|
||
const hint = document.createElement("span"); hint.className = "ohint"; hint.textContent = earthEchoOn ? "the whole mix, on tape" : "the whole mix on tape — turn it on"; hd.appendChild(hint);
|
||
}
|
||
box.appendChild(hd);
|
||
const knobs = document.createElement("div"); knobs.className = "oknobs";
|
||
for (const key of mod.keys) knobs.appendChild(this.knobEl(key, mod.accent));
|
||
box.appendChild(knobs);
|
||
return box;
|
||
},
|
||
knobEl(key, accent) {
|
||
const isNote = /note/.test(key.split(".").pop());
|
||
const col = "rgb(" + accent[0] + "," + accent[1] + "," + accent[2] + ")";
|
||
const wrap = document.createElement("div"); wrap.className = "okn" + (isNote ? " note" : ""); wrap.dataset.dest = key;
|
||
wrap.innerHTML =
|
||
'<svg viewBox="0 0 100 100">'
|
||
+ '<path class="trk" d="' + this.ARC + '"/>'
|
||
+ '<path class="val" d="' + this.ARC + '" pathLength="1000" stroke="' + col + '" stroke-dasharray="1000" stroke-dashoffset="1000"/>'
|
||
+ '<circle class="dot" r="4.5" fill="#fff"/>'
|
||
+ '</svg><div class="olbl">' + (this.LABELS[key] || prettyDest(key)) + '</div>';
|
||
wrap.title = isNote ? (this.LABELS[key] || key) + " — plays pitch; shape it with scale (right-click)"
|
||
: (this.LABELS[key] || key) + " — drag to shape · double-click to reset · right-click for the full menu";
|
||
const val = wrap.querySelector(".val"), dot = wrap.querySelector(".dot");
|
||
if (!isNote) {
|
||
let dragging = false, sy = 0, so = 0;
|
||
wrap.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; dragging = true; sy = e.clientY; so = ((tweaks[key] && tweaks[key].offset) || 0); try { wrap.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); });
|
||
wrap.addEventListener("pointermove", (e) => { if (!dragging) return; ungodlyGesture("trim"); setParamLocal(key, "offset", clamp(so + (sy - e.clientY) / 150, -1, 1)); });
|
||
const end = (e) => { dragging = false; try { wrap.releasePointerCapture(e.pointerId); } catch (x) {} if (zeroMode) zeroUI.persist(); }; // a knob shape is part of the zero build
|
||
wrap.addEventListener("pointerup", end); wrap.addEventListener("pointercancel", end);
|
||
wrap.ondblclick = () => { ungodlyMark("trim"); setParamLocal(key, "offset", 0); if (zeroMode) zeroUI.persist(); };
|
||
}
|
||
wrap.oncontextmenu = (e) => { e.preventDefault(); const n = dests.get(key); if (n) openCtxMenu(n, e.clientX, e.clientY); };
|
||
this.knobs.push({ key, val, dot, isNote });
|
||
return wrap;
|
||
},
|
||
update() {
|
||
if (!this._open) return;
|
||
const bpmEl = document.getElementById("omnibpm"); if (bpmEl) bpmEl.textContent = Math.round(godtime.bpm) + " bpm";
|
||
if (this._chips) for (const [c, isOn] of this._chips) c.classList.toggle("on", !!isOn()); // GODSONIQ/♪ light when their async state lands (#8)
|
||
for (const k of this.knobs) {
|
||
const n = dests.get(k.key);
|
||
const frac = n ? clamp(n.normDisp != null ? n.normDisp : (n.norm || 0), 0, 1) : 0;
|
||
k.val.style.strokeDashoffset = (1000 * (1 - frac)).toFixed(1);
|
||
if (!k.isNote) {
|
||
const off = (tweaks[k.key] && tweaks[k.key].offset) || 0; // read-only — don't create a neutral tweak just by rendering
|
||
const a = (135 + ((off + 1) / 2) * 270) * Math.PI / 180;
|
||
k.dot.setAttribute("cx", (50 + 34 * Math.cos(a)).toFixed(1));
|
||
k.dot.setAttribute("cy", (50 + 34 * Math.sin(a)).toFixed(1));
|
||
k.dot.classList.toggle("show", Math.abs(off) > 0.02);
|
||
}
|
||
}
|
||
},
|
||
};
|
||
|
||
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);
|
||
columnInfo = {
|
||
src: { x: leftX, gap: usableH / Math.max(1, srcList.length), list: srcList },
|
||
dst: { x: rightX, gap: usableH / Math.max(1, dstList.length), list: dstList },
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Hover zoom (the loupe) — a dense column (dozens of sources ~8px apart) is
|
||
// impossible to target one node in. On hover, magnify the ~9 nodes nearest
|
||
// the cursor into a readable, comfortably-spaced strip; each row is a live
|
||
// hit target (click to mute, ⇧-click for its tab, ⌘-click to group, drag onto
|
||
// a voice to patch) so precision is restored without moving the base layout.
|
||
// ---------------------------------------------------------------------------
|
||
let columnInfo = null;
|
||
const loupe = { on: true, active: false, side: null, y: 0, rows: [], frozen: false, panel: null };
|
||
try { loupe.on = localStorage.getItem("gs_loupe") !== "0"; } catch (e) {}
|
||
|
||
function updateLoupe(mx, my) {
|
||
if (!loupe.on || chakraMode || performMode || zenMode || !columnInfo
|
||
|| inspectEl.classList.contains("open") || arr.open) { loupe.active = false; return; }
|
||
if (loupe.frozen) return; // held still through a press
|
||
// once open, keep it open while the cursor is over the magnified panel, so
|
||
// you can reach across to a row without it vanishing under you
|
||
if (loupe.active && loupe.panel &&
|
||
mx >= loupe.panel.x0 - 10 && mx <= loupe.panel.x1 + 10 &&
|
||
my >= loupe.panel.y0 - 14 && my <= loupe.panel.y1 + 14) return;
|
||
let side = null;
|
||
for (const s of ["src", "dst"]) {
|
||
const c = columnInfo[s];
|
||
if (c && c.gap < 15 && c.list.length > 8 && Math.abs(mx - c.x) < 48) side = s;
|
||
}
|
||
if (!side) { loupe.active = false; return; }
|
||
loupe.side = side; loupe.active = true; loupe.y = my; // side is stable across relayouts
|
||
}
|
||
|
||
function loupeNodeAt(mx, my) {
|
||
if (!loupe.active) return null;
|
||
for (const row of loupe.rows)
|
||
if (mx >= row.x0 && mx <= row.x1 && my >= row.y0 && my <= row.y1) return row.node;
|
||
return null;
|
||
}
|
||
|
||
function drawLoupe(t) { // a draw helper must never be able to kill the render loop
|
||
try { drawLoupeInner(t); } catch (e) { loupe.active = false; loupe.rows = []; }
|
||
}
|
||
function drawLoupeInner(t) {
|
||
const col = loupe.active && columnInfo ? columnInfo[loupe.side] : null;
|
||
if (!loupe.active || !col || !col.list.length) { loupe.rows = []; return; }
|
||
const list = col.list;
|
||
let ci = 0, best = Infinity;
|
||
for (let i = 0; i < list.length; i++) {
|
||
const d = Math.abs((list[i].targetY != null ? list[i].targetY : list[i].y) - loupe.y);
|
||
if (d < best) { best = d; ci = i; }
|
||
}
|
||
const ROWH = 24, SHOW = Math.min(9, list.length), half = (SHOW - 1) / 2;
|
||
const i0 = clamp(ci - Math.round(half), 0, Math.max(0, list.length - SHOW));
|
||
const isSrc = loupe.side === "src";
|
||
const panelW = 208;
|
||
const panelX = isSrc ? col.x + 32 : col.x - 32 - panelW;
|
||
const cy = clamp(loupe.y, HEADER_H + SHOW * ROWH / 2 + 10, H - FOOTER_H - SHOW * ROWH / 2 - 10);
|
||
const topY = cy - SHOW * ROWH / 2;
|
||
ctx.save();
|
||
ctx.fillStyle = "rgba(9,14,24,0.95)";
|
||
ctx.fillRect(panelX, topY - 6, panelW, SHOW * ROWH + 12);
|
||
ctx.strokeStyle = "rgba(120,150,200,0.4)"; ctx.lineWidth = 1;
|
||
ctx.strokeRect(panelX, topY - 6, panelW, SHOW * ROWH + 12);
|
||
const rows = [];
|
||
for (let k = 0; k < SHOW; k++) {
|
||
const n = list[i0 + k]; if (!n) continue;
|
||
const ry = topY + k * ROWH, centered = (i0 + k) === ci;
|
||
const col2 = (n.kind === "source" ? n.col : (FAMILIES[n.family] || FAMILIES.other).col) || [150, 170, 200];
|
||
if (centered) { ctx.fillStyle = "rgba(90,130,230,0.22)"; ctx.fillRect(panelX + 3, ry + 2, panelW - 6, ROWH - 4); }
|
||
ctx.beginPath(); ctx.arc(panelX + 16, ry + ROWH / 2, 6 + (n.normDisp || 0) * 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(rgbMix(col2, 0.4), 0.9); ctx.fill();
|
||
if (n.muted) {
|
||
ctx.strokeStyle = "rgba(255,95,95,0.9)"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(panelX + 11, ry + ROWH / 2 - 5); ctx.lineTo(panelX + 21, ry + ROWH / 2 + 5); ctx.stroke();
|
||
}
|
||
ctx.textAlign = "left"; ctx.textBaseline = "middle";
|
||
ctx.font = (centered ? "600 " : "500 ") + "12px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = rgba(rgbMix(col2, centered ? 0.55 : 0.4), centered ? 0.98 : 0.82);
|
||
ctx.fillText(truncate(n.label, 22), panelX + 30, ry + ROWH / 2);
|
||
ctx.textAlign = "right"; ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,170,200,0.7)";
|
||
ctx.fillText(n.isNote ? midiToName(n.raw) : fmtRaw(n.rawDisp != null ? n.rawDisp : n.raw), panelX + panelW - 8, ry + ROWH / 2);
|
||
rows.push({ node: n, x0: panelX, y0: ry, x1: panelX + panelW, y1: ry + ROWH });
|
||
}
|
||
ctx.strokeStyle = "rgba(150,180,230,0.3)"; ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(col.x, loupe.y);
|
||
ctx.lineTo(panelX + (isSrc ? 0 : panelW), topY + (ci - i0) * ROWH + ROWH / 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
loupe.rows = rows;
|
||
loupe.panel = { x0: panelX, y0: topY - 6, x1: panelX + panelW, y1: topY + SHOW * ROWH + 6 };
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ---- 🗣 GODSPEAK — the voice reaches the client --------------------------
|
||
// The worker (Apple-Silicon-only) sends each transcript to the hub, which relays
|
||
// it as {godspeak:{text|rec}}. Here the word becomes action: a tight, forgiving
|
||
// grammar over the instrument. First match wins; a command that fires narrates
|
||
// itself into the ticker; unmatched words simply arrive in the world (🗣 "…") —
|
||
// never an error. Nothing here reaches the network; the voice only ever acts locally.
|
||
let godspeakChip = null;
|
||
function godspeakChipEnsure() {
|
||
if (godspeakChip) return godspeakChip;
|
||
godspeakChip = document.createElement("div");
|
||
godspeakChip.id = "godspeakrec"; godspeakChip.textContent = "🗣";
|
||
godspeakChip.title = "GODSPEAK — the instrument is listening";
|
||
document.body.appendChild(godspeakChip);
|
||
return godspeakChip;
|
||
}
|
||
function godspeakSay(text) { eventTicker.unshift({ text: "🗣 " + text, tAdded: now(), src: "sys" }); }
|
||
function handleGodspeak(g) {
|
||
if (!g) return;
|
||
if ("rec" in g) godspeakChipEnsure().classList.toggle("on", !!(+g.rec)); // pulse while the mic is held
|
||
if (typeof g.text === "string" && g.text.trim()) godspeakCommand(g.text);
|
||
}
|
||
// spoken → number, tuned for the tempo range. Digits win; else number-words, with
|
||
// "one twenty" → 120 (a leading single before a tens word reads as hundreds).
|
||
function spokenNumber(t) {
|
||
const dm = t.match(/\b(\d{1,3})\b/); if (dm) return parseInt(dm[1], 10);
|
||
const W = { zero:0, one:1, two:2, three:3, four:4, five:5, six:6, seven:7, eight:8, nine:9,
|
||
ten:10, eleven:11, twelve:12, thirteen:13, fourteen:14, fifteen:15, sixteen:16, seventeen:17,
|
||
eighteen:18, nineteen:19, twenty:20, thirty:30, forty:40, fourty:40, fifty:50, sixty:60,
|
||
seventy:70, eighty:80, ninety:90, hundred:100 };
|
||
let cur = 0, seen = false;
|
||
for (const tok of t.split(/[^a-z]+/)) {
|
||
if (tok === "a") { cur += 1; seen = true; continue; }
|
||
if (tok === "and") continue;
|
||
const v = W[tok]; if (v == null) continue; seen = true;
|
||
if (v === 100) cur = (cur || 1) * 100;
|
||
else if (v >= 20 && cur >= 1 && cur <= 9) cur = cur * 100 + v; // "one twenty" → 120
|
||
else cur += v;
|
||
}
|
||
return seen ? cur : null;
|
||
}
|
||
function godspeakBeat(on) { // mirror the beat panel's master ▶ — flip all five drum lanes
|
||
ungodlyMark("beat play");
|
||
if (on && !DRUM_VOICES.some(v => seqs["drum." + v])) seedBeat(); // a virgin session still makes music
|
||
for (const v of DRUM_VOICES) drumSeqFor("drum." + v).on = on;
|
||
if (beatRefresh) beatRefresh();
|
||
}
|
||
function matchMood(t) { // real MOODS keys only — never invent a name
|
||
const strong = { "the full storm": ["storm"], "ambient drift": ["ambient", "drift"], "first light": ["first light"] };
|
||
for (const name in MOODS) {
|
||
if (t.indexOf(name) >= 0) return name;
|
||
for (const s of (strong[name] || [])) if (t.indexOf(s) >= 0) return name;
|
||
}
|
||
if (t.indexOf("mood") >= 0) for (const name in MOODS)
|
||
for (const w of name.split(" ")) if (w.length >= 4 && t.indexOf(w) >= 0) return name;
|
||
return null;
|
||
}
|
||
function matchSkin(t) { // gated on the word "skin" — never hijack ordinary speech
|
||
if (t.indexOf("skin") < 0) return null;
|
||
const nrm = (x) => String(x).toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
||
for (const k in SKINS) { const s = SKINS[k];
|
||
for (const c of [k, nrm(s.label || ""), nrm(s.word || "")])
|
||
if (c && c.length >= 3 && t.indexOf(c) >= 0) return k; }
|
||
return null;
|
||
}
|
||
function godspeakCommand(raw) {
|
||
const t = String(raw).toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
||
if (!t) return;
|
||
const has = (w) => t.indexOf(w) >= 0;
|
||
if (has("take it back") || (has("undo") && !has("redo"))) { ungodlyUndo(); return; } // the voice of god, un-doing
|
||
if (has("tempo") || has("bpm")) {
|
||
const n = spokenNumber(t);
|
||
if (n != null) {
|
||
if (godtime.locked) godspeakSay("godtime is locked — unlock the dial first");
|
||
else { ungodlyMark("tempo"); godtime.bpm = clamp(Math.round(n), 40, 180); godspeakSay("tempo → " + godtime.bpm); }
|
||
return;
|
||
}
|
||
}
|
||
if (has("squash")) {
|
||
const n = spokenNumber(t);
|
||
if (n != null) { ensureDest("squash"); ungodlyMark("squash");
|
||
const val = clamp(n / 100, 0, 1); setParamLocal("squash", "offset", val);
|
||
godspeakSay("squash → " + Math.round(val * 100) + "%"); return; }
|
||
}
|
||
if (has("capture") || has("sample")) {
|
||
const n = spokenNumber(t), secs = (n && n >= 1 && n <= 10) ? n : 3;
|
||
const ok = Synth.amplerArm ? Synth.amplerArm(secs) : false;
|
||
godspeakSay(ok ? ("AMPLER — capturing " + secs + "s of the world") : "AMPLER needs the world sounding — press ♪ first");
|
||
return;
|
||
}
|
||
if (has("chop")) {
|
||
const n = (/\beight\b/.test(t) || /\b8\b/.test(t)) ? 8 : 16;
|
||
const ok = Synth.amplerChop ? Synth.amplerChop(n) : false;
|
||
if (ok && ampler.open) amplerRender();
|
||
godspeakSay(ok ? ("chopped into " + n) : "nothing to chop — capture first"); return;
|
||
}
|
||
if (has("open") || has("close") || has("show") || has("hide")) {
|
||
const closing = has("close") || has("hide");
|
||
const flip = (name, isOpen, open, close) => {
|
||
if (closing) { if (isOpen) close(); } else { if (!isOpen) open(); }
|
||
godspeakSay((closing ? "closed " : "opened ") + name);
|
||
};
|
||
if (has("beat")) { flip("beat", beat.open, openBeat, closeBeat); return; }
|
||
if (has("track")) { flip("tracks", arr.open, openArranger, closeArranger); return; }
|
||
if (has("omni")) { flip("OMNI", OMNI.isOpen(), () => OMNI.open(), () => OMNI.close()); return; }
|
||
if (has("ampler")) { flip("AMPLER", ampler.open, openAmpler, closeAmpler); return; }
|
||
if (has("key")) { flip("keys", keysEl.classList.contains("open"), openKeys, closeKeys); return; }
|
||
if (has("godspeed") || has("arp")) { flip("GODSPEED", godspeedOpen(), openGodspeed, closeGodspeed); return; }
|
||
}
|
||
if (has("beat") || has("drums")) {
|
||
if (has("stop") || has("pause")) { godspeakBeat(false); godspeakSay("beat stopped"); return; }
|
||
if (has("play") || has("start") || has("go")) { godspeakBeat(true); godspeakSay("beat playing"); return; }
|
||
}
|
||
{ const m = matchMood(t); if (m) { applyMood(m); godspeakSay("mood → " + m); return; } }
|
||
{ const k = matchSkin(t); if (k) { setSkin(k); godspeakSay("skin → " + (SKINS[k].label || k)); return; } }
|
||
if (/\bzen\b/.test(t)) { setZen(!zenMode); godspeakSay(zenMode ? "zen" : "left zen"); return; }
|
||
godspeakSay('"' + String(raw).trim() + '"'); // unmatched — the word arrives in the world
|
||
}
|
||
|
||
function handleMessage(msg) {
|
||
if (msg.commune) { handleCommune(msg.commune); return; } // a roommate speaking
|
||
if (msg.godspeak) { handleGodspeak(msg.godspeak); return; } // 🗣 the voice reaches the client
|
||
if (msg.hello) {
|
||
helloReceived = true;
|
||
if (Array.isArray(msg.routes)) {
|
||
preLayoutRoutes(msg.routes);
|
||
clientMatrix = true; // from here YOUR patch is computed locally
|
||
// greet with your last mood (default: the gentle "first light"). Applied
|
||
// once per page load — never on a mid-session reconnect, so it can't
|
||
// clobber what you're playing. A reload no longer drops you into the wall.
|
||
if (!moodAppliedThisLoad) {
|
||
moodAppliedThisLoad = true;
|
||
let firstEver = false, saved = "first light";
|
||
try { firstEver = !localStorage.getItem("gs_seen"); } catch (e) {}
|
||
try { saved = localStorage.getItem("gs_mood") || "first light"; } catch (e) {}
|
||
setTimeout(() => {
|
||
if (firstEver) {
|
||
try { localStorage.setItem("gs_seen", "1"); } catch (e) {}
|
||
showFirstVisitCard(); // three doors: zero / first light / storm
|
||
} else if (saved === "0 zero") {
|
||
enterZero(true); // a returning zero-builder comes back to zero, build intact (C2)
|
||
} else {
|
||
const _um0 = ungodly.muted; ungodly.muted = true; // ⏪ the reload-restore mood is not a user act
|
||
applyMood(saved); ungodly.muted = _um0;
|
||
}
|
||
try { // #vibe=GSV1… — a shared vibe in the URL; offer, never auto-apply (Z3)
|
||
const m = /[#&]vibe=([^&]+)/.exec(location.hash || "");
|
||
if (m) { const v = codeToVibe(decodeURIComponent(m[1])); if (v) setTimeout(() => offerVibeImport(v), 400);
|
||
history.replaceState(null, "", location.pathname + location.search); }
|
||
} catch (e) {}
|
||
}, 900);
|
||
}
|
||
}
|
||
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;
|
||
const hh = n.hist || (n.hist = []); // a couple of minutes of memory, for the sparkline
|
||
if (hh[hh.length - 1] !== s.raw || hh.length < 2) { hh.push(s.raw); if (hh.length > 120) hh.shift(); }
|
||
}
|
||
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;
|
||
if (zeroMode) zeroUI.update(); // Z1: breathe the vibe rings + drawer bars
|
||
if (OMNI._open) OMNI.update(); // Z5: live arcs + tweak dots on the synth panel
|
||
|
||
|
||
|
||
// 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);
|
||
|
||
// the groove clock — 16th notes on the godtime transport, with swing + humanize
|
||
seqPhase += dt * godtime.bpm / 60 * 4;
|
||
const rawStep = Math.floor(seqPhase) % 16;
|
||
if (rawStep !== seqRawStep) { // entered a new raw 16th — roll its onset
|
||
seqRawStep = rawStep;
|
||
const sw = (rawStep % 2) ? groove.swing * groove.amount * 0.5 : 0; // off-beats land late
|
||
seqStepOnset = Math.min(0.9, sw + groove.humanize * groove.amount * 0.35 * Math.random());
|
||
}
|
||
const frac = seqPhase - Math.floor(seqPhase);
|
||
const st16 = frac >= seqStepOnset ? rawStep : (rawStep + 15) % 16; // hold until the onset
|
||
if (st16 !== seqCurStep) { seqCurStep = st16; seqAbsStep++; seqTick(); } // seqAbsStep drives WHEELS' per-lane wrap
|
||
|
||
// the arrangement advances one column per bar
|
||
const barAbs = Math.floor(seqPhase / 16);
|
||
if (barAbs !== arrBarAbs) {
|
||
arrBarAbs = barAbs;
|
||
if (arr.playing) {
|
||
arr.bar = (arr.bar + 1) % arr.bars;
|
||
for (const key in arr.grid) { // follow actions divert this bar (else obey the grid)
|
||
const prev = arr.now[key] != null ? arr.now[key] : arr.grid[key][(arr.bar + arr.bars - 1) % arr.bars];
|
||
const nxt = (prev === "A" || prev === "B" || prev === "C") ? arrFollowNext(key, prev) : null;
|
||
arr.now[key] = nxt != null ? nxt : arr.grid[key][arr.bar];
|
||
}
|
||
applyArrColumn();
|
||
}
|
||
}
|
||
|
||
if (stageWin) stageFrame(dt); // the projector camera glides after its subject
|
||
|
||
// MIDI clock out — 24 ppqn (0xF8) whenever an out port is chosen, so a DAW
|
||
// can slave to godtime. (rAF-timed: fine for lights & loose sync; a
|
||
// lookahead scheduler is the upgrade path if tight sync ever matters.)
|
||
if (midiOut) {
|
||
midiClockPhase += dt * godtime.bpm / 60 * 24;
|
||
while (midiClockPhase >= 1) {
|
||
midiClockPhase -= 1;
|
||
try { midiOut.send([0xF8]); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
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);
|
||
if (skyWheel && !chakraMode && connected) { // the sky wheel, huge and faint behind everything (press W)
|
||
const r = Math.min(W, H) * 0.47;
|
||
drawWheelOverlay(t, null, { x0: W / 2 - r, x1: W / 2 + r, y0: H / 2 - r, y1: H / 2 + r });
|
||
}
|
||
if (!zeroMode) { // zero mode hides the node field — the ring UI replaces it
|
||
drawCables(t);
|
||
drawShockwaves(t, dt);
|
||
drawParticles(dt);
|
||
if (chakraMode) drawChakraView(t);
|
||
else for (const n of sources.values()) drawSourceNode(n, t);
|
||
drawAspectCables(t); // the sky's geometry, drawn between its spheres
|
||
for (const n of dests.values()) drawDestNode(n, t);
|
||
if (!chakraMode) drawLoupe(t); // the hover zoom over a dense column
|
||
drawEditing(t);
|
||
}
|
||
if (performMode && !zenMode) drawPerform(t, dt);
|
||
if (!zenMode && !performMode) drawHeader(t); // performance: no wordmark, no counts
|
||
if (connected && !zenMode && !performMode) drawGodtime(t);
|
||
if (!zenMode && !performMode) drawFooter(t); // performance: no ticker
|
||
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 = activeSkin.scaleOf(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;
|
||
const v = vel == null ? 0.85 : vel;
|
||
if (arp.on) { arpLatch(mindKeys.notes[i], v); return; } // ⚡ GODSPEED — latch the note; the clock plays it (MIDI-in routes here too, so hardware latches for free)
|
||
Synth.pluck(mindKeys.notes[i], v);
|
||
midiPluck(2, mindKeys.notes[i], v, 320); // the console has its own channel
|
||
}
|
||
|
||
function chakraOf(key) {
|
||
const k = (key || "").toLowerCase();
|
||
const p = k.split(".")[0];
|
||
if (p === "body") return "heart"; // your pulse at anahata
|
||
if (p === "natal") return "eye"; // your own sky, seen
|
||
if (p === "syn") return "heart"; // the bond lives at the heart
|
||
// 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","me","sport"].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";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Skins — the same 7-station spine read through a different lens. "chakra" is
|
||
// the subtle body; "planes" (registered below) is the Bailey / D.K. map of
|
||
// consciousness. A skin is only: 7 stations (top->bottom), a source->station
|
||
// mapper, a per-station {root,scale}, and optional grouping brackets. The
|
||
// spine, orbits, mind mode, the vector console and trails are all shared.
|
||
// ---------------------------------------------------------------------------
|
||
// ---- the Bailey / D.K. "planes of consciousness" skin -----------------------
|
||
// The Seven Planes & 49 sub-planes of the Cosmic Physical Plane (Alice Bailey /
|
||
// Djwhal Khul). Spec cross-checked against the Theosophical corpus. Same spine,
|
||
// read as the ladder of consciousness: I Divine (white light) down to VII
|
||
// Physical (red). Colours follow the descending spectrum of the user's chart.
|
||
const BAILEY = [ // I → VII, most rarefied → densest
|
||
{ key: "divine", label: "I · divine · adi", petals: 1, col: [238, 240, 250] },
|
||
{ key: "monadic", label: "II · monadic · anupadaka", petals: 3, col: [150, 80, 255] },
|
||
{ key: "atmic", label: "III · spiritual · atmic", petals: 5, col: [70, 110, 255] },
|
||
{ key: "buddhic", label: "IV · intuitional · buddhic", petals: 8, col: [40, 220, 200] },
|
||
{ key: "mental", label: "V · mental · egoic lotus", petals: 12, col: [240, 225, 70] },
|
||
{ key: "astral", label: "VI · emotional · astral", petals: 10, col: [255, 150, 60] },
|
||
{ key: "physical", label: "VII · physical · sthula", petals: 7, col: [255, 85, 85] },
|
||
];
|
||
const BAILEY_BY_KEY = {};
|
||
for (const c of BAILEY) BAILEY_BY_KEY[c.key] = c;
|
||
|
||
const BAILEY_SCALES = { // higher planes airier & higher-pitched
|
||
divine: { root: 84, scale: "wholetone" },
|
||
monadic: { root: 77, scale: "lydian" },
|
||
atmic: { root: 72, scale: "pentatonic" },
|
||
buddhic: { root: 65, scale: "major" },
|
||
mental: { root: 60, scale: "mixolydian" },
|
||
astral: { root: 53, scale: "dorian" },
|
||
physical: { root: 45, scale: "minor_pent" },
|
||
};
|
||
|
||
// the three side-bracket groupings from the chart (station indices, 0-based;
|
||
// Mental sits in both — its abstract mind is Triad, its concrete mind Personality)
|
||
const BAILEY_GROUPS = [
|
||
{ name: "MONAD", from: 1, to: 1 },
|
||
{ name: "SPIRITUAL TRIAD", from: 2, to: 4 },
|
||
{ name: "PERSONALITY", from: 4, to: 6 },
|
||
];
|
||
|
||
// source → plane by "rarefaction", per the verified mapping
|
||
function baileyOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "sun" && !/elev/.test(k)) return "divine"; // I · the star's engine
|
||
if (p === "iss" || p === "sky" || /_lon/.test(k) || // II · celestial machinery
|
||
/^astro\.(saturn|mars|jupiter|venus|mercury)$/.test(k)) return "monadic";
|
||
if (k === "astro.moon" || /zodiac|season/.test(k) || // III · the great cyclic wheels
|
||
k === "almanac.dec" || k === "almanac.element") return "atmic";
|
||
if (p === "natal" || p === "syn") return "buddhic"; // omen; and the plane of at-one-ment
|
||
if (p === "astro" || /aspect|harmon|tension|retro|fertil|aurora/.test(k)) return "buddhic"; // IV · omen & aspect
|
||
if (["wiki","net","news","social","edit","econ","debt"].includes(p) || // V · abstract thought & money-as-idea
|
||
/inflation/.test(k) || k === "market.turnover" || k === "crypto.price") return "mental";
|
||
if (/vel$|velocity/.test(k) || // VI · desire, fire, felt sensation
|
||
["fire","crypto","fx","market","light","ambient","audio","mic","me","sport"].includes(p)) return "astral";
|
||
return "physical"; // VII · the dense body of the world
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The wider skins — one Source, many Spirits, one Matter. Each tradition is
|
||
// rendered from its own published lore, literally and without frills. Spec
|
||
// sheets researched + cross-checked against the canonical sources.
|
||
// pos = [x,y] in the map frame (maps stand as their true figures, not spines).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// THE TREE OF LIFE — Hermetic Qabalah (Golden Dawn attributions, stated as
|
||
// such). Three pillars: Mercy right, Severity left, Equilibrium the middle.
|
||
// The 22 paths below are the canonical set, all of them, none invented.
|
||
const TREE = [
|
||
{ key: "keter", label: "כתר keter · crown", petals: 1, col: [255, 255, 255], pos: [0.50, 0.00] },
|
||
{ key: "chokmah", label: "חכמה chokmah · wisdom", petals: 2, col: [150, 154, 160], pos: [0.84, 0.12] },
|
||
{ key: "binah", label: "בינה binah · understanding", petals: 3, col: [70, 66, 92], pos: [0.16, 0.12] },
|
||
{ key: "chesed", label: "חסד chesed · mercy", petals: 4, col: [60, 110, 230], pos: [0.84, 0.38] },
|
||
{ key: "gevurah", label: "גבורה gevurah · severity", petals: 5, col: [210, 40, 40], pos: [0.16, 0.38] },
|
||
{ key: "tiferet", label: "תפארת tiferet · beauty", petals: 6, col: [255, 210, 60], pos: [0.50, 0.50] },
|
||
{ key: "netzach", label: "נצח netzach · victory", petals: 7, col: [40, 190, 100], pos: [0.84, 0.64] },
|
||
{ key: "hod", label: "הוד hod · splendour", petals: 8, col: [240, 140, 40], pos: [0.16, 0.64] },
|
||
{ key: "yesod", label: "יסוד yesod · foundation", petals: 9, col: [140, 70, 220], pos: [0.50, 0.78] },
|
||
{ key: "malkut", label: "מלכות malkut · kingdom", petals: 10, col: [140, 120, 60], pos: [0.50, 0.98] },
|
||
];
|
||
const TREE_PATHS = [[0,1],[0,2],[0,5],[1,2],[1,5],[1,3],[2,5],[2,4],[3,4],[3,5],[3,6],
|
||
[4,5],[4,7],[5,6],[5,7],[5,8],[6,7],[6,8],[6,9],[7,8],[7,9],[8,9]];
|
||
const TREE_SCALES = {
|
||
keter: { root: 72, scale: "wholetone" }, chokmah: { root: 64, scale: "pentatonic" },
|
||
binah: { root: 60, scale: "minor" }, chesed: { root: 57, scale: "lydian" },
|
||
gevurah: { root: 55, scale: "phrygian" }, tiferet: { root: 53, scale: "major" },
|
||
netzach: { root: 48, scale: "dorian" }, hod: { root: 46, scale: "mixolydian" },
|
||
yesod: { root: 41, scale: "phrygian" }, malkut: { root: 36, scale: "minor" },
|
||
};
|
||
function treeOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "body") return "tiferet"; // the heart at the Tree's solar centre
|
||
if (p === "natal") return "tiferet"; // your own sky at the centre of the self
|
||
if (p === "syn") return "netzach"; // Venus holds the bond
|
||
if (k === "sun.speed") return "keter"; // the first swirlings
|
||
if (/sun_lon|zodiac/.test(k) || p === "iss" || k === "clock.births" || k === "clock.popvel") return "chokmah";
|
||
if (k === "astro.saturn" || p === "debt" || /inflation/.test(k)) return "binah"; // Saturn: time, weight, form
|
||
if (k === "world.lifeexp" || k === "world.poverty" || k === "astro.harmony") return "chesed";
|
||
if (k === "astro.mars" || p === "fire" || k === "quake.event" || k === "astro.tension" || k === "world.hunger") return "gevurah";
|
||
if (/^sky\./.test(k)) return "tiferet"; // the solar heart
|
||
if (p === "sport") return "netzach"; // Netzach is Victory — the games live here
|
||
if (/season|almanac\.(element|fertile)/.test(k)) return "netzach"; // Venus: nature's rhythms
|
||
if (["wiki","market","crypto","fx","planes","econ","me"].includes(p) || /retro/.test(k)) return "hod"; // Mercury: mind & number
|
||
if (/^astro\.moon|almanac\.dec|ambient|light\./.test(k)) return "yesod"; // Levanah: the astral machinery
|
||
return "malkut"; // the Kingdom: body & world
|
||
}
|
||
|
||
// THE LODGE — Craft Freemasonry, from published sources only (Mackey,
|
||
// Duncan's): the three principal officers, the two pillars of the porch, the
|
||
// Lesser Lights' triangle, the Blazing Star on the mosaic pavement, the VSL.
|
||
// East at top. The player's own hands stand at the porch, between the pillars.
|
||
const LODGE = [
|
||
{ key: "east", label: "the east · worshipful master · wisdom", petals: 3, col: [255, 204, 60], pos: [0.50, 0.02] },
|
||
{ key: "west", label: "the west · senior warden · strength", petals: 3, col: [205, 100, 30], pos: [0.50, 0.74] },
|
||
{ key: "south", label: "the south · junior warden · beauty", petals: 3, col: [255, 238, 150], pos: [0.90, 0.38] },
|
||
{ key: "north", label: "the north · place of darkness", petals: 3, col: [55, 62, 95], pos: [0.10, 0.38] },
|
||
{ key: "boaz", label: "boaz · strength", petals: 2, col: [176, 141, 87], pos: [0.32, 0.96] },
|
||
{ key: "jachin", label: "jachin · establishment", petals: 2, col: [205, 175, 115], pos: [0.68, 0.96] },
|
||
{ key: "star", label: "the blazing star", petals: 5, col: [255, 250, 220], pos: [0.50, 0.30] },
|
||
{ key: "altar", label: "the altar · volume of the sacred law", petals: 3, col: [240, 240, 245], pos: [0.50, 0.46] },
|
||
{ key: "pavement", label: "the mosaic pavement", petals: 4, col: [150, 150, 155], pos: [0.50, 0.60] },
|
||
];
|
||
const LODGE_PATHS = [[0,2],[2,1],[1,3],[3,0],[4,5],[6,7],[7,8]]; // the square of the lodge, the porch lintel, star->altar->pavement
|
||
const LODGE_SCALES = {
|
||
east: { root: 60, scale: "major" }, west: { root: 50, scale: "dorian" },
|
||
south: { root: 55, scale: "mixolydian" }, north: { root: 48, scale: "phrygian" },
|
||
boaz: { root: 52, scale: "minor" }, jachin: { root: 53, scale: "minor" },
|
||
star: { root: 72, scale: "lydian" }, altar: { root: 64, scale: "lydian" },
|
||
pavement: { root: 57, scale: "wholetone" },
|
||
};
|
||
function lodgeOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "natal") return "east"; // the heavens, read for you
|
||
if (p === "syn") return "pavement"; // two squares, one floor
|
||
if (p === "body") return "altar"; // your living heart, laid at the centre
|
||
if (p === "hand" || p === "tof" || p === "touch") // the candidate at the porch
|
||
return /\.x$|open/.test(k) ? "boaz" : "jachin";
|
||
if (/^sky\.|sun_lon|_lon|zodiac/.test(k) || k === "astro.saturn" || k === "astro.mars") return "east"; // the heavens, read from the East
|
||
if (["crypto","fx","market","econ","debt","me"].includes(p)) return "west"; // the wages, paid in the West
|
||
if (p === "audio" || /season|almanac|lifeexp|births/.test(k)) return "south"; // Beauty holds music & the arts
|
||
if (/deaths|hunger|poverty|quake|pm25|aqi/.test(k)) return "north"; // what waits in the dark
|
||
if (k === "sun.speed" || k === "astro.harmony" || k === "almanac.fertile") return "star"; // Providence
|
||
if (p === "wiki") return "altar"; // humanity's open book
|
||
if (/tension|light\.|ambient/.test(k)) return "pavement"; // the chequered floor: light & dark
|
||
return "pavement";
|
||
}
|
||
|
||
// THE LADDER OF THE SPHERES — Hermetic ascent, Moon lowest to Saturn highest,
|
||
// each gate its classical metal. As above, so below.
|
||
const LADDER = [
|
||
{ key: "saturn", label: "♄ saturn · lead gate · the threshold", petals: 7, col: [95, 90, 110], },
|
||
{ key: "jupiter", label: "♃ jupiter · tin gate", petals: 6, col: [70, 110, 200], },
|
||
{ key: "mars", label: "♂ mars · iron gate", petals: 5, col: [190, 60, 48], },
|
||
{ key: "sun", label: "☉ sol · gold gate · the heart", petals: 4, col: [246, 200, 60], },
|
||
{ key: "venus", label: "♀ venus · copper gate", petals: 3, col: [100, 190, 130], },
|
||
{ key: "mercury", label: "☿ mercury · quicksilver gate", petals: 2, col: [200, 176, 130], },
|
||
{ key: "moon", label: "☽ luna · silver gate", petals: 1, col: [225, 230, 240], },
|
||
];
|
||
const LADDER_SCALES = {
|
||
saturn: { root: 69, scale: "wholetone" }, jupiter: { root: 65, scale: "mixolydian" },
|
||
mars: { root: 62, scale: "phrygian" }, sun: { root: 60, scale: "major" },
|
||
venus: { root: 55, scale: "lydian" }, mercury: { root: 52, scale: "dorian" },
|
||
moon: { root: 48, scale: "minor" },
|
||
};
|
||
function ladderOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "body") return "sun"; // the heart at Sol's gate
|
||
if (p === "natal") return "sun"; // your chart at the heart gate
|
||
if (p === "syn") return "venus"; // the copper gate holds two
|
||
if (k === "astro.saturn" || p === "debt" || /inflation|poverty|hunger|zodiac/.test(k)) return "saturn";
|
||
if (/lifeexp|fertile|harmony|births|popvel/.test(k)) return "jupiter";
|
||
if (k === "astro.mars" || p === "fire" || /tension|quake\.event|deaths/.test(k)) return "mars";
|
||
if (k === "sun.speed" || /^sky\./.test(k)) return "sun";
|
||
if (/season|almanac\.element|centroid/.test(k)) return "venus";
|
||
if (["wiki","market","crypto","fx","planes","iss","me"].includes(p) || /retro/.test(k)) return "mercury";
|
||
return "moon"; // the sublunary world of flux
|
||
}
|
||
|
||
// YGGDRASIL — the nine worlds on the world-tree; the classic three-tier
|
||
// arrangement (a scholarly reconstruction, as the Eddas never draw one map).
|
||
const NINE = [
|
||
{ key: "asgard", label: "ásgarðr · the aesir", petals: 8, col: [236, 223, 140] },
|
||
{ key: "vanaheim", label: "vanaheimr · the vanir", petals: 6, col: [86, 196, 150] },
|
||
{ key: "alfheim", label: "álfheimr · the light elves", petals: 6, col: [214, 236, 255] },
|
||
{ key: "midgard", label: "miðgarðr · humankind", petals: 4, col: [110, 170, 110] },
|
||
{ key: "jotunheim", label: "jötunheimr · the giants", petals: 5, col: [120, 130, 148] },
|
||
{ key: "nidavellir", label: "niðavellir · the dwarves", petals: 4, col: [150, 116, 82] },
|
||
{ key: "muspelheim", label: "múspellsheimr · fire", petals: 7, col: [255, 96, 52] },
|
||
{ key: "niflheim", label: "niflheimr · ice & mist", petals: 7, col: [150, 196, 230] },
|
||
{ key: "helheim", label: "helheimr · the dead", petals: 3, col: [110, 90, 125] },
|
||
];
|
||
const NINE_SCALES = {
|
||
asgard: { root: 62, scale: "lydian" }, vanaheim: { root: 57, scale: "mixolydian" },
|
||
alfheim: { root: 55, scale: "lydian" }, midgard: { root: 50, scale: "major" },
|
||
jotunheim:{ root: 47, scale: "phrygian" }, nidavellir: { root: 45, scale: "dorian" },
|
||
muspelheim:{ root: 43, scale: "minor_pent" }, niflheim: { root: 40, scale: "minor" },
|
||
helheim: { root: 36, scale: "phrygian" },
|
||
};
|
||
function nineOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "natal") return "asgard";
|
||
if (p === "syn") return "vanaheim"; // Freyja's house
|
||
if (p === "sun" || p === "sky" || p === "iss" || p === "astro" || /zodiac/.test(k)) return "asgard";
|
||
if (/fertile|season|births|almanac/.test(k)) return "vanaheim";
|
||
if (/ambient|light\.|harmony/.test(k)) return "alfheim";
|
||
if (p === "wiki" || p === "hand" || p === "planes" || /lifeexp|popvel/.test(k)) return "midgard";
|
||
if (/wind|pressure|tension|quake\.event/.test(k)) return "jotunheim"; // the storm-giants
|
||
if (["crypto","fx","market","debt","econ","me"].includes(p)) return "nidavellir"; // the dwarves' gold & its curse
|
||
if (p === "fire" || k === "weather.temp") return "muspelheim";
|
||
if (/pm25|aqi/.test(k)) return "niflheim"; // the poison-mists
|
||
if (/deaths|hunger|poverty|quake\.depth/.test(k)) return "helheim";
|
||
return "midgard";
|
||
}
|
||
|
||
// LATA'IF-E-SITTA — the six subtle centres of the Sufi heart (Naqshbandi
|
||
// tabulation, colours per Sirhindi's school). Ruh is spirit-as-breath.
|
||
const LATAIF = [
|
||
{ key: "akhfa", label: "أخفى akhfa · the most hidden", petals: 6, col: [70, 175, 90] },
|
||
{ key: "khafi", label: "خفي khafi · the hidden", petals: 5, col: [90, 85, 100] },
|
||
{ key: "sirr", label: "سر sirr · the secret", petals: 4, col: [240, 240, 245] },
|
||
{ key: "ruh", label: "روح ruh · the spirit", petals: 3, col: [225, 60, 66] },
|
||
{ key: "qalb", label: "قلب qalb · the heart", petals: 2, col: [235, 205, 60] },
|
||
{ key: "nafs", label: "نفس nafs · the self", petals: 7, col: [70, 110, 220] },
|
||
];
|
||
const LATAIF_SCALES = {
|
||
akhfa: { root: 72, scale: "lydian" }, khafi: { root: 69, scale: "phrygian" },
|
||
sirr: { root: 67, scale: "wholetone" }, ruh: { root: 64, scale: "major" },
|
||
qalb: { root: 60, scale: "dorian" }, nafs: { root: 55, scale: "minor" },
|
||
};
|
||
function lataifOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "body" || p === "syn") return "qalb"; // Qalb IS the heart — and holds two
|
||
if (p === "natal") return "sirr"; // the secret — your hidden sky
|
||
if (k === "sun.speed") return "akhfa"; // the most rarefied
|
||
if (p === "astro" || /zodiac|almanac/.test(k)) return "khafi"; // the hidden influences
|
||
if (/^sky\.|_lon/.test(k)) return "sirr"; // the secret geometry above
|
||
if (/wind|^air\.|^planes|iss/.test(k)) return "ruh"; // ruh IS breath — the moving air
|
||
if (p === "wiki" || p === "clock" || /^world\./.test(k)) return "qalb"; // the heart holds humanity
|
||
return "nafs"; // appetite, money, fire, the body
|
||
}
|
||
|
||
// WU XING — the Five Phases; outer pentagon = the generating (sheng) cycle,
|
||
// inner pentagram = the controlling (ke) cycle. South (Fire) at top, per the
|
||
// Chinese convention. Planets in their classical phase correspondences.
|
||
const WUXING = [
|
||
{ key: "wood", label: "wood 木 · spring · jupiter", petals: 8, col: [70, 180, 95], pos: [0.06, 0.36] },
|
||
{ key: "fire", label: "fire 火 · summer · mars", petals: 7, col: [225, 55, 48], pos: [0.50, 0.02] },
|
||
{ key: "earth", label: "earth 土 · centre · saturn", petals: 5, col: [212, 178, 62], pos: [0.94, 0.36] },
|
||
{ key: "metal", label: "metal 金 · autumn · venus", petals: 4, col: [225, 226, 232], pos: [0.78, 0.92] },
|
||
{ key: "water", label: "water 水 · winter · mercury", petals: 6, col: [65, 90, 160], pos: [0.22, 0.92] },
|
||
];
|
||
const WUXING_PATHS = [[0,1],[1,2],[2,3],[3,4],[4,0]]; // sheng — the generating ring
|
||
const WUXING_PATHS2 = [[0,2],[2,4],[4,1],[1,3],[3,0]]; // ke — the controlling star
|
||
const WUXING_SCALES = {
|
||
wood: { root: 57, scale: "dorian" }, fire: { root: 62, scale: "mixolydian" },
|
||
earth: { root: 65, scale: "major" }, metal: { root: 69, scale: "minor_pent" },
|
||
water: { root: 74, scale: "pentatonic" },
|
||
};
|
||
function wuxingOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "body") return "fire"; // the heart is the fire-organ (TCM)
|
||
if (p === "natal" || p === "syn") return "water"; // fate and bond flow in the deep
|
||
if (/births|popvel|season|fertile|lifeexp|wind|harmony|almanac\.element/.test(k)) return "wood";
|
||
if (p === "fire" || /temp|mars|tension|ambient|light\.|sky\./.test(k) || k === "sun.speed") return "fire";
|
||
if (/saturn|quake|hunger|poverty|pressure|debt\.total/.test(k)) return "earth";
|
||
if (["market","fx","planes","audio","me"].includes(p) || /inflation|crypto\.price|debt\.vel/.test(k)) return "metal"; // coin, bells, metal birds
|
||
return "water"; // the deep: moon, wisdom, the word
|
||
}
|
||
|
||
// THE SPREAD — the Celtic Cross (Waite's Pictorial Key, 1910), dealt live and
|
||
// never finished: the ten positions as stations, the world's data as the
|
||
// cards that keep turning. The cross at the left, the staff ascending at the
|
||
// right. The player's own hands are the Querent; the retrogrades sit in
|
||
// "what is behind you".
|
||
const SPREAD = [
|
||
{ key: "present", label: "I · what covers you · the present", petals: 1, col: [255, 214, 90], pos: [0.30, 0.50] },
|
||
{ key: "crossing", label: "II · what crosses you · the obstacle",petals: 2, col: [200, 70, 60], pos: [0.44, 0.58] },
|
||
{ key: "crown", label: "III · what crowns you · the aim", petals: 3, col: [235, 235, 210], pos: [0.30, 0.14] },
|
||
{ key: "foundation", label: "IV · what is beneath you", petals: 4, col: [140, 110, 70], pos: [0.30, 0.86] },
|
||
{ key: "behind", label: "V · what is behind you · the past", petals: 5, col: [150, 120, 190], pos: [0.08, 0.50] },
|
||
{ key: "before", label: "VI · what is before you", petals: 6, col: [110, 200, 130], pos: [0.56, 0.42] },
|
||
{ key: "querent", label: "VII · the querent · you", petals: 7, col: [255, 170, 140], pos: [0.88, 0.88] },
|
||
{ key: "house", label: "VIII · your house · the environment", petals: 8, col: [90, 140, 230], pos: [0.88, 0.62] },
|
||
{ key: "hopes", label: "IX · your hopes and fears", petals: 9, col: [210, 190, 240], pos: [0.88, 0.36] },
|
||
{ key: "outcome", label: "X · what will come · the outcome", petals: 10, col: [250, 250, 255], pos: [0.88, 0.10] },
|
||
];
|
||
const SPREAD_PATHS = [[0,1],[0,2],[0,3],[0,4],[0,5],[6,7],[7,8],[8,9]]; // the cross, and the staff ascending
|
||
const SPREAD_SCALES = {
|
||
present: { root: 60, scale: "major" }, crossing: { root: 58, scale: "phrygian" },
|
||
crown: { root: 67, scale: "lydian" }, foundation:{ root: 41, scale: "minor" },
|
||
behind: { root: 45, scale: "phrygian" }, before: { root: 62, scale: "dorian" },
|
||
querent: { root: 48, scale: "dorian" }, house: { root: 52, scale: "mixolydian" },
|
||
hopes: { root: 55, scale: "lydian" }, outcome: { root: 72, scale: "wholetone" },
|
||
};
|
||
function spreadOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "hand" || p === "tof" || p === "touch" || p === "body") return "querent"; // you are the one asking
|
||
if (p === "natal") return "crown"; // your stars, above you
|
||
if (p === "syn") return "house"; // the other, around you
|
||
if (/retro/.test(k) || k === "clock.deaths") return "behind"; // what recedes, what looks backward
|
||
if (/^sky\.|sun\.|_lon|zodiac/.test(k) || p === "iss") return "crown"; // what is above you
|
||
if (/quake\.depth|debt\.total|saturn|poverty/.test(k)) return "foundation"; // the deep structure beneath
|
||
if (/tension|quake\.event|pm25|aqi|inflation|hunger/.test(k)) return "crossing"; // what stands in the way
|
||
if (/births|popvel|season|fertile|almanac/.test(k)) return "before"; // what approaches
|
||
if (["crypto","fx","market","fire","me"].includes(p) || k === "debt.vel") return "hopes"; // money & fire: hope and fear at once
|
||
if (p === "weather" || p === "planes" || p === "wiki") return "house"; // the world around you, talking
|
||
if (/lifeexp|astro\.moon|harmony/.test(k)) return "outcome"; // the culminating light
|
||
return "present"; // what covers you right now
|
||
}
|
||
|
||
// THE WHEEL — the astrologer's own view: twelve signs in a circle, 0° Aries
|
||
// at the ascendant's place (left), longitudes running counterclockwise. The
|
||
// live planets ride the rim; a loaded natal chart sits on the inner ring;
|
||
// aspects draw as chords — red the hard, green the soft, gold the meetings.
|
||
const SIGNS = [
|
||
{ key: "aries", label: "♈ aries", petals: 1, col: [255, 90, 70] },
|
||
{ key: "taurus", label: "♉ taurus", petals: 2, col: [110, 200, 110] },
|
||
{ key: "gemini", label: "♊ gemini", petals: 3, col: [255, 220, 120] },
|
||
{ key: "cancer", label: "♋ cancer", petals: 4, col: [180, 200, 230] },
|
||
{ key: "leo", label: "♌ leo", petals: 5, col: [255, 190, 60] },
|
||
{ key: "virgo", label: "♍ virgo", petals: 6, col: [180, 220, 160] },
|
||
{ key: "libra", label: "♎ libra", petals: 7, col: [230, 180, 220] },
|
||
{ key: "scorpio", label: "♏ scorpio", petals: 8, col: [190, 70, 100] },
|
||
{ key: "sagittarius", label: "♐ sagittarius", petals: 9, col: [220, 130, 255] },
|
||
{ key: "capricorn", label: "♑ capricorn", petals: 10, col: [160, 150, 130] },
|
||
{ key: "aquarius", label: "♒ aquarius", petals: 11, col: [110, 210, 240] },
|
||
{ key: "pisces", label: "♓ pisces", petals: 12, col: [140, 160, 255] },
|
||
];
|
||
SIGNS.forEach((s2, i) => {
|
||
const a2 = (180 + i * 30 + 15) * Math.PI / 180; // sign midpoints, Aries left, CCW
|
||
s2.pos = [0.5 + 0.45 * Math.cos(a2), 0.5 - 0.45 * Math.sin(a2)];
|
||
});
|
||
const SIGN_SCALES = {};
|
||
SIGNS.forEach((s2, i) => { // circle of fifths up the zodiac,
|
||
SIGN_SCALES[s2.key] = { // scales by element
|
||
root: 48 + (i * 7) % 12,
|
||
scale: ["mixolydian", "minor_pent", "lydian", "dorian"][i % 4],
|
||
};
|
||
});
|
||
function zodiacOf(key) { // by traditional rulership/affinity
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "fire") return "aries"; // Mars burns
|
||
if (p === "me" || p === "fx" || k === "market.turnover" || k === "crypto.price") return "taurus"; // holdings & value: the second house
|
||
if (p === "wiki" || /mercury_retro/.test(k)) return "gemini"; // the word; Mercury's own condition
|
||
if (p === "weather" || k === "almanac.dec") return "cancer"; // the home sky
|
||
if (p === "sun" || p === "sport" || /^sky\.|light|ambient/.test(k)) return "leo"; // the light itself; the fifth house holds the games
|
||
if (/aqi|pm25|lifeexp/.test(k)) return "virgo"; // health
|
||
if (p === "syn" || /harmony|season/.test(k)) return "libra"; // partnership; balance, equinox
|
||
if (/deaths|debt|tension/.test(k)) return "scorpio"; // the deep and the owed
|
||
if (p === "planes" || p === "iss" || /inflation/.test(k)) return "sagittarius"; // far travel; Jupiterian expansion
|
||
if (/quake|hunger|poverty/.test(k)) return "capricorn"; // Saturn: structure, mountains, want
|
||
if (p === "crypto" || /popvel|births|zodiac/.test(k)) return "aquarius"; // the collective
|
||
if (p === "natal") return natalActive && natalActive.asc != null
|
||
? SIGNS[Math.floor(natalActive.asc / 30)].key : "aries"; // your rising sign
|
||
if (["hand", "tof", "touch", "body"].includes(p))
|
||
return natalActive ? SIGNS[Math.floor(natalActive.pos.sun / 30)].key : "leo"; // your sun sign
|
||
return "pisces"; // the dream-tide
|
||
}
|
||
function rezodiac() { // the planets walk the signs live
|
||
for (const gp of GT_PLANETS) {
|
||
const n = sources.get(gp[0]);
|
||
if (n && typeof n.raw === "number") n.station = SIGNS[Math.floor((((n.raw % 360) + 360) % 360) / 30)].key;
|
||
}
|
||
}
|
||
function drawWheelOverlay(t, g, layerBox) { // layerBox: drawn as a main-screen layer (press W)
|
||
const box = layerBox || mapBox(g);
|
||
const cx = (box.x0 + box.x1) / 2, cy = (box.y0 + box.y1) / 2;
|
||
const R = Math.min(box.x1 - box.x0, box.y1 - box.y0) * 0.5 - 4;
|
||
const lonXY = (lon, r) => {
|
||
const a2 = (180 + lon) * Math.PI / 180;
|
||
return [cx + r * Math.cos(a2), cy - r * Math.sin(a2)];
|
||
};
|
||
ctx.save();
|
||
glyphPass = true; // the wheel's glyphs survive zen
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
ctx.strokeStyle = "rgba(200,212,240,0.20)"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(cx, cy, R * 0.55, 0, Math.PI * 2); ctx.stroke();
|
||
|
||
// as a layer the skin's station nodes aren't there, so the wheel reads like
|
||
// a natal chart: a sign band with the glyphs inside it, degree ticks, and
|
||
// the sign the Sun is walking right now lit in its own colour
|
||
const bandR = R * 0.88;
|
||
if (layerBox) {
|
||
ctx.beginPath(); ctx.arc(cx, cy, bandR, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(200,212,240,0.20)"; ctx.lineWidth = 1; ctx.stroke();
|
||
for (let d = 0; d < 360; d += 5) { // degree ticks along the band's inner edge
|
||
if (d % 30 === 0) continue;
|
||
const t1 = lonXY(d, bandR), t2 = lonXY(d, bandR + (d % 10 === 0 ? 7 : 3.5));
|
||
ctx.beginPath(); ctx.moveTo(t1[0], t1[1]); ctx.lineTo(t2[0], t2[1]);
|
||
ctx.strokeStyle = "rgba(200,212,240,0.10)"; ctx.lineWidth = 1; ctx.stroke();
|
||
}
|
||
const sunN = sources.get("astro.sun_lon");
|
||
const sunSign = sunN && typeof sunN.raw === "number"
|
||
? Math.floor((((sunN.raw % 360) + 360) % 360) / 30) : -1;
|
||
for (let i2 = 0; i2 < 12; i2++) {
|
||
const t1 = lonXY(i2 * 30, bandR), t2 = lonXY(i2 * 30, R);
|
||
ctx.beginPath(); ctx.moveTo(t1[0], t1[1]); ctx.lineTo(t2[0], t2[1]);
|
||
ctx.strokeStyle = "rgba(200,212,240,0.16)"; ctx.lineWidth = 1; ctx.stroke();
|
||
const here = i2 === sunSign; // the month we are inside right now
|
||
if (here) { // the current sign's slice of band, aglow
|
||
const a0 = (180 + i2 * 30) * Math.PI / 180;
|
||
ctx.beginPath(); ctx.arc(cx, cy, (bandR + R) / 2, -(a0 + Math.PI / 6), -a0);
|
||
ctx.strokeStyle = rgba(SIGNS[i2].col, 0.16); ctx.lineWidth = R - bandR; ctx.stroke();
|
||
}
|
||
const gp = lonXY(i2 * 30 + 15, (bandR + R) / 2);
|
||
ctx.fillStyle = rgba(SIGNS[i2].col, here ? 0.95 : 0.5);
|
||
ctx.font = (here ? "15px" : "13px") + " 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText(SIGNS[i2].label.split(" ")[0] + "︎", gp[0], gp[1]); // FE0E: text glyph, not emoji
|
||
}
|
||
}
|
||
const pR = layerBox ? R * 0.80 : R; // as a chart the planets sit inside the band
|
||
|
||
// whole-sign houses + the ascendant, when a chart with a birthplace is cast
|
||
if (natalActive && natalActive.asc != null) {
|
||
const ascSign = Math.floor(natalActive.asc / 30);
|
||
for (let i2 = 0; i2 < 12; i2++) {
|
||
const s1 = lonXY(i2 * 30, R * 0.55), s2 = lonXY(i2 * 30, R);
|
||
ctx.beginPath(); ctx.moveTo(s1[0], s1[1]); ctx.lineTo(s2[0], s2[1]);
|
||
ctx.strokeStyle = "rgba(200,212,240,0.10)"; ctx.lineWidth = 1; ctx.stroke();
|
||
const hp = lonXY(i2 * 30 + 15, R * 0.47);
|
||
ctx.fillStyle = "rgba(190,205,235,0.4)";
|
||
ctx.font = "500 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText(String(((i2 - ascSign + 12) % 12) + 1), hp[0], hp[1]);
|
||
}
|
||
const a1 = lonXY(natalActive.asc, R * 0.5), a2 = lonXY(natalActive.asc, R + 9);
|
||
ctx.beginPath(); ctx.moveTo(a1[0], a1[1]); ctx.lineTo(a2[0], a2[1]);
|
||
ctx.strokeStyle = "rgba(255,214,130,0.7)"; ctx.lineWidth = 1.6; ctx.stroke();
|
||
const al = lonXY(natalActive.asc, R + 20);
|
||
ctx.fillStyle = "rgba(255,214,130,0.85)";
|
||
ctx.font = "600 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText("ASC", al[0], al[1]);
|
||
if (natalActive.mc != null) {
|
||
const m1 = lonXY(natalActive.mc, R * 0.5), m2 = lonXY(natalActive.mc, R + 9);
|
||
ctx.beginPath(); ctx.moveTo(m1[0], m1[1]); ctx.lineTo(m2[0], m2[1]);
|
||
ctx.strokeStyle = "rgba(255,214,130,0.45)"; ctx.lineWidth = 1.2; ctx.stroke();
|
||
const ml = lonXY(natalActive.mc, R + 20);
|
||
ctx.fillStyle = "rgba(255,214,130,0.6)";
|
||
ctx.fillText("MC", ml[0], ml[1]);
|
||
}
|
||
}
|
||
|
||
const KINDCOL = { hard: "rgba(255,95,95,", soft: "rgba(110,220,150,", conj: "rgba(255,214,110," };
|
||
const live = [];
|
||
for (const gp of GT_PLANETS) {
|
||
const n = sources.get(gp[0]);
|
||
if (n && typeof n.raw === "number")
|
||
live.push({ k: gp[0], glyph: gp[1], lon: ((n.raw % 360) + 360) % 360 });
|
||
}
|
||
// sky-to-sky chords on the rim
|
||
for (let i2 = 0; i2 < live.length; i2++) for (let j2 = i2 + 1; j2 < live.length; j2++) {
|
||
const h = aspectHit(live[i2].lon, live[j2].lon, /sun|moon/.test(live[i2].k + live[j2].k));
|
||
if (!h || h.s < 0.05) continue;
|
||
const p1 = lonXY(live[i2].lon, pR), p2 = lonXY(live[j2].lon, pR);
|
||
ctx.beginPath(); ctx.moveTo(p1[0], p1[1]); ctx.lineTo(p2[0], p2[1]);
|
||
ctx.strokeStyle = KINDCOL[h.kind] + (0.15 + h.s * 0.6).toFixed(3) + ")";
|
||
ctx.lineWidth = 0.8 + h.s * 2.2; ctx.stroke();
|
||
}
|
||
// synastry — the partner's chart on a third ring, silver, with the bond's
|
||
// own chords strung between the two charts
|
||
if (natalActive && synPartner) {
|
||
const NG2 = { sun: "☉", moon: "☽", mercury: "☿", venus: "♀", mars: "♂", jupiter: "♃", saturn: "♄" };
|
||
for (const iw of interWeb) {
|
||
const q1 = lonXY(natalActive.pos[iw.a], R * 0.55), q2 = lonXY(synPartner.pos[iw.b], R * 0.34);
|
||
ctx.beginPath(); ctx.moveTo(q1[0], q1[1]); ctx.lineTo(q2[0], q2[1]);
|
||
ctx.strokeStyle = KINDCOL[iw.kind] + (0.10 + iw.w * 0.4).toFixed(3) + ")";
|
||
ctx.lineWidth = 0.7 + iw.w * 1.5; ctx.stroke();
|
||
}
|
||
for (const p2 in NG2) {
|
||
const gp3 = lonXY(synPartner.pos[p2], R * 0.34);
|
||
ctx.fillStyle = "rgba(170,200,235,0.55)";
|
||
ctx.font = "10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText(NG2[p2], gp3[0], gp3[1]);
|
||
}
|
||
}
|
||
|
||
// the natal chart on the inner ring + transit chords striking it
|
||
if (natalActive) {
|
||
const NG = { sun: "☉", moon: "☽", mercury: "☿", venus: "♀", mars: "♂", jupiter: "♃", saturn: "♄" };
|
||
for (const p2 in NG) {
|
||
const nl = natalActive.pos[p2];
|
||
for (const lv of live) {
|
||
const h = aspectHit(lv.lon, nl, p2 === "sun" || p2 === "moon" || /sun|moon/.test(lv.k));
|
||
if (!h || h.s < 0.08) continue;
|
||
const q1 = lonXY(lv.lon, pR), q2 = lonXY(nl, R * 0.55);
|
||
ctx.setLineDash([3, 4]);
|
||
ctx.beginPath(); ctx.moveTo(q1[0], q1[1]); ctx.lineTo(q2[0], q2[1]);
|
||
ctx.strokeStyle = KINDCOL[h.kind] + (0.15 + h.s * 0.55).toFixed(3) + ")";
|
||
ctx.lineWidth = 0.8 + h.s * 1.8; ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
const gp2 = lonXY(nl, R * 0.55);
|
||
ctx.fillStyle = "rgba(255,214,130,0.6)";
|
||
ctx.font = "11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillText(NG[p2], gp2[0], gp2[1]);
|
||
}
|
||
}
|
||
// the living planets on the rim
|
||
for (const lv of live) {
|
||
const pp = lonXY(lv.lon, pR);
|
||
ctx.font = (layerBox ? "16px" : "13px") + " 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(150,190,255,0.35)";
|
||
ctx.fillText(lv.glyph, pp[0], pp[1] + 0.5);
|
||
ctx.fillStyle = "rgba(224,234,255,0.95)";
|
||
ctx.fillText(lv.glyph, pp[0], pp[1]);
|
||
}
|
||
glyphPass = false;
|
||
ctx.restore();
|
||
}
|
||
|
||
// THE ELDER FUTHARK — the 24 runes in the Kylver-stone row, three ættir of
|
||
// eight, ringed like a rune-band. Every glyph codepoint and meaning verified
|
||
// against the rune-poem scholarship; the routing keeps each rune's attested
|
||
// core sense (poetic licences marked).
|
||
const FUTHARK = [
|
||
{ key: "fehu", label: "ᚠ fehu · wealth", petals: 2, col: [255, 200, 90] },
|
||
{ key: "uruz", label: "ᚢ uruz · the aurochs", petals: 2, col: [150, 110, 80] },
|
||
{ key: "thurisaz", label: "ᚦ thurisaz · the giant",petals: 3, col: [180, 70, 60] },
|
||
{ key: "ansuz", label: "ᚨ ansuz · the word", petals: 4, col: [130, 160, 255] },
|
||
{ key: "raidho", label: "ᚱ raidho · the ride", petals: 5, col: [200, 170, 120] },
|
||
{ key: "kenaz", label: "ᚲ kenaz · the torch", petals: 6, col: [255, 130, 50] },
|
||
{ key: "gebo", label: "ᚷ gebo · the gift", petals: 2, col: [220, 180, 240] },
|
||
{ key: "wunjo", label: "ᚹ wunjo · joy", petals: 8, col: [255, 230, 140] },
|
||
{ key: "hagalaz", label: "ᚺ hagalaz · hail", petals: 6, col: [170, 200, 230] },
|
||
{ key: "nauthiz", label: "ᚾ nauthiz · need", petals: 2, col: [140, 100, 90] },
|
||
{ key: "isa", label: "ᛁ isa · ice", petals: 1, col: [210, 235, 250] },
|
||
{ key: "jera", label: "ᛃ jera · the year", petals: 4, col: [200, 190, 90] },
|
||
{ key: "eihwaz", label: "ᛇ eihwaz · the yew", petals: 3, col: [100, 90, 110] },
|
||
{ key: "perthro", label: "ᛈ perthro · the lot-cup", petals: 5, col: [170, 120, 220] },
|
||
{ key: "algiz", label: "ᛉ algiz · the warding", petals: 3, col: [160, 230, 180] },
|
||
{ key: "sowilo", label: "ᛊ sowilo · the sun", petals: 8, col: [255, 215, 70] },
|
||
{ key: "tiwaz", label: "ᛏ tiwaz · justice", petals: 1, col: [220, 80, 70] },
|
||
{ key: "berkano", label: "ᛒ berkano · the birch", petals: 2, col: [190, 240, 190] },
|
||
{ key: "ehwaz", label: "ᛖ ehwaz · the horse", petals: 2, col: [200, 180, 150] },
|
||
{ key: "mannaz", label: "ᛗ mannaz · humanity", petals: 4, col: [230, 150, 200] },
|
||
{ key: "laguz", label: "ᛚ laguz · water", petals: 3, col: [110, 160, 230] },
|
||
{ key: "ingwaz", label: "ᛜ ingwaz · the seed", petals: 4, col: [150, 210, 120] },
|
||
{ key: "dagaz", label: "ᛞ dagaz · day", petals: 2, col: [250, 245, 200] },
|
||
{ key: "othala", label: "ᛟ othala · inheritance",petals: 4, col: [180, 140, 100] },
|
||
];
|
||
FUTHARK.forEach((r2, i) => { // the rune-band: a ring, clockwise from the top
|
||
const a2 = (90 - i * 15) * Math.PI / 180;
|
||
r2.pos = [0.5 + 0.47 * Math.cos(a2), 0.5 - 0.47 * Math.sin(a2)];
|
||
});
|
||
const FUTHARK_SCALES = {};
|
||
FUTHARK.forEach((r2, i) => { // each ætt its own mood, the row rising
|
||
FUTHARK_SCALES[r2.key] = { root: 44 + i, scale: ["pentatonic", "minor", "dorian"][Math.floor(i / 8)] };
|
||
});
|
||
function futharkOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "me" || p === "fx" || k === "crypto.price" || /inflation/.test(k)) return "fehu"; // "wealth is strife among kinsmen"
|
||
if (p === "body" || k === "quake.depth") return "uruz"; // primal strength
|
||
if (k === "quake.event") return "thurisaz"; // the giant's blow
|
||
if (p === "wiki") return "ansuz"; // Odin's word
|
||
if (p === "planes" || p === "iss") return "raidho"; // the ride
|
||
if (p === "fire" || k === "weather.temp") return "kenaz"; // the torch
|
||
if (/market\./.test(k)) return "gebo"; // exchange is the gift
|
||
if (k === "astro.harmony" || p === "sport") return "wunjo"; // joy — the crowd's rune
|
||
if (/pressure|tension|wind/.test(k)) return "hagalaz"; // the hail-storm sky
|
||
if (/hunger|poverty|debt\.vel/.test(k)) return "nauthiz"; // need, the narrow band
|
||
if (/retro/.test(k)) return "isa"; // the sky standing still (licence)
|
||
if (/season|zodiac/.test(k)) return "jera"; // the year-wheel
|
||
if (k === "clock.deaths" || k === "astro.saturn") return "eihwaz"; // the yew endures
|
||
if (p === "natal" || k === "crypto.vel") return "perthro"; // the lot-cup: fate & dice
|
||
if (["hand", "tof", "touch"].includes(p)) return "algiz"; // ᛉ — the raised, warding hand
|
||
if (p === "sun" || /^sky\.elev/.test(k)) return "sowilo";
|
||
if (k === "astro.mars") return "tiwaz"; // ordered war, Tyr = Mars
|
||
if (k === "clock.births" || k === "almanac.fertile") return "berkano"; // the birch births
|
||
if (p === "syn") return "ehwaz"; // horse & rider: the trusted bond
|
||
if (p === "audio" || k === "clock.popvel") return "mannaz";
|
||
if (/astro\.moon|almanac\.dec/.test(k)) return "laguz"; // the tide-water
|
||
if (p === "almanac") return "ingwaz"; // the seed held
|
||
if (/^sky\.day|light|ambient/.test(k)) return "dagaz"; // day breaks
|
||
if (k === "debt.total" || k === "world.lifeexp") return "othala"; // what is handed down (licence: the debt)
|
||
return "mannaz";
|
||
}
|
||
|
||
// THE WHEEL OF THE YEAR — the eight festivals at their true solar longitudes
|
||
// (quarters at 0/90/180/270, cross-quarters at 15° of the fixed signs — the
|
||
// astronomical reckoning). An honest modern synthesis of the attested Irish
|
||
// fire festivals and the solstice customs; the sun's live position shows
|
||
// where the year stands.
|
||
const WHEELYEAR = [
|
||
{ key: "ostara", label: "ostara · equinox · balance", lon: 0, petals: 4, col: [170, 230, 160] },
|
||
{ key: "beltane", label: "beltane · the fires · union", lon: 45, petals: 8, col: [255, 90, 60] },
|
||
{ key: "litha", label: "litha · midsummer · the peak", lon: 90, petals: 8, col: [255, 215, 80] },
|
||
{ key: "lughnasadh", label: "lughnasadh · first fruits · the fair", lon: 135, petals: 6, col: [215, 150, 60] },
|
||
{ key: "mabon", label: "mabon · the reckoning", lon: 180, petals: 4, col: [185, 95, 55] },
|
||
{ key: "samhain", label: "samhain · the veil thins", lon: 225, petals: 3, col: [235, 115, 45] },
|
||
{ key: "yule", label: "yule · the light returns", lon: 270, petals: 6, col: [240, 232, 205] },
|
||
{ key: "imbolc", label: "imbolc · first stirrings", lon: 315, petals: 3, col: [235, 240, 248] },
|
||
];
|
||
WHEELYEAR.forEach((f2) => { // same convention as the zodiac wheel: 0° left, CCW
|
||
const a2 = (180 + f2.lon) * Math.PI / 180;
|
||
f2.pos = [0.5 + 0.45 * Math.cos(a2), 0.5 - 0.45 * Math.sin(a2)];
|
||
});
|
||
const WHEELYEAR_SCALES = { // the light half bright, the dark half deep
|
||
ostara: { root: 53, scale: "major" }, beltane: { root: 55, scale: "mixolydian" },
|
||
litha: { root: 60, scale: "lydian" }, lughnasadh: { root: 57, scale: "mixolydian" },
|
||
mabon: { root: 48, scale: "dorian" }, samhain: { root: 43, scale: "phrygian" },
|
||
yule: { root: 45, scale: "minor" }, imbolc: { root: 50, scale: "dorian" },
|
||
};
|
||
function wheelyearOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (p === "weather" || /season|harmony/.test(k)) return "ostara"; // the turning, the balance
|
||
if (/deaths|quake|debt|poverty/.test(k) || p === "astro") return "samhain"; // the ancestors, the deep, the seer's night
|
||
if (/light|ambient/.test(k)) return "yule"; // the returning light
|
||
if (/births|fertile/.test(k) || p === "natal") return "imbolc"; // first stirrings; your birth
|
||
if (p === "fire" || ["hand", "tof", "touch", "body", "syn"].includes(p)) return "beltane"; // the fires, the dance, the union
|
||
if (p === "sun" || /^sky\./.test(k)) return "litha"; // the sun at its height
|
||
if (["wiki", "audio", "market", "me", "crypto", "fx", "planes", "iss", "sport"].includes(p)) return "lughnasadh"; // the great fair — Lugh's own funeral games
|
||
return "mabon"; // the reckoning of the year
|
||
}
|
||
function drawYearOverlay(t, g) { // the sun walking the year, live
|
||
const sunN = sources.get("astro.sun_lon");
|
||
const d3 = new Date();
|
||
const lon = sunN && typeof sunN.raw === "number" ? ((sunN.raw % 360) + 360) % 360
|
||
: sunLon(jCent(julianDay(d3.getUTCFullYear(), d3.getUTCMonth() + 1, d3.getUTCDate(), d3.getUTCHours())));
|
||
const box = mapBox(g);
|
||
const cx = (box.x0 + box.x1) / 2, cy = (box.y0 + box.y1) / 2;
|
||
const R = Math.min(box.x1 - box.x0, box.y1 - box.y0) * 0.5 - 4;
|
||
const a2 = (180 + lon) * Math.PI / 180;
|
||
const sx2 = cx + R * Math.cos(a2), sy2 = cy - R * Math.sin(a2);
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(200,212,240,0.18)"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();
|
||
drawHalo(sx2, sy2, 26, [255, 214, 110], 0.7);
|
||
ctx.fillStyle = "rgba(255,225,150,0.95)";
|
||
ctx.font = "13px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
ctx.fillText("☉", sx2, sy2);
|
||
ctx.font = "500 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(255,225,150,0.7)";
|
||
ctx.fillText("the year stands here", sx2, sy2 + 16);
|
||
ctx.restore();
|
||
}
|
||
|
||
// THE ARTIST — Kandinsky, "Concerning the Spiritual in Art" (1911), his own
|
||
// colour-sound table taken literally: yellow the trumpet blown louder and
|
||
// louder, orange the Angelus bell, red the drumbeat, green the violin's
|
||
// placid middle strings, blue deepening from flute toward the organ, violet
|
||
// the bassoon, white the great silence pregnant with possibility, black the
|
||
// pause after which nothing follows. Six pigments squeezed round a palette,
|
||
// white waiting as the canvas on the easel, black at the palette's edge; his
|
||
// four great contrasts (yellow–blue, red–green, orange–violet, white–black)
|
||
// strung dashed between them. Labels in German — the tongue he wrote it in.
|
||
const PALETTE = [
|
||
{ key: "white", label: "weiß · the great silence", petals: 1, col: [246, 246, 252], pos: [0.50, 0.22] },
|
||
{ key: "yellow", label: "gelb · the trumpet", petals: 6, col: [250, 214, 40], pos: [0.13, 0.63] },
|
||
{ key: "orange", label: "orange · the angelus bell", petals: 5, col: [248, 138, 40], pos: [0.28, 0.565] },
|
||
{ key: "red", label: "rot · the drumbeat", petals: 4, col: [226, 54, 44], pos: [0.45, 0.545] },
|
||
{ key: "green", label: "grün · the middle violin", petals: 3, col: [70, 188, 92], pos: [0.62, 0.565] },
|
||
{ key: "blue", label: "blau · the organ", petals: 4, col: [58, 108, 235], pos: [0.78, 0.63] },
|
||
{ key: "violet", label: "violett · the bassoon", petals: 5, col: [150, 72, 200], pos: [0.82, 0.76] },
|
||
{ key: "black", label: "schwarz · the final pause", petals: 2, col: [62, 60, 72], pos: [0.19, 0.77] },
|
||
];
|
||
// solid: the pigments smearing into their neighbours round the palette rim;
|
||
// dashed: Kandinsky's four pairs of great contrasts
|
||
const ARTIST_PATHS = [[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],[7,1]];
|
||
const ARTIST_PATHS2 = [[1,5],[3,4],[2,6],[0,7]];
|
||
const PALETTE_SCALES = { // each colour in the register of its instrument
|
||
white: { root: 84, scale: "wholetone" }, // the silence before the first note
|
||
yellow: { root: 76, scale: "lydian" }, // the trumpet
|
||
orange: { root: 62, scale: "dorian" }, // the alto bell, largo
|
||
red: { root: 45, scale: "mixolydian" }, // tuba & drum, triumphant
|
||
green: { root: 60, scale: "major" }, // the violin's restful middle
|
||
blue: { root: 48, scale: "pentatonic" }, // deepening toward the organ
|
||
violet: { root: 52, scale: "phrygian" }, // the bassoon's sorrow
|
||
black: { root: 36, scale: "minor_pent" }, // the pause after which nothing follows
|
||
};
|
||
function artistOf(key) { // every feed seated by the feeling of its colour, per the book
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (["hand", "tof", "touch"].includes(p)) return "white"; // the brush is in your hand, poised at the canvas
|
||
if (p === "natal" || p === "syn") return "white"; // the picture not yet painted
|
||
if (/harmony|fertile|births|light\.|ambient/.test(k)) return "white"; // silence, pregnant with possibility
|
||
if (/deaths|debt|poverty/.test(k)) return "black"; // the silence with no future
|
||
if (p === "body" || p === "fire" || k === "quake.event" || k === "astro.mars") return "red"; // heartbeat & flame: the drum
|
||
if (["wiki", "net", "news", "social", "edit", "crypto", "fx", "market", "econ", "me", "sport"].includes(p) ||
|
||
/inflation/.test(k)) return "yellow"; // the trumpet, blown louder and louder — the stadium too
|
||
if (p === "clock" || p === "audio" || /season|zodiac|hour|lifeexp/.test(k)) return "orange"; // the bell, rung on the hour
|
||
if (["weather", "air", "planes", "plane"].includes(p) || /temp|wind|pressure|humid|rain|precip/.test(k)) return "green"; // nature at rest
|
||
if (p === "sun" || p === "sky" || p === "iss" || p === "space" || /_lon|moon|elev|aurora|daynight/.test(k)) return "blue"; // the heavenly circle
|
||
if (/retro|saturn|hunger|tension|quake/.test(k)) return "violet"; // the cooled red: sorrow & the deep
|
||
return "green";
|
||
}
|
||
function drawArtistOverlay(t, g) { // the easel, the canvas, the palette — and the strokes
|
||
const box = mapBox(g);
|
||
const X = (u) => box.x0 + u * (box.x1 - box.x0);
|
||
const Y = (v) => box.y0 + v * (box.y1 - box.y0);
|
||
const legX = (side, v) => X(0.5 + side * 0.34 * (v + 0.02) / 1.02);
|
||
ctx.save();
|
||
ctx.lineCap = "round";
|
||
// the easel — wood remembered in warm wireframe: two legs and the tray
|
||
ctx.strokeStyle = "rgba(214,170,116,0.30)";
|
||
ctx.lineWidth = 1.4;
|
||
ctx.beginPath(); ctx.moveTo(X(0.50), Y(-0.02)); ctx.lineTo(X(0.16), Y(1.00)); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(X(0.50), Y(-0.02)); ctx.lineTo(X(0.84), Y(1.00)); ctx.stroke();
|
||
for (const vy of [0.475, 0.495]) { // the tray's two rails, holding the canvas
|
||
ctx.beginPath(); ctx.moveTo(legX(-1, vy) - 8, Y(vy)); ctx.lineTo(legX(1, vy) + 8, Y(vy)); ctx.stroke();
|
||
}
|
||
// the palette, thumb-hole and all
|
||
ctx.strokeStyle = "rgba(214,170,116,0.35)";
|
||
ctx.beginPath();
|
||
ctx.ellipse(X(0.485), Y(0.685), (box.x1 - box.x0) * 0.40, (box.y1 - box.y0) * 0.155, 0, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.ellipse(X(0.55), Y(0.73), (box.x1 - box.x0) * 0.028, (box.y1 - box.y0) * 0.028, 0, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
// the canvas — white's own station sits at its centre; it brightens as the
|
||
// great silence gathers energy
|
||
const wS = activeSkin.byKey.white, we = (wS && wS.energy) || 0;
|
||
ctx.strokeStyle = "rgba(235,238,248," + (0.28 + we * 0.4).toFixed(3) + ")";
|
||
ctx.lineWidth = 1.2;
|
||
ctx.strokeRect(X(0.24), Y(0.04), X(0.76) - X(0.24), Y(0.46) - Y(0.04));
|
||
// the strokes — each pigment pulled up onto the canvas as its data sings,
|
||
// every colour painting its own patch, the daub swelling with the energy
|
||
activeSkin.stations.forEach((c, i) => {
|
||
if (c.key === "white" || c.key === "black" || c.sx == null) return;
|
||
const e = c.energy || 0;
|
||
if (e < 0.02) return;
|
||
const tx = X(0.29 + 0.072 * i), ty = Y(0.14 + 0.12 * (i % 3));
|
||
ctx.beginPath();
|
||
ctx.moveTo(c.sx, c.sy);
|
||
ctx.quadraticCurveTo((c.sx + tx) / 2, Y(0.52), tx, ty);
|
||
ctx.strokeStyle = rgba(c.col, 0.08 + e * 0.45);
|
||
ctx.lineWidth = 1 + e * 3.5;
|
||
ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(tx, ty, 2.2 + e * 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = rgba(c.col, 0.10 + e * 0.5);
|
||
ctx.fill();
|
||
});
|
||
// the honesty note, signed where a painter signs
|
||
ctx.font = "italic 500 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(230,235,245,0.35)";
|
||
ctx.textAlign = "right"; ctx.textBaseline = "alphabetic";
|
||
ctx.fillText("nach kandinsky · 1911", X(0.75), Y(0.44));
|
||
ctx.restore();
|
||
}
|
||
|
||
// THE BAGUA — the I Ching's eight trigrams in the King Wen "Later Heaven"
|
||
// arrangement, south at the top as the old compasses have it (so east lies
|
||
// left). Every routing follows the Shuogua (the "Discussion of the Trigrams",
|
||
// Wilhelm's translation): Gen IS the hand, Dui IS the mouth and tongue, Kan
|
||
// IS the moon, Li the sun and the eye, Qian jade and gold and the heavens,
|
||
// Kun the mother and the multitude — poetic licences marked where taken.
|
||
// Petals follow the Fuxi binary order (Leibniz's delight), Qian 8 down to
|
||
// Kun 1. And at the centre, the instrument's own party trick: the two
|
||
// loudest trigrams stack into a LIVE HEXAGRAM — the strongest speaks from
|
||
// below as the ground of the moment, the second from above — named from all
|
||
// sixty-four by the King Wen table. With no data at all it rests at
|
||
// hexagram 11, Peace: heaven below, earth above.
|
||
const BAGUA = [ // Fuxi binary order; lines are bottom→top, 1 = solid yang
|
||
{ key: "qian", label: "☰ 乾 qián · heaven · the creative", petals: 8, col: [225, 225, 235], lines: [1,1,1] },
|
||
{ key: "dui", label: "☱ 兌 duì · lake · the joyous", petals: 7, col: [170, 200, 225], lines: [1,1,0] },
|
||
{ key: "li", label: "☲ 離 lí · fire · the clinging", petals: 6, col: [235, 75, 50], lines: [1,0,1] },
|
||
{ key: "zhen", label: "☳ 震 zhèn · thunder · the arousing", petals: 5, col: [150, 110, 235], lines: [1,0,0] },
|
||
{ key: "xun", label: "☴ 巽 xùn · wind · the gentle", petals: 4, col: [150, 215, 130], lines: [0,1,1] },
|
||
{ key: "kan", label: "☵ 坎 kǎn · water · the abysmal", petals: 3, col: [70, 110, 200], lines: [0,1,0] },
|
||
{ key: "gen", label: "☶ 艮 gèn · mountain · keeping still", petals: 2, col: [170, 140, 100], lines: [0,0,1] },
|
||
{ key: "kun", label: "☷ 坤 kūn · earth · the receptive", petals: 1, col: [230, 200, 110], lines: [0,0,0] },
|
||
];
|
||
const BAGUA_ANG = { li: 90, kun: 45, dui: 0, qian: 315, kan: 270, gen: 225, zhen: 180, xun: 135 };
|
||
BAGUA.forEach((tr) => {
|
||
const a2 = BAGUA_ANG[tr.key] * Math.PI / 180;
|
||
tr.pos = [0.5 + 0.45 * Math.cos(a2), 0.5 - 0.45 * Math.sin(a2)];
|
||
});
|
||
const BAGUA_PATHS = [[2,7],[7,1],[1,0],[0,5],[5,6],[6,3],[3,4],[4,2]]; // the octagon rim
|
||
const BAGUA_SCALES = {
|
||
qian: { root: 74, scale: "lydian" }, dui: { root: 64, scale: "major" },
|
||
li: { root: 60, scale: "mixolydian" }, zhen: { root: 43, scale: "phrygian" },
|
||
xun: { root: 67, scale: "pentatonic" }, kan: { root: 38, scale: "minor" },
|
||
gen: { root: 55, scale: "hirajoshi" }, kun: { root: 36, scale: "minor_pent" },
|
||
};
|
||
function baguaOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (["hand", "tof", "touch"].includes(p)) return "gen"; // "Gen is the hand" — Shuogua
|
||
if (/deaths|retro|season|zodiac|hour/.test(k)) return "gen"; // where all things end and begin; the standstill
|
||
if (p === "quake" || p === "body" || k === "astro.tension") return "zhen"; // the shock; the pulse (licence: the foot that moves you)
|
||
if (p === "fire" || p === "sun" || /light\.|ambient|aurora|elev/.test(k)) return "li"; // fire, the sun, lightning, the eye
|
||
if (/rain|precip|humid|tide/.test(k) || k === "astro.moon" || p === "debt" || /inflation/.test(k)) return "kan"; // water, the moon; the abyss (licence: the debt)
|
||
if (["wiki", "net", "news", "social", "edit", "audio", "sport"].includes(p) || k === "astro.harmony" || p === "syn") return "dui"; // the mouth and tongue; joy — the crowd
|
||
if (["weather", "air", "planes", "plane"].includes(p) || /wind|pressure|temp/.test(k)) return "xun"; // what rides the wind
|
||
if (p === "iss" || p === "space" || p === "natal" || /^sky\.|_lon/.test(k) || p === "astro") return "qian"; // the heavens
|
||
if (["crypto", "fx", "market", "econ", "me"].includes(p)) return "qian"; // "Qian is jade, is gold" (licence: the markets)
|
||
if (/births|fertile|poverty|hunger|popvel/.test(k) || p === "world" || p === "almanac") return "kun"; // the mother, the multitude, nourishment
|
||
return "kun"; // the receptive earth holds the rest
|
||
}
|
||
// King Wen numbers, row = lower trigram, col = upper, both in BAGUA order
|
||
const KING_WEN = [
|
||
[ 1, 43, 14, 34, 9, 5, 26, 11], // lower qian
|
||
[10, 58, 38, 54, 61, 60, 41, 19], // lower dui
|
||
[13, 49, 30, 55, 37, 63, 22, 36], // lower li
|
||
[25, 17, 21, 51, 42, 3, 27, 24], // lower zhen
|
||
[44, 28, 50, 32, 57, 48, 18, 46], // lower xun
|
||
[ 6, 47, 64, 40, 59, 29, 4, 7], // lower kan
|
||
[33, 31, 56, 62, 53, 39, 52, 15], // lower gen
|
||
[12, 45, 35, 16, 20, 8, 23, 2], // lower kun
|
||
];
|
||
const HEX_NAMES = ["", // Wilhelm's names, 1..64
|
||
"the creative", "the receptive", "difficulty at the beginning", "youthful folly",
|
||
"waiting", "conflict", "the army", "holding together",
|
||
"the taming power of the small", "treading", "peace", "standstill",
|
||
"fellowship with men", "possession in great measure", "modesty", "enthusiasm",
|
||
"following", "work on what has been spoiled", "approach", "contemplation",
|
||
"biting through", "grace", "splitting apart", "return",
|
||
"innocence", "the taming power of the great", "the corners of the mouth", "preponderance of the great",
|
||
"the abysmal water", "the clinging fire", "influence", "duration",
|
||
"retreat", "the power of the great", "progress", "darkening of the light",
|
||
"the family", "opposition", "obstruction", "deliverance",
|
||
"decrease", "increase", "breakthrough", "coming to meet",
|
||
"gathering together", "pushing upward", "oppression", "the well",
|
||
"revolution", "the cauldron", "the arousing thunder", "keeping still mountain",
|
||
"development", "the marrying maiden", "abundance", "the wanderer",
|
||
"the gentle wind", "the joyous lake", "dispersion", "limitation",
|
||
"inner truth", "preponderance of the small", "after completion", "before completion"];
|
||
let baguaCast = null; // the standing reading; re-cast at most every 2s so it reads, not flickers
|
||
function drawBaguaOverlay(t, g) {
|
||
const box = mapBox(g);
|
||
const cx = (box.x0 + box.x1) / 2, cy = (box.y0 + box.y1) / 2;
|
||
const ranked = activeSkin.stations.slice().sort((a, b) => (b.energy || 0) - (a.energy || 0));
|
||
let lower = ranked[0], upper = ranked[1];
|
||
if (!lower || !(lower.energy > 0)) { // silence: heaven below, earth above — Peace
|
||
lower = activeSkin.byKey.qian; upper = activeSkin.byKey.kun;
|
||
} else if (!upper || !(upper.energy > 0)) {
|
||
upper = lower === activeSkin.byKey.kun ? activeSkin.byKey.qian : activeSkin.byKey.kun;
|
||
}
|
||
if (!baguaCast || t - baguaCast.t > 2)
|
||
baguaCast = { lo: BAGUA.indexOf(lower), up: BAGUA.indexOf(upper), t };
|
||
const lo = BAGUA[baguaCast.lo], up = BAGUA[baguaCast.up];
|
||
const kw = KING_WEN[baguaCast.lo][baguaCast.up];
|
||
const lines = lo.lines.concat(up.lines); // six lines, bottom → top
|
||
const e = Math.max(lo.energy || 0, up.energy || 0);
|
||
const lw = Math.min(76, (box.x1 - box.x0) * 0.17), lh = 4.5, step = 13;
|
||
ctx.save();
|
||
for (let i = 0; i < 6; i++) {
|
||
const y = cy + (2.5 - i) * step;
|
||
const col = i < 3 ? lo.col : up.col;
|
||
ctx.fillStyle = rgba(col, 0.55 + e * 0.4);
|
||
if (lines[i]) ctx.fillRect(cx - lw / 2, y - lh / 2, lw, lh);
|
||
else { // yin: the opening in the line
|
||
ctx.fillRect(cx - lw / 2, y - lh / 2, lw * 0.40, lh);
|
||
ctx.fillRect(cx + lw * 0.10, y - lh / 2, lw * 0.40, lh);
|
||
}
|
||
}
|
||
ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(225,232,245," + (0.55 + e * 0.35).toFixed(3) + ")";
|
||
ctx.fillText(String.fromCharCode(0x4DC0 + kw - 1) + " hexagram " + kw + " · " + HEX_NAMES[kw], cx, cy + 3.5 * step + 14);
|
||
ctx.font = "500 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(160,180,210,0.55)";
|
||
ctx.fillText("the world casts — " + up.label.split(" ")[0] + " over " + lo.label.split(" ")[0], cx, cy - 3.5 * step - 8);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ---- THE GOD'S EYE — GODSIGH's cosmology as a Spirit ----------------------
|
||
// The world's sensor picture read AS the map: a minimal equirectangular Earth
|
||
// (a sparse hand-drawn continent set — no heavy geo data ships into the one-file
|
||
// viz), a 30° graticule, and every feed seated at its LITERAL coordinates. The
|
||
// quakes flash where the ground actually moved; the ISS and the recon birds sweep
|
||
// as a moving point with a trail; the planes are a shimmer band; the markets pulse
|
||
// at their financial capitals; the solar wind washes the poles as an aurora; the
|
||
// word sparks at random civilisational coordinates. Cold watch-floor palette
|
||
// (near-black, cyan #35e0ff, warning amber) and a minor-pentatonic retune, so
|
||
// stepping inside sounds like a surveillance room. (Ported from the GODSIGH view.)
|
||
const geU = (lon) => ((((lon + 180) % 360) + 360) % 360) / 360; // lon -> u in [0,1)
|
||
const geV = (lat) => (90 - clamp(lat, -90, 90)) / 180; // lat -> v in [0,1]
|
||
const GODSEYE_DEFAULT = [144.96, -37.81]; // [lon, lat] — Melbourne, when no venue signal is up
|
||
function godseyeVenueLL() { // the venue's live coordinates (world_sky emits them), or the default
|
||
const lo = sources.get("sky.lon"), la = sources.get("sky.lat");
|
||
const lon = lo && typeof lo.raw === "number" ? lo.raw : GODSEYE_DEFAULT[0];
|
||
const lat = la && typeof la.raw === "number" ? la.raw : GODSEYE_DEFAULT[1];
|
||
return [lon, lat];
|
||
}
|
||
const GODSEYE = [ // the anchor posts — real world locations the sources are seated at
|
||
{ key: "orbit", label: "orbit · the birds", ll: [-20, 60], col: [120, 210, 255], petals: 7 },
|
||
{ key: "markets", label: "new york · the market", ll: [-74, 40.7], col: [255, 180, 80], petals: 6 },
|
||
{ key: "wire", label: "london · the wire", ll: [-0.1, 51.5], col: [120, 220, 235], petals: 6 },
|
||
{ key: "weather", label: "tokyo · the weather", ll: [139.7, 35.7], col: [120, 200, 230], petals: 5 },
|
||
{ key: "air", label: "delhi · the air", ll: [77.2, 28.6], col: [205, 190, 120], petals: 5 },
|
||
{ key: "pole", label: "the pole · the wind", ll: [20, 74], col: [140, 240, 220], petals: 5 },
|
||
{ key: "world", label: "the world · the people", ll: [22, 2], col: [150, 205, 205], petals: 8 },
|
||
{ key: "deep", label: "the ring · the ground", ll: [-160, -8], col: [235, 100, 70], petals: 5 },
|
||
{ key: "here", label: "here · your hands", ll: GODSEYE_DEFAULT, col: [53, 224, 255], petals: 6 },
|
||
];
|
||
GODSEYE.forEach((s) => { s.pos = [geU(s.ll[0]), geV(s.ll[1])]; }); // initial; godseyeLayout refits each frame
|
||
const GODSEYE_SCALES = {
|
||
orbit: { root: 72, scale: "minor_pent" }, markets: { root: 60, scale: "minor_pent" },
|
||
wire: { root: 62, scale: "minor" }, weather: { root: 57, scale: "minor_pent" },
|
||
air: { root: 55, scale: "hirajoshi" }, pole: { root: 67, scale: "minor_pent" },
|
||
world: { root: 48, scale: "minor" }, deep: { root: 36, scale: "minor_pent" },
|
||
here: { root: 58, scale: "hirajoshi" },
|
||
};
|
||
function godseyeOf(key) {
|
||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||
if (["hand", "tof", "touch", "light", "audio", "body", "me"].includes(p)) return "here"; // you are here
|
||
if (["iss", "sats", "astro", "sky", "space", "natal", "zodiac", "almanac"].includes(p)) return "orbit";
|
||
if (["crypto", "fx", "market", "econ", "debt"].includes(p)) return "markets";
|
||
if (["wiki", "news", "social", "sport", "net"].includes(p)) return "wire";
|
||
if (p === "weather") return "weather";
|
||
if (p === "air") return "air";
|
||
if (p === "sun") return "pole";
|
||
if (["quake", "fire"].includes(p) || /season/.test(k)) return "deep";
|
||
if (["clock", "world"].includes(p)) return "world";
|
||
return "world"; // the receptive earth holds the rest
|
||
}
|
||
// a sparse hand-drawn world — coarse [lon,lat] continent outlines (deliberately
|
||
// rough: enough to read as Earth, nothing to bloat the single-file console)
|
||
const GE_LAND = [
|
||
[[-165,60],[-140,70],[-95,70],[-80,56],[-60,50],[-70,42],[-82,25],[-100,18],[-115,30],[-125,42],[-135,58],[-165,60]], // N America
|
||
[[-80,9],[-60,8],[-50,-2],[-40,-12],[-55,-25],[-70,-40],[-74,-52],[-72,-20],[-81,-4],[-80,9]], // S America
|
||
[[-16,33],[0,36],[11,37],[33,31],[43,12],[51,-1],[40,-16],[20,-35],[12,-18],[9,4],[-8,5],[-17,15],[-16,33]], // Africa
|
||
[[-9,44],[2,51],[12,55],[30,60],[40,48],[28,41],[12,44],[-9,44]], // Europe
|
||
[[42,48],[60,56],[90,72],[140,72],[150,60],[140,50],[122,40],[122,22],[104,10],[80,8],[70,24],[55,40],[42,48]], // Asia
|
||
[[114,-22],[131,-12],[142,-11],[153,-28],[147,-38],[131,-32],[115,-34],[114,-22]], // Australia
|
||
];
|
||
const GE_CAPITALS = [{ ll: [-74, 40.7] }, { ll: [-0.1, 51.5] }, { ll: [139.7, 35.7] }]; // NY · London · Tokyo
|
||
const GE_CIV = [[-74,40],[-0.1,51],[139,35],[116,39],[77,28],[37,55],[2,48],[-99,19],[31,30],[106,-6],[28,-26],[151,-33]];
|
||
const godseye = { rect: null, satTrail: [], sparks: [], _wikiHi: 0 };
|
||
function godseyeLayout() { // dynamicStations: refit a 2:1 world into the map box; posts track it
|
||
const g = chakraGeom(), box = mapBox(g);
|
||
const bw = box.x1 - box.x0, bh = box.y1 - box.y0;
|
||
let mw = bw, mh = mw / 2;
|
||
if (mh > bh) { mh = bh; mw = mh * 2; } // keep the world 2:1, centred, un-stretched
|
||
const mx0 = box.x0 + (bw - mw) / 2, my0 = box.y0 + (bh - mh) / 2;
|
||
godseye.rect = { x0: mx0, y0: my0, x1: mx0 + mw, y1: my0 + mh };
|
||
const [vlon, vlat] = godseyeVenueLL();
|
||
for (const s of GODSEYE) {
|
||
const ll = s.key === "here" ? [vlon, vlat] : s.ll;
|
||
const sx = mx0 + geU(ll[0]) * mw, sy = my0 + geV(ll[1]) * mh;
|
||
s.pos = [(sx - box.x0) / bw, (sy - box.y0) / bh]; // express as a full-box fraction the renderer expects
|
||
}
|
||
}
|
||
function drawGodseyeOverlay(t, g) {
|
||
const R = godseye.rect || mapBox(g);
|
||
const MX = (u) => R.x0 + u * (R.x1 - R.x0);
|
||
const MY = (v) => R.y0 + v * (R.y1 - R.y0);
|
||
const xy = (lon, lat) => [MX(geU(lon)), MY(geV(lat))];
|
||
const sv = (k) => { const n = sources.get(k); return n ? (n.normDisp || 0) : -1; }; // -1 = feed absent
|
||
const sraw = (k) => { const n = sources.get(k); return n && typeof n.raw === "number" ? n.raw : null; };
|
||
const rw = R.x1 - R.x0, rh = R.y1 - R.y0;
|
||
ctx.save();
|
||
ctx.fillStyle = "rgba(6,13,20,0.34)"; ctx.fillRect(R.x0, R.y0, rw, rh); // the watch-floor screen
|
||
ctx.strokeStyle = "rgba(53,224,255,0.22)"; ctx.lineWidth = 1; ctx.strokeRect(R.x0, R.y0, rw, rh);
|
||
ctx.beginPath(); ctx.rect(R.x0, R.y0, rw, rh); ctx.clip();
|
||
// aurora — the solar wind washing the poles
|
||
const sw = sv("sun.speed");
|
||
if (sw >= 0) for (const top of [true, false]) {
|
||
const y0 = top ? R.y0 : R.y1 - rh * 0.16;
|
||
const grad = ctx.createLinearGradient(0, top ? R.y0 : R.y1, 0, top ? R.y0 + rh * 0.16 : R.y1 - rh * 0.16);
|
||
grad.addColorStop(0, rgba([90, 240, 200], 0.05 + sw * 0.30)); grad.addColorStop(1, rgba([90, 240, 200], 0));
|
||
ctx.fillStyle = grad; ctx.fillRect(R.x0, y0, rw, rh * 0.16);
|
||
}
|
||
// graticule every 30°
|
||
for (let lon = -180; lon <= 180; lon += 30) {
|
||
const x = MX(geU(lon === 180 ? 179.99 : lon));
|
||
ctx.strokeStyle = "rgba(53,224,255," + (lon === 0 ? 0.14 : 0.05) + ")";
|
||
ctx.beginPath(); ctx.moveTo(x, R.y0); ctx.lineTo(x, R.y1); ctx.stroke();
|
||
}
|
||
for (let lat = -60; lat <= 60; lat += 30) {
|
||
const y = MY(geV(lat));
|
||
ctx.strokeStyle = "rgba(53,224,255," + (lat === 0 ? 0.14 : 0.05) + ")";
|
||
ctx.beginPath(); ctx.moveTo(R.x0, y); ctx.lineTo(R.x1, y); ctx.stroke();
|
||
}
|
||
// continents
|
||
ctx.strokeStyle = "rgba(70,150,180,0.5)"; ctx.lineWidth = 1; ctx.lineJoin = "round";
|
||
for (const poly of GE_LAND) {
|
||
ctx.beginPath();
|
||
poly.forEach((p, i) => { const c = xy(p[0], p[1]); i ? ctx.lineTo(c[0], c[1]) : ctx.moveTo(c[0], c[1]); });
|
||
ctx.stroke();
|
||
}
|
||
// planes — a shimmer band across the busy mid-latitudes
|
||
const pc = sv("planes.count");
|
||
if (pc >= 0) {
|
||
const yb = MY(geV(28)), amp = 0.35 + pc * 0.65;
|
||
ctx.strokeStyle = rgba([120, 200, 235], 1); ctx.lineWidth = 1;
|
||
for (let i = 0; i < 40; i++) {
|
||
const x = R.x0 + (i + 0.5) / 40 * rw, fl = 0.5 + 0.5 * Math.sin(t * 3 + i * 1.3);
|
||
ctx.globalAlpha = (0.08 + pc * 0.36) * fl;
|
||
ctx.beginPath(); ctx.moveTo(x, yb - (3 + fl * 7 * amp)); ctx.lineTo(x, yb + (3 + fl * 7 * amp)); ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// market pulses at the financial capitals
|
||
const mk = Math.max(sv("crypto.vel"), sv("market.velocity"), 0);
|
||
for (const cap of GE_CAPITALS) {
|
||
const c = xy(cap.ll[0], cap.ll[1]), pr = 3 + (0.5 + 0.5 * Math.sin(t * 4)) * (2 + mk * 8);
|
||
ctx.strokeStyle = rgba([255, 180, 80], 0.25 + mk * 0.6); ctx.lineWidth = 1.2;
|
||
ctx.beginPath(); ctx.arc(c[0], c[1], pr, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = rgba([255, 190, 90], 0.5 + mk * 0.5);
|
||
ctx.beginPath(); ctx.arc(c[0], c[1], 1.6, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
// the ISS / recon birds — a moving point with a short trail
|
||
const ilat = sraw("sats.iss_lat"), ilon = sraw("sats.iss_lon");
|
||
if (ilat != null && ilon != null) {
|
||
const c = xy(ilon, ilat), tr = godseye.satTrail;
|
||
// a stale trail chords straight across the ocean: clear it after an rAF nap
|
||
// (backgrounded tab — the ISS moves ~4°/min while we sleep) or a resize
|
||
const tnow = now(), rkey = R.x0 + "," + R.y0 + "," + rw;
|
||
if (tnow - (godseye._trailT || 0) > 5 || godseye._trailKey !== rkey) tr.length = 0;
|
||
godseye._trailT = tnow; godseye._trailKey = rkey;
|
||
if (!tr.length || Math.hypot(c[0] - tr[tr.length - 1][0], c[1] - tr[tr.length - 1][1]) > 1.5) tr.push(c);
|
||
if (tr.length > 44) tr.splice(0, tr.length - 44);
|
||
ctx.strokeStyle = "rgba(120,210,255,0.4)"; ctx.lineWidth = 1; ctx.beginPath();
|
||
const seam = rw * 0.5; // break the polyline at the antimeridian (or a stale re-entry point)
|
||
for (let i = 0; i < tr.length; i++) {
|
||
if (i === 0 || Math.abs(tr[i][0] - tr[i - 1][0]) > seam) ctx.moveTo(tr[i][0], tr[i][1]);
|
||
else ctx.lineTo(tr[i][0], tr[i][1]);
|
||
}
|
||
ctx.stroke();
|
||
drawHalo(c[0], c[1], 22, [120, 210, 255], 0.18 + clamp(sv("sats.best_elev"), 0, 1) * 0.3);
|
||
ctx.fillStyle = sv("sats.recon_elev") > 0 ? "rgba(255,180,80,0.95)" : "rgba(150,230,255,0.95)";
|
||
ctx.beginPath(); ctx.arc(c[0], c[1], 2.6, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
// earthquakes — a flash where the ground actually moved
|
||
const qn = sv("quake.event");
|
||
if (qn > 0.02) {
|
||
let qlon = sraw("quake.lon"), qlat = sraw("quake.lat");
|
||
if (qlon == null || qlat == null) { const v = godseyeVenueLL(); qlon = v[0] + 180; qlat = -v[1]; } // venue antipode fallback
|
||
const c = xy(qlon, qlat), r = 5 + (1 - qn) * 30;
|
||
ctx.strokeStyle = rgba([255, 120, 70], qn * 0.9); ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(c[0], c[1], r, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = rgba([255, 140, 80], qn * 0.7);
|
||
ctx.beginPath(); ctx.arc(c[0], c[1], 2.5 + qn * 3, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
// the venue — here, your hands
|
||
const v = godseyeVenueLL(), vc = xy(v[0], v[1]), vpr = 6 + 1.6 * Math.sin(t * 2.2);
|
||
ctx.strokeStyle = "rgba(53,224,255,0.85)"; ctx.lineWidth = 1.4;
|
||
ctx.beginPath(); ctx.arc(vc[0], vc[1], vpr, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.strokeStyle = "rgba(53,224,255,0.3)"; ctx.beginPath(); ctx.arc(vc[0], vc[1], vpr + 5, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = "rgba(53,224,255,0.9)"; ctx.beginPath(); ctx.arc(vc[0], vc[1], 1.6, 0, Math.PI * 2); ctx.fill();
|
||
// the word — brief sparks at civilisational coordinates
|
||
const we = sv("wiki.edit.event");
|
||
if (we > 0.4 && godseye._wikiHi <= 0.4) {
|
||
const cc = GE_CIV[Math.floor(Math.random() * GE_CIV.length)];
|
||
godseye.sparks.push({ c: xy(cc[0] + (Math.random() * 10 - 5), cc[1] + (Math.random() * 8 - 4)), t0: t });
|
||
}
|
||
godseye._wikiHi = we;
|
||
for (let i = godseye.sparks.length - 1; i >= 0; i--) {
|
||
const sp = godseye.sparks[i], age = (t - sp.t0) / 1.1;
|
||
if (age >= 1) { godseye.sparks.splice(i, 1); continue; }
|
||
ctx.fillStyle = rgba([180, 240, 255], (1 - age) * 0.9);
|
||
ctx.beginPath(); ctx.arc(sp.c[0], sp.c[1], 1 + (1 - age) * 2.5, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
ctx.save(); // the corner tag, outside the clip
|
||
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 9px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(53,224,255,0.5)";
|
||
ctx.fillText("◉ GOD'S EYE — the world's sensor picture", R.x0 + 4, R.y0 - 5);
|
||
ctx.restore();
|
||
}
|
||
|
||
const SKINS = {
|
||
chakra: {
|
||
key: "chakra", label: "chakra", word: "chakra",
|
||
stations: CHAKRAS, stationOf: chakraOf, scaleOf: (k) => CHAKRA_SCALES[k],
|
||
},
|
||
planes: {
|
||
key: "planes", label: "the planes (bailey)", word: "plane",
|
||
stations: BAILEY, stationOf: baileyOf, scaleOf: (k) => BAILEY_SCALES[k],
|
||
groups: BAILEY_GROUPS, ribbon: true,
|
||
},
|
||
tree: {
|
||
key: "tree", label: "tree of life", word: "sephirah", layout: "map",
|
||
stations: TREE, stationOf: treeOf, scaleOf: (k) => TREE_SCALES[k], paths: TREE_PATHS,
|
||
},
|
||
lodge: {
|
||
key: "lodge", label: "the lodge", word: "station", layout: "map",
|
||
stations: LODGE, stationOf: lodgeOf, scaleOf: (k) => LODGE_SCALES[k], paths: LODGE_PATHS,
|
||
},
|
||
ladder: {
|
||
key: "ladder", label: "hermetic ladder", word: "sphere",
|
||
stations: LADDER, stationOf: ladderOf, scaleOf: (k) => LADDER_SCALES[k],
|
||
},
|
||
yggdrasil: {
|
||
key: "yggdrasil", label: "yggdrasil", word: "world",
|
||
stations: NINE, stationOf: nineOf, scaleOf: (k) => NINE_SCALES[k],
|
||
},
|
||
lataif: {
|
||
key: "lataif", label: "lata'if (sufi)", word: "latifa",
|
||
stations: LATAIF, stationOf: lataifOf, scaleOf: (k) => LATAIF_SCALES[k],
|
||
},
|
||
wuxing: {
|
||
key: "wuxing", label: "wu xing", word: "phase", layout: "map",
|
||
stations: WUXING, stationOf: wuxingOf, scaleOf: (k) => WUXING_SCALES[k],
|
||
paths: WUXING_PATHS, paths2: WUXING_PATHS2,
|
||
},
|
||
tarot: {
|
||
key: "tarot", label: "the spread (tarot)", word: "position", layout: "map",
|
||
stations: SPREAD, stationOf: spreadOf, scaleOf: (k) => SPREAD_SCALES[k], paths: SPREAD_PATHS,
|
||
},
|
||
zodiac: {
|
||
key: "zodiac", label: "the wheel (zodiac)", word: "sign", layout: "map",
|
||
stations: SIGNS, stationOf: zodiacOf, scaleOf: (k) => SIGN_SCALES[k],
|
||
overlay: drawWheelOverlay, dynamicStations: rezodiac,
|
||
},
|
||
futhark: {
|
||
key: "futhark", label: "the runes (futhark)", word: "rune", layout: "map",
|
||
stations: FUTHARK, stationOf: futharkOf, scaleOf: (k) => FUTHARK_SCALES[k],
|
||
},
|
||
wheelyear: {
|
||
key: "wheelyear", label: "wheel of the year (celtic)", word: "festival", layout: "map",
|
||
stations: WHEELYEAR, stationOf: wheelyearOf, scaleOf: (k) => WHEELYEAR_SCALES[k],
|
||
overlay: drawYearOverlay,
|
||
},
|
||
artist: {
|
||
key: "artist", label: "the artist (kandinsky)", word: "colour", layout: "map",
|
||
stations: PALETTE, stationOf: artistOf, scaleOf: (k) => PALETTE_SCALES[k],
|
||
paths: ARTIST_PATHS, paths2: ARTIST_PATHS2, overlay: drawArtistOverlay,
|
||
},
|
||
bagua: {
|
||
key: "bagua", label: "the bagua (i ching)", word: "trigram", layout: "map",
|
||
stations: BAGUA, stationOf: baguaOf, scaleOf: (k) => BAGUA_SCALES[k],
|
||
paths: BAGUA_PATHS, overlay: drawBaguaOverlay,
|
||
},
|
||
godseye: {
|
||
key: "godseye", label: "god's eye (surveillance)", word: "post", layout: "map",
|
||
stations: GODSEYE, stationOf: godseyeOf, scaleOf: (k) => GODSEYE_SCALES[k],
|
||
overlay: drawGodseyeOverlay, dynamicStations: godseyeLayout,
|
||
},
|
||
};
|
||
for (const s in SKINS) {
|
||
const byKey = {};
|
||
for (const c of SKINS[s].stations) byKey[c.key] = c;
|
||
SKINS[s].byKey = byKey;
|
||
}
|
||
let activeSkin = SKINS.chakra;
|
||
try { const s = localStorage.getItem("gs_skin"); if (s && SKINS[s]) activeSkin = SKINS[s]; } catch (e) {}
|
||
// Source → Spirit → Matter, made true: the active skin re-tunes the whole world.
|
||
// Its home tonality (its first station's scale) becomes the global scale, so the
|
||
// sequencer AND the ambient data-melodies re-harmonise when you change cosmology.
|
||
let skinTunesWorld = true;
|
||
// Hand-tuned home key per Spirit — a deliberate, distinct mood for each cosmology
|
||
// (every {root,scale} pair is unique, so turning the wheel always changes the key).
|
||
const SKIN_HOME = {
|
||
chakra: { root: 0, name: "minor_pent" }, // C — warm, grounded, meditative
|
||
planes: { root: 9, name: "lydian" }, // A — airy, ascending (higher planes)
|
||
tree: { root: 2, name: "minor" }, // D — deep, solemn, mystical
|
||
lodge: { root: 7, name: "major" }, // G — noble, bright, geometric
|
||
ladder: { root: 4, name: "phrygian" }, // E — ancient, alchemical
|
||
yggdrasil: { root: 6, name: "minor" }, // F# — cold, epic, mythic
|
||
lataif: { root: 11, name: "dorian" }, // B — spiritual, warm-modal (Sufi)
|
||
wuxing: { root: 6, name: "pentatonic" }, // F# — the classic Chinese pentatonic
|
||
tarot: { root: 9, name: "minor" }, // A — mysterious, fateful
|
||
zodiac: { root: 0, name: "mixolydian" }, // C — cosmic, rolling, bright
|
||
futhark: { root: 7, name: "dorian" }, // G — old folk modal (runes)
|
||
wheelyear: { root: 5, name: "major" }, // F — pastoral, seasonal
|
||
artist: { root: 0, name: "wholetone" }, // C — pure colour, no tonic (Kandinsky)
|
||
bagua: { root: 4, name: "minor_pent" }, // E — Eastern pentatonic (I Ching)
|
||
godseye: { root: 10, name: "minor_pent" }, // Bb — cold, spare, a watch-floor pentatonic
|
||
};
|
||
function skinHomeScale() {
|
||
const h = SKIN_HOME[activeSkin.key];
|
||
if (h && SCALES[h.name]) return { root: h.root, name: h.name };
|
||
const st = activeSkin.stations && activeSkin.stations[0]; // fallback for any future skin
|
||
const sc = st && activeSkin.scaleOf(st.key);
|
||
if (sc && SCALES[sc.scale]) return { root: ((sc.root % 12) + 12) % 12, name: sc.scale };
|
||
return null;
|
||
}
|
||
function applySkinScale() {
|
||
if (!skinTunesWorld) return;
|
||
const h = skinHomeScale();
|
||
if (!h) return;
|
||
gScale.root = h.root; gScale.name = h.name; SEQ_ROWS = scaleRows(gScale.name);
|
||
if (arr.open) openArranger(); // refresh the scale readout if the panel is up
|
||
}
|
||
applySkinScale(); // tune to the skin loaded from storage
|
||
function setSkin(k) {
|
||
if (!SKINS[k] || activeSkin === SKINS[k]) return;
|
||
activeSkin = SKINS[k];
|
||
try { localStorage.setItem("gs_skin", k); } catch (e) {}
|
||
mindChakra = null; // leave mind mode across a skin change
|
||
for (const n of sources.values()) n.station = null; // re-map sources next frame
|
||
clearTrails();
|
||
godseye.satTrail.length = 0; godseye.sparks.length = 0; // the god's eye keeps its own trail/sparks — reset them too
|
||
applySkinScale(); // the Spirit re-tunes the Matter
|
||
}
|
||
function nextSkinKey() {
|
||
const ks = Object.keys(SKINS), i = ks.indexOf(activeSkin.key);
|
||
return ks[(i + 1) % ks.length];
|
||
}
|
||
function prevSkinKey() {
|
||
const ks = Object.keys(SKINS), i = ks.indexOf(activeSkin.key);
|
||
return ks[(i - 1 + ks.length) % ks.length];
|
||
}
|
||
|
||
function chakraGeom() {
|
||
const top = HEADER_H + 74;
|
||
const bot = H - FOOTER_H - 60;
|
||
return { cx: W * 0.42, top, bot, gap: (bot - top) / (activeSkin.stations.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();
|
||
if (activeSkin.dynamicStations) activeSkin.dynamicStations(); // e.g. planets walking the signs
|
||
for (const c of activeSkin.stations) { c.members = []; c.energy = 0; }
|
||
for (const n of sources.values()) {
|
||
if (!n.station) n.station = activeSkin.stationOf(n.key);
|
||
const c = activeSkin.byKey[n.station];
|
||
if (!c) continue;
|
||
c.members.push(n); c.energy += n.normDisp;
|
||
}
|
||
for (const c of activeSkin.stations) {
|
||
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; }
|
||
const isMap = activeSkin.layout === "map";
|
||
const box = mapBox(g);
|
||
activeSkin.stations.forEach((c, i) => {
|
||
if (isMap && c.pos) { // 2D sacred figure (Tree, Lodge…)
|
||
c.sx = box.x0 + c.pos[0] * (box.x1 - box.x0);
|
||
c.sy = box.y0 + c.pos[1] * (box.y1 - box.y0);
|
||
} else { // the spine — stacked top to bottom
|
||
c.sx = g.cx; c.sy = g.top + g.gap * i;
|
||
}
|
||
c.cy = c.sy; // legacy alias
|
||
const m = c.members.length;
|
||
const dir = i % 2 ? -1 : 1; // adjacent wheels counter-rotate
|
||
const baseR = isMap ? Math.min(84, W * 0.09, 40 + m * 7)
|
||
: Math.min(300, W * 0.24, 130 + m * 12);
|
||
for (let j = 0; j < m; j++) {
|
||
const n = c.members[j];
|
||
let R = baseR;
|
||
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 = c.sx + x3 * s;
|
||
n.y = c.sy + 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 mapBox(g) { // the frame a 2D skin (map layout) is drawn in
|
||
const halfW = Math.min(W * 0.30, 360);
|
||
return { x0: g.cx - halfW, x1: g.cx + halfW, y0: g.top + 8, y1: g.bot - 8 };
|
||
}
|
||
|
||
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.station !== c.key) { n.x = -9999; n.y = -9999; n._z = 0; n._s = 1; n.targetY = n.y; }
|
||
const dir = activeSkin.stations.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 = activeSkin.scaleOf(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) {
|
||
ctx.save();
|
||
const isMap = activeSkin.layout === "map";
|
||
|
||
// map skins (Tree of Life, the Lodge…) draw their canonical connective paths
|
||
// between stations instead of a single vertical spine.
|
||
if (isMap) drawStationPaths();
|
||
|
||
// planes skin: the spine IS the 49 sub-planes — a descending spectrum ladder,
|
||
// each plane's 7 sub-planes shading from atomic (lightest) to densest.
|
||
if (activeSkin.ribbon && !isMap) {
|
||
const y0 = g.top - 24, y1 = g.bot + 24, n = activeSkin.stations.length * 7;
|
||
const segH = (y1 - y0) / n;
|
||
for (let j = 0; j < n; j++) {
|
||
const st = activeSkin.stations[Math.floor(j / 7)], sub = j % 7;
|
||
ctx.fillStyle = rgba(rgbMix(st.col, (6 - sub) / 6 * 0.55), 0.55);
|
||
ctx.fillRect(g.cx - 3.5, y0 + j * segH, 7, segH + 0.6);
|
||
}
|
||
}
|
||
|
||
// the central channel — a column of light, pulsing on the godtime beat
|
||
if (!isMap) {
|
||
ctx.strokeStyle = "rgba(215,228,255,0.30)";
|
||
ctx.lineWidth = (activeSkin.ribbon ? 1.4 : 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;
|
||
}
|
||
|
||
// the three grouping brackets (Monad / Spiritual Triad / Personality)
|
||
if (activeSkin.groups) {
|
||
const bx = 22;
|
||
for (const grp of activeSkin.groups) {
|
||
const a = activeSkin.stations[grp.from], b = activeSkin.stations[grp.to];
|
||
if (!a || !b) continue;
|
||
const yA = a.cy - 22, yB = b.cy + 22;
|
||
ctx.strokeStyle = "rgba(200,210,235,0.30)";
|
||
ctx.lineWidth = 1.4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(bx + 7, yA); ctx.lineTo(bx, yA); ctx.lineTo(bx, yB); ctx.lineTo(bx + 7, yB);
|
||
ctx.stroke();
|
||
ctx.save();
|
||
ctx.translate(bx - 5, (yA + yB) / 2); ctx.rotate(-Math.PI / 2);
|
||
ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
|
||
ctx.font = "600 10px 'Helvetica Neue', Arial, sans-serif";
|
||
ctx.fillStyle = "rgba(195,208,235,0.6)";
|
||
ctx.fillText(grp.name, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// rising prana
|
||
if (!isMap) {
|
||
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();
|
||
}
|
||
}
|
||
|
||
activeSkin.stations.forEach((c, i) => {
|
||
const e = c.energy || 0;
|
||
const cx2 = c.sx != null ? c.sx : g.cx, cy2 = c.sy != null ? c.sy : c.cy;
|
||
const r = 15 + e * 10; // the wheel breathes with its members' data
|
||
|
||
drawHalo(cx2, cy2, (isMap ? 44 : 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(cx2 + Math.cos(a) * (r + 9), cy2 + 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(cx2 - 3, cy2 - 3, 1, cx2, cy2, r);
|
||
cg.addColorStop(0, rgba(rgbMix(c.col, 0.6), 0.95));
|
||
cg.addColorStop(1, rgba(c.col, 0.45));
|
||
ctx.beginPath(); ctx.arc(cx2, cy2, 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 : ""), cx2, cy2 + r + 16);
|
||
});
|
||
if (activeSkin.overlay) activeSkin.overlay(t, g); // e.g. the zodiac wheel's chords & houses
|
||
ctx.restore();
|
||
}
|
||
|
||
// aspects as cables — in every view, the planets' live aspects draw as lines
|
||
// between their own spheres: red the hard, green the soft, gold the meetings
|
||
function drawAspectCables(t) {
|
||
if (typeof aspectHit !== "function") return;
|
||
const pts = [];
|
||
for (const gp of GT_PLANETS) {
|
||
const n = sources.get(gp[0]);
|
||
if (n && typeof n.raw === "number" && n.x > -9000 && n.x !== 0) pts.push(n);
|
||
}
|
||
const KINDCOL = { hard: "rgba(255,95,95,", soft: "rgba(110,220,150,", conj: "rgba(255,214,110," };
|
||
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) {
|
||
const h = aspectHit(((pts[i].raw % 360) + 360) % 360, ((pts[j].raw % 360) + 360) % 360,
|
||
/sun|moon/.test(pts[i].key + pts[j].key));
|
||
if (!h || h.s < 0.08) continue;
|
||
ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[j].x, pts[j].y);
|
||
ctx.strokeStyle = KINDCOL[h.kind] + (0.10 + h.s * 0.45).toFixed(3) + ")";
|
||
ctx.lineWidth = 0.8 + h.s * 2;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawStationPaths() { // the canonical connective lines of a map skin
|
||
const draw = (pairs, dashed) => {
|
||
if (!pairs) return;
|
||
ctx.setLineDash(dashed ? [4, 6] : []);
|
||
for (const pr of pairs) {
|
||
const A = activeSkin.stations[pr[0]], B = activeSkin.stations[pr[1]];
|
||
if (!A || !B || A.sx == null || B.sx == null) continue;
|
||
const e = Math.max(A.energy || 0, B.energy || 0); // a path glows with its livelier end
|
||
ctx.beginPath();
|
||
ctx.moveTo(A.sx, A.sy); ctx.lineTo(B.sx, B.sy);
|
||
ctx.lineWidth = 1.2 + godtime.beatFlash * 0.8;
|
||
ctx.strokeStyle = "rgba(205,215,240," + (0.14 + e * 0.30).toFixed(3) + ")";
|
||
ctx.stroke();
|
||
}
|
||
ctx.setLineDash([]);
|
||
};
|
||
draw(activeSkin.paths, false);
|
||
draw(activeSkin.paths2, true); // e.g. the wu xing controlling star, dashed
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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) {
|
||
// no wordmark in the app itself — the landing page is the front door and
|
||
// the instrument doesn't need to be told what it's called. Only the
|
||
// counts and the header line remain.
|
||
ctx.textBaseline = "alphabetic";
|
||
|
||
// 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);
|
||
}
|
||
// natal ghosts — the sky you were born under, fixed in gold beneath the
|
||
// wheel; watch the living planets close in on your chart
|
||
if (natalActive) {
|
||
const NG = { sun: "☉", moon: "☽", mercury: "☿", venus: "♀", mars: "♂", jupiter: "♃", saturn: "♄" };
|
||
ctx.font = "10px 'Helvetica Neue', Arial, sans-serif";
|
||
for (const p in NG) {
|
||
const x = b.left + b.w * (natalActive.pos[p] / 360), y = godArcY(x, b);
|
||
ctx.fillStyle = "rgba(255,214,130,0.42)";
|
||
ctx.fillText(NG[p], x, y + 13);
|
||
}
|
||
}
|
||
|
||
// 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 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 pk of tickerPins) { // the watchlist — your chosen numbers, live, ahead of the news
|
||
const pn = sources.get(pk);
|
||
if (!pn) continue;
|
||
const label = "📌 " + (pn.label || pk) + " " + fmtVal(pn.raw);
|
||
const w = ctx.measureText(label).width;
|
||
segs.push({ label, w, col: pn.col });
|
||
totalW += w + spacing;
|
||
}
|
||
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;
|
||
|
||
// the sky wheel as a main-screen layer (W) and zen — nothing but the sky (Z)
|
||
let zenMode = false, glyphPass = false;
|
||
let skyWheel = false;
|
||
try { skyWheel = localStorage.getItem("gs_wheel") === "1"; } catch (e) {}
|
||
function toggleWheel() {
|
||
skyWheel = !skyWheel;
|
||
try { localStorage.setItem("gs_wheel", skyWheel ? "1" : "0"); } catch (e) {}
|
||
}
|
||
function setZen(on) { zenMode = on; document.body.classList.toggle("zen", on); }
|
||
const _fillText = ctx.fillText.bind(ctx); // zen silences every canvas label…
|
||
ctx.fillText = (s, x, y, m) => { if (!zenMode || glyphPass) _fillText(s, x, y, m); }; // …except the wheel's glyphs
|
||
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("wheel", (ev) => { // scroll or trackpad-pinch → zoom toward the cursor
|
||
ev.preventDefault();
|
||
const cx = (ev.clientX - view.x) / view.z, cy = (ev.clientY - view.y) / view.z; // content point under cursor
|
||
view.z = clamp(view.z * Math.exp(-ev.deltaY * 0.0016), 1, 6);
|
||
view.x = ev.clientX - cx * view.z; view.y = ev.clientY - cy * view.z;
|
||
applyView();
|
||
}, { passive: false });
|
||
canvas.addEventListener("dblclick", () => { if (view.z !== 1) { view.z = 1; view.x = 0; view.y = 0; applyView(); } });
|
||
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) / view.z, my = (ev.clientY - r.top) / view.z; // screen → zoomed content coords
|
||
if (inGodBand(mx, my)) { godDrag = { x0: mx, bpm0: godtime.bpm, moved: false }; return; }
|
||
const lp = loupeNodeAt(mx, my); // a magnified row in the hover zoom
|
||
if (lp) loupe.frozen = true; // hold the panel still through the press
|
||
const s = lp || 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 activeSkin.stations)
|
||
if (Math.hypot(mx - (c.sx != null ? c.sx : g.cx), my - (c.sy != null ? c.sy : 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 };
|
||
} else if (view.z > 1) { // zoomed in, empty space → pan the view
|
||
panDrag = { sx: ev.clientX, sy: ev.clientY, x0: view.x, y0: view.y };
|
||
}
|
||
});
|
||
window.addEventListener("mousemove", (ev) => {
|
||
const r = canvas.getBoundingClientRect();
|
||
const mx = (ev.clientX - r.left) / view.z, my = (ev.clientY - r.top) / view.z;
|
||
if (panDrag) { // drag the zoomed view around
|
||
view.x = panDrag.x0 + (ev.clientX - panDrag.sx);
|
||
view.y = panDrag.y0 + (ev.clientY - panDrag.sy);
|
||
applyView(); canvas.style.cursor = "grabbing";
|
||
} else 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;
|
||
ungodlyGesture("level"); 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 {
|
||
updateLoupe(mx, my);
|
||
canvas.style.cursor = inGodBand(mx, my) ? "ew-resize"
|
||
: (loupeNodeAt(mx, my) || nodeAt(mx, my) || destAt(mx, my)) ? "pointer"
|
||
: chakraMode ? "grab" : "default";
|
||
}
|
||
});
|
||
window.addEventListener("mouseup", (ev) => {
|
||
loupe.frozen = false; // the hover zoom follows the cursor again
|
||
if (panDrag) { panDrag = null; canvas.style.cursor = "default"; return; }
|
||
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) / view.z, my = (ev.clientY - r.top) / view.z;
|
||
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) { ungodlyMark("patch cable"); 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
|
||
ungodlyMark("solo"); 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
|
||
ungodlyMark("reset");
|
||
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(activeSkin.stations.indexOf(mindChakra) + (ev.key === "ArrowUp" ? -1 : 1),
|
||
0, activeSkin.stations.length - 1);
|
||
enterMind(activeSkin.stations[i2]);
|
||
return;
|
||
}
|
||
}
|
||
if ((ev.metaKey || ev.ctrlKey) && (ev.key === "z" || ev.key === "Z")) { // ⏪ UNGODLY — ⌘Z undo · ⌘⇧Z redo
|
||
ev.preventDefault(); ev.shiftKey ? ungodlyRedo() : ungodlyUndo(); return;
|
||
} // (⌘/⌃ + a letter no longer trips a plain-key shortcut — see guards below)
|
||
if ((ev.key === "p" || ev.key === "P") && !ev.metaKey && !ev.ctrlKey) {
|
||
performMode = !performMode; // pure visuals: the CSS hides every
|
||
document.body.classList.toggle("perform", performMode); // DOM chrome, the frame loop
|
||
} // skips header/godtime/footer
|
||
if ((ev.key === "c" || ev.key === "C") && !zeroMode && !ev.metaKey && !ev.ctrlKey) {
|
||
chakraMode = !chakraMode;
|
||
if (!chakraMode) { layoutDirty = true; exitMind(); } // sources glide home
|
||
else clearTrails();
|
||
}
|
||
if ((ev.key === "o" || ev.key === "O") && !ev.metaKey && !ev.ctrlKey) OMNI.toggle(); // Z5: the OMNI synth panel
|
||
if ((ev.key === "w" || ev.key === "W") && !ev.metaKey && !ev.ctrlKey) toggleWheel(); // the sky wheel layer
|
||
if ((ev.key === "z" || ev.key === "Z") && !ev.metaKey && !ev.ctrlKey) setZen(!zenMode); // zen — nothing but the sky
|
||
if (ev.key === "Escape" && zenMode) setZen(false);
|
||
if ((ev.key === "t" || ev.key === "T") && !ev.metaKey && !ev.ctrlKey) {
|
||
arr.open ? closeArranger() : openArranger(); // tracks, for the casuals
|
||
}
|
||
if ((ev.key === "b" || ev.key === "B") && !ev.metaKey && !ev.ctrlKey) {
|
||
beat.open ? closeBeat() : openBeat(); // 🥁 GODRUM — make a beat
|
||
}
|
||
if ((ev.key === "g" || ev.key === "G") && !ev.metaKey && !ev.ctrlKey) toggleGodspeed(); // ⚡ GODSPEED — the arp panel
|
||
if (beat.open && !ev.repeat && !ev.metaKey && !ev.ctrlKey && !ev.altKey) { // finger-drum the kit (1–5)
|
||
const byCode = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].indexOf(ev.code); // ev.code keeps ⇧+digit mapped
|
||
const di = byCode >= 0 ? byCode : "12345".indexOf(ev.key); // …with an ev.key fallback for code-less envs
|
||
if (di >= 0) {
|
||
const name = DRUM_VOICES[di], vel = ev.shiftKey ? 0.5 : 0.9; // ⇧ = the ghost note
|
||
fireDrum(name, vel, 60000 / Math.max(40, godtime.bpm) / 4 * 0.9);
|
||
if (beat.rec) { // record: quantize the tap to the nearest 16th, write the step
|
||
const step = ((Math.round(seqPhase - (beat.recAnchor || 0)) % 16) + 16) % 16; // anchored at arm when stopped; raw grid, swing rides at playback
|
||
const sq = drumSeqFor("drum." + name);
|
||
ungodlyGesture("beat rec"); sq.steps[step] = 1; sq.vel[step] = vel;
|
||
if (beatRefresh) beatRefresh();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
let ctxLive = null; // the open menu's live-readout timer
|
||
function closeCtx() { ctxEl.classList.remove("open"); ctxEl.innerHTML = ""; if (ctxLive) { clearInterval(ctxLive); ctxLive = null; } }
|
||
function fmtVal(v) { // raw numbers, human-sized
|
||
if (typeof v !== "number" || !isFinite(v)) return "—";
|
||
const a = Math.abs(v);
|
||
if (a >= 1e12) return (v / 1e12).toFixed(2) + "T";
|
||
if (a >= 1e9) return (v / 1e9).toFixed(2) + "B";
|
||
if (a >= 1e6) return (v / 1e6).toFixed(2) + "M";
|
||
if (a >= 1000) return Math.round(v).toLocaleString("en-US");
|
||
if (a >= 10) return v.toFixed(1);
|
||
return v.toFixed(3);
|
||
}
|
||
let tickerPins = new Set(); // your watchlist — live values riding the ticker
|
||
try { tickerPins = new Set(JSON.parse(localStorage.getItem("gs_pins") || "[]")); } catch (e) {}
|
||
function savePins() { try { localStorage.setItem("gs_pins", JSON.stringify([...tickerPins])); } catch (e) {} }
|
||
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 (ctxLive) { clearInterval(ctxLive); ctxLive = null; }
|
||
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 ? " · " + activeSkin.stationOf(node.key) + " " + activeSkin.word : " · voice");
|
||
hd.appendChild(b); hd.appendChild(sp); ctxEl.appendChild(hd);
|
||
|
||
if (isSrc) { // the actual data — live value + two minutes of shape
|
||
const lv = document.createElement("div");
|
||
lv.style.cssText = "color:#9fd0ff;font-size:11px;padding:2px 12px 0";
|
||
const spark = document.createElement("canvas");
|
||
spark.width = 220; spark.height = 30;
|
||
spark.style.cssText = "display:block;margin:2px 12px 4px;width:220px;height:30px";
|
||
const sc = spark.getContext("2d");
|
||
const drawLive = () => {
|
||
lv.textContent = "live: " + fmtVal(node.raw) +
|
||
(typeof node.norm === "number" ? " · " + node.norm.toFixed(2) + " into the matrix" : "");
|
||
const h = node.hist || [];
|
||
sc.clearRect(0, 0, spark.width, spark.height);
|
||
if (h.length > 1) {
|
||
let lo = Infinity, hi = -Infinity;
|
||
for (const v of h) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||
const span = hi - lo || 1;
|
||
sc.beginPath();
|
||
for (let i = 0; i < h.length; i++)
|
||
sc.lineTo(4 + (i / (h.length - 1)) * (spark.width - 8),
|
||
spark.height - 4 - ((h[i] - lo) / span) * (spark.height - 8));
|
||
sc.strokeStyle = rgba(node.col, 0.9); sc.lineWidth = 1.4; sc.stroke();
|
||
sc.fillStyle = "rgba(150,170,200,0.6)"; sc.font = "8px 'Helvetica Neue', Arial, sans-serif";
|
||
sc.textAlign = "left";
|
||
sc.fillText(fmtVal(hi), 4, 8); sc.fillText(fmtVal(lo), 4, spark.height - 1);
|
||
} else {
|
||
sc.fillStyle = "rgba(150,170,200,0.4)"; sc.font = "9px 'Helvetica Neue', Arial, sans-serif";
|
||
sc.fillText("gathering history…", 6, 18);
|
||
}
|
||
};
|
||
drawLive();
|
||
ctxLive = setInterval(drawLive, 400);
|
||
ctxEl.appendChild(lv); ctxEl.appendChild(spark);
|
||
}
|
||
|
||
ctxItem(node.muted ? "unmute" : "mute", () => toggleMuteLocal(node.key), "click");
|
||
if (isSrc) ctxItem(soloKey === node.key ? "unsolo" : "solo",
|
||
() => { ungodlyMark("solo"); soloKey = soloKey === node.key ? null : node.key; }, "⌥-click");
|
||
if (!node.isNote) { // level/trim don't apply to a pitch — no dead sliders
|
||
ctxSlider("level", t0.gain, 0, 2, x => { ungodlyGesture("level"); t0.gain = x; }, x => x.toFixed(2) + "×");
|
||
ctxSlider("trim", t0.offset, -1, 1, x => { ungodlyGesture("trim"); 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 = () => { ungodlyMark("unpatch"); removeRouteLocal(rt.source, rt.dest); row.remove(); };
|
||
row.appendChild(lbl); row.appendChild(sl); row.appendChild(x);
|
||
cb.appendChild(row);
|
||
}
|
||
if (rel.length) ctxItem("💾 save these as a vibe", () => { // this node's cables → a shareable vibe (Z3)
|
||
closeCtx();
|
||
nameVibeCard(isSrc ? (node.label || node.key) : prettyDest(node.key), (name) => {
|
||
const vibe = vibeFromRoutes(rel, name, "✨");
|
||
if (!vibe) return;
|
||
vibeStore.save(vibe); applyVibe(vibe);
|
||
eventTicker.unshift({ text: "💾 saved vibe “" + vibe.name + "” · " + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + " — ⚙ → ✨ vibes to share it", tAdded: now(), src: "sys" });
|
||
});
|
||
});
|
||
|
||
// 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 = () => {
|
||
ungodlyMark("patch to");
|
||
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");
|
||
if (isSrc) ctxItem(tickerPins.has(node.key) ? "📌 unpin from the ticker" : "📌 pin to the ticker",
|
||
() => { if (tickerPins.has(node.key)) tickerPins.delete(node.key); else tickerPins.add(node.key); savePins(); });
|
||
ctxItem("reset to neutral", () => resetTweaks(node.key), "⌘⇧-click");
|
||
ctxItem("📽 project this node", () => openStage({ type: "node", key: node.key }));
|
||
const mapSub = ctxSub("⌨ map a key to this");
|
||
const mk = (lbl, act) => { const it2 = document.createElement("div"); it2.className = "it"; it2.textContent = lbl;
|
||
it2.onclick = () => { bindKeyPrompt(act, node.key, prettyDest(node.key)); closeCtx(); }; mapSub.appendChild(it2); };
|
||
mk("raise its level", "nudgeUp");
|
||
mk("lower its level", "nudgeDown");
|
||
mk("mute / unmute", "muteKey");
|
||
if (isSrc) mk("solo", "soloKeyA");
|
||
if (isSrc && chakraMode)
|
||
ctxItem("📽 project its " + activeSkin.word,
|
||
() => openStage({ type: "station", key: node.station || activeSkin.stationOf(node.key) }));
|
||
if (!isSrc && commune.room) {
|
||
const owner = commune.alloc[node.key];
|
||
if (owner === communeMe()) ctxItem("🕸 release this voice", () => communeRelease(node.key));
|
||
else ctxItem(owner ? "🕸 held by " + owner + (commune.host === communeMe() ? " — reassign to you" : " — ask the host")
|
||
: "🕸 claim this voice (you play it)",
|
||
() => communeClaim(node.key));
|
||
}
|
||
const hint = document.createElement("div"); hint.className = "hint";
|
||
hint.textContent = "drag = patch a cable · ⇧-drag = ride the level";
|
||
ctxEl.appendChild(hint);
|
||
} 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",
|
||
() => { if (zeroMode) return; chakraMode = !chakraMode; if (!chakraMode) layoutDirty = true; }, "C");
|
||
const skinSub = ctxSub("skin · " + activeSkin.label);
|
||
for (const sk of Object.keys(SKINS)) {
|
||
const it2 = document.createElement("div"); it2.className = "it";
|
||
it2.textContent = (SKINS[sk].key === activeSkin.key ? "✓ " : "") + SKINS[sk].label;
|
||
it2.onclick = () => { setSkin(sk); if (!chakraMode && !zeroMode) { chakraMode = true; clearTrails(); } closeCtx(); };
|
||
skinSub.appendChild(it2);
|
||
}
|
||
ctxItem(performMode ? "leave performance mode" : "performance mode",
|
||
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "p" })), "P");
|
||
ctxItem((skyWheel ? "✓ " : "") + "☉ sky wheel — signs, houses & planets", () => toggleWheel(), "W");
|
||
ctxItem(zenMode ? "leave zen" : "☾ zen — only the visuals", () => setZen(!zenMode), "Z");
|
||
ctxItem(arr.open ? "close tracks" : "🎛 tracks — arrange clips",
|
||
() => { arr.open ? closeArranger() : openArranger(); }, "T");
|
||
ctxItem(beat.open ? "close beat" : "🥁 beat — make a beat",
|
||
() => { beat.open ? closeBeat() : openBeat(); }, "B");
|
||
ctxItem("⏪ UNGODLY — take it back" + (ungodly.past.length ? " (" + ungodly.past.length + ")" : ""),
|
||
() => ungodlyUndo(), "⌘Z");
|
||
const moodSub = ctxSub("🌗 mood — how much world");
|
||
const z0 = document.createElement("div"); z0.className = "it";
|
||
z0.textContent = zeroMode ? "0 zero — you're building it (a mood leaves)" : "0 zero — build from nothing";
|
||
z0.onclick = () => { enterZero(); closeCtx(); };
|
||
moodSub.appendChild(z0);
|
||
for (const mn of Object.keys(MOODS)) {
|
||
const it2 = document.createElement("div"); it2.className = "it"; it2.textContent = mn;
|
||
it2.onclick = () => { zeroMode ? exitZeroToMood(mn) : applyMood(mn); closeCtx(); };
|
||
moodSub.appendChild(it2);
|
||
}
|
||
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"));
|
||
ctxItem("hover zoom: " + (loupe.on ? "on" : "off"),
|
||
() => { loupe.on = !loupe.on; try { localStorage.setItem("gs_loupe", loupe.on ? "1" : "0"); } catch (e) {} });
|
||
if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { ungodlyMark("solo"); soloKey = null; });
|
||
ctxSep();
|
||
if (!hrDevice) {
|
||
ctxItem("♥ connect your heart (bluetooth)", () => connectHeart());
|
||
} else {
|
||
ctxItem("♥ " + (heartBpm() ? Math.round(heartBpm()) + " bpm" : "listening…") + " · disconnect",
|
||
() => disconnectHeart());
|
||
ctxItem((heartDrivesGodtime ? "✓ " : "") + "godtime follows your heart",
|
||
() => { heartDrivesGodtime = !heartDrivesGodtime; });
|
||
}
|
||
ctxItem("💰 your holdings…" + (holdings.length ? " (" + holdings.length + ")" : ""),
|
||
() => openHoldings());
|
||
ctxItem("✨ your stars…" + (natalActive ? " (" + natalActive.name + ")" : ""),
|
||
() => openNatal());
|
||
ctxItem("🕸 the commune…" + (commune.room ? " (" + commune.room + ")" : ""),
|
||
() => openCommune());
|
||
ctxItem("⚡ control voltage…" + (cv.on ? " (live)" : ""),
|
||
() => openCV());
|
||
ctxItem((earthEchoOn ? "🌀 earth echo: on" : "🌀 earth echo — the planet on tape"),
|
||
() => earthEchoSet(!earthEchoOn));
|
||
ctxItem("🎸 string voice" + (Synth.stringVoice() ? " (on — plucks ring like a string)" : " — swap the FM tine for a Karplus string"),
|
||
() => Synth.stringVoice(!Synth.stringVoice()));
|
||
ctxItem("🎛 GODSONIQ" + (Synth.sampling() ? " (on — plucks replay your capture · tap to re-sample)" : " — resample the world onto the keys"),
|
||
() => { const ok = Synth.godsoniq(3);
|
||
eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling the world for 3s… then every pluck replays it, pitched across the keys (🎸 string voice to exit)"
|
||
: "🎛 GODSONIQ needs the audio worklet — press ♪ first, then try again", tAdded: now(), src: "sys" }); });
|
||
ctxItem("🌾 AMPLER — there is ample (sample the world into the kit)", () => openAmpler());
|
||
ctxItem((Synth.testamentState && Synth.testamentState().on) ? "📼 TESTAMENT — stop & save the take" : "📼 TESTAMENT — record what god does",
|
||
() => { if (Synth.testament) Synth.testament(); });
|
||
ctxItem("⚡ GODSPEED — hold a chord, the machine runs", () => openGodspeed());
|
||
ctxItem((psalm.on ? "🕊 PSALM — listening… (click to stop)" : "🕊 PSALM — sing, and the instrument answers"), () => togglePsalm());
|
||
ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"),
|
||
async () => { const r = await Synth.feedTab();
|
||
const msg = r.stopped ? "🌐 tab feed stopped"
|
||
: r.ok ? "🌐 tab feed is live — it's in the world now: route crush for lo-fi, or 🎛 GODSONIQ to resample it pitched"
|
||
: r.reason === "unsupported" ? "🌐 tab-audio capture needs Chrome or Edge (Safari & Firefox can't). On a Mac it grabs one TAB's sound, not all-system"
|
||
: r.reason === "no-audio" ? "🌐 pick the tab AND tick “Share tab audio” in the picker — no audio came through"
|
||
: "🌐 tab feed cancelled";
|
||
eventTicker.unshift({ text: msg, tAdded: now(), src: "sys" }); });
|
||
ctxItem("⌨ keys — remap the keyboard", () => openKeys());
|
||
ctxItem("🌱 lifestrument — the gentle one (new tab)", () => window.open("/life.html", "_blank"));
|
||
if (commune.room) {
|
||
const gOwner = commune.alloc["@godtime"];
|
||
ctxItem(gOwner === communeMe() ? "🕸 release the godtime" :
|
||
(gOwner ? "🕸 godtime held by " + gOwner : "🕸 claim the godtime (tempo)"),
|
||
() => { gOwner === communeMe() ? communeRelease("@godtime") : communeClaim("@godtime"); });
|
||
}
|
||
ctxSep();
|
||
ctxItem(stageWin ? "📽 stage: whole view" : "📽 the stage — project this view",
|
||
() => openStage(null));
|
||
if (stageWin) ctxItem("📽 close the stage", () => closeStage());
|
||
}
|
||
ctxEl.classList.add("open");
|
||
ctxEl.style.left = Math.min(px, window.innerWidth - 252) + "px";
|
||
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) / view.z, my = (ev.clientY - r.top) / view.z; // zoomed content coords
|
||
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 ? " · " + activeSkin.stationOf(node.key) + " " + activeSkin.word : "");
|
||
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 = () => { ungodlyMark("unpatch"); removeRouteLocal(rt.source, rt.dest); setTimeout(() => openInspector(node), 120); };
|
||
row.appendChild(sl); row.appendChild(x);
|
||
inspectEl.appendChild(row);
|
||
}
|
||
|
||
// ---- the groove grid: piano roll for note voices, 808 gate row for CC
|
||
// voices, sample-&-hold steps for sources (stepped data — "quantize in time")
|
||
{
|
||
const sq = seqFor(node.key, !isSource && node.isNote);
|
||
const wrap = document.createElement("div"); wrap.style.marginTop = "10px";
|
||
const head = document.createElement("div"); head.className = "sub";
|
||
const tog = document.createElement("button"); tog.className = "midibtn";
|
||
const paintTog = () => {
|
||
tog.textContent = sq.on ? "■ groove on" : "▶ groove off";
|
||
tog.classList.toggle("on", sq.on);
|
||
};
|
||
paintTog();
|
||
tog.onclick = () => { sq.on = !sq.on; paintTog(); };
|
||
head.appendChild(tog);
|
||
const lbl = document.createElement("span");
|
||
lbl.style.marginLeft = "8px";
|
||
lbl.textContent = isSource ? "sample & hold — the data steps in your time"
|
||
: node.isNote ? "16 steps · " + NOTE_NAMES[gScale.root] + " " + gScale.name.replace("_", " ") + " · stack 2+ = play one"
|
||
: "16-step gate on the data";
|
||
head.appendChild(lbl);
|
||
wrap.appendChild(head);
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "seqgrid";
|
||
const rows = node.isNote ? SEQ_ROWS.length : 1;
|
||
const cells = [];
|
||
for (let rw = rows - 1; rw >= 0; rw--) { // high notes on top
|
||
for (let stp = 0; stp < 16; stp++) {
|
||
const cell = document.createElement("div");
|
||
cell.className = "seqcell";
|
||
if (stp % 4 === 0) cell.classList.add("bar");
|
||
if (node.isNote) {
|
||
cell.title = midiToName(seqRootFor(node.key) + SEQ_ROWS[rw]) + " · step " + (stp + 1);
|
||
cell.onclick = () => { stepToggle(sq.steps, stp, rw); paint(); }; // stack 2+ = Play One
|
||
} else {
|
||
cell.title = "step " + (stp + 1);
|
||
cell.onclick = () => { sq.steps[stp] = sq.steps[stp] ? 0 : 1; paint(); };
|
||
}
|
||
cells.push({ el: cell, rw, stp });
|
||
grid.appendChild(cell);
|
||
}
|
||
}
|
||
let renderNotesI = null; // note-length overlays (note lanes only)
|
||
if (node.isNote) { if (!sq.len) sq.len = new Array(16).fill(1);
|
||
renderNotesI = attachNoteLengths(grid, cells, () => sq.steps, sq.len, rows, null); }
|
||
const paint = () => {
|
||
for (const c of cells) {
|
||
c.el.classList.toggle("on", node.isNote ? stepHas(sq.steps[c.stp], c.rw) : !!sq.steps[c.stp]);
|
||
c.el.classList.toggle("playone", node.isNote && stepStack(sq.steps[c.stp]) && stepHas(sq.steps[c.stp], c.rw));
|
||
c.el.classList.toggle("cur", c.stp === seqCurStep && sq.on);
|
||
}
|
||
if (renderNotesI) renderNotesI();
|
||
};
|
||
paint();
|
||
seqGridRefresh = paint; // the playhead sweeps while open
|
||
wrap.appendChild(grid);
|
||
inspectEl.appendChild(wrap);
|
||
if (node.isNote) { // hint the new capability
|
||
const lh = document.createElement("div"); lh.className = "sub"; lh.style.marginTop = "2px";
|
||
lh.style.color = "rgba(150,170,200,0.6)"; lh.textContent = "drag a note's right edge → note length";
|
||
wrap.appendChild(lh);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ---- your heart (Web Bluetooth, opt-in) ---------------------------------
|
||
// The standard BLE Heart Rate Service (0x180D). The browser's own permission
|
||
// prompt IS the opt-in; the pulse goes to this page and nowhere else. From
|
||
// connection on, body.heart is a source like any other — and godtime can be
|
||
// set to follow it, so the whole planet plays at the speed of your blood.
|
||
let hrDevice = null;
|
||
let heartDrivesGodtime = false;
|
||
function heartBpm() { const n = sources.get("body.heart"); return n ? n.raw : 0; }
|
||
|
||
async function connectHeart() {
|
||
if (!navigator.bluetooth) {
|
||
eventTicker.unshift({ text: "web bluetooth needs chrome or edge", tAdded: now(), src: "sys" });
|
||
return;
|
||
}
|
||
try {
|
||
const dev = await navigator.bluetooth.requestDevice({ filters: [{ services: ["heart_rate"] }] });
|
||
const server = await dev.gatt.connect();
|
||
const svc = await server.getPrimaryService("heart_rate");
|
||
const ch = await svc.getCharacteristic("heart_rate_measurement");
|
||
await ch.startNotifications();
|
||
ch.addEventListener("characteristicvaluechanged", (e) => {
|
||
const dv = e.target.value;
|
||
const flags = dv.getUint8(0);
|
||
const bpm = (flags & 1) ? dv.getUint16(1, true) : dv.getUint8(1); // spec: bit0 = 16-bit value
|
||
if (!bpm || bpm > 250) return;
|
||
const n = ensureSource("body.heart", { label: "your heart" });
|
||
n.raw = bpm;
|
||
n.norm = clamp((bpm - 40) / (180 - 40), 0, 1);
|
||
if (heartDrivesGodtime && !godtime.locked)
|
||
godtime.bpm = clamp(bpm, 40, 180);
|
||
});
|
||
hrDevice = dev;
|
||
dev.addEventListener("gattserverdisconnected", () => {
|
||
hrDevice = null;
|
||
eventTicker.unshift({ text: "heart disconnected", tAdded: now(), src: "sys" });
|
||
});
|
||
eventTicker.unshift({ text: "♥ your heart is in the choir", tAdded: now(), src: "sys" });
|
||
} catch (e) { /* user closed the chooser — their call, no fuss */ }
|
||
}
|
||
function disconnectHeart() {
|
||
if (hrDevice) { try { hrDevice.gatt.disconnect(); } catch (e) {} hrDevice = null; }
|
||
heartDrivesGodtime = false;
|
||
}
|
||
|
||
// ---- your holdings (the portfolio source, opt-in & client-side) ----------
|
||
// You declare what you hold and how much; the amounts are WEIGHTS. Prices
|
||
// poll from Binance's public spot API (no key, CORS-open) straight to your
|
||
// browser — the server never learns your bag. Symbols: anything with a USDT
|
||
// pair (BTC, ETH, SOL… PAXG is tokenized gold). Each holding becomes
|
||
// me.<sym>, its influence auto-scaled by its share of the total; the whole
|
||
// bag breathes as me.portfolio (drift) and me.portfolio.vel (churn).
|
||
let holdings = []; // [{sym, qty}]
|
||
try { holdings = JSON.parse(localStorage.getItem("gs_holdings") || "[]"); } catch (e) {}
|
||
const holdNorm = {}; // sym -> {min, max} session range
|
||
let holdTimer = null, lastPortfolio = 0, portfolioVel = 0;
|
||
|
||
function holdingsStart() {
|
||
if (holdTimer) clearInterval(holdTimer);
|
||
if (!holdings.length) return;
|
||
pollHoldings();
|
||
holdTimer = setInterval(pollHoldings, 30000); // ponytail: 30s poll; ws stream if it ever matters
|
||
}
|
||
async function pollHoldings() {
|
||
if (!holdings.length) return;
|
||
const syms = holdings.map(h => '"' + h.sym.toUpperCase() + 'USDT"').join(",");
|
||
let arr;
|
||
try {
|
||
const r = await fetch("https://api.binance.com/api/v3/ticker/price?symbols=[" + syms + "]");
|
||
arr = await r.json();
|
||
} catch (e) { return; } // offline / blocked: try again next poll
|
||
if (!Array.isArray(arr)) {
|
||
eventTicker.unshift({ text: "holdings: a symbol has no USDT pair on binance", tAdded: now(), src: "sys" });
|
||
return;
|
||
}
|
||
const price = {};
|
||
for (const t of arr) price[t.symbol.replace(/USDT$/, "")] = parseFloat(t.price);
|
||
let total = 0;
|
||
for (const h of holdings) total += (price[h.sym.toUpperCase()] || 0) * h.qty;
|
||
for (const h of holdings) {
|
||
const sym = h.sym.toUpperCase(), pv = price[sym];
|
||
if (!pv) continue;
|
||
const key = "me." + sym.toLowerCase();
|
||
const nrm = holdNorm[sym] || (holdNorm[sym] = { min: pv, max: pv });
|
||
nrm.min = Math.min(nrm.min, pv); nrm.max = Math.max(nrm.max, pv);
|
||
const share = total > 0 ? (pv * h.qty) / total : 0;
|
||
const fresh = !sources.has(key);
|
||
const n = ensureSource(key, { label: sym + " · " + Math.round(share * 100) + "%" });
|
||
n.label = sym + " · " + Math.round(share * 100) + "%";
|
||
n.raw = pv;
|
||
n.norm = nrm.max > nrm.min ? (pv - nrm.min) / (nrm.max - nrm.min) : 0.5;
|
||
if (fresh) tw(key).gain = clamp(share * 2, 0.2, 2); // your weights, literally
|
||
}
|
||
if (total > 0) {
|
||
const tn = holdNorm.__total || (holdNorm.__total = { min: total, max: total });
|
||
tn.min = Math.min(tn.min, total); tn.max = Math.max(tn.max, total);
|
||
const nTot = ensureSource("me.portfolio", { label: "your portfolio" });
|
||
nTot.raw = total;
|
||
nTot.norm = tn.max > tn.min ? (total - tn.min) / (tn.max - tn.min) : 0.5;
|
||
portfolioVel = lastPortfolio > 0 ? Math.abs(total - lastPortfolio) / lastPortfolio : 0;
|
||
lastPortfolio = total;
|
||
const nVel = ensureSource("me.portfolio.vel", { label: "portfolio churn" });
|
||
nVel.raw = portfolioVel * 100;
|
||
nVel.norm = clamp(portfolioVel * 120, 0, 1); // ~0.8% move per poll = full scale
|
||
}
|
||
}
|
||
|
||
// the holdings panel — a small private ledger, localStorage only
|
||
const holdEl = document.createElement("div");
|
||
holdEl.id = "holdings";
|
||
document.body.appendChild(holdEl);
|
||
function closeHoldings() { holdEl.classList.remove("open"); }
|
||
function openHoldings() {
|
||
holdEl.innerHTML = "";
|
||
const h3 = document.createElement("h3"); h3.textContent = "💰 your holdings";
|
||
const x = document.createElement("span"); x.className = "close"; x.textContent = "×";
|
||
x.onclick = closeHoldings;
|
||
holdEl.appendChild(x); holdEl.appendChild(h3);
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.textContent = "amounts are weights — a bigger bag sings louder. prices poll straight to your browser; the server never sees this. symbols need a USDT pair (BTC · ETH · SOL · PAXG = gold).";
|
||
holdEl.appendChild(sub);
|
||
const rows = document.createElement("div");
|
||
holdEl.appendChild(rows);
|
||
const addRow = (sym, qty) => {
|
||
const row = document.createElement("div"); row.className = "hrow";
|
||
const si = document.createElement("input"); si.placeholder = "BTC"; si.value = sym || ""; si.maxLength = 8;
|
||
const qi = document.createElement("input"); qi.placeholder = "0.5"; qi.value = qty != null ? qty : ""; qi.type = "number"; qi.step = "any";
|
||
const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "×";
|
||
rm.onclick = () => row.remove();
|
||
row.appendChild(si); row.appendChild(qi); row.appendChild(rm);
|
||
rows.appendChild(row);
|
||
};
|
||
for (const h of holdings) addRow(h.sym, h.qty);
|
||
if (!holdings.length) addRow();
|
||
const add = document.createElement("button"); add.className = "midibtn"; add.textContent = "+ add";
|
||
add.onclick = () => addRow();
|
||
const save = document.createElement("button"); save.className = "midibtn"; save.style.marginLeft = "8px";
|
||
save.textContent = "save & listen";
|
||
save.onclick = () => {
|
||
holdings = [...rows.querySelectorAll(".hrow")].map(r => {
|
||
const [si, qi] = r.querySelectorAll("input");
|
||
return { sym: si.value.trim().toUpperCase(), qty: parseFloat(qi.value) };
|
||
}).filter(h => h.sym && h.qty > 0);
|
||
try { localStorage.setItem("gs_holdings", JSON.stringify(holdings)); } catch (e) {}
|
||
holdingsStart();
|
||
closeHoldings();
|
||
if (holdings.length)
|
||
eventTicker.unshift({ text: "💰 your bag is in the choir (" + holdings.length + " holdings)", tAdded: now(), src: "sys" });
|
||
};
|
||
const btns = document.createElement("div"); btns.style.marginTop = "8px";
|
||
btns.appendChild(add); btns.appendChild(save);
|
||
holdEl.appendChild(btns);
|
||
holdEl.classList.add("open");
|
||
}
|
||
if (holdings.length) holdingsStart(); // returning holders rejoin the choir on load
|
||
|
||
// ---- control voltage out (⚡) — the world as raw electricity for modular gear
|
||
// Two paths. (1) MIDI-to-CV, already live: the note voices are 1V/oct pitch +
|
||
// gate and the CCs are modulation, so any MIDI-CV module (Befaco, CM1A, FH-2,
|
||
// Yarns…) is driven today. (2) This module: TRUE DC control voltage from the
|
||
// browser via Web Audio ConstantSourceNodes → a DC-coupled interface (Expert
|
||
// Sleepers ES-8/ES-9…). Each channel maps a source/voice to a voltage, at
|
||
// unipolar / bipolar / 1V-oct, with a calibration trim — because a real
|
||
// interface and a real oscillator never track perfectly on paper.
|
||
const CV_MODES = ["unipolar", "bipolar", "1V/oct"];
|
||
const cv = { on: false, ctx: null, merger: null, sink: "default", timer: null, rows: [] };
|
||
try { const s2 = JSON.parse(localStorage.getItem("gs_cv") || "null"); if (s2 && Array.isArray(s2.rows)) { cv.rows = s2.rows; cv.sink = s2.sink || "default"; } } catch (e) {}
|
||
cv.rows.forEach(r => { r.node = null; });
|
||
|
||
function cvNodeFor(key) { return sources.get(key) || dests.get(key); }
|
||
function cvValue(row) { // → offset in Web Audio "sample units" (-1..1)
|
||
const n = cvNodeFor(row.key); if (!n) return 0;
|
||
const amt = row.amount != null ? row.amount : 0.5, trim = row.trim || 0;
|
||
if (row.mode === "1V/oct") { // amount = sample-units per octave (calibrate ~0.1 for ±10V full-scale)
|
||
const note = n.isNote ? n.raw : (n.norm || 0) * 60 + 24;
|
||
return clamp((Math.round(note) - 24) / 12 * amt + trim, -1, 1);
|
||
}
|
||
const v = clamp(n.norm || 0, 0, 1);
|
||
return clamp((row.mode === "bipolar" ? v * 2 - 1 : v) * amt + trim, -1, 1);
|
||
}
|
||
function cvWire(row) {
|
||
if (!cv.ctx || !cv.merger) return;
|
||
if (row.node) { try { row.node.disconnect(); row.node.stop(); } catch (e) {} }
|
||
const cs = cv.ctx.createConstantSource(); cs.offset.value = 0; cs.start();
|
||
cs.connect(cv.merger, 0, clamp(Math.round(row.ch || 0), 0, cv.merger.numberOfInputs - 1));
|
||
row.node = cs;
|
||
}
|
||
function cvTick() {
|
||
if (!cv.on || !cv.ctx) return;
|
||
for (const row of cv.rows) if (row.node)
|
||
row.node.offset.setTargetAtTime(cvValue(row), cv.ctx.currentTime, 0.006);
|
||
}
|
||
async function cvStart() {
|
||
if (cv.on) return;
|
||
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
|
||
cv.ctx = new AC();
|
||
if (cv.sink !== "default" && cv.ctx.setSinkId) { try { await cv.ctx.setSinkId(cv.sink); } catch (e) {} }
|
||
await cv.ctx.resume();
|
||
const maxCh = Math.min(8, cv.ctx.destination.maxChannelCount || 2);
|
||
try { cv.ctx.destination.channelCount = maxCh;
|
||
cv.ctx.destination.channelCountMode = "explicit";
|
||
cv.ctx.destination.channelInterpretation = "discrete"; } catch (e) {}
|
||
cv.merger = cv.ctx.createChannelMerger(maxCh);
|
||
cv.merger.connect(cv.ctx.destination);
|
||
for (const row of cv.rows) cvWire(row);
|
||
cv.on = true;
|
||
if (!cv.timer) cv.timer = setInterval(cvTick, 15);
|
||
eventTicker.unshift({ text: "⚡ control voltage live on " + maxCh + " channels — DC out to your modular", tAdded: now(), src: "sys" });
|
||
}
|
||
function cvStop() {
|
||
cv.on = false;
|
||
if (cv.timer) { clearInterval(cv.timer); cv.timer = null; }
|
||
for (const row of cv.rows) { if (row.node) { try { row.node.stop(); row.node.disconnect(); } catch (e) {} row.node = null; } }
|
||
if (cv.ctx) { try { cv.ctx.close(); } catch (e) {} cv.ctx = null; cv.merger = null; }
|
||
}
|
||
function cvSave() { try { localStorage.setItem("gs_cv", JSON.stringify({ sink: cv.sink, rows: cv.rows.map(r => ({ key: r.key, ch: r.ch, mode: r.mode, amount: r.amount, trim: r.trim })) })); } catch (e) {} }
|
||
|
||
const cvEl = document.createElement("div"); cvEl.id = "cvpanel"; document.body.appendChild(cvEl);
|
||
function closeCV() { cvEl.classList.remove("open"); }
|
||
async function openCV() {
|
||
cvEl.innerHTML = "";
|
||
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×"; x2.onclick = closeCV; cvEl.appendChild(x2);
|
||
const h3 = document.createElement("h3"); h3.textContent = "⚡ control voltage"; cvEl.appendChild(h3);
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.innerHTML = "true DC voltage out of the browser into a <b>DC-coupled interface</b> (Expert Sleepers ES-8/ES-9…). Each channel maps a feed to a voltage. <i>No DC-coupled gear? The MIDI out already drives any MIDI-to-CV module — see the grimoire.</i>";
|
||
cvEl.appendChild(sub);
|
||
// output device
|
||
const devRow = document.createElement("div"); devRow.className = "hrow";
|
||
const devSel = document.createElement("select"); devSel.className = "asel"; devSel.style.flex = "1";
|
||
const optD = document.createElement("option"); optD.value = "default"; optD.textContent = "default output device"; devSel.appendChild(optD);
|
||
try {
|
||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||
devs.filter(d => d.kind === "audiooutput").forEach(d => {
|
||
if (d.deviceId === "default") return;
|
||
const o = document.createElement("option"); o.value = d.deviceId; o.textContent = d.label || ("output " + d.deviceId.slice(0, 6)); devSel.appendChild(o);
|
||
});
|
||
} catch (e) {}
|
||
devSel.value = cv.sink;
|
||
devSel.onchange = () => { cv.sink = devSel.value; cvSave(); if (cv.on) { cvStop(); cvStart(); } };
|
||
devRow.appendChild(devSel); cvEl.appendChild(devRow);
|
||
// rows
|
||
const list = document.createElement("div"); cvEl.appendChild(list);
|
||
const allKeys = Array.from(sources.keys()).sort().concat(Array.from(dests.keys()).sort());
|
||
const drawRow = (row) => {
|
||
const rw = document.createElement("div"); rw.className = "cvrow";
|
||
const kSel = document.createElement("select"); kSel.className = "asel";
|
||
for (const k2 of allKeys) { const o = document.createElement("option"); o.value = k2; o.textContent = (dests.has(k2) ? "▸ " : "") + prettyDest(k2); if (k2 === row.key) o.selected = true; kSel.appendChild(o); }
|
||
kSel.onchange = () => { row.key = kSel.value; cvSave(); };
|
||
const chSel = document.createElement("select"); chSel.className = "asel"; chSel.style.width = "48px";
|
||
for (let c2 = 0; c2 < 8; c2++) { const o = document.createElement("option"); o.value = c2; o.textContent = "ch" + (c2 + 1); if (c2 === (row.ch || 0)) o.selected = true; chSel.appendChild(o); }
|
||
chSel.onchange = () => { row.ch = +chSel.value; if (cv.on) cvWire(row); cvSave(); };
|
||
const mSel = document.createElement("select"); mSel.className = "asel"; mSel.style.width = "78px";
|
||
for (const md of CV_MODES) { const o = document.createElement("option"); o.value = md; o.textContent = md; if (md === row.mode) o.selected = true; mSel.appendChild(o); }
|
||
mSel.onchange = () => { row.mode = mSel.value; cvSave(); };
|
||
const amt = document.createElement("input"); amt.type = "range"; amt.min = "0"; amt.max = "1"; amt.step = "0.01"; amt.value = row.amount != null ? row.amount : 0.5; amt.title = "scale (calibrate)"; amt.style.width = "60px";
|
||
amt.oninput = () => { row.amount = parseFloat(amt.value); cvSave(); };
|
||
const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "×";
|
||
rm.onclick = () => { const i = cv.rows.indexOf(row); if (i >= 0) { if (row.node) { try { row.node.stop(); row.node.disconnect(); } catch (e) {} } cv.rows.splice(i, 1); } cvSave(); openCV(); };
|
||
rw.appendChild(kSel); rw.appendChild(chSel); rw.appendChild(mSel); rw.appendChild(amt); rw.appendChild(rm);
|
||
list.appendChild(rw);
|
||
};
|
||
cv.rows.forEach(drawRow);
|
||
const add = document.createElement("button"); add.className = "midibtn"; add.textContent = "+ channel"; add.style.marginTop = "6px";
|
||
add.onclick = () => { const row = { key: allKeys[0], ch: cv.rows.length % 8, mode: "unipolar", amount: 0.5, trim: 0, node: null }; cv.rows.push(row); if (cv.on) cvWire(row); cvSave(); openCV(); };
|
||
const tog = document.createElement("button"); tog.className = "midibtn"; tog.style.marginLeft = "8px";
|
||
tog.textContent = cv.on ? "■ stop CV" : "▶ start CV"; tog.classList.toggle("on", cv.on);
|
||
tog.onclick = () => { cv.on ? cvStop() : cvStart(); openCV(); };
|
||
const btns = document.createElement("div"); btns.style.marginTop = "8px"; btns.appendChild(tog); btns.appendChild(add); cvEl.appendChild(btns);
|
||
const cal = document.createElement("div"); cal.className = "sub"; cal.style.marginTop = "8px";
|
||
cal.innerHTML = "<b>calibrate:</b> for <b>1V/oct</b> pitch, nudge <i>scale</i> until an octave of change lands a true octave on your oscillator (interfaces differ; ES-8 ≈ 0.1 scale). unipolar = 0→+V, bipolar = −V→+V.";
|
||
cvEl.appendChild(cal);
|
||
cvEl.classList.add("open");
|
||
}
|
||
if (cv.rows.length) { /* wait for user to press start — audio needs a gesture */ }
|
||
|
||
// ---- the Earth Echo — a tape delay whose knobs ARE matrix voices, wired to
|
||
// the planet by default (any feed can be re-patched onto them like any voice).
|
||
let earthEchoOn = false;
|
||
try { earthEchoOn = localStorage.getItem("gs_echo") === "1"; } catch (e) {}
|
||
const ECHO_CABLES = [
|
||
["weather.wind", "echo.time", 0.75], // wind whips the tape speed → pitch-glide
|
||
["crypto.vel", "echo.feedback", 0.8], // volatility drives it toward self-oscillation
|
||
["air.pm25", "echo.tone", 0.9], // pollution rolls the treble off, dark dub soup
|
||
["astro.moon", "echo.flutter", 0.65], // the moon warbles the pitch (wow & flutter)
|
||
["fire.count", "echo.wear", 0.7], // the world's fires wear the tape (hiss)
|
||
["quake.event", "echo.wet", 0.6], // a quake throws the whole echo forward
|
||
];
|
||
function earthEchoSet(on) {
|
||
earthEchoOn = on;
|
||
try { localStorage.setItem("gs_echo", on ? "1" : "0"); } catch (e) {}
|
||
if (on) {
|
||
for (const [s, d2, a] of ECHO_CABLES) addRouteLocal(s, d2, a); // installs cables + echo.* voices
|
||
eventTicker.unshift({ text: "🌀 earth echo on — the planet is on the tape: wind→time · volatility→feedback · air→tone · moon→wow · fire→wear. patch any of it like any voice.", tAdded: now(), src: "sys" });
|
||
} else {
|
||
for (const [s, d2] of ECHO_CABLES) removeRouteLocal(s, d2);
|
||
eventTicker.unshift({ text: "🌀 earth echo off", tAdded: now(), src: "sys" });
|
||
}
|
||
}
|
||
if (earthEchoOn) setTimeout(() => { for (const [s, d2, a] of ECHO_CABLES) addRouteLocal(s, d2, a); }, 1200);
|
||
|
||
// ---- the keymap — global, fully remappable keyboard control ---------------
|
||
// Every binding is key → action (some actions take a target voice/source).
|
||
// F1–F10 ship with a useful default set; bind anything from the ⌨ keys panel
|
||
// or "map a key" in a node's right-click menu. a/s as up/down on a voice? bind
|
||
// nudge-up / nudge-down to a & s, targeted at that voice.
|
||
let cycleMoodIdx = 0;
|
||
function cycleMood() { const ks = Object.keys(MOODS); cycleMoodIdx = (cycleMoodIdx + 1) % ks.length; applyMood(ks[cycleMoodIdx]); }
|
||
const KEY_ACTIONS = {
|
||
grimoire: { label: "open the grimoire (help)", run: () => { const g = document.getElementById("grimoire"); if (g) g.classList.add("open"); } },
|
||
play: { label: "play / stop the synth", run: () => { if (typeof Synth !== "undefined") Synth.toggle(); } },
|
||
chakraView:{ label: "chakra / skin view", run: () => { if (zeroMode) return; chakraMode = !chakraMode; if (!chakraMode) { layoutDirty = true; exitMind(); } else clearTrails(); } },
|
||
skinNext: { label: "next skin", run: () => { setSkin(nextSkinKey()); if (!chakraMode && !zeroMode) { chakraMode = true; clearTrails(); } } },
|
||
skinPrev: { label: "previous skin", run: () => { setSkin(prevSkinKey()); if (!chakraMode && !zeroMode) { chakraMode = true; clearTrails(); } } },
|
||
moodCycle: { label: "cycle mood (first light / drift / storm)", run: () => cycleMood() },
|
||
tracks: { label: "tracks / arranger", run: () => { arr.open ? closeArranger() : openArranger(); } },
|
||
perform: { label: "performance mode", run: () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "p" })) },
|
||
skyWheel: { label: "the sky wheel (zodiac layer)", run: () => toggleWheel() },
|
||
zen: { label: "zen — only the visuals", run: () => setZen(!zenMode) },
|
||
godLock: { label: "lock / unlock godtime", run: () => { godtime.locked = !godtime.locked; } },
|
||
stage: { label: "the stage (project the view)", run: () => { stageWin ? closeStage() : openStage(null); } },
|
||
earthEcho: { label: "toggle the earth echo", run: () => earthEchoSet(!earthEchoOn) },
|
||
holdings: { label: "your holdings", run: () => openHoldings() },
|
||
stars: { label: "your stars", run: () => openNatal() },
|
||
commune: { label: "the commune", run: () => openCommune() },
|
||
hoverZoom: { label: "toggle hover zoom", run: () => { loupe.on = !loupe.on; try { localStorage.setItem("gs_loupe", loupe.on ? "1" : "0"); } catch (e) {} } },
|
||
nudgeUp: { label: "raise the level of…", targeted: true, run: (k) => { const t = tw(k); t.gain = clamp((t.gain != null ? t.gain : 1) + 0.08, 0, 2); } },
|
||
nudgeDown: { label: "lower the level of…", targeted: true, run: (k) => { const t = tw(k); t.gain = clamp((t.gain != null ? t.gain : 1) - 0.08, 0, 2); } },
|
||
muteKey: { label: "mute / unmute…", targeted: true, run: (k) => toggleMuteLocal(k) },
|
||
soloKeyA: { label: "solo…", targeted: true, run: (k) => { soloKey = soloKey === k ? null : k; } },
|
||
zero: { label: "zero — build from nothing", run: () => { zeroMode ? exitZeroToMood("first light") : enterZero(); } },
|
||
};
|
||
const DEFAULT_KEYS = { F1: { a: "grimoire" }, F2: { a: "play" }, F3: { a: "chakraView" }, F4: { a: "skinNext" },
|
||
F5: { a: "moodCycle" }, F6: { a: "tracks" }, F7: { a: "perform" }, F8: { a: "godLock" }, F9: { a: "earthEcho" }, F10: { a: "stage" }, F11: { a: "zero" } };
|
||
let keymap = {};
|
||
try { keymap = JSON.parse(localStorage.getItem("gs_keys")) || {}; } catch (e) {}
|
||
if (!Object.keys(keymap).length) keymap = JSON.parse(JSON.stringify(DEFAULT_KEYS));
|
||
function saveKeys() { try { localStorage.setItem("gs_keys", JSON.stringify(keymap)); } catch (e) {} }
|
||
const normKey = (k) => (k && k.length === 1 ? k.toLowerCase() : k); // 'A' and 'a' are one key
|
||
let keyCapture = null; // fn(key) while binding
|
||
|
||
window.addEventListener("keydown", (ev) => { // capture phase — keymap wins
|
||
const tag = (ev.target && ev.target.tagName) || "";
|
||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||
const k = normKey(ev.key);
|
||
if (keyCapture) { // binding a key
|
||
if (["Shift", "Control", "Alt", "Meta"].indexOf(ev.key) >= 0) return;
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
const fn = keyCapture; keyCapture = null; fn(k);
|
||
return;
|
||
}
|
||
const b = keymap[k];
|
||
if (!b || !KEY_ACTIONS[b.a]) return;
|
||
if (mindChakra && k.length === 1) return; // the mind-mode console owns letters
|
||
ev.preventDefault(); ev.stopPropagation(); // override any built-in for this key
|
||
try { KEY_ACTIONS[b.a].run(b.arg); } catch (e) {}
|
||
}, true);
|
||
|
||
function bindKeyPrompt(action, arg, niceTarget) {
|
||
keyCapture = (k) => {
|
||
keymap[k] = arg != null ? { a: action, arg } : { a: action };
|
||
saveKeys();
|
||
eventTicker.unshift({ text: "⌨ " + (k === " " ? "space" : k) + " → " + KEY_ACTIONS[action].label.replace("…", "") + (arg ? " " + niceTarget : ""), tAdded: now(), src: "sys" });
|
||
if (keysEl.classList.contains("open")) openKeys();
|
||
};
|
||
eventTicker.unshift({ text: "⌨ press any key to bind…", tAdded: now(), src: "sys" });
|
||
}
|
||
|
||
// the ⌨ keys panel
|
||
const keysEl = document.createElement("div"); keysEl.id = "keys"; document.body.appendChild(keysEl);
|
||
function closeKeys() { keysEl.classList.remove("open"); }
|
||
function openKeys() {
|
||
keysEl.innerHTML = "";
|
||
const x2 = document.createElement("span"); x2.className = "close"; x2.textContent = "×"; x2.onclick = closeKeys; keysEl.appendChild(x2);
|
||
const h3 = document.createElement("h3"); h3.textContent = "⌨ keys"; keysEl.appendChild(h3);
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.textContent = "global keyboard control. F1–F10 come pre-set; bind any key to any action below (or map a key from a voice's right-click menu). a & s as up/down on a voice? bind raise/lower there.";
|
||
keysEl.appendChild(sub);
|
||
const rows = document.createElement("div");
|
||
const drawBinding = (k) => {
|
||
const b = keymap[k]; const act = KEY_ACTIONS[b.a]; if (!act) return;
|
||
const row = document.createElement("div"); row.className = "hrow";
|
||
const kk = document.createElement("span"); kk.className = "keycap"; kk.textContent = (k === " " ? "space" : k);
|
||
const lbl = document.createElement("span"); lbl.style.flex = "1"; lbl.style.fontSize = "11px";
|
||
lbl.textContent = act.label.replace("…", "") + (b.arg ? " " + prettyDest(b.arg) : "");
|
||
const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "×";
|
||
rm.onclick = () => { delete keymap[k]; saveKeys(); openKeys(); };
|
||
row.appendChild(kk); row.appendChild(lbl); row.appendChild(rm); rows.appendChild(row);
|
||
};
|
||
Object.keys(keymap).sort().forEach(drawBinding);
|
||
keysEl.appendChild(rows);
|
||
// add a global (non-targeted) binding
|
||
const addRow = document.createElement("div"); addRow.className = "hrow"; addRow.style.marginTop = "8px";
|
||
const aSel = document.createElement("select"); aSel.className = "asel"; aSel.style.flex = "1";
|
||
for (const a in KEY_ACTIONS) if (!KEY_ACTIONS[a].targeted) { const o = document.createElement("option"); o.value = a; o.textContent = KEY_ACTIONS[a].label; aSel.appendChild(o); }
|
||
const bindBtn = document.createElement("button"); bindBtn.className = "midibtn"; bindBtn.textContent = "bind a key";
|
||
bindBtn.onclick = () => bindKeyPrompt(aSel.value, null);
|
||
addRow.appendChild(aSel); addRow.appendChild(bindBtn); keysEl.appendChild(addRow);
|
||
const rst = document.createElement("button"); rst.className = "midibtn"; rst.style.marginTop = "8px"; rst.textContent = "reset to F-key defaults";
|
||
rst.onclick = () => { keymap = JSON.parse(JSON.stringify(DEFAULT_KEYS)); saveKeys(); openKeys(); };
|
||
keysEl.appendChild(rst);
|
||
keysEl.classList.add("open");
|
||
}
|
||
|
||
// ---- your stars (the natal source — a real ephemeris, in the browser) -----
|
||
// Truncated Meeus (Sun ~0.01°, Moon ≤0.1°) + JPL/Standish Keplerian elements
|
||
// (planets ≤~0.2°, valid 1800-2050), precessed J2000→equinox of date so the
|
||
// zodiac is tropical. Verified against eclipse timings, Meeus's worked
|
||
// examples and JPL-checked positions — errors sit far inside transit orbs.
|
||
// Birth data lives in localStorage only; the server never sees a chart.
|
||
const D2R = Math.PI / 180;
|
||
const norm360 = (x) => ((x % 360) + 360) % 360;
|
||
const sepDeg = (a, b) => { const d = Math.abs(norm360(a - b)); return Math.min(d, 360 - d); };
|
||
|
||
function julianDay(y, mo, d, hUTC) {
|
||
if (mo <= 2) { y -= 1; mo += 12; }
|
||
const A = Math.floor(y / 100), B = 2 - A + Math.floor(A / 4);
|
||
return Math.floor(365.25 * (y + 4716)) + Math.floor(30.6001 * (mo + 1)) + d + B - 1524.5 + hUTC / 24;
|
||
}
|
||
const jCent = (jd) => (jd - 2451545.0) / 36525;
|
||
const precession = (T) => 1.39697 * T + 0.000308 * T * T;
|
||
|
||
function sunLon(T) { // Meeus ch.25 — geometric, of date
|
||
const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
|
||
const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * D2R;
|
||
return norm360(L0
|
||
+ (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M)
|
||
+ (0.019993 - 0.000101 * T) * Math.sin(2 * M)
|
||
+ 0.000289 * Math.sin(3 * M));
|
||
}
|
||
function moonLon(T) { // Meeus ch.47, 13 main terms
|
||
const Lp = 218.3164477 + 481267.88123421 * T;
|
||
const D = (297.8501921 + 445267.1114034 * T) * D2R;
|
||
const M = (357.5291092 + 35999.0502909 * T) * D2R;
|
||
const Mp = (134.9633964 + 477198.8675055 * T) * D2R;
|
||
const F = (93.2720950 + 483202.0175233 * T) * D2R;
|
||
return norm360(Lp
|
||
+ 6.288774 * Math.sin(Mp) + 1.274027 * Math.sin(2 * D - Mp) + 0.658314 * Math.sin(2 * D)
|
||
+ 0.213618 * Math.sin(2 * Mp) - 0.185116 * Math.sin(M) - 0.114332 * Math.sin(2 * F)
|
||
+ 0.058793 * Math.sin(2 * D - 2 * Mp) + 0.057066 * Math.sin(2 * D - M - Mp)
|
||
+ 0.053322 * Math.sin(2 * D + Mp) + 0.045758 * Math.sin(2 * D - M)
|
||
- 0.040923 * Math.sin(M - Mp) - 0.034720 * Math.sin(D) - 0.030383 * Math.sin(M + Mp));
|
||
}
|
||
const KEP = { // a,da e,de I,dI L,dL peri,dperi node,dnode (per century)
|
||
mercury: [0.38709927, 0.00000037, 0.20563593, 0.00001906, 7.00497902, -0.00594749, 252.25032350, 149472.67411175, 77.45779628, 0.16047689, 48.33076593, -0.12534081],
|
||
venus: [0.72333566, 0.00000390, 0.00677672, -0.00004107, 3.39467605, -0.00078890, 181.97909950, 58517.81538729, 131.60246718, 0.00268329, 76.67984255, -0.27769418],
|
||
earth: [1.00000261, 0.00000562, 0.01671123, -0.00004392, -0.00001531, -0.01294668, 100.46457166, 35999.37244981, 102.93768193, 0.32327364, 0.0, 0.0],
|
||
mars: [1.52371034, 0.00001847, 0.09339410, 0.00007882, 1.84969142, -0.00813131, -4.55343205, 19140.30268499, -23.94362959, 0.44441088, 49.55953891, -0.29257343],
|
||
jupiter: [5.20288700, -0.00011607, 0.04838624, -0.00013253, 1.30439695, -0.00183714, 34.39644051, 3034.74612775, 14.72847983, 0.21252668, 100.47390909, 0.20469106],
|
||
saturn: [9.53667594, -0.00125060, 0.05386179, -0.00050991, 2.48599187, 0.00193609, 49.95424423, 1222.49362201, 92.59887831, -0.41897216, 113.66242448, -0.28867794],
|
||
};
|
||
function helio(el, T) {
|
||
const a = el[0] + el[1] * T, e = el[2] + el[3] * T, I = (el[4] + el[5] * T) * D2R;
|
||
const L = el[6] + el[7] * T, w = el[8] + el[9] * T, O = el[10] + el[11] * T;
|
||
const Mr = (norm360(L - w + 180) - 180) * D2R;
|
||
let E = Mr + e * Math.sin(Mr);
|
||
for (let i = 0; i < 8; i++) E -= (E - e * Math.sin(E) - Mr) / (1 - e * Math.cos(E));
|
||
const xv = a * (Math.cos(E) - e), yv = a * Math.sqrt(1 - e * e) * Math.sin(E);
|
||
const v = Math.atan2(yv, xv), r = Math.sqrt(xv * xv + yv * yv);
|
||
const wa = (el[8] - el[10]) * D2R + v, Or = O * D2R;
|
||
return {
|
||
x: r * (Math.cos(Or) * Math.cos(wa) - Math.sin(Or) * Math.sin(wa) * Math.cos(I)),
|
||
y: r * (Math.sin(Or) * Math.cos(wa) + Math.cos(Or) * Math.sin(wa) * Math.cos(I)),
|
||
};
|
||
}
|
||
function planetLon(name, T) {
|
||
const p = helio(KEP[name], T), e2 = helio(KEP.earth, T);
|
||
return norm360(Math.atan2(p.y - e2.y, p.x - e2.x) / D2R + precession(T));
|
||
}
|
||
const PLANET7 = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"];
|
||
function skyAt(T) {
|
||
const S = { sun: sunLon(T), moon: moonLon(T) };
|
||
for (let i = 2; i < 7; i++) S[PLANET7[i]] = planetLon(PLANET7[i], T);
|
||
return S;
|
||
}
|
||
|
||
// aspect engine — TIGHT transit orbs (wide orbs are a natal-chart convention;
|
||
// transit practice peaks hard at exactness), strength quadratic toward exact
|
||
const NATAL_ASPECTS = [
|
||
{ a: 0, orb: 5, kind: "conj" },
|
||
{ a: 60, orb: 3, kind: "soft" },
|
||
{ a: 90, orb: 4, kind: "hard" },
|
||
{ a: 120, orb: 4, kind: "soft" },
|
||
{ a: 180, orb: 5, kind: "hard" },
|
||
];
|
||
function aspectHit(l1, l2, lum) {
|
||
const d = sepDeg(l1, l2);
|
||
let best = null;
|
||
for (const A of NATAL_ASPECTS) {
|
||
const orb = A.orb * (lum ? 1.3 : 1);
|
||
const off = Math.abs(d - A.a);
|
||
if (off < orb) {
|
||
const s = Math.pow(1 - off / orb, 2);
|
||
if (!best || s > best.s) best = { kind: A.kind, s };
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
let charts = { list: [], active: 0 };
|
||
try { charts = JSON.parse(localStorage.getItem("gs_charts")) || charts; } catch (e) {}
|
||
let natalActive = null; // {name, pos{7}, web[{a,b,kind,w}]}
|
||
|
||
function computeNatal(c) {
|
||
const jd0 = julianDay(+c.y, +c.mo, +c.d, +c.hh + (+c.mm) / 60 - (+c.utcoff || 0));
|
||
const T = jCent(jd0);
|
||
const pos = skyAt(T);
|
||
const web = []; // the chart's own tight aspects — what transits will ring
|
||
for (let i = 0; i < 7; i++) for (let j = i + 1; j < 7; j++) {
|
||
const h = aspectHit(pos[PLANET7[i]], pos[PLANET7[j]], false);
|
||
if (h && h.s > 0.1) web.push({ a: PLANET7[i], b: PLANET7[j], kind: h.kind, w: h.s });
|
||
}
|
||
// the angles — ascendant & midheaven, when a birthplace is given. Formula
|
||
// verified analytically (Monte-Carlo horizon residual ~1e-13°) and against
|
||
// sunrise: the Sun sits on the ASC at the geometric rise, refraction aside.
|
||
let asc = null, mc = null;
|
||
if (c.lat != null && c.lon != null && isFinite(+c.lat) && isFinite(+c.lon)) {
|
||
const eps = (23.4392911 - 0.0130042 * T) * D2R;
|
||
const th = norm360(280.46061837 + 360.98564736629 * (jd0 - 2451545.0) + (+c.lon)) * D2R;
|
||
const phi = (+c.lat) * D2R;
|
||
asc = norm360(Math.atan2(Math.cos(th), -(Math.sin(th) * Math.cos(eps) + Math.tan(phi) * Math.sin(eps))) / D2R);
|
||
mc = norm360(Math.atan2(Math.sin(th), Math.cos(th) * Math.cos(eps)) / D2R);
|
||
}
|
||
return { pos, web, asc, mc };
|
||
}
|
||
|
||
function setNatalSource(key, label, norm) {
|
||
const n = ensureSource(key, { label });
|
||
n.label = label;
|
||
n.raw = Math.round(norm * 100);
|
||
n.norm = clamp(norm, 0, 1);
|
||
}
|
||
function natalTick() {
|
||
if (!natalActive) return;
|
||
const d0 = new Date();
|
||
const T = jCent(julianDay(d0.getUTCFullYear(), d0.getUTCMonth() + 1, d0.getUTCDate(),
|
||
d0.getUTCHours() + d0.getUTCMinutes() / 60));
|
||
const Tr = skyAt(T);
|
||
let harmony = 0, tension = 0, conj = 0, moonS = 0, satS = 0;
|
||
const hitOn = {}; // strongest transit landing on each natal point
|
||
for (const tp of PLANET7) for (const np of PLANET7) {
|
||
const h = aspectHit(Tr[tp], natalActive.pos[np], tp === "sun" || tp === "moon" || np === "sun" || np === "moon");
|
||
if (!h) continue;
|
||
if (h.kind === "soft") harmony += h.s;
|
||
else if (h.kind === "hard") tension += h.s;
|
||
else conj += h.s;
|
||
if (tp === "moon") moonS = Math.max(moonS, h.s);
|
||
if (tp === "saturn") satS = Math.max(satS, h.s);
|
||
hitOn[np] = Math.max(hitOn[np] || 0, h.s);
|
||
}
|
||
let web = 0; // a hit on one end of a natal aspect rings the whole figure
|
||
for (const wa of natalActive.web) web += wa.w * ((hitOn[wa.a] || 0) + (hitOn[wa.b] || 0));
|
||
const who = natalActive.name;
|
||
setNatalSource("natal.harmony", who + " · sky grace", harmony / 2.5);
|
||
setNatalSource("natal.tension", who + " · sky friction", tension / 2.5);
|
||
setNatalSource("natal.hits", who + " · conjunctions", conj / 1.5);
|
||
setNatalSource("natal.web", who + " · natal web ringing", web / 1.5);
|
||
setNatalSource("natal.moon", "the moon on " + who + "'s chart", moonS);
|
||
setNatalSource("natal.saturn", "saturn on " + who + "'s chart", satS);
|
||
setNatalSource("natal.birthday", who + " · solar return", Math.max(0, 1 - sepDeg(Tr.sun, natalActive.pos.sun) / 15));
|
||
if (natalActive.asc != null) { // the angles receive; hard contacts carry the weight
|
||
let ang = 0;
|
||
for (const tp of PLANET7) {
|
||
const hA = aspectHit(Tr[tp], natalActive.asc, false);
|
||
const hM = aspectHit(Tr[tp], natalActive.mc, false);
|
||
if (hA && hA.kind !== "soft") ang = Math.max(ang, hA.s);
|
||
if (hM && hM.kind !== "soft") ang = Math.max(ang, hM.s);
|
||
}
|
||
setNatalSource("natal.angles", who + " · the angles struck", ang);
|
||
}
|
||
// synastry — the relationship rings when the living sky strikes either end
|
||
// of any chart-to-chart aspect
|
||
if (synPartner) {
|
||
const hitOnB = {};
|
||
for (const tp of PLANET7) for (const np of PLANET7) {
|
||
const h = aspectHit(Tr[tp], synPartner.pos[np], tp === "sun" || tp === "moon" || np === "sun" || np === "moon");
|
||
if (h) hitOnB[np] = Math.max(hitOnB[np] || 0, h.s);
|
||
}
|
||
let synWebS = 0, synMoonS = 0;
|
||
for (const iw of interWeb) {
|
||
synWebS += iw.w * ((hitOn[iw.a] || 0) + (hitOnB[iw.b] || 0));
|
||
const hma = aspectHit(Tr.moon, natalActive.pos[iw.a], true);
|
||
const hmb = aspectHit(Tr.moon, synPartner.pos[iw.b], true);
|
||
synMoonS = Math.max(synMoonS, iw.w * Math.max(hma ? hma.s : 0, hmb ? hmb.s : 0));
|
||
}
|
||
const pair = who + " & " + synPartner.name;
|
||
setNatalSource("syn.web", pair + " · the bond ringing", synWebS / 1.5);
|
||
setNatalSource("syn.moon", pair + " · the bond's daily weather", synMoonS);
|
||
}
|
||
}
|
||
|
||
// ---- synastry: two charts, one figure ------------------------------------
|
||
let synPartner = null; // {name, pos}
|
||
let interWeb = []; // [{a: yours, b: theirs, kind, w}]
|
||
function syncSynastry() {
|
||
synPartner = null; interWeb = [];
|
||
if (!natalActive || charts.synWith == null || charts.synWith === charts.active) return;
|
||
const pc = charts.list[charts.synWith];
|
||
if (!pc) { charts.synWith = null; return; }
|
||
const pn = computeNatal(pc);
|
||
synPartner = { name: pc.name || "them", pos: pn.pos };
|
||
let harmony = 0, tension = 0, conj = 0;
|
||
for (const a of PLANET7) for (const b of PLANET7) {
|
||
const h = aspectHit(natalActive.pos[a], synPartner.pos[b],
|
||
a === "sun" || a === "moon" || b === "sun" || b === "moon");
|
||
if (!h || h.s < 0.1) continue;
|
||
interWeb.push({ a, b, kind: h.kind, w: h.s });
|
||
if (h.kind === "soft") harmony += h.s; else if (h.kind === "hard") tension += h.s; else conj += h.s;
|
||
}
|
||
const pair = (natalActive.name) + " & " + synPartner.name;
|
||
setNatalSource("syn.harmony", pair + " · standing grace", harmony / 3);
|
||
setNatalSource("syn.tension", pair + " · standing friction", tension / 3);
|
||
setNatalSource("syn.hits", pair + " · meetings", conj / 2);
|
||
eventTicker.unshift({ text: "⚭ " + pair + " · " + interWeb.length + " aspects between them", tAdded: now(), src: "sys" });
|
||
natalTick();
|
||
}
|
||
function activateChart(i) {
|
||
charts.active = i;
|
||
try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {}
|
||
const c = charts.list[i];
|
||
if (!c) { natalActive = null; return; }
|
||
const n = computeNatal(c);
|
||
natalActive = { name: c.name || "you", pos: n.pos, web: n.web, asc: n.asc, mc: n.mc };
|
||
natalTick();
|
||
const rising = n.asc != null ? " · " + SIGNS[Math.floor(n.asc / 30)].key + " rising" : "";
|
||
eventTicker.unshift({ text: "✨ " + natalActive.name + "'s sky is in the choir · " + n.web.length + " natal aspects in the web" + rising, tAdded: now(), src: "sys" });
|
||
syncSynastry();
|
||
}
|
||
setInterval(natalTick, 60000); // the moon moves ~0.5°/hour — a minute is plenty
|
||
if (charts.list.length) setTimeout(() => activateChart(charts.active), 1500);
|
||
|
||
// the natal panel — birth data in, private
|
||
const natalEl = document.createElement("div");
|
||
natalEl.id = "natal";
|
||
document.body.appendChild(natalEl);
|
||
function closeNatal() { natalEl.classList.remove("open"); }
|
||
function openNatal() {
|
||
natalEl.innerHTML = "";
|
||
const x = document.createElement("span"); x.className = "close"; x.textContent = "×";
|
||
x.onclick = closeNatal; natalEl.appendChild(x);
|
||
const h3 = document.createElement("h3"); h3.textContent = "✨ your stars"; natalEl.appendChild(h3);
|
||
const sub = document.createElement("div"); sub.className = "sub";
|
||
sub.textContent = "birth date, time (to ~10 min), and the UTC offset where you were born. computed here, kept here — the server never sees a chart. load anyone's: yours, a lover's, a friend's.";
|
||
natalEl.appendChild(sub);
|
||
// saved charts
|
||
for (let i = 0; i < charts.list.length; i++) {
|
||
const c = charts.list[i];
|
||
const row = document.createElement("div"); row.className = "hrow";
|
||
const btn = document.createElement("button"); btn.className = "midibtn";
|
||
btn.textContent = (i === charts.active && natalActive ? "✦ " : "") + (c.name || "chart " + (i + 1));
|
||
btn.style.flex = "1";
|
||
btn.onclick = () => { activateChart(i); openNatal(); };
|
||
const rm = document.createElement("span"); rm.className = "x"; rm.textContent = "×";
|
||
rm.onclick = () => {
|
||
charts.list.splice(i, 1);
|
||
charts.synWith = null; synPartner = null; interWeb = [];
|
||
if (charts.active >= charts.list.length) charts.active = 0;
|
||
if (!charts.list.length) natalActive = null;
|
||
try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {}
|
||
openNatal();
|
||
};
|
||
row.appendChild(btn);
|
||
if (i !== charts.active) { // ⚭ — bind this chart to the active one
|
||
const syb = document.createElement("button"); syb.className = "midibtn";
|
||
syb.textContent = charts.synWith === i ? "⚭ bound" : "⚭";
|
||
syb.title = "synastry — this chart against the active one";
|
||
syb.onclick = () => {
|
||
charts.synWith = charts.synWith === i ? null : i;
|
||
try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {}
|
||
syncSynastry(); openNatal();
|
||
};
|
||
row.appendChild(syb);
|
||
}
|
||
row.appendChild(rm);
|
||
natalEl.appendChild(row);
|
||
}
|
||
// new chart form
|
||
const mk = (ph, type, val) => {
|
||
const inp = document.createElement("input");
|
||
inp.placeholder = ph; inp.type = type || "text"; if (val != null) inp.value = val;
|
||
return inp;
|
||
};
|
||
const nameI = mk("name (you, a friend…)"), dateI = mk("", "date"), timeI = mk("", "time"),
|
||
utcI = mk("UTC offset at birth, e.g. +10 or -5.5", "number"),
|
||
latI = mk("birth lat, e.g. -27.47", "number"), lonI = mk("birth lon (east +), e.g. 153.03", "number");
|
||
utcI.step = "0.5"; latI.step = "any"; lonI.step = "any";
|
||
const r1 = document.createElement("div"); r1.className = "hrow"; r1.appendChild(nameI);
|
||
const r2 = document.createElement("div"); r2.className = "hrow"; r2.appendChild(dateI); r2.appendChild(timeI);
|
||
const r3 = document.createElement("div"); r3.className = "hrow"; r3.appendChild(utcI);
|
||
const r4 = document.createElement("div"); r4.className = "hrow"; r4.appendChild(latI); r4.appendChild(lonI);
|
||
natalEl.appendChild(r1); natalEl.appendChild(r2); natalEl.appendChild(r3); natalEl.appendChild(r4);
|
||
const hint = document.createElement("div"); hint.className = "sub";
|
||
hint.textContent = "lat/lon unlock the ascendant, midheaven & whole-sign houses (the wheel skin draws them). the angles move 1° every 4 minutes — birth-time precision matters most here.";
|
||
natalEl.appendChild(hint);
|
||
const save = document.createElement("button"); save.className = "midibtn"; save.style.marginTop = "8px";
|
||
save.textContent = "cast the chart";
|
||
save.onclick = () => {
|
||
if (!dateI.value || !timeI.value) return;
|
||
const [y, mo, d] = dateI.value.split("-").map(Number);
|
||
const [hh, mm] = timeI.value.split(":").map(Number);
|
||
if (y < 1800 || y > 2049) { sub.textContent = "the ephemeris holds 1800–2049 — outside that the sky drifts."; return; }
|
||
charts.list.push({ name: nameI.value.trim() || "you", y, mo, d, hh, mm, utcoff: parseFloat(utcI.value) || 0,
|
||
lat: latI.value === "" ? null : parseFloat(latI.value),
|
||
lon: lonI.value === "" ? null : parseFloat(lonI.value) });
|
||
try { localStorage.setItem("gs_charts", JSON.stringify(charts)); } catch (e) {}
|
||
activateChart(charts.list.length - 1);
|
||
openNatal();
|
||
};
|
||
natalEl.appendChild(save);
|
||
natalEl.classList.add("open");
|
||
}
|
||
|
||
// ---- 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);
|
||
}
|
||
}
|
||
|
||
// a struck note out the MIDI door — grooves & the mind-console play your rig
|
||
// pending note-off timers, keyed by (channel<<8 | note). A note length can now
|
||
// outlast the next onset of the SAME pitch on the SAME channel; without tracking,
|
||
// the earlier note's delayed note-off would silence the retriggered note (the live
|
||
// mirror of the same-pitch clamp exportMid does). So retriggering a held pitch first
|
||
// ends the earlier note cleanly, then re-strikes — no stale off cuts the new note.
|
||
const midiOffTimers = {};
|
||
function midiPluck(ch, note, vel, durMs) {
|
||
if (!midiOut || note == null || note < 0 || note > 127) return;
|
||
const c = ch & 15, n = Math.round(note), k = (c << 8) | n;
|
||
try {
|
||
const prev = midiOffTimers[k];
|
||
if (prev) { clearTimeout(prev); midiOut.send([0x80 | c, n, 0]); } // end the earlier same-pitch note first
|
||
midiOut.send([0x90 | c, n, clamp(Math.round((vel || 0.8) * 127), 1, 127)]);
|
||
midiOffTimers[k] = setTimeout(() => { delete midiOffTimers[k]; try { midiOut.send([0x80 | c, n, 0]); } catch (e) {} }, durMs || 220);
|
||
} catch (e) {}
|
||
}
|
||
|
||
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; ungodlyMark("mute"); 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; ungodlyMark("freeze"); 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) { ungodlyGesture("level"); 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);
|
||
|
||
// Dimensions (Z4) — personal full-scene saves to your account (the whole patch:
|
||
// routes, tweaks, tempo). A dimension is everything; a vibe is a feeling you can
|
||
// hand a friend. Same /api/presets storage — this is a framing rename only.
|
||
const th = document.createElement("h3"); th.textContent = "DIMENSIONS"; th.style.marginTop = "8px";
|
||
panelEl.appendChild(th);
|
||
const pMsg = document.createElement("div"); pMsg.className = "hint"; pMsg.style.margin = "2px 0 4px";
|
||
pMsg.textContent = "your whole instrument, saved to your account. a dimension is everything.";
|
||
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 dimension…"; 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;
|
||
if (n === "__zero__") { enterZero(); pMsg.textContent = "entered dimension zero — the empty field"; return; }
|
||
const r = await gsFetch("/api/presets/" + encodeURIComponent(n), "GET");
|
||
if (r.ok && r.data.data) { ungodlyMark("loaded " + n); loadPatch(r.data.data); pMsg.textContent = "loaded “" + n + "”"; }
|
||
else pMsg.textContent = "load failed";
|
||
};
|
||
loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);
|
||
refreshTemplates();
|
||
|
||
// Vibes — small, shareable route bundles (Z3). A dimension is everything;
|
||
// a vibe is a feeling you can hand a friend.
|
||
const vh = document.createElement("h3"); vh.textContent = "✨ VIBES"; vh.style.marginTop = "12px";
|
||
panelEl.appendChild(vh);
|
||
const vMsg = document.createElement("div"); vMsg.className = "hint"; vMsg.style.margin = "2px 0 6px";
|
||
vMsg.textContent = "build a feeling, save it, share the code.";
|
||
panelEl.appendChild(vMsg);
|
||
const vBtn = document.createElement("button"); vBtn.textContent = "open ✨ vibes"; vBtn.className = "midibtn";
|
||
vBtn.onclick = () => openVibesPanel();
|
||
panelEl.appendChild(vBtn);
|
||
}
|
||
|
||
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); };
|
||
opt("__zero__", "✦ dimension zero — the empty field"); // always first: the place you build from nothing
|
||
if (!(typeof Accounts !== "undefined" && Accounts && Accounts.user)) { opt("", "— sign in to load your own —"); 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 yet —");
|
||
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;
|
||
let lastLeadT = 0, lastBassT = 0; // retrigger rate-limiters
|
||
|
||
// ---- GODRUM — five synthesized drum voices. They sum onto preSat like every
|
||
// other voice, so they ride the same saturation → crush → chorus/reverb/echo
|
||
// world (the instrument's ethos, kept). Every param is normalized 0..1 at the
|
||
// boundary and mapped to real units inside its voice. DRUM_DEFS is the single
|
||
// source of truth the panel renders from; KIT holds the live, editable values.
|
||
const DRUM_DEFS = {
|
||
kick: { params: ["tune", "punch", "decay", "duck", "level", "pan"], def: [0.5, 0.5, 0.5, 0.35, 0.85, 0.5] },
|
||
snare: { params: ["tune", "snap", "decay", "level", "pan"], def: [0.5, 0.6, 0.4, 0.7, 0.5] },
|
||
clap: { params: ["tone", "decay", "level", "pan"], def: [0.5, 0.5, 0.65, 0.5] },
|
||
hat: { params: ["tone", "decay", "level", "pan"], def: [0.6, 0.35, 0.5, 0.5] },
|
||
ohat: { params: ["tone", "decay", "level", "pan"], def: [0.6, 0.5, 0.45, 0.5] },
|
||
};
|
||
const KIT = {};
|
||
for (const _v in DRUM_DEFS) { KIT[_v] = {}; DRUM_DEFS[_v].params.forEach((p, i) => { KIT[_v][p] = DRUM_DEFS[_v].def[i]; }); }
|
||
// caller-supplied names (from patch/vibe JSON — cross-user data) must be OWN keys, never reached through
|
||
// the prototype chain — a "__proto__" key would otherwise write onto Object.prototype and corrupt the runtime.
|
||
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
||
|
||
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;
|
||
// 🫀 GODSQUASH — the master squeeze the world can pump. out → squash → makeup → lim.
|
||
// knee/attack are static; threshold/ratio/release/makeup ride the `squash` dest in params().
|
||
// Baseline is the sq=0 transparent state (ratio 1:1) so it's clean before params() first runs.
|
||
// The GODSONIQ recorder tap on N.out (in loadWorklets) stays PRE-squash — captures are pre-limiter by design.
|
||
const squash = ctx.createDynamicsCompressor();
|
||
squash.knee.value = 12; squash.attack.value = 0.004;
|
||
squash.threshold.value = -6; squash.ratio.value = 1; squash.release.value = 0.12;
|
||
const squashMakeup = ctx.createGain(); squashMakeup.gain.value = 1;
|
||
out.connect(squash); squash.connect(squashMakeup); squashMakeup.connect(lim); lim.connect(ctx.destination);
|
||
|
||
const conv = ctx.createConvolver(); conv.buffer = impulse(1.8, 3.2); // tighter room, faster decay
|
||
const revSend = ctx.createGain(); revSend.gain.value = 0;
|
||
const revWet = ctx.createGain(); revWet.gain.value = 0.55;
|
||
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.12;
|
||
const delWet = ctx.createGain(); delWet.gain.value = 0.35;
|
||
delSend.connect(delay); delay.connect(delWet); delWet.connect(out);
|
||
|
||
// ---- Earth Echo — a Roland Space Echo whose tape is driven by the planet.
|
||
// Repeat rate glides (tape-motor inertia → pitch bend); feedback climbs to
|
||
// near self-oscillation; a lowpass in the loop is the treble roll (pollution
|
||
// darkens); a waveshaper is tape grit; an LFO on the delay time is wow &
|
||
// flutter (the moon warbles it); a noise bed is tape hiss (wear).
|
||
const echoIn = ctx.createGain(); echoIn.gain.value = 1;
|
||
const echoDelay = ctx.createDelay(1.2); echoDelay.delayTime.value = 0.28;
|
||
const echoFilt = ctx.createBiquadFilter(); echoFilt.type = "lowpass"; echoFilt.frequency.value = 6500;
|
||
const echoHP = ctx.createBiquadFilter(); echoHP.type = "highpass"; echoHP.frequency.value = 120;
|
||
const echoSat = ctx.createWaveShaper(); echoSat.curve = tanhCurve();
|
||
const echoFb = ctx.createGain(); echoFb.gain.value = 0.3;
|
||
const echoWet = ctx.createGain(); echoWet.gain.value = 0; // silent until enabled
|
||
echoDelay.connect(echoFilt); echoFilt.connect(echoHP); echoHP.connect(echoSat);
|
||
echoSat.connect(echoFb); echoFb.connect(echoDelay); // the tape loop
|
||
echoIn.connect(echoDelay); echoDelay.connect(echoWet); echoWet.connect(out);
|
||
const echoFlutLfo = ctx.createOscillator(); echoFlutLfo.type = "sine"; echoFlutLfo.frequency.value = 1.3;
|
||
const echoFlutAmt = ctx.createGain(); echoFlutAmt.gain.value = 0;
|
||
echoFlutLfo.connect(echoFlutAmt); echoFlutAmt.connect(echoDelay.delayTime); echoFlutLfo.start();
|
||
const hissBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
||
{ const ch = hissBuf.getChannelData(0); for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * 0.5; }
|
||
const hiss = ctx.createBufferSource(); hiss.buffer = hissBuf; hiss.loop = true;
|
||
const hissGain = ctx.createGain(); hissGain.gain.value = 0;
|
||
hiss.connect(hissGain); hissGain.connect(echoDelay); hiss.start();
|
||
|
||
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);
|
||
post.connect(echoIn); // the mix onto the Earth Echo tape
|
||
|
||
// ---- Juno-106-style BBD chorus — two short delays, each warbled by its own
|
||
// slow LFO and panned wide. Always gently on, so the dry oscillators sound
|
||
// warm and stereo. The single best-value vintage borrow: universally widens
|
||
// the mix and can't sound bad. World-modulatable via chorusWet if wanted. ----
|
||
const chorusWet = ctx.createGain(); chorusWet.gain.value = 0.3;
|
||
const chIn = ctx.createGain(); chIn.gain.value = 1;
|
||
const chD1 = ctx.createDelay(0.05); chD1.delayTime.value = 0.017;
|
||
const chD2 = ctx.createDelay(0.05); chD2.delayTime.value = 0.023;
|
||
const chLfo1 = ctx.createOscillator(); chLfo1.frequency.value = 0.62;
|
||
const chLfo2 = ctx.createOscillator(); chLfo2.frequency.value = 0.47;
|
||
const chDep1 = ctx.createGain(); chDep1.gain.value = 0.0038; // ±3.8 ms warble
|
||
const chDep2 = ctx.createGain(); chDep2.gain.value = 0.0044;
|
||
const chPanL = ctx.createStereoPanner(); chPanL.pan.value = -0.75;
|
||
const chPanR = ctx.createStereoPanner(); chPanR.pan.value = 0.75;
|
||
chLfo1.connect(chDep1); chDep1.connect(chD1.delayTime);
|
||
chLfo2.connect(chDep2); chDep2.connect(chD2.delayTime);
|
||
chIn.connect(chD1); chD1.connect(chPanL); chPanL.connect(chorusWet);
|
||
chIn.connect(chD2); chD2.connect(chPanR); chPanR.connect(chorusWet);
|
||
chorusWet.connect(out); post.connect(chIn);
|
||
chLfo1.start(); chLfo2.start();
|
||
|
||
lfo = ctx.createOscillator(); lfo.frequency.value = 0.2;
|
||
const lfoAmt = ctx.createGain(); lfoAmt.gain.value = 260; lfo.connect(lfoAmt);
|
||
|
||
// real wavetable lead: crossfade a mellow wave ↔ a bright (saw-rich) wave by wavetable.morph
|
||
const leadFilt = ctx.createBiquadFilter(); leadFilt.type = "lowpass"; leadFilt.frequency.value = 1200;
|
||
const leadAmp = ctx.createGain(); leadAmp.gain.value = 0;
|
||
const _nH = 20, _rA = new Float32Array(_nH), _iA = new Float32Array(_nH), _rB = new Float32Array(_nH), _iB = new Float32Array(_nH);
|
||
for (let n = 1; n < _nH; n++) { _iA[n] = n <= 4 ? 0.9 / n : 0.03 / n; _iB[n] = 1 / n; } // A mellow · B bright
|
||
const leadA = ctx.createOscillator(); leadA.setPeriodicWave(ctx.createPeriodicWave(_rA, _iA));
|
||
const leadB = ctx.createOscillator(); leadB.setPeriodicWave(ctx.createPeriodicWave(_rB, _iB));
|
||
const morphA = ctx.createGain(); morphA.gain.value = 1;
|
||
const morphB = ctx.createGain(); morphB.gain.value = 0;
|
||
leadA.connect(morphA); leadB.connect(morphB); morphA.connect(leadFilt); morphB.connect(leadFilt);
|
||
leadFilt.connect(leadAmp); leadAmp.connect(preSat);
|
||
lfoAmt.connect(leadFilt.frequency); leadA.start(); leadB.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();
|
||
|
||
// GODRUM kick-duck bus: pad + drone route through duckG, so a kick can dip the
|
||
// low-mid wall out from under itself ("the kick parts the sea"). Rides at 1.
|
||
const duckG = ctx.createGain(); duckG.gain.value = 1; duckG.connect(preSat);
|
||
const padFilt = ctx.createBiquadFilter(); padFilt.type = "lowpass"; padFilt.frequency.value = 800;
|
||
const padAmp = ctx.createGain(); padAmp.gain.value = 0;
|
||
padFilt.connect(padAmp); padAmp.connect(duckG); lfoAmt.connect(padFilt.frequency);
|
||
// lush supersaw pad — each chord tone is a cluster of detuned saws (JP-8000 style).
|
||
// Spectrally verified: lush (133 partials), no clip, no aliasing, no DC.
|
||
const padOscs = [];
|
||
for (const iv of [0, 7, 16]) for (const s of [-1, 0, 1]) {
|
||
const o = ctx.createOscillator(); o.type = "sawtooth"; o.detune.value = s * 14; // ±14¢ supersaw spread
|
||
o.frequency.value = mtof(50 + iv); o.connect(padFilt); o.start();
|
||
padOscs.push({ o, iv });
|
||
}
|
||
|
||
const droneFilt = ctx.createBiquadFilter(); droneFilt.type = "lowpass"; droneFilt.frequency.value = 380;
|
||
const droneAmp = ctx.createGain(); droneAmp.gain.value = 0;
|
||
droneFilt.connect(droneAmp); droneAmp.connect(duckG);
|
||
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;
|
||
});
|
||
|
||
// ---- the Schumann resonance — the Earth's electromagnetic heartbeat.
|
||
// The planet-ionosphere cavity rings at 7.83 Hz (Schumann, 1952), pumped
|
||
// by every lightning strike on the globe. Below human hearing, so it
|
||
// plays the machine instead of the ear: a 7.83 Hz pulse breathes the
|
||
// Earth Echo's tone and lays a second, planetary wow on the tape — and
|
||
// octaved up ×4 into 31.32 Hz it hums as a true sub under the drone
|
||
// voice. The one dial the world never turns: it is the size of the
|
||
// Earth, and the Earth's size does not change.
|
||
// ponytail: fundamental only — harmonics (14.3 / 20.8 / 27.3 Hz) when wanted
|
||
const schu = ctx.createOscillator(); schu.type = "sine"; schu.frequency.value = 7.83;
|
||
const schuTone = ctx.createGain(); schuTone.gain.value = 240; // breathes the loop's lowpass
|
||
const schuWow = ctx.createGain(); schuWow.gain.value = 0.00035; // the planetary wow, under the moon's
|
||
schu.connect(schuTone); schuTone.connect(echoFilt.frequency);
|
||
schu.connect(schuWow); schuWow.connect(echoDelay.delayTime);
|
||
schu.start();
|
||
const schuDrone = ctx.createOscillator(); schuDrone.type = "sine";
|
||
schuDrone.frequency.value = 7.83 * 4; // 31.32 Hz — the cavity, made audible
|
||
const schuDroneG = ctx.createGain(); schuDroneG.gain.value = 0.55;
|
||
schuDrone.connect(schuDroneG); schuDroneG.connect(droneFilt); schuDrone.start();
|
||
|
||
lfo.start();
|
||
|
||
// ---- GODRUM bus: the five voices sum here, then into preSat so they share
|
||
// the world's saturation/crush/chorus/reverb/echo. Each voice keeps a
|
||
// persistent level→pan chain; individual hits are one-shot graphs that
|
||
// connect into their voice's level gain (like pluck()/noiseHit()). ----
|
||
const drumBus = ctx.createGain(); drumBus.gain.value = 0.8; drumBus.connect(preSat);
|
||
const noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); // 1s white noise, looped & reused per hit
|
||
{ const nch = noiseBuf.getChannelData(0); for (let i = 0; i < nch.length; i++) nch[i] = Math.random() * 2 - 1; }
|
||
const drums = {};
|
||
for (const dv in DRUM_DEFS) {
|
||
const g = ctx.createGain(), pan = ctx.createStereoPanner();
|
||
g.gain.value = (KIT[dv].level != null ? KIT[dv].level : 1) * 1.2; // level 0..1 → gain 0..1.2
|
||
pan.pan.value = (KIT[dv].pan != null ? KIT[dv].pan : 0.5) * 2 - 1; // pan 0..1 → −1..1
|
||
g.connect(pan); pan.connect(drumBus); drums[dv] = { g, pan };
|
||
}
|
||
|
||
return { out, squash, squashMakeup, lim, preSat, glitch, revSend, fb, satDry, satWet, leadA, leadB, morphA, morphB, leadFilt, leadAmp,
|
||
bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp,
|
||
echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain, chorusWet,
|
||
drumBus, drums, noiseBuf, duckG };
|
||
}
|
||
|
||
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, at) {
|
||
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(at != null ? at : ctx.currentTime);
|
||
}
|
||
|
||
// ---- GODRUM voices. Velocity scales amplitude as vel*vel (punchier than
|
||
// linear for drums). Envelopes are linear ramps + setTargetAtTime — never
|
||
// exponential-to-0 (illegal); noise voices loop the shared buffer and are
|
||
// stopped once their tail has rung out. ----
|
||
function dNoise() { const s = ctx.createBufferSource(); s.buffer = N.noiseBuf; s.loop = true; return s; }
|
||
function drumSync(name, param) { // level/pan → live nodes on a short glide
|
||
if (!N || !N.drums || !own(N.drums, name)) return;
|
||
const t = ctx.currentTime, dv = N.drums[name];
|
||
if (param === "level") dv.g.gain.setTargetAtTime(KIT[name].level * 1.2, t, 0.02);
|
||
else if (param === "pan") dv.pan.pan.setTargetAtTime(KIT[name].pan * 2 - 1, t, 0.02);
|
||
}
|
||
|
||
// cancel pending automation but HOLD the value the timeline actually has at t — 0 for a same-task
|
||
// hit whose envelope hasn't rendered (reading .value there returns the 1.0 default → a pop), the
|
||
// decaying level mid-ring. cancelAndHoldAtTime is the right primitive; read-value is the legacy fallback.
|
||
const cancelHold = (p, t) => { if (p.cancelAndHoldAtTime) p.cancelAndHoldAtTime(t);
|
||
else { p.cancelScheduledValues(t); p.setValueAtTime(p.value, t); } };
|
||
let ohatEnv = null; // the currently-ringing open hat, for the TR choke
|
||
function choke(t) { if (ohatEnv) { cancelHold(ohatEnv.gain, t); ohatEnv.gain.setTargetAtTime(0, t, 0.012); ohatEnv = null; } }
|
||
function kickDuck(t) { // the kick parts the sea — dip pad+drone via duckG, then let them flood back (shared by synth- and slice-kick)
|
||
if (KIT.kick.duck > 0 && N.duckG) { const dg = N.duckG.gain; cancelHold(dg, t);
|
||
dg.linearRampToValueAtTime(1 - KIT.kick.duck * 0.85, t + 0.012); dg.setTargetAtTime(1, t + 0.05, 0.15); }
|
||
}
|
||
|
||
function fireKick(vel) {
|
||
const t = ctx.currentTime, K = KIT.kick, dst = N.drums.kick.g;
|
||
const fB = 35 + K.tune * 45, peak = vel * vel, tau = 0.05 + K.decay * 0.30;
|
||
const o = ctx.createOscillator(); o.type = "sine";
|
||
o.frequency.setValueAtTime(fB * (2 + K.punch * 6), t);
|
||
o.frequency.exponentialRampToValueAtTime(fB, t + 0.045); // the pitch drop IS the thump
|
||
const g = ctx.createGain();
|
||
g.gain.setValueAtTime(0, t);
|
||
g.gain.linearRampToValueAtTime(peak, t + 0.003);
|
||
g.gain.setTargetAtTime(0, t + 0.01, tau);
|
||
o.connect(g); g.connect(dst); o.start(t); o.stop(t + 0.01 + tau * 8); // ~8τ: let the tail ring fully out — no click at high decay
|
||
if (K.punch > 0) { // a 2ms highpassed noise click for attack
|
||
const cs = dNoise(), hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 1000;
|
||
const cg = ctx.createGain(); cg.gain.setValueAtTime(vel * K.punch * 0.3, t); cg.gain.setTargetAtTime(0, t, 0.002);
|
||
cs.connect(hp); hp.connect(cg); cg.connect(dst); cs.start(t); cs.stop(t + 0.05);
|
||
}
|
||
kickDuck(t); // dip pad+drone (factored — the slice-kick path shares this exact block)
|
||
}
|
||
function fireSnare(vel) {
|
||
const t = ctx.currentTime, K = KIT.snare, dst = N.drums.snare.g;
|
||
const f = 140 + K.tune * 120, peak = vel * vel, tau = 0.05 + K.decay * 0.30;
|
||
const toneG = ctx.createGain(); // body: two oscs, fixed ~80ms decay (snap 0 = all tone)
|
||
toneG.gain.setValueAtTime(0, t); toneG.gain.linearRampToValueAtTime(peak * (1 - K.snap) * 0.85, t + 0.002);
|
||
toneG.gain.setTargetAtTime(0, t + 0.004, 0.028); toneG.connect(dst);
|
||
[f, f * 1.5].forEach((fr) => { const o = ctx.createOscillator(); o.type = "triangle"; o.frequency.value = fr; o.connect(toneG); o.start(t); o.stop(t + 0.2); });
|
||
const ns = dNoise(); // rattle: bandpassed + highpassed noise (snap 1 = all noise)
|
||
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1800; bp.Q.value = 0.8;
|
||
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 400;
|
||
const ng = ctx.createGain(); ng.gain.setValueAtTime(0, t); ng.gain.linearRampToValueAtTime(peak * K.snap * 1.1, t + 0.002);
|
||
ng.gain.setTargetAtTime(0, t + 0.004, tau);
|
||
ns.connect(bp); bp.connect(hp); hp.connect(ng); ng.connect(dst); ns.start(t); ns.stop(t + Math.max(0.2, tau * 6));
|
||
}
|
||
function fireClap(vel) {
|
||
const t = ctx.currentTime, K = KIT.clap, dst = N.drums.clap.g, peak = vel * vel * 0.9;
|
||
const ns = dNoise(), bp = ctx.createBiquadFilter();
|
||
bp.type = "bandpass"; bp.frequency.value = 700 + K.tone * 1500; bp.Q.value = 1.5;
|
||
const g = ctx.createGain(); g.gain.setValueAtTime(0, t);
|
||
const jit = () => (Math.random() * 2 - 1) * 0.002; // ±2ms per hit → the three claps breathe
|
||
for (const off of [0, 0.011, 0.023]) { const st = t + Math.max(0, off + jit()); g.gain.setValueAtTime(peak, st); g.gain.setTargetAtTime(0, st, 0.004); }
|
||
const body = t + 0.030 + jit(), tau = 0.04 + K.decay * 0.25; // then the ringing body (909 spikes → tail)
|
||
g.gain.setValueAtTime(peak, body); g.gain.setTargetAtTime(0, body, tau);
|
||
ns.connect(bp); bp.connect(g); g.connect(dst); ns.start(t); ns.stop(body + Math.max(0.2, tau * 6));
|
||
}
|
||
function fireHat(vel, open) {
|
||
const t = ctx.currentTime, K = open ? KIT.ohat : KIT.hat, dst = N.drums[open ? "ohat" : "hat"].g;
|
||
choke(t); // a closed OR a new open hat chokes the ringing open hat — the TR rule
|
||
const ns = dNoise(), hp = ctx.createBiquadFilter();
|
||
hp.type = "highpass"; hp.frequency.value = 5500 + K.tone * 4000;
|
||
const g = ctx.createGain(), peak = vel * vel * (open ? 0.45 : 0.35); // mix: sit the hats at/just under the ambient perc.density shimmer (§A.3 intent)
|
||
const tau = open ? (0.10 + K.decay * 0.5) : (0.012 + K.decay * 0.06);
|
||
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + 0.001); g.gain.setTargetAtTime(0, t + 0.002, tau);
|
||
ns.connect(hp); hp.connect(g); g.connect(dst); ns.start(t); ns.stop(t + Math.max(0.1, tau * 6));
|
||
if (open) ohatEnv = g; // this open hat can now be choked
|
||
}
|
||
|
||
// fire a drum — mirrors pluck()'s wake-up, so a drum can be the first sound the page makes
|
||
function drum(name, vel) {
|
||
if (!own(DRUM_DEFS, name)) return; // unknown / non-own voice → silent, no side effects
|
||
if (!AC) return;
|
||
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
|
||
ctx.resume();
|
||
if (!on) N.out.gain.setTargetAtTime(0.85, ctx.currentTime, 0.1);
|
||
const v = vel == null ? 0.8 : c01(vel);
|
||
const slb = own(aMap, name) ? amplerSliceBuf(aMap[name]) : null; // 🌾 AMPLER — an assigned slice PLAYS the world for this voice
|
||
if (slb) { fireSlice(name, slb, v); return; }
|
||
if (name === "kick") fireKick(v);
|
||
else if (name === "snare") fireSnare(v);
|
||
else if (name === "clap") fireClap(v);
|
||
else if (name === "hat") fireHat(v, false);
|
||
else if (name === "ohat") fireHat(v, true);
|
||
}
|
||
function drumParam(name, param, value) {
|
||
if (!own(KIT, name) || !own(KIT[name], param)) return undefined; // own-key only — no proto-chain reach
|
||
const kv = KIT[name];
|
||
if (value === undefined) return kv[param]; // get
|
||
if (Number.isFinite(value)) { kv[param] = c01(value); drumSync(name, param); } // set (finite, clamped 0..1) → live nodes; ignore junk
|
||
return kv[param];
|
||
}
|
||
function drumParamList(name) { const def = own(DRUM_DEFS, name) ? DRUM_DEFS[name] : null; return def ? def.params.map((p) => ({ key: p, label: p })) : []; }
|
||
function drumKit() { const o = {}; for (const v in KIT) if (own(KIT, v)) { o[v] = {}; for (const p in KIT[v]) if (own(KIT[v], p)) o[v][p] = KIT[v][p]; } return o; } // deep, JSON-safe, own-keys only
|
||
function drumKitLoad(obj) { // merge a snapshot; missing/foreign/proto keys keep defaults
|
||
if (!obj) return;
|
||
for (const v in obj) if (own(obj, v) && own(KIT, v) && obj[v]) for (const p in obj[v])
|
||
if (own(obj[v], p) && own(KIT[v], p) && Number.isFinite(obj[v][p])) { KIT[v][p] = c01(obj[v][p]); drumSync(v, p); }
|
||
}
|
||
|
||
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);
|
||
const _wm = clamp(d("wavetable.morph", 0), 0, 1); // real wavetable: crossfade mellow ↔ bright
|
||
S(N.morphA.gain, 1 - _wm, 0.08); S(N.morphB.gain, _wm, 0.08);
|
||
S(N.padFilt.frequency, 220 + d("pad.brightness", 0) * 4200);
|
||
S(N.padAmp.gain, d("pad.brightness", 0) * 0.07, 0.3); // ×9 detuned saws → trimmed to hold the level
|
||
if (crusher) { // world-driven 12-bit-DAC crunch (default clean)
|
||
const cb = clamp(d("crush", 0), 0, 1);
|
||
crusher.parameters.get("bits").value = 16 - cb * 12; // 16 clean → 4 crunchy
|
||
crusher.parameters.get("reduction").value = 1 + cb * 8; // 1 → 9× downsample
|
||
}
|
||
// 🫀 GODSQUASH — one knob squeezes the whole master bus; a world route can pump it.
|
||
const sq = c01(d("squash", 0)); // 0 = transparent (no route, no squeeze)
|
||
S(N.squash.threshold, -6 - sq * 30, 0.1); // −6 → −36 dB
|
||
S(N.squash.ratio, 1 + sq * 7, 0.1); // 1:1 → 8:1
|
||
N.squash.release.setTargetAtTime(0.12 + sq * 0.13, t, 0.2);
|
||
S(N.squashMakeup.gain, 1 + sq * sq, 0.1); // makeup: quadratic, gain-compensates the compressor's ~5.95·sq² dB RMS drop (measured) — loudness stays level ±0.5 dB while crest falls
|
||
S(N.droneAmp.gain, d("drone.voices", 0) * 0.08, 0.3);
|
||
S(N.revSend.gain, (d("reverb.size", 0) * 0.5 + d("master.space", 0) * 0.5) * 0.30, 0.3);
|
||
S(N.fb.gain, Math.min(0.55, d("delay.feedback", 0) * 0.6), 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);
|
||
|
||
// ---- Earth Echo — the planet on the tape ----
|
||
// Repeat rate GLIDES (long time-constant) so a change pitch-bends the tail,
|
||
// the tape-motor artifact. Feedback climbs toward — never past — self-osc.
|
||
N.echoDelay.delayTime.setTargetAtTime(0.06 + c01(d("echo.time", 0)) * 0.44, t, 0.28);
|
||
N.echoFb.gain.setTargetAtTime(Math.min(0.97, c01(d("echo.feedback", 0)) * 0.99), t, 0.15);
|
||
N.echoFilt.frequency.setTargetAtTime(6800 - c01(d("echo.tone", 0)) * 6300, t, 0.3); // pollution darkens
|
||
N.echoFlutAmt.gain.setTargetAtTime(c01(d("echo.flutter", 0)) * 0.006, t, 0.3); // the moon warbles it
|
||
N.hissGain.gain.setTargetAtTime(c01(d("echo.wear", 0)) * 0.03, t, 0.4); // fire wears the tape
|
||
N.echoWet.gain.setTargetAtTime(earthEchoOn ? (0.16 + c01(d("echo.wet", 0)) * 0.5) : 0, t, 0.3);
|
||
// retriggers are rate-limited to the godtime grid — data jitter glides
|
||
// the pitch but only strikes on musical time, not machine-gun time
|
||
const minGap = 30 / Math.max(40, godtime.bpm); // one 8th note
|
||
const ln = Math.round(d("lead.note", 0));
|
||
if (ln >= 24 && ln !== leadNote) {
|
||
leadNote = ln; N.leadA.frequency.setTargetAtTime(mtof(ln), t, 0.02); N.leadB.frequency.setTargetAtTime(mtof(ln), t, 0.02);
|
||
if (t - lastLeadT > minGap) { hit(N.leadAmp, 0.13, 0.35); lastLeadT = t; }
|
||
}
|
||
const bn = Math.round(d("bass.note", 0));
|
||
if (bn >= 20 && bn !== bassNote && t - lastBassT > minGap * 2) {
|
||
lastBassT = t;
|
||
bassNote = bn; N.bassOsc.frequency.setTargetAtTime(mtof(bn), t, 0.03); hit(N.bassAmp, 0.20, 0.6);
|
||
N.padOscs.forEach(p => p.o.frequency.setTargetAtTime(mtof(bn + 12 + p.iv), 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.06 + dens * 0.07);
|
||
const _gd = d("granular.density", 0); // real granular: a cloud of short scattered grains
|
||
for (let _g = 0, _ng = Math.floor(_gd * _gd * 6); _g < _ng; _g++)
|
||
noiseHit(1400 + Math.random() * 5000, 0.012 + Math.random() * 0.028, 0.02 + _gd * 0.03, t + Math.random() * six);
|
||
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();
|
||
}
|
||
|
||
// ---- AudioWorklet voices: a 12-bit-DAC bitcrusher on the master bus (world-
|
||
// drivable) and a sample-accurate Karplus-Strong string. Both need a worklet
|
||
// (native feedback + real sample-rate reduction are unstable/impossible with
|
||
// stock nodes — the spectral harness proved it). Loaded async from a Blob URL.
|
||
const WORKLET_SRC = "class Bitcrush extends AudioWorkletProcessor{static get parameterDescriptors(){return[{name:'bits',defaultValue:16,minValue:1,maxValue:16,automationRate:'k-rate'},{name:'reduction',defaultValue:1,minValue:1,maxValue:64,automationRate:'k-rate'}];}constructor(){super();this.phase=0;this.hold=null;}process(inputs,outputs,p){const inp=inputs[0],out=outputs[0];if(!inp||!inp.length)return true;const b=p.bits[0],red=Math.max(1,Math.round(p.reduction[0])),lv=Math.pow(2,b),nC=out.length,nS=out[0].length;if(!this.hold)this.hold=new Float32Array(8);for(let i=0;i<nS;i++){if(this.phase<=0){for(let c=0;c<nC;c++){const xi=(inp[c]||inp[0])[i];this.hold[c]=Math.round(xi*lv)/lv;}this.phase=red;}this.phase--;for(let c=0;c<nC;c++)out[c][i]=this.hold[c];}return true;}}registerProcessor('bitcrush',Bitcrush);class Karplus extends AudioWorkletProcessor{constructor(){super();this.v=[];for(let i=0;i<8;i++)this.v.push({buf:new Float32Array(4096),len:0,idx:0,active:false,fb:0.995,damp:0.5,last:0,amp:0});this.n=0;this.port.onmessage=(e)=>{const vo=this.v[this.n];this.n=(this.n+1)%8;const L=Math.max(2,Math.min(4096,Math.round(sampleRate/e.data.freq)));vo.len=L;vo.idx=0;vo.active=true;vo.amp=e.data.vel;vo.last=0;for(let i=0;i<L;i++)vo.buf[i]=Math.random()*2-1;};}process(inputs,outputs){const out=outputs[0],y=out[0],nS=y.length;for(let i=0;i<nS;i++)y[i]=0;for(const vo of this.v){if(!vo.active)continue;for(let i=0;i<nS;i++){const cur=vo.buf[vo.idx];const f=vo.damp*cur+(1-vo.damp)*vo.last;vo.last=f;vo.buf[vo.idx]=f*vo.fb;y[i]+=cur*vo.amp*0.4;vo.idx++;if(vo.idx>=vo.len)vo.idx=0;}}for(let c=1;c<out.length;c++)out[c].set(y);return true;}}registerProcessor('karplus',Karplus);class Recorder extends AudioWorkletProcessor{constructor(){super();this.rec=false;this.buf=null;this.pos=0;this.port.onmessage=(e)=>{if(e.data.rec){this.buf=new Float32Array(e.data.len);this.pos=0;this.rec=true;this.tag=e.data.tag;}};}process(inputs){const inp=inputs[0];if(this.rec&&inp&&inp[0]){const x=inp[0];for(let i=0;i<x.length&&this.pos<this.buf.length;i++)this.buf[this.pos++]=x[i];if(this.pos>=this.buf.length){this.rec=false;const b=this.buf;this.buf=null;this.port.postMessage({done:true,buf:b,tag:this.tag},[b.buffer]);}}return true;}}registerProcessor('recorder',Recorder);";
|
||
let crusher = null, ksNode = null, ksMode = false, workletTried = false;
|
||
let recNode = null, sampleBuf = null, sampleMode = false; // GODSONIQ (cheeky Ensoniq nod): resample the world onto the keys
|
||
let recTarget = "godsoniq"; // routes a finished Recorder capture: "godsoniq" → keys-replay, "ampler" → amplerBuf
|
||
let amplerBuf = null, slN = 0, sliceFwd = [], sliceRevBuf = [], sliceRev = []; // 🌾 AMPLER: capture + equal slices (fwd + lazily-cached reversed) + per-slice reverse flags
|
||
const aMap = {}; // 🌾 AMPLER assignment map: voice → slice idx (session-only, not persisted in v1)
|
||
function loadWorklets() {
|
||
if (workletTried || !ctx || !ctx.audioWorklet || !N) return;
|
||
workletTried = true;
|
||
const url = URL.createObjectURL(new Blob([WORKLET_SRC], { type: "application/javascript" }));
|
||
ctx.audioWorklet.addModule(url).then(() => {
|
||
crusher = new AudioWorkletNode(ctx, "bitcrush");
|
||
N.glitch.disconnect(); N.glitch.connect(crusher); crusher.connect(N.out); // splice onto the master bus
|
||
ksNode = new AudioWorkletNode(ctx, "karplus"); ksNode.connect(N.preSat);
|
||
// GODSONIQ recorder: tap the full master mix (post-FX, post-crush). Pulled via a
|
||
// muted sink so it processes every quantum; records N samples on cue, ships the buffer back.
|
||
recNode = new AudioWorkletNode(ctx, "recorder", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
|
||
N.out.connect(recNode);
|
||
const rsink = ctx.createGain(); rsink.gain.value = 0; recNode.connect(rsink); rsink.connect(ctx.destination);
|
||
recNode.port.onmessage = (e) => { if (!e.data.done) return;
|
||
const f = e.data.buf, ab = ctx.createBuffer(1, f.length, ctx.sampleRate); ab.copyToChannel(f, 0);
|
||
if (e.data.tag === "ampler") { amplerBuf = ab; slN = 0; sliceFwd = []; sliceRev = []; sliceRevBuf = []; } // 🌾 AMPLER — its own buffer; must (re)chop. Routed by the capture's own tag (race-proof), so GODSONIQ's sampleBuf/sampleMode stay untouched.
|
||
else { sampleBuf = ab; sampleMode = true; ksMode = false; } // GODSONIQ — plucks now replay it, pitched
|
||
};
|
||
}).catch(() => { workletTried = false; });
|
||
}
|
||
function armRec(secs) { // shared: arm the Recorder worklet for `secs` of the live master
|
||
if (!ctx) start();
|
||
if (!recNode) return false; // worklet not ready
|
||
if (!on) start(); // world must be sounding to sample it
|
||
recNode.port.postMessage({ rec: true, len: Math.floor((secs || 3) * ctx.sampleRate), tag: recTarget }); // tag travels with the capture → the finished buffer routes to its own sink, no shared-state race
|
||
return true;
|
||
}
|
||
function godsoniq(secs) { recTarget = "godsoniq"; return armRec(secs); } // GODSONIQ — capture lands on the keys-replay path (unchanged)
|
||
|
||
// ---- 🌾 AMPLER — chop the captured world into slices seated on the drum voices.
|
||
// Captures land in amplerBuf (recTarget routes them, so GODSONIQ stays byte-identical);
|
||
// slices are equal divisions; reverse is a lazily-built, cached per-slice copy; an
|
||
// assigned voice PLAYS its slice through that voice's level→pan→drumBus chain, so the
|
||
// kit knobs and their patch-persistence keep working, and duck/choke stay source-agnostic. ----
|
||
function revBuf(fwd) { const n = fwd.length, r = ctx.createBuffer(1, n, fwd.sampleRate), s = fwd.getChannelData(0), o = r.getChannelData(0);
|
||
for (let i = 0; i < n; i++) o[i] = s[n - 1 - i]; return r; }
|
||
function amplerSliceBuf(idx) { // the buffer to play for slice idx (reversed copy if flagged, built lazily)
|
||
if (idx == null || idx < 0 || idx >= slN || !sliceFwd[idx]) return null;
|
||
if (sliceRev[idx]) { if (!sliceRevBuf[idx]) sliceRevBuf[idx] = revBuf(sliceFwd[idx]); return sliceRevBuf[idx]; }
|
||
return sliceFwd[idx];
|
||
}
|
||
function playBuf(buf, dst, vel, t) { // one-shot slice playback with a short anti-click fade; returns the gain (for choke)
|
||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||
const g = ctx.createGain(), pk = vel * vel, dur = buf.duration;
|
||
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(pk, t + 0.003);
|
||
if (dur > 0.008) { const fo = t + dur - Math.min(0.006, dur / 3); g.gain.setValueAtTime(pk, fo); g.gain.linearRampToValueAtTime(0, t + dur); } // anti-click tail, fade clamped for short slices
|
||
src.connect(g); g.connect(dst); src.start(t); src.stop(t + dur + 0.02);
|
||
return g;
|
||
}
|
||
function fireSlice(voice, buf, vel) { // an AMPLER slice seated on a drum voice (playbackRate 1.0 — no pitching in v1)
|
||
const t = ctx.currentTime;
|
||
if (voice === "hat" || voice === "ohat") choke(t); // the TR choke is source-agnostic
|
||
const g = playBuf(buf, N.drums[voice].g, vel, t); // → level → pan → drumBus (kit knobs & persistence still act)
|
||
if (voice === "kick") kickDuck(t); // a slice-kick still parts the sea
|
||
if (voice === "ohat") ohatEnv = g; // a slice-ohat can be choked
|
||
}
|
||
function amplerArm(secs) { recTarget = "ampler"; return armRec(secs); } // capture `secs` of the live master into amplerBuf
|
||
function amplerStatus() { return { has: !!amplerBuf, secs: amplerBuf ? amplerBuf.duration : 0, slices: slN }; }
|
||
function amplerChop(n) { // slice the capture into n equal slices (8 or 16)
|
||
if (!amplerBuf) return false;
|
||
n = n === 8 ? 8 : n === 16 ? 16 : 0; if (!n) return false;
|
||
const total = amplerBuf.length, data = amplerBuf.getChannelData(0), per = Math.floor(total / n);
|
||
if (per < 1) return false;
|
||
sliceFwd = []; sliceRev = []; sliceRevBuf = [];
|
||
for (let i = 0; i < n; i++) { const s0 = i * per, len = (i === n - 1) ? (total - s0) : per;
|
||
const b = ctx.createBuffer(1, len, amplerBuf.sampleRate); b.copyToChannel(data.subarray(s0, s0 + len), 0);
|
||
sliceFwd.push(b); sliceRev.push(false); sliceRevBuf.push(null); }
|
||
slN = n;
|
||
for (const v in aMap) if (own(aMap, v) && aMap[v] >= n) delete aMap[v]; // drop assignments that fell out of range so map/UI match playback
|
||
return true;
|
||
}
|
||
function amplerPlay(idx, vel) { // audition a slice through the full FX chain (default vel 0.9)
|
||
const b = amplerSliceBuf(idx); if (!b || !ctx) return false;
|
||
ctx.resume(); const t = ctx.currentTime; if (!on) N.out.gain.setTargetAtTime(0.85, t, 0.1);
|
||
playBuf(b, N.preSat, vel == null ? 0.9 : c01(vel), t); return true;
|
||
}
|
||
function amplerReverse(idx, on2) { // toggle (on omitted) or set the slice's reverse; → new boolean
|
||
if (idx == null || idx < 0 || idx >= slN) return false;
|
||
const nv = on2 === undefined ? !sliceRev[idx] : !!on2;
|
||
if (nv && !sliceRevBuf[idx]) sliceRevBuf[idx] = revBuf(sliceFwd[idx]);
|
||
sliceRev[idx] = nv; return nv;
|
||
}
|
||
function amplerAssign(voice, idx) { // voice now PLAYS this slice (own-key voice only — proto-safe)
|
||
if (!own(DRUM_DEFS, voice) || idx == null || idx < 0 || idx >= slN || !sliceFwd[idx]) return false;
|
||
aMap[voice] = idx; return true;
|
||
}
|
||
function amplerClear(voice) { if (own(aMap, voice)) delete aMap[voice]; return true; } // back to the synth recipe
|
||
function amplerMap() { const m = {}; for (const v in aMap) if (own(aMap, v)) m[v] = { slice: aMap[v], reverse: !!sliceRev[aMap[v]] }; return m; }
|
||
function amplerPeaks(cols) { // min/max per column for the panel waveform (a D helper beyond the frozen 8)
|
||
cols = Math.max(1, Math.min(2048, Math.floor(cols || 200))); const out = [];
|
||
if (!amplerBuf) return out;
|
||
const data = amplerBuf.getChannelData(0), n = data.length, per = n / cols;
|
||
for (let c = 0; c < cols; c++) { let mn = 1, mx = -1; const a = Math.floor(c * per), b = Math.min(n, Math.floor((c + 1) * per));
|
||
for (let i = a; i < b; i++) { const x = data[i]; if (x < mn) mn = x; if (x > mx) mx = x; }
|
||
if (b <= a) { mn = 0; mx = 0; } out.push([mn, mx]); }
|
||
return out;
|
||
}
|
||
// 🌐 external tab feed — pull another tab's audio (a YouTube tab, a live stream) into the
|
||
// world via getDisplayMedia. Wired into preSat, so it flows the whole FX chain to the master:
|
||
// audible live AND resampleable by GODSONIQ (which taps N.out). Chrome/Edge only; on a Mac the
|
||
// picker grabs a TAB's audio (tick "Share tab audio"), not all-system (that needs a virtual device).
|
||
let extStream = null, extSrc = null, extGain = null;
|
||
function stopFeed() {
|
||
if (extStream) extStream.getTracks().forEach(t => t.stop());
|
||
if (extSrc) { try { extSrc.disconnect(); } catch (e) {} }
|
||
if (extGain) { try { extGain.disconnect(); } catch (e) {} }
|
||
extStream = extSrc = extGain = null;
|
||
}
|
||
async function feedTab() {
|
||
const md = navigator.mediaDevices;
|
||
if (!md || !md.getDisplayMedia) return { ok: false, reason: "unsupported" };
|
||
if (extStream) { stopFeed(); return { ok: true, stopped: true }; } // second click = stop
|
||
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
|
||
let stream;
|
||
try { // video:true is mandatory to get tab audio in Chromium; kill the voice-DSP that mangles music, and mute the tab's own speaker output so we don't hear it twice
|
||
stream = await md.getDisplayMedia({ video: true, audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, suppressLocalAudioPlayback: true } });
|
||
} catch (e) { return { ok: false, reason: "cancelled" }; }
|
||
const aud = stream.getAudioTracks();
|
||
if (!aud.length) { stream.getTracks().forEach(t => t.stop()); return { ok: false, reason: "no-audio" }; } // user forgot "Share tab audio"
|
||
stream.getVideoTracks().forEach(t => t.stop()); // we only want the sound
|
||
extStream = stream;
|
||
extSrc = ctx.createMediaStreamSource(stream);
|
||
extGain = ctx.createGain(); extGain.gain.value = 0.9;
|
||
extSrc.connect(extGain); extGain.connect(N.preSat); // into the FX chain → master
|
||
if (!on) start();
|
||
aud[0].addEventListener("ended", stopFeed); // browser's "Stop sharing" button
|
||
return { ok: true };
|
||
}
|
||
function start() {
|
||
if (!AC) return;
|
||
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
|
||
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.
|
||
const FM_RATIO = 3; // FM operator ratio (2 = warm/woody, 3 = bright tine, 4 = glassy) — all verified alias-free
|
||
// durMs (optional): a sequenced note's length in ms — the FM voice is gated to it
|
||
// (piano-roll note length). Omitted (hand-played / mind keys / ratchets) = the
|
||
// original long natural decay, byte-for-byte unchanged.
|
||
function pluck(midi, vel, durMs) {
|
||
if (!AC) return;
|
||
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
|
||
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 freq = mtof(midi);
|
||
if (sampleMode && sampleBuf) { // GODSONIQ — replay the captured world, pitched across the keys
|
||
const src = ctx.createBufferSource(); src.buffer = sampleBuf;
|
||
src.playbackRate.value = Math.pow(2, (midi - 60) / 12); // note-spread from middle C (ASR-style)
|
||
const g = ctx.createGain(); g.gain.value = 0;
|
||
src.connect(g); g.connect(N.preSat); // through the full FX chain — route `crush` for the ASR grit
|
||
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.5 * v, t + 0.005);
|
||
g.gain.setTargetAtTime(0, t + 0.05, 0.6);
|
||
src.start(t); src.stop(t + 3.2);
|
||
return;
|
||
}
|
||
if (ksMode && ksNode) { ksNode.port.postMessage({ freq, vel: v }); return; } // Karplus string voice
|
||
|
||
// FM pluck — a sine carrier phase-modulated by a sine operator, index enveloped
|
||
// (bright "tine" attack → mellow tail) and scaled by velocity. Spectrally verified:
|
||
// in-tune, harder=brighter, no aliasing, no DC. (DX7 §, PM not FM — stable pitch.)
|
||
const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = freq;
|
||
const mod = ctx.createOscillator(); mod.type = "sine"; mod.frequency.value = freq * FM_RATIO;
|
||
const mi = ctx.createGain(); // mod index in Hz
|
||
mi.gain.setValueAtTime(freq * (1.2 + 4 * v), t);
|
||
mi.gain.exponentialRampToValueAtTime(Math.max(1, freq * 0.3 * v), t + 0.22);
|
||
mod.connect(mi); mi.connect(o.frequency);
|
||
const f = ctx.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = 900 + v * 4200;
|
||
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);
|
||
if (durMs == null) { // hand-played: the original long natural decay
|
||
g.gain.setTargetAtTime(0, t + 0.02, 0.38);
|
||
o.start(t); mod.start(t); o.stop(t + 2.4); mod.stop(t + 2.4);
|
||
} else { // sequenced: gated to the note's length, with a soft release
|
||
const dur = Math.max(0.04, durMs / 1000), rel = 0.16;
|
||
g.gain.setTargetAtTime(0.13 * v, t + 0.02, Math.min(0.34, dur)); // settle to a sustained body
|
||
g.gain.setTargetAtTime(0, t + dur, rel); // release when the note ends
|
||
const end = t + dur + rel * 8;
|
||
o.start(t); mod.start(t); o.stop(end); mod.stop(end);
|
||
}
|
||
}
|
||
|
||
const btn = document.createElement("div");
|
||
btn.id = "sound"; btn.textContent = "♪"; btn.title = "play OMNI — 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", () => {
|
||
const wasOff = !on;
|
||
on ? stop() : start(); paint();
|
||
if (wasOff) { // starting into the raw factory wall? point the way out
|
||
const anyGroove = Object.keys(seqs).some(k => seqs[k] && seqs[k].on);
|
||
if (!anyGroove)
|
||
eventTicker.unshift({ text: "♪ this is the full world at once — for a gentle song, right-click the sky → 🌗 mood → first light", tAdded: now(), src: "sys" });
|
||
}
|
||
});
|
||
|
||
// ---- 📼 TESTAMENT — record what god does ("it is written") --------------
|
||
// A MediaRecorder tapped off N.lim: the TRUE final mix, post-squash and
|
||
// post-limiter — exactly what you hear. Lazy (destination + recorder built
|
||
// on first roll), timesliced so a crash loses ≤1s. The GODSONIQ worklet
|
||
// Recorder is a different machine for a different purpose — untouched.
|
||
let testDest = null, testRec = null, testChunks = [], testT0 = 0, testUiTimer = null;
|
||
function testamentStart() {
|
||
if (!ctx) start(); // wake the graph — a recording can begin the session
|
||
if (testRec) return false; // already rolling
|
||
if (!testDest) { testDest = ctx.createMediaStreamDestination(); N.lim.connect(testDest); }
|
||
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]
|
||
.find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m));
|
||
if (!mime) return false; // no MediaRecorder / no supported container (old Safari)
|
||
testChunks = []; testRec = new MediaRecorder(testDest.stream, { mimeType: mime, audioBitsPerSecond: 256000 });
|
||
testRec.ondataavailable = (e) => { if (e.data && e.data.size) testChunks.push(e.data); };
|
||
testRec.start(1000); // 1s timeslices
|
||
testT0 = ctx.currentTime; return true;
|
||
}
|
||
function testamentStop() {
|
||
return new Promise((resolve) => {
|
||
if (!testRec) { resolve(null); return; }
|
||
const rec = testRec, secs = ctx.currentTime - testT0;
|
||
const ext = rec.mimeType.indexOf("mp4") >= 0 ? "m4a" : "webm";
|
||
rec.onstop = () => { const blob = new Blob(testChunks, { type: rec.mimeType }); testChunks = []; resolve({ blob, secs, ext }); };
|
||
testRec = null; rec.stop();
|
||
});
|
||
}
|
||
function testamentState() { return { on: !!testRec, secs: testRec ? ctx.currentTime - testT0 : 0 }; }
|
||
|
||
const testBtn = document.createElement("div");
|
||
testBtn.id = "testament"; testBtn.textContent = "⏺"; testBtn.title = "TESTAMENT — record the master mix to a file";
|
||
Object.assign(testBtn.style, { position: "fixed", top: "12px", right: "132px", zIndex: 10,
|
||
height: "30px", minWidth: "30px", padding: "0 7px", borderRadius: "8px", display: "flex",
|
||
alignItems: "center", justifyContent: "center", background: "rgba(20,28,44,0.7)", color: "#cba6a6",
|
||
cursor: "pointer", fontSize: "13px", border: "1px solid rgba(120,150,200,0.25)",
|
||
backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" });
|
||
if (!AC || !window.MediaRecorder) { testBtn.style.opacity = "0.3"; testBtn.title = "this browser cannot record"; }
|
||
document.body.appendChild(testBtn);
|
||
const testFmt = (s) => { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); };
|
||
function testPaint() { const st = testamentState(); testBtn.classList.toggle("on", st.on); testBtn.textContent = st.on ? "⏺ " + testFmt(st.secs) : "⏺"; }
|
||
function testamentToggle() {
|
||
if (testamentState().on) {
|
||
testamentStop().then((r) => {
|
||
if (testUiTimer) { clearInterval(testUiTimer); testUiTimer = null; }
|
||
testPaint();
|
||
if (!r || !r.blob || !r.blob.size) return;
|
||
const d = new Date(), pad = (n) => String(n).padStart(2, "0");
|
||
const stamp = "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + "-" + pad(d.getHours()) + pad(d.getMinutes());
|
||
const a = document.createElement("a"); a.href = URL.createObjectURL(r.blob);
|
||
a.download = "godstrument-testament-" + stamp + "." + r.ext;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||
eventTicker.unshift({ text: "📼 testament: " + testFmt(r.secs) + " — it is written", tAdded: now(), src: "sys" });
|
||
});
|
||
} else {
|
||
if (!testamentStart()) { eventTicker.unshift({ text: "📼 this browser cannot bear witness", tAdded: now(), src: "sys" }); return; }
|
||
testPaint(); testUiTimer = setInterval(testPaint, 1000);
|
||
}
|
||
}
|
||
testBtn.addEventListener("click", testamentToggle);
|
||
|
||
return { update(dests) { if (dests) Object.assign(D, dests); }, pluck,
|
||
testamentStart, testamentStop, testamentState, testament: testamentToggle,
|
||
toggle() { on ? stop() : start(); paint(); return on; }, playing() { return on; },
|
||
stringVoice(v) { if (v === undefined) return ksMode; if (!ctx) start(); ksMode = !!v; sampleMode = false; return ksMode; },
|
||
godsoniq(secs) { return godsoniq(secs); }, sampling() { return sampleMode && !!sampleBuf; },
|
||
feedTab() { return feedTab(); }, feeding() { return !!extStream; },
|
||
drum, drumParam, drumParamList, drumKit, drumKitLoad,
|
||
amplerArm, amplerStatus, amplerChop, amplerPlay, amplerReverse, amplerAssign, amplerClear, amplerMap, amplerPeaks };
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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", "wide");
|
||
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";
|
||
acct.classList.add("wide");
|
||
const codes = s.open_codes || [];
|
||
const me = user.username;
|
||
const userRows = (s.accounts || []).map(a => {
|
||
const isMe = a.username === me;
|
||
const canToggle = !a.env_admin && !(isMe && a.admin); // never strand yourself; env-admin can't toggle in db
|
||
return '<div class="urow" data-id="' + a.id + '">' +
|
||
'<div class="uhead"><span class="uname">@' + esc(a.username) + "</span>" +
|
||
(a.admin ? '<span class="ubadge">admin</span>' : "") +
|
||
'<span class="umeta">' + esc(a.email) + " · " + plural(a.presets, "patch") + " · joined " + esc(a.joined) + (isMe ? " · you" : "") + "</span></div>" +
|
||
'<div class="uact">' +
|
||
'<button class="ubtn" data-act="edit">✎ edit</button>' +
|
||
'<button class="ubtn" data-act="reset">🔑 reset pw</button>' +
|
||
(canToggle ? '<button class="ubtn" data-act="toggle">' + (a.admin ? "☆ revoke admin" : "★ make admin") + "</button>" : "") +
|
||
(isMe ? "" : '<button class="ubtn danger" data-act="delete">🗑 delete</button>') +
|
||
"</div>" +
|
||
'<div class="uedit" style="display:none">' +
|
||
'<input class="ename" value="' + esc(a.username) + '" placeholder="username" maxlength="24">' +
|
||
'<input class="eemail" value="' + esc(a.email) + '" placeholder="email">' +
|
||
'<button class="ubtn" data-act="save">save</button><button class="ubtn" data-act="cancel">cancel</button></div>' +
|
||
'<div class="umsg"></div></div>';
|
||
}).join("");
|
||
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>' +
|
||
'<div class="sub" style="margin-top:10px">open invite codes — click any to copy</div>' +
|
||
'<div class="codes" id="codes">' +
|
||
(codes.length ? codes.map(c => '<span class="codechip">' + esc(c) + "</span>").join("")
|
||
: '<span class="sub">none — issue one above</span>') + "</div>" +
|
||
'<div class="sub" style="margin-top:14px">users — click ✎ to edit, 🔑 for a temp password</div>' +
|
||
'<div class="userlist" id="userlist">' + (userRows || '<div class="sub">none</div>') + "</div>" +
|
||
'<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>";
|
||
const wireChip = (chip) => {
|
||
chip.onclick = () => copyText(chip.textContent, () => {
|
||
const prev = chip.textContent;
|
||
chip.classList.add("copied"); chip.textContent = "copied ✓";
|
||
setTimeout(() => { chip.classList.remove("copied"); chip.textContent = prev; }, 1100);
|
||
});
|
||
};
|
||
box.querySelectorAll(".codechip").forEach(wireChip);
|
||
box.querySelector("#newcode").onclick = async () => {
|
||
const cr = await api("/api/admin/invite", "POST");
|
||
if (!cr.ok) return;
|
||
const holder = box.querySelector("#codes");
|
||
if (holder.querySelector(".sub")) holder.innerHTML = ""; // clear the "none" placeholder
|
||
const chip = document.createElement("span");
|
||
chip.className = "codechip"; chip.textContent = cr.data.code;
|
||
holder.insertBefore(chip, holder.firstChild);
|
||
wireChip(chip);
|
||
copyText(cr.data.code, () => { // fresh code copies itself
|
||
chip.classList.add("copied"); const c = cr.data.code; chip.textContent = "copied ✓";
|
||
setTimeout(() => { chip.classList.remove("copied"); chip.textContent = c; }, 1100);
|
||
});
|
||
};
|
||
const ulist = box.querySelector("#userlist");
|
||
if (ulist) ulist.onclick = async (e) => { // edit / reset / admin / delete a user
|
||
const btn = e.target.closest(".ubtn"); if (!btn) return;
|
||
const row = btn.closest(".urow"); if (!row) return;
|
||
const id = row.dataset.id, act = btn.dataset.act, msg = row.querySelector(".umsg");
|
||
msg.style.color = "rgba(160,180,210,0.7)";
|
||
const fail = (rr, def) => { msg.style.color = "#ff9ba0"; msg.textContent = (rr.data && rr.data.error) || def; };
|
||
if (act === "edit") { row.querySelector(".uedit").style.display = "flex"; return; }
|
||
if (act === "cancel") { row.querySelector(".uedit").style.display = "none"; msg.textContent = ""; return; }
|
||
if (act === "save") {
|
||
const rr = await api("/api/admin/user/" + id, "POST",
|
||
{ username: row.querySelector(".ename").value.trim(), email: row.querySelector(".eemail").value.trim() });
|
||
if (rr.ok) loadAdmin(); else fail(rr, "save failed");
|
||
return;
|
||
}
|
||
if (act === "toggle") {
|
||
const rr = await api("/api/admin/user/" + id, "POST", { admin: btn.textContent.indexOf("make") >= 0 });
|
||
if (rr.ok) loadAdmin(); else fail(rr, "failed");
|
||
return;
|
||
}
|
||
if (act === "reset") {
|
||
const rr = await api("/api/admin/user/" + id + "/reset", "POST");
|
||
if (rr.ok && rr.data.password) {
|
||
msg.style.color = "rgba(160,180,210,0.7)";
|
||
msg.innerHTML = 'temp password (give it to them, they should change it): <span class="tmp">' + esc(rr.data.password) + "</span>";
|
||
const tmp = msg.querySelector(".tmp");
|
||
tmp.style.cursor = "pointer"; tmp.title = "click to copy";
|
||
tmp.onclick = () => copyText(rr.data.password, () => { const p = tmp.textContent; tmp.textContent = "copied ✓"; setTimeout(() => tmp.textContent = p, 1100); });
|
||
} else fail(rr, "reset failed");
|
||
return;
|
||
}
|
||
if (act === "delete") {
|
||
if (!confirm("Delete " + row.querySelector(".uname").textContent + " and their saved patches? This cannot be undone.")) return;
|
||
const rr = await api("/api/admin/user/" + id, "DELETE");
|
||
if (rr.ok) loadAdmin(); else fail(rr, "delete failed");
|
||
return;
|
||
}
|
||
};
|
||
}
|
||
// clipboard with a fallback for non-secure contexts
|
||
function copyText(text, onOk) {
|
||
const done = () => { if (onOk) onOk(); };
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
|
||
} else fallbackCopy(text, done);
|
||
}
|
||
function fallbackCopy(text, done) {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
||
document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand("copy"); } catch (e) {}
|
||
document.body.removeChild(ta); done();
|
||
}
|
||
|
||
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(); });
|
||
|
||
// ---- table of contents (the grimoire got big) — a jump-anywhere index ----
|
||
const heads = [...g.querySelectorAll("h2")];
|
||
heads.forEach((h, i) => { if (!h.id) h.id = "grim-" + i; h.style.scrollMarginTop = "24px"; });
|
||
const toc = document.createElement("nav"); toc.id = "grimtoc";
|
||
const th = document.createElement("div"); th.className = "grimtoc-head"; th.textContent = "contents";
|
||
toc.appendChild(th);
|
||
heads.forEach(h => {
|
||
const it = document.createElement("div"); it.className = "grimtoc-item";
|
||
it.textContent = h.textContent.replace(/\s+—.*$/, ""); // trim " — subtitle" for a tidy list
|
||
it.title = h.textContent;
|
||
it.addEventListener("click", () => { // delta-based jump (robust in any layout)
|
||
const target = g.scrollTop + h.getBoundingClientRect().top - g.getBoundingClientRect().top - 20;
|
||
g.scrollTop = Math.max(0, target); // instant teleport — always lands
|
||
toc.classList.remove("open");
|
||
});
|
||
toc.appendChild(it);
|
||
});
|
||
g.appendChild(toc);
|
||
const tb = document.createElement("div"); tb.id = "grimtocbtn"; tb.textContent = "☰ contents"; tb.title = "jump to a section";
|
||
tb.addEventListener("click", (e) => { e.stopPropagation(); toc.classList.toggle("open"); });
|
||
g.appendChild(tb);
|
||
g.addEventListener("click", (e) => { if (e.target !== tb && !toc.contains(e.target)) toc.classList.remove("open"); });
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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>
|