Compare commits
1 Commits
burn/1354-
...
nexusburn/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d3d949d4 |
@@ -27,8 +27,10 @@ jobs:
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
cd ~/the-nexus || git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git ~/the-nexus
|
||||
cd ~/the-nexus || git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git ~/the-nexus
|
||||
cd ~/the-nexus
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
./deploy.sh main
|
||||
docker compose build nexus-main
|
||||
docker compose up -d --force-recreate nexus-main
|
||||
echo "Nexus deployed — HTTP :8080, WS :8765"
|
||||
|
||||
@@ -11,11 +11,16 @@ COPY nexus/ nexus/
|
||||
COPY server.py ./
|
||||
|
||||
# Frontend assets referenced by index.html
|
||||
COPY index.html help.html style.css app.js service-worker.js manifest.json ./
|
||||
COPY index.html help.html style.css app.js boot.js bootstrap.mjs gofai_worker.js mempalace.js service-worker.js manifest.json ./
|
||||
|
||||
# Config/data
|
||||
COPY portals.json vision.json robots.txt ./
|
||||
|
||||
# Icons
|
||||
COPY icons/ icons/
|
||||
|
||||
# Expose HTTP (static) and WebSocket
|
||||
EXPOSE 8080
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["python3", "server.py"]
|
||||
|
||||
6
app.js
6
app.js
@@ -2188,7 +2188,11 @@ function connectHermes() {
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
|
||||
// WS gateway runs on HTTP port - 115 (8080->8765, 8081->8766)
|
||||
const wsHost = window.location.hostname;
|
||||
const httpPort = parseInt(window.location.port) || 8080;
|
||||
const wsPort = httpPort === 8081 ? 8766 : 8765;
|
||||
const wsUrl = `${protocol}//${wsHost}:${wsPort}/api/world/ws`;
|
||||
|
||||
console.log(`Connecting to Hermes at ${wsUrl}...`);
|
||||
hermesWs = new WebSocket(wsUrl);
|
||||
|
||||
12
deploy.sh
12
deploy.sh
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — spin up (or update) the Nexus staging environment
|
||||
# Usage: ./deploy.sh — rebuild and restart nexus-main (port 4200)
|
||||
# ./deploy.sh staging — rebuild and restart nexus-staging (port 4201)
|
||||
# Usage: ./deploy.sh — rebuild and restart nexus-main (HTTP :8080, WS :8765)
|
||||
# ./deploy.sh staging — rebuild and restart nexus-staging (HTTP :8081, WS :8766)
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${1:-nexus-main}"
|
||||
@@ -14,4 +14,12 @@ esac
|
||||
echo "==> Deploying $SERVICE …"
|
||||
docker compose build "$SERVICE"
|
||||
docker compose up -d --force-recreate "$SERVICE"
|
||||
|
||||
if [ "$SERVICE" = "nexus-main" ]; then
|
||||
echo "==> HTTP: http://localhost:8080"
|
||||
echo "==> WS: ws://localhost:8765"
|
||||
else
|
||||
echo "==> HTTP: http://localhost:8081"
|
||||
echo "==> WS: ws://localhost:8766"
|
||||
fi
|
||||
echo "==> Done. Container: $SERVICE"
|
||||
|
||||
@@ -6,10 +6,12 @@ services:
|
||||
container_name: nexus-main
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8765:8765"
|
||||
nexus-staging:
|
||||
build: .
|
||||
container_name: nexus-staging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8766:8765"
|
||||
- "8081:8080"
|
||||
- "8766:8765"
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Sovereign Sound Playground
|
||||
|
||||
An interactive audio-visual experience that lets you paint with sound and create music visually.
|
||||
|
||||
## Live Version
|
||||
|
||||
**LIVE:** https://playground.alexanderwhitestone.com/playground.html
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Visual Piano Keyboard**: 26 keys mapped to keyboard (QWERTY layout)
|
||||
- **6 Visual Modes**:
|
||||
- FREE: Freeform painting with sound
|
||||
- GRAVITY: Notes gravitate toward cursor
|
||||
- RAIN: Musical rain falls from above
|
||||
- CONSTELLATION: Notes connect in constellation patterns
|
||||
- BPM: Grid pulses to the beat
|
||||
- MIRROR: Mirror notes across vertical axis
|
||||
- **5 Color Palettes**:
|
||||
- AURORA: Warm rainbow colors
|
||||
- OCEAN: Cool blues and teals
|
||||
- EMBER: Warm reds and oranges
|
||||
- FOREST: Natural greens
|
||||
- NEON: Vibrant neon colors
|
||||
|
||||
### Audio Features
|
||||
- **Ambient Beat**: Automatic chord progressions with kick, snare, and hi-hat
|
||||
- **Chord Detection**: Real-time chord recognition (major, minor, 7th, etc.)
|
||||
- **Mouse Playback**: Hover over painted notes to hear them again
|
||||
- **Touch Support**: Works on mobile devices
|
||||
|
||||
### Tools
|
||||
- **Recording**: Press R to record your session
|
||||
- **Export**: Press S to save your creation as PNG
|
||||
- **Clear**: Press Backspace to clear the canvas
|
||||
- **Mode Switch**: Press Tab to cycle through modes
|
||||
- **Palette Switch**: Press 1-5 to switch color palettes
|
||||
|
||||
## Controls
|
||||
|
||||
### Keyboard
|
||||
- **A-Z**: Play notes and paint
|
||||
- **Space**: Toggle ambient beat
|
||||
- **Backspace**: Clear canvas
|
||||
- **Tab**: Switch mode
|
||||
- **R**: Toggle recording
|
||||
- **S**: Save as PNG
|
||||
- **1-5**: Switch color palette
|
||||
|
||||
### Mouse
|
||||
- **Click**: Play random note and paint
|
||||
- **Drag**: Continuous painting
|
||||
- **Hover over notes**: Replay sounds
|
||||
|
||||
### Touch
|
||||
- **Touch and drag**: Paint with sound
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Zero dependencies
|
||||
- Pure HTML5 Canvas + Web Audio API
|
||||
- No external libraries
|
||||
- Self-contained single HTML file
|
||||
|
||||
## Integration
|
||||
|
||||
The playground is integrated into The Nexus as a portal:
|
||||
- **Portal ID**: `playground`
|
||||
- **Portal Type**: `creative-tool`
|
||||
- **Status**: Online
|
||||
- **Access**: Visitor mode (no operator privileges needed)
|
||||
|
||||
## Iteration Plan
|
||||
|
||||
Future enhancements:
|
||||
- [ ] More modes (Spiral, Gravity Well, Strobe)
|
||||
- [ ] MIDI keyboard support
|
||||
- [ ] Share session as URL
|
||||
- [ ] Mobile optimization
|
||||
- [ ] Multiplayer via WebSocket
|
||||
- [ ] Integration with Nexus spatial audio system
|
||||
- [ ] Memory system for saved compositions
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
playground/
|
||||
├── playground.html # Main playground application
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Created as part of the Timmy Foundation's Sovereign Sound initiative.
|
||||
@@ -1,692 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Sovereign Sound — Playground</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #050510;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #fff;
|
||||
cursor: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
canvas { display: block; position: fixed; top: 0; left: 0; }
|
||||
.piano {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
height: 80px; display: flex;
|
||||
background: rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
}
|
||||
.key {
|
||||
flex: 1; border-right: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
padding-bottom: 8px; font-size: 9px; opacity: 0.3;
|
||||
transition: all 0.1s; position: relative;
|
||||
}
|
||||
.key.black {
|
||||
background: rgba(0,0,0,0.5);
|
||||
height: 50px; margin: 0 -8px; width: 60%; z-index: 1;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.key.active {
|
||||
background: rgba(255,255,255,0.15);
|
||||
opacity: 0.8;
|
||||
transform: scaleY(0.98);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.hud {
|
||||
position: fixed; top: 16px; left: 16px;
|
||||
font-size: 9px; letter-spacing: 3px;
|
||||
text-transform: uppercase; opacity: 0.2;
|
||||
line-height: 2.2; z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mode-switch {
|
||||
position: fixed; top: 16px; right: 16px;
|
||||
display: flex; gap: 4px; z-index: 10;
|
||||
}
|
||||
.mode-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
cursor: pointer; transition: all 0.3s;
|
||||
pointer-events: all;
|
||||
}
|
||||
.mode-dot.active { background: rgba(255,255,255,0.6); transform: scale(1.4); }
|
||||
.toast {
|
||||
position: fixed; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px; letter-spacing: 6px;
|
||||
text-transform: uppercase; opacity: 0;
|
||||
transition: opacity 0.4s; pointer-events: none; z-index: 20;
|
||||
}
|
||||
.toast.show { opacity: 0.4; }
|
||||
.rec-dot {
|
||||
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #ff0040; opacity: 0;
|
||||
transition: opacity 0.3s; z-index: 10;
|
||||
}
|
||||
.rec-dot.on { opacity: 1; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="c"></canvas>
|
||||
|
||||
<div class="hud" id="hud">
|
||||
<div id="h-mode">FREE</div>
|
||||
<div id="h-pal">AURORA</div>
|
||||
<div id="h-notes">0 notes</div>
|
||||
<div id="h-chord">—</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-switch" id="modes"></div>
|
||||
<div class="rec-dot" id="rec"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<div class="piano" id="piano"></div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SOVEREIGN SOUND — PLAYGROUND v3
|
||||
// The ultimate interactive audio-visual experience.
|
||||
// Zero dependencies. Pure craft.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H;
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = innerWidth;
|
||||
H = canvas.height = innerHeight;
|
||||
ctx.fillStyle = '#050510';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AUDIO ENGINE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let ac = null, master = null, analyser = null;
|
||||
|
||||
function initAudio() {
|
||||
if (ac) return;
|
||||
ac = new AudioContext();
|
||||
master = ac.createGain(); master.gain.value = 0.4;
|
||||
|
||||
const wet = ac.createGain(); wet.gain.value = 0.2;
|
||||
[0.037, 0.059, 0.083, 0.127].forEach(t => {
|
||||
const d = ac.createDelay(1); d.delayTime.value = t;
|
||||
const fb = ac.createGain(); fb.gain.value = 0.22;
|
||||
master.connect(d); d.connect(fb); fb.connect(d); d.connect(wet);
|
||||
});
|
||||
wet.connect(ac.destination);
|
||||
|
||||
analyser = ac.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
master.connect(analyser);
|
||||
master.connect(ac.destination);
|
||||
}
|
||||
|
||||
function freq(name) {
|
||||
const n = { C:0,'C#':1,D:2,'D#':3,E:4,F:5,'F#':6,G:7,'G#':8,A:9,'A#':10,B:11 };
|
||||
const nm = name.replace(/\d/,'');
|
||||
const oct = parseInt(name.match(/\d/)?.[0] || 4);
|
||||
return 440 * Math.pow(2, (n[nm] + (oct-4)*12 - 9) / 12);
|
||||
}
|
||||
|
||||
function tone(f, type='sine', dur=0.5, vol=0.1) {
|
||||
initAudio();
|
||||
const t = ac.currentTime;
|
||||
const o = ac.createOscillator();
|
||||
const g = ac.createGain();
|
||||
o.type = type; o.frequency.value = f;
|
||||
g.gain.setValueAtTime(0, t);
|
||||
g.gain.linearRampToValueAtTime(vol, t + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(vol*0.3, t+dur*0.4);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t+dur);
|
||||
o.connect(g); g.connect(master);
|
||||
o.start(t); o.stop(t+dur);
|
||||
}
|
||||
|
||||
function kick() { initAudio(); const t=ac.currentTime; const o=ac.createOscillator(), g=ac.createGain(); o.type='sine'; o.frequency.setValueAtTime(80,t); o.frequency.exponentialRampToValueAtTime(30,t+0.12); g.gain.setValueAtTime(0.4,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.15); o.connect(g); g.connect(master); o.start(t); o.stop(t+0.15); }
|
||||
function snare() { initAudio(); const t=ac.currentTime; const len=ac.sampleRate*0.06; const buf=ac.createBuffer(1,len,ac.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*0.25; const s=ac.createBufferSource(); s.buffer=buf; const g=ac.createGain(); g.gain.setValueAtTime(0.2,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.08); s.connect(g); g.connect(master); s.start(t); }
|
||||
function hat() { initAudio(); const t=ac.currentTime; const len=ac.sampleRate*0.025; const buf=ac.createBuffer(1,len,ac.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*0.12; const s=ac.createBufferSource(); s.buffer=buf; const g=ac.createGain(); g.gain.setValueAtTime(0.1,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.025); s.connect(g); g.connect(master); s.start(t); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SCALES & PALETTES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const SCALES = {
|
||||
AURORA: { colors:['#ff6b6b','#ff9f43','#feca57','#48dbfb','#54a0ff','#5f27cd','#ff9ff3','#00d2d3'], notes:['C5','D5','E5','F5','G5','A5','B5','C6','D6','E6','C4','D4','E4','F4','G4','A4','B4','C5','D5','E5','F5','C2','D2','E2','F2','G2'], bg:[6,6,16], glow:'#ff9ff3' },
|
||||
OCEAN: { colors:['#0077b6','#00b4d8','#90e0ef','#48cae4','#023e8a','#ade8f4'], notes:['D5','E5','F#5','G5','A5','B5','C#6','D6','E6','D4','E4','F#4','G4','A4','B4','C#5','D5','E5','D3','E3','F#3','D2','E2','F#2','G2','A2'], bg:[4,12,22], glow:'#48cae4' },
|
||||
EMBER: { colors:['#ff4500','#ff6347','#ff7f50','#dc143c','#cd5c5c','#f08080'], notes:['C5','Eb5','F5','G5','Ab5','Bb5','C6','D5','Eb5','C4','Eb4','F4','G4','Ab4','Bb4','C5','D5','Eb5','C3','Eb3','F3','C2','Eb2','F2','G2','Ab2'], bg:[14,5,5], glow:'#ff6347' },
|
||||
FOREST: { colors:['#2d6a4f','#40916c','#52b788','#74c69d','#95d5b2','#b7e4c7'], notes:['E5','F#5','G5','A5','B5','C6','D6','E6','F#6','E4','F#4','G4','A4','B4','C5','D5','E5','F#5','E3','F#3','G3','E2','F#2','G2','A2','B2'], bg:[4,12,6], glow:'#52b788' },
|
||||
NEON: { colors:['#ff00ff','#00ffff','#ffff00','#ff0080','#00ff80','#8000ff'], notes:['C5','D5','E5','G5','A5','C6','D6','E6','G6','C4','D4','E4','G4','A4','C5','D5','E5','G5','C3','D3','E3','C2','D2','E2','G2','A2'], bg:[8,2,16], glow:'#00ffff' },
|
||||
};
|
||||
|
||||
let palName = 'AURORA';
|
||||
let pal = SCALES[palName];
|
||||
const PAL_NAMES = Object.keys(SCALES);
|
||||
let palIdx = 0;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MODES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const MODES = ['FREE','GRAVITY','RAIN','CONSTELLATION','BPM','MIRROR'];
|
||||
let modeIdx = 0, mode = MODES[0];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STATE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let notes = []; // permanent painted notes
|
||||
let particles = []; // transient particles
|
||||
let ripples = []; // ripple effects
|
||||
let raindrops = [];
|
||||
let mouseX = W/2, mouseY = H/2;
|
||||
let mouseDown = false;
|
||||
let time = 0;
|
||||
let ambientOn = false;
|
||||
let ambientStep = 0;
|
||||
let ambientTimer = null;
|
||||
let screenShake = 0;
|
||||
let lastPaintTime = 0;
|
||||
let recentNotes = [];
|
||||
let recording = false;
|
||||
let recordedNotes = [];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PIANO KEYBOARD — visual at bottom
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const KEYS = 'qwertyuiopasdfghjklzxcvbnm';
|
||||
const IS_BLACK = [false,true,false,true,false,false,true,false,true,false,true,false,
|
||||
false,true,false,true,false,false,true,false,true,false,true,false,false,false];
|
||||
|
||||
function buildPiano() {
|
||||
const piano = document.getElementById('piano');
|
||||
piano.innerHTML = '';
|
||||
KEYS.split('').forEach((k, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'key' + (IS_BLACK[i] ? ' black' : '');
|
||||
div.dataset.key = k;
|
||||
div.textContent = k.toUpperCase();
|
||||
div.addEventListener('mousedown', () => triggerKey(k));
|
||||
div.addEventListener('touchstart', (e) => { e.preventDefault(); triggerKey(k); });
|
||||
piano.appendChild(div);
|
||||
});
|
||||
}
|
||||
buildPiano();
|
||||
|
||||
// Mode/palette dots
|
||||
const modesDiv = document.getElementById('modes');
|
||||
MODES.forEach((m, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mode-dot' + (i===0?' active':'');
|
||||
dot.onclick = () => { modeIdx=i; mode=MODES[i]; updateDots(); toast(m); };
|
||||
modesDiv.appendChild(dot);
|
||||
});
|
||||
PAL_NAMES.forEach((p, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mode-dot';
|
||||
dot.style.background = SCALES[p].glow;
|
||||
dot.style.opacity = '0.2';
|
||||
if (i===0) { dot.classList.add('active'); dot.style.opacity='0.6'; }
|
||||
dot.onclick = () => { palIdx=i; palName=p; pal=SCALES[p]; updateDots(); toast(p); };
|
||||
modesDiv.appendChild(dot);
|
||||
});
|
||||
|
||||
function updateDots() {
|
||||
modesDiv.querySelectorAll('.mode-dot').forEach((d, i) => {
|
||||
if (i < MODES.length) {
|
||||
d.classList.toggle('active', i===modeIdx);
|
||||
} else {
|
||||
const pi = i - MODES.length;
|
||||
d.classList.toggle('active', pi===palIdx);
|
||||
d.style.opacity = pi===palIdx ? '0.6' : '0.2';
|
||||
}
|
||||
});
|
||||
document.getElementById('h-mode').textContent = mode;
|
||||
document.getElementById('h-pal').textContent = palName;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PAINT & PLAY
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function paint(x, y, color, noteFreq, noteType, size=25) {
|
||||
// Permanent splash
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.06;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath(); ctx.arc(x, y, size*2, 0, Math.PI*2); ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.beginPath();
|
||||
const pts = 6+Math.floor(Math.random()*6);
|
||||
for (let i=0; i<=pts; i++) {
|
||||
const a = (i/pts)*Math.PI*2;
|
||||
const r = size*(0.5+Math.random()*0.5);
|
||||
i===0 ? ctx.moveTo(x+Math.cos(a)*r, y+Math.sin(a)*r) : ctx.lineTo(x+Math.cos(a)*r, y+Math.sin(a)*r);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.beginPath(); ctx.arc(x, y, size*0.12, 0, Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
notes.push({ x, y, radius: size, color, freq: noteFreq, type: noteType });
|
||||
if (notes.length > 4000) notes.splice(0, 500);
|
||||
|
||||
// Particles
|
||||
for (let i=0; i<12; i++) {
|
||||
const a = Math.random()*Math.PI*2;
|
||||
const s = 1+Math.random()*4;
|
||||
particles.push({ x, y, vx:Math.cos(a)*s, vy:Math.sin(a)*s, size:1+Math.random()*3, life:1, color });
|
||||
}
|
||||
if (particles.length > 400) particles.splice(0, 100);
|
||||
|
||||
ripples.push({ x, y, color, size: size*0.3, maxSize: size*3, life:1 });
|
||||
if (ripples.length > 25) ripples.shift();
|
||||
|
||||
if (noteType === 'sawtooth' && noteFreq < 200) screenShake = 6;
|
||||
}
|
||||
|
||||
function triggerKey(key) {
|
||||
const i = KEYS.indexOf(key);
|
||||
if (i < 0) return;
|
||||
|
||||
const noteName = pal.notes[i % pal.notes.length];
|
||||
const noteFreq = freq(noteName);
|
||||
const isBass = i >= 21;
|
||||
const noteType = isBass ? 'sawtooth' : (i%3===0 ? 'triangle' : 'sine');
|
||||
|
||||
tone(noteFreq, noteType, isBass ? 0.3 : 0.6, isBass ? 0.18 : 0.12);
|
||||
|
||||
const x = mouseX + (Math.random()-0.5)*50;
|
||||
const y = mouseY + (Math.random()-0.5)*50;
|
||||
paint(x, y, pal.colors[i % pal.colors.length], noteFreq, noteType, isBass ? 35+Math.random()*15 : 20+Math.random()*15);
|
||||
|
||||
// Piano visual
|
||||
const pianoKey = document.querySelector(`.key[data-key="${key}"]`);
|
||||
if (pianoKey) {
|
||||
pianoKey.classList.add('active');
|
||||
pianoKey.style.background = pal.colors[i % pal.colors.length] + '30';
|
||||
setTimeout(() => { pianoKey.classList.remove('active'); pianoKey.style.background = ''; }, 200);
|
||||
}
|
||||
|
||||
// Track for chord detection
|
||||
recentNotes.push({ freq: noteFreq, time: Date.now() });
|
||||
if (recentNotes.length > 10) recentNotes.shift();
|
||||
detectChord();
|
||||
|
||||
// Recording
|
||||
if (recording) recordedNotes.push({ key, time: Date.now(), x, y });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CHORD DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function detectChord() {
|
||||
const now = Date.now();
|
||||
const recent = recentNotes.filter(n => now-n.time < 1500);
|
||||
if (recent.length < 2) { document.getElementById('h-chord').textContent = '—'; return; }
|
||||
|
||||
const freqs = recent.map(n => n.freq).sort((a,b) => a-b);
|
||||
const ratios = [];
|
||||
for (let i=1; i<freqs.length; i++) ratios.push(Math.round(1200*Math.log2(freqs[i]/freqs[0])));
|
||||
|
||||
const patterns = { 'major':[0,400,700],'minor':[0,300,700],'7':[0,400,700,1000],'maj7':[0,400,700,1100],'min7':[0,300,700,1000],'power':[0,700],'sus4':[0,500,700],'sus2':[0,200,700],'dim':[0,300,600],'aug':[0,400,800] };
|
||||
|
||||
let best = '—', bestScore = 0;
|
||||
for (const [name, pat] of Object.entries(patterns)) {
|
||||
let score = 0;
|
||||
for (const p of pat) if (ratios.some(r => Math.abs(r-p) < 60)) score++;
|
||||
score /= pat.length;
|
||||
if (score > bestScore && score > 0.5) { bestScore = score; best = name; }
|
||||
}
|
||||
document.getElementById('h-chord').textContent = best;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MOUSE PLAYBACK — play notes by hovering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let lastPlayed = null, lastPlayT = 0;
|
||||
function checkPlay(x, y) {
|
||||
const now = Date.now();
|
||||
if (now-lastPlayT < 50) return;
|
||||
let closest = null, closestD = Infinity;
|
||||
for (const n of notes) {
|
||||
const d = Math.hypot(x-n.x, y-n.y);
|
||||
if (d < n.radius*1.4 && d < closestD) { closest = n; closestD = d; }
|
||||
}
|
||||
if (closest && closest !== lastPlayed) {
|
||||
const vol = 0.05 + (1-closestD/closest.radius)*0.1;
|
||||
tone(closest.freq, closest.type, 0.2, vol);
|
||||
ripples.push({ x:closest.x, y:closest.y, color:closest.color, size:closest.radius*0.2, maxSize:closest.radius*1.5, life:1 });
|
||||
for (let i=0; i<3; i++) {
|
||||
const a = Math.random()*Math.PI*2;
|
||||
particles.push({ x:closest.x, y:closest.y, vx:Math.cos(a)*1.5, vy:Math.sin(a)*1.5, size:1.5, life:1, color:closest.color });
|
||||
}
|
||||
lastPlayed = closest;
|
||||
lastPlayT = now;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AMBIENT BEAT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function ambientTick() {
|
||||
if (!ambientOn) return;
|
||||
const bpm = [72,60,80,66,128,90][palIdx];
|
||||
const stepDur = 60000/bpm/4;
|
||||
const beat = ambientStep % 16;
|
||||
|
||||
if (beat%4===0) { kick(); screenShake=2; }
|
||||
if (beat===4||beat===12) snare();
|
||||
if (beat%2===1) hat();
|
||||
|
||||
if (beat===0) {
|
||||
const chords = [
|
||||
[freq('C4'),freq('E4'),freq('G4')],
|
||||
[freq('A3'),freq('C4'),freq('E4')],
|
||||
[freq('F3'),freq('A3'),freq('C4')],
|
||||
[freq('G3'),freq('B3'),freq('D4')]
|
||||
];
|
||||
chords[Math.floor(ambientStep/16)%4].forEach(f => tone(f,'triangle',0.7,0.05));
|
||||
}
|
||||
|
||||
if (beat%2===0) {
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
const k = KEYS[i];
|
||||
const noteName = pal.notes[i % pal.notes.length];
|
||||
paint(W/2+(Math.random()-0.5)*400, H/2+(Math.random()-0.5)*300,
|
||||
pal.colors[i%pal.colors.length], freq(noteName), i>=21?'sawtooth':'sine', 10+Math.random()*8);
|
||||
}
|
||||
|
||||
ambientStep++;
|
||||
ambientTimer = setTimeout(ambientTick, stepDur);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// INPUT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg; el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 1200);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
const k = e.key.toLowerCase();
|
||||
|
||||
if (k===' ') { e.preventDefault(); ambientOn=!ambientOn; ambientOn?(ambientStep=0,ambientTick(),toast('AMBIENT ON')):(clearTimeout(ambientTimer),toast('AMBIENT OFF')); return; }
|
||||
if (k==='backspace') { e.preventDefault(); ctx.fillStyle='#050510'; ctx.fillRect(0,0,W,H); notes=[]; ripples=[]; particles=[]; raindrops=[]; toast('CLEARED'); return; }
|
||||
if (k==='tab') { e.preventDefault(); modeIdx=(modeIdx+1)%MODES.length; mode=MODES[modeIdx]; updateDots(); toast(mode); return; }
|
||||
if (k==='r') { recording=!recording; document.getElementById('rec').classList.toggle('on',recording); toast(recording?'REC ON':'REC OFF'); if(!recording&&recordedNotes.length) replayRecording(); return; }
|
||||
if (k==='s') { e.preventDefault(); saveCanvas(); return; }
|
||||
if (k>='1' && k<='5') { palIdx=parseInt(k)-1; palName=PAL_NAMES[palIdx]; pal=SCALES[palName]; updateDots(); toast(palName); return; }
|
||||
|
||||
triggerKey(k);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
mouseX = e.clientX; mouseY = e.clientY;
|
||||
checkPlay(mouseX, mouseY);
|
||||
if (mouseDown && Date.now()-lastPaintTime > 40) {
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
triggerKey(KEYS[i]);
|
||||
lastPaintTime = Date.now();
|
||||
}
|
||||
if (Math.random()>0.65) {
|
||||
particles.push({ x:mouseX, y:mouseY, vx:(Math.random()-0.5)*0.5, vy:(Math.random()-0.5)*0.5, size:1+Math.random()*1.5, life:1, color:'rgba(255,255,255,0.3)' });
|
||||
if (particles.length>400) particles.splice(0,80);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousedown', e => { mouseDown=true; triggerKey(KEYS[Math.floor(Math.random()*KEYS.length)]); });
|
||||
canvas.addEventListener('mouseup', () => mouseDown=false);
|
||||
|
||||
// Touch
|
||||
canvas.addEventListener('touchmove', e => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
mouseX = t.clientX; mouseY = t.clientY;
|
||||
checkPlay(mouseX, mouseY);
|
||||
if (Date.now()-lastPaintTime > 60) {
|
||||
triggerKey(KEYS[Math.floor(Math.random()*KEYS.length)]);
|
||||
lastPaintTime = Date.now();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MODE EFFECTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function applyGravity() {
|
||||
for (const n of notes) {
|
||||
const dx = mouseX-n.x, dy = mouseY-n.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d>10 && d<300) { n.x += dx*0.2/d; n.y += dy*0.2/d; }
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRain() {
|
||||
if (Math.random()>0.2) return;
|
||||
const i = Math.floor(Math.random()*KEYS.length);
|
||||
raindrops.push({ x:Math.random()*W, y:-20, vy:1.5+Math.random()*3, color:pal.colors[i%pal.colors.length], freq:freq(pal.notes[i%pal.notes.length]), type:i>=21?'sawtooth':'sine', size:8+Math.random()*12, played:false });
|
||||
if (raindrops.length>40) raindrops.shift();
|
||||
}
|
||||
|
||||
function updateRain() {
|
||||
for (let i=raindrops.length-1; i>=0; i--) {
|
||||
const r = raindrops[i]; r.y += r.vy;
|
||||
if (!r.played) for (const n of notes) {
|
||||
if (Math.hypot(r.x-n.x, r.y-n.y) < n.radius) {
|
||||
tone(r.freq, r.type, 0.3, 0.06);
|
||||
ripples.push({ x:r.x, y:r.y, color:r.color, size:5, maxSize:25, life:1 });
|
||||
r.played = true; break;
|
||||
}
|
||||
}
|
||||
if (r.y > H) {
|
||||
if (!r.played) { paint(r.x, H-20, r.color, r.freq, r.type, r.size); tone(r.freq, r.type, 0.3, 0.05); }
|
||||
raindrops.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawConstellation() {
|
||||
ctx.save();
|
||||
for (let i=0; i<notes.length; i++) {
|
||||
for (let j=i+1; j<notes.length; j++) {
|
||||
const d = Math.hypot(notes[i].x-notes[j].x, notes[i].y-notes[j].y);
|
||||
if (d < 180) {
|
||||
ctx.globalAlpha = (1-d/180)*0.12;
|
||||
ctx.strokeStyle = notes[i].color;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(notes[i].x, notes[i].y);
|
||||
ctx.lineTo(notes[j].x, notes[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBPMGrid() {
|
||||
const bpm = 120;
|
||||
const beat = (time % (60/bpm)) / (60/bpm);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = pal.colors[0];
|
||||
ctx.lineWidth = 0.5 + beat;
|
||||
ctx.globalAlpha = 0.02 + beat*0.03;
|
||||
for (let x=0; x<W; x+=80) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
|
||||
for (let y=0; y<H; y+=80) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMirror() {
|
||||
// Mirror notes across vertical axis
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.08;
|
||||
for (const n of notes) {
|
||||
ctx.fillStyle = n.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W-n.x, n.y, n.radius*0.6, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RECORDING & EXPORT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function replayRecording() {
|
||||
if (!recordedNotes.length) return;
|
||||
toast(`REPLAY ${recordedNotes.length} notes`);
|
||||
const start = recordedNotes[0].time;
|
||||
recordedNotes.forEach(n => {
|
||||
setTimeout(() => triggerKey(n.key), n.time - start);
|
||||
});
|
||||
recordedNotes = [];
|
||||
}
|
||||
|
||||
function saveCanvas() {
|
||||
const link = document.createElement('a');
|
||||
link.download = `sovereign-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
toast('SAVED');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RENDER LOOP
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function render() {
|
||||
time += 0.016;
|
||||
|
||||
if (screenShake > 0) { ctx.save(); ctx.translate((Math.random()-0.5)*screenShake,(Math.random()-0.5)*screenShake); screenShake*=0.85; if(screenShake<0.5)screenShake=0; }
|
||||
|
||||
// Mode effects
|
||||
if (mode==='GRAVITY') applyGravity();
|
||||
if (mode==='RAIN') { spawnRain(); updateRain(); }
|
||||
if (mode==='CONSTELLATION') drawConstellation();
|
||||
if (mode==='BPM') drawBPMGrid();
|
||||
if (mode==='MIRROR') drawMirror();
|
||||
|
||||
// Ripples
|
||||
for (let i=ripples.length-1; i>=0; i--) {
|
||||
const r = ripples[i];
|
||||
r.size += (r.maxSize-r.size)*0.07;
|
||||
r.life -= 0.02;
|
||||
if (r.life<=0) { ripples.splice(i,1); continue; }
|
||||
ctx.globalAlpha = r.life*0.3;
|
||||
ctx.strokeStyle = r.color;
|
||||
ctx.lineWidth = 1.5*r.life;
|
||||
ctx.beginPath(); ctx.arc(r.x,r.y,r.size,0,Math.PI*2); ctx.stroke();
|
||||
}
|
||||
|
||||
// Rain
|
||||
for (const r of raindrops) {
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = r.color;
|
||||
ctx.beginPath(); ctx.arc(r.x,r.y,r.size*0.2,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// Particles
|
||||
for (let i=particles.length-1; i>=0; i--) {
|
||||
const p = particles[i];
|
||||
p.x+=p.vx; p.y+=p.vy; p.vx*=0.96; p.vy*=0.96; p.life-=0.014;
|
||||
if (p.life<=0) { particles.splice(i,1); continue; }
|
||||
ctx.globalAlpha = p.life*0.5;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*p.life,0,Math.PI*2); ctx.fill();
|
||||
}
|
||||
|
||||
// Audio-reactive
|
||||
if (analyser) {
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(data);
|
||||
let energy = 0;
|
||||
for (let i=0; i<data.length; i++) energy += data[i];
|
||||
energy /= data.length*255;
|
||||
|
||||
if (energy > 0.08) {
|
||||
const grad = ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,200+energy*200);
|
||||
grad.addColorStop(0, pal.glow+'08');
|
||||
grad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalAlpha = 0.3+energy*0.3;
|
||||
ctx.fillRect(0,0,W,H);
|
||||
}
|
||||
|
||||
// Edge frequency bars
|
||||
ctx.globalAlpha = 0.03;
|
||||
for (let i=0; i<data.length; i++) {
|
||||
const v = data[i]/255;
|
||||
if (v<0.08) continue;
|
||||
ctx.fillStyle = pal.colors[i%pal.colors.length];
|
||||
ctx.fillRect((i/data.length)*W, H-v*40-80, 2, v*40); // above piano
|
||||
}
|
||||
}
|
||||
|
||||
if (screenShake > 0) ctx.restore();
|
||||
|
||||
// Cursor
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mouseX-8,mouseY); ctx.lineTo(mouseX-3,mouseY);
|
||||
ctx.moveTo(mouseX+3,mouseY); ctx.lineTo(mouseX+8,mouseY);
|
||||
ctx.moveTo(mouseX,mouseY-8); ctx.lineTo(mouseX,mouseY-3);
|
||||
ctx.moveTo(mouseX,mouseY+3); ctx.lineTo(mouseX,mouseY+8);
|
||||
ctx.stroke();
|
||||
|
||||
// Color ring when hovering note
|
||||
for (const n of notes) {
|
||||
if (Math.hypot(mouseX-n.x, mouseY-n.y) < n.radius*1.4) {
|
||||
ctx.strokeStyle = n.color;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.beginPath(); ctx.arc(mouseX, mouseY, 12, 0, Math.PI*2); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath(); ctx.arc(mouseX,mouseY,1.5,0,Math.PI*2); ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// HUD
|
||||
document.getElementById('h-notes').textContent = `${notes.length} notes`;
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
119
portals.json
119
portals.json
@@ -6,6 +6,24 @@
|
||||
"status": "online",
|
||||
"color": "#ff6600",
|
||||
"role": "pilot",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "local",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"readiness_steps": {
|
||||
"prototype": { "label": "Prototype", "done": true },
|
||||
"runtime_ready": { "label": "Runtime Ready", "done": false },
|
||||
"launched": { "label": "Launched", "done": false },
|
||||
"harness_bridged": { "label": "Harness Bridged", "done": false }
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "hermes-harness:morrowind",
|
||||
"owner": "Timmy",
|
||||
"app_id": 22320,
|
||||
"window_title": "OpenMW",
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
@@ -14,38 +32,12 @@
|
||||
"rotation": {
|
||||
"y": -0.5
|
||||
},
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "local",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"readiness_steps": {
|
||||
"prototype": {
|
||||
"label": "Prototype",
|
||||
"done": true
|
||||
},
|
||||
"runtime_ready": {
|
||||
"label": "Runtime Ready",
|
||||
"done": false
|
||||
},
|
||||
"launched": {
|
||||
"label": "Launched",
|
||||
"done": false
|
||||
},
|
||||
"harness_bridged": {
|
||||
"label": "Harness Bridged",
|
||||
"done": false
|
||||
}
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "hermes-harness:morrowind",
|
||||
"owner": "Timmy",
|
||||
"app_id": 22320,
|
||||
"window_title": "OpenMW",
|
||||
"destination": {
|
||||
"url": null,
|
||||
"type": "harness",
|
||||
"action_label": "Enter Vvardenfell",
|
||||
"params": { "world": "vvardenfell" }
|
||||
}
|
||||
"params": {
|
||||
"world": "vvardenfell"
|
||||
}
|
||||
@@ -62,6 +54,8 @@
|
||||
"status": "downloaded",
|
||||
"color": "#ffd700",
|
||||
"role": "pilot",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
@@ -116,6 +110,8 @@
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"role": "timmy",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -144,6 +140,8 @@
|
||||
"status": "online",
|
||||
"color": "#0066ff",
|
||||
"role": "timmy",
|
||||
"position": { "x": 25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": -1.57 },
|
||||
"position": {
|
||||
"x": 25,
|
||||
"y": 0,
|
||||
@@ -171,6 +169,8 @@
|
||||
"status": "online",
|
||||
"color": "#ffd700",
|
||||
"role": "timmy",
|
||||
"position": { "x": -25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": 1.57 },
|
||||
"position": {
|
||||
"x": -25,
|
||||
"y": 0,
|
||||
@@ -196,6 +196,8 @@
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"role": "reflex",
|
||||
"position": { "x": 15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": -2.5 },
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
@@ -224,6 +226,8 @@
|
||||
"status": "standby",
|
||||
"color": "#ff4466",
|
||||
"role": "reflex",
|
||||
"position": { "x": -15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": 2.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
@@ -241,64 +245,5 @@
|
||||
},
|
||||
"agents_present": [],
|
||||
"interaction_ready": false
|
||||
},
|
||||
{
|
||||
"id": "playground",
|
||||
"name": "Sound Playground",
|
||||
"description": "Interactive audio-visual experience. Paint with sound, create music visually.",
|
||||
"status": "online",
|
||||
"color": "#ff00ff",
|
||||
"role": "creative",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"z": 15
|
||||
},
|
||||
"rotation": {
|
||||
"y": -0.7
|
||||
},
|
||||
"portal_type": "creative-tool",
|
||||
"world_category": "audio-visual",
|
||||
"environment": "production",
|
||||
"access_mode": "visitor",
|
||||
"readiness_state": "online",
|
||||
"readiness_steps": {
|
||||
"prototype": {
|
||||
"label": "Prototype",
|
||||
"done": true
|
||||
},
|
||||
"runtime_ready": {
|
||||
"label": "Runtime Ready",
|
||||
"done": true
|
||||
},
|
||||
"launched": {
|
||||
"label": "Launched",
|
||||
"done": true
|
||||
},
|
||||
"harness_bridged": {
|
||||
"label": "Harness Bridged",
|
||||
"done": true
|
||||
}
|
||||
},
|
||||
"blocked_reason": null,
|
||||
"telemetry_source": "playground",
|
||||
"owner": "Timmy",
|
||||
"destination": {
|
||||
"url": "./playground/playground.html",
|
||||
"type": "local",
|
||||
"action_label": "Enter Playground",
|
||||
"params": {}
|
||||
},
|
||||
"agents_present": [],
|
||||
"interaction_ready": true,
|
||||
"features": [
|
||||
"Visual piano keyboard",
|
||||
"6 modes (Free, Gravity, Rain, Constellation, BPM, Mirror)",
|
||||
"5 color palettes (Aurora, Ocean, Ember, Forest, Neon)",
|
||||
"Ambient beat with chord progressions",
|
||||
"Mouse playback and chord detection",
|
||||
"Touch support",
|
||||
"Export as PNG"
|
||||
]
|
||||
}
|
||||
]
|
||||
16
run.sh
Executable file
16
run.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# run.sh — run Nexus locally without Docker
|
||||
# Usage: ./run.sh — HTTP :8080, WS :8765
|
||||
# NEXUS_HTTP_PORT=9090 ./run.sh — custom HTTP port
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Install deps if missing
|
||||
if ! python3 -c "import websockets" 2>/dev/null; then
|
||||
echo "==> Installing dependencies..."
|
||||
pip3 install -r requirements.txt
|
||||
fi
|
||||
|
||||
echo "==> Starting Nexus server..."
|
||||
exec python3 server.py
|
||||
233
server.py
233
server.py
@@ -1,92 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
||||
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
||||
the body (Evennia/Morrowind), and the visualization surface.
|
||||
The Nexus — Unified HTTP + WebSocket server.
|
||||
|
||||
Serves static frontend files (Three.js app) over HTTP on port 8080
|
||||
and runs the WebSocket gateway on port 8765.
|
||||
Single-process, single-command deployment — no nginx required.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
# Branch protected file - see POLICY.md
|
||||
import websockets
|
||||
import websockets.asyncio.server
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
PORT = 8765
|
||||
HOST = "0.0.0.0" # Allow external connections if needed
|
||||
# ---------------------------------------------------------------------------
|
||||
HTTP_PORT = int(os.environ.get("NEXUS_HTTP_PORT", "8080"))
|
||||
WS_PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
|
||||
HOST = os.environ.get("NEXUS_HOST", "0.0.0.0")
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
# Logging setup
|
||||
# Static file extensions we're willing to serve
|
||||
SAFE_SUFFIXES = {
|
||||
".html", ".htm", ".css", ".js", ".mjs", ".json",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
|
||||
".woff", ".woff2", ".ttf", ".eot",
|
||||
".txt", ".xml", ".webmanifest",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("nexus-gateway")
|
||||
log = logging.getLogger("nexus")
|
||||
|
||||
# State
|
||||
clients: Set[websockets.WebSocketServerProtocol] = set()
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP — static file server
|
||||
# ---------------------------------------------------------------------------
|
||||
async def http_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
"""Minimal async HTTP/1.1 server for static files."""
|
||||
try:
|
||||
# Read request line + headers (max 8 KB)
|
||||
data = b""
|
||||
while b"\r\n\r\n" not in data and len(data) < 8192:
|
||||
chunk = await asyncio.wait_for(reader.read(4096), timeout=10)
|
||||
if not chunk:
|
||||
return
|
||||
data += chunk
|
||||
header_text = data.split(b"\r\n\r\n", 1)[0].decode("utf-8", errors="replace")
|
||||
lines = header_text.split("\r\n")
|
||||
request_line = lines[0]
|
||||
parts = request_line.split(" ", 2)
|
||||
if len(parts) < 2:
|
||||
writer.close()
|
||||
return
|
||||
method, raw_path = parts[0], parts[1]
|
||||
|
||||
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
"""Handles individual client connections and message broadcasting."""
|
||||
clients.add(websocket)
|
||||
addr = websocket.remote_address
|
||||
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
|
||||
|
||||
if method not in ("GET", "HEAD"):
|
||||
_write_response(writer, HTTPStatus.METHOD_NOT_ALLOWED, b"Method Not Allowed")
|
||||
return
|
||||
|
||||
# Normalise path — prevent directory traversal
|
||||
safe_path = raw_path.split("?", 1)[0].split("#", 1)[0]
|
||||
safe_path = os.path.normpath(safe_path).lstrip("/")
|
||||
if not safe_path:
|
||||
safe_path = "index.html"
|
||||
|
||||
file_path = ROOT / safe_path
|
||||
# Reject traversal
|
||||
if not str(file_path.resolve()).startswith(str(ROOT)):
|
||||
_write_response(writer, HTTPStatus.FORBIDDEN, b"Forbidden")
|
||||
return
|
||||
|
||||
if not file_path.exists() or file_path.is_dir():
|
||||
# Try index.html for directories
|
||||
if file_path.is_dir() and (file_path / "index.html").exists():
|
||||
file_path = file_path / "index.html"
|
||||
else:
|
||||
_write_response(writer, HTTPStatus.NOT_FOUND, b"Not Found")
|
||||
return
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix not in SAFE_SUFFIXES:
|
||||
_write_response(writer, HTTPStatus.FORBIDDEN, b"Forbidden")
|
||||
return
|
||||
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
body = file_path.read_bytes()
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(len(body)),
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
}
|
||||
_write_response(writer, HTTPStatus.OK, body, headers, method == "HEAD")
|
||||
except (asyncio.TimeoutError, ConnectionError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("HTTP handler error: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_response(writer, status, body, headers=None, head_only=False):
|
||||
line = f"HTTP/1.1 {status.value} {status.phrase}\r\n"
|
||||
hdr = ""
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
hdr += f"{k}: {v}\r\n"
|
||||
response = (line + hdr + "\r\n").encode()
|
||||
if not head_only:
|
||||
response += body if isinstance(body, bytes) else body.encode()
|
||||
writer.write(response)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket — broadcast gateway
|
||||
# ---------------------------------------------------------------------------
|
||||
ws_clients: Set[websockets.asyncio.server.ServerConnection] = set()
|
||||
|
||||
|
||||
async def ws_handler(websocket: websockets.asyncio.server.ServerConnection):
|
||||
ws_clients.add(websocket)
|
||||
log.info("WS client connected from %s. Total: %d", websocket.remote_address, len(ws_clients))
|
||||
try:
|
||||
async for message in websocket:
|
||||
# Parse for logging/validation if it's JSON
|
||||
try:
|
||||
data = json.loads(message)
|
||||
msg_type = data.get("type", "unknown")
|
||||
# Optional: log specific important message types
|
||||
if msg_type in ["agent_register", "thought", "action"]:
|
||||
logger.debug(f"Received {msg_type} from {addr}")
|
||||
if msg_type in ("agent_register", "thought", "action"):
|
||||
log.debug("WS %s from %s", msg_type, websocket.remote_address)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Broadcast to all OTHER clients
|
||||
if not clients:
|
||||
continue
|
||||
|
||||
disconnected = set()
|
||||
# Create broadcast tasks, tracking which client each task targets
|
||||
task_client_pairs = []
|
||||
for client in clients:
|
||||
for client in ws_clients:
|
||||
if client != websocket and client.open:
|
||||
task = asyncio.create_task(client.send(message))
|
||||
task_client_pairs.append((task, client))
|
||||
try:
|
||||
await client.send(message)
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
ws_clients.difference_update(disconnected)
|
||||
|
||||
if task_client_pairs:
|
||||
tasks = [pair[0] for pair in task_client_pairs]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
target_client = task_client_pairs[i][1]
|
||||
logger.error(f"Failed to send to client {target_client.remote_address}: {result}")
|
||||
disconnected.add(target_client)
|
||||
|
||||
if disconnected:
|
||||
clients.difference_update(disconnected)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.debug(f"Connection closed by client {addr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling client {addr}: {e}")
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.error("WS handler error for %s: %s", websocket.remote_address, exc)
|
||||
finally:
|
||||
clients.discard(websocket)
|
||||
logger.info(f"Client disconnected {addr}. Total clients: {len(clients)}")
|
||||
ws_clients.discard(websocket)
|
||||
log.info("WS client disconnected. Total: %d", len(ws_clients))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main — run both servers concurrently
|
||||
# ---------------------------------------------------------------------------
|
||||
async def main():
|
||||
"""Main server loop with graceful shutdown."""
|
||||
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
# HTTP server
|
||||
http_server = await asyncio.start_server(http_handler, HOST, HTTP_PORT)
|
||||
log.info("HTTP server listening on http://%s:%d", HOST, HTTP_PORT)
|
||||
|
||||
# WebSocket server
|
||||
ws_server = await websockets.asyncio.server.serve(ws_handler, HOST, WS_PORT)
|
||||
log.info("WebSocket server listening on ws://%s:%d", HOST, WS_PORT)
|
||||
log.info("Nexus is live — open http://%s:%d in a browser", HOST, HTTP_PORT)
|
||||
|
||||
# Graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = loop.create_future()
|
||||
|
||||
|
||||
def shutdown():
|
||||
if not stop.done():
|
||||
stop.set_result(None)
|
||||
@@ -95,29 +193,28 @@ async def main():
|
||||
try:
|
||||
loop.add_signal_handler(sig, shutdown)
|
||||
except NotImplementedError:
|
||||
# Signal handlers not supported on Windows
|
||||
pass
|
||||
|
||||
async with websockets.serve(broadcast_handler, HOST, PORT):
|
||||
logger.info("Gateway is ready and listening.")
|
||||
await stop
|
||||
|
||||
logger.info("Shutting down Nexus WS gateway...")
|
||||
# Close any remaining client connections (handlers may have already cleaned up)
|
||||
remaining = {c for c in clients if c.open}
|
||||
await stop
|
||||
log.info("Shutting down...")
|
||||
|
||||
http_server.close()
|
||||
await http_server.wait_closed()
|
||||
ws_server.close()
|
||||
await ws_server.wait_closed()
|
||||
|
||||
remaining = {c for c in ws_clients if c.open}
|
||||
if remaining:
|
||||
logger.info(f"Closing {len(remaining)} active connections...")
|
||||
close_tasks = [client.close() for client in remaining]
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
clients.clear()
|
||||
|
||||
logger.info("Shutdown complete.")
|
||||
await asyncio.gather(*(c.close() for c in remaining), return_exceptions=True)
|
||||
ws_clients.clear()
|
||||
log.info("Shutdown complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.critical(f"Fatal server error: {e}")
|
||||
except Exception as exc:
|
||||
log.critical("Fatal: %s", exc)
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user