467 lines
14 KiB
HTML
467 lines
14 KiB
HTML
<!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>
|