feat: add fullscreen WebGL compositor with Web Audio and WebSocket client

This commit is contained in:
Mortdecai
2026-04-10 01:25:48 -04:00
parent 68a1d143e8
commit 5d03c46dcc
+466
View File
@@ -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>