feat: add fullscreen WebGL compositor with Web Audio and WebSocket client
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>AI Hell</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; cursor: none; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
#start-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: #000; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; z-index: 10;
|
||||
}
|
||||
#start-overlay span {
|
||||
color: #333; font-family: monospace; font-size: 14px;
|
||||
transition: opacity 2s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="start-overlay"><span>click to enter</span></div>
|
||||
<canvas id="c"></canvas>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// AI Hell — Frontend Compositor
|
||||
// ============================================================
|
||||
|
||||
const RECONNECT_DELAY = 2000;
|
||||
const PHASE_LERP_SPEED = 0.02;
|
||||
|
||||
// --- State ---
|
||||
let ws = null;
|
||||
let audioCtx = null;
|
||||
let gl = null;
|
||||
let program = null;
|
||||
let started = false;
|
||||
|
||||
// Current and target params (lerped)
|
||||
let currentParams = {
|
||||
morph_speed: 0, shader_severity: 0, noise_level: 0,
|
||||
voice_frequency: 0, surprise_chance: 0,
|
||||
};
|
||||
let targetParams = { ...currentParams };
|
||||
let currentIntensity = 0;
|
||||
|
||||
// Image state
|
||||
let currentTexture = null;
|
||||
let nextTexture = null;
|
||||
let blendProgress = 0;
|
||||
let blendTarget = 0;
|
||||
let transitionMode = 0; // 0=crossfade, 1=dissolve, 2=glitch, 3=melt
|
||||
let blendSpeed = 0.01;
|
||||
|
||||
// Flash state
|
||||
let flashIntensity = 0;
|
||||
let flashType = 0;
|
||||
let flashDecay = 0;
|
||||
|
||||
// Palette colors
|
||||
const PALETTES = {
|
||||
void_black: [0.1, 0.1, 0.1],
|
||||
crimson_void: [1.0, 0.15, 0.1],
|
||||
deep_rot: [0.5, 0.1, 0.05],
|
||||
sickly_green: [0.2, 0.6, 0.1],
|
||||
bruise_purple: [0.4, 0.1, 0.5],
|
||||
ash_grey: [0.4, 0.4, 0.4],
|
||||
bile_yellow: [0.6, 0.5, 0.1],
|
||||
blood_orange: [0.8, 0.3, 0.05],
|
||||
};
|
||||
let colorTint = [1.0, 1.0, 1.0];
|
||||
|
||||
// Cursor hide timer
|
||||
let cursorTimer = null;
|
||||
document.addEventListener('mousemove', () => {
|
||||
document.body.style.cursor = 'default';
|
||||
clearTimeout(cursorTimer);
|
||||
cursorTimer = setTimeout(() => { document.body.style.cursor = 'none'; }, 3000);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// WebGL Setup
|
||||
// ============================================================
|
||||
|
||||
const VERT_SRC = `
|
||||
attribute vec2 a_position;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_position * 0.5 + 0.5;
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
function initWebGL() {
|
||||
const canvas = document.getElementById('c');
|
||||
gl = canvas.getContext('webgl', { antialias: false, alpha: false });
|
||||
if (!gl) { console.error('WebGL not supported'); return; }
|
||||
|
||||
// Load fragment shader via fetch
|
||||
return fetch('/shaders/compositor.frag')
|
||||
.then(r => r.text())
|
||||
.then(fragSrc => {
|
||||
const vs = compileShader(gl.VERTEX_SHADER, VERT_SRC);
|
||||
const fs = compileShader(gl.FRAGMENT_SHADER, fragSrc);
|
||||
program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.error('Shader link error:', gl.getProgramInfoLog(program));
|
||||
return;
|
||||
}
|
||||
gl.useProgram(program);
|
||||
|
||||
// Fullscreen quad
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// Create placeholder textures (black 1x1)
|
||||
currentTexture = createTexture(null);
|
||||
nextTexture = createTexture(null);
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
});
|
||||
}
|
||||
|
||||
function compileShader(type, src) {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||
console.error('Shader error:', gl.getShaderInfoLog(s));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function createTexture(source) {
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
if (source) {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
||||
} else {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0,0,0,255]));
|
||||
}
|
||||
return tex;
|
||||
}
|
||||
|
||||
function loadImageTexture(url, callback) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => callback(img);
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function updateTexture(tex, source) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const canvas = gl.canvas;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Audio System
|
||||
// ============================================================
|
||||
|
||||
// Layer 1: Ambient drones (client-side loops)
|
||||
let droneGain = null;
|
||||
let droneFilter = null;
|
||||
let droneSource = null;
|
||||
|
||||
// Layer 2: Whisper pool (server-pushed clips)
|
||||
// Layer 3: Direct address (server-pushed, dry)
|
||||
|
||||
function initAudio() {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
||||
// Master gain
|
||||
const master = audioCtx.createGain();
|
||||
master.gain.value = 0.8;
|
||||
master.connect(audioCtx.destination);
|
||||
|
||||
// Drone chain: source -> filter -> gain -> master
|
||||
droneFilter = audioCtx.createBiquadFilter();
|
||||
droneFilter.type = 'lowpass';
|
||||
droneFilter.frequency.value = 400;
|
||||
droneFilter.connect(master);
|
||||
|
||||
droneGain = audioCtx.createGain();
|
||||
droneGain.gain.value = 0.3;
|
||||
droneGain.connect(droneFilter);
|
||||
|
||||
// Generate a dark drone using oscillators (no external files needed)
|
||||
startDrone();
|
||||
|
||||
// Store master for whisper/address routing
|
||||
window._audioMaster = master;
|
||||
}
|
||||
|
||||
function startDrone() {
|
||||
// Layered oscillators for a dark ambient drone
|
||||
const freqs = [55, 55.5, 82.5, 110.2]; // Slightly detuned for beating
|
||||
freqs.forEach(freq => {
|
||||
const osc = audioCtx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const oscGain = audioCtx.createGain();
|
||||
oscGain.gain.value = 0.08;
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(droneGain);
|
||||
osc.start();
|
||||
});
|
||||
|
||||
// Sub-bass rumble
|
||||
const sub = audioCtx.createOscillator();
|
||||
sub.type = 'triangle';
|
||||
sub.frequency.value = 30;
|
||||
const subGain = audioCtx.createGain();
|
||||
subGain.gain.value = 0.15;
|
||||
sub.connect(subGain);
|
||||
subGain.connect(droneGain);
|
||||
sub.start();
|
||||
|
||||
// LFO on drone filter frequency for slow movement
|
||||
const lfo = audioCtx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.05; // Very slow
|
||||
const lfoGain = audioCtx.createGain();
|
||||
lfoGain.gain.value = 200;
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(droneFilter.frequency);
|
||||
lfo.start();
|
||||
}
|
||||
|
||||
function updateDroneFromIntensity(intensity) {
|
||||
if (!droneFilter || !droneGain) return;
|
||||
// Open up filter and increase gain with intensity
|
||||
const targetFreq = 400 + Math.min(intensity, 5) * 300;
|
||||
const targetGain = 0.3 + Math.min(intensity, 5) * 0.1;
|
||||
droneFilter.frequency.linearRampToValueAtTime(targetFreq, audioCtx.currentTime + 2);
|
||||
droneGain.gain.linearRampToValueAtTime(targetGain, audioCtx.currentTime + 2);
|
||||
}
|
||||
|
||||
function playWhisper(url, pan, volume, reverb) {
|
||||
fetch(url)
|
||||
.then(r => r.arrayBuffer())
|
||||
.then(buf => audioCtx.decodeAudioData(buf))
|
||||
.then(audioBuffer => {
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = volume;
|
||||
|
||||
const panner = audioCtx.createStereoPanner();
|
||||
panner.pan.value = pan;
|
||||
|
||||
// Simple convolver substitute: delay for reverb-like effect
|
||||
const delay = audioCtx.createDelay();
|
||||
delay.delayTime.value = reverb * 0.1;
|
||||
const delayGain = audioCtx.createGain();
|
||||
delayGain.gain.value = reverb * 0.4;
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(panner);
|
||||
panner.connect(window._audioMaster);
|
||||
|
||||
// Reverb feedback path
|
||||
source.connect(delay);
|
||||
delay.connect(delayGain);
|
||||
delayGain.connect(panner);
|
||||
|
||||
source.start();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function playDirectAddress(base64Audio) {
|
||||
const binary = atob(base64Audio);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
|
||||
audioCtx.decodeAudioData(bytes.buffer)
|
||||
.then(audioBuffer => {
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
|
||||
// Direct address: dry, centered, slightly louder
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = 0.9;
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(window._audioMaster);
|
||||
source.start();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WebSocket
|
||||
// ============================================================
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(proto + '//' + location.host + '/stream');
|
||||
|
||||
ws.onopen = () => {
|
||||
// Keepalive
|
||||
setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}');
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'phase':
|
||||
currentIntensity = msg.intensity;
|
||||
Object.assign(targetParams, msg.params);
|
||||
if (msg.params.palette && PALETTES[msg.params.palette]) {
|
||||
colorTint = PALETTES[msg.params.palette];
|
||||
}
|
||||
updateDroneFromIntensity(msg.intensity);
|
||||
break;
|
||||
|
||||
case 'asset':
|
||||
// Load new image and start transition
|
||||
loadImageTexture(msg.url, (img) => {
|
||||
// Swap: current becomes old, next becomes new
|
||||
const tmp = currentTexture;
|
||||
currentTexture = nextTexture;
|
||||
nextTexture = tmp;
|
||||
updateTexture(nextTexture, img);
|
||||
blendProgress = 0;
|
||||
blendTarget = 1;
|
||||
transitionMode = ['crossfade','dissolve','glitch_cut','melt_morph'].indexOf(msg.transition);
|
||||
if (transitionMode < 0) transitionMode = 0;
|
||||
// Blend speed scales with morph_speed
|
||||
blendSpeed = 0.005 + currentParams.morph_speed * 0.03;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'whisper':
|
||||
playWhisper(msg.url, msg.pan, msg.volume, msg.reverb);
|
||||
break;
|
||||
|
||||
case 'address':
|
||||
playDirectAddress(msg.audio);
|
||||
break;
|
||||
|
||||
case 'scare':
|
||||
triggerScare(msg.effect, msg.duration_ms);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => setTimeout(connect, RECONNECT_DELAY);
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
function triggerScare(effect, durationMs) {
|
||||
flashIntensity = 1.0;
|
||||
flashDecay = 1.0 / (durationMs / 16.67); // frames to decay
|
||||
switch (effect) {
|
||||
case 'face_flash': flashType = 2; break;
|
||||
case 'white_out': flashType = 0; break;
|
||||
case 'inversion': flashType = 1; break;
|
||||
case 'glitch_burst': flashType = 0; flashDecay *= 0.3; break; // Slower decay
|
||||
default: flashType = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render Loop
|
||||
// ============================================================
|
||||
|
||||
let frameTime = 0;
|
||||
|
||||
function render(timestamp) {
|
||||
requestAnimationFrame(render);
|
||||
if (!gl || !program) return;
|
||||
|
||||
frameTime = timestamp * 0.001;
|
||||
|
||||
// Lerp params
|
||||
for (const key in currentParams) {
|
||||
if (targetParams[key] !== undefined) {
|
||||
currentParams[key] += (targetParams[key] - currentParams[key]) * PHASE_LERP_SPEED;
|
||||
}
|
||||
}
|
||||
|
||||
// Blend transition
|
||||
if (blendProgress < blendTarget) {
|
||||
blendProgress = Math.min(blendTarget, blendProgress + blendSpeed);
|
||||
}
|
||||
|
||||
// Flash decay
|
||||
if (flashIntensity > 0) {
|
||||
flashIntensity = Math.max(0, flashIntensity - flashDecay);
|
||||
}
|
||||
|
||||
// Bind textures
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, currentTexture);
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_currentImage'), 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, nextTexture);
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_nextImage'), 1);
|
||||
|
||||
// Set uniforms
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_blend'), blendProgress);
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_transitionMode'), transitionMode);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_morphSpeed'), currentParams.morph_speed);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_shaderSeverity'), currentParams.shader_severity);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_noiseLevel'), currentParams.noise_level);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_time'), frameTime);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_flashIntensity'), flashIntensity);
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_flashType'), flashType);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_vignetteStrength'), 0.5 + currentParams.shader_severity * 0.5);
|
||||
gl.uniform3fv(gl.getUniformLocation(program, 'u_colorTint'), colorTint);
|
||||
|
||||
// Draw
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Start
|
||||
// ============================================================
|
||||
|
||||
document.getElementById('start-overlay').addEventListener('click', () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
document.getElementById('start-overlay').style.display = 'none';
|
||||
|
||||
// Request fullscreen
|
||||
const el = document.documentElement;
|
||||
if (el.requestFullscreen) el.requestFullscreen();
|
||||
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
|
||||
|
||||
initAudio();
|
||||
initWebGL().then(() => {
|
||||
connect();
|
||||
requestAnimationFrame(render);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user