- RLE format: 2-byte LE count (matches Rust rle_encode/rle_decode) - Delta frames: FLAG_COMPRESSED flag set correctly - Signal sync: FLAG_KEYFRAME flag + periodic sync every ~5s for late-join - Source: handle touch, gamepad axis/button, resize inputs from receiver - Receiver: touch event capture for mobile, ping frame handling - Protocol constants: added FRAME_PING, INPUT_TOUCH, INPUT_GAMEPAD_*
817 lines
No EOL
38 KiB
HTML
817 lines
No EOL
38 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>DreamStack — Stream Source</title>
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', system-ui, sans-serif;
|
|
background: #0a0a0f;
|
|
color: #e2e8f0;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6, #ec4899);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
|
|
.subtitle {
|
|
color: rgba(255, 255, 255, 0.25);
|
|
font-size: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.layout {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
|
|
.panel {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
border-radius: 16px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.panel h2 {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 0.8rem;
|
|
}
|
|
|
|
canvas {
|
|
border-radius: 12px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 0.4rem;
|
|
min-width: 320px;
|
|
}
|
|
|
|
.stat {
|
|
background: rgba(99, 102, 241, 0.06);
|
|
border: 1px solid rgba(99, 102, 241, 0.1);
|
|
border-radius: 10px;
|
|
padding: 0.5rem 0.7rem;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.55rem;
|
|
color: rgba(255, 255, 255, 0.3);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
color: #c4b5fd;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.connection {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.connection.disconnected {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.connection.connected {
|
|
background: rgba(34, 197, 94, 0.1);
|
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
color: #86efac;
|
|
}
|
|
|
|
.connection .dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
.connection.disconnected .dot {
|
|
background: #ef4444;
|
|
}
|
|
|
|
.connection.connected .dot {
|
|
background: #22c55e;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
|
|
50% {
|
|
opacity: 0.3;
|
|
}
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
flex-wrap: wrap;
|
|
margin-top: 0.6rem;
|
|
}
|
|
|
|
.controls button {
|
|
padding: 0.35rem 0.8rem;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.12));
|
|
color: #c4b5fd;
|
|
cursor: pointer;
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
transition: all 0.15s;
|
|
border: 1px solid rgba(139, 92, 246, 0.1);
|
|
}
|
|
|
|
.controls button:hover {
|
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.25), rgba(139, 92, 246, 0.25));
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.controls button.active {
|
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.4), rgba(139, 92, 246, 0.4));
|
|
border-color: rgba(139, 92, 246, 0.4);
|
|
}
|
|
|
|
.mode-row {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.mode-btn {
|
|
padding: 0.3rem 0.7rem;
|
|
border: none;
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: rgba(255, 255, 255, 0.3);
|
|
cursor: pointer;
|
|
font-size: 0.65rem;
|
|
font-weight: 500;
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.mode-btn.active {
|
|
background: rgba(139, 92, 246, 0.2);
|
|
color: #c4b5fd;
|
|
border-color: rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
.bitstream-vis {
|
|
display: flex;
|
|
gap: 2px;
|
|
height: 25px;
|
|
align-items: flex-end;
|
|
margin-top: 0.4rem;
|
|
}
|
|
|
|
.bitstream-bar {
|
|
width: 3px;
|
|
background: linear-gradient(to top, #6366f1, #8b5cf6);
|
|
border-radius: 1px;
|
|
min-height: 2px;
|
|
transition: height 0.1s ease;
|
|
}
|
|
|
|
.powered {
|
|
margin-top: 1rem;
|
|
font-size: 0.6rem;
|
|
color: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.powered span {
|
|
color: rgba(139, 92, 246, 0.25);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<h1>⚡ Bitstream Source</h1>
|
|
<p class="subtitle">Renders the UI. Streams pixels/signals/audio. Receives inputs.</p>
|
|
|
|
<div class="connection disconnected" id="connStatus">
|
|
<div class="dot"></div>
|
|
<span id="connText">Connecting to relay...</span>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="panel">
|
|
<h2>Rendered Scene</h2>
|
|
<canvas id="scene" width="600" height="400"></canvas>
|
|
<div class="controls" id="controls"></div>
|
|
<div class="mode-row">
|
|
<button class="mode-btn active" id="modePixel" onclick="setMode('pixel')">📺 Pixels</button>
|
|
<button class="mode-btn" id="modeDelta" onclick="setMode('delta')">Δ Delta</button>
|
|
<button class="mode-btn" id="modeSignal" onclick="setMode('signal')">📡 Signals</button>
|
|
<button class="mode-btn" id="modeNeural" onclick="setMode('neural')">🧠 Neural</button>
|
|
<button class="mode-btn" id="modeAudio" onclick="toggleAudio()">🔇 Audio</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>Stream Stats</h2>
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<div class="stat-label">FPS</div>
|
|
<div class="stat-value" id="statFps">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Mode</div>
|
|
<div class="stat-value" id="statMode">pixel</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Receivers</div>
|
|
<div class="stat-value" id="statReceivers">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Frame Size</div>
|
|
<div class="stat-value" id="statFrameSize">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Bandwidth</div>
|
|
<div class="stat-value" id="statBandwidth">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Delta Ratio</div>
|
|
<div class="stat-value" id="statDelta">—</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Frames</div>
|
|
<div class="stat-value" id="statFrames">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Inputs</div>
|
|
<div class="stat-value" id="statInputs">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Audio</div>
|
|
<div class="stat-value" id="statAudio">off</div>
|
|
</div>
|
|
</div>
|
|
<div class="bitstream-vis" id="bitstreamVis"></div>
|
|
</div>
|
|
</div>
|
|
<div class="powered">Built with <span>DreamStack</span> — universal bitstream</div>
|
|
|
|
<script>
|
|
// ════════════════════════════════════════════════════════
|
|
// DreamStack Runtime (signals + springs)
|
|
// ════════════════════════════════════════════════════════
|
|
const DS = (() => {
|
|
let currentEffect = null, batchDepth = 0, pendingEffects = new Set();
|
|
class Signal {
|
|
constructor(val) { this._value = val; this._subs = new Set(); }
|
|
get value() { if (currentEffect) this._subs.add(currentEffect); return this._value; }
|
|
set value(v) {
|
|
if (this._value === v) return; this._value = v;
|
|
if (batchDepth > 0) for (const s of this._subs) pendingEffects.add(s);
|
|
else for (const s of [...this._subs]) s._run();
|
|
}
|
|
}
|
|
class Effect {
|
|
constructor(fn) { this._fn = fn; this._disposed = false; }
|
|
_run() { if (this._disposed) return; const prev = currentEffect; currentEffect = this; try { this._fn(); } finally { currentEffect = prev; } }
|
|
dispose() { this._disposed = true; }
|
|
}
|
|
const signal = v => new Signal(v);
|
|
const effect = fn => { const e = new Effect(fn); e._run(); return e; };
|
|
const batch = fn => { batchDepth++; try { fn(); } finally { batchDepth--; if (batchDepth === 0) { const effs = [...pendingEffects]; pendingEffects.clear(); for (const e of effs) e._run(); } } };
|
|
|
|
const _activeSprings = new Set();
|
|
let _rafId = null, _lastTime = 0;
|
|
class Spring {
|
|
constructor({ value = 0, target, stiffness = 170, damping = 26, mass = 1 } = {}) {
|
|
this._signal = new Signal(value); this._velocity = 0;
|
|
this._target = target !== undefined ? target : value;
|
|
this.stiffness = stiffness; this.damping = damping; this.mass = mass; this._settled = true;
|
|
}
|
|
get value() { return this._signal.value; }
|
|
set value(v) { this.target = v; }
|
|
get target() { return this._target; }
|
|
set target(t) { this._target = t; this._settled = false; _activeSprings.add(this); _startLoop(); }
|
|
set(v) { this._signal.value = v; this._target = v; this._velocity = 0; this._settled = true; _activeSprings.delete(this); }
|
|
_step(dt) {
|
|
const pos = this._signal._value, vel = this._velocity;
|
|
const k = this.stiffness, d = this.damping, m = this.mass;
|
|
const a = (p, v) => (-k * (p - this._target) - d * v) / m;
|
|
const k1v = a(pos, vel), k1p = vel;
|
|
const k2v = a(pos + k1p * dt / 2, vel + k1v * dt / 2), k2p = vel + k1v * dt / 2;
|
|
const k3v = a(pos + k2p * dt / 2, vel + k2v * dt / 2), k3p = vel + k2v * dt / 2;
|
|
const k4v = a(pos + k3p * dt, vel + k3v * dt), k4p = vel + k3v * dt;
|
|
this._velocity = vel + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
|
|
this._signal.value = pos + (dt / 6) * (k1p + 2 * k2p + 2 * k3p + k4p);
|
|
if (Math.abs(this._velocity) < 0.01 && Math.abs(this._signal._value - this._target) < 0.01) {
|
|
this._signal.value = this._target; this._velocity = 0; this._settled = true; _activeSprings.delete(this);
|
|
}
|
|
}
|
|
}
|
|
function _startLoop() { if (_rafId !== null) return; _lastTime = performance.now(); _rafId = requestAnimationFrame(_loop); }
|
|
function _loop(now) {
|
|
const dt = Math.min((now - _lastTime) / 1000, 0.064); _lastTime = now;
|
|
batch(() => { for (const s of _activeSprings) { const steps = Math.ceil(dt / (1 / 120)); const subDt = dt / steps; for (let i = 0; i < steps; i++) s._step(subDt); } });
|
|
if (_activeSprings.size > 0) _rafId = requestAnimationFrame(_loop); else _rafId = null;
|
|
}
|
|
function spring(opts) { return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts }); }
|
|
return { signal, effect, batch, spring, Signal, Spring };
|
|
})();
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
// Protocol Constants
|
|
// ════════════════════════════════════════════════════════
|
|
const HEADER_SIZE = 16;
|
|
const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10;
|
|
const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40;
|
|
const FRAME_PING = 0xFE, FRAME_END = 0xFF;
|
|
const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03;
|
|
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11;
|
|
const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21;
|
|
const INPUT_GAMEPAD_AXIS = 0x30, INPUT_GAMEPAD_BUTTON = 0x31;
|
|
const INPUT_SCROLL = 0x50, INPUT_RESIZE = 0x60;
|
|
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04;
|
|
|
|
function encodeHeader(type, flags, seq, ts, w, h, len) {
|
|
const b = new ArrayBuffer(HEADER_SIZE), v = new DataView(b);
|
|
v.setUint8(0, type); v.setUint8(1, flags); v.setUint16(2, seq, true); v.setUint32(4, ts, true);
|
|
v.setUint16(8, w, true); v.setUint16(10, h, true); v.setUint32(12, len, true);
|
|
return new Uint8Array(b);
|
|
}
|
|
function decodeHeader(buf) {
|
|
const v = new DataView(buf.buffer || buf, buf.byteOffset || 0);
|
|
return {
|
|
type: v.getUint8(0), flags: v.getUint8(1), seq: v.getUint16(2, true), timestamp: v.getUint32(4, true),
|
|
width: v.getUint16(8, true), height: v.getUint16(10, true), length: v.getUint32(12, true)
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
// Scene Setup
|
|
// ════════════════════════════════════════════════════════
|
|
const canvas = document.getElementById('scene');
|
|
const ctx = canvas.getContext('2d');
|
|
const W = 600, H = 400;
|
|
|
|
const ballX = DS.spring({ value: 300, stiffness: 170, damping: 26 });
|
|
const ballY = DS.spring({ value: 200, stiffness: 170, damping: 26 });
|
|
const ballR = DS.spring({ value: 25, stiffness: 300, damping: 20 });
|
|
const targetX = DS.signal(300), targetY = DS.signal(200);
|
|
|
|
function renderScene() {
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1;
|
|
for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
|
|
for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
|
|
const bx = ballX.value, by = ballY.value, br = ballR.value, tx = targetX.value, ty = targetY.value;
|
|
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(tx, ty);
|
|
ctx.strokeStyle = 'rgba(139,92,246,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]);
|
|
ctx.strokeStyle = 'rgba(99,102,241,0.08)'; ctx.setLineDash([4, 4]);
|
|
ctx.beginPath(); ctx.moveTo(tx, 0); ctx.lineTo(tx, H); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(0, ty); ctx.lineTo(W, ty); ctx.stroke(); ctx.setLineDash([]);
|
|
ctx.beginPath(); ctx.arc(tx, ty, 6, 0, Math.PI * 2); ctx.fillStyle = 'rgba(99,102,241,0.3)'; ctx.fill();
|
|
ctx.beginPath(); ctx.arc(bx, by, br + 16, 0, Math.PI * 2); ctx.fillStyle = 'rgba(139,92,246,0.03)'; ctx.fill();
|
|
ctx.beginPath(); ctx.arc(bx, by, br + 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(139,92,246,0.08)'; ctx.fill();
|
|
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2);
|
|
const grad = ctx.createRadialGradient(bx - br * 0.3, by - br * 0.3, br * 0.1, bx, by, br);
|
|
grad.addColorStop(0, '#c4b5fd'); grad.addColorStop(1, '#8b5cf6');
|
|
ctx.fillStyle = grad; ctx.shadowColor = '#8b5cf6'; ctx.shadowBlur = 25; ctx.fill(); ctx.shadowBlur = 0;
|
|
ctx.font = '10px Inter'; ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
|
ctx.fillText(`DreamStack — ${streamMode} mode`, 10, H - 10);
|
|
}
|
|
DS.effect(renderScene);
|
|
|
|
// ── Feature 5: Neural Renderer ──
|
|
// Generates pixels directly from signal state — no canvas primitives
|
|
// This is what a trained model would do: signal_state → framebuffer
|
|
function neuralRender() {
|
|
const bx = ballX._signal._value, by = ballY._signal._value;
|
|
const br = ballR._signal._value;
|
|
const imgData = ctx.createImageData(W, H);
|
|
const d = imgData.data;
|
|
for (let py = 0; py < H; py++) {
|
|
for (let px = 0; px < W; px++) {
|
|
const i = (py * W + px) * 4;
|
|
// Grid (procedural)
|
|
const onGrid = (px % 40 === 0 || py % 40 === 0) ? 8 : 0;
|
|
// Ball glow (distance field — a "learned" SDF)
|
|
const dx = px - bx, dy = py - by;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const glow = Math.max(0, 1 - dist / (br + 30)) * 0.15;
|
|
const innerGlow = Math.max(0, 1 - dist / (br + 10)) * 0.3;
|
|
const solid = dist < br ? 1 : 0;
|
|
// "Neural" color mixing
|
|
const r = Math.min(255, onGrid + glow * 60 + innerGlow * 80 + solid * 139);
|
|
const g = Math.min(255, onGrid + glow * 30 + innerGlow * 40 + solid * 92);
|
|
const b2 = Math.min(255, onGrid + glow * 120 + innerGlow * 160 + solid * 246);
|
|
d[i] = r; d[i + 1] = g; d[i + 2] = b2; d[i + 3] = 255;
|
|
}
|
|
}
|
|
ctx.putImageData(imgData, 0, 0);
|
|
ctx.font = '10px Inter'; ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
|
ctx.fillText('🧠 Neural Renderer (procedural SDF)', 10, H - 10);
|
|
return imgData;
|
|
}
|
|
|
|
// ── Interaction ──
|
|
let dragging = false;
|
|
function handlePointer(x, y, type) {
|
|
if (type === 'down') {
|
|
const bx = ballX._signal._value, by = ballY._signal._value;
|
|
if (Math.hypot(x - bx, y - by) < 40) { dragging = true; canvas.style.cursor = 'grabbing'; }
|
|
else { targetX.value = x; targetY.value = y; ballX.value = x; ballY.value = y; }
|
|
} else if (type === 'move' && dragging) {
|
|
ballX.set(Math.max(20, Math.min(W - 20, x)));
|
|
ballY.set(Math.max(20, Math.min(H - 20, y)));
|
|
targetX.value = x; targetY.value = y;
|
|
} else if (type === 'up' && dragging) {
|
|
dragging = false; canvas.style.cursor = 'pointer';
|
|
ballX.value = 300; ballY.value = 200; targetX.value = 300; targetY.value = 200;
|
|
}
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', e => { const r = canvas.getBoundingClientRect(); handlePointer(e.clientX - r.left, e.clientY - r.top, 'down'); });
|
|
window.addEventListener('mousemove', e => { if (!dragging) return; const r = canvas.getBoundingClientRect(); handlePointer(e.clientX - r.left, e.clientY - r.top, 'move'); });
|
|
window.addEventListener('mouseup', () => handlePointer(0, 0, 'up'));
|
|
|
|
// Preset buttons
|
|
const presets = [
|
|
{ label: '↖ TL', x: 60, y: 60 }, { label: '↗ TR', x: 540, y: 60 },
|
|
{ label: '⊙ Center', x: 300, y: 200 }, { label: '↙ BL', x: 60, y: 340 },
|
|
{ label: '↘ BR', x: 540, y: 340 },
|
|
{ label: '🎾 Bounce', action: 'bounce' }, { label: '💥 Pulse', action: 'pulse' },
|
|
];
|
|
const ctrlEl = document.getElementById('controls');
|
|
presets.forEach(p => {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = p.label;
|
|
btn.addEventListener('click', () => {
|
|
if (p.action === 'bounce') {
|
|
const pos = [[80, 80], [520, 80], [520, 320], [80, 320]]; let i = 0;
|
|
const iv = setInterval(() => { const [x, y] = pos[i % pos.length]; targetX.value = x; targetY.value = y; ballX.value = x; ballY.value = y; if (++i >= 8) clearInterval(iv); }, 350);
|
|
} else if (p.action === 'pulse') { ballR.value = 70; setTimeout(() => ballR.value = 25, 250); }
|
|
else { targetX.value = p.x; targetY.value = p.y; ballX.value = p.x; ballY.value = p.y; }
|
|
});
|
|
ctrlEl.appendChild(btn);
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
// Feature 4: Audio Synthesis
|
|
// ════════════════════════════════════════════════════════
|
|
let audioCtx = null, audioEnabled = false;
|
|
const AUDIO_SAMPLE_RATE = 22050, AUDIO_CHUNK_SIZE = 1024;
|
|
|
|
function toggleAudio() {
|
|
audioEnabled = !audioEnabled;
|
|
const btn = document.getElementById('modeAudio');
|
|
if (audioEnabled) {
|
|
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE });
|
|
btn.textContent = '🔊 Audio'; btn.classList.add('active');
|
|
} else {
|
|
btn.textContent = '🔇 Audio'; btn.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
function synthesizeAudio() {
|
|
if (!audioEnabled || !audioCtx) return null;
|
|
// Synthesize from spring state — velocity drives pitch, distance drives volume
|
|
const vel = Math.sqrt(ballX._velocity * ballX._velocity + ballY._velocity * ballY._velocity);
|
|
const dist = Math.hypot(ballX._signal._value - targetX._value, ballY._signal._value - targetY._value);
|
|
const freq = 220 + vel * 2; // base frequency + velocity
|
|
const amp = Math.min(0.3, dist / 500); // volume from distance
|
|
const samples = new Float32Array(AUDIO_CHUNK_SIZE);
|
|
const t0 = performance.now() / 1000;
|
|
for (let i = 0; i < AUDIO_CHUNK_SIZE; i++) {
|
|
const t = t0 + i / AUDIO_SAMPLE_RATE;
|
|
samples[i] = amp * Math.sin(2 * Math.PI * freq * t) * Math.exp(-vel * 0.001);
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
function encodeAudioFrame(samples, seq, ts) {
|
|
const payload = new Uint8Array(samples.buffer);
|
|
const header = encodeHeader(FRAME_AUDIO, 0, seq, ts, 1, AUDIO_SAMPLE_RATE / 100, payload.length);
|
|
const msg = new Uint8Array(HEADER_SIZE + payload.length);
|
|
msg.set(header, 0); msg.set(payload, HEADER_SIZE);
|
|
return msg;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
// Streaming Engine
|
|
// ════════════════════════════════════════════════════════
|
|
let ws = null, seq = 0, streamStart = performance.now();
|
|
let framesSent = 0, bytesSent = 0, inputsRecv = 0;
|
|
let lastSecBytes = 0, lastSecFrames = 0, lastSecTime = performance.now();
|
|
let prevFrame = null; // for delta compression
|
|
let lastDeltaRatio = 0;
|
|
let streamMode = 'pixel'; // pixel | delta | signal | neural
|
|
let prevSignalState = null;
|
|
let receiverCount = 0;
|
|
|
|
// Bitstream viz
|
|
const visEl = document.getElementById('bitstreamVis');
|
|
const visBars = [];
|
|
for (let i = 0; i < 60; i++) {
|
|
const bar = document.createElement('div'); bar.className = 'bitstream-bar'; bar.style.height = '2px';
|
|
visEl.appendChild(bar); visBars.push(bar);
|
|
}
|
|
let visIdx = 0;
|
|
|
|
function setMode(mode) {
|
|
streamMode = mode;
|
|
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
|
const id = 'mode' + mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.add('active');
|
|
prevFrame = null; prevSignalState = null; // reset state on mode change
|
|
}
|
|
|
|
function connect() {
|
|
ws = new WebSocket('ws://localhost:9100');
|
|
ws.binaryType = 'arraybuffer';
|
|
ws.onopen = () => {
|
|
document.getElementById('connStatus').className = 'connection connected';
|
|
document.getElementById('connText').textContent = 'Connected — streaming';
|
|
streamStart = performance.now(); startStreaming();
|
|
};
|
|
ws.onmessage = e => {
|
|
if (e.data instanceof ArrayBuffer && e.data.byteLength >= HEADER_SIZE) {
|
|
const header = decodeHeader(new Uint8Array(e.data));
|
|
if (header.flags & FLAG_INPUT) { inputsRecv++; handleRemoteInput(header, new Uint8Array(e.data, HEADER_SIZE)); }
|
|
}
|
|
};
|
|
ws.onclose = () => {
|
|
document.getElementById('connStatus').className = 'connection disconnected';
|
|
document.getElementById('connText').textContent = 'Disconnected — reconnecting...';
|
|
stopStreaming(); setTimeout(connect, 2000);
|
|
};
|
|
ws.onerror = () => {
|
|
document.getElementById('connStatus').className = 'connection disconnected';
|
|
document.getElementById('connText').textContent = 'Relay not found — cargo run -p ds-stream';
|
|
};
|
|
}
|
|
|
|
// ── Feature 2: Full Bidirectional Input ──
|
|
function handleRemoteInput(header, payload) {
|
|
const view = new DataView(payload.buffer, payload.byteOffset);
|
|
switch (header.type) {
|
|
case INPUT_PTR_DOWN:
|
|
if (payload.length >= 5) handlePointer(view.getUint16(0, true), view.getUint16(2, true), 'down');
|
|
break;
|
|
case INPUT_POINTER:
|
|
if (payload.length >= 5) handlePointer(view.getUint16(0, true), view.getUint16(2, true), 'move');
|
|
break;
|
|
case INPUT_PTR_UP:
|
|
handlePointer(0, 0, 'up');
|
|
break;
|
|
case INPUT_KEY_DOWN:
|
|
if (payload.length >= 3) {
|
|
const keycode = view.getUint16(0, true);
|
|
// Arrow keys drive ball
|
|
if (keycode === 37) { ballX.value = ballX._signal._value - 50; targetX.value = ballX._target; }
|
|
if (keycode === 39) { ballX.value = ballX._signal._value + 50; targetX.value = ballX._target; }
|
|
if (keycode === 38) { ballY.value = ballY._signal._value - 50; targetY.value = ballY._target; }
|
|
if (keycode === 40) { ballY.value = ballY._signal._value + 50; targetY.value = ballY._target; }
|
|
if (keycode === 32) { ballR.value = 70; setTimeout(() => ballR.value = 25, 250); } // space = pulse
|
|
}
|
|
break;
|
|
case INPUT_SCROLL:
|
|
if (payload.length >= 4) {
|
|
const dy = view.getInt16(2, true);
|
|
ballR.value = Math.max(10, Math.min(80, ballR._signal._value + dy * 0.1));
|
|
}
|
|
break;
|
|
case INPUT_TOUCH:
|
|
if (payload.length >= 6) {
|
|
const tx = view.getUint16(1, true), ty = view.getUint16(3, true);
|
|
const phase = payload[5];
|
|
if (phase === 0) handlePointer(tx, ty, 'down');
|
|
else handlePointer(tx, ty, 'move');
|
|
}
|
|
break;
|
|
case INPUT_TOUCH_END:
|
|
handlePointer(0, 0, 'up');
|
|
break;
|
|
case INPUT_GAMEPAD_AXIS:
|
|
if (payload.length >= 3) {
|
|
const axis = payload[0], val = view.getInt16(1, true) / 32768;
|
|
if (axis === 0) { targetX.value = 300 + val * 250; ballX.value = targetX._value; }
|
|
if (axis === 1) { targetY.value = 200 + val * 150; ballY.value = targetY._value; }
|
|
}
|
|
break;
|
|
case INPUT_GAMEPAD_BUTTON:
|
|
if (payload.length >= 3 && payload[1] === 1) {
|
|
ballR.value = 70; setTimeout(() => ballR.value = 25, 250);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── Capture & Send ──
|
|
let streamingInterval = null;
|
|
function startStreaming() { if (!streamingInterval) streamingInterval = setInterval(captureAndSend, 1000 / 30); }
|
|
function stopStreaming() { if (streamingInterval) { clearInterval(streamingInterval); streamingInterval = null; } }
|
|
|
|
function captureAndSend() {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
const ts = Math.round(performance.now() - streamStart);
|
|
let msg;
|
|
|
|
switch (streamMode) {
|
|
case 'pixel':
|
|
msg = sendPixelFrame(ts, false);
|
|
break;
|
|
case 'delta':
|
|
msg = sendPixelFrame(ts, true);
|
|
break;
|
|
case 'signal':
|
|
msg = sendSignalFrame(ts);
|
|
break;
|
|
case 'neural':
|
|
msg = sendNeuralFrame(ts);
|
|
break;
|
|
}
|
|
|
|
if (msg) {
|
|
ws.send(msg.buffer);
|
|
seq++; framesSent++; bytesSent += msg.length; lastSecBytes += msg.length; lastSecFrames++;
|
|
visBars[visIdx % visBars.length].style.height = Math.min(25, Math.max(2, msg.length / 30000)) + 'px';
|
|
visIdx++;
|
|
}
|
|
|
|
// Feature 4: Send audio alongside
|
|
if (audioEnabled) {
|
|
const samples = synthesizeAudio();
|
|
if (samples) {
|
|
const audioMsg = encodeAudioFrame(samples, seq & 0xFFFF, ts);
|
|
ws.send(audioMsg.buffer);
|
|
bytesSent += audioMsg.length;
|
|
lastSecBytes += audioMsg.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Mode: Raw Pixels ──
|
|
function sendPixelFrame(ts, useDelta) {
|
|
const imageData = ctx.getImageData(0, 0, W, H);
|
|
const pixels = imageData.data;
|
|
|
|
if (useDelta && prevFrame) {
|
|
// Feature 1: XOR Delta Compression
|
|
const delta = new Uint8Array(pixels.length);
|
|
let zeroCount = 0;
|
|
for (let i = 0; i < pixels.length; i++) {
|
|
delta[i] = pixels[i] ^ prevFrame[i];
|
|
if (delta[i] === 0) zeroCount++;
|
|
}
|
|
lastDeltaRatio = (zeroCount / pixels.length * 100);
|
|
|
|
if (zeroCount > pixels.length * 0.3) {
|
|
// Delta is worthwhile — compress by RLE-encoding the zero runs
|
|
const compressed = rleCompress(delta);
|
|
prevFrame = new Uint8Array(pixels);
|
|
const header = encodeHeader(FRAME_DELTA, FLAG_COMPRESSED, seq & 0xFFFF, ts, W, H, compressed.length);
|
|
const msg = new Uint8Array(HEADER_SIZE + compressed.length);
|
|
msg.set(header, 0); msg.set(compressed, HEADER_SIZE);
|
|
return msg;
|
|
}
|
|
}
|
|
|
|
// Send full keyframe
|
|
prevFrame = new Uint8Array(pixels);
|
|
const header = encodeHeader(FRAME_PIXELS, FLAG_KEYFRAME, seq & 0xFFFF, ts, W, H, pixels.length);
|
|
const msg = new Uint8Array(HEADER_SIZE + pixels.length);
|
|
msg.set(header, 0); msg.set(pixels, HEADER_SIZE);
|
|
return msg;
|
|
}
|
|
|
|
// RLE compression matching Rust codec: 0x00 + 2-byte LE count for zero runs
|
|
function rleCompress(data) {
|
|
const out = [];
|
|
let i = 0;
|
|
while (i < data.length) {
|
|
if (data[i] === 0) {
|
|
let run = 0;
|
|
while (i < data.length && data[i] === 0) { run++; i++; }
|
|
// Emit run in chunks of max 65535
|
|
while (run > 0) {
|
|
const chunk = Math.min(run, 65535);
|
|
out.push(0, chunk & 0xFF, (chunk >> 8) & 0xFF);
|
|
run -= chunk;
|
|
}
|
|
} else {
|
|
out.push(data[i]); i++;
|
|
}
|
|
}
|
|
return new Uint8Array(out);
|
|
}
|
|
|
|
// ── Feature 3: Signal Diff Mode ──
|
|
function sendSignalFrame(ts) {
|
|
const state = {
|
|
bx: Math.round(ballX._signal._value * 10) / 10,
|
|
by: Math.round(ballY._signal._value * 10) / 10,
|
|
br: Math.round(ballR._signal._value * 10) / 10,
|
|
bvx: Math.round(ballX._velocity * 10) / 10,
|
|
bvy: Math.round(ballY._velocity * 10) / 10,
|
|
tx: Math.round(targetX._value * 10) / 10,
|
|
ty: Math.round(targetY._value * 10) / 10,
|
|
drag: dragging ? 1 : 0,
|
|
};
|
|
|
|
const json = JSON.stringify(state);
|
|
const payload = new TextEncoder().encode(json);
|
|
|
|
// Send full sync (with FLAG_KEYFRAME) or diff
|
|
const isSync = !prevSignalState || (framesSent % 150 === 0); // sync every ~5s at 30fps
|
|
const frameType = isSync ? FRAME_SIGNAL_SYNC : FRAME_SIGNAL_DIFF;
|
|
const flags = isSync ? FLAG_KEYFRAME : 0;
|
|
prevSignalState = state;
|
|
|
|
const header = encodeHeader(frameType, flags, seq & 0xFFFF, ts, W, H, payload.length);
|
|
const msg = new Uint8Array(HEADER_SIZE + payload.length);
|
|
msg.set(header, 0); msg.set(payload, HEADER_SIZE);
|
|
return msg;
|
|
}
|
|
|
|
// ── Feature 5: Neural Renderer Mode ──
|
|
function sendNeuralFrame(ts) {
|
|
const imgData = neuralRender();
|
|
const pixels = imgData.data;
|
|
const header = encodeHeader(FRAME_NEURAL, FLAG_KEYFRAME, seq & 0xFFFF, ts, W, H, pixels.length);
|
|
const msg = new Uint8Array(HEADER_SIZE + pixels.length);
|
|
msg.set(header, 0); msg.set(pixels, HEADER_SIZE);
|
|
return msg;
|
|
}
|
|
|
|
// ── Stats ──
|
|
setInterval(() => {
|
|
const now = performance.now(), elapsed = (now - lastSecTime) / 1000;
|
|
document.getElementById('statFps').textContent = Math.round(lastSecFrames / elapsed);
|
|
document.getElementById('statMode').textContent = streamMode;
|
|
const avgSize = lastSecFrames > 0 ? lastSecBytes / lastSecFrames : 0;
|
|
if (avgSize > 1024 * 1024) document.getElementById('statFrameSize').textContent = (avgSize / 1024 / 1024).toFixed(1) + 'MB';
|
|
else if (avgSize > 1024) document.getElementById('statFrameSize').textContent = (avgSize / 1024).toFixed(0) + 'KB';
|
|
else document.getElementById('statFrameSize').textContent = Math.round(avgSize) + 'B';
|
|
const bw = lastSecBytes / 1024 / 1024 / elapsed;
|
|
document.getElementById('statBandwidth').textContent = bw < 1 ? (bw * 1024).toFixed(0) + 'KB/s' : bw.toFixed(1) + 'MB/s';
|
|
document.getElementById('statDelta').textContent = streamMode === 'delta' ? lastDeltaRatio.toFixed(0) + '%' : '—';
|
|
document.getElementById('statFrames').textContent = framesSent;
|
|
document.getElementById('statInputs').textContent = inputsRecv;
|
|
document.getElementById('statAudio').textContent = audioEnabled ? 'on' : 'off';
|
|
document.getElementById('statReceivers').textContent = receiverCount;
|
|
lastSecBytes = 0; lastSecFrames = 0; lastSecTime = now;
|
|
}, 1000);
|
|
|
|
connect();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |